{"owner":"cloudflare","repo":"vibesdk","hasSkills":true,"totalSkillsCount":10,"totalTokensCount":51473,"categories":["root-instruction","claude-rule","plugin-manifest","anthropic-skill"],"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md","CLAUDE.md","packages/artifacts-viewer/.changeset/README.md","packages/artifacts-viewer/README.md","packages/artifacts-viewer/packages/artifacts-viewer/README.md","worker/agents/think/skills/app-ai-binding/SKILL.md","worker/agents/think/skills/app-file-structure/SKILL.md","worker/agents/think/skills/frontend-design-landing-page/SKILL.md","worker/agents/think/skills/frontend-design-saas/SKILL.md","worker/agents/think/skills/frontend-design/SKILL.md"],"skills":{"AGENTS.md":"# AGENTS.md\n\n## Tooling\n- Use Bun from the repository root. The tracked lockfile is `bun.lock`, the `space` workspace dependency uses `workspace:*`, and install/build hooks invoke Bun even when started through npm.\n- `bun run setup` is the interactive Cloudflare/resource bootstrap. Local development expects the generated `.dev.vars`; never commit `.dev.vars*` or `.prod.vars`.\n- `bun run dev` starts the React frontend and Worker together through `@cloudflare/vite-plugin` at `http://localhost:5173`. There is no separate Worker dev command.\n- `bun run dev:browser` is an optional local Chromium sidecar for the think agent's browser-console tool; absence only produces a warning.\n\n## Verification\n- Root checks: `bun run typecheck`, `bun run lint`, `bun run test`, `bun run build`.\n- `bun run build` builds `space` and the Vite/Worker bundle; it does not typecheck. Run `bun run typecheck` separately.\n- Focus a root test with `bunx vitest run path/to/file.test.ts`; test execution uses the Workers pool and `wrangler.test.jsonc`.\n- The root Vitest suite excludes all `sdk/test/**` and `container/monitor-cli.test.ts`. SDK tests use Bun: `bun run --cwd sdk test`.\n- SDK integration tests require a running root dev server and `VIBESDK_INTEGRATION_API_KEY`; run `bun run --cwd sdk test:integration`. They can take 5-10 minutes; `VIBESDK_INTEGRATION_RUN_PREVIEW=1` enables the slower preview case.\n- Root typecheck/lint do not validate `space` or `sdk`. For touched packages run `bun run --cwd space typecheck` / `bun run --cwd space build` and `bun run --cwd sdk package` as appropriate.\n- ESLint checks only `src/**` and `worker/**` and deliberately ignores tests; do not treat `bun run lint` as repository-wide validation.\n- Pre-commit typechecks staged TypeScript and runs related Vitest tests. `RUN_ALL_TESTS=1` selects its broader suite; `SKIP_TESTS=1` bypasses the hook.\n\n## Frontend UI\n- Tailwind CSS v4 via CSS-first setup in `src/index.css` (`@import 'tailwindcss'`, `@theme`, Kumo tokens); no `tailwind.config.*`.\n- Prefer `@cloudflare/kumo` for new UI. List components with `bun kumo ls`; component docs via `bun kumo doc Button` (swap name as needed). Legacy shadcn/Radix under `src/components/ui/` still exists—do not add new primitives there when Kumo covers the case.\n- Icons: `@phosphor-icons/react`. Dark mode is `data-mode=\"dark\"` on the root (not a `class` strategy).\n- Path aliases: `@/*` → `src/*`, `shared/*`, `worker/*` (see `tsconfig.app.json`).\n\n## Frontend Data Fetching\n- Use TanStack Query for frontend server state and network-call caching. `QueryClientProvider` is wired at the React root; configure shared defaults in `src/lib/query-client.ts`.\n- Keep TanStack query keys centralized in `src/lib/query-keys.ts`. Use hierarchical keys so broad invalidation works, for example `queryKeys.apps.all` should invalidate app list/favorite variants.\n- Frontend HTTP still goes through `src/lib/api-client.ts`; query functions should wrap existing `apiClient` methods rather than calling `fetch` directly from components.\n- Include user/account identity in query keys when cached data is user-specific, or explicitly clear/remove those queries on logout/user switch. `enabled: !!user` prevents fetching but does not clear old cached data.\n- Mutations that change cached server state must update cache with `queryClient.setQueryData` or invalidate the relevant `queryKeys` on success. Do not rely on a local `refetch()` in one component if sidebar or other shared UI consumes the same data.\n- Prefer query hooks (`useQuery`, `useMutation`) over ad-hoc loading/error state in React contexts. Context remains appropriate for client-only UI state or providers required by libraries.\n\n## Boundaries\n- `src/` is the React app (`src/main.tsx`, routes in `src/routes.tsx`). API contracts live in `src/api-types.ts`; frontend HTTP calls belong in `src/lib/api-client.ts`.\n- `worker/index.ts` is the Worker entrypoint and Durable Object export surface. Hono middleware/routes are wired by `worker/app.ts` and `worker/api/routes/index.ts`.\n- `space/` is the only declared workspace package. It provides the `SpaceDO` workspace and file layer used by the think agent, with durable git history stored through Cloudflare Artifacts, and is bundled before the root app; edit implementation in `space/src`, never generated `space/dist`, and keep the hand-maintained `space/types/index.d.ts` aligned with public exports.\n- `sdk/` is an independent Bun package with its own lockfile, scripts, and tests. It imports the platform WebSocket protocol from `worker/api/websocketTypes.ts`, so protocol changes must remain SDK-compatible.\n- Shared frontend/backend types belong in `shared/`; Worker-only types stay under `worker/`.\n- Architecture overview (ThinkAgent, SpaceDO, Artifacts, Dynamic Worker previews): `docs/llm.md`. Production deploy: `bun run deploy` (needs `.prod.vars`).\n\n## Change Paths\n- API endpoint: update `src/api-types.ts` -> `src/lib/api-client.ts` -> `worker/database/services/` (when persistence is needed) -> `worker/api/controllers/` -> `worker/api/routes/`, then register the route in `worker/api/routes/index.ts`.\n- WebSocket message: update `worker/api/websocketTypes.ts`, backend handling in `worker/agents/core/websocket.ts`, and frontend handling in `src/routes/chat/utils/handle-websocket-message.ts`; verify SDK tests because its protocol re-exports these types.\n- LLM tool: add it under `worker/agents/tools/toolkit/` and register it in `worker/agents/tools/customTools.ts` (`buildTools` or `buildDebugTools`). The think behavior has a separate tool path and bypasses `buildTools`.\n- Think tool: create it under `worker/agents/think/`, add SpaceDO RPC typing if needed, register it in `ThinkAgent.getTools()`, and update the relevant prompt or skill.\n- D1 schema source is `worker/database/schema.ts`; generate migrations into `migrations/` with `bun run db:generate`, then apply locally with `bun run db:migrate:local`.\n- After changing Wrangler bindings, run `bun run cf-typegen`; `worker-configuration.d.ts` is consumed by setup and TypeScript configs.\n\n## Constraints\n- Do not introduce new `any` types even though ESLint currently permits existing ones; find or define a concrete type. Frontend API types should import from `@/api-types`.\n- Worker code reads bindings from `env`; do not use Vite environment variables there.\n- All `/api/*` routes are owner-only by default in `worker/app.ts`; public routes must explicitly follow the existing auth override pattern.\n- User secrets RPC methods return `null`/`boolean` on failure rather than throwing; preserve that contract when editing `worker/services/secrets/`.\n- For usage-limit UI behavior and its cross-component invariants, read `docs/usage-limits-ui.md` before editing the badge, credits banner, or limit popups.\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code when working with code in this repository.\n\n## Communication Style\n- Be professional, concise, and direct\n- Do NOT use emojis in code reviews, changelogs, or any generated content. You may use professional visual indicators or favor markdown formatting over emojis.\n- Focus on substance over style\n- Use clear technical language\n\n## Project Overview\n\nVibeSDK is an agentic full-stack application builder on Cloudflare.\n\n**Tech Stack:**\n- Frontend: React 19, TypeScript, Vite, TailwindCSS, React Router v7\n- Backend: Cloudflare Workers, Durable Objects, Hono, D1, R2, and KV\n- Agent: Cloudflare Think with AI Gateway model routing\n- Workspace: SpaceDO Durable Objects\n- Version history: Cloudflare Artifacts\n- Preview runtime: Worker Loader bindings and Dynamic Workers\n- Generated app data: Durable Object Facets with isolated SQLite\n- WebSocket: PartySocket for real-time communication\n\n**Project Structure:**\n- `/src` - React frontend, API types, and API client\n- `/worker/agents/think` - ThinkAgent, prompts, skills, workspace adapter, and tools\n- `/worker/agents/core/behaviors/think.ts` - Think host orchestration\n- `/worker/api` - Routes, controllers, handlers, and WebSocket types\n- `/worker/database` - D1 schema and services\n- `/space` - SpaceDO, Artifacts synchronization, preview bundling, and App Facets\n- `/sdk` - TypeScript client SDK\n- `/migrations` - D1 migrations\n- `/scripts` - Setup and deployment utilities\n\n## Key Architectural Patterns\n\n**ThinkAgent:**\n- One Agent backed by a Durable Object per app session\n- Owns conversation, context selection, skills, streaming, tools, and step limits\n- Uses explicit SpaceDO-backed tools; workspace bash is disabled\n\n**Workspace and Versioning:**\n- SpaceDO owns the isolated live workspace and files\n- Cloudflare Artifacts owns durable commits, branches, history, and restore points\n- `commit` saves without deploying; `deploy_space` commits and rebuilds the preview\n- Rollback applies a selected tree, creates a new commit, and redeploys\n\n**Preview Runtime:**\n- `@cloudflare/worker-bundler` builds committed project files\n- Worker Loader loads bundled modules as a Dynamic Worker\n- Generated `App` classes run as Durable Object Facets with isolated SQLite\n\n**WebSocket Communication:**\n- PartySocket carries realtime agent output, tools, files, and deployment state\n- Session state is restored on reconnect\n\n## Common Development Tasks\n\n**Change LLM Model for Operation:**\nEdit `/worker/agents/inferutils/config.ts` → `AGENT_CONFIG` object\n\n**Modify Think Agent Behavior:**\nEdit `worker/agents/think/ThinkAgent.ts`, the host behavior in `worker/agents/core/behaviors/think.ts`, and the relevant prompt or skill.\n\n**Add New WebSocket Message:**\n1. Add type to `worker/api/websocketTypes.ts`\n2. Handle in `worker/agents/core/websocket.ts`\n3. Handle in `src/routes/chat/utils/handle-websocket-message.ts`\n\n**Add New Think Tool:**\n1. Create the tool under `worker/agents/think/`\n2. Add required SpaceDO RPC typing to `space-workspace-ops.ts`\n3. Register it in `ThinkAgent.getTools()`\n4. Update the relevant prompt or skill\n5. Add focused tests\n\n**Add API Endpoint:**\n1. Define types in `src/api-types.ts`\n2. Add to `src/lib/api-client.ts`\n3. Create service in `worker/database/services/`\n4. Create controller in `worker/api/controllers/`\n5. Add route in `worker/api/routes/`\n6. Register in `worker/api/routes/index.ts`\n\n## Important Context\n\n**User Secrets Store (Durable Object):**\n- Location: `/worker/services/secrets/`\n- Purpose: Encrypted storage for user API keys with key rotation\n- Architecture: One DO per user, XChaCha20-Poly1305 encryption, SQLite backend\n- Key derivation: MEK → UMK → DEK (hierarchical PBKDF2)\n- Features: Key rotation, soft deletion, access tracking, expiration support\n- RPC Methods: Return `null`/`boolean` on error, never throw exceptions\n- Testing: 90 comprehensive tests in `/test/worker/services/secrets/`\n\n**Workspace and Git:**\n- SpaceDO provides workspace and file operations\n- Cloudflare Artifacts stores durable git history\n- Artifacts synchronization lives in `space/src/space/artifacts-sync.ts`\n- Rollback preserves history by creating a new commit\n\n**Abort Controller Pattern:**\n- `getOrCreateAbortController()` reuses controller for nested operations\n- Cleared after top-level operations complete\n- Shared by parent and nested tool calls\n- User abort cancels entire operation tree\n\n**Message Deduplication:**\n- Tool execution causes duplicate AI messages\n- Backend skips redundant LLM calls (empty tool results)\n- Frontend utilities deduplicate live and restored messages\n- System prompt teaches LLM not to repeat\n\n## Core Rules (Non-Negotiable)\n\n**1. Strict Type Safety**\n- NEVER use `any` type\n- Frontend imports types from `@/api-types` (single source of truth)\n- Search codebase for existing types before creating new ones\n\n**2. DRY Principle**\n- Search for similar functionality before implementing\n- Extract reusable utilities, hooks, and components\n- Never copy-paste code - refactor into shared functions\n\n**3. Follow Existing Patterns**\n- Frontend APIs: All in `/src/lib/api-client.ts`\n- Backend Routes: Controllers in `worker/api/controllers/`, routes in `worker/api/routes/`\n- Database Services: In `worker/database/services/`\n- Types: Shared in `shared/types/`, API in `src/api-types.ts`\n\n**4. Code Quality**\n- Production-ready code only - no TODOs or placeholders\n- No hacky workarounds\n- Comments explain purpose, not narration\n- No overly verbose AI-like comments\n\n**5. File Naming**\n- React Components: PascalCase.tsx\n- Utilities/Hooks: kebab-case.ts\n- Backend Services: PascalCase.ts\n\n## Common Pitfalls\n\n**Don't:**\n- Use `any` type (find or create proper types)\n- Copy-paste code (extract to utilities)\n- Use Vite env variables in Worker code\n- Forget to update types when changing APIs\n- Create new implementations without searching for existing ones\n- Use emojis in code or comments\n- Write verbose AI-like comments\n\n**Do:**\n- Search codebase thoroughly before creating new code\n- Follow existing patterns consistently\n- Keep comments concise and purposeful\n- Write production-ready code\n- Test thoroughly before submitting","packages/artifacts-viewer/.changeset/README.md":"# Change intents\n\nRun `pnpm change` for every change that affects the published package. Commit the generated Markdown file with the code change.\n\nDocumentation, tests, and changes limited to private workspace applications do not require an intent.\n","packages/artifacts-viewer/README.md":"# artifacts-viewer\n\nMonorepo for [`artifacts-viewer`](./packages/artifacts-viewer), a read-only React\nrepository viewer for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/).\n\nFor package documentation — installation, API reference, styling, theming — see\n[`packages/artifacts-viewer/README.md`](./packages/artifacts-viewer/README.md).\nFor the release procedure, see [`PUBLISH.md`](./PUBLISH.md).\n\n## Layout\n\n```text\napps/example                 Cloudflare Workers + React app that consumes the package\npackages/artifacts-viewer    The published npm package\n```\n\nThe package ships four JavaScript entry points, one CSS export, and package metadata, each with a deliberate boundary:\n\n| Entry point                     | Contains                                                | Must not import       |\n| ------------------------------- | ------------------------------------------------------- | --------------------- |\n| `artifacts-viewer`              | `routeArtifactRequest`, the server-side read-only proxy | React                 |\n| `artifacts-viewer/server/cache` | Workers Cache API and KV adapters                       | React                 |\n| `artifacts-viewer/client`       | `createArtifactsClient`, framework-independent          | React, Worker globals |\n| `artifacts-viewer/react`        | Hooks and components                                    | The server router     |\n| `artifacts-viewer/styles.css`   | Structural CSS only                                     | —                     |\n\nPlatform-neutral code shared by both sides (wire types, path helpers, blob\nclassification) lives in `packages/artifacts-viewer/src/shared/`. The entry\npoints are designed to preserve those boundaries: the server and client builds\ndo not import React, and the React build does not import the server router.\n\n## Toolchain\n\nThis workspace uses [Vite+](https://viteplus.dev), which wraps runtime\nmanagement, package management, and frontend tooling behind a single `vp` CLI.\nRun `vp help` for the command list. Docs are vendored at\n`node_modules/vite-plus/docs`.\n\nDependency versions are centralised in the `catalog:` block of\n`pnpm-workspace.yaml`. Add a version there and reference it as `\"catalog:\"` from\na package rather than pinning per package.\n\n## Development\n\n```bash\nvp install          # install; run after pulling\nvp run dev          # start apps/example with source-level library HMR\nvp run ready        # vp check && vp run -r build — the CI gate\nvp run -r build     # build every workspace project\n```\n\nThere is no test suite. `vp check` covers formatting, linting, and type checking\nacross all projects.\n\n### Running the example app\n\nThe example needs credentials for a real Artifacts namespace. The API token\nlives only in the Worker; the browser never sees it.\n\n```bash\ncd apps/example\ncp .dev.vars.example .dev.vars   # then fill in the three values\n```\n\n`wrangler types` reads `.dev.vars` to generate `Env`, so rerun\n`vp run cf-typegen` from `apps/example` after adding or renaming a variable.\n\n`apps/example/wrangler.jsonc` sets `assets.run_worker_first: [\"/api/*\"]`.\nWithout it, `not_found_handling: \"single-page-application\"` would serve\n`index.html` for API routes and the Worker would never run.\n\n## Releases\n\nVersions and changelogs are generated by pnpm's native release tooling; never\nhand-edit `version` or `CHANGELOG.md`.\n\n```bash\npnpm change         # record a change intent, committed with the code\npnpm version -r     # consume intents: bump, write changelogs, update the ledger\n```\n\nPublishing runs on GitHub Actions via npm OIDC trusted publishing, triggered by\na published GitHub Release whose tag matches `v<version>`. No npm token exists\nanywhere in the repository. [`PUBLISH.md`](./PUBLISH.md) has the full procedure\nand the failure modes worth knowing about.\n\n## License\n\nMIT\n","packages/artifacts-viewer/packages/artifacts-viewer/README.md":"# artifacts-viewer\n\nA read-only repository browser for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/). Ships a server-side proxy, a typed HTTP client, and unstyled React components.\n\nYour Cloudflare API token stays on the server. The browser only ever talks to your own origin.\n\n> Pre-1.0. The API is still moving; pin an exact version.\n\n## Contents\n\n- [artifacts-viewer](#artifacts-viewer)\n  - [Contents](#contents)\n  - [How it works](#how-it-works)\n    - [Request flow](#request-flow)\n  - [Install](#install)\n  - [Quick start](#quick-start)\n    - [1. Mount the router](#1-mount-the-router)\n    - [2. Render the viewer](#2-render-the-viewer)\n  - [`artifacts-viewer` — server router](#artifacts-viewer--server-router)\n    - [Authorization](#authorization)\n    - [Response behaviour](#response-behaviour)\n    - [CORS](#cors)\n  - [`artifacts-viewer/server/cache` — cache adapters](#artifacts-viewerservercache--cache-adapters)\n  - [`artifacts-viewer/client` — typed client](#artifacts-viewerclient--typed-client)\n  - [`artifacts-viewer/react` — components and hooks](#artifacts-viewerreact--components-and-hooks)\n    - [Components](#components)\n      - [`renderStatus` — loading, empty, and error states](#renderstatus--loading-empty-and-error-states)\n    - [Hooks](#hooks)\n    - [Building your own UI](#building-your-own-ui)\n  - [Styling](#styling)\n    - [Custom properties](#custom-properties)\n    - [Selector hooks](#selector-hooks)\n  - [Syntax highlighting](#syntax-highlighting)\n    - [Loading](#loading)\n      - [`renderCodeFallback` — what the wait looks like](#rendercodefallback--what-the-wait-looks-like)\n      - [`preloadCodeView` — how long the wait is](#preloadcodeview--how-long-the-wait-is)\n    - [Theming](#theming)\n  - [Limits and behaviour](#limits-and-behaviour)\n  - [Not included](#not-included)\n  - [License](#license)\n\n## How it works\n\n```\nbrowser                    your server                     Cloudflare\n┌──────────────────┐      ┌──────────────────────┐      ┌──────────────┐\n│ArtifactRepoViewer│      │ routeArtifactRequest │      │ Artifacts    │\n│        ↓         │─────▶│  · validates         │─────▶│ REST API     │\n│ ArtifactsClient  │      │  · adds API token    │      │              │\n└──────────────────┘      │  · optional cache    │      └──────────────┘\n     GET /artifacts/…     └──────────────────────┘        Bearer token\n```\n\nThe router mirrors Cloudflare's namespace-relative paths 1:1 and proxies **only** these seven reads. `accountId` and `namespace` are deliberately absent from the browser-facing URL.\n\n| Mounted route                                        | Purpose                                     |\n| ---------------------------------------------------- | ------------------------------------------- |\n| `GET {apiPath}/repos/{repo}`                         | Repository metadata                         |\n| `GET {apiPath}/repos/{repo}/log?ref=&limit=&offset=` | Commit log                                  |\n| `GET {apiPath}/repos/{repo}/commit/{hash}`           | One commit                                  |\n| `GET {apiPath}/repos/{repo}/tree/{hash}`             | One directory level                         |\n| `GET {apiPath}/repos/{repo}/blob/{hash}`             | File bytes by hash                          |\n| `GET {apiPath}/repos/{repo}/file?ref=&path=`         | File bytes by path                          |\n| `GET {apiPath}/repos/{repo}/raw/{ref}/{path}`        | File bytes with a browser-safe content type |\n\nNothing else is reachable. Writes, repository creation, and token endpoints are not proxied at all.\n\n### Request flow\n\nRendering a repository takes two round trips, then goes content-addressed:\n\n```\ngetLog({ limit: 1 })       →  commits[0].treeHash    (ref omitted = default branch)\nreadTree(commit.treeHash)  →  root entries\n```\n\nAfter that every navigation is addressed by git hash — `readTree(entry.hash)` for a directory, `readBlob(entry.hash)` for a file — so responses are immutable and cacheable forever.\n\n## Install\n\n```bash\nnpm install artifacts-viewer\n# or\npnpm install artifacts-viewer\n# or\nbun install artifacts-viewer\n```\n\n`react` and `react-dom` are peer dependencies (`^18.2.0 || ^19.0.0`). `@pierre/diffs` is a direct dependency. It is split from the initial bundle and proactively prefetched when `ArtifactRepoViewer` mounts.\n\n## Quick start\n\n### 1. Mount the router\n\n> **The router allows every request by default.** Add a `beforeRequest` policy before exposing this endpoint; otherwise anyone who can reach it can read any valid repository name in the configured namespace.\n\n```ts\n// worker/index.ts\nimport { routeArtifactRequest } from \"artifacts-viewer\";\nimport { createCacheApiAdapter } from \"artifacts-viewer/server/cache\";\n\nexport default {\n  async fetch(request, env, ctx) {\n    const handled = await routeArtifactRequest(request, {\n      accountId: env.ARTIFACTS_ACCOUNT_ID,\n      namespace: env.ARTIFACTS_NAMESPACE,\n      apiToken: env.ARTIFACTS_API_TOKEN,\n      beforeRequest: async ({ request, read }) => {\n        const user = await getSessionUser(request);\n        if (user === null || !user.repositories.includes(read.repoName)) {\n          return new Response(\"Forbidden\", { status: 403 });\n        }\n      },\n      cache: createCacheApiAdapter({\n        cache: caches.default,\n        baseUrl: new URL(request.url).origin,\n      }),\n      waitUntil: (promise) => {\n        ctx.waitUntil(promise);\n      },\n    });\n\n    return handled ?? new Response(\"Not found\", { status: 404 });\n  },\n} satisfies ExportedHandler<Env>;\n```\n\n`routeArtifactRequest` returns `null` when the request is outside `apiPath`, so you can fall through to your own routing.\n\nIf you serve static assets from the same Worker, route the API to the Worker first or the asset handler will answer instead:\n\n```jsonc\n// wrangler.jsonc\n{\n  \"assets\": {\n    \"not_found_handling\": \"single-page-application\",\n    \"run_worker_first\": [\"/artifacts/*\"],\n  },\n}\n```\n\n### 2. Render the viewer\n\n```tsx\nimport { createArtifactsClient } from \"artifacts-viewer/client\";\nimport { ArtifactRepoViewer } from \"artifacts-viewer/react\";\nimport \"artifacts-viewer/styles.css\";\n\n// Module scope: the hooks treat `client` as a dependency, so a new instance\n// per render would refetch forever.\nconst client = createArtifactsClient();\n\nexport function Repository() {\n  return <ArtifactRepoViewer client={client} repoName=\"my-repository\" />;\n}\n```\n\n## `artifacts-viewer` — server router\n\nReact-free and platform-neutral. Uses only `Request`, `Response`, `Headers`, `URL`, and `fetch`, so it runs on Workers, Node, Deno, or Bun.\n\n```ts\nfunction routeArtifactRequest(\n  request: Request,\n  options: ArtifactRouterOptions,\n): Promise<Response | null>;\n```\n\n| Option          | Type                            | Notes                                                     |\n| --------------- | ------------------------------- | --------------------------------------------------------- |\n| `accountId`     | `string`                        | Required                                                  |\n| `namespace`     | `string`                        | Required                                                  |\n| `apiToken`      | `string`                        | Required. Cloudflare API token with Artifacts read access |\n| `apiPath`       | `string`                        | Default `\"/artifacts\"`. Matched on a segment boundary     |\n| `fetch`         | `typeof fetch`                  | Optional injection point for tests and tracing            |\n| `beforeRequest` | `ArtifactBeforeRequestHook`     | Authorization hook. Return a `Response` to deny           |\n| `cache`         | `ArtifactsCacheAdapter`         | Consulted for content-addressed reads only                |\n| `waitUntil`     | `(p: Promise<unknown>) => void` | Keeps cache writes alive past the response                |\n| `onCacheError`  | `(error: unknown) => void`      | Cache failures are reported here, never thrown            |\n\n### Authorization\n\n`beforeRequest` runs **before** the cache, so a cache hit can never bypass it.\n\n```ts\nbeforeRequest: async ({ request, read, operation }) => {\n  if (!(await mayRead(request, read.repoName))) {\n    return new Response(\"Forbidden\", { status: 403 });\n  }\n},\n```\n\n`read` is a fully validated discriminated union, so `read.repoName`, `read.hash`, `read.ref`, and `read.path` are safe to use in a policy decision.\n\n### Response behaviour\n\n- Outside `apiPath` → `null`.\n- Inside `apiPath` but not a known route → `404`.\n- Known route, wrong method → `405` with an `Allow` header.\n- Malformed argument → `400`. Nothing is sent upstream.\n- Errors use the Cloudflare v4 envelope, so the client has one parser.\n\nOutbound requests are built from scratch with only `Accept` and `Authorization`. A caller's `Authorization`, `Cookie`, and every other inbound header are dropped. Response headers are copied through an allowlist (`Content-Type`, `Content-Length`, `Content-Disposition`, `ETag`, `Last-Modified`); bodies stream and are never buffered.\n\n### CORS\n\nNot handled. You own the response, so add headers after the call. Note that `OPTIONS` returns `405`, so intercept preflight before delegating:\n\n```ts\nif (request.method === \"OPTIONS\") return myPreflight(request);\n```\n\n## `artifacts-viewer/server/cache` — cache adapters\n\nOnly content-addressed reads (`commit`, `tree`, `blob`) are ever cached. Their values cannot become semantically stale, but the bundled Cache API and KV adapters use one-year freshness or expiration settings and either backend may evict an entry earlier. Ref-addressed reads are never cached.\n\n```ts\ntype ArtifactsCacheAdapter = {\n  get(key: string): Promise<Response | undefined>;\n  set(key: string, response: Response): Promise<void>;\n};\n```\n\n```ts\nimport { createCacheApiAdapter, createKvCacheAdapter } from \"artifacts-viewer/server/cache\";\n\ncreateCacheApiAdapter({ cache: caches.default, baseUrl: new URL(request.url).origin });\ncreateKvCacheAdapter({ kv: env.ARTIFACTS_CACHE });\n```\n\n`createCacheApiAdapter` is the recommended backend: it streams, so large blobs never sit in memory. `baseUrl` must be an origin your Worker actually serves — the Cache API is keyed by URL and scoped to the zone.\n\nAdapters may throw. A failing cache degrades to a slower request, never a failed one.\n\n## `artifacts-viewer/client` — typed client\n\nFramework-independent and free of Worker globals.\n\n```ts\nconst client = createArtifactsClient({\n  apiPath: \"/artifacts\", // must match the router\n  fetch: myFetch, // optional\n});\n```\n\nEvery asynchronous request method returns a result union rather than throwing. The synchronous `getRawUrl` helper returns a string:\n\n```ts\ntype ArtifactsResult<T> = { ok: true; value: T } | { ok: false; error: ArtifactsClientError };\n\ntype ArtifactsClientError =\n  | { kind: \"network\"; message: string; cause: unknown }\n  | { kind: \"not-found\"; message: string }\n  | { kind: \"http\"; message: string; status: number }\n  | { kind: \"malformed\"; message: string };\n```\n\n| Method                                                 | Returns                     |\n| ------------------------------------------------------ | --------------------------- |\n| `getRepository({ repoName, signal? })`                 | `ArtifactsRepository`       |\n| `getLog({ repoName, ref?, limit?, offset?, signal? })` | `ArtifactsCommitMetadata[]` |\n| `readCommit({ repoName, hash, signal? })`              | `ArtifactsCommitMetadata`   |\n| `readTree({ repoName, hash, signal? })`                | `ArtifactsTreeEntry[]`      |\n| `readBlob({ repoName, hash, signal? })`                | `Response`                  |\n| `readFile({ repoName, ref, path, signal? })`           | `Response`                  |\n| `getRawUrl({ repoName, ref, path })`                   | `string` (synchronous)      |\n\nBinary reads hand back the `Response` untouched, so you can stream it, size-check it, or discard it. Nothing is buffered or base64-encoded.\n\nJSON payloads are narrowed field by field at the boundary — there are no type assertions — and repository metadata is normalized from the wire's snake_case to camelCase.\n\n```ts\ntype ArtifactsTreeEntry = {\n  name: string;\n  mode: string;\n  hash: string;\n  type: \"tree\" | \"blob\" | \"symlink\" | \"gitlink\" | \"exec\";\n};\n```\n\n`exec` and `symlink` behave like blobs. `gitlink` is a submodule pointer with no content in this repository, and renders as an inert row.\n\n## `artifacts-viewer/react` — components and hooks\n\n### Components\n\n| Component               | Purpose                                                  |\n| ----------------------- | -------------------------------------------------------- |\n| `ArtifactRepoViewer`    | Two-pane browser: sidebar tree plus content pane         |\n| `ArtifactFileTree`      | Lazily expanding sidebar tree                            |\n| `ArtifactDirectoryView` | Flat listing of one directory                            |\n| `ArtifactFileView`      | One file, with Raw/Download and content rendering        |\n| `CodeView`              | Syntax-highlighted text, given contents you already have |\n\nIn `ArtifactRepoViewer` the content pane shows a file when one is selected, and a listing for any directory below the root. The root itself is left blank, because the sidebar already lists it and a second copy reads as a duplicate. `ArtifactDirectoryView` has no such rule — used directly, it lists whatever tree you give it.\n\n`ArtifactRepoViewer` props:\n\n| Prop                  | Type                                    | Notes                                                             |\n| --------------------- | --------------------------------------- | ----------------------------------------------------------------- |\n| `client`              | `ArtifactsClient`                       | Required. Keep the identity stable                                |\n| `repoName`            | `string`                                | Required                                                          |\n| `gitRef`              | `string`                                | Branch, tag, or commit. Omitted resolves the default branch       |\n| `onSelect`            | `(s: ArtifactSelection) => void`        | Fires initially for root, then for each non-gitlink row selection |\n| `buildHref`           | `(s: ArtifactSelection) => string`      | When set, rows render as `<a>` instead of `<button>`              |\n| `icons`               | `Partial<ArtifactIconSlots>`            | `file`, `folder`, `folderOpen`, `submodule`                       |\n| `className` / `style` | —                                       | Applied to the root                                               |\n| `classNames`          | `Partial<Record<ArtifactSlot, string>>` | Per-slot classes                                                  |\n| `colorMode`           | `\"light\" \\| \"dark\" \\| \"system\"`         | Emitted as `data-mode`                                            |\n| `maxInlineBytes`      | `number`                                | Default `524288` (512 KiB)                                        |\n| `pierreDiffsOptions`  | `ArtifactPierreDiffsOptions`            | Code-view theming                                                 |\n| `renderCodeFallback`  | `ArtifactCodeFallbackRenderer`          | Placeholder shown while the code view is prepared                 |\n| `renderStatus`        | `ArtifactStatusRenderers`               | Replaces the loading, empty, and error markup                     |\n\nThe prop is `gitRef`, not `ref`, because React reserves `ref`.\n\n#### `renderStatus` — loading, empty, and error states\n\nBy default every pane renders a plain `<p>` with English text: `Loading repository…`,\n`This directory is empty.`, `Binary file (12.4 KiB).`, and so on. `renderStatus`\nreplaces that markup, which is also how you localise the copy.\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"website\"\n  renderStatus={{\n    loading: (context) => <Spinner label={context.scope} />,\n    empty: (context, kind) => <Notice kind={kind} scope={context.scope} />,\n    error: (context, error) => <Notice tone=\"danger\">{error.message}</Notice>,\n  }}\n/>\n```\n\nEvery renderer receives a discriminated context, so one function can speak\ndifferently per pane:\n\n```ts\ntype ArtifactStatusContext =\n  | { scope: \"repository\"; repoName: string }\n  | { scope: \"tree\"; repoName: string; path: string }\n  | { scope: \"file\"; repoName: string; path: string; name: string };\n\ntype ArtifactEmptyKind = \"empty\" | \"binary\" | \"oversized\";\n\ntype ArtifactStatusRenderers = {\n  loading?: (context: ArtifactStatusContext) => ReactNode;\n  empty?: (context: ArtifactStatusContext, kind?: ArtifactEmptyKind) => ReactNode;\n  error?: (context: ArtifactStatusContext, error: ArtifactsClientError) => ReactNode;\n};\n```\n\n`kind` is only set for files, because a repository or directory is already\nidentified by `context.scope`.\n\nThree things worth knowing:\n\n- **The map is partial.** A renderer returning `undefined` — including one you\n  simply did not name — keeps the default. Return `null` to render nothing.\n- **The slot survives.** Your output is wrapped in the matching slot element, so\n  `data-artifacts-viewer-slot`, `aria-busy`, `role=\"alert\"`, and `data-kind` are\n  still there to target and still announced. You do not repeat them.\n- **`renderStatus` is also accepted by** `ArtifactFileTree`, `ArtifactDirectoryView`,\n  and `ArtifactFileView`, so it works when you compose the pieces yourself.\n\nThe code view is deliberately not covered by `renderStatus`: it waits on the\nhighlighter rather than the network, and [`renderCodeFallback`](#rendercodefallback--what-the-wait-looks-like)\nalready owns that placeholder.\n\n### Hooks\n\nHooks return a discriminated state union — no `isLoading` booleans:\n\n```ts\ntype ArtifactQueryState<T> =\n  | { status: \"idle\" }\n  | { status: \"loading\" }\n  | { status: \"success\"; data: T }\n  | { status: \"error\"; error: ArtifactsClientError };\n```\n\n| Hook                                                                 | Returns                                     |\n| -------------------------------------------------------------------- | ------------------------------------------- |\n| `useArtifactRepository(client, repoName)`                            | `ArtifactsRepository`                       |\n| `useArtifactLog(client, { repoName, ref?, limit?, offset? })`        | `ArtifactsCommitMetadata[]`                 |\n| `useArtifactHeadCommit(client, repoName, ref?)`                      | `ArtifactsCommitMetadata \\| null`           |\n| `useArtifactTree(client, repoName, treeHash \\| null)`                | `ArtifactsTreeEntry[]`                      |\n| `useArtifactBlob(client, { repoName, name, hash, maxInlineBytes? })` | `ArtifactBlobRender`                        |\n| `useArtifactQuery(run, deps)`                                        | Anything; the primitive the others build on |\n\n**There is no cache and no request deduplication.** One request per dependency change, cancelled via `AbortSignal` when dependencies change or the component unmounts. Caching belongs to your data layer; pass a `fetch` that consults it, or wrap the client.\n\n`useArtifactHeadCommit` resolves `null` for an empty repository. Do not use `lastPushAt` for this — the Artifacts API always returns `null` there.\n\n`useArtifactBlob` produces the render classification:\n\n```ts\ntype ArtifactBlobRender =\n  | { kind: \"empty\" }\n  | { kind: \"text\"; contents: string }\n  | { kind: \"image\"; contentType: string }\n  | { kind: \"binary\"; sizeBytes: number }\n  | { kind: \"oversized\" };\n```\n\n### Building your own UI\n\nThe components are composable and customizable, and the hooks are public, so you can replace the markup entirely. `ArtifactRepoViewer` owns its current selection internally.\n\n```tsx\nfunction MyTree({ client, repoName, treeHash }) {\n  const tree = useArtifactTree(client, repoName, treeHash);\n  if (tree.status !== \"success\") return null;\n  return (\n    <ul>\n      {tree.data.map((e) => (\n        <li key={e.hash}>{e.name}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\n## Styling\n\n`artifacts-viewer/styles.css` is **structural only**. It picks no theme, and its main layout values are exposed as custom properties. Its selectors are intentionally low-specificity, using `:where()` for public hooks where practical, so ordinary application rules can override them without `!important`.\n\nImport it once:\n\n```ts\nimport \"artifacts-viewer/styles.css\";\n```\n\n### Custom properties\n\n| Property                                | Default                  |\n| --------------------------------------- | ------------------------ |\n| `--artifacts-viewer-color`              | `inherit`                |\n| `--artifacts-viewer-background`         | `transparent`            |\n| `--artifacts-viewer-font`               | `inherit`                |\n| `--artifacts-viewer-font-size`          | `inherit`                |\n| `--artifacts-viewer-mono-font`          | `monospace`              |\n| `--artifacts-viewer-muted-color`        | `inherit`                |\n| `--artifacts-viewer-error-color`        | `inherit`                |\n| `--artifacts-viewer-icon-color`         | `currentColor`           |\n| `--artifacts-viewer-toolbar-background` | `inherit`                |\n| `--artifacts-viewer-gap`                | `0.5rem`                 |\n| `--artifacts-viewer-indent`             | `1rem`                   |\n| `--artifacts-viewer-row-padding`        | `0.375rem 0.75rem`       |\n| `--artifacts-viewer-sidebar-width`      | `16rem`                  |\n| `--artifacts-viewer-pane-height`        | `none`                   |\n| `--artifacts-viewer-pane-overflow`      | `visible`                |\n| `--artifacts-viewer-image-max-height`   | `80vh`                   |\n| `--artifacts-viewer-focus-outline`      | `2px solid currentColor` |\n| `--artifacts-viewer-focus-offset`       | `-2px`                   |\n\n```css\n.my-viewer {\n  --artifacts-viewer-font: ui-monospace, monospace;\n  --artifacts-viewer-sidebar-width: 18rem;\n  --artifacts-viewer-pane-height: 32rem;\n  --artifacts-viewer-pane-overflow: auto;\n  --artifacts-viewer-muted-color: #6b7280;\n}\n```\n\n### Selector hooks\n\nPublic slots and parts carry stable attributes. Slots also accept a class through `classNames`; internal wrapper elements are not part of this selector contract.\n\n`data-artifacts-viewer-slot`: `root`, `toolbar`, `sidebar`, `tree`, `treeItem`, `content`, `directory`, `directoryItem`, `file`, `loading`, `empty`, `error`\n\n`data-artifacts-viewer-part`: `header`, `name`, `icon`, `actions`, `raw`, `download`, `code`, `code-pending`, `code-fallback`, `code-highlight`, `image`\n\nState attributes:\n\n| Attribute                    | Where                                                                       |\n| ---------------------------- | --------------------------------------------------------------------------- |\n| `data-artifacts-viewer-root` | The root element                                                            |\n| `data-mode`                  | Root; mirrors `colorMode`                                                   |\n| `data-kind`                  | Rows (`tree`, `blob`, `exec`, `symlink`, `gitlink`), and error/notice kinds |\n| `data-selected`              | The selected row                                                            |\n| `data-disabled`              | Submodule rows                                                              |\n\n`data-selected` is a **bare** attribute, so match `[data-selected]` and not `[data-selected=\"true\"]`:\n\n```css\n[data-artifacts-viewer-slot=\"treeItem\"][data-selected] {\n  background: #eef2ff;\n}\n```\n\nThe layout is container-query driven: the two panes collapse to one column below `40rem` of the root's own width, not the viewport's.\n\n## Syntax highlighting\n\nText files render through [`@pierre/diffs`](https://www.npmjs.com/package/@pierre/diffs), which wraps Shiki. It is loaded with a dynamic `import()`, so neither Pierre nor Shiki lands in your initial bundle. `ArtifactRepoViewer` proactively starts fetching the chunk when it mounts.\n\n### Loading\n\nUnhighlighted text is never shown while highlighting is still on its way. Pierre paints an empty node if it is mounted before its theme and grammar are attached, so the code view waits for both, then reveals the highlighted copy only once it has real output. Placeholder and file share one grid cell, so nothing shifts.\n\nThree states, in order:\n\n| State       | Rendered                                                                                            |\n| ----------- | --------------------------------------------------------------------------------------------------- |\n| Preparing   | `renderCodeFallback`, or a loading message by default — `data-artifacts-viewer-part=\"code-pending\"` |\n| Ready       | The highlighted file — `data-artifacts-viewer-part=\"code-highlight\"`                                |\n| Unavailable | Plain `<pre>` holding the file contents — `data-artifacts-viewer-part=\"code-fallback\"`              |\n\nThe unavailable state renders readable text when preloading reports that highlighting could not be initialized. It is not a general error boundary for failures after the lazy component starts rendering.\n\n#### `renderCodeFallback` — what the wait looks like\n\nA render prop, accepted by `ArtifactRepoViewer`, `ArtifactFileView`, and `CodeView`:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  renderCodeFallback={({ name }) => <MySpinner label={`Preparing ${name}`} />}\n/>\n```\n\n```ts\ntype ArtifactCodeFallbackRenderer = (file: { name: string; contents: string }) => ReactNode;\n```\n\n`contents` is the decoded file, so a skeleton can be sized to the real line count.\n\n#### `preloadCodeView` — how long the wait is\n\n**A function, not a prop.** It warms the lazy chunk, the shared Shiki engine, the themes, and — given a file name — that file's grammar.\n\n```ts\nimport { preloadCodeView } from \"artifacts-viewer/react\";\n\nawait preloadCodeView({ theme: { light: \"github-light\", dark: \"vesper\" } });\n```\n\n`ArtifactRepoViewer` already calls it on mount, so warming overlaps with browsing the tree and the first file opened is usually highlighted straight away. Call it yourself to start earlier still — on route entry, or when a row is hovered:\n\n```tsx\n<li onPointerEnter={() => void preloadCodeView({ theme, name: entry.name })}>\n```\n\nMemoized per theme and grammar set, so repeat calls cost nothing. It resolves `boolean`; `false` means highlighting is unavailable, which is what makes the plain-text state above reachable.\n\n### Theming\n\n`@pierre/diffs` renders into an open Shadow DOM. **Your CSS cannot reach inside it.** Theming has to go through options:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  pierreDiffsOptions={{\n    theme: { light: \"github-light\", dark: \"vesper\" },\n    themeType: \"system\",\n  }}\n/>\n```\n\n| Option      | Notes                                                                 |\n| ----------- | --------------------------------------------------------------------- |\n| `theme`     | A Shiki theme name, or a `{ light, dark }` pair                       |\n| `themeType` | `\"system\"` (default), `\"light\"`, or `\"dark\"`                          |\n| `unsafeCSS` | Raw CSS injected into the Shadow DOM. Unstable across Pierre releases |\n\nKeep the object identity stable, or memoize it, to avoid re-rendering the code view.\n\n## Limits and behaviour\n\n**Large files.** `maxInlineBytes` defaults to 512 KiB. If the response declares a larger `Content-Length` the body is never downloaded. Otherwise the stream is read and cancelled the moment the cap is crossed. Either way the state becomes `oversized` and the Raw and Download links still work.\n\n**Images** are detected from the file extension, so they never download through the client — they render straight from the raw URL via `<img src>`. SVG stays in image context and is never inlined. Recognized: `apng`, `avif`, `bmp`, `gif`, `ico`, `jfif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.\n\n**Text vs binary** is decided by a strict UTF-8 decode plus a NUL-byte scan over the first 8 KiB, not by an extension list. Anything that fails is `binary` and never reaches the highlighter.\n\n**Raw and download URLs** are pinned to the commit hash rather than a branch name, so a link keeps resolving — and stays cacheable — even if someone pushes mid-session.\n\n**Tree expansion** is keyed by path, not by tree hash, so two identical subtrees at different paths expand independently.\n\n**Accessibility.** The sidebar is a `<nav>` of nested disclosure buttons rather than `role=\"tree\"`, because full WAI-ARIA tree keyboard navigation is not implemented and claiming the role without it would be worse than not claiming it. Rows are real buttons, or real anchors when `buildHref` is supplied.\n\n## Not included\n\nDeliberately out of scope for now:\n\n- **Markdown rendering.** `.md` files currently display as highlighted plain text.\n- **Diff viewing** and commit history UI.\n- **Branch switching.** The Artifacts REST API exposes no ref-listing operation; enumerating branches needs Git protocol v2 `ls-refs`. Pass a known branch to `gitRef`, or omit it for the default branch.\n- **Writes** of any kind.\n\n## License\n\nMIT\n","worker/agents/think/skills/app-ai-binding/SKILL.md":"---\nname: app-ai-binding\ndescription: Build AI features in generated apps using the platform-provided Workers AI binding. Load this skill for chatbots, summarization, classification, embeddings, semantic search, image understanding, transcription, translation, or other model inference.\n---\n\nGenerated apps can call Workers AI from the `App` Durable Object through `this.env.AI`. The platform provides this binding automatically in Dynamic Worker previews and native Cloudflare deployments. Do not add an `ai` section to the generated app's `wrangler.json`.\n\nUse only `AI.run(model, inputs, options?)`. Preview uses a restricted proxy with this method, while deployed Workers receive the native Workers AI binding. Methods such as `AI.gateway()`, `AI.models()`, `AI.autorag()`, and `AI.toMarkdown()` are not portable between preview and deployment.\n\n## Type and usage\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\n\ninterface Env {\n  AI: Ai;\n}\n\nexport class App extends DurableObject<Env> {\n  async fetch(request: Request): Promise<Response> {\n    const { prompt } = await request.json<{ prompt: string }>();\n    const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n      messages: [\n        { role: \"system\", content: \"Answer clearly and concisely.\" },\n        { role: \"user\", content: prompt },\n      ],\n    });\n    return Response.json(result);\n  }\n}\n```\n\nKeep AI calls in the server-side `App` class. Browser code should call an app API route; it must never receive model credentials or call the binding directly.\n\n## Common tasks\n\n### Text generation\n\nUse `@cf/meta/llama-4-scout-17b-16e-instruct` with either `prompt` or `messages`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n  messages: [{ role: \"user\", content: userPrompt }],\n  max_tokens: 512,\n});\n```\n\n### Embeddings\n\nUse `@cf/baai/bge-base-en-v1.5`. The response contains embedding vectors in `data`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/baai/bge-base-en-v1.5\", {\n  text: [\"first document\", \"second document\"],\n});\n```\n\n### Image classification\n\nUse `@cf/microsoft/resnet-50` with image bytes converted to a number array:\n\n```ts\nconst image = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/microsoft/resnet-50\", { image });\n```\n\n### Speech transcription\n\nUse `@cf/openai/whisper` with audio bytes:\n\n```ts\nconst audio = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/openai/whisper\", { audio });\n```\n\nModel input and output shapes differ. Keep model-specific parsing close to the call and validate user input before inference. Use only active model IDs from the current Workers AI model catalog; never generate code with a deprecated model.\n\n## Limits and errors\n\nDynamic Worker previews enforce a per-app AI call limit. AI features must fail transparently when inference is unavailable.\n\n- Never return canned, random, mock, placeholder, or hard-coded content as a fallback for an AI response.\n- Never catch an `AI.run()` error and continue with an apparently successful response.\n- Never return `200` with fields such as `fallbackUsed` after inference fails.\n- Return a non-2xx JSON response containing a safe, useful error message. The frontend must display that error to the user.\n- Do not retry rate-limit failures in a loop.\n\n```ts\ntry {\n  const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n    messages: [{ role: \"user\", content: userPrompt }],\n  });\n  return Response.json(result);\n} catch (error) {\n  const message = error instanceof Error ? error.message : \"AI inference failed\";\n  return Response.json({ error: \"AI inference failed\", details: message }, { status: 503 });\n}\n```\n\nFrontend callers must inspect both the HTTP status and error body:\n\n```ts\nconst response = await fetch(\"./api/chat\", requestOptions);\nconst body = await response.json<{ content?: string; error?: string; details?: string }>();\nif (!response.ok) throw new Error(body.details || body.error || \"AI request failed\");\n```\n\nUse non-streaming calls for preview-compatible code. Native Workers AI deployments support `{ stream: true }`, but streaming across the preview proxy is not guaranteed.\n","worker/agents/think/skills/app-file-structure/SKILL.md":"---\nname: cloudflare-bundler-apps\ndescription: Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts).\n---\n\nThis skill teaches how to build apps that deploy cleanly through the space deploy pipeline. Every project committed to a space is built by `@cloudflare/worker-bundler` (`createApp` when there are static assets, `createWorker` when there are none) and served on a Dynamic Worker via `WorkerLoader`. Get the conventions right and the preview \"just works\". Get them wrong and you get a blank page, a 500, or a `Build failed` error.\n\n## How the pipeline works\n\nWhen `deploy_space(branch)` is called, the space DO:\n\n1. Reads every file from the branch's working tree (skipping `.git/`).\n2. Parses `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` for `main`, `compatibility_date`, `compatibility_flags`, and `[assets]`.\n3. If `[assets].directory` is set, files under that directory become static assets served host-side by `handleAssetRequest`. Everything else is built into a Worker via `createApp` (with `server: <main>`).\n4. If no assets directory is configured, only `createWorker({ entryPoint: <main> })` is run. The output is loaded as a Dynamic Worker; all requests go to the Worker.\n5. Previews are served at `/space/:name/preview/:branch/*`. Responses with `content-type: text/html` are run through `HTMLRewriter`, which prefixes root-relative `src` / `href` / `action` attributes with the preview base path. **JS-side fetches and dynamic imports are NOT rewritten.**\n\nPractical consequences:\n\n- **You can rely on root-relative `src=\"/foo.js\"` and `href=\"/style.css\"` in HTML.** They will be rewritten to the preview path automatically.\n- **You cannot rely on root-relative paths inside JS strings**: `fetch(\"/api/x\")`, `new URL(\"/foo\", location.origin)`, dynamic `import(\"/lib.js\")`. These hit the wrong path under the preview prefix. Use relative paths (`./api/x`, `import.meta.url`) or read the base path from `<base href>` / a meta tag injected at build time.\n- The `<base href>` tag is also rewritten if present — using it lets all relative URLs resolve against the preview path.\n\n## Project layout\n\nA typical full-stack space project:\n\n```\n/\n├── wrangler.json          # required for non-trivial setups\n├── package.json           # npm deps (text-only packages, see limits below)\n├── src/\n│   └── index.ts           # server entry: export default { fetch }\n└── public/                # static assets directory (configurable)\n    ├── index.html\n    ├── app.js             # compiled, no JSX\n    └── styles.css\n```\n\nA static-only SPA can skip `src/` entirely — just `wrangler.json` + `public/` is enough as long as `main` points to a minimal pass-through worker or you accept that all requests fall through assets.\n\n## Wrangler config\n\nMinimum viable `wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\nNotes:\n\n- `main` is required when there's any server code. The bundler also auto-detects `src/index.ts`, `src/index.js`, `index.ts`, `index.js` if missing.\n- `compatibility_date` defaults to `2025-04-01` if omitted. Set it explicitly for newer features.\n- Add `\"compatibility_flags\": [\"nodejs_compat\"]` only if you actually need Node built-ins.\n- `assets.directory` is the **only** way to ship static files. Files outside this directory are bundled into the Worker or ignored — they will **not** be reachable via URL.\n- `html_handling: \"auto-trailing-slash\"` is usually what you want for multi-page sites; SPAs should also set `not_found_handling: \"single-page-application\"` so deep links return `index.html`.\n\nTOML works too (`wrangler.toml`), but the parser only handles top-level scalar fields plus an `[assets]` table — no inline tables, no env overrides. Prefer JSON.\n\n## Server entry: one Durable Object class named `App`\n\n**Your entire backend is one Durable Object.** Your main module exports\na class named `App` extending `DurableObject`. The platform loads it as\na [Cloudflare Durable Object\nFacet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/)\nof the host SpaceDO and forwards every non-asset request — including\nWebSocket upgrades — to your `App.fetch(request)`.\n\n**Do not `export default { fetch }`.** A default fetch handler will be\nignored. There is no separate worker; the DO is the worker.\n\n```ts\n// src/index.ts\nimport { DurableObject } from \"cloudflare:workers\";\n\nexport class App extends DurableObject {\n  async fetch(request: Request): Promise<Response> {\n    const url = new URL(request.url);\n\n    // Lazy schema init on the first request. Idempotent.\n    this.ctx.storage.sql.exec(`\n      CREATE TABLE IF NOT EXISTS notes (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        body TEXT NOT NULL\n      )\n    `);\n\n    if (url.pathname === \"/api/notes\" && request.method === \"POST\") {\n      const { body } = await request.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return new Response(null, { status: 201 });\n    }\n    if (url.pathname === \"/api/notes\") {\n      const rows = this.ctx.storage.sql\n        .exec(`SELECT id, body FROM notes ORDER BY id DESC`)\n        .toArray();\n      return Response.json(rows);\n    }\n    return new Response(\"Not found\", { status: 404 });\n  }\n}\n```\n\nOr with Hono inside the DO:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/hello\", (c) => c.json({ msg: \"hi\" }))\n    .post(\"/api/notes\", async (c) => {\n      const { body } = await c.req.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return c.json({ ok: true });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\nWhen `[assets]` is configured, static files take priority — your `fetch`\nonly sees requests that didn't match an asset. For a SPA, put\n`index.html` in `public/` and `App.fetch` only handles `/api/*`.\n\n### State and storage\n\nState lives on `this.ctx.storage`. No `env.DB` exists; you do not need\nto declare any binding. The DO's storage is a per-(space, branch)\nSQLite database that survives redeploys.\n\n- `this.ctx.storage.sql.exec(sql, ...params)` — full SQLite. Returns a\n  cursor with `.toArray()`, `.one()`, `.columnNames`, etc.\n- `this.ctx.storage.kv.get(key)` / `.put(key, value)` — simple KV\n  backed by SQLite.\n- `this.ctx.storage.transactionSync(() => { ... })` — atomic batch\n  inside the same DO instance.\n\n```ts\n// SQL\nthis.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS counter (n INTEGER)`);\nconst cur = this.ctx.storage.sql.exec(`SELECT n FROM counter LIMIT 1`).toArray();\nconst n = (cur[0]?.n as number) ?? 0;\n\n// KV\nconst last = this.ctx.storage.kv.get<number>(\"last_seen\") ?? 0;\nthis.ctx.storage.kv.put(\"last_seen\", Date.now());\n```\n\n**Use the DO's storage for all persistent state** — users, sessions,\ncontent, settings, anything that should survive a reload. Do not use\n`localStorage` or `sessionStorage` for primary data; they are\nper-browser, per-origin, and lost on incognito or any other browser.\n\n### WebSockets\n\nThe platform forwards `Upgrade: websocket` requests transparently into\n`App.fetch`. Use the standard Durable Object hibernation API:\n\n```ts\nexport class App extends DurableObject {\n  async fetch(request: Request) {\n    const upgrade = request.headers.get(\"Upgrade\");\n    if (upgrade === \"websocket\") {\n      const { 0: client, 1: server } = new WebSocketPair();\n      this.ctx.acceptWebSocket(server);\n      return new Response(null, { status: 101, webSocket: client });\n    }\n    return new Response(\"not found\", { status: 404 });\n  }\n\n  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {\n    // Broadcast to every connected client (chat-style fan-out).\n    for (const peer of this.ctx.getWebSockets()) {\n      peer.send(typeof message === \"string\" ? message : new Uint8Array(message));\n    }\n  }\n\n  webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {\n    // Cleanup if needed.\n  }\n}\n```\n\n`acceptWebSocket` + `webSocketMessage` is the right pattern for\nmulti-connection use cases (chat rooms, multiplayer, presence) because\nthe DO can hibernate between messages without losing connections.\n\n### Per-entity grouping (chat rooms, per-user records)\n\nYou get **one App DO per (space, branch)** — a single instance, not a\nnamespace. Do not call `idFromName` or try to declare a DO binding in\n`wrangler.json`; both are rejected. For multi-room / multi-user state,\n**partition rows with a column** in the App's SQLite:\n\n```sql\nCREATE TABLE messages (\n  room_id TEXT NOT NULL,\n  user_id TEXT NOT NULL,\n  body TEXT NOT NULL,\n  ts INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)\n);\nCREATE INDEX messages_by_room ON messages (room_id, ts);\n```\n\nThen route by `room_id` inside `App.fetch`. SQLite is fast enough that\nsharing the same table across rooms is fine for prototyping; if you\nlater need stronger isolation, attach `room_id` to a tag and use\n`acceptWebSocket(ws, [room_id])` so `getWebSockets(room_id)` returns\nonly that room's sockets.\n\n### What you cannot declare\n\n- **`durable_objects.bindings` in `wrangler.json`.** The platform\n  extracts your `App` class automatically. Adding bindings fails the\n  deploy with a clear error.\n- **`new_sqlite_classes` / migrations.** Same reason. Your App's\n  storage is set up by the platform.\n- **`d1_databases`, `r2_buckets`, `kv_namespaces`.** Not available.\n  Everything goes in `this.ctx.storage`.\n\n## Static asset rules (read this twice)\n\nThis is where most preview failures happen. The browser eventually runs your assets — the bundler does **not** transform them. So:\n\n### **NEVER ship JSX in a `<script type=\"module\">` (or any other script tag).** Browsers cannot parse JSX.\n\nThis page **will be blank** with a `SyntaxError: Unexpected token '<'`:\n\n```html\n<script type=\"module\">\n  import React from \"https://esm.sh/react@18\";\n  const App = () => <div>hi</div>;   // ← browser dies here\n</script>\n```\n\nFix one of these three ways:\n\n1. **Pre-compile** — write JSX in a build step (Vite/esbuild/tsup) and emit plain JS into `public/`. Best for production.\n2. **`React.createElement` by hand** — works without a build step but is verbose.\n3. **`@babel/standalone`** — only for prototypes. Load Babel **before** the script and use `type=\"text/babel\"`. The modern `@babel/preset-react` defaults to the **automatic JSX runtime**, which emits `import { jsx } from \"react/jsx-runtime\"`. Browsers will reject that bare specifier unless your importmap maps it. **Always ship a complete importmap alongside the Babel script:**\n\n   ```html\n   <script type=\"importmap\">\n   {\n     \"imports\": {\n       \"react\": \"https://esm.sh/react@18.3.1\",\n       \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n       \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n       \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\"\n     }\n   }\n   </script>\n   <script src=\"https://unpkg.com/@babel/standalone/babel.min.js\"></script>\n   <script type=\"text/babel\" data-type=\"module\" data-presets=\"react\">\n     import React from \"react\";\n     import { createRoot } from \"react-dom/client\";\n     const App = () => <div>hi</div>;\n     createRoot(document.getElementById(\"root\")).render(<App />);\n   </script>\n   ```\n\n   `data-type=\"module\"` is required for `import` to work inside the Babel script. Import React from the importmap key (`\"react\"`) — not from a hard-coded `esm.sh` URL — so your code and Babel's emitted `react/jsx-runtime` import resolve to the **same** React instance.\n\n   If you really cannot ship `react/jsx-runtime` in the importmap, force Babel to the legacy classic runtime instead so it emits `React.createElement` calls and never touches `react/jsx-runtime`:\n\n   ```html\n   <script\n     type=\"text/babel\"\n     data-type=\"module\"\n     data-presets=\"react\"\n     data-plugins='[[\"transform-react-jsx\", { \"runtime\": \"classic\" }]]'\n   >\n     import React from \"https://esm.sh/react@18.3.1\";  /* must be in scope */\n     const App = () => <div>hi</div>;\n   </script>\n   ```\n\n   The classic runtime requires `React` to be in lexical scope (because `<div>` becomes `React.createElement(\"div\")`). The automatic runtime (default) does not, but needs `react/jsx-runtime` resolvable.\n\n### Symptom-to-fix index\n\n| Console error | Cause | Fix |\n| --- | --- | --- |\n| `Uncaught SyntaxError: Unexpected token '<'` (inside `<script type=\"module\">`) | JSX shipped raw to the browser | Pre-compile, or use `@babel/standalone` with `type=\"text/babel\"` |\n| `Uncaught TypeError: Failed to resolve module specifier \"react/jsx-runtime\". Relative references must start with either \"/\", \"./\", or \"../\".` | Automatic JSX runtime emits `import \"react/jsx-runtime\"` but importmap doesn't map it | Add `\"react/jsx-runtime\": \"https://esm.sh/react@<same-version>/jsx-runtime\"` to the importmap **before** the Babel/script tag — or force the classic runtime (see above) |\n| `Uncaught TypeError: Failed to resolve module specifier \"react\"` (or any bare name) | No importmap entry for that package | Add it to the importmap; importmap script tag must appear **before** any module script that uses the specifier |\n| `TypeError: Cannot read properties of null (reading 'useContext')` | Dual-React (two copies loaded) | Append `?external=react,react-dom` to every esm.sh URL with React as a peer dep — see the dual-React trap below |\n\n### Other asset gotchas\n\n- **Binary assets are not extracted from npm packages.** Fonts, images, and `.wasm` shipped via npm tarballs will be missing. Put binaries directly in `public/` instead.\n- **Paths in JS need care under preview**: prefer `import.meta.url`, relative paths, or a runtime base detected from `document.baseURI`. Don't hard-code `/api/...` in client code; use `./api/...` or read a base from a `<meta>` tag.\n- **Trailing slashes matter** for `auto-trailing-slash` mode: `/about` serves `about.html`, `/about/` serves `about/index.html`. Pick one shape and link consistently.\n- **No CSS preprocessors at runtime** — ship `.css`, not `.scss`. Compile first if you need Sass.\n- **Importmaps work** since they're inline JSON. Use them to avoid bundling React/etc. for prototypes — but watch the dual-React trap below.\n\n### The dual-React trap (the #1 cause of \"blank page + `useContext` is null`)\n\n`esm.sh` bundles a package's peer dependencies *into the package itself* unless you tell it otherwise. So this importmap looks correct but ships **two copies of React** — one for your app, another nested inside `framer-motion`:\n\n```html\n<!-- BROKEN: framer-motion has its own React inside, hooks crash with `Cannot read properties of null (reading 'useContext')` -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.2.0\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.2.0/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11\"\n  }\n}\n</script>\n```\n\nThe symptom is exactly: blank background + console errors like\n\n```\nTypeError: Cannot read properties of null (reading 'useContext')\n  at u (SwitchLayoutGroupContext.mjs:1:1)\n```\n\nbecause the nested React instance has no provider in the tree.\n\n**Fix: append `?external=react,react-dom` (and any other shared peers) to every esm.sh URL that has React as a peer dep.** Pin the exact same React version everywhere too.\n\n```html\n<!-- CORRECT: one React shared across the whole importmap -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.3.1\",\n    \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n    \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11?external=react,react-dom\",\n    \"lucide-react\": \"https://esm.sh/lucide-react@0.400.0?external=react,react-dom\"\n  }\n}\n</script>\n```\n\nApply `?external=...` to **every** React-consuming dep (UI libraries, icon sets, animation libs, headless components). Same rule for `vue`, `solid-js`, etc. if you swap the framework.\n\nIf a project needs more than two or three of these packages, stop using the importmap path — switch to option 1 (pre-compile with Vite/esbuild) so the bundler can dedupe React for you.\n\n## npm dependencies\n\nThe bundler installs deps from npm at build time. Limits to respect:\n\n- **Flat node_modules.** No two versions of the same package can coexist — peer-dep conflicts will pick one and break the other. Keep dep graphs shallow.\n- **Text-only files** are extracted from tarballs. `.js`, `.ts`, `.json`, `.css`, `.md` work. `.wasm`, `.node`, native binaries, fonts, images do not.\n- **No PAX tar headers** — packages whose internal paths exceed 100 chars may have those files silently dropped. Avoid deeply-nested monorepo packages.\n- **No build scripts run** — `postinstall`, `prepare`, etc. are ignored. Packages that compile native code or run codegen at install time will not work.\n- **`cloudflare:*` imports are always external** and resolved by the runtime (`cloudflare:workers`, etc.). Don't add them to `package.json`.\n\nSafe and well-tested deps: `hono`, `zod`, `itty-router`, `nanoid`, `valibot`, `@hono/zod-validator`. Avoid anything that needs node-native modules unless you set `compatibility_flags: [\"nodejs_compat\"]` and the package is pure JS under that flag.\n\n## Project shapes\n\n| Project shape                       | What you need                                                                          |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| Pure static site (HTML+JS+CSS only) | `wrangler.json` with `[assets]`, files in `public/`. No `main`. No App class.          |\n| SPA + API                           | `[assets]` for the SPA, `main` exporting `class App extends DurableObject`             |\n| API only (JSON, no frontend)        | `main` exporting `class App extends DurableObject`, no `[assets]`                      |\n| Realtime (WebSocket / multiplayer)  | Same as SPA + API; `App.fetch` upgrades to WebSocket and uses `ctx.acceptWebSocket`    |\n\n## Pre-flight checklist before `deploy_space`\n\nRun through every item — most \"preview is broken\" reports trace back to one of these.\n\n- `wrangler.json` exists at repo root with `main` and (if static assets exist) `[assets].directory`.\n- Every browser-loaded `.js` / `.mjs` / `<script type=\"module\">` is **plain JS, no JSX**, or wrapped with `@babel/standalone` + `type=\"text/babel\"`.\n- If you use React via importmap (Babel-standalone or pre-compiled with the automatic JSX runtime), the importmap maps **all** of `react`, `react/jsx-runtime`, `react-dom`, and `react-dom/client` — same version on every entry. Missing `react/jsx-runtime` produces `Failed to resolve module specifier \"react/jsx-runtime\"` and a blank page.\n- The `<script type=\"importmap\">` tag appears **before** any `<script type=\"module\">` (or `type=\"text/babel\" data-type=\"module\"`) that depends on the mapped specifiers.\n- Static files live under the configured `assets.directory` (default suggestion: `public/`).\n- HTML uses root-relative paths (`/foo.js`) — they get rewritten. JS uses relative paths (`./foo`).\n- No binary assets are imported from npm packages. Binaries live in `public/`.\n- `package.json` deps are pure-JS, no native modules, no install scripts.\n- SPA routing? Set `not_found_handling: \"single-page-application\"`.\n- Server entry exports `class App extends DurableObject` from your `main` module (no `export default { fetch }`).\n- No `durable_objects` / `d1_databases` / `kv_namespaces` / `r2_buckets` blocks in `wrangler.json`.\n- `compatibility_date` is set if you use APIs newer than the default.\n\nIf any of these are off, fix them in the working tree, commit, and redeploy. The preview will pick up the new build on the next `deploy_space` call (the dynamic worker is keyed by commit hash, so old builds are not reused).\n\n## Minimal end-to-end example\n\nA working SPA with a tiny API:\n\n```\nwrangler.json\npackage.json\nsrc/index.ts\npublic/index.html\npublic/app.js\npublic/style.css\n```\n\n`wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\n`package.json`:\n\n```json\n{ \"name\": \"demo\", \"type\": \"module\", \"dependencies\": { \"hono\": \"^4.6.0\" } }\n```\n\n`src/index.ts`:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/time\", (c) => c.json({ now: new Date().toISOString() }))\n    .post(\"/api/visit\", async (c) => {\n      this.ctx.storage.sql.exec(\n        `CREATE TABLE IF NOT EXISTS visits (id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER)`\n      );\n      this.ctx.storage.sql.exec(`INSERT INTO visits (ts) VALUES (?)`, Date.now());\n      const cnt = this.ctx.storage.sql\n        .exec(`SELECT COUNT(*) AS c FROM visits`)\n        .one().c as number;\n      return c.json({ count: cnt });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\n`public/index.html`:\n\n```html\n<!doctype html>\n<html>\n<head>\n  <link rel=\"stylesheet\" href=\"/style.css\">\n</head>\n<body>\n  <div id=\"root\">Loading…</div>\n  <script type=\"module\" src=\"/app.js\"></script>\n</body>\n</html>\n```\n\n`public/app.js`:\n\n```js\nconst res = await fetch(\"./api/time\");\nconst data = await res.json();\ndocument.getElementById(\"root\").textContent = data.now;\n\n// Increment + display visit counter (server state lives in App.ctx.storage.sql)\nconst v = await fetch(\"./api/visit\", { method: \"POST\" }).then((r) => r.json());\ndocument.getElementById(\"root\").textContent += `  ·  visits: ${v.count}`;\n```\n\nNote `./api/time` (relative) in JS, `/style.css` (root-relative) in HTML. The HTML href is rewritten by the preview; the JS fetch resolves against the document's base URL.\n\nCommit this, call `deploy_space(\"main\")`, and the preview at `/space/<name>/preview/main/` will render the timestamp and an incrementing visit counter persisted in the App DO's SQLite.\n","worker/agents/think/skills/frontend-design-landing-page/SKILL.md":"---\nname: frontend-design-landing-page\ndescription: Marketing landing page and conversion-focused product page reference. Use this skill when building hero sections, feature grids, pricing pages, testimonials, CTAs, footers, navigation bars, or any public-facing marketing surface. Covers a warm, professional, developer-friendly design language (cream backgrounds, generous whitespace, pill CTAs, corner-bracket card decorations) and a complete token set, animation system, and copy-paste component snippets. NOT for product/dashboard UIs — use frontend-design-saas for those.\n---\n\n# Landing Page Design System\n\n> **AI-Optimized Design Reference** for building warm, professional, conversion-focused marketing landing pages, product pages, feature pages, and pricing pages.\n\nThis skill captures a specific landing-page register: warm cream backgrounds (NOT pure white), brown text (NOT pure black), a single bold accent for CTAs, pill-rounded buttons, and signature corner-bracket card decorations. It's tuned for developer-adjacent / B2B products — the aesthetic feels human and crafted without being playful or consumer-y. Pin the accent color to your brand; the rest of the system is reusable as-is.\n\n> **When to use this vs. `frontend-design-saas`:** Landing-page surfaces (homepage, /features, /pricing, /about, blog) → this skill. Product UI behind the login (dashboards, settings, tables) → `frontend-design-saas`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #FF4801 (replace with your brand) / #FF7038 (hover)\nBackground:      #FFFBF5 (warm cream) / #121212 (dark mode)\nText:            #521000 (warm brown) / #F0E3DE (dark mode)\nBorder:          #EBD5C1 (cream-tinted)\nFont Sans:       \"Inter\" / \"Geist\" / \"Manrope\" / any modern grotesk\nFont Mono:       \"JetBrains Mono\" / \"IBM Plex Mono\" / \"Geist Mono\"\nBase Spacing:    4px (multiples: 8, 12, 16, 24, 32, 48, 64)\nBorder Radius:   Buttons = pill (9999px), Cards = 12-16px, Inputs = 8px, Hero = 16-20px\nSignature:       Corner brackets on cards (8px decorative squares at corners)\n```\n\nThis is a **landing-page** register, not a product-UI register. Buttons are pills (consumer/marketing read), backgrounds are warm cream (inviting, not sterile), and decorative idioms like corner brackets are welcome. For product surfaces behind auth, use the SaaS skill instead.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Warm but Technical** | Cream tones soften technical content |\n| **Professional yet Approachable** | Modern typography, generous whitespace |\n| **Developer-Focused** | Monospace for code, terminal aesthetics |\n| **Performance-Oriented** | Smooth animations convey speed |\n\n### Visual Identity\n\n- **Never use pure white** (`#FFFFFF`) for backgrounds — always warm cream (`#FFFBF5`)\n- **Never use pure black** (`#000000`) for text — always warm brown (`#521000`)\n- **Orange is the accent**, not the dominant color\n- **Corner brackets** on cards are a signature decorative element\n- **Dot patterns** and **dashed lines** add visual texture\n\n---\n\n## 2. Color System\n\n### 2.1 Primary Palette (Light Mode)\n\n| Token | Hex | RGB | Usage |\n|-------|-----|-----|-------|\n| `--lp-accent` | `#FF4801` | `rgb(255, 72, 1)` | Primary accent, CTAs, links |\n| `--lp-accent-hover` | `#FF7038` | `rgb(255, 112, 56)` | Hover states |\n| `--lp-accent-light` | `rgba(255, 72, 1, 0.1)` | — | Badges, light backgrounds |\n| `--lp-text` | `#521000` | `rgb(82, 16, 0)` | Primary text |\n| `--lp-text-muted` | `rgba(82, 16, 0, 0.6)` | — | Secondary text |\n| `--lp-text-subtle` | `rgba(82, 16, 0, 0.38)` | — | Tertiary text, placeholders |\n| `--lp-bg-page` | `#F5F1EB` | `rgb(245, 241, 235)` | Page background (outer) |\n| `--lp-bg-100` | `#FFFBF5` | `rgb(255, 251, 245)` | Primary background |\n| `--lp-bg-200` | `#FFFDFB` | `rgb(255, 253, 251)` | Card backgrounds |\n| `--lp-bg-300` | `#FEF7ED` | `rgb(254, 247, 237)` | Hover backgrounds |\n| `--lp-border` | `#EBD5C1` | `rgb(235, 213, 193)` | Borders, dividers |\n| `--lp-border-light` | `rgba(235, 213, 193, 0.5)` | — | Subtle borders |\n\n### 2.2 Primary Palette (Dark Mode)\n\n| Token | Hex | Usage |\n|-------|-----|-------|\n| `--lp-accent` | `#F14602` | Primary accent |\n| `--lp-accent-hover` | `#FF6D33` | Hover states |\n| `--lp-text` | `#F0E3DE` | Primary text |\n| `--lp-text-muted` | `rgba(255, 253, 251, 0.56)` | Secondary text |\n| `--lp-bg-100` | `#121212` | Primary background |\n| `--lp-bg-200` | `#191817` | Card backgrounds |\n| `--lp-bg-300` | `#2A2927` | Hover backgrounds |\n| `--lp-border` | `rgba(240, 227, 222, 0.13)` | Borders |\n\n### 2.3 Category / Tag Colors (optional)\n\nIf your product has multiple feature categories that appear as tags or pills throughout the page (feature cards, navigation, etc.), define a small categorical palette. Keep luminance similar across the set so no single color overpowers the others.\n\n| Slot | Primary | Background | Example use |\n|----------|---------|------------|-------|\n| Category A | `#0A95FF` | `rgba(10, 149, 255, 0.1)` | \"Compute\" / \"Performance\" |\n| Category B | `#EE0DDB` | `rgba(238, 13, 219, 0.1)` | \"Storage\" / \"Data\" |\n| Category C | `#19E306` | `#F2F5E1` | \"AI\" / \"Intelligence\" |\n| Category D | `#9616FF` | `#F8EBEE` | \"Media\" / \"Edge\" |\n\nThese are placeholders — swap to your product's category palette. The goal is a small set of visually distinct accent colors used only on category labels, NEVER on primary CTAs (the brand accent owns that role).\n\n### 2.4 Semantic Colors\n\n| Purpose | Light Mode | Dark Mode |\n|---------|------------|-----------|\n| Success | `#16A34A` | `#4ADE80` |\n| Warning | `#EAB308` | `#FACC15` |\n| Error | `#DC2626` | `#F87171` |\n| Info | `#2563EB` | `#60A5FA` |\n\n---\n\n## 3. Typography\n\n### 3.1 Font Families\n\n```css\n--font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n--font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", \"Consolas\", monospace;\n```\n\n**Font Files:**\n- `Kunst Grotesk Regular.woff2` (400)\n- `Kunst Grotesk Medium.woff2` (500)\n- `JetBrains Mono Regular.woff2` (400)\n\n### 3.2 Type Scale\n\n| Name | Size | Line Height | Weight | Usage |\n|------|------|-------------|--------|-------|\n| `xs` | 12px (0.75rem) | 1.33 | 400 | Badges, captions, footnotes |\n| `sm` | 14px (0.875rem) | 1.43 | 400 | Secondary text, labels |\n| `base` | 16px (1rem) | 1.5 | 400 | Body text |\n| `lg` | 18px (1.125rem) | 1.56 | 400/500 | Large body, subheadings |\n| `xl` | 20px (1.25rem) | 1.4 | 500 | Section titles |\n| `2xl` | 24px (1.5rem) | 1.33 | 500 | Card headings |\n| `3xl` | 30px (1.875rem) | 1.2 | 500 | Section headings |\n| `4xl` | 36px (2.25rem) | 1.11 | 500 | Page headings |\n| `5xl` | 48px (3rem) | 1.0 | 500 | Hero headings |\n\n### 3.3 Font Weights\n\n| Weight | Value | Usage |\n|--------|-------|-------|\n| Normal | 400 | Body text, descriptions |\n| Medium | 500 | Headings, buttons, emphasis |\n\n### 3.4 Letter Spacing\n\n| Context | Value | CSS |\n|---------|-------|-----|\n| Headings | -0.02em | `letter-spacing: -0.02em` |\n| Body | Normal | `letter-spacing: normal` |\n| Uppercase labels | 0.05em | `letter-spacing: 0.05em` |\n| Logo text | -0.46px | `letter-spacing: -0.46px` |\n\n### 3.5 Text Rendering\n\n```css\nbody {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--spacing-unit: 4px;\n```\n\n### 4.2 Spacing Scale\n\n| Token | Value | Pixels | Common Usage |\n|-------|-------|--------|--------------|\n| `0` | 0 | 0px | Reset |\n| `0.5` | 0.125rem | 2px | Tiny gaps |\n| `1` | 0.25rem | 4px | Tight spacing |\n| `1.5` | 0.375rem | 6px | Small gaps |\n| `2` | 0.5rem | 8px | Default small padding |\n| `3` | 0.75rem | 12px | Input padding, card gaps |\n| `4` | 1rem | 16px | Standard padding |\n| `5` | 1.25rem | 20px | Medium spacing |\n| `6` | 1.5rem | 24px | Section padding (mobile) |\n| `8` | 2rem | 32px | Large padding |\n| `10` | 2.5rem | 40px | Section gaps |\n| `12` | 3rem | 48px | Large section gaps |\n| `16` | 4rem | 64px | Hero padding |\n| `20` | 5rem | 80px | Major sections |\n| `24` | 6rem | 96px | Max section spacing |\n\n### 4.3 Common Spacing Patterns\n\n```css\n/* Card padding */\npadding: 24px;  /* p-6 */\n\n/* Input padding */\npadding: 12px;  /* p-3 */\n\n/* Button padding */\npadding: 12px 24px;  /* py-3 px-6 */\n\n/* Section padding (responsive) */\npadding: 32px 16px;  /* Mobile */\npadding: 48px 32px;  /* Tablet */\npadding: 64px 48px;  /* Desktop */\n\n/* Grid gaps */\ngap: 16px;  /* Cards */\ngap: 24px;  /* Sections */\n```\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `rounded-sm` | 4px | Small badges |\n| `rounded` | 6px | Tags, small elements |\n| `rounded-md` | 8px | Inputs |\n| `rounded-lg` | 12px | Icon containers, panels |\n| `rounded-xl` | 16px | Hero sections |\n| `rounded-2xl` | 20px | Large hero sections |\n| `rounded-full` | 9999px | Buttons, pills, avatars |\n| `rounded-none` | 0 | Cards (sharp edges) |\n\n### Common Patterns\n\n```css\n/* Buttons - always fully rounded */\nborder-radius: 9999px;\n\n/* Cards - sharp edges */\nborder-radius: 0;\n\n/* Inputs */\nborder-radius: 8px;\n\n/* Hero sections (desktop) */\nborder-radius: 16px;\n\n/* Progress bars */\nborder-radius: 9999px;\n\n/* Icon containers */\nborder-radius: 8px;\n```\n\n---\n\n## 6. Shadow System\n\n### 6.1 Shadow Stack (Signature Effect)\n\nUsed on hero sections and elevated cards for depth with inner glow.\n\n```css\n/* Light Mode */\n--shadow-stack: \n  1px 6px 6px 0 rgba(255, 255, 255, 0.2) inset,\n  0 0 0px 0 rgba(255, 255, 255, 0.35) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n\n/* Dark Mode */\n--shadow-stack-dark:\n  1px 6px 16px 0 rgba(255, 255, 255, 0.05) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n```\n\n### 6.2 Utility Shadows\n\n```css\n/* Subtle shadow for cards */\n--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n\n/* Standard card shadow */\n--shadow-card: \n  0 1px 3px rgba(82, 16, 0, 0.04),\n  0 4px 12px rgba(82, 16, 0, 0.02);\n\n/* Elevated shadow */\n--shadow-lg: \n  0 10px 15px -3px rgba(0, 0, 0, 0.1),\n  0 4px 6px -4px rgba(0, 0, 0, 0.1);\n\n/* Focus ring shadow */\n--shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n```\n\n---\n\n## 7. Animation System\n\n### 7.1 Timing Functions\n\n```css\n/* Standard ease-out (default for most transitions) */\n--ease-out: cubic-bezier(0, 0, 0.2, 1);\n\n/* Button interactions */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press states */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Smooth deceleration */\n--ease-decel: cubic-bezier(0.4, 0, 0.2, 1);\n```\n\n### 7.2 Duration Scale\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--duration-instant` | 100ms | Micro-interactions |\n| `--duration-fast` | 150ms | Default transitions |\n| `--duration-normal` | 200ms | Hover states |\n| `--duration-medium` | 300ms | Theme transitions |\n| `--duration-slow` | 500ms | Complex animations |\n| `--duration-long` | 1000ms | Page transitions |\n\n### 7.3 Standard Transitions\n\n```css\n/* Color transitions (default) */\ntransition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;\n\n/* Button transitions */\ntransition: all 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Card hover */\ntransition: box-shadow 0.2s ease, transform 0.2s ease;\n\n/* Input focus */\ntransition: border-color 0.15s ease, box-shadow 0.15s ease;\n```\n\n### 7.4 Keyframe Animations\n\n```css\n/* Fade in */\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n\n/* Slide up */\n@keyframes slideUp {\n  from { opacity: 0; transform: translateY(10px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n/* Pulse (loading) */\n@keyframes pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.5; }\n}\n\n/* Progress bar fill */\n@keyframes progressFill {\n  from { width: 0; }\n  to { width: var(--progress-width); }\n}\n\n/* Infinite scroll (logos) */\n@keyframes infiniteScroll {\n  from { transform: translateX(0); }\n  to { transform: translateX(-50%); }\n}\n```\n\n### 7.5 Framer Motion Presets (React)\n\n```javascript\n// Fade in\nconst fadeIn = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  transition: { duration: 0.3 }\n};\n\n// Slide up\nconst slideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { opacity: 1, y: 0 },\n  transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }\n};\n\n// Stagger children\nconst staggerContainer = {\n  animate: { transition: { staggerChildren: 0.1 } }\n};\n\n// Scale on hover\nconst scaleHover = {\n  whileHover: { scale: 1.02 },\n  whileTap: { scale: 0.98 }\n};\n\n// Button press\nconst buttonPress = {\n  whileTap: { scale: 0.98, y: 1 }\n};\n```\n\n---\n\n## 8. Layout System\n\n### 8.1 Container Widths\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--container-sm` | 640px | Narrow content |\n| `--container-md` | 768px | Medium content |\n| `--container-lg` | 1024px | Standard content |\n| `--container-xl` | 1200px | Wide content |\n| `--container-2xl` | 1480px | Full-width sections |\n\n### 8.2 Breakpoints\n\n| Name | Min Width | Usage |\n|------|-----------|-------|\n| `sm` | 640px | Large phones |\n| `md` | 768px | Tablets |\n| `lg` | 1024px | Laptops |\n| `xl` | 1280px | Desktops |\n| `2xl` | 1536px | Large screens |\n\n### 8.3 Grid Patterns\n\n```css\n/* 2-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(2, 1fr);\ngap: 16px;\n\n/* 3-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(3, 1fr);\ngap: 16px;\n\n/* 4-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(4, 1fr);\ngap: 16px;\n\n/* Calculator layout (2 columns, different widths) */\ndisplay: grid;\ngrid-template-columns: 1fr 1fr;\ngap: 24px;\n\n/* Responsive grid */\ndisplay: grid;\ngrid-template-columns: repeat(1, 1fr);  /* Mobile */\ngrid-template-columns: repeat(2, 1fr);  /* md: */\ngrid-template-columns: repeat(3, 1fr);  /* lg: */\n```\n\n### 8.4 Bento Grid\n\n```css\n/* Bento layout with varying sizes */\ndisplay: grid;\ngrid-template-columns: repeat(12, 1fr);\ngap: 8px;\n\n/* Bento cell sizes */\n.bento-sm { grid-column: span 4; }    /* 1/3 width */\n.bento-md { grid-column: span 6; }    /* 1/2 width */\n.bento-lg { grid-column: span 8; }    /* 2/3 width */\n.bento-full { grid-column: span 12; } /* Full width */\n```\n\n---\n\n## 9. Dark Mode Implementation\n\n### 9.1 Detection Script\n\nPlace in `<head>` before any styles load:\n\n```html\n<script>\n(function() {\n  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n    document.documentElement.classList.add('dark');\n  }\n})();\n</script>\n```\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --lp-accent: #FF4801;\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-border: #EBD5C1;\n}\n\n:root.dark, html.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n```\n\n### 9.3 Theme Transition\n\n```css\nhtml.theme-transitioning,\nhtml.theme-transitioning * {\n  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;\n}\n```\n\n### 9.4 System Preference Listener\n\n```javascript\nconst mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\nmediaQuery.addEventListener('change', (e) => {\n  document.documentElement.classList.toggle('dark', e.matches);\n});\n```\n\n---\n\n## 10. Accessibility\n\n### 10.1 Focus States\n\n```css\n/* Default focus ring */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\n/* Button focus */\nbutton:focus-visible {\n  outline: none;\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.3);\n}\n\n/* Input focus */\ninput:focus-visible,\nselect:focus-visible,\ntextarea:focus-visible {\n  outline: none;\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n### 10.2 Disabled States\n\n```css\n:disabled,\n[disabled],\n.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  pointer-events: none;\n}\n```\n\n### 10.3 Error States\n\n```css\n[aria-invalid=\"true\"],\n.input-error {\n  border-color: #DC2626;\n  box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);\n}\n\n.error-message {\n  color: #DC2626;\n  font-size: 14px;\n  margin-top: 4px;\n}\n```\n\n### 10.4 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n```\n\n### 10.5 Selection Styling\n\n```css\n::selection {\n  background-color: rgba(255, 72, 1, 0.2);\n  color: var(--lp-text);\n}\n```\n\n---\n\n## 11. Decorative Elements\n\n### 11.1 Corner Brackets\n\nSignature decorative element for cards:\n\n```css\n/* Corner bracket container */\n.corner-brackets {\n  position: relative;\n}\n\n/* Individual bracket */\n.corner-bracket {\n  position: absolute;\n  width: 8px;\n  height: 8px;\n  border: 1px solid var(--lp-border);\n  border-radius: 1.5px;\n  background: var(--lp-bg-100);\n}\n\n/* Positions */\n.corner-bracket.top-left { top: -4px; left: -4px; }\n.corner-bracket.top-right { top: -4px; right: -4px; }\n.corner-bracket.bottom-left { bottom: -4px; left: -4px; }\n.corner-bracket.bottom-right { bottom: -4px; right: -4px; }\n```\n\n### 11.2 Dot Pattern Background\n\n```css\n.dot-pattern {\n  background-image: radial-gradient(\n    circle,\n    var(--lp-border) 0.75px,\n    transparent 0.75px\n  );\n  background-size: 12px 12px;\n}\n```\n\n### 11.3 Dashed Line Borders\n\n```css\n/* Vertical dashed line */\n.dashed-line-vertical {\n  width: 1px;\n  background-image: linear-gradient(\n    to bottom,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 1px 16px;\n  background-repeat: repeat-y;\n}\n\n/* Horizontal dashed line */\n.dashed-line-horizontal {\n  height: 1px;\n  background-image: linear-gradient(\n    to right,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 16px 1px;\n  background-repeat: repeat-x;\n}\n```\n\n### 11.4 Gradient Masks\n\n```css\n/* Fade edges */\n.fade-left {\n  mask-image: linear-gradient(to right, transparent, black 20%);\n}\n\n.fade-right {\n  mask-image: linear-gradient(to left, transparent, black 20%);\n}\n\n.fade-both {\n  mask-image: linear-gradient(\n    to right,\n    transparent,\n    black 15%,\n    black 85%,\n    transparent\n  );\n}\n```\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border |\n|---------|------------|------|--------|\n| Primary | `#FFFBF5` | `#FF4801` | `#FFFBF5` |\n| Secondary | `#FF4801` | `#FFFBF5` | transparent |\n| Ghost | transparent | `#FF4801` | `#EBD5C1` |\n| Outline | transparent | `#521000` | `#EBD5C1` |\n\n### Cards\n\n| Variant | Background | Border | Shadow |\n|---------|------------|--------|--------|\n| Default | `#FFFDFB` | `#EBD5C1` | shadow-card |\n| Elevated | `#FFFBF5` | `#EBD5C1` | shadow-lg |\n| Interactive | `#FFFDFB` | `#EBD5C1` | hover: shadow-lg |\n\n### Inputs\n\n| State | Border | Shadow |\n|-------|--------|--------|\n| Default | `#EBD5C1` | none |\n| Focus | `#FF4801` | `0 0 0 3px rgba(255,72,1,0.1)` |\n| Error | `#DC2626` | `0 0 0 3px rgba(220,38,38,0.1)` |\n| Disabled | `#EBD5C1` | none, opacity: 0.5 |\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── styles/\n│   ├── tokens.css          # CSS custom properties\n│   ├── base.css            # Reset, typography, global styles\n│   ├── components/\n│   │   ├── buttons.css\n│   │   ├── cards.css\n│   │   ├── forms.css\n│   │   ├── navigation.css\n│   │   └── calculator.css\n│   └── utilities.css       # Helper classes\n├── fonts/\n│   ├── Kunst Grotesk Regular.woff2\n│   ├── Kunst Grotesk Medium.woff2\n│   └── JetBrains Mono Regular.woff2\n└── components/             # React/Vue components\n    ├── Button.tsx\n    ├── Card.tsx\n    ├── Input.tsx\n    └── Calculator/\n        ├── InputPanel.tsx\n        ├── OutputPanel.tsx\n        └── ComparisonBar.tsx\n```\n\n---\n\n## 14. CSS Custom Properties (Full Set)\n\nCopy this into your project's CSS:\n\n```css\n:root {\n  /* Colors - Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.1);\n  \n  /* Colors - Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-text-subtle: rgba(82, 16, 0, 0.38);\n  \n  /* Colors - Backgrounds */\n  --lp-bg-page: #F5F1EB;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Colors - Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Colors - Semantic */\n  --lp-success: #16A34A;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  --lp-info: #2563EB;\n  \n  /* Colors - Product Categories */\n  --lp-compute: #0A95FF;\n  --lp-storage: #EE0DDB;\n  --lp-ai: #19E306;\n  --lp-media: #9616FF;\n  \n  /* Colors - Provider Comparisons */\n  --lp-aws: #FF9900;\n  --lp-gcp: #4285F4;\n  --lp-azure: #0078D4;\n  \n  /* Typography */\n  --font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", monospace;\n  \n  /* Spacing */\n  --spacing-unit: 4px;\n  \n  /* Border Radius */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-xl: 16px;\n  --radius-full: 9999px;\n  \n  /* Shadows */\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);\n  --shadow-card: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02);\n  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n  \n  /* Transitions */\n  --ease-out: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 300ms;\n  \n  /* Containers */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1200px;\n  --container-2xl: 1480px;\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Animation Physics**\n> A specification so precise that a developer could rebuild it without seeing the original.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Grid System\n\n| Aspect | Value | Notes |\n|--------|-------|-------|\n| **Layout Type** | Asymmetrical fluid with max-width constraints | NOT a strict 12-column grid |\n| **Max Container** | `1480px` (homepage / wide marketing) / `1024px` (focused content / docs) | Content centered with `mx-auto` |\n| **Content Width** | `64rem` (1024px) for tools/calculators | Narrower for focused interfaces |\n| **Grid Columns** | 1-col mobile → 2-col tablet → responsive desktop | Uses CSS Grid, not flexbox grids |\n\n#### Spacing Constants (The \"Airiness\")\n\n```\nSection Padding (Y-axis):\n├── Mobile:   32px (py-8)\n├── Tablet:   48px (pt-12)\n└── Desktop:  64px-80px (py-16 to py-20)\n\nGrid Gaps (X-axis):\n├── Card grids:     24px (gap-6)\n├── Form elements:  24px (gap-6)\n├── Tight grids:    12px (gap-3)\n└── Use-case cards: 12px (gap-3)\n\nComponent Internal Padding:\n├── Cards:          24px (p-6) to 32px (p-8)\n├── Buttons:        12px 24px (py-3 px-6)\n├── Inputs:         12px (p-3)\n└── Hero sections:  48px-64px (p-12 to p-16)\n```\n\n#### Density Classification\n\n| Context | Density | Characteristics |\n|---------|---------|-----------------|\n| **Landing pages** | Expressive (Marketing) | Generous whitespace, large text, breathing room |\n| **Calculator tools** | Moderate | Balanced density, functional spacing |\n| **Data tables** | Compact | Tighter padding (py-3 pr-4) |\n\n### 15.2 Color Science & Elevation\n\n#### Primary Palette (Extracted Exact Values)\n\n```css\n/* Core landing-page palette */\n:root {\n  --lp-accent: #ff4801;        /* Primary accent - EXACT */\n  --lp-text: #521000;          /* Primary text - warm brown */\n  --lp-bg-page: #fffbf5;       /* Page background - warm cream */\n  --lp-border: #EBD5C1;        /* Border color */\n}\n\n/* Background Layers (Light Mode) */\n--lp-bg-100: rgb(255, 251, 245);  /* #FFFBF5 - Primary */\n--lp-bg-200: rgb(255, 253, 251);  /* #FFFDFB - Cards/elevated */\n--lp-bg-300: rgb(254, 247, 237);  /* #FEF7ED - Hover states */\n\n/* Text Opacity Variations */\n--lp-text-muted: rgba(82, 16, 0, 0.7);   /* #521000b3 - Secondary */\n--lp-text-subtle: rgba(82, 16, 0, 0.4);  /* #52100066 - Tertiary */\n```\n\n#### Semantic Colors (from components)\n\n| Purpose | Color | Usage Example |\n|---------|-------|---------------|\n| Success | `#16A34A` (green-600) | Savings badges, positive indicators |\n| Success Background | `#DCF7E3` (green-100) | Badge backgrounds |\n| Warning | `#EAB308` | Caution states |\n| Error | `#DC2626` | Error borders, messages |\n| Info | `#2563EB` | Informational highlights |\n\n#### Depth Strategy\n\n**NO Glassmorphism** - The design uses:\n\n1. **Solid backgrounds** with subtle layering (`bg-100` → `bg-200` → `bg-300`)\n2. **Border lines** for separation (1px solid `#EBD5C1`)\n3. **Minimal shadows** - shadows are subtle, not dramatic\n\n```css\n/* Card Shadow (Light/Subtle) */\n--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);\n\n/* Focus Shadow */\nbox-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n\n/* NO backdrop-filter: blur() usage */\n/* NO heavy drop shadows */\n```\n\n### 15.3 Typography & Micro-Copy Specs\n\n#### Type Personality: **Grotesk Sans**\n\nThe typography uses **Inter** - a modern grotesk with humanist touches. Fallback chain:\n```css\nfont-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, \n             Segoe UI, sans-serif, ui-sans-serif, system-ui, sans-serif;\n```\n\n#### Monospace for Code\n```css\nfont-family: JetBrains Mono, ui-monospace, SFMono-Regular, SF Mono, \n             Monaco, Consolas, monospace;\n```\n\n#### Hierarchy Definition\n\n| Level | Size | Weight | Line Height | Letter Spacing | Example |\n|-------|------|--------|-------------|----------------|---------|\n| **h1** | 24px-30px (`text-2xl` to `text-3xl`) | 500 (medium) | 1.2-1.33 | `-0.035em` | \"Pricing\", \"Build faster\" |\n| **h2** | 18px (`text-lg`) | 500 | 1.4 | normal | \"Pricing Details\" |\n| **h3** | 16px (`text-base`) | 500 | 1.5 | normal | Form labels |\n| **p** (body) | 14px-16px (`text-sm` to `text-base`) | 400 | 1.4-1.5 | normal | Descriptions |\n| **p** (muted) | 14px (`text-sm`) | 400 | 1.4 | normal | Secondary info |\n| **small** | 12px (`text-xs`) | 400 | 1.33 | normal | Footnotes, captions |\n\n#### Typography Style: **Tight and Medium Weight**\n\n- **Headings**: Tighter tracking (`letter-spacing: -0.035em`)\n- **Body**: Normal tracking\n- **Weight distribution**: Primarily 400 (regular) and 500 (medium)\n- **NO bold (700)** used in the interfaces\n\n### 15.4 Component Anatomy\n\n#### The \"Radius\" Strategy\n\n| Element | Radius | CSS |\n|---------|--------|-----|\n| **Buttons** | Hyper-rounded | `border-radius: 9999px` (rounded-full) |\n| **Inputs** | Soft | `border-radius: 8px` (rounded-lg) |\n| **Cards** | Sharp | `border-radius: 0` (no rounding) |\n| **Progress bars** | Hyper-rounded | `border-radius: 9999px` |\n| **Dropdowns** | Soft | `border-radius: 8px` |\n| **Badges/Pills** | Hyper-rounded | `border-radius: 9999px` |\n| **Hero sections** | Large soft | `border-radius: 16px` (md:rounded-2xl) |\n\n#### Interactive States\n\n**Hover Effects:**\n```css\n/* Buttons - Dashed border reveal */\n.button:hover {\n  border-style: dashed;\n  opacity: 0.95;\n}\n\n/* Cards - Dashed border */\n.card:hover {\n  border-style: dashed;\n}\n\n/* Links - Underline */\n.link:hover {\n  text-decoration: underline;\n}\n\n/* Background shift */\n.interactive:hover {\n  background-color: var(--lp-bg-300);  /* Warmer cream */\n}\n```\n\n**Active/Press States:**\n```css\nbutton:active {\n  transform: translateY(1px);\n  scale: 0.98;\n}\n```\n\n**Focus States:**\n```css\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus {\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n**NO \"Grow\" effects, \"Glow\" borders, or \"Shimmer\" overlays** - The design is subtle and professional.\n\n#### Corner Brackets (Signature Element)\n\nThe 8px corner bracket decorations are the **signature decorative element** of this design language — they appear at the four outer corners of cards and feature blocks, suggesting precision and crafted technical detail without adding visual noise:\n\n```html\n<!-- Corner bracket structure -->\n<div class=\"pointer-events-none absolute inset-0 z-10 select-none\">\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;left:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;right:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"left:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"right:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n</div>\n```\n\n### 15.5 Animation Physics (The Motion Signature)\n\n#### The Easing Curves\n\n```css\n/* Standard ease-out (most transitions) */\n--ease-standard: cubic-bezier(0, 0, 0.2, 1);  /* Tailwind's ease-out */\n\n/* Button interactions - High-end feel */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press response */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Page entrance - Apple-style smooth deceleration */\n--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n```\n\n#### Duration Scale\n\n| Context | Duration | Usage |\n|---------|----------|-------|\n| Instant feedback | `0.15s` (150ms) | Color changes, opacity |\n| Standard transitions | `0.16s` (160ms) | Button presses |\n| Hover effects | `0.2s` (200ms) | Background color shifts |\n| Complex animations | `0.5s` (500ms) | Progress bars, entrance |\n| Page transitions | `2s` (2000ms) | Background fade-in |\n\n#### Orchestration: **Fade-Slide as Single Block**\n\nThe content does NOT stagger in one-by-one. Instead, entire sections fade and slide together:\n\n```css\n/* Page entrance animation */\n.animate-in {\n  animation: fadeSlideUp 0.5s ease-out forwards;\n}\n\n@keyframes fadeSlideUp {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Background lines fade in slowly */\n.fade-in {\n  transition: opacity 2000ms ease-out;\n  transition-delay: 100ms;\n}\n```\n\n#### Micro-interactions\n\n**Button Press Physics:**\n```css\nbutton {\n  transition: scale 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94),\n              translate 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n}\n\nbutton:active {\n  scale: 0.98;\n  transform: translateY(1px);\n}\n```\n\n**Progress Bar Animation:**\n```css\n.progress-bar {\n  transition: width 0.5s ease-out;\n}\n```\n\n**Slider Thumb:**\n```css\n.slider-thumb {\n  cursor: grab;\n  transition: box-shadow 0.15s ease;\n}\n\n.slider-thumb:active {\n  cursor: grabbing;\n}\n\n.slider-thumb:hover {\n  box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);\n}\n```\n\n**NO \"Magnetic\" follow effects or \"Tilt\" effects** - Interactions are clean and direct.\n\n### 15.6 Technical Deliverables\n\n#### Tailwind Configuration\n\n```javascript\n// tailwind.config.js\nmodule.exports = {\n  theme: {\n    extend: {\n      colors: {\n        'cf': {\n          'orange': '#FF4801',\n          'orange-hover': '#FF7038',\n          'orange-light': 'rgba(255, 72, 1, 0.06)',\n          'text': '#521000',\n          'text-muted': 'rgba(82, 16, 0, 0.7)',\n          'text-subtle': 'rgba(82, 16, 0, 0.4)',\n          'bg-page': '#FFFBF5',\n          'bg-100': '#FFFBF5',\n          'bg-200': '#FFFDFB',\n          'bg-300': '#FEF7ED',\n          'border': '#EBD5C1',\n          'border-light': 'rgba(235, 213, 193, 0.5)',\n        },\n        'aws-orange': '#FF9900',\n        'gcp-blue': '#4285F4',\n      },\n      fontFamily: {\n        sans: ['Inter', '-apple-system', 'system-ui', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],\n        mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Monaco', 'Consolas', 'monospace'],\n      },\n      fontSize: {\n        'sm': ['0.9rem', { lineHeight: '1.4' }],\n        'base': ['1rem', { lineHeight: '1.5' }],\n        'lg': ['1.125rem', { lineHeight: '1.75rem' }],\n        '2xl': ['1.5rem', { lineHeight: '2rem' }],\n        '3xl': ['1.875rem', { lineHeight: '2.25rem' }],\n      },\n      letterSpacing: {\n        'tight-heading': '-0.035em',\n        'logo': '-0.46px',\n      },\n      borderRadius: {\n        'lg': '0.5rem',\n        'xl': '0.75rem',\n        '2xl': '1rem',\n      },\n      transitionTimingFunction: {\n        'button': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n        'active': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)',\n        'entrance': 'cubic-bezier(0.16, 1, 0.3, 1)',\n      },\n      transitionDuration: {\n        '160': '160ms',\n        '2000': '2000ms',\n      },\n      animation: {\n        'float-subtle': 'float-subtle 3s ease-in-out infinite',\n        'dash-draw': 'dashdraw 0.5s linear infinite',\n      },\n      keyframes: {\n        'float-subtle': {\n          '0%, 100%': { transform: 'translateY(0)' },\n          '50%': { transform: 'translateY(-3px)' },\n        },\n        'dashdraw': {\n          '0%': { strokeDashoffset: '10' },\n        },\n      },\n      boxShadow: {\n        'focus': '0 0 0 3px rgba(255, 72, 1, 0.1)',\n        'card': '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',\n      },\n      maxWidth: {\n        '5xl': '64rem',\n        '8xl': '1480px',\n      },\n    },\n  },\n  plugins: [],\n}\n```\n\n#### CSS Variables Block\n\n```css\n:root {\n  /* === COLORS === */\n  /* Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.06);\n  \n  /* Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.7);\n  --lp-text-subtle: rgba(82, 16, 0, 0.4);\n  \n  /* Backgrounds */\n  --lp-bg-page: #FFFBF5;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Semantic */\n  --lp-success: #16A34A;\n  --lp-success-bg: #DCF7E3;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  \n  /* Provider Colors */\n  --aws-orange: #FF9900;\n  --gcp-blue: #4285F4;\n  \n  /* === TYPOGRAPHY === */\n  --font-sans: \"Inter\", -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Monaco, Consolas, monospace;\n  \n  /* === SPACING === */\n  --spacing-unit: 4px;\n  \n  /* === BORDER RADIUS === */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-full: 9999px;\n  \n  /* === SHADOWS === */\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.1);\n  --shadow-card: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);\n  --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);\n  \n  /* === TRANSITIONS === */\n  --ease-standard: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n  --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n  \n  --duration-instant: 100ms;\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 500ms;\n  \n  /* === CONTAINERS === */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1480px;\n}\n\n/* Dark Mode */\n:root.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-page: #0D0D0D;\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-bg-300: #2A2927;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n\n/* Base Styles */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n  margin: 0;\n  background-color: var(--lp-bg-page);\n  color: var(--lp-text);\n  font-family: var(--font-sans);\n  overflow-x: hidden;\n}\n\n/* Focus States */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus,\nselect:focus {\n  border-color: var(--lp-accent);\n  box-shadow: var(--shadow-focus);\n}\n\n/* Transitions */\nbutton,\na,\ninput,\nselect {\n  transition: all 0.2s ease-in-out;\n}\n```\n\n#### Framer Motion Variants\n\n```javascript\n// framer-motion-variants.js\n\n// Page entrance animation\nexport const pageEntrance = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 0.5, \n      ease: [0.16, 1, 0.3, 1] \n    }\n  }\n};\n\n// Section slide-up\nexport const sectionSlideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.4, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Stagger container (for card grids)\nexport const staggerContainer = {\n  animate: {\n    transition: {\n      staggerChildren: 0.08,\n      delayChildren: 0.1\n    }\n  }\n};\n\n// Stagger child item\nexport const staggerItem = {\n  initial: { opacity: 0, y: 16 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.35, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Button hover and tap\nexport const buttonInteraction = {\n  whileHover: { scale: 1.01 },\n  whileTap: { \n    scale: 0.98, \n    y: 1,\n    transition: { \n      duration: 0.16, \n      ease: [0.55, 0.085, 0.68, 0.53] \n    }\n  }\n};\n\n// Card hover\nexport const cardHover = {\n  initial: { scale: 1 },\n  whileHover: { \n    scale: 1.01,\n    transition: { \n      duration: 0.2, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Progress bar fill\nexport const progressFill = {\n  initial: { width: 0 },\n  animate: (width) => ({\n    width: `${width}%`,\n    transition: { \n      duration: 0.5, \n      ease: \"easeOut\" \n    }\n  })\n};\n\n// Floating animation (for icons/decorations)\nexport const floatSubtle = {\n  animate: {\n    y: [0, -3, 0],\n    transition: {\n      duration: 3,\n      ease: \"easeInOut\",\n      repeat: Infinity\n    }\n  }\n};\n\n// Background fade-in (slow entrance)\nexport const backgroundFadeIn = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 2, \n      delay: 0.1,\n      ease: \"easeOut\" \n    }\n  }\n};\n\n// Usage example with React\n/*\nimport { motion } from 'framer-motion';\nimport { pageEntrance, staggerContainer, staggerItem } from './framer-motion-variants';\n\nfunction Page() {\n  return (\n    <motion.main {...pageEntrance}>\n      <motion.div \n        variants={staggerContainer}\n        initial=\"initial\"\n        animate=\"animate\"\n        className=\"grid grid-cols-3 gap-6\"\n      >\n        {items.map((item) => (\n          <motion.div key={item.id} variants={staggerItem}>\n            {item.content}\n          </motion.div>\n        ))}\n      </motion.div>\n    </motion.main>\n  );\n}\n*/\n```\n\n---\n\n*Synthesized from analysis of warm-cream developer-marketing landing pages. The aesthetic — warm cream backgrounds, brown text, single bold accent for CTAs, corner-bracket decorations — is portable to any B2B / developer-tools / dev-adjacent product brand.*\n\n\n\n# ===== SNIPPETS.md =====\n\n# Landing Page Design - Component Snippets\n\n> **Copy-paste ready components** for building warm-cream landing-page interfaces.\n> Each snippet includes React + Tailwind AND Vanilla HTML versions.\n\n---\n\n## Table of Contents\n\n1. [Buttons](#buttons)\n2. [Cards](#cards)\n3. [Forms](#forms)\n4. [Calculator Tools](#calculator-tools)\n5. [Navigation](#navigation)\n6. [Hero Sections](#hero-sections)\n7. [Data Display](#data-display)\n8. [Layout](#layout)\n9. [Decorative](#decorative)\n\n---\n\n# Buttons\n\n## BTN-PRIMARY\n\nPrimary CTA button - cream background, orange text, fully rounded.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFBF5] text-[#FF4801] border border-[#FFFBF5] transition-all duration-150 ease-out hover:bg-transparent hover:border-[#FF4801] active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Get started\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFBF5;\n  color: #FF4801;\n  border: 1px solid #FFFBF5;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.background='transparent'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.background='#FFFBF5'; this.style.borderColor='#FFFBF5';\">\n  Get started\n</button>\n```\n\n---\n\n## BTN-SECONDARY\n\nSecondary button - orange background, white text.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 ease-out hover:opacity-95 hover:border-dashed hover:border-white/50 active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Learn more\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: 1px solid transparent;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.opacity='0.95';\" onmouseout=\"this.style.opacity='1';\">\n  Learn more\n</button>\n```\n\n---\n\n## BTN-GHOST\n\nGhost button - transparent with border, for secondary actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-transparent text-[#FF4801] border border-[#EBD5C1] transition-all duration-150 ease-out hover:border-dashed hover:border-[#FF4801] hover:text-[#521000] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20 disabled:opacity-50 disabled:cursor-not-allowed\">\n  View docs\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: transparent;\n  color: #FF4801;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.borderStyle='dashed'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.borderStyle='solid'; this.style.borderColor='#EBD5C1';\">\n  View docs\n</button>\n```\n\n---\n\n## BTN-OUTLINE\n\nOutline button - for less prominent actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:border-dashed active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#EBD5C1]/50 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Cancel\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\">\n  Cancel\n</button>\n```\n\n---\n\n## BTN-ICON\n\nIcon-only button with tooltip.\n\n### React + Tailwind\n\n```jsx\n<button \n  className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:text-[#FF4801] hover:border-[#FF4801] active:scale-[0.95] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20\"\n  aria-label=\"Settings\"\n  title=\"Settings\"\n>\n  <svg className=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={1.5}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 40px;\n  height: 40px;\n  border-radius: 9999px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" aria-label=\"Settings\" title=\"Settings\">\n  <svg width=\"20\" height=\"20\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n---\n\n## BTN-LINK\n\nLink styled as text with arrow.\n\n### React + Tailwind\n\n```jsx\n<a href=\"#\" className=\"inline-flex items-center gap-1 font-medium text-[#FF4801] hover:underline hover:underline-offset-4 transition-all duration-150\">\n  View documentation\n  <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n### Vanilla HTML\n\n```html\n<a href=\"#\" style=\"\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  color: #FF4801;\n  text-decoration: none;\n  transition: all 0.15s ease;\n\">\n  View documentation\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n---\n\n## BTN-LOADING\n\nButton with loading spinner state.\n\n### React + Tailwind\n\n```jsx\n<button \n  disabled\n  className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 disabled:opacity-70 disabled:cursor-not-allowed\"\n>\n  <svg className=\"animate-spin h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n    <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n    <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button disabled style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: none;\n  opacity: 0.7;\n  cursor: not-allowed;\n\">\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" style=\"animation: spin 1s linear infinite;\">\n    <circle opacity=\"0.25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\" />\n    <path opacity=\"0.75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n<style>\n  @keyframes spin { to { transform: rotate(360deg); } }\n</style>\n```\n\n---\n\n# Cards\n\n## CARD-DEFAULT\n\nStandard card with corner bracket decorations.\n\n### React + Tailwind\n\n```jsx\n<div className=\"relative bg-[#FFFDFB] border border-[#EBD5C1] p-6 shadow-[0_1px_3px_rgba(82,16,0,0.04),0_4px_12px_rgba(82,16,0,0.02)]\">\n  {/* Corner brackets */}\n  <div className=\"absolute -top-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -top-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  \n  <h3 className=\"text-lg font-medium text-[#521000] mb-2\">Card Title</h3>\n  <p className=\"text-sm text-[#521000]/60 leading-relaxed\">\n    Card description goes here. This is a standard card with the signature corner bracket decorations.\n  </p>\n</div>\n```\n\n### Vanilla HTML\n\n```html\n<div \n\n<!-- [TRUNCATED AT 50KB] -->","worker/agents/think/skills/frontend-design-saas/SKILL.md":"---\nname: frontend-design-saas\ndescription: S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear.\n---\n\n# SaaS Design System (Stripe / Linear / Vercel lineage)\n\n> **AI-Optimized Design Reference** for building S-tier SaaS dashboards, admin consoles, billing pages, settings UIs, data-dense application shells, and product surfaces.\n>\n> Tonal anchors: `stripe.com/dashboard`, `linear.app`, `vercel.com/dashboard`, `notion.so/teamspace`, `clerk.com`, `planetscale.com/console`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #5E6AD2 (indigo) - replace with product brand\nNeutrals:        #FFFFFF → #F8FAFC → #F1F5F9 → #E2E8F0 → #94A3B8 → #475569 → #0F172A\nText:            #0F172A (primary) / #475569 (muted) / #94A3B8 (subtle)\nBorder:          #E2E8F0 (default) / #CBD5E1 (strong)\nFont Sans:       \"Inter\", \"Geist\", system-ui (NOT Arial, NOT Roboto)\nFont Mono:       \"JetBrains Mono\", \"Geist Mono\", ui-monospace\nBase Spacing:    4px (scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64)\nBorder Radius:   Buttons/inputs = 6-8px (NOT pill), Cards = 8-12px, Modals = 12-16px\nShadow:          Tight, low-spread, single-direction (NOT diffuse marketing shadows)\nDensity:         Compact-to-comfortable; row heights 36-44px, button heights 32-36px\n```\n\nThe fastest way to get this wrong: use marketing-page aesthetics (huge type, generous whitespace, pill buttons, gradient hero shadows) in a product surface. SaaS UIs are **dense, neutral, fast, and quiet**. The brand color appears in <5% of the pixels and earns its weight by marking only what's interactive or active.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Users First** | Workflows, keyboard navigation, and information density beat decoration. |\n| **Quiet by Default** | Neutrals dominate; the brand color is reserved for primary action, active state, and focus. |\n| **Speed & Density** | Snappy transitions (≤200ms), compact row heights, no entrance animations on every page load. |\n| **Opinionated Defaults** | Single canonical pattern per surface (one button hierarchy, one date picker, one empty state). |\n| **Meticulous Craft** | 1px borders aligned to pixel grid, focus rings 2px offset 2px, motion uses ease-out not linear. |\n| **Accessible (WCAG AA+)** | 4.5:1 text contrast, 3:1 UI element contrast, every interactive surface keyboard-reachable. |\n| **Predictable** | Same component looks/behaves identically on every page; no marketing-grade variants. |\n\n### Visual Identity Rules\n\n- **Do not use pure black on pure white.** Use `#0F172A` on `#FFFFFF` (or `#0B1220` on `#0A0E1A` in dark mode).\n- **Brand color is the accent, never the dominant.** If the brand color fills more than ~5% of the viewport in steady state, something is wrong.\n- **Avoid pill buttons** (`border-radius: 9999px`) for SaaS actions. Pills read as marketing/consumer; SaaS uses `6-8px` radius for buttons and inputs.\n- **No glassmorphism, no blur, no gradient borders.** The aesthetic is precision, not richness.\n- **Shadows are tight and downward.** Not diffuse, not multi-direction. Reserved for floating layers (popovers, modals, dropdowns).\n- **One font.** Inter / Geist / IBM Plex Sans / native system stack. Avoid mixing display + body fonts; SaaS uses a single workhorse sans.\n- **Mono for IDs, code, tokens, money.** Tabular figures (`font-variant-numeric: tabular-nums`) for any column of numbers.\n\n### Reference apps to mentally calibrate against\n\n| Surface to build | Look at |\n|---|---|\n| Application shell | Linear, Vercel, Stripe Dashboard |\n| Data table | Stripe `/payments`, Linear backlog, PlanetScale branches |\n| Settings page | Vercel project settings, Clerk dashboard, GitHub repo settings |\n| Billing / invoice | Stripe billing, Vercel usage |\n| Empty state | Linear, Notion, Vercel deployments |\n| Command palette | Linear ⌘K, Vercel ⌘K, Raycast |\n| Auth | Clerk hosted pages, Stripe Connect onboarding |\n| Onboarding checklist | Stripe Atlas, Vercel project import |\n\n---\n\n## 2. Color System\n\n### 2.1 Neutral Scale (the backbone)\n\nSaaS UIs are 80–90% neutrals. The neutral scale carries the entire interface; brand is sprinkled on top. Use a slightly cool neutral (slate) by default — it reads more \"product\" than warm grays which feel marketing/lifestyle.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--neutral-0` | `#FFFFFF` | Page background (light mode), surface lifted onto canvas |\n| `--neutral-50` | `#F8FAFC` | Canvas background, alternating row stripe |\n| `--neutral-100` | `#F1F5F9` | Hover background, subtle fill, table header |\n| `--neutral-200` | `#E2E8F0` | **Default border**, divider |\n| `--neutral-300` | `#CBD5E1` | Strong border, input border on focus-within parent |\n| `--neutral-400` | `#94A3B8` | **Subtle text** (placeholders, disabled, captions) |\n| `--neutral-500` | `#64748B` | Tertiary text, icon default |\n| `--neutral-600` | `#475569` | **Muted text** (descriptions, secondary labels) |\n| `--neutral-700` | `#334155` | Strong icon, hover text |\n| `--neutral-800` | `#1E293B` | Heading in dense UIs, code text |\n| `--neutral-900` | `#0F172A` | **Primary text**, primary icons |\n| `--neutral-950` | `#020617` | Highest contrast (rare; only for critical labels) |\n\n### 2.2 Brand Accent (single color, used sparingly)\n\nPick **one** accent. Use a 6-step ramp so hover/active/focused/disabled all derive from the same hue. Defaults below use indigo (`#5E6AD2`, Linear's accent) — swap the hue for your brand but keep the structure.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--brand-50` | `#EEF0FB` | Selected row background, badge background, focus ring background |\n| `--brand-100` | `#DDE0F7` | Hover on brand surfaces |\n| `--brand-200` | `#B9C0EF` | Disabled brand button, decorative |\n| `--brand-500` | `#7B85DC` | Brand hover surface stroke |\n| `--brand-600` | `#5E6AD2` | **Primary brand** — default button bg, link text, focus ring, active nav item |\n| `--brand-700` | `#4A56C0` | Hover state for primary button |\n| `--brand-800` | `#3A45A8` | Active/pressed state |\n| `--brand-900` | `#2A3284` | High-contrast brand text on light background |\n\n### 2.3 Semantic / Status Colors\n\nUsed for state — never for decoration. Each has a `text`, `bg`, `border` triplet so badges and inline messages stay legible.\n\n| State | `text` | `bg` (filled) | `bg-subtle` (badge) | `border` |\n|---|---|---|---|---|\n| **Success** | `#15803D` | `#16A34A` | `#DCFCE7` | `#86EFAC` |\n| **Warning** | `#A16207` | `#CA8A04` | `#FEF9C3` | `#FDE68A` |\n| **Error / Destructive** | `#B91C1C` | `#DC2626` | `#FEE2E2` | `#FCA5A5` |\n| **Info** | `#1D4ED8` | `#2563EB` | `#DBEAFE` | `#93C5FD` |\n| **Neutral (default badge)** | `#334155` | `#64748B` | `#F1F5F9` | `#CBD5E1` |\n\nRules:\n- Inline form errors: `text` color on `bg-subtle` background, `border` for the input.\n- Toasts: `bg-subtle` panel, `text` heading, `--neutral-700` body.\n- Status badges: `text` on `bg-subtle` + 1px `border`. Never use solid `bg` for badges (too loud).\n\n### 2.4 Data Visualization Palette\n\nWhen you need >1 color for charts. Categorical — visually distinct, similar luminance. Avoid the brand color in this palette so brand reads as \"interactive\" elsewhere.\n\n```\n--chart-1: #2563EB  (blue)\n--chart-2: #16A34A  (green)\n--chart-3: #CA8A04  (amber)\n--chart-4: #DC2626  (red)\n--chart-5: #9333EA  (violet)\n--chart-6: #0891B2  (cyan)\n--chart-7: #DB2777  (pink)\n--chart-8: #65A30D  (lime)\n```\n\n### 2.5 Dark Mode Mapping\n\nSaaS dark modes are **not just inverted**. They are slightly desaturated, slightly lifted, with carefully tuned elevation. Use `#0A0E1A` (or `#0B0F1C`) as the canvas — NEVER `#000000`. Pure black creates harsh contrast and exposes any banding in shadows.\n\n| Token | Light | Dark |\n|---|---|---|\n| `--bg-canvas` | `#F8FAFC` | `#0A0E1A` |\n| `--bg-surface` | `#FFFFFF` | `#111827` |\n| `--bg-elevated` | `#FFFFFF` | `#1F2937` |\n| `--bg-overlay` (popover/modal) | `#FFFFFF` | `#1F2937` |\n| `--text-primary` | `#0F172A` | `#F1F5F9` |\n| `--text-muted` | `#475569` | `#94A3B8` |\n| `--text-subtle` | `#94A3B8` | `#64748B` |\n| `--border-default` | `#E2E8F0` | `#1F2937` |\n| `--border-strong` | `#CBD5E1` | `#374151` |\n| `--brand-bg-subtle` | `#EEF0FB` | `rgba(94, 106, 210, 0.16)` |\n| `--brand-fg` | `#5E6AD2` | `#A5B4FC` |\n\nIn dark mode the **brand color shifts lighter** (toward `--brand-500/400` instead of `--brand-600/700`) to maintain contrast against the dark canvas.\n\n---\n\n## 3. Typography\n\n### 3.1 Font Stack\n\n```css\n--font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n             \"SF Pro Text\", system-ui, sans-serif;\n--font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n             ui-monospace, monospace;\n```\n\nHard rules:\n- **Never use `Arial`, `Helvetica`, `Roboto`, or `Times`** in a SaaS UI. These read as default/unstyled. If you can't load a webfont, fall through to `system-ui` instead.\n- **Never mix two display fonts.** One sans + one mono is the entire stack.\n- **`font-feature-settings: \"cv11\", \"ss01\", \"ss03\"`** for Inter (or whatever stylistic sets your font has) — small caps and alternate forms make tables look intentional.\n- **`font-variant-numeric: tabular-nums`** on EVERY column that contains numbers (money, dates, IDs, counts). Non-tabular figures make rows shimmer.\n\n### 3.2 Type Scale (SaaS-tuned — denser than marketing)\n\n| Token | Size | Line Height | Weight | Letter Spacing | Use |\n|---|---|---|---|---|---|\n| `text-2xs` | 11px / 0.6875rem | 1.36 (15px) | 500 | 0.04em | Microlabels (uppercase metadata, table column tags) |\n| `text-xs` | 12px / 0.75rem | 1.33 (16px) | 400/500 | 0 | Captions, badge text, table secondary |\n| `text-sm` | 13px / 0.8125rem | 1.38 (18px) | 400/500 | 0 | **Default UI text**: table rows, buttons, form labels |\n| `text-base` | 14px / 0.875rem | 1.43 (20px) | 400/500 | 0 | Body text, paragraph copy in panels |\n| `text-md` | 15px / 0.9375rem | 1.47 (22px) | 400/500 | 0 | Comfortable reading (descriptions, modals) |\n| `text-lg` | 16px / 1rem | 1.5 (24px) | 500/600 | -0.005em | Card headings, sidebar section labels |\n| `text-xl` | 18px / 1.125rem | 1.44 (26px) | 600 | -0.01em | Subsection headings |\n| `text-2xl` | 20px / 1.25rem | 1.4 (28px) | 600 | -0.015em | Page subheadings |\n| `text-3xl` | 24px / 1.5rem | 1.33 (32px) | 600 | -0.02em | Page title (h1 in dashboards) |\n| `text-4xl` | 30px / 1.875rem | 1.2 (36px) | 600/700 | -0.025em | Marketing-adjacent hero in settings |\n\n**Critical**: the default UI size in SaaS is **13px or 14px**, NOT 16px. Stripe is 14px. Linear is 13px. Vercel is 14px. 16px feels too big and creates pages that scroll forever.\n\n### 3.3 Weights\n\n| Weight | Value | Use |\n|---|---|---|\n| Regular | 400 | Body, table cells, descriptions |\n| Medium | 500 | Default for buttons, labels, table headers, navigation, badge text |\n| Semibold | 600 | Headings, emphasized stats, active nav |\n| Bold | 700 | Rare — only marketing-adjacent surfaces or KPI numbers |\n\n**Never** use weight 800 or 900 in a SaaS UI.\n\n### 3.4 Letter Spacing\n\n| Context | Value |\n|---|---|\n| Body / table | 0 |\n| Headings ≥18px | -0.01em to -0.025em (tighter as size grows) |\n| Microlabels / uppercase | 0.04em to 0.06em |\n| Mono | 0 |\n\n### 3.5 Text Rendering\n\n```css\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1;\n}\n\n.numeric, table, [data-numeric] {\n  font-variant-numeric: tabular-nums;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--space-unit: 4px;\n```\n\n### 4.2 Scale\n\n| Token | px | rem | Common Use |\n|---|---|---|---|\n| `space-0` | 0 | 0 | Reset |\n| `space-px` | 1 | — | Hairlines, dividers |\n| `space-0.5` | 2 | 0.125 | Tight icon-to-text gaps |\n| `space-1` | 4 | 0.25 | Inline tight |\n| `space-1.5` | 6 | 0.375 | — |\n| `space-2` | 8 | 0.5 | Default tight gap, icon-text, badge padding |\n| `space-2.5` | 10 | 0.625 | Input vertical padding (compact) |\n| `space-3` | 12 | 0.75 | Input vertical padding (default), small card |\n| `space-4` | 16 | 1 | **Default card/panel padding**, form field gaps |\n| `space-5` | 20 | 1.25 | Section subheading gap |\n| `space-6` | 24 | 1.5 | Card padding (comfortable), section internal |\n| `space-8` | 32 | 2 | Page section gap, large card padding |\n| `space-10` | 40 | 2.5 | Major section gap |\n| `space-12` | 48 | 3 | Page-level section gap |\n| `space-16` | 64 | 4 | Hero, max page-section |\n\n**Density convention** (which scale value to default to):\n\n- Buttons: `8px 12px` (sm), `8px 14px` (md), `10px 16px` (lg) — vertical padding stays tight\n- Inputs: `8px 12px` (sm), `9px 12px` (md), `10px 14px` (lg)\n- Cards: `16px` (sm), `20-24px` (md), `32px` (lg / settings panel)\n- Table cells: `10px 16px` (compact), `12px 16px` (default), `16px 20px` (comfortable)\n- Page padding: `24px` (mobile) → `32px` (desktop) → `48px` (wide settings)\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Use |\n|---|---|---|\n| `--radius-none` | 0 | Table cells, full-bleed sections |\n| `--radius-sm` | 4px | Badges, tags, small chips |\n| `--radius-md` | 6px | **Default**: buttons, inputs, dropdowns, menu items |\n| `--radius-lg` | 8px | Cards, panels, popovers |\n| `--radius-xl` | 12px | Modals, large surfaces, sheets |\n| `--radius-2xl` | 16px | Marketing-adjacent (pricing card, onboarding) |\n| `--radius-full` | 9999px | Avatars, status dots, **NOT buttons** |\n\nRules:\n- **Buttons in SaaS UIs use 6px, not pill (`9999px`).** Pills are for marketing/consumer apps. The exception is a \"tag\" or \"chip\" inside a table cell.\n- **Match input and button radius.** If button = 6px, input = 6px. Visual consistency in form rows.\n- **Cards never share radius with modals.** Cards = 8px, modals = 12px+. This is how the modal reads as a different elevation layer.\n\n---\n\n## 6. Shadow System (Elevation)\n\nSaaS shadows are **tight, downward, single-direction**. They mark elevation; they do not decorate. No glows, no colored shadows, no inset highlights, no large spread radii.\n\n### 6.1 Elevation Tokens\n\n```css\n/* Light mode */\n--shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n--shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05),\n              0 1px 3px 0 rgba(15, 23, 42, 0.06);\n--shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06),\n              0 4px 6px -2px rgba(15, 23, 42, 0.04);\n--shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05),\n              0 10px 15px -3px rgba(15, 23, 42, 0.08);\n--shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06),\n              0 20px 25px -5px rgba(15, 23, 42, 0.10);\n--shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n\n/* Focus ring (NOT a shadow, but the only \"glow\" allowed) */\n--ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n--ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n/* Dark mode: shadows almost invisible; elevation is conveyed via background lightening */\n--shadow-xs-dark:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n--shadow-sm-dark:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n--shadow-md-dark:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n--shadow-lg-dark:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n```\n\n### 6.2 Elevation → Component Mapping\n\n| Layer | Shadow | Examples |\n|---|---|---|\n| 0 (flat on canvas) | none | Sidebar, page body, inline rows |\n| 1 (resting card) | `--shadow-xs` or none + 1px border | Default card on `--bg-canvas` |\n| 2 (hovered card) | `--shadow-sm` | Card with `:hover` state |\n| 3 (dropdown, popover) | `--shadow-md` | Menu, popover, select dropdown |\n| 4 (modal, dialog) | `--shadow-xl` | Dialog, slide-over |\n| 5 (toast over modal) | `--shadow-2xl` | Toast, command palette over modal backdrop |\n\n### 6.3 Border-vs-Shadow Decision\n\nFor most cards, **prefer a 1px border over a shadow**. Borders are crisper at any DPI and read more \"product\". Use shadow only when:\n- The element is floating over content (popover, modal, dropdown, toast)\n- A drag-and-drop ghost is rendered\n- A row is \"lifted\" to indicate selection while dragging\n\n---\n\n## 7. Animation System\n\n### 7.1 Principle\n\nSaaS motion is **fast, ease-out, and only on user-initiated events**. No entrance animations on page mount, no decorative parallax, no stagger on every grid. Motion's job is to confirm an action happened, not to entertain.\n\n### 7.2 Easing\n\n```css\n/* Default — used for most state transitions */\n--ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out, snappy */\n\n/* For things that recoil (modal entrance, popover) */\n--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);\n\n/* Press / active (deceleration into rest) */\n--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n/* Linear — for indeterminate progress / spinners only */\n--ease-linear: linear;\n```\n\n**Do NOT use** `ease`, `ease-in`, or unspecified defaults. They feel sluggish in SaaS contexts.\n\n### 7.3 Duration Scale\n\n| Token | ms | Use |\n|---|---|---|\n| `--duration-75` | 75 | Color change, opacity flip (button press color) |\n| `--duration-100` | 100 | Hover background fill |\n| `--duration-150` | 150 | **Default** — most transitions |\n| `--duration-200` | 200 | Dropdown/popover enter |\n| `--duration-250` | 250 | Modal/dialog enter |\n| `--duration-300` | 300 | Slide-over (drawer) enter |\n| `--duration-500` | 500 | Skeleton shimmer cycle |\n\n**Anything longer than 300ms in a product UI is wrong.** Marketing pages can take more time; product UIs cannot.\n\n### 7.4 Standard Transitions\n\n```css\n/* Color/border (default for all interactive elements) */\nbutton, a, input, select, textarea, [role=\"button\"] {\n  transition:\n    background-color var(--duration-150) var(--ease-out),\n    border-color var(--duration-150) var(--ease-out),\n    color var(--duration-150) var(--ease-out),\n    box-shadow var(--duration-150) var(--ease-out);\n}\n\n/* Modal/dialog */\n.dialog-enter { animation: dialog-enter 250ms var(--ease-out-back); }\n@keyframes dialog-enter {\n  from { opacity: 0; transform: translateY(8px) scale(0.98); }\n  to   { opacity: 1; transform: translateY(0) scale(1); }\n}\n\n/* Popover/dropdown */\n.popover-enter { animation: popover-enter 150ms var(--ease-out); }\n@keyframes popover-enter {\n  from { opacity: 0; transform: translateY(-4px); }\n  to   { opacity: 1; transform: translateY(0); }\n}\n\n/* Toast slide-in */\n.toast-enter { animation: toast-enter 200ms var(--ease-out); }\n@keyframes toast-enter {\n  from { opacity: 0; transform: translateX(8px); }\n  to   { opacity: 1; transform: translateX(0); }\n}\n\n/* Skeleton shimmer */\n.skeleton {\n  background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);\n  background-size: 200% 100%;\n  animation: shimmer 1.5s ease-in-out infinite;\n}\n@keyframes shimmer {\n  from { background-position: 200% 0; }\n  to   { background-position: -200% 0; }\n}\n```\n\n### 7.5 What NOT to Animate\n\n- **Page mount.** Render synchronously. The page is the priority, not the motion.\n- **Sidebar nav items individually staggering in.** Render the whole nav at once.\n- **Tab content fade.** Switching tabs is an instant context change; fade hides the change.\n- **Table rows.** A 1000-row table that fades in row-by-row is unusable.\n\n### 7.6 What SHOULD Animate\n\n- Button hover (background-color 100ms)\n- Focus ring (box-shadow 150ms)\n- Modal/dialog enter and exit (250ms)\n- Popover/dropdown enter (150ms)\n- Toast slide-in (200ms)\n- Skeleton shimmer (loading states)\n- Accordion expand/collapse (height 200ms)\n- Optimistic UI confirmations (checkmark scale-in 150ms)\n\n### 7.7 `prefers-reduced-motion`\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n```\n\nAlways include. SaaS users may have vestibular sensitivity or use the OS-level reduce-motion setting.\n\n---\n\n## 8. Layout System\n\n### 8.1 The Canonical SaaS Shell\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ [Topbar: workspace switcher | search ⌘K | user menu] 48px       │\n├──────────────┬──────────────────────────────────────────────────┤\n│              │                                                  │\n│              │   Page header                                    │\n│              │   ┌────────────────────────────────────────────┐ │\n│              │   │ Title             [primary action] [⋯ menu]│ │\n│   Sidebar    │   │ Description / breadcrumb                   │ │\n│   (240-     │   └────────────────────────────────────────────┘ │\n│    280px)    │                                                  │\n│              │   Page body (cards, tables, settings)            │\n│   Nav items  │                                                  │\n│   Sections   │   ┌──────────────────────────────────────────┐   │\n│   Footer     │   │  Card / Panel                            │   │\n│              │   └──────────────────────────────────────────┘   │\n│              │                                                  │\n└──────────────┴──────────────────────────────────────────────────┘\n```\n\nThis is the Stripe/Linear/Vercel/Notion layout. Three regions:\n\n1. **Topbar** (48–56px): workspace/team switcher on the left, global search (`⌘K`) center or right, user menu on far right. Stays fixed during scroll.\n2. **Sidebar** (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.\n3. **Content** (remaining width, max ~1200px centered for readability OR full-width for tables): page header → page body.\n\nVariations:\n- **Linear-style** has no topbar — the sidebar takes the whole left edge, search lives inside the content header.\n- **Notion-style** has only a sidebar, page-level breadcrumbs at top of content.\n- **Settings-style** uses a *secondary* sidebar (settings nav) inside the content area, so two sidebars are visible.\n\n### 8.2 Container Widths\n\n| Token | px | Use |\n|---|---|---|\n| `--container-sm` | 640 | Single-column forms, auth, dialogs |\n| `--container-md` | 768 | Settings forms, account pages |\n| `--container-lg` | 1024 | Dashboard pages, default content max |\n| `--container-xl` | 1280 | Wide dashboards with sidebars-in-content |\n| `--container-2xl` | 1536 | Data tables, monitoring views (full-bleed) |\n\n**Tables get `--container-2xl` or full-bleed.** Settings forms get `--container-md` (max ~720px reading column).\n\n### 8.3 Breakpoints\n\n| Name | min-width | Behavior |\n|---|---|---|\n| `sm` | 640px | Form inputs reach full width on phones |\n| `md` | 768px | Two-column form layouts kick in |\n| `lg` | 1024px | Sidebar appears (below this, sidebar becomes a sheet/drawer) |\n| `xl` | 1280px | Sidebar stays expanded; data tables show all columns |\n| `2xl` | 1536px | Multi-column dashboards |\n\nMobile rule: the sidebar collapses to a hamburger that opens a full-height sheet. The topbar's search and user menu remain.\n\n### 8.4 Grid Patterns\n\n```css\n/* Page header — title left, actions right */\n.page-header {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 16px;\n  margin-bottom: 24px;\n}\n\n/* Two-column form (label left, input right) — settings page idiom */\n.form-row {\n  display: grid;\n  grid-template-columns: minmax(0, 240px) minmax(0, 1fr);\n  gap: 24px;\n  padding: 20px 0;\n  border-bottom: 1px solid var(--border-default);\n}\n.form-row:last-child { border-bottom: none; }\n.form-row__label { font-size: 13px; font-weight: 500; color: var(--text-primary); }\n.form-row__hint  { font-size: 12px; color: var(--text-muted); margin-top: 4px; }\n\n/* Card grid (3-col on desktop, 2 on tablet, 1 on mobile) */\n.card-grid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: 1fr;\n}\n@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }\n@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }\n\n/* KPI strip — auto-fit, never wider than 280, never narrower than 200 */\n.kpi-strip {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n  gap: 12px;\n}\n```\n\n### 8.5 Sidebar Anatomy\n\n```\n[Workspace switcher]      32px row, hover bg, dropdown chevron\n─────────────────────\nSECTION LABEL             11px uppercase, --neutral-500, 8px y-padding\n  [icon] Item 1           36px row, 13px text, 8px gap, hover bg-100\n  [icon] Item 2           selected: bg-brand-50 + brand-700 text\n  [icon] Item 3\n─────────────────────\nSECTION LABEL\n  [icon] Item 4\n─────────────────────\n                          (stretches to fill)\n[avatar] user@email       Bottom; opens user menu upward\n```\n\nKey spec:\n- Sidebar bg: `--bg-canvas` (slightly tinted from page surface) OR `--bg-surface` flat\n- Nav items: 36px tall, 8px horizontal padding, 6px radius, gap 8px between icon + text\n- Active item: `bg: var(--brand-50)`, `color: var(--brand-700)`, **no border, no left-accent stripe** (Linear-style minimal)\n- Section labels: `text-2xs` uppercase, `--neutral-500`, 8px y-padding\n- Width: 256px default, 56px collapsed (icons only)\n\n---\n\n## 9. Dark Mode\n\n### 9.1 Detection\n\n```html\n<script>\n  (function() {\n    var stored = localStorage.getItem('theme');\n    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n    var dark = stored === 'dark' || (stored !== 'light' && prefersDark);\n    document.documentElement.classList.toggle('dark', dark);\n  })();\n</script>\n```\n\nInline in `<head>` BEFORE any stylesheets to prevent flash-of-light-theme on dark-preferred users.\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --bg-canvas: #F8FAFC;\n  --bg-surface: #FFFFFF;\n  --bg-elevated: #FFFFFF;\n  --text-primary: #0F172A;\n  --text-muted: #475569;\n  --text-subtle: #94A3B8;\n  --border-default: #E2E8F0;\n  --border-strong: #CBD5E1;\n  --brand-fg: #5E6AD2;\n  --brand-bg-subtle: #EEF0FB;\n}\n\n.dark {\n  --bg-canvas: #0A0E1A;\n  --bg-surface: #111827;\n  --bg-elevated: #1F2937;\n  --text-primary: #F1F5F9;\n  --text-muted: #94A3B8;\n  --text-subtle: #64748B;\n  --border-default: #1F2937;\n  --border-strong: #374151;\n  --brand-fg: #A5B4FC;\n  --brand-bg-subtle: rgba(94, 106, 210, 0.16);\n}\n```\n\n### 9.3 Three-Way Theme Toggle\n\nAlways offer `system | light | dark`. The user expects to match OS preference by default but be able to override.\n\n```javascript\nfunction setTheme(mode /* 'system' | 'light' | 'dark' */) {\n  if (mode === 'system') {\n    localStorage.removeItem('theme');\n    const dark = matchMedia('(prefers-color-scheme: dark)').matches;\n    document.documentElement.classList.toggle('dark', dark);\n  } else {\n    localStorage.setItem('theme', mode);\n    document.documentElement.classList.toggle('dark', mode === 'dark');\n  }\n}\n\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {\n  if (!localStorage.getItem('theme')) {\n    document.documentElement.classList.toggle('dark', e.matches);\n  }\n});\n```\n\n### 9.4 Dark-Mode-Specific Rules\n\n- **Borders go invisible.** In dark mode, a 1px border with `rgba(255, 255, 255, 0.08)` reads cleaner than the inverted neutral. Use a slightly translucent border for surfaces, solid `--border-default` for input fields.\n- **No drop shadows.** Or, dramatically reduce them. In dark mode, elevation is conveyed by *lighter* surface backgrounds, not by darker shadows underneath.\n- **Brand color shifts lighter.** `--brand-fg` becomes `#A5B4FC` (brand-300/400 range) so it has enough contrast against the dark canvas.\n- **Image / chart luminance.** If you render charts/screenshots inside a card, the card background should be `--bg-elevated` (lighter than canvas) so the artwork doesn't appear floating on void.\n\n---\n\n## 10. Accessibility\n\n### 10.1 Color Contrast\n\nMinimum ratios:\n- **Body text on background: 4.5:1** (WCAG AA Normal Text)\n- **Large text (≥18px or 14px bold) on background: 3:1**\n- **UI components (button border, input border, focus ring) against adjacent color: 3:1**\n- **Non-text indicators (icons that convey state): 3:1**\n\nVerify the included palette:\n- `#0F172A` on `#FFFFFF` = 19.3:1 ✓\n- `#475569` (muted) on `#FFFFFF` = 7.5:1 ✓\n- `#94A3B8` (subtle) on `#FFFFFF` = 3.6:1 ✓ (large text / decorative only)\n- `#5E6AD2` (brand) on `#FFFFFF` = 5.7:1 ✓\n- `#FFFFFF` on `#5E6AD2` (brand button) = 5.7:1 ✓\n\n### 10.2 Focus States\n\n```css\n/* Default focus ring for all interactive elements */\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n  border-radius: var(--radius-sm);\n}\n\n/* For elements with their own visual treatment (buttons, inputs), use box-shadow ring */\nbutton:focus-visible,\n[role=\"button\"]:focus-visible,\na:focus-visible {\n  outline: none;\n  box-shadow: var(--ring-focus);\n}\n\ninput:focus-visible,\ntextarea:focus-visible,\nselect:focus-visible {\n  outline: none;\n  border-color: var(--brand-fg);\n  box-shadow: var(--ring-focus);\n}\n```\n\n**Never remove the focus ring without replacing it.** This is the single most common SaaS accessibility regression.\n\n### 10.3 Keyboard Map (every SaaS app should implement)\n\n| Key | Action |\n|---|---|\n| `⌘K` / `Ctrl+K` | Open command palette |\n| `/` | Focus the page search input |\n| `g` then `d` | Go to dashboard (vim-style nav, optional but Linear/GitHub do it) |\n| `?` | Show keyboard shortcut cheat sheet |\n| `Esc` | Close modal, dismiss popover, blur input |\n| `Tab` / `Shift+Tab` | Move focus forward/back |\n| `Enter` | Activate focused button/link; submit form when input focused |\n| `Space` | Toggle checkbox/switch; activate button |\n| `↑` / `↓` | Navigate list items, table rows, menu items |\n| `j` / `k` | Same as ↓ / ↑ (vim-style; optional) |\n\n### 10.4 Semantic Patterns\n\n- Buttons use `<button>`, links use `<a href>`. **Never** use a `<div onClick>` for either.\n- Form fields have a visible `<label>` (or `aria-label` if visually replaced by a placeholder pattern).\n- Required fields: visible `*` marker AND `aria-required=\"true\"`.\n- Error messages: linked via `aria-describedby`, role `alert` for the first error.\n- Tables: `<th scope=\"col\">` headers, optional `<caption>` for context.\n- Modals: trap focus, restore focus to trigger on close, `aria-modal=\"true\"`, `role=\"dialog\"`, labeled by heading.\n- Toasts: `role=\"status\"` for non-critical, `role=\"alert\"` for errors.\n\n### 10.5 Touch Targets\n\nMinimum 44×44px (WCAG 2.5.5). On desktop SaaS, buttons can be 32-36px tall as long as the **clickable hit area** (via padding) reaches 44px.\n\n```css\n.icon-button {\n  position: relative;\n  width: 32px;\n  height: 32px;\n}\n.icon-button::before {\n  content: '';\n  position: absolute;\n  inset: -6px;  /* extends hit area to 44x44 */\n}\n```\n\n### 10.6 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n.sr-only-focusable:focus {\n  position: static;\n  width: auto;\n  height: auto;\n  clip: auto;\n  white-space: normal;\n}\n```\n\nUse `.sr-only` for \"Skip to main content\" links and icon-only button labels.\n\n---\n\n## 11. Decorative Elements (Used Sparingly)\n\nSaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:\n\n### 11.1 Section Dividers\n\n```css\n.divider {\n  height: 1px;\n  background: var(--border-default);\n  margin: 24px 0;\n}\n\n/* Vertical divider (e.g., between toolbar groups) */\n.divider-v {\n  width: 1px;\n  background: var(--border-default);\n  align-self: stretch;\n  margin: 0 8px;\n}\n```\n\n### 11.2 Inline Pills / Status Dots\n\n```css\n/* Status dot (used in tables: ● Active) */\n.status-dot {\n  display: inline-block;\n  width: 8px;\n  height: 8px;\n  border-radius: 9999px;\n  vertical-align: middle;\n  margin-right: 6px;\n}\n.status-dot--success { background: #16A34A; }\n.status-dot--warning { background: #CA8A04; }\n.status-dot--error   { background: #DC2626; }\n.status-dot--neutral { background: #94A3B8; }\n\n/* Pulsing dot for \"live\" / \"syncing\" */\n.status-dot--pulse {\n  position: relative;\n}\n.status-dot--pulse::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: inherit;\n  animation: pulse-ring 1.6s ease-out infinite;\n}\n@keyframes pulse-ring {\n  0%   { transform: scale(1);   opacity: 0.6; }\n  100% { transform: scale(2.5); opacity: 0; }\n}\n```\n\n### 11.3 Empty-State Iconography Placeholder\n\nFor empty states, draw a single 1.5px-stroke line icon at 48–64px size in `--neutral-300`. Do not use 3D illustrations or stock vectors — they break the \"quiet product\" register.\n\n```html\n<div class=\"empty-state\">\n  <svg width=\"48\" height=\"48\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n       stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n       style=\"color: var(--neutral-300);\">\n    <!-- single line-art icon (e.g., a Lucide icon) -->\n  </svg>\n  <h3>No customers yet</h3>\n  <p>Customers appear here after their first successful payment.</p>\n  <button>Create test customer</button>\n</div>\n```\n\n### 11.4 Subtle Background Grid (for empty canvases)\n\nFor dashboard backgrounds, an OPTIONAL very subtle dot grid is acceptable:\n\n```css\n.canvas-grid {\n  background-color: var(--bg-canvas);\n  background-image: radial-gradient(circle, var(--neutral-200) 1px, transparent 1px);\n  background-size: 24px 24px;\n  background-position: 0 0;\n}\n```\n\nOpacity: never above 30%. Most SaaS apps omit this entirely.\n\n### 11.5 Keyboard Shortcut Chips\n\n```css\n.kbd {\n  display: inline-flex;\n  align-items: center;\n  gap: 2px;\n  padding: 2px 6px;\n  font-family: var(--font-mono);\n  font-size: 11px;\n  line-height: 1.2;\n  color: var(--text-muted);\n  background: var(--bg-surface);\n  border: 1px solid var(--border-default);\n  border-bottom-width: 2px;  /* mechanical key look */\n  border-radius: 4px;\n  vertical-align: middle;\n}\n```\n\nUsage: `<span class=\"kbd\">⌘</span><span class=\"kbd\">K</span>` inside tooltips, menu items, command palette.\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border | Use |\n|---|---|---|---|---|\n| `primary` | `--brand-600` | `#FFFFFF` | none | The one primary action per surface |\n| `secondary` | `--bg-surface` | `--text-primary` | `--border-default` 1px | Most actions (\"Cancel\", \"Save\", \"Edit\") |\n| `ghost` | transparent | `--text-primary` | none | Toolbar buttons, table row actions |\n| `destructive` | `#DC2626` | `#FFFFFF` | none | Delete, revoke, irreversible |\n| `destructive-outline` | `--bg-surface` | `#B91C1C` | `#FCA5A5` | Less aggressive destructive |\n| `link` | none | `--brand-600` | none, underline on hover | Inline navigation actions |\n\nSizes: `sm` (28px tall, 12px text), `md` (32px tall, 13px text — **default**), `lg` (40px tall, 14px text).\n\n### Inputs\n\n| State | Border | Background | Shadow |\n|---|---|---|---|\n| default | `--border-default` | `--bg-surface` | none |\n| hover | `--border-strong` | `--bg-surface` | none |\n| focus | `--brand-fg` | `--bg-surface` | `--ring-focus` |\n| error | `#DC2626` | `--bg-surface` | `--ring-error` |\n| disabled | `--border-default` | `--neutral-50` | none, `opacity: 0.6` |\n\nSizes match buttons: `sm` (28px), `md` (32px default), `lg` (40px).\n\n### Cards\n\n| Variant | Background | Border | Padding | Shadow |\n|---|---|---|---|---|\n| default | `--bg-surface` | 1px `--border-default` | 20px | none |\n| interactive | `--bg-surface` | 1px `--border-default` | 20px | hover: `--shadow-sm` |\n| ghost | none | none | 0 | none — for grouping without enclosure |\n| elevated | `--bg-elevated` | none | 24px | `--shadow-md` |\n| outline-only | none | 1px `--border-default` | 16px | none — for placeholder/empty slots |\n\n### Badges / Status Pills\n\n| Variant | Background | Text | Border |\n|---|---|---|---|\n| neutral | `--neutral-100` | `--neutral-700` | none |\n| brand | `--brand-50` | `--brand-700` | none |\n| success | `#DCFCE7` | `#15803D` | none |\n| warning | `#FEF9C3` | `#A16207` | none |\n| error | `#FEE2E2` | `#B91C1C` | none |\n| info | `#DBEAFE` | `#1D4ED8` | none |\n\nSizing: padding `2px 8px`, font 11–12px, weight 500, radius 4px (NOT pill). Optional leading status dot.\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── src/\n│   ├── styles/\n│   │   ├── tokens.css           # All CSS custom properties (one file, single source of truth)\n│   │   ├── base.css             # Reset, html/body, typography defaults, focus\n│   │   └── globals.css          # @import the above + utility classes\n│   ├── components/\n│   │   ├── primitives/          # Lowest-level, design-token-aware\n│   │   │   ├── Button.tsx\n│   │   │   ├── Input.tsx\n│   │   │   ├── Select.tsx\n│   │   │   ├── Checkbox.tsx\n│   │   │   ├── Switch.tsx\n│   │   │   ├── Badge.tsx\n│   │   │   ├── Card.tsx\n│   │   │   ├── Avatar.tsx\n│   │   │   ├── Tooltip.tsx\n│   │   │   ├── Kbd.tsx\n│   │   │   └── Spinner.tsx\n│   │   ├── overlays/            # Floating layers\n│   │   │   ├── Dialog.tsx\n│   │   │   ├── Sheet.tsx\n│   │   │   ├── Popover.tsx\n│   │   │   ├── DropdownMenu.tsx\n│   │   │   ├── Toast.tsx\n│   │   │   └── CommandPalette.tsx\n│   │   ├── data/                # Data display\n│   │   │   ├── DataTable.tsx\n│   │   │   ├── Pagination.tsx\n│   │   │   ├── KpiCard.tsx\n│   │   │   └── EmptyState.tsx\n│   │   ├── forms/\n│   │   │   ├── FormField.tsx\n│   │   │   ├── FormRow.tsx\n│   │   │   └── FormSection.tsx\n│   │   └── shell/               # The app frame\n│   │       ├── AppShell.tsx\n│   │       ├── Sidebar.tsx\n│   │       ├── Topbar.tsx\n│   │       ├── PageHeader.tsx\n│   │       └── Breadcrumb.tsx\n│   └── lib/\n│       ├── theme.ts             # Theme toggle (system/light/dark)\n│       ├── shortcuts.ts         # Global keyboard handler\n│       └── format.ts            # Money, date, relative time helpers\n├── tailwind.config.ts           # Tokens mirrored from tokens.css\n└── tsconfig.json\n```\n\nRules:\n- **`tokens.css` is the single source of truth.** `tailwind.config.ts` reads from it (or duplicates its values, but only as a mirror).\n- **Primitives have no business logic.** They accept props, render styled DOM. State lives in feature components that wrap them.\n- **No component crosses layer boundaries.** A primitive can't import from `shell/`; `shell/` can't import from `data/`.\n\n---\n\n## 14. Full CSS Custom Properties\n\nCopy into `styles/tokens.css`:\n\n```css\n:root {\n  /* ── Brand ── */\n  --brand-50:  #EEF0FB;\n  --brand-100: #DDE0F7;\n  --brand-200: #B9C0EF;\n  --brand-500: #7B85DC;\n  --brand-600: #5E6AD2;\n  --brand-700: #4A56C0;\n  --brand-800: #3A45A8;\n  --brand-900: #2A3284;\n  --brand-fg: var(--brand-600);\n  --brand-bg-subtle: var(--brand-50);\n\n  /* ── Neutrals (slate) ── */\n  --neutral-0:   #FFFFFF;\n  --neutral-50:  #F8FAFC;\n  --neutral-100: #F1F5F9;\n  --neutral-200: #E2E8F0;\n  --neutral-300: #CBD5E1;\n  --neutral-400: #94A3B8;\n  --neutral-500: #64748B;\n  --neutral-600: #475569;\n  --neutral-700: #334155;\n  --neutral-800: #1E293B;\n  --neutral-900: #0F172A;\n  --neutral-950: #020617;\n\n  /* ── Semantic surfaces ── */\n  --bg-canvas:   var(--neutral-50);\n  --bg-surface:  var(--neutral-0);\n  --bg-elevated: var(--neutral-0);\n  --bg-hover:    var(--neutral-100);\n\n  --text-primary: var(--neutral-900);\n  --text-muted:   var(--neutral-600);\n  --text-subtle:  var(--neutral-400);\n\n  --border-default: var(--neutral-200);\n  --border-strong:  var(--neutral-300);\n\n  /* ── State ── */\n  --success-fg:  #15803D;\n  --success-bg:  #DCFCE7;\n  --warning-fg:  #A16207;\n  --warning-bg:  #FEF9C3;\n  --error-fg:    #B91C1C;\n  --error-bg:    #FEE2E2;\n  --info-fg:     #1D4ED8;\n  --info-bg:     #DBEAFE;\n\n  /* ── Chart palette ── */\n  --chart-1: #2563EB;\n  --chart-2: #16A34A;\n  --chart-3: #CA8A04;\n  --chart-4: #DC2626;\n  --chart-5: #9333EA;\n  --chart-6: #0891B2;\n  --chart-7: #DB2777;\n  --chart-8: #65A30D;\n\n  /* ── Typography ── */\n  --font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n               \"SF Pro Text\", system-ui, sans-serif;\n  --font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n               ui-monospace, monospace;\n\n  /* ── Spacing (multiples of 4) ── */\n  --space-unit: 4px;\n\n  /* ── Radius ── */\n  --radius-none: 0;\n  --radius-sm:   4px;\n  --radius-md:   6px;\n  --radius-lg:   8px;\n  --radius-xl:   12px;\n  --radius-2xl:  16px;\n  --radius-full: 9999px;\n\n  /* ── Shadows ── */\n  --shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n  --shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06);\n  --shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04);\n  --shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08);\n  --shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10);\n  --shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n  --ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n  --ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n  /* ── Motion ── */\n  --ease-out:        cubic-bezier(0.16, 1, 0.3, 1);\n  --ease-out-back:   cubic-bezier(0.34, 1.56, 0.64, 1);\n  --ease-in-out:     cubic-bezier(0.4, 0, 0.2, 1);\n  --duration-75:  75ms;\n  --duration-100: 100ms;\n  --duration-150: 150ms;\n  --duration-200: 200ms;\n  --duration-250: 250ms;\n  --duration-300: 300ms;\n\n  /* ── Layout ── */\n  --container-sm:  640px;\n  --container-md:  768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1536px;\n  --sidebar-w:        256px;\n  --sidebar-w-collapsed: 56px;\n  --topbar-h:         48px;\n}\n\n.dark {\n  --bg-canvas:   #0A0E1A;\n  --bg-surface:  #111827;\n  --bg-elevated: #1F2937;\n  --bg-hover:    rgba(255, 255, 255, 0.04);\n\n  --text-primary: #F1F5F9;\n  --text-muted:   #94A3B8;\n  --text-subtle:  #64748B;\n\n  --border-default: #1F2937;\n  --border-strong:  #374151;\n\n  --brand-fg:         #A5B4FC;\n  --brand-bg-subtle:  rgba(94, 106, 210, 0.16);\n\n  --success-fg: #4ADE80;\n  --success-bg: rgba(22, 163, 74, 0.16);\n  --warning-fg: #FACC15;\n  --warning-bg: rgba(202, 138, 4, 0.16);\n  --error-fg:   #F87171;\n  --error-bg:   rgba(220, 38, 38, 0.16);\n  --info-fg:    #60A5FA;\n  --info-bg:    rgba(37, 99, 235, 0.16);\n\n  --shadow-xs:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n  --shadow-sm:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n  --shadow-md:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n  --shadow-lg:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n  --shadow-xl:  0 20px 32px -10px rgba(0, 0, 0, 0.60);\n  --shadow-2xl: 0 30px 60px -15px rgba(0, 0, 0, 0.70);\n}\n\n/* ── Base ── */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1, \"cv11\" 1, \"ss01\" 1;\n}\n\nbody {\n  margin: 0;\n  background: var(--bg-canvas);\n  color: var(--text-primary);\n  font-family: var(--font-sans);\n  font-size: 14px;\n  line-height: 1.43;\n}\n\n.numeric, table, [data-numeric=\"true\"] {\n  font-variant-numeric: tabular-nums;\n}\n\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Implementation Physics**\n> Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Density Tier\n\n| Tier | Row Height | Button Height | Default Text | When |\n|---|---|---|---|---|\n| **Compact** (Linear, dev tools) | 32px | 28px | 13px | Power-user surfaces, data-heavy tables |\n| **Default** (Stripe, Vercel) | 40px | 32px | 14px | The default SaaS product UI |\n| **Comfortable** (Notion, Clerk) | 48px | 36px | 15px | Content-heavy, settings, billing |\n| **Spacious** (Auth, onboarding) | 56px+ | 40px+ | 16px | Single-purpose conversion pages |\n\n**Pick one tier per product, not per page.** Mixing density breaks the rhythm.\n\n#### Layout Constants\n\n```\nContent width:\n├── Tables / lists:       full bleed within --container-2xl (1536px)\n├── Dashboards:           --container-xl (1280px) centered\n├── Settings forms:       --container-md (768px) — readable column\n└── Auth / onboarding:    --container-sm (640px) centered\n\nPage padding (X):\n├── Mobile:    16px (px-4)\n├── Tablet:    24px (px-6)\n└── Desktop:   32px (px-8)\n\nPage padding (Y):\n├── Page header → content:     16-24px\n├── Section → section:         32-40px\n└── Last section → page end:   64px (so footer doesn't crowd)\n\nGrid gaps:\n├── KPI cards:           12px\n├── Card grids:          16px (sm) → 20px (md) → 24px (lg)\n├── Form fields:         16px (tight) or 20px (default)\n└── Settings rows:       0 — separated by 1px bottom border\n```\n\n#### The \"Three Heights\" Rule\n\nIn a SaaS UI, three vertical rhythms coexist:\n\n1. **Page rhythm**: 24px / 32px / 48px between page sections.\n2. **Component rhythm**: 12px / 16px / 20px inside cards and panels.\n3. **Inline rhythm**: 4px / 6px / 8px between adjacent elements (icon + label, badge + text).\n\nEach rhythm is a multiple of 4 but **does not reuse** the same numbers from another rhythm. Inline gaps stay ≤8px, component gaps stay 12–20px, page gaps stay ≥24px. This is what makes a SaaS UI feel \"organized\" without conscious effort.\n\n### 15.2 Color Science & Elevation\n\n#### How elevation is conveyed (without heavy shadows)\n\n| Layer | Light Mode | Dark Mode |\n|---|---|---|\n| Canvas | `#F8FAFC` (1 step down from white) | `#0A0E1A` |\n| Surface (default card) | `#FFFFFF` + 1px border | `#111827` + 1px `rgba(255,255,255,0.04)` border |\n| Elevated (popover) | `#FFFFFF` + `--shadow-md` | `#1F2937` + low shadow |\n| Modal | `#FFFFFF` + `--shadow-xl` + backdrop blur | `#1F2937` + `--shadow-xl` + backdrop blur |\n\nIn light mode, elevation = subtle shadow + 1px border.\nIn dark mode, elevation = LIGHTER background (the higher the layer, the lighter the bg) + nearly-invisible shadow.\n\n#### Brand color is \"interaction language\"\n\n- Brand color = \"this is interactive, this is selected, this is focused.\"\n- Anything else uses neutrals.\n- Status colors (success/error/etc.) are ONLY for status — never for buttons that don't represent that status.\n\nA useful test: **squint at the UI**. You should see 90% gray-on-white, with brand pinpricks marking exactly the interactive surface you'd click.\n\n#### Selected-state convention\n\n| State | Convention |\n|---|---|\n| Hover | Background shifts by 1 step (`--bg-surface` → `--bg-hover`) |\n| Active (pressed) | Background shifts by 2 steps OR scale(0.98) |\n| Selected (persistent) | `--brand-bg-subtle` background + `--brand-fg` text. **No border change.** |\n| Focused (keyboard) | `--ring-focus` box-shadow ring. Independent of hover/selected. |\n| Disabled | `opacity: 0.5` + `cursor: not-allowed`. Color unchanged. |\n\n### 15.3 Typography & Micro-Copy\n\n#### The \"13/14 default\" rule\n\nThe default body/UI size is **13px or 14px**. Headings step up modestly (no 48px headlines in a dashboard). The biggest text in a typical product page is the H1 at 24–30px.\n\n| Element | Size | Weight |\n|---|---|---|\n| H1 (page title) | 24px | 600 |\n| H2 (section) | 18px | 600 |\n| H3 (card heading) | 15-16px | 600 |\n| Body / table cell | 13-14px | 400 |\n| Label / button | 13-14px | 500 |\n| Microlabel / caption | 11-12px | 500 (uppercase optional) |\n| Mono (id, code, money) | 12-13px | 400 |\n\n#### Micro-copy patterns\n\n**Buttons** — verb + object, never just verb:\n- ✅ \"Create customer\", \"Save changes\", \"Delete invoice\"\n- ❌ \"Submit\", \"OK\", \"Click here\"\n\n**Empty state headings** — declarative, not interrogative:\n- ✅ \"No active subscriptions\"\n- ❌ \"You don't have any subscriptions yet, would you like to create one?\"\n\n**Inline help / hints** — single sentence, no period (because it's not a sentence in form context):\n- ✅ \"Used for receipts and customer emails\"\n- ❌ \"This email will be used for receipts and customer emails.\"\n\n**Destructive confirmation** — type-to-confirm for irreversible:\n- \"Type `acme-corp` to confirm deletion\" — see GitHub's repo deletion modal.\n\n**Toast text** — past tense, with optional action:\n- ✅ \"Invoice sent. [Undo]\"\n- ❌ \"Sending invoice…\" (use a loading state for in-flight)\n\n### 15.4 Component Anatomy\n\n#### Button (the most-touched primitive)\n\n```\n┌────────────────────────────────────┐\n│  [icon] [label]            [⌘K]    │   ← gap 8px between icon/label, 12px to shortcut\n└────────────────────────────────────┘\n   ↑ padding 12-14px              ↑ padding 12-14px\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ font: 13px / 500\n```\n\nVariants:\n- `primary` — solid brand bg, white text. ONE per surface.\n- `secondary` — surface bg, neutral border, primary text. Most actions.\n- `ghost` — no bg, no border. Toolbar / row actions.\n- `destructive` — solid red bg. Reserved for irreversible.\n\nStates:\n- default → hover (bg shifts) → active (bg shifts more / scale 0.98) → focus (ring) → disabled (opacity 0.5)\n\nForbidden:\n- Pill radius (`9999px`) — that's marketing\n- Gradient background — that's marketing\n- Drop shadow — buttons are flat against the surface\n\n#### Input\n\n```\n┌────────────────────────────────────┐\n│  [icon] placeholder text      [×]  │   ← optional leading icon, trailing clear\n└────────────────────────────────────┘\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ border: 1px --border-default\n   ─ padding: 0 12px (or 0 36px when icon present)\n   ─ font: 14px / 400\n```\n\nFocus state: border → `--brand-fg`, add `--ring-focus` box-shadow. **Never use solid background change for focus** — borders + ring are the convention.\n\nError state: border → `#DC2626`, ring → `--ring-error`. Show error message below input in `#B91C1C`, 12px, with `aria-describedby` linking.\n\n#### Card\n\n```\n┌──────────────────────────────────┐\n│  Heading            [actions]    │   ← header row, 16-20px from edges\n│  Optional description            │\n├──────────────────────────────────┤   ← 1px divider OR padding gap\n│                                  │\n│  Card body                       │\n│                                  │\n└──────\n\n<!-- [TRUNCATED AT 50KB] -->","worker/agents/think/skills/frontend-design/SKILL.md":"---\nname: frontend-design\ndescription: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.\nlicense: Complete terms in LICENSE.txt\n---\n\nThis skill guides creation of distinctive, production-grade frontend interfaces that avoid generic \"AI slop\" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.\n\nThe user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.\n\n## Design Thinking\n\nBefore coding, understand the context and commit to a BOLD aesthetic direction:\n- **Purpose**: What problem does this interface solve? Who uses it?\n- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.\n- **Constraints**: Technical requirements (framework, performance, accessibility).\n- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?\n\n**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.\n\nThen implement working code (HTML/CSS/JS, React, Vue, etc.) that is:\n- Production-grade and functional\n- Visually striking and memorable\n- Cohesive with a clear aesthetic point-of-view\n- Meticulously refined in every detail\n\n## Frontend Aesthetics Guidelines\n\nFocus on:\n- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.\n- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.\n- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.\n- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.\n- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.\n\nNEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.\n\nInterpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.\n\n**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.\n\nRemember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision."},"files":{"AGENTS.md":"# AGENTS.md\n\n## Tooling\n- Use Bun from the repository root. The tracked lockfile is `bun.lock`, the `space` workspace dependency uses `workspace:*`, and install/build hooks invoke Bun even when started through npm.\n- `bun run setup` is the interactive Cloudflare/resource bootstrap. Local development expects the generated `.dev.vars`; never commit `.dev.vars*` or `.prod.vars`.\n- `bun run dev` starts the React frontend and Worker together through `@cloudflare/vite-plugin` at `http://localhost:5173`. There is no separate Worker dev command.\n- `bun run dev:browser` is an optional local Chromium sidecar for the think agent's browser-console tool; absence only produces a warning.\n\n## Verification\n- Root checks: `bun run typecheck`, `bun run lint`, `bun run test`, `bun run build`.\n- `bun run build` builds `space` and the Vite/Worker bundle; it does not typecheck. Run `bun run typecheck` separately.\n- Focus a root test with `bunx vitest run path/to/file.test.ts`; test execution uses the Workers pool and `wrangler.test.jsonc`.\n- The root Vitest suite excludes all `sdk/test/**` and `container/monitor-cli.test.ts`. SDK tests use Bun: `bun run --cwd sdk test`.\n- SDK integration tests require a running root dev server and `VIBESDK_INTEGRATION_API_KEY`; run `bun run --cwd sdk test:integration`. They can take 5-10 minutes; `VIBESDK_INTEGRATION_RUN_PREVIEW=1` enables the slower preview case.\n- Root typecheck/lint do not validate `space` or `sdk`. For touched packages run `bun run --cwd space typecheck` / `bun run --cwd space build` and `bun run --cwd sdk package` as appropriate.\n- ESLint checks only `src/**` and `worker/**` and deliberately ignores tests; do not treat `bun run lint` as repository-wide validation.\n- Pre-commit typechecks staged TypeScript and runs related Vitest tests. `RUN_ALL_TESTS=1` selects its broader suite; `SKIP_TESTS=1` bypasses the hook.\n\n## Frontend UI\n- Tailwind CSS v4 via CSS-first setup in `src/index.css` (`@import 'tailwindcss'`, `@theme`, Kumo tokens); no `tailwind.config.*`.\n- Prefer `@cloudflare/kumo` for new UI. List components with `bun kumo ls`; component docs via `bun kumo doc Button` (swap name as needed). Legacy shadcn/Radix under `src/components/ui/` still exists—do not add new primitives there when Kumo covers the case.\n- Icons: `@phosphor-icons/react`. Dark mode is `data-mode=\"dark\"` on the root (not a `class` strategy).\n- Path aliases: `@/*` → `src/*`, `shared/*`, `worker/*` (see `tsconfig.app.json`).\n\n## Frontend Data Fetching\n- Use TanStack Query for frontend server state and network-call caching. `QueryClientProvider` is wired at the React root; configure shared defaults in `src/lib/query-client.ts`.\n- Keep TanStack query keys centralized in `src/lib/query-keys.ts`. Use hierarchical keys so broad invalidation works, for example `queryKeys.apps.all` should invalidate app list/favorite variants.\n- Frontend HTTP still goes through `src/lib/api-client.ts`; query functions should wrap existing `apiClient` methods rather than calling `fetch` directly from components.\n- Include user/account identity in query keys when cached data is user-specific, or explicitly clear/remove those queries on logout/user switch. `enabled: !!user` prevents fetching but does not clear old cached data.\n- Mutations that change cached server state must update cache with `queryClient.setQueryData` or invalidate the relevant `queryKeys` on success. Do not rely on a local `refetch()` in one component if sidebar or other shared UI consumes the same data.\n- Prefer query hooks (`useQuery`, `useMutation`) over ad-hoc loading/error state in React contexts. Context remains appropriate for client-only UI state or providers required by libraries.\n\n## Boundaries\n- `src/` is the React app (`src/main.tsx`, routes in `src/routes.tsx`). API contracts live in `src/api-types.ts`; frontend HTTP calls belong in `src/lib/api-client.ts`.\n- `worker/index.ts` is the Worker entrypoint and Durable Object export surface. Hono middleware/routes are wired by `worker/app.ts` and `worker/api/routes/index.ts`.\n- `space/` is the only declared workspace package. It provides the `SpaceDO` workspace and file layer used by the think agent, with durable git history stored through Cloudflare Artifacts, and is bundled before the root app; edit implementation in `space/src`, never generated `space/dist`, and keep the hand-maintained `space/types/index.d.ts` aligned with public exports.\n- `sdk/` is an independent Bun package with its own lockfile, scripts, and tests. It imports the platform WebSocket protocol from `worker/api/websocketTypes.ts`, so protocol changes must remain SDK-compatible.\n- Shared frontend/backend types belong in `shared/`; Worker-only types stay under `worker/`.\n- Architecture overview (ThinkAgent, SpaceDO, Artifacts, Dynamic Worker previews): `docs/llm.md`. Production deploy: `bun run deploy` (needs `.prod.vars`).\n\n## Change Paths\n- API endpoint: update `src/api-types.ts` -> `src/lib/api-client.ts` -> `worker/database/services/` (when persistence is needed) -> `worker/api/controllers/` -> `worker/api/routes/`, then register the route in `worker/api/routes/index.ts`.\n- WebSocket message: update `worker/api/websocketTypes.ts`, backend handling in `worker/agents/core/websocket.ts`, and frontend handling in `src/routes/chat/utils/handle-websocket-message.ts`; verify SDK tests because its protocol re-exports these types.\n- LLM tool: add it under `worker/agents/tools/toolkit/` and register it in `worker/agents/tools/customTools.ts` (`buildTools` or `buildDebugTools`). The think behavior has a separate tool path and bypasses `buildTools`.\n- Think tool: create it under `worker/agents/think/`, add SpaceDO RPC typing if needed, register it in `ThinkAgent.getTools()`, and update the relevant prompt or skill.\n- D1 schema source is `worker/database/schema.ts`; generate migrations into `migrations/` with `bun run db:generate`, then apply locally with `bun run db:migrate:local`.\n- After changing Wrangler bindings, run `bun run cf-typegen`; `worker-configuration.d.ts` is consumed by setup and TypeScript configs.\n\n## Constraints\n- Do not introduce new `any` types even though ESLint currently permits existing ones; find or define a concrete type. Frontend API types should import from `@/api-types`.\n- Worker code reads bindings from `env`; do not use Vite environment variables there.\n- All `/api/*` routes are owner-only by default in `worker/app.ts`; public routes must explicitly follow the existing auth override pattern.\n- User secrets RPC methods return `null`/`boolean` on failure rather than throwing; preserve that contract when editing `worker/services/secrets/`.\n- For usage-limit UI behavior and its cross-component invariants, read `docs/usage-limits-ui.md` before editing the badge, credits banner, or limit popups.\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code when working with code in this repository.\n\n## Communication Style\n- Be professional, concise, and direct\n- Do NOT use emojis in code reviews, changelogs, or any generated content. You may use professional visual indicators or favor markdown formatting over emojis.\n- Focus on substance over style\n- Use clear technical language\n\n## Project Overview\n\nVibeSDK is an agentic full-stack application builder on Cloudflare.\n\n**Tech Stack:**\n- Frontend: React 19, TypeScript, Vite, TailwindCSS, React Router v7\n- Backend: Cloudflare Workers, Durable Objects, Hono, D1, R2, and KV\n- Agent: Cloudflare Think with AI Gateway model routing\n- Workspace: SpaceDO Durable Objects\n- Version history: Cloudflare Artifacts\n- Preview runtime: Worker Loader bindings and Dynamic Workers\n- Generated app data: Durable Object Facets with isolated SQLite\n- WebSocket: PartySocket for real-time communication\n\n**Project Structure:**\n- `/src` - React frontend, API types, and API client\n- `/worker/agents/think` - ThinkAgent, prompts, skills, workspace adapter, and tools\n- `/worker/agents/core/behaviors/think.ts` - Think host orchestration\n- `/worker/api` - Routes, controllers, handlers, and WebSocket types\n- `/worker/database` - D1 schema and services\n- `/space` - SpaceDO, Artifacts synchronization, preview bundling, and App Facets\n- `/sdk` - TypeScript client SDK\n- `/migrations` - D1 migrations\n- `/scripts` - Setup and deployment utilities\n\n## Key Architectural Patterns\n\n**ThinkAgent:**\n- One Agent backed by a Durable Object per app session\n- Owns conversation, context selection, skills, streaming, tools, and step limits\n- Uses explicit SpaceDO-backed tools; workspace bash is disabled\n\n**Workspace and Versioning:**\n- SpaceDO owns the isolated live workspace and files\n- Cloudflare Artifacts owns durable commits, branches, history, and restore points\n- `commit` saves without deploying; `deploy_space` commits and rebuilds the preview\n- Rollback applies a selected tree, creates a new commit, and redeploys\n\n**Preview Runtime:**\n- `@cloudflare/worker-bundler` builds committed project files\n- Worker Loader loads bundled modules as a Dynamic Worker\n- Generated `App` classes run as Durable Object Facets with isolated SQLite\n\n**WebSocket Communication:**\n- PartySocket carries realtime agent output, tools, files, and deployment state\n- Session state is restored on reconnect\n\n## Common Development Tasks\n\n**Change LLM Model for Operation:**\nEdit `/worker/agents/inferutils/config.ts` → `AGENT_CONFIG` object\n\n**Modify Think Agent Behavior:**\nEdit `worker/agents/think/ThinkAgent.ts`, the host behavior in `worker/agents/core/behaviors/think.ts`, and the relevant prompt or skill.\n\n**Add New WebSocket Message:**\n1. Add type to `worker/api/websocketTypes.ts`\n2. Handle in `worker/agents/core/websocket.ts`\n3. Handle in `src/routes/chat/utils/handle-websocket-message.ts`\n\n**Add New Think Tool:**\n1. Create the tool under `worker/agents/think/`\n2. Add required SpaceDO RPC typing to `space-workspace-ops.ts`\n3. Register it in `ThinkAgent.getTools()`\n4. Update the relevant prompt or skill\n5. Add focused tests\n\n**Add API Endpoint:**\n1. Define types in `src/api-types.ts`\n2. Add to `src/lib/api-client.ts`\n3. Create service in `worker/database/services/`\n4. Create controller in `worker/api/controllers/`\n5. Add route in `worker/api/routes/`\n6. Register in `worker/api/routes/index.ts`\n\n## Important Context\n\n**User Secrets Store (Durable Object):**\n- Location: `/worker/services/secrets/`\n- Purpose: Encrypted storage for user API keys with key rotation\n- Architecture: One DO per user, XChaCha20-Poly1305 encryption, SQLite backend\n- Key derivation: MEK → UMK → DEK (hierarchical PBKDF2)\n- Features: Key rotation, soft deletion, access tracking, expiration support\n- RPC Methods: Return `null`/`boolean` on error, never throw exceptions\n- Testing: 90 comprehensive tests in `/test/worker/services/secrets/`\n\n**Workspace and Git:**\n- SpaceDO provides workspace and file operations\n- Cloudflare Artifacts stores durable git history\n- Artifacts synchronization lives in `space/src/space/artifacts-sync.ts`\n- Rollback preserves history by creating a new commit\n\n**Abort Controller Pattern:**\n- `getOrCreateAbortController()` reuses controller for nested operations\n- Cleared after top-level operations complete\n- Shared by parent and nested tool calls\n- User abort cancels entire operation tree\n\n**Message Deduplication:**\n- Tool execution causes duplicate AI messages\n- Backend skips redundant LLM calls (empty tool results)\n- Frontend utilities deduplicate live and restored messages\n- System prompt teaches LLM not to repeat\n\n## Core Rules (Non-Negotiable)\n\n**1. Strict Type Safety**\n- NEVER use `any` type\n- Frontend imports types from `@/api-types` (single source of truth)\n- Search codebase for existing types before creating new ones\n\n**2. DRY Principle**\n- Search for similar functionality before implementing\n- Extract reusable utilities, hooks, and components\n- Never copy-paste code - refactor into shared functions\n\n**3. Follow Existing Patterns**\n- Frontend APIs: All in `/src/lib/api-client.ts`\n- Backend Routes: Controllers in `worker/api/controllers/`, routes in `worker/api/routes/`\n- Database Services: In `worker/database/services/`\n- Types: Shared in `shared/types/`, API in `src/api-types.ts`\n\n**4. Code Quality**\n- Production-ready code only - no TODOs or placeholders\n- No hacky workarounds\n- Comments explain purpose, not narration\n- No overly verbose AI-like comments\n\n**5. File Naming**\n- React Components: PascalCase.tsx\n- Utilities/Hooks: kebab-case.ts\n- Backend Services: PascalCase.ts\n\n## Common Pitfalls\n\n**Don't:**\n- Use `any` type (find or create proper types)\n- Copy-paste code (extract to utilities)\n- Use Vite env variables in Worker code\n- Forget to update types when changing APIs\n- Create new implementations without searching for existing ones\n- Use emojis in code or comments\n- Write verbose AI-like comments\n\n**Do:**\n- Search codebase thoroughly before creating new code\n- Follow existing patterns consistently\n- Keep comments concise and purposeful\n- Write production-ready code\n- Test thoroughly before submitting","packages/artifacts-viewer/.changeset/README.md":"# Change intents\n\nRun `pnpm change` for every change that affects the published package. Commit the generated Markdown file with the code change.\n\nDocumentation, tests, and changes limited to private workspace applications do not require an intent.\n","packages/artifacts-viewer/README.md":"# artifacts-viewer\n\nMonorepo for [`artifacts-viewer`](./packages/artifacts-viewer), a read-only React\nrepository viewer for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/).\n\nFor package documentation — installation, API reference, styling, theming — see\n[`packages/artifacts-viewer/README.md`](./packages/artifacts-viewer/README.md).\nFor the release procedure, see [`PUBLISH.md`](./PUBLISH.md).\n\n## Layout\n\n```text\napps/example                 Cloudflare Workers + React app that consumes the package\npackages/artifacts-viewer    The published npm package\n```\n\nThe package ships four JavaScript entry points, one CSS export, and package metadata, each with a deliberate boundary:\n\n| Entry point                     | Contains                                                | Must not import       |\n| ------------------------------- | ------------------------------------------------------- | --------------------- |\n| `artifacts-viewer`              | `routeArtifactRequest`, the server-side read-only proxy | React                 |\n| `artifacts-viewer/server/cache` | Workers Cache API and KV adapters                       | React                 |\n| `artifacts-viewer/client`       | `createArtifactsClient`, framework-independent          | React, Worker globals |\n| `artifacts-viewer/react`        | Hooks and components                                    | The server router     |\n| `artifacts-viewer/styles.css`   | Structural CSS only                                     | —                     |\n\nPlatform-neutral code shared by both sides (wire types, path helpers, blob\nclassification) lives in `packages/artifacts-viewer/src/shared/`. The entry\npoints are designed to preserve those boundaries: the server and client builds\ndo not import React, and the React build does not import the server router.\n\n## Toolchain\n\nThis workspace uses [Vite+](https://viteplus.dev), which wraps runtime\nmanagement, package management, and frontend tooling behind a single `vp` CLI.\nRun `vp help` for the command list. Docs are vendored at\n`node_modules/vite-plus/docs`.\n\nDependency versions are centralised in the `catalog:` block of\n`pnpm-workspace.yaml`. Add a version there and reference it as `\"catalog:\"` from\na package rather than pinning per package.\n\n## Development\n\n```bash\nvp install          # install; run after pulling\nvp run dev          # start apps/example with source-level library HMR\nvp run ready        # vp check && vp run -r build — the CI gate\nvp run -r build     # build every workspace project\n```\n\nThere is no test suite. `vp check` covers formatting, linting, and type checking\nacross all projects.\n\n### Running the example app\n\nThe example needs credentials for a real Artifacts namespace. The API token\nlives only in the Worker; the browser never sees it.\n\n```bash\ncd apps/example\ncp .dev.vars.example .dev.vars   # then fill in the three values\n```\n\n`wrangler types` reads `.dev.vars` to generate `Env`, so rerun\n`vp run cf-typegen` from `apps/example` after adding or renaming a variable.\n\n`apps/example/wrangler.jsonc` sets `assets.run_worker_first: [\"/api/*\"]`.\nWithout it, `not_found_handling: \"single-page-application\"` would serve\n`index.html` for API routes and the Worker would never run.\n\n## Releases\n\nVersions and changelogs are generated by pnpm's native release tooling; never\nhand-edit `version` or `CHANGELOG.md`.\n\n```bash\npnpm change         # record a change intent, committed with the code\npnpm version -r     # consume intents: bump, write changelogs, update the ledger\n```\n\nPublishing runs on GitHub Actions via npm OIDC trusted publishing, triggered by\na published GitHub Release whose tag matches `v<version>`. No npm token exists\nanywhere in the repository. [`PUBLISH.md`](./PUBLISH.md) has the full procedure\nand the failure modes worth knowing about.\n\n## License\n\nMIT\n","packages/artifacts-viewer/packages/artifacts-viewer/README.md":"# artifacts-viewer\n\nA read-only repository browser for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/). Ships a server-side proxy, a typed HTTP client, and unstyled React components.\n\nYour Cloudflare API token stays on the server. The browser only ever talks to your own origin.\n\n> Pre-1.0. The API is still moving; pin an exact version.\n\n## Contents\n\n- [artifacts-viewer](#artifacts-viewer)\n  - [Contents](#contents)\n  - [How it works](#how-it-works)\n    - [Request flow](#request-flow)\n  - [Install](#install)\n  - [Quick start](#quick-start)\n    - [1. Mount the router](#1-mount-the-router)\n    - [2. Render the viewer](#2-render-the-viewer)\n  - [`artifacts-viewer` — server router](#artifacts-viewer--server-router)\n    - [Authorization](#authorization)\n    - [Response behaviour](#response-behaviour)\n    - [CORS](#cors)\n  - [`artifacts-viewer/server/cache` — cache adapters](#artifacts-viewerservercache--cache-adapters)\n  - [`artifacts-viewer/client` — typed client](#artifacts-viewerclient--typed-client)\n  - [`artifacts-viewer/react` — components and hooks](#artifacts-viewerreact--components-and-hooks)\n    - [Components](#components)\n      - [`renderStatus` — loading, empty, and error states](#renderstatus--loading-empty-and-error-states)\n    - [Hooks](#hooks)\n    - [Building your own UI](#building-your-own-ui)\n  - [Styling](#styling)\n    - [Custom properties](#custom-properties)\n    - [Selector hooks](#selector-hooks)\n  - [Syntax highlighting](#syntax-highlighting)\n    - [Loading](#loading)\n      - [`renderCodeFallback` — what the wait looks like](#rendercodefallback--what-the-wait-looks-like)\n      - [`preloadCodeView` — how long the wait is](#preloadcodeview--how-long-the-wait-is)\n    - [Theming](#theming)\n  - [Limits and behaviour](#limits-and-behaviour)\n  - [Not included](#not-included)\n  - [License](#license)\n\n## How it works\n\n```\nbrowser                    your server                     Cloudflare\n┌──────────────────┐      ┌──────────────────────┐      ┌──────────────┐\n│ArtifactRepoViewer│      │ routeArtifactRequest │      │ Artifacts    │\n│        ↓         │─────▶│  · validates         │─────▶│ REST API     │\n│ ArtifactsClient  │      │  · adds API token    │      │              │\n└──────────────────┘      │  · optional cache    │      └──────────────┘\n     GET /artifacts/…     └──────────────────────┘        Bearer token\n```\n\nThe router mirrors Cloudflare's namespace-relative paths 1:1 and proxies **only** these seven reads. `accountId` and `namespace` are deliberately absent from the browser-facing URL.\n\n| Mounted route                                        | Purpose                                     |\n| ---------------------------------------------------- | ------------------------------------------- |\n| `GET {apiPath}/repos/{repo}`                         | Repository metadata                         |\n| `GET {apiPath}/repos/{repo}/log?ref=&limit=&offset=` | Commit log                                  |\n| `GET {apiPath}/repos/{repo}/commit/{hash}`           | One commit                                  |\n| `GET {apiPath}/repos/{repo}/tree/{hash}`             | One directory level                         |\n| `GET {apiPath}/repos/{repo}/blob/{hash}`             | File bytes by hash                          |\n| `GET {apiPath}/repos/{repo}/file?ref=&path=`         | File bytes by path                          |\n| `GET {apiPath}/repos/{repo}/raw/{ref}/{path}`        | File bytes with a browser-safe content type |\n\nNothing else is reachable. Writes, repository creation, and token endpoints are not proxied at all.\n\n### Request flow\n\nRendering a repository takes two round trips, then goes content-addressed:\n\n```\ngetLog({ limit: 1 })       →  commits[0].treeHash    (ref omitted = default branch)\nreadTree(commit.treeHash)  →  root entries\n```\n\nAfter that every navigation is addressed by git hash — `readTree(entry.hash)` for a directory, `readBlob(entry.hash)` for a file — so responses are immutable and cacheable forever.\n\n## Install\n\n```bash\nnpm install artifacts-viewer\n# or\npnpm install artifacts-viewer\n# or\nbun install artifacts-viewer\n```\n\n`react` and `react-dom` are peer dependencies (`^18.2.0 || ^19.0.0`). `@pierre/diffs` is a direct dependency. It is split from the initial bundle and proactively prefetched when `ArtifactRepoViewer` mounts.\n\n## Quick start\n\n### 1. Mount the router\n\n> **The router allows every request by default.** Add a `beforeRequest` policy before exposing this endpoint; otherwise anyone who can reach it can read any valid repository name in the configured namespace.\n\n```ts\n// worker/index.ts\nimport { routeArtifactRequest } from \"artifacts-viewer\";\nimport { createCacheApiAdapter } from \"artifacts-viewer/server/cache\";\n\nexport default {\n  async fetch(request, env, ctx) {\n    const handled = await routeArtifactRequest(request, {\n      accountId: env.ARTIFACTS_ACCOUNT_ID,\n      namespace: env.ARTIFACTS_NAMESPACE,\n      apiToken: env.ARTIFACTS_API_TOKEN,\n      beforeRequest: async ({ request, read }) => {\n        const user = await getSessionUser(request);\n        if (user === null || !user.repositories.includes(read.repoName)) {\n          return new Response(\"Forbidden\", { status: 403 });\n        }\n      },\n      cache: createCacheApiAdapter({\n        cache: caches.default,\n        baseUrl: new URL(request.url).origin,\n      }),\n      waitUntil: (promise) => {\n        ctx.waitUntil(promise);\n      },\n    });\n\n    return handled ?? new Response(\"Not found\", { status: 404 });\n  },\n} satisfies ExportedHandler<Env>;\n```\n\n`routeArtifactRequest` returns `null` when the request is outside `apiPath`, so you can fall through to your own routing.\n\nIf you serve static assets from the same Worker, route the API to the Worker first or the asset handler will answer instead:\n\n```jsonc\n// wrangler.jsonc\n{\n  \"assets\": {\n    \"not_found_handling\": \"single-page-application\",\n    \"run_worker_first\": [\"/artifacts/*\"],\n  },\n}\n```\n\n### 2. Render the viewer\n\n```tsx\nimport { createArtifactsClient } from \"artifacts-viewer/client\";\nimport { ArtifactRepoViewer } from \"artifacts-viewer/react\";\nimport \"artifacts-viewer/styles.css\";\n\n// Module scope: the hooks treat `client` as a dependency, so a new instance\n// per render would refetch forever.\nconst client = createArtifactsClient();\n\nexport function Repository() {\n  return <ArtifactRepoViewer client={client} repoName=\"my-repository\" />;\n}\n```\n\n## `artifacts-viewer` — server router\n\nReact-free and platform-neutral. Uses only `Request`, `Response`, `Headers`, `URL`, and `fetch`, so it runs on Workers, Node, Deno, or Bun.\n\n```ts\nfunction routeArtifactRequest(\n  request: Request,\n  options: ArtifactRouterOptions,\n): Promise<Response | null>;\n```\n\n| Option          | Type                            | Notes                                                     |\n| --------------- | ------------------------------- | --------------------------------------------------------- |\n| `accountId`     | `string`                        | Required                                                  |\n| `namespace`     | `string`                        | Required                                                  |\n| `apiToken`      | `string`                        | Required. Cloudflare API token with Artifacts read access |\n| `apiPath`       | `string`                        | Default `\"/artifacts\"`. Matched on a segment boundary     |\n| `fetch`         | `typeof fetch`                  | Optional injection point for tests and tracing            |\n| `beforeRequest` | `ArtifactBeforeRequestHook`     | Authorization hook. Return a `Response` to deny           |\n| `cache`         | `ArtifactsCacheAdapter`         | Consulted for content-addressed reads only                |\n| `waitUntil`     | `(p: Promise<unknown>) => void` | Keeps cache writes alive past the response                |\n| `onCacheError`  | `(error: unknown) => void`      | Cache failures are reported here, never thrown            |\n\n### Authorization\n\n`beforeRequest` runs **before** the cache, so a cache hit can never bypass it.\n\n```ts\nbeforeRequest: async ({ request, read, operation }) => {\n  if (!(await mayRead(request, read.repoName))) {\n    return new Response(\"Forbidden\", { status: 403 });\n  }\n},\n```\n\n`read` is a fully validated discriminated union, so `read.repoName`, `read.hash`, `read.ref`, and `read.path` are safe to use in a policy decision.\n\n### Response behaviour\n\n- Outside `apiPath` → `null`.\n- Inside `apiPath` but not a known route → `404`.\n- Known route, wrong method → `405` with an `Allow` header.\n- Malformed argument → `400`. Nothing is sent upstream.\n- Errors use the Cloudflare v4 envelope, so the client has one parser.\n\nOutbound requests are built from scratch with only `Accept` and `Authorization`. A caller's `Authorization`, `Cookie`, and every other inbound header are dropped. Response headers are copied through an allowlist (`Content-Type`, `Content-Length`, `Content-Disposition`, `ETag`, `Last-Modified`); bodies stream and are never buffered.\n\n### CORS\n\nNot handled. You own the response, so add headers after the call. Note that `OPTIONS` returns `405`, so intercept preflight before delegating:\n\n```ts\nif (request.method === \"OPTIONS\") return myPreflight(request);\n```\n\n## `artifacts-viewer/server/cache` — cache adapters\n\nOnly content-addressed reads (`commit`, `tree`, `blob`) are ever cached. Their values cannot become semantically stale, but the bundled Cache API and KV adapters use one-year freshness or expiration settings and either backend may evict an entry earlier. Ref-addressed reads are never cached.\n\n```ts\ntype ArtifactsCacheAdapter = {\n  get(key: string): Promise<Response | undefined>;\n  set(key: string, response: Response): Promise<void>;\n};\n```\n\n```ts\nimport { createCacheApiAdapter, createKvCacheAdapter } from \"artifacts-viewer/server/cache\";\n\ncreateCacheApiAdapter({ cache: caches.default, baseUrl: new URL(request.url).origin });\ncreateKvCacheAdapter({ kv: env.ARTIFACTS_CACHE });\n```\n\n`createCacheApiAdapter` is the recommended backend: it streams, so large blobs never sit in memory. `baseUrl` must be an origin your Worker actually serves — the Cache API is keyed by URL and scoped to the zone.\n\nAdapters may throw. A failing cache degrades to a slower request, never a failed one.\n\n## `artifacts-viewer/client` — typed client\n\nFramework-independent and free of Worker globals.\n\n```ts\nconst client = createArtifactsClient({\n  apiPath: \"/artifacts\", // must match the router\n  fetch: myFetch, // optional\n});\n```\n\nEvery asynchronous request method returns a result union rather than throwing. The synchronous `getRawUrl` helper returns a string:\n\n```ts\ntype ArtifactsResult<T> = { ok: true; value: T } | { ok: false; error: ArtifactsClientError };\n\ntype ArtifactsClientError =\n  | { kind: \"network\"; message: string; cause: unknown }\n  | { kind: \"not-found\"; message: string }\n  | { kind: \"http\"; message: string; status: number }\n  | { kind: \"malformed\"; message: string };\n```\n\n| Method                                                 | Returns                     |\n| ------------------------------------------------------ | --------------------------- |\n| `getRepository({ repoName, signal? })`                 | `ArtifactsRepository`       |\n| `getLog({ repoName, ref?, limit?, offset?, signal? })` | `ArtifactsCommitMetadata[]` |\n| `readCommit({ repoName, hash, signal? })`              | `ArtifactsCommitMetadata`   |\n| `readTree({ repoName, hash, signal? })`                | `ArtifactsTreeEntry[]`      |\n| `readBlob({ repoName, hash, signal? })`                | `Response`                  |\n| `readFile({ repoName, ref, path, signal? })`           | `Response`                  |\n| `getRawUrl({ repoName, ref, path })`                   | `string` (synchronous)      |\n\nBinary reads hand back the `Response` untouched, so you can stream it, size-check it, or discard it. Nothing is buffered or base64-encoded.\n\nJSON payloads are narrowed field by field at the boundary — there are no type assertions — and repository metadata is normalized from the wire's snake_case to camelCase.\n\n```ts\ntype ArtifactsTreeEntry = {\n  name: string;\n  mode: string;\n  hash: string;\n  type: \"tree\" | \"blob\" | \"symlink\" | \"gitlink\" | \"exec\";\n};\n```\n\n`exec` and `symlink` behave like blobs. `gitlink` is a submodule pointer with no content in this repository, and renders as an inert row.\n\n## `artifacts-viewer/react` — components and hooks\n\n### Components\n\n| Component               | Purpose                                                  |\n| ----------------------- | -------------------------------------------------------- |\n| `ArtifactRepoViewer`    | Two-pane browser: sidebar tree plus content pane         |\n| `ArtifactFileTree`      | Lazily expanding sidebar tree                            |\n| `ArtifactDirectoryView` | Flat listing of one directory                            |\n| `ArtifactFileView`      | One file, with Raw/Download and content rendering        |\n| `CodeView`              | Syntax-highlighted text, given contents you already have |\n\nIn `ArtifactRepoViewer` the content pane shows a file when one is selected, and a listing for any directory below the root. The root itself is left blank, because the sidebar already lists it and a second copy reads as a duplicate. `ArtifactDirectoryView` has no such rule — used directly, it lists whatever tree you give it.\n\n`ArtifactRepoViewer` props:\n\n| Prop                  | Type                                    | Notes                                                             |\n| --------------------- | --------------------------------------- | ----------------------------------------------------------------- |\n| `client`              | `ArtifactsClient`                       | Required. Keep the identity stable                                |\n| `repoName`            | `string`                                | Required                                                          |\n| `gitRef`              | `string`                                | Branch, tag, or commit. Omitted resolves the default branch       |\n| `onSelect`            | `(s: ArtifactSelection) => void`        | Fires initially for root, then for each non-gitlink row selection |\n| `buildHref`           | `(s: ArtifactSelection) => string`      | When set, rows render as `<a>` instead of `<button>`              |\n| `icons`               | `Partial<ArtifactIconSlots>`            | `file`, `folder`, `folderOpen`, `submodule`                       |\n| `className` / `style` | —                                       | Applied to the root                                               |\n| `classNames`          | `Partial<Record<ArtifactSlot, string>>` | Per-slot classes                                                  |\n| `colorMode`           | `\"light\" \\| \"dark\" \\| \"system\"`         | Emitted as `data-mode`                                            |\n| `maxInlineBytes`      | `number`                                | Default `524288` (512 KiB)                                        |\n| `pierreDiffsOptions`  | `ArtifactPierreDiffsOptions`            | Code-view theming                                                 |\n| `renderCodeFallback`  | `ArtifactCodeFallbackRenderer`          | Placeholder shown while the code view is prepared                 |\n| `renderStatus`        | `ArtifactStatusRenderers`               | Replaces the loading, empty, and error markup                     |\n\nThe prop is `gitRef`, not `ref`, because React reserves `ref`.\n\n#### `renderStatus` — loading, empty, and error states\n\nBy default every pane renders a plain `<p>` with English text: `Loading repository…`,\n`This directory is empty.`, `Binary file (12.4 KiB).`, and so on. `renderStatus`\nreplaces that markup, which is also how you localise the copy.\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"website\"\n  renderStatus={{\n    loading: (context) => <Spinner label={context.scope} />,\n    empty: (context, kind) => <Notice kind={kind} scope={context.scope} />,\n    error: (context, error) => <Notice tone=\"danger\">{error.message}</Notice>,\n  }}\n/>\n```\n\nEvery renderer receives a discriminated context, so one function can speak\ndifferently per pane:\n\n```ts\ntype ArtifactStatusContext =\n  | { scope: \"repository\"; repoName: string }\n  | { scope: \"tree\"; repoName: string; path: string }\n  | { scope: \"file\"; repoName: string; path: string; name: string };\n\ntype ArtifactEmptyKind = \"empty\" | \"binary\" | \"oversized\";\n\ntype ArtifactStatusRenderers = {\n  loading?: (context: ArtifactStatusContext) => ReactNode;\n  empty?: (context: ArtifactStatusContext, kind?: ArtifactEmptyKind) => ReactNode;\n  error?: (context: ArtifactStatusContext, error: ArtifactsClientError) => ReactNode;\n};\n```\n\n`kind` is only set for files, because a repository or directory is already\nidentified by `context.scope`.\n\nThree things worth knowing:\n\n- **The map is partial.** A renderer returning `undefined` — including one you\n  simply did not name — keeps the default. Return `null` to render nothing.\n- **The slot survives.** Your output is wrapped in the matching slot element, so\n  `data-artifacts-viewer-slot`, `aria-busy`, `role=\"alert\"`, and `data-kind` are\n  still there to target and still announced. You do not repeat them.\n- **`renderStatus` is also accepted by** `ArtifactFileTree`, `ArtifactDirectoryView`,\n  and `ArtifactFileView`, so it works when you compose the pieces yourself.\n\nThe code view is deliberately not covered by `renderStatus`: it waits on the\nhighlighter rather than the network, and [`renderCodeFallback`](#rendercodefallback--what-the-wait-looks-like)\nalready owns that placeholder.\n\n### Hooks\n\nHooks return a discriminated state union — no `isLoading` booleans:\n\n```ts\ntype ArtifactQueryState<T> =\n  | { status: \"idle\" }\n  | { status: \"loading\" }\n  | { status: \"success\"; data: T }\n  | { status: \"error\"; error: ArtifactsClientError };\n```\n\n| Hook                                                                 | Returns                                     |\n| -------------------------------------------------------------------- | ------------------------------------------- |\n| `useArtifactRepository(client, repoName)`                            | `ArtifactsRepository`                       |\n| `useArtifactLog(client, { repoName, ref?, limit?, offset? })`        | `ArtifactsCommitMetadata[]`                 |\n| `useArtifactHeadCommit(client, repoName, ref?)`                      | `ArtifactsCommitMetadata \\| null`           |\n| `useArtifactTree(client, repoName, treeHash \\| null)`                | `ArtifactsTreeEntry[]`                      |\n| `useArtifactBlob(client, { repoName, name, hash, maxInlineBytes? })` | `ArtifactBlobRender`                        |\n| `useArtifactQuery(run, deps)`                                        | Anything; the primitive the others build on |\n\n**There is no cache and no request deduplication.** One request per dependency change, cancelled via `AbortSignal` when dependencies change or the component unmounts. Caching belongs to your data layer; pass a `fetch` that consults it, or wrap the client.\n\n`useArtifactHeadCommit` resolves `null` for an empty repository. Do not use `lastPushAt` for this — the Artifacts API always returns `null` there.\n\n`useArtifactBlob` produces the render classification:\n\n```ts\ntype ArtifactBlobRender =\n  | { kind: \"empty\" }\n  | { kind: \"text\"; contents: string }\n  | { kind: \"image\"; contentType: string }\n  | { kind: \"binary\"; sizeBytes: number }\n  | { kind: \"oversized\" };\n```\n\n### Building your own UI\n\nThe components are composable and customizable, and the hooks are public, so you can replace the markup entirely. `ArtifactRepoViewer` owns its current selection internally.\n\n```tsx\nfunction MyTree({ client, repoName, treeHash }) {\n  const tree = useArtifactTree(client, repoName, treeHash);\n  if (tree.status !== \"success\") return null;\n  return (\n    <ul>\n      {tree.data.map((e) => (\n        <li key={e.hash}>{e.name}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\n## Styling\n\n`artifacts-viewer/styles.css` is **structural only**. It picks no theme, and its main layout values are exposed as custom properties. Its selectors are intentionally low-specificity, using `:where()` for public hooks where practical, so ordinary application rules can override them without `!important`.\n\nImport it once:\n\n```ts\nimport \"artifacts-viewer/styles.css\";\n```\n\n### Custom properties\n\n| Property                                | Default                  |\n| --------------------------------------- | ------------------------ |\n| `--artifacts-viewer-color`              | `inherit`                |\n| `--artifacts-viewer-background`         | `transparent`            |\n| `--artifacts-viewer-font`               | `inherit`                |\n| `--artifacts-viewer-font-size`          | `inherit`                |\n| `--artifacts-viewer-mono-font`          | `monospace`              |\n| `--artifacts-viewer-muted-color`        | `inherit`                |\n| `--artifacts-viewer-error-color`        | `inherit`                |\n| `--artifacts-viewer-icon-color`         | `currentColor`           |\n| `--artifacts-viewer-toolbar-background` | `inherit`                |\n| `--artifacts-viewer-gap`                | `0.5rem`                 |\n| `--artifacts-viewer-indent`             | `1rem`                   |\n| `--artifacts-viewer-row-padding`        | `0.375rem 0.75rem`       |\n| `--artifacts-viewer-sidebar-width`      | `16rem`                  |\n| `--artifacts-viewer-pane-height`        | `none`                   |\n| `--artifacts-viewer-pane-overflow`      | `visible`                |\n| `--artifacts-viewer-image-max-height`   | `80vh`                   |\n| `--artifacts-viewer-focus-outline`      | `2px solid currentColor` |\n| `--artifacts-viewer-focus-offset`       | `-2px`                   |\n\n```css\n.my-viewer {\n  --artifacts-viewer-font: ui-monospace, monospace;\n  --artifacts-viewer-sidebar-width: 18rem;\n  --artifacts-viewer-pane-height: 32rem;\n  --artifacts-viewer-pane-overflow: auto;\n  --artifacts-viewer-muted-color: #6b7280;\n}\n```\n\n### Selector hooks\n\nPublic slots and parts carry stable attributes. Slots also accept a class through `classNames`; internal wrapper elements are not part of this selector contract.\n\n`data-artifacts-viewer-slot`: `root`, `toolbar`, `sidebar`, `tree`, `treeItem`, `content`, `directory`, `directoryItem`, `file`, `loading`, `empty`, `error`\n\n`data-artifacts-viewer-part`: `header`, `name`, `icon`, `actions`, `raw`, `download`, `code`, `code-pending`, `code-fallback`, `code-highlight`, `image`\n\nState attributes:\n\n| Attribute                    | Where                                                                       |\n| ---------------------------- | --------------------------------------------------------------------------- |\n| `data-artifacts-viewer-root` | The root element                                                            |\n| `data-mode`                  | Root; mirrors `colorMode`                                                   |\n| `data-kind`                  | Rows (`tree`, `blob`, `exec`, `symlink`, `gitlink`), and error/notice kinds |\n| `data-selected`              | The selected row                                                            |\n| `data-disabled`              | Submodule rows                                                              |\n\n`data-selected` is a **bare** attribute, so match `[data-selected]` and not `[data-selected=\"true\"]`:\n\n```css\n[data-artifacts-viewer-slot=\"treeItem\"][data-selected] {\n  background: #eef2ff;\n}\n```\n\nThe layout is container-query driven: the two panes collapse to one column below `40rem` of the root's own width, not the viewport's.\n\n## Syntax highlighting\n\nText files render through [`@pierre/diffs`](https://www.npmjs.com/package/@pierre/diffs), which wraps Shiki. It is loaded with a dynamic `import()`, so neither Pierre nor Shiki lands in your initial bundle. `ArtifactRepoViewer` proactively starts fetching the chunk when it mounts.\n\n### Loading\n\nUnhighlighted text is never shown while highlighting is still on its way. Pierre paints an empty node if it is mounted before its theme and grammar are attached, so the code view waits for both, then reveals the highlighted copy only once it has real output. Placeholder and file share one grid cell, so nothing shifts.\n\nThree states, in order:\n\n| State       | Rendered                                                                                            |\n| ----------- | --------------------------------------------------------------------------------------------------- |\n| Preparing   | `renderCodeFallback`, or a loading message by default — `data-artifacts-viewer-part=\"code-pending\"` |\n| Ready       | The highlighted file — `data-artifacts-viewer-part=\"code-highlight\"`                                |\n| Unavailable | Plain `<pre>` holding the file contents — `data-artifacts-viewer-part=\"code-fallback\"`              |\n\nThe unavailable state renders readable text when preloading reports that highlighting could not be initialized. It is not a general error boundary for failures after the lazy component starts rendering.\n\n#### `renderCodeFallback` — what the wait looks like\n\nA render prop, accepted by `ArtifactRepoViewer`, `ArtifactFileView`, and `CodeView`:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  renderCodeFallback={({ name }) => <MySpinner label={`Preparing ${name}`} />}\n/>\n```\n\n```ts\ntype ArtifactCodeFallbackRenderer = (file: { name: string; contents: string }) => ReactNode;\n```\n\n`contents` is the decoded file, so a skeleton can be sized to the real line count.\n\n#### `preloadCodeView` — how long the wait is\n\n**A function, not a prop.** It warms the lazy chunk, the shared Shiki engine, the themes, and — given a file name — that file's grammar.\n\n```ts\nimport { preloadCodeView } from \"artifacts-viewer/react\";\n\nawait preloadCodeView({ theme: { light: \"github-light\", dark: \"vesper\" } });\n```\n\n`ArtifactRepoViewer` already calls it on mount, so warming overlaps with browsing the tree and the first file opened is usually highlighted straight away. Call it yourself to start earlier still — on route entry, or when a row is hovered:\n\n```tsx\n<li onPointerEnter={() => void preloadCodeView({ theme, name: entry.name })}>\n```\n\nMemoized per theme and grammar set, so repeat calls cost nothing. It resolves `boolean`; `false` means highlighting is unavailable, which is what makes the plain-text state above reachable.\n\n### Theming\n\n`@pierre/diffs` renders into an open Shadow DOM. **Your CSS cannot reach inside it.** Theming has to go through options:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  pierreDiffsOptions={{\n    theme: { light: \"github-light\", dark: \"vesper\" },\n    themeType: \"system\",\n  }}\n/>\n```\n\n| Option      | Notes                                                                 |\n| ----------- | --------------------------------------------------------------------- |\n| `theme`     | A Shiki theme name, or a `{ light, dark }` pair                       |\n| `themeType` | `\"system\"` (default), `\"light\"`, or `\"dark\"`                          |\n| `unsafeCSS` | Raw CSS injected into the Shadow DOM. Unstable across Pierre releases |\n\nKeep the object identity stable, or memoize it, to avoid re-rendering the code view.\n\n## Limits and behaviour\n\n**Large files.** `maxInlineBytes` defaults to 512 KiB. If the response declares a larger `Content-Length` the body is never downloaded. Otherwise the stream is read and cancelled the moment the cap is crossed. Either way the state becomes `oversized` and the Raw and Download links still work.\n\n**Images** are detected from the file extension, so they never download through the client — they render straight from the raw URL via `<img src>`. SVG stays in image context and is never inlined. Recognized: `apng`, `avif`, `bmp`, `gif`, `ico`, `jfif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.\n\n**Text vs binary** is decided by a strict UTF-8 decode plus a NUL-byte scan over the first 8 KiB, not by an extension list. Anything that fails is `binary` and never reaches the highlighter.\n\n**Raw and download URLs** are pinned to the commit hash rather than a branch name, so a link keeps resolving — and stays cacheable — even if someone pushes mid-session.\n\n**Tree expansion** is keyed by path, not by tree hash, so two identical subtrees at different paths expand independently.\n\n**Accessibility.** The sidebar is a `<nav>` of nested disclosure buttons rather than `role=\"tree\"`, because full WAI-ARIA tree keyboard navigation is not implemented and claiming the role without it would be worse than not claiming it. Rows are real buttons, or real anchors when `buildHref` is supplied.\n\n## Not included\n\nDeliberately out of scope for now:\n\n- **Markdown rendering.** `.md` files currently display as highlighted plain text.\n- **Diff viewing** and commit history UI.\n- **Branch switching.** The Artifacts REST API exposes no ref-listing operation; enumerating branches needs Git protocol v2 `ls-refs`. Pass a known branch to `gitRef`, or omit it for the default branch.\n- **Writes** of any kind.\n\n## License\n\nMIT\n","worker/agents/think/skills/app-ai-binding/SKILL.md":"---\nname: app-ai-binding\ndescription: Build AI features in generated apps using the platform-provided Workers AI binding. Load this skill for chatbots, summarization, classification, embeddings, semantic search, image understanding, transcription, translation, or other model inference.\n---\n\nGenerated apps can call Workers AI from the `App` Durable Object through `this.env.AI`. The platform provides this binding automatically in Dynamic Worker previews and native Cloudflare deployments. Do not add an `ai` section to the generated app's `wrangler.json`.\n\nUse only `AI.run(model, inputs, options?)`. Preview uses a restricted proxy with this method, while deployed Workers receive the native Workers AI binding. Methods such as `AI.gateway()`, `AI.models()`, `AI.autorag()`, and `AI.toMarkdown()` are not portable between preview and deployment.\n\n## Type and usage\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\n\ninterface Env {\n  AI: Ai;\n}\n\nexport class App extends DurableObject<Env> {\n  async fetch(request: Request): Promise<Response> {\n    const { prompt } = await request.json<{ prompt: string }>();\n    const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n      messages: [\n        { role: \"system\", content: \"Answer clearly and concisely.\" },\n        { role: \"user\", content: prompt },\n      ],\n    });\n    return Response.json(result);\n  }\n}\n```\n\nKeep AI calls in the server-side `App` class. Browser code should call an app API route; it must never receive model credentials or call the binding directly.\n\n## Common tasks\n\n### Text generation\n\nUse `@cf/meta/llama-4-scout-17b-16e-instruct` with either `prompt` or `messages`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n  messages: [{ role: \"user\", content: userPrompt }],\n  max_tokens: 512,\n});\n```\n\n### Embeddings\n\nUse `@cf/baai/bge-base-en-v1.5`. The response contains embedding vectors in `data`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/baai/bge-base-en-v1.5\", {\n  text: [\"first document\", \"second document\"],\n});\n```\n\n### Image classification\n\nUse `@cf/microsoft/resnet-50` with image bytes converted to a number array:\n\n```ts\nconst image = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/microsoft/resnet-50\", { image });\n```\n\n### Speech transcription\n\nUse `@cf/openai/whisper` with audio bytes:\n\n```ts\nconst audio = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/openai/whisper\", { audio });\n```\n\nModel input and output shapes differ. Keep model-specific parsing close to the call and validate user input before inference. Use only active model IDs from the current Workers AI model catalog; never generate code with a deprecated model.\n\n## Limits and errors\n\nDynamic Worker previews enforce a per-app AI call limit. AI features must fail transparently when inference is unavailable.\n\n- Never return canned, random, mock, placeholder, or hard-coded content as a fallback for an AI response.\n- Never catch an `AI.run()` error and continue with an apparently successful response.\n- Never return `200` with fields such as `fallbackUsed` after inference fails.\n- Return a non-2xx JSON response containing a safe, useful error message. The frontend must display that error to the user.\n- Do not retry rate-limit failures in a loop.\n\n```ts\ntry {\n  const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n    messages: [{ role: \"user\", content: userPrompt }],\n  });\n  return Response.json(result);\n} catch (error) {\n  const message = error instanceof Error ? error.message : \"AI inference failed\";\n  return Response.json({ error: \"AI inference failed\", details: message }, { status: 503 });\n}\n```\n\nFrontend callers must inspect both the HTTP status and error body:\n\n```ts\nconst response = await fetch(\"./api/chat\", requestOptions);\nconst body = await response.json<{ content?: string; error?: string; details?: string }>();\nif (!response.ok) throw new Error(body.details || body.error || \"AI request failed\");\n```\n\nUse non-streaming calls for preview-compatible code. Native Workers AI deployments support `{ stream: true }`, but streaming across the preview proxy is not guaranteed.\n","worker/agents/think/skills/app-file-structure/SKILL.md":"---\nname: cloudflare-bundler-apps\ndescription: Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts).\n---\n\nThis skill teaches how to build apps that deploy cleanly through the space deploy pipeline. Every project committed to a space is built by `@cloudflare/worker-bundler` (`createApp` when there are static assets, `createWorker` when there are none) and served on a Dynamic Worker via `WorkerLoader`. Get the conventions right and the preview \"just works\". Get them wrong and you get a blank page, a 500, or a `Build failed` error.\n\n## How the pipeline works\n\nWhen `deploy_space(branch)` is called, the space DO:\n\n1. Reads every file from the branch's working tree (skipping `.git/`).\n2. Parses `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` for `main`, `compatibility_date`, `compatibility_flags`, and `[assets]`.\n3. If `[assets].directory` is set, files under that directory become static assets served host-side by `handleAssetRequest`. Everything else is built into a Worker via `createApp` (with `server: <main>`).\n4. If no assets directory is configured, only `createWorker({ entryPoint: <main> })` is run. The output is loaded as a Dynamic Worker; all requests go to the Worker.\n5. Previews are served at `/space/:name/preview/:branch/*`. Responses with `content-type: text/html` are run through `HTMLRewriter`, which prefixes root-relative `src` / `href` / `action` attributes with the preview base path. **JS-side fetches and dynamic imports are NOT rewritten.**\n\nPractical consequences:\n\n- **You can rely on root-relative `src=\"/foo.js\"` and `href=\"/style.css\"` in HTML.** They will be rewritten to the preview path automatically.\n- **You cannot rely on root-relative paths inside JS strings**: `fetch(\"/api/x\")`, `new URL(\"/foo\", location.origin)`, dynamic `import(\"/lib.js\")`. These hit the wrong path under the preview prefix. Use relative paths (`./api/x`, `import.meta.url`) or read the base path from `<base href>` / a meta tag injected at build time.\n- The `<base href>` tag is also rewritten if present — using it lets all relative URLs resolve against the preview path.\n\n## Project layout\n\nA typical full-stack space project:\n\n```\n/\n├── wrangler.json          # required for non-trivial setups\n├── package.json           # npm deps (text-only packages, see limits below)\n├── src/\n│   └── index.ts           # server entry: export default { fetch }\n└── public/                # static assets directory (configurable)\n    ├── index.html\n    ├── app.js             # compiled, no JSX\n    └── styles.css\n```\n\nA static-only SPA can skip `src/` entirely — just `wrangler.json` + `public/` is enough as long as `main` points to a minimal pass-through worker or you accept that all requests fall through assets.\n\n## Wrangler config\n\nMinimum viable `wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\nNotes:\n\n- `main` is required when there's any server code. The bundler also auto-detects `src/index.ts`, `src/index.js`, `index.ts`, `index.js` if missing.\n- `compatibility_date` defaults to `2025-04-01` if omitted. Set it explicitly for newer features.\n- Add `\"compatibility_flags\": [\"nodejs_compat\"]` only if you actually need Node built-ins.\n- `assets.directory` is the **only** way to ship static files. Files outside this directory are bundled into the Worker or ignored — they will **not** be reachable via URL.\n- `html_handling: \"auto-trailing-slash\"` is usually what you want for multi-page sites; SPAs should also set `not_found_handling: \"single-page-application\"` so deep links return `index.html`.\n\nTOML works too (`wrangler.toml`), but the parser only handles top-level scalar fields plus an `[assets]` table — no inline tables, no env overrides. Prefer JSON.\n\n## Server entry: one Durable Object class named `App`\n\n**Your entire backend is one Durable Object.** Your main module exports\na class named `App` extending `DurableObject`. The platform loads it as\na [Cloudflare Durable Object\nFacet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/)\nof the host SpaceDO and forwards every non-asset request — including\nWebSocket upgrades — to your `App.fetch(request)`.\n\n**Do not `export default { fetch }`.** A default fetch handler will be\nignored. There is no separate worker; the DO is the worker.\n\n```ts\n// src/index.ts\nimport { DurableObject } from \"cloudflare:workers\";\n\nexport class App extends DurableObject {\n  async fetch(request: Request): Promise<Response> {\n    const url = new URL(request.url);\n\n    // Lazy schema init on the first request. Idempotent.\n    this.ctx.storage.sql.exec(`\n      CREATE TABLE IF NOT EXISTS notes (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        body TEXT NOT NULL\n      )\n    `);\n\n    if (url.pathname === \"/api/notes\" && request.method === \"POST\") {\n      const { body } = await request.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return new Response(null, { status: 201 });\n    }\n    if (url.pathname === \"/api/notes\") {\n      const rows = this.ctx.storage.sql\n        .exec(`SELECT id, body FROM notes ORDER BY id DESC`)\n        .toArray();\n      return Response.json(rows);\n    }\n    return new Response(\"Not found\", { status: 404 });\n  }\n}\n```\n\nOr with Hono inside the DO:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/hello\", (c) => c.json({ msg: \"hi\" }))\n    .post(\"/api/notes\", async (c) => {\n      const { body } = await c.req.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return c.json({ ok: true });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\nWhen `[assets]` is configured, static files take priority — your `fetch`\nonly sees requests that didn't match an asset. For a SPA, put\n`index.html` in `public/` and `App.fetch` only handles `/api/*`.\n\n### State and storage\n\nState lives on `this.ctx.storage`. No `env.DB` exists; you do not need\nto declare any binding. The DO's storage is a per-(space, branch)\nSQLite database that survives redeploys.\n\n- `this.ctx.storage.sql.exec(sql, ...params)` — full SQLite. Returns a\n  cursor with `.toArray()`, `.one()`, `.columnNames`, etc.\n- `this.ctx.storage.kv.get(key)` / `.put(key, value)` — simple KV\n  backed by SQLite.\n- `this.ctx.storage.transactionSync(() => { ... })` — atomic batch\n  inside the same DO instance.\n\n```ts\n// SQL\nthis.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS counter (n INTEGER)`);\nconst cur = this.ctx.storage.sql.exec(`SELECT n FROM counter LIMIT 1`).toArray();\nconst n = (cur[0]?.n as number) ?? 0;\n\n// KV\nconst last = this.ctx.storage.kv.get<number>(\"last_seen\") ?? 0;\nthis.ctx.storage.kv.put(\"last_seen\", Date.now());\n```\n\n**Use the DO's storage for all persistent state** — users, sessions,\ncontent, settings, anything that should survive a reload. Do not use\n`localStorage` or `sessionStorage` for primary data; they are\nper-browser, per-origin, and lost on incognito or any other browser.\n\n### WebSockets\n\nThe platform forwards `Upgrade: websocket` requests transparently into\n`App.fetch`. Use the standard Durable Object hibernation API:\n\n```ts\nexport class App extends DurableObject {\n  async fetch(request: Request) {\n    const upgrade = request.headers.get(\"Upgrade\");\n    if (upgrade === \"websocket\") {\n      const { 0: client, 1: server } = new WebSocketPair();\n      this.ctx.acceptWebSocket(server);\n      return new Response(null, { status: 101, webSocket: client });\n    }\n    return new Response(\"not found\", { status: 404 });\n  }\n\n  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {\n    // Broadcast to every connected client (chat-style fan-out).\n    for (const peer of this.ctx.getWebSockets()) {\n      peer.send(typeof message === \"string\" ? message : new Uint8Array(message));\n    }\n  }\n\n  webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {\n    // Cleanup if needed.\n  }\n}\n```\n\n`acceptWebSocket` + `webSocketMessage` is the right pattern for\nmulti-connection use cases (chat rooms, multiplayer, presence) because\nthe DO can hibernate between messages without losing connections.\n\n### Per-entity grouping (chat rooms, per-user records)\n\nYou get **one App DO per (space, branch)** — a single instance, not a\nnamespace. Do not call `idFromName` or try to declare a DO binding in\n`wrangler.json`; both are rejected. For multi-room / multi-user state,\n**partition rows with a column** in the App's SQLite:\n\n```sql\nCREATE TABLE messages (\n  room_id TEXT NOT NULL,\n  user_id TEXT NOT NULL,\n  body TEXT NOT NULL,\n  ts INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)\n);\nCREATE INDEX messages_by_room ON messages (room_id, ts);\n```\n\nThen route by `room_id` inside `App.fetch`. SQLite is fast enough that\nsharing the same table across rooms is fine for prototyping; if you\nlater need stronger isolation, attach `room_id` to a tag and use\n`acceptWebSocket(ws, [room_id])` so `getWebSockets(room_id)` returns\nonly that room's sockets.\n\n### What you cannot declare\n\n- **`durable_objects.bindings` in `wrangler.json`.** The platform\n  extracts your `App` class automatically. Adding bindings fails the\n  deploy with a clear error.\n- **`new_sqlite_classes` / migrations.** Same reason. Your App's\n  storage is set up by the platform.\n- **`d1_databases`, `r2_buckets`, `kv_namespaces`.** Not available.\n  Everything goes in `this.ctx.storage`.\n\n## Static asset rules (read this twice)\n\nThis is where most preview failures happen. The browser eventually runs your assets — the bundler does **not** transform them. So:\n\n### **NEVER ship JSX in a `<script type=\"module\">` (or any other script tag).** Browsers cannot parse JSX.\n\nThis page **will be blank** with a `SyntaxError: Unexpected token '<'`:\n\n```html\n<script type=\"module\">\n  import React from \"https://esm.sh/react@18\";\n  const App = () => <div>hi</div>;   // ← browser dies here\n</script>\n```\n\nFix one of these three ways:\n\n1. **Pre-compile** — write JSX in a build step (Vite/esbuild/tsup) and emit plain JS into `public/`. Best for production.\n2. **`React.createElement` by hand** — works without a build step but is verbose.\n3. **`@babel/standalone`** — only for prototypes. Load Babel **before** the script and use `type=\"text/babel\"`. The modern `@babel/preset-react` defaults to the **automatic JSX runtime**, which emits `import { jsx } from \"react/jsx-runtime\"`. Browsers will reject that bare specifier unless your importmap maps it. **Always ship a complete importmap alongside the Babel script:**\n\n   ```html\n   <script type=\"importmap\">\n   {\n     \"imports\": {\n       \"react\": \"https://esm.sh/react@18.3.1\",\n       \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n       \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n       \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\"\n     }\n   }\n   </script>\n   <script src=\"https://unpkg.com/@babel/standalone/babel.min.js\"></script>\n   <script type=\"text/babel\" data-type=\"module\" data-presets=\"react\">\n     import React from \"react\";\n     import { createRoot } from \"react-dom/client\";\n     const App = () => <div>hi</div>;\n     createRoot(document.getElementById(\"root\")).render(<App />);\n   </script>\n   ```\n\n   `data-type=\"module\"` is required for `import` to work inside the Babel script. Import React from the importmap key (`\"react\"`) — not from a hard-coded `esm.sh` URL — so your code and Babel's emitted `react/jsx-runtime` import resolve to the **same** React instance.\n\n   If you really cannot ship `react/jsx-runtime` in the importmap, force Babel to the legacy classic runtime instead so it emits `React.createElement` calls and never touches `react/jsx-runtime`:\n\n   ```html\n   <script\n     type=\"text/babel\"\n     data-type=\"module\"\n     data-presets=\"react\"\n     data-plugins='[[\"transform-react-jsx\", { \"runtime\": \"classic\" }]]'\n   >\n     import React from \"https://esm.sh/react@18.3.1\";  /* must be in scope */\n     const App = () => <div>hi</div>;\n   </script>\n   ```\n\n   The classic runtime requires `React` to be in lexical scope (because `<div>` becomes `React.createElement(\"div\")`). The automatic runtime (default) does not, but needs `react/jsx-runtime` resolvable.\n\n### Symptom-to-fix index\n\n| Console error | Cause | Fix |\n| --- | --- | --- |\n| `Uncaught SyntaxError: Unexpected token '<'` (inside `<script type=\"module\">`) | JSX shipped raw to the browser | Pre-compile, or use `@babel/standalone` with `type=\"text/babel\"` |\n| `Uncaught TypeError: Failed to resolve module specifier \"react/jsx-runtime\". Relative references must start with either \"/\", \"./\", or \"../\".` | Automatic JSX runtime emits `import \"react/jsx-runtime\"` but importmap doesn't map it | Add `\"react/jsx-runtime\": \"https://esm.sh/react@<same-version>/jsx-runtime\"` to the importmap **before** the Babel/script tag — or force the classic runtime (see above) |\n| `Uncaught TypeError: Failed to resolve module specifier \"react\"` (or any bare name) | No importmap entry for that package | Add it to the importmap; importmap script tag must appear **before** any module script that uses the specifier |\n| `TypeError: Cannot read properties of null (reading 'useContext')` | Dual-React (two copies loaded) | Append `?external=react,react-dom` to every esm.sh URL with React as a peer dep — see the dual-React trap below |\n\n### Other asset gotchas\n\n- **Binary assets are not extracted from npm packages.** Fonts, images, and `.wasm` shipped via npm tarballs will be missing. Put binaries directly in `public/` instead.\n- **Paths in JS need care under preview**: prefer `import.meta.url`, relative paths, or a runtime base detected from `document.baseURI`. Don't hard-code `/api/...` in client code; use `./api/...` or read a base from a `<meta>` tag.\n- **Trailing slashes matter** for `auto-trailing-slash` mode: `/about` serves `about.html`, `/about/` serves `about/index.html`. Pick one shape and link consistently.\n- **No CSS preprocessors at runtime** — ship `.css`, not `.scss`. Compile first if you need Sass.\n- **Importmaps work** since they're inline JSON. Use them to avoid bundling React/etc. for prototypes — but watch the dual-React trap below.\n\n### The dual-React trap (the #1 cause of \"blank page + `useContext` is null`)\n\n`esm.sh` bundles a package's peer dependencies *into the package itself* unless you tell it otherwise. So this importmap looks correct but ships **two copies of React** — one for your app, another nested inside `framer-motion`:\n\n```html\n<!-- BROKEN: framer-motion has its own React inside, hooks crash with `Cannot read properties of null (reading 'useContext')` -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.2.0\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.2.0/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11\"\n  }\n}\n</script>\n```\n\nThe symptom is exactly: blank background + console errors like\n\n```\nTypeError: Cannot read properties of null (reading 'useContext')\n  at u (SwitchLayoutGroupContext.mjs:1:1)\n```\n\nbecause the nested React instance has no provider in the tree.\n\n**Fix: append `?external=react,react-dom` (and any other shared peers) to every esm.sh URL that has React as a peer dep.** Pin the exact same React version everywhere too.\n\n```html\n<!-- CORRECT: one React shared across the whole importmap -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.3.1\",\n    \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n    \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11?external=react,react-dom\",\n    \"lucide-react\": \"https://esm.sh/lucide-react@0.400.0?external=react,react-dom\"\n  }\n}\n</script>\n```\n\nApply `?external=...` to **every** React-consuming dep (UI libraries, icon sets, animation libs, headless components). Same rule for `vue`, `solid-js`, etc. if you swap the framework.\n\nIf a project needs more than two or three of these packages, stop using the importmap path — switch to option 1 (pre-compile with Vite/esbuild) so the bundler can dedupe React for you.\n\n## npm dependencies\n\nThe bundler installs deps from npm at build time. Limits to respect:\n\n- **Flat node_modules.** No two versions of the same package can coexist — peer-dep conflicts will pick one and break the other. Keep dep graphs shallow.\n- **Text-only files** are extracted from tarballs. `.js`, `.ts`, `.json`, `.css`, `.md` work. `.wasm`, `.node`, native binaries, fonts, images do not.\n- **No PAX tar headers** — packages whose internal paths exceed 100 chars may have those files silently dropped. Avoid deeply-nested monorepo packages.\n- **No build scripts run** — `postinstall`, `prepare`, etc. are ignored. Packages that compile native code or run codegen at install time will not work.\n- **`cloudflare:*` imports are always external** and resolved by the runtime (`cloudflare:workers`, etc.). Don't add them to `package.json`.\n\nSafe and well-tested deps: `hono`, `zod`, `itty-router`, `nanoid`, `valibot`, `@hono/zod-validator`. Avoid anything that needs node-native modules unless you set `compatibility_flags: [\"nodejs_compat\"]` and the package is pure JS under that flag.\n\n## Project shapes\n\n| Project shape                       | What you need                                                                          |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| Pure static site (HTML+JS+CSS only) | `wrangler.json` with `[assets]`, files in `public/`. No `main`. No App class.          |\n| SPA + API                           | `[assets]` for the SPA, `main` exporting `class App extends DurableObject`             |\n| API only (JSON, no frontend)        | `main` exporting `class App extends DurableObject`, no `[assets]`                      |\n| Realtime (WebSocket / multiplayer)  | Same as SPA + API; `App.fetch` upgrades to WebSocket and uses `ctx.acceptWebSocket`    |\n\n## Pre-flight checklist before `deploy_space`\n\nRun through every item — most \"preview is broken\" reports trace back to one of these.\n\n- `wrangler.json` exists at repo root with `main` and (if static assets exist) `[assets].directory`.\n- Every browser-loaded `.js` / `.mjs` / `<script type=\"module\">` is **plain JS, no JSX**, or wrapped with `@babel/standalone` + `type=\"text/babel\"`.\n- If you use React via importmap (Babel-standalone or pre-compiled with the automatic JSX runtime), the importmap maps **all** of `react`, `react/jsx-runtime`, `react-dom`, and `react-dom/client` — same version on every entry. Missing `react/jsx-runtime` produces `Failed to resolve module specifier \"react/jsx-runtime\"` and a blank page.\n- The `<script type=\"importmap\">` tag appears **before** any `<script type=\"module\">` (or `type=\"text/babel\" data-type=\"module\"`) that depends on the mapped specifiers.\n- Static files live under the configured `assets.directory` (default suggestion: `public/`).\n- HTML uses root-relative paths (`/foo.js`) — they get rewritten. JS uses relative paths (`./foo`).\n- No binary assets are imported from npm packages. Binaries live in `public/`.\n- `package.json` deps are pure-JS, no native modules, no install scripts.\n- SPA routing? Set `not_found_handling: \"single-page-application\"`.\n- Server entry exports `class App extends DurableObject` from your `main` module (no `export default { fetch }`).\n- No `durable_objects` / `d1_databases` / `kv_namespaces` / `r2_buckets` blocks in `wrangler.json`.\n- `compatibility_date` is set if you use APIs newer than the default.\n\nIf any of these are off, fix them in the working tree, commit, and redeploy. The preview will pick up the new build on the next `deploy_space` call (the dynamic worker is keyed by commit hash, so old builds are not reused).\n\n## Minimal end-to-end example\n\nA working SPA with a tiny API:\n\n```\nwrangler.json\npackage.json\nsrc/index.ts\npublic/index.html\npublic/app.js\npublic/style.css\n```\n\n`wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\n`package.json`:\n\n```json\n{ \"name\": \"demo\", \"type\": \"module\", \"dependencies\": { \"hono\": \"^4.6.0\" } }\n```\n\n`src/index.ts`:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/time\", (c) => c.json({ now: new Date().toISOString() }))\n    .post(\"/api/visit\", async (c) => {\n      this.ctx.storage.sql.exec(\n        `CREATE TABLE IF NOT EXISTS visits (id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER)`\n      );\n      this.ctx.storage.sql.exec(`INSERT INTO visits (ts) VALUES (?)`, Date.now());\n      const cnt = this.ctx.storage.sql\n        .exec(`SELECT COUNT(*) AS c FROM visits`)\n        .one().c as number;\n      return c.json({ count: cnt });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\n`public/index.html`:\n\n```html\n<!doctype html>\n<html>\n<head>\n  <link rel=\"stylesheet\" href=\"/style.css\">\n</head>\n<body>\n  <div id=\"root\">Loading…</div>\n  <script type=\"module\" src=\"/app.js\"></script>\n</body>\n</html>\n```\n\n`public/app.js`:\n\n```js\nconst res = await fetch(\"./api/time\");\nconst data = await res.json();\ndocument.getElementById(\"root\").textContent = data.now;\n\n// Increment + display visit counter (server state lives in App.ctx.storage.sql)\nconst v = await fetch(\"./api/visit\", { method: \"POST\" }).then((r) => r.json());\ndocument.getElementById(\"root\").textContent += `  ·  visits: ${v.count}`;\n```\n\nNote `./api/time` (relative) in JS, `/style.css` (root-relative) in HTML. The HTML href is rewritten by the preview; the JS fetch resolves against the document's base URL.\n\nCommit this, call `deploy_space(\"main\")`, and the preview at `/space/<name>/preview/main/` will render the timestamp and an incrementing visit counter persisted in the App DO's SQLite.\n","worker/agents/think/skills/frontend-design-landing-page/SKILL.md":"---\nname: frontend-design-landing-page\ndescription: Marketing landing page and conversion-focused product page reference. Use this skill when building hero sections, feature grids, pricing pages, testimonials, CTAs, footers, navigation bars, or any public-facing marketing surface. Covers a warm, professional, developer-friendly design language (cream backgrounds, generous whitespace, pill CTAs, corner-bracket card decorations) and a complete token set, animation system, and copy-paste component snippets. NOT for product/dashboard UIs — use frontend-design-saas for those.\n---\n\n# Landing Page Design System\n\n> **AI-Optimized Design Reference** for building warm, professional, conversion-focused marketing landing pages, product pages, feature pages, and pricing pages.\n\nThis skill captures a specific landing-page register: warm cream backgrounds (NOT pure white), brown text (NOT pure black), a single bold accent for CTAs, pill-rounded buttons, and signature corner-bracket card decorations. It's tuned for developer-adjacent / B2B products — the aesthetic feels human and crafted without being playful or consumer-y. Pin the accent color to your brand; the rest of the system is reusable as-is.\n\n> **When to use this vs. `frontend-design-saas`:** Landing-page surfaces (homepage, /features, /pricing, /about, blog) → this skill. Product UI behind the login (dashboards, settings, tables) → `frontend-design-saas`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #FF4801 (replace with your brand) / #FF7038 (hover)\nBackground:      #FFFBF5 (warm cream) / #121212 (dark mode)\nText:            #521000 (warm brown) / #F0E3DE (dark mode)\nBorder:          #EBD5C1 (cream-tinted)\nFont Sans:       \"Inter\" / \"Geist\" / \"Manrope\" / any modern grotesk\nFont Mono:       \"JetBrains Mono\" / \"IBM Plex Mono\" / \"Geist Mono\"\nBase Spacing:    4px (multiples: 8, 12, 16, 24, 32, 48, 64)\nBorder Radius:   Buttons = pill (9999px), Cards = 12-16px, Inputs = 8px, Hero = 16-20px\nSignature:       Corner brackets on cards (8px decorative squares at corners)\n```\n\nThis is a **landing-page** register, not a product-UI register. Buttons are pills (consumer/marketing read), backgrounds are warm cream (inviting, not sterile), and decorative idioms like corner brackets are welcome. For product surfaces behind auth, use the SaaS skill instead.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Warm but Technical** | Cream tones soften technical content |\n| **Professional yet Approachable** | Modern typography, generous whitespace |\n| **Developer-Focused** | Monospace for code, terminal aesthetics |\n| **Performance-Oriented** | Smooth animations convey speed |\n\n### Visual Identity\n\n- **Never use pure white** (`#FFFFFF`) for backgrounds — always warm cream (`#FFFBF5`)\n- **Never use pure black** (`#000000`) for text — always warm brown (`#521000`)\n- **Orange is the accent**, not the dominant color\n- **Corner brackets** on cards are a signature decorative element\n- **Dot patterns** and **dashed lines** add visual texture\n\n---\n\n## 2. Color System\n\n### 2.1 Primary Palette (Light Mode)\n\n| Token | Hex | RGB | Usage |\n|-------|-----|-----|-------|\n| `--lp-accent` | `#FF4801` | `rgb(255, 72, 1)` | Primary accent, CTAs, links |\n| `--lp-accent-hover` | `#FF7038` | `rgb(255, 112, 56)` | Hover states |\n| `--lp-accent-light` | `rgba(255, 72, 1, 0.1)` | — | Badges, light backgrounds |\n| `--lp-text` | `#521000` | `rgb(82, 16, 0)` | Primary text |\n| `--lp-text-muted` | `rgba(82, 16, 0, 0.6)` | — | Secondary text |\n| `--lp-text-subtle` | `rgba(82, 16, 0, 0.38)` | — | Tertiary text, placeholders |\n| `--lp-bg-page` | `#F5F1EB` | `rgb(245, 241, 235)` | Page background (outer) |\n| `--lp-bg-100` | `#FFFBF5` | `rgb(255, 251, 245)` | Primary background |\n| `--lp-bg-200` | `#FFFDFB` | `rgb(255, 253, 251)` | Card backgrounds |\n| `--lp-bg-300` | `#FEF7ED` | `rgb(254, 247, 237)` | Hover backgrounds |\n| `--lp-border` | `#EBD5C1` | `rgb(235, 213, 193)` | Borders, dividers |\n| `--lp-border-light` | `rgba(235, 213, 193, 0.5)` | — | Subtle borders |\n\n### 2.2 Primary Palette (Dark Mode)\n\n| Token | Hex | Usage |\n|-------|-----|-------|\n| `--lp-accent` | `#F14602` | Primary accent |\n| `--lp-accent-hover` | `#FF6D33` | Hover states |\n| `--lp-text` | `#F0E3DE` | Primary text |\n| `--lp-text-muted` | `rgba(255, 253, 251, 0.56)` | Secondary text |\n| `--lp-bg-100` | `#121212` | Primary background |\n| `--lp-bg-200` | `#191817` | Card backgrounds |\n| `--lp-bg-300` | `#2A2927` | Hover backgrounds |\n| `--lp-border` | `rgba(240, 227, 222, 0.13)` | Borders |\n\n### 2.3 Category / Tag Colors (optional)\n\nIf your product has multiple feature categories that appear as tags or pills throughout the page (feature cards, navigation, etc.), define a small categorical palette. Keep luminance similar across the set so no single color overpowers the others.\n\n| Slot | Primary | Background | Example use |\n|----------|---------|------------|-------|\n| Category A | `#0A95FF` | `rgba(10, 149, 255, 0.1)` | \"Compute\" / \"Performance\" |\n| Category B | `#EE0DDB` | `rgba(238, 13, 219, 0.1)` | \"Storage\" / \"Data\" |\n| Category C | `#19E306` | `#F2F5E1` | \"AI\" / \"Intelligence\" |\n| Category D | `#9616FF` | `#F8EBEE` | \"Media\" / \"Edge\" |\n\nThese are placeholders — swap to your product's category palette. The goal is a small set of visually distinct accent colors used only on category labels, NEVER on primary CTAs (the brand accent owns that role).\n\n### 2.4 Semantic Colors\n\n| Purpose | Light Mode | Dark Mode |\n|---------|------------|-----------|\n| Success | `#16A34A` | `#4ADE80` |\n| Warning | `#EAB308` | `#FACC15` |\n| Error | `#DC2626` | `#F87171` |\n| Info | `#2563EB` | `#60A5FA` |\n\n---\n\n## 3. Typography\n\n### 3.1 Font Families\n\n```css\n--font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n--font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", \"Consolas\", monospace;\n```\n\n**Font Files:**\n- `Kunst Grotesk Regular.woff2` (400)\n- `Kunst Grotesk Medium.woff2` (500)\n- `JetBrains Mono Regular.woff2` (400)\n\n### 3.2 Type Scale\n\n| Name | Size | Line Height | Weight | Usage |\n|------|------|-------------|--------|-------|\n| `xs` | 12px (0.75rem) | 1.33 | 400 | Badges, captions, footnotes |\n| `sm` | 14px (0.875rem) | 1.43 | 400 | Secondary text, labels |\n| `base` | 16px (1rem) | 1.5 | 400 | Body text |\n| `lg` | 18px (1.125rem) | 1.56 | 400/500 | Large body, subheadings |\n| `xl` | 20px (1.25rem) | 1.4 | 500 | Section titles |\n| `2xl` | 24px (1.5rem) | 1.33 | 500 | Card headings |\n| `3xl` | 30px (1.875rem) | 1.2 | 500 | Section headings |\n| `4xl` | 36px (2.25rem) | 1.11 | 500 | Page headings |\n| `5xl` | 48px (3rem) | 1.0 | 500 | Hero headings |\n\n### 3.3 Font Weights\n\n| Weight | Value | Usage |\n|--------|-------|-------|\n| Normal | 400 | Body text, descriptions |\n| Medium | 500 | Headings, buttons, emphasis |\n\n### 3.4 Letter Spacing\n\n| Context | Value | CSS |\n|---------|-------|-----|\n| Headings | -0.02em | `letter-spacing: -0.02em` |\n| Body | Normal | `letter-spacing: normal` |\n| Uppercase labels | 0.05em | `letter-spacing: 0.05em` |\n| Logo text | -0.46px | `letter-spacing: -0.46px` |\n\n### 3.5 Text Rendering\n\n```css\nbody {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--spacing-unit: 4px;\n```\n\n### 4.2 Spacing Scale\n\n| Token | Value | Pixels | Common Usage |\n|-------|-------|--------|--------------|\n| `0` | 0 | 0px | Reset |\n| `0.5` | 0.125rem | 2px | Tiny gaps |\n| `1` | 0.25rem | 4px | Tight spacing |\n| `1.5` | 0.375rem | 6px | Small gaps |\n| `2` | 0.5rem | 8px | Default small padding |\n| `3` | 0.75rem | 12px | Input padding, card gaps |\n| `4` | 1rem | 16px | Standard padding |\n| `5` | 1.25rem | 20px | Medium spacing |\n| `6` | 1.5rem | 24px | Section padding (mobile) |\n| `8` | 2rem | 32px | Large padding |\n| `10` | 2.5rem | 40px | Section gaps |\n| `12` | 3rem | 48px | Large section gaps |\n| `16` | 4rem | 64px | Hero padding |\n| `20` | 5rem | 80px | Major sections |\n| `24` | 6rem | 96px | Max section spacing |\n\n### 4.3 Common Spacing Patterns\n\n```css\n/* Card padding */\npadding: 24px;  /* p-6 */\n\n/* Input padding */\npadding: 12px;  /* p-3 */\n\n/* Button padding */\npadding: 12px 24px;  /* py-3 px-6 */\n\n/* Section padding (responsive) */\npadding: 32px 16px;  /* Mobile */\npadding: 48px 32px;  /* Tablet */\npadding: 64px 48px;  /* Desktop */\n\n/* Grid gaps */\ngap: 16px;  /* Cards */\ngap: 24px;  /* Sections */\n```\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `rounded-sm` | 4px | Small badges |\n| `rounded` | 6px | Tags, small elements |\n| `rounded-md` | 8px | Inputs |\n| `rounded-lg` | 12px | Icon containers, panels |\n| `rounded-xl` | 16px | Hero sections |\n| `rounded-2xl` | 20px | Large hero sections |\n| `rounded-full` | 9999px | Buttons, pills, avatars |\n| `rounded-none` | 0 | Cards (sharp edges) |\n\n### Common Patterns\n\n```css\n/* Buttons - always fully rounded */\nborder-radius: 9999px;\n\n/* Cards - sharp edges */\nborder-radius: 0;\n\n/* Inputs */\nborder-radius: 8px;\n\n/* Hero sections (desktop) */\nborder-radius: 16px;\n\n/* Progress bars */\nborder-radius: 9999px;\n\n/* Icon containers */\nborder-radius: 8px;\n```\n\n---\n\n## 6. Shadow System\n\n### 6.1 Shadow Stack (Signature Effect)\n\nUsed on hero sections and elevated cards for depth with inner glow.\n\n```css\n/* Light Mode */\n--shadow-stack: \n  1px 6px 6px 0 rgba(255, 255, 255, 0.2) inset,\n  0 0 0px 0 rgba(255, 255, 255, 0.35) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n\n/* Dark Mode */\n--shadow-stack-dark:\n  1px 6px 16px 0 rgba(255, 255, 255, 0.05) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n```\n\n### 6.2 Utility Shadows\n\n```css\n/* Subtle shadow for cards */\n--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n\n/* Standard card shadow */\n--shadow-card: \n  0 1px 3px rgba(82, 16, 0, 0.04),\n  0 4px 12px rgba(82, 16, 0, 0.02);\n\n/* Elevated shadow */\n--shadow-lg: \n  0 10px 15px -3px rgba(0, 0, 0, 0.1),\n  0 4px 6px -4px rgba(0, 0, 0, 0.1);\n\n/* Focus ring shadow */\n--shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n```\n\n---\n\n## 7. Animation System\n\n### 7.1 Timing Functions\n\n```css\n/* Standard ease-out (default for most transitions) */\n--ease-out: cubic-bezier(0, 0, 0.2, 1);\n\n/* Button interactions */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press states */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Smooth deceleration */\n--ease-decel: cubic-bezier(0.4, 0, 0.2, 1);\n```\n\n### 7.2 Duration Scale\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--duration-instant` | 100ms | Micro-interactions |\n| `--duration-fast` | 150ms | Default transitions |\n| `--duration-normal` | 200ms | Hover states |\n| `--duration-medium` | 300ms | Theme transitions |\n| `--duration-slow` | 500ms | Complex animations |\n| `--duration-long` | 1000ms | Page transitions |\n\n### 7.3 Standard Transitions\n\n```css\n/* Color transitions (default) */\ntransition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;\n\n/* Button transitions */\ntransition: all 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Card hover */\ntransition: box-shadow 0.2s ease, transform 0.2s ease;\n\n/* Input focus */\ntransition: border-color 0.15s ease, box-shadow 0.15s ease;\n```\n\n### 7.4 Keyframe Animations\n\n```css\n/* Fade in */\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n\n/* Slide up */\n@keyframes slideUp {\n  from { opacity: 0; transform: translateY(10px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n/* Pulse (loading) */\n@keyframes pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.5; }\n}\n\n/* Progress bar fill */\n@keyframes progressFill {\n  from { width: 0; }\n  to { width: var(--progress-width); }\n}\n\n/* Infinite scroll (logos) */\n@keyframes infiniteScroll {\n  from { transform: translateX(0); }\n  to { transform: translateX(-50%); }\n}\n```\n\n### 7.5 Framer Motion Presets (React)\n\n```javascript\n// Fade in\nconst fadeIn = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  transition: { duration: 0.3 }\n};\n\n// Slide up\nconst slideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { opacity: 1, y: 0 },\n  transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }\n};\n\n// Stagger children\nconst staggerContainer = {\n  animate: { transition: { staggerChildren: 0.1 } }\n};\n\n// Scale on hover\nconst scaleHover = {\n  whileHover: { scale: 1.02 },\n  whileTap: { scale: 0.98 }\n};\n\n// Button press\nconst buttonPress = {\n  whileTap: { scale: 0.98, y: 1 }\n};\n```\n\n---\n\n## 8. Layout System\n\n### 8.1 Container Widths\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--container-sm` | 640px | Narrow content |\n| `--container-md` | 768px | Medium content |\n| `--container-lg` | 1024px | Standard content |\n| `--container-xl` | 1200px | Wide content |\n| `--container-2xl` | 1480px | Full-width sections |\n\n### 8.2 Breakpoints\n\n| Name | Min Width | Usage |\n|------|-----------|-------|\n| `sm` | 640px | Large phones |\n| `md` | 768px | Tablets |\n| `lg` | 1024px | Laptops |\n| `xl` | 1280px | Desktops |\n| `2xl` | 1536px | Large screens |\n\n### 8.3 Grid Patterns\n\n```css\n/* 2-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(2, 1fr);\ngap: 16px;\n\n/* 3-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(3, 1fr);\ngap: 16px;\n\n/* 4-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(4, 1fr);\ngap: 16px;\n\n/* Calculator layout (2 columns, different widths) */\ndisplay: grid;\ngrid-template-columns: 1fr 1fr;\ngap: 24px;\n\n/* Responsive grid */\ndisplay: grid;\ngrid-template-columns: repeat(1, 1fr);  /* Mobile */\ngrid-template-columns: repeat(2, 1fr);  /* md: */\ngrid-template-columns: repeat(3, 1fr);  /* lg: */\n```\n\n### 8.4 Bento Grid\n\n```css\n/* Bento layout with varying sizes */\ndisplay: grid;\ngrid-template-columns: repeat(12, 1fr);\ngap: 8px;\n\n/* Bento cell sizes */\n.bento-sm { grid-column: span 4; }    /* 1/3 width */\n.bento-md { grid-column: span 6; }    /* 1/2 width */\n.bento-lg { grid-column: span 8; }    /* 2/3 width */\n.bento-full { grid-column: span 12; } /* Full width */\n```\n\n---\n\n## 9. Dark Mode Implementation\n\n### 9.1 Detection Script\n\nPlace in `<head>` before any styles load:\n\n```html\n<script>\n(function() {\n  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n    document.documentElement.classList.add('dark');\n  }\n})();\n</script>\n```\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --lp-accent: #FF4801;\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-border: #EBD5C1;\n}\n\n:root.dark, html.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n```\n\n### 9.3 Theme Transition\n\n```css\nhtml.theme-transitioning,\nhtml.theme-transitioning * {\n  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;\n}\n```\n\n### 9.4 System Preference Listener\n\n```javascript\nconst mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\nmediaQuery.addEventListener('change', (e) => {\n  document.documentElement.classList.toggle('dark', e.matches);\n});\n```\n\n---\n\n## 10. Accessibility\n\n### 10.1 Focus States\n\n```css\n/* Default focus ring */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\n/* Button focus */\nbutton:focus-visible {\n  outline: none;\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.3);\n}\n\n/* Input focus */\ninput:focus-visible,\nselect:focus-visible,\ntextarea:focus-visible {\n  outline: none;\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n### 10.2 Disabled States\n\n```css\n:disabled,\n[disabled],\n.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  pointer-events: none;\n}\n```\n\n### 10.3 Error States\n\n```css\n[aria-invalid=\"true\"],\n.input-error {\n  border-color: #DC2626;\n  box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);\n}\n\n.error-message {\n  color: #DC2626;\n  font-size: 14px;\n  margin-top: 4px;\n}\n```\n\n### 10.4 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n```\n\n### 10.5 Selection Styling\n\n```css\n::selection {\n  background-color: rgba(255, 72, 1, 0.2);\n  color: var(--lp-text);\n}\n```\n\n---\n\n## 11. Decorative Elements\n\n### 11.1 Corner Brackets\n\nSignature decorative element for cards:\n\n```css\n/* Corner bracket container */\n.corner-brackets {\n  position: relative;\n}\n\n/* Individual bracket */\n.corner-bracket {\n  position: absolute;\n  width: 8px;\n  height: 8px;\n  border: 1px solid var(--lp-border);\n  border-radius: 1.5px;\n  background: var(--lp-bg-100);\n}\n\n/* Positions */\n.corner-bracket.top-left { top: -4px; left: -4px; }\n.corner-bracket.top-right { top: -4px; right: -4px; }\n.corner-bracket.bottom-left { bottom: -4px; left: -4px; }\n.corner-bracket.bottom-right { bottom: -4px; right: -4px; }\n```\n\n### 11.2 Dot Pattern Background\n\n```css\n.dot-pattern {\n  background-image: radial-gradient(\n    circle,\n    var(--lp-border) 0.75px,\n    transparent 0.75px\n  );\n  background-size: 12px 12px;\n}\n```\n\n### 11.3 Dashed Line Borders\n\n```css\n/* Vertical dashed line */\n.dashed-line-vertical {\n  width: 1px;\n  background-image: linear-gradient(\n    to bottom,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 1px 16px;\n  background-repeat: repeat-y;\n}\n\n/* Horizontal dashed line */\n.dashed-line-horizontal {\n  height: 1px;\n  background-image: linear-gradient(\n    to right,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 16px 1px;\n  background-repeat: repeat-x;\n}\n```\n\n### 11.4 Gradient Masks\n\n```css\n/* Fade edges */\n.fade-left {\n  mask-image: linear-gradient(to right, transparent, black 20%);\n}\n\n.fade-right {\n  mask-image: linear-gradient(to left, transparent, black 20%);\n}\n\n.fade-both {\n  mask-image: linear-gradient(\n    to right,\n    transparent,\n    black 15%,\n    black 85%,\n    transparent\n  );\n}\n```\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border |\n|---------|------------|------|--------|\n| Primary | `#FFFBF5` | `#FF4801` | `#FFFBF5` |\n| Secondary | `#FF4801` | `#FFFBF5` | transparent |\n| Ghost | transparent | `#FF4801` | `#EBD5C1` |\n| Outline | transparent | `#521000` | `#EBD5C1` |\n\n### Cards\n\n| Variant | Background | Border | Shadow |\n|---------|------------|--------|--------|\n| Default | `#FFFDFB` | `#EBD5C1` | shadow-card |\n| Elevated | `#FFFBF5` | `#EBD5C1` | shadow-lg |\n| Interactive | `#FFFDFB` | `#EBD5C1` | hover: shadow-lg |\n\n### Inputs\n\n| State | Border | Shadow |\n|-------|--------|--------|\n| Default | `#EBD5C1` | none |\n| Focus | `#FF4801` | `0 0 0 3px rgba(255,72,1,0.1)` |\n| Error | `#DC2626` | `0 0 0 3px rgba(220,38,38,0.1)` |\n| Disabled | `#EBD5C1` | none, opacity: 0.5 |\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── styles/\n│   ├── tokens.css          # CSS custom properties\n│   ├── base.css            # Reset, typography, global styles\n│   ├── components/\n│   │   ├── buttons.css\n│   │   ├── cards.css\n│   │   ├── forms.css\n│   │   ├── navigation.css\n│   │   └── calculator.css\n│   └── utilities.css       # Helper classes\n├── fonts/\n│   ├── Kunst Grotesk Regular.woff2\n│   ├── Kunst Grotesk Medium.woff2\n│   └── JetBrains Mono Regular.woff2\n└── components/             # React/Vue components\n    ├── Button.tsx\n    ├── Card.tsx\n    ├── Input.tsx\n    └── Calculator/\n        ├── InputPanel.tsx\n        ├── OutputPanel.tsx\n        └── ComparisonBar.tsx\n```\n\n---\n\n## 14. CSS Custom Properties (Full Set)\n\nCopy this into your project's CSS:\n\n```css\n:root {\n  /* Colors - Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.1);\n  \n  /* Colors - Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-text-subtle: rgba(82, 16, 0, 0.38);\n  \n  /* Colors - Backgrounds */\n  --lp-bg-page: #F5F1EB;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Colors - Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Colors - Semantic */\n  --lp-success: #16A34A;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  --lp-info: #2563EB;\n  \n  /* Colors - Product Categories */\n  --lp-compute: #0A95FF;\n  --lp-storage: #EE0DDB;\n  --lp-ai: #19E306;\n  --lp-media: #9616FF;\n  \n  /* Colors - Provider Comparisons */\n  --lp-aws: #FF9900;\n  --lp-gcp: #4285F4;\n  --lp-azure: #0078D4;\n  \n  /* Typography */\n  --font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", monospace;\n  \n  /* Spacing */\n  --spacing-unit: 4px;\n  \n  /* Border Radius */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-xl: 16px;\n  --radius-full: 9999px;\n  \n  /* Shadows */\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);\n  --shadow-card: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02);\n  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n  \n  /* Transitions */\n  --ease-out: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 300ms;\n  \n  /* Containers */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1200px;\n  --container-2xl: 1480px;\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Animation Physics**\n> A specification so precise that a developer could rebuild it without seeing the original.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Grid System\n\n| Aspect | Value | Notes |\n|--------|-------|-------|\n| **Layout Type** | Asymmetrical fluid with max-width constraints | NOT a strict 12-column grid |\n| **Max Container** | `1480px` (homepage / wide marketing) / `1024px` (focused content / docs) | Content centered with `mx-auto` |\n| **Content Width** | `64rem` (1024px) for tools/calculators | Narrower for focused interfaces |\n| **Grid Columns** | 1-col mobile → 2-col tablet → responsive desktop | Uses CSS Grid, not flexbox grids |\n\n#### Spacing Constants (The \"Airiness\")\n\n```\nSection Padding (Y-axis):\n├── Mobile:   32px (py-8)\n├── Tablet:   48px (pt-12)\n└── Desktop:  64px-80px (py-16 to py-20)\n\nGrid Gaps (X-axis):\n├── Card grids:     24px (gap-6)\n├── Form elements:  24px (gap-6)\n├── Tight grids:    12px (gap-3)\n└── Use-case cards: 12px (gap-3)\n\nComponent Internal Padding:\n├── Cards:          24px (p-6) to 32px (p-8)\n├── Buttons:        12px 24px (py-3 px-6)\n├── Inputs:         12px (p-3)\n└── Hero sections:  48px-64px (p-12 to p-16)\n```\n\n#### Density Classification\n\n| Context | Density | Characteristics |\n|---------|---------|-----------------|\n| **Landing pages** | Expressive (Marketing) | Generous whitespace, large text, breathing room |\n| **Calculator tools** | Moderate | Balanced density, functional spacing |\n| **Data tables** | Compact | Tighter padding (py-3 pr-4) |\n\n### 15.2 Color Science & Elevation\n\n#### Primary Palette (Extracted Exact Values)\n\n```css\n/* Core landing-page palette */\n:root {\n  --lp-accent: #ff4801;        /* Primary accent - EXACT */\n  --lp-text: #521000;          /* Primary text - warm brown */\n  --lp-bg-page: #fffbf5;       /* Page background - warm cream */\n  --lp-border: #EBD5C1;        /* Border color */\n}\n\n/* Background Layers (Light Mode) */\n--lp-bg-100: rgb(255, 251, 245);  /* #FFFBF5 - Primary */\n--lp-bg-200: rgb(255, 253, 251);  /* #FFFDFB - Cards/elevated */\n--lp-bg-300: rgb(254, 247, 237);  /* #FEF7ED - Hover states */\n\n/* Text Opacity Variations */\n--lp-text-muted: rgba(82, 16, 0, 0.7);   /* #521000b3 - Secondary */\n--lp-text-subtle: rgba(82, 16, 0, 0.4);  /* #52100066 - Tertiary */\n```\n\n#### Semantic Colors (from components)\n\n| Purpose | Color | Usage Example |\n|---------|-------|---------------|\n| Success | `#16A34A` (green-600) | Savings badges, positive indicators |\n| Success Background | `#DCF7E3` (green-100) | Badge backgrounds |\n| Warning | `#EAB308` | Caution states |\n| Error | `#DC2626` | Error borders, messages |\n| Info | `#2563EB` | Informational highlights |\n\n#### Depth Strategy\n\n**NO Glassmorphism** - The design uses:\n\n1. **Solid backgrounds** with subtle layering (`bg-100` → `bg-200` → `bg-300`)\n2. **Border lines** for separation (1px solid `#EBD5C1`)\n3. **Minimal shadows** - shadows are subtle, not dramatic\n\n```css\n/* Card Shadow (Light/Subtle) */\n--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);\n\n/* Focus Shadow */\nbox-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n\n/* NO backdrop-filter: blur() usage */\n/* NO heavy drop shadows */\n```\n\n### 15.3 Typography & Micro-Copy Specs\n\n#### Type Personality: **Grotesk Sans**\n\nThe typography uses **Inter** - a modern grotesk with humanist touches. Fallback chain:\n```css\nfont-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, \n             Segoe UI, sans-serif, ui-sans-serif, system-ui, sans-serif;\n```\n\n#### Monospace for Code\n```css\nfont-family: JetBrains Mono, ui-monospace, SFMono-Regular, SF Mono, \n             Monaco, Consolas, monospace;\n```\n\n#### Hierarchy Definition\n\n| Level | Size | Weight | Line Height | Letter Spacing | Example |\n|-------|------|--------|-------------|----------------|---------|\n| **h1** | 24px-30px (`text-2xl` to `text-3xl`) | 500 (medium) | 1.2-1.33 | `-0.035em` | \"Pricing\", \"Build faster\" |\n| **h2** | 18px (`text-lg`) | 500 | 1.4 | normal | \"Pricing Details\" |\n| **h3** | 16px (`text-base`) | 500 | 1.5 | normal | Form labels |\n| **p** (body) | 14px-16px (`text-sm` to `text-base`) | 400 | 1.4-1.5 | normal | Descriptions |\n| **p** (muted) | 14px (`text-sm`) | 400 | 1.4 | normal | Secondary info |\n| **small** | 12px (`text-xs`) | 400 | 1.33 | normal | Footnotes, captions |\n\n#### Typography Style: **Tight and Medium Weight**\n\n- **Headings**: Tighter tracking (`letter-spacing: -0.035em`)\n- **Body**: Normal tracking\n- **Weight distribution**: Primarily 400 (regular) and 500 (medium)\n- **NO bold (700)** used in the interfaces\n\n### 15.4 Component Anatomy\n\n#### The \"Radius\" Strategy\n\n| Element | Radius | CSS |\n|---------|--------|-----|\n| **Buttons** | Hyper-rounded | `border-radius: 9999px` (rounded-full) |\n| **Inputs** | Soft | `border-radius: 8px` (rounded-lg) |\n| **Cards** | Sharp | `border-radius: 0` (no rounding) |\n| **Progress bars** | Hyper-rounded | `border-radius: 9999px` |\n| **Dropdowns** | Soft | `border-radius: 8px` |\n| **Badges/Pills** | Hyper-rounded | `border-radius: 9999px` |\n| **Hero sections** | Large soft | `border-radius: 16px` (md:rounded-2xl) |\n\n#### Interactive States\n\n**Hover Effects:**\n```css\n/* Buttons - Dashed border reveal */\n.button:hover {\n  border-style: dashed;\n  opacity: 0.95;\n}\n\n/* Cards - Dashed border */\n.card:hover {\n  border-style: dashed;\n}\n\n/* Links - Underline */\n.link:hover {\n  text-decoration: underline;\n}\n\n/* Background shift */\n.interactive:hover {\n  background-color: var(--lp-bg-300);  /* Warmer cream */\n}\n```\n\n**Active/Press States:**\n```css\nbutton:active {\n  transform: translateY(1px);\n  scale: 0.98;\n}\n```\n\n**Focus States:**\n```css\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus {\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n**NO \"Grow\" effects, \"Glow\" borders, or \"Shimmer\" overlays** - The design is subtle and professional.\n\n#### Corner Brackets (Signature Element)\n\nThe 8px corner bracket decorations are the **signature decorative element** of this design language — they appear at the four outer corners of cards and feature blocks, suggesting precision and crafted technical detail without adding visual noise:\n\n```html\n<!-- Corner bracket structure -->\n<div class=\"pointer-events-none absolute inset-0 z-10 select-none\">\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;left:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;right:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"left:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"right:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n</div>\n```\n\n### 15.5 Animation Physics (The Motion Signature)\n\n#### The Easing Curves\n\n```css\n/* Standard ease-out (most transitions) */\n--ease-standard: cubic-bezier(0, 0, 0.2, 1);  /* Tailwind's ease-out */\n\n/* Button interactions - High-end feel */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press response */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Page entrance - Apple-style smooth deceleration */\n--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n```\n\n#### Duration Scale\n\n| Context | Duration | Usage |\n|---------|----------|-------|\n| Instant feedback | `0.15s` (150ms) | Color changes, opacity |\n| Standard transitions | `0.16s` (160ms) | Button presses |\n| Hover effects | `0.2s` (200ms) | Background color shifts |\n| Complex animations | `0.5s` (500ms) | Progress bars, entrance |\n| Page transitions | `2s` (2000ms) | Background fade-in |\n\n#### Orchestration: **Fade-Slide as Single Block**\n\nThe content does NOT stagger in one-by-one. Instead, entire sections fade and slide together:\n\n```css\n/* Page entrance animation */\n.animate-in {\n  animation: fadeSlideUp 0.5s ease-out forwards;\n}\n\n@keyframes fadeSlideUp {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Background lines fade in slowly */\n.fade-in {\n  transition: opacity 2000ms ease-out;\n  transition-delay: 100ms;\n}\n```\n\n#### Micro-interactions\n\n**Button Press Physics:**\n```css\nbutton {\n  transition: scale 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94),\n              translate 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n}\n\nbutton:active {\n  scale: 0.98;\n  transform: translateY(1px);\n}\n```\n\n**Progress Bar Animation:**\n```css\n.progress-bar {\n  transition: width 0.5s ease-out;\n}\n```\n\n**Slider Thumb:**\n```css\n.slider-thumb {\n  cursor: grab;\n  transition: box-shadow 0.15s ease;\n}\n\n.slider-thumb:active {\n  cursor: grabbing;\n}\n\n.slider-thumb:hover {\n  box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);\n}\n```\n\n**NO \"Magnetic\" follow effects or \"Tilt\" effects** - Interactions are clean and direct.\n\n### 15.6 Technical Deliverables\n\n#### Tailwind Configuration\n\n```javascript\n// tailwind.config.js\nmodule.exports = {\n  theme: {\n    extend: {\n      colors: {\n        'cf': {\n          'orange': '#FF4801',\n          'orange-hover': '#FF7038',\n          'orange-light': 'rgba(255, 72, 1, 0.06)',\n          'text': '#521000',\n          'text-muted': 'rgba(82, 16, 0, 0.7)',\n          'text-subtle': 'rgba(82, 16, 0, 0.4)',\n          'bg-page': '#FFFBF5',\n          'bg-100': '#FFFBF5',\n          'bg-200': '#FFFDFB',\n          'bg-300': '#FEF7ED',\n          'border': '#EBD5C1',\n          'border-light': 'rgba(235, 213, 193, 0.5)',\n        },\n        'aws-orange': '#FF9900',\n        'gcp-blue': '#4285F4',\n      },\n      fontFamily: {\n        sans: ['Inter', '-apple-system', 'system-ui', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],\n        mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Monaco', 'Consolas', 'monospace'],\n      },\n      fontSize: {\n        'sm': ['0.9rem', { lineHeight: '1.4' }],\n        'base': ['1rem', { lineHeight: '1.5' }],\n        'lg': ['1.125rem', { lineHeight: '1.75rem' }],\n        '2xl': ['1.5rem', { lineHeight: '2rem' }],\n        '3xl': ['1.875rem', { lineHeight: '2.25rem' }],\n      },\n      letterSpacing: {\n        'tight-heading': '-0.035em',\n        'logo': '-0.46px',\n      },\n      borderRadius: {\n        'lg': '0.5rem',\n        'xl': '0.75rem',\n        '2xl': '1rem',\n      },\n      transitionTimingFunction: {\n        'button': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n        'active': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)',\n        'entrance': 'cubic-bezier(0.16, 1, 0.3, 1)',\n      },\n      transitionDuration: {\n        '160': '160ms',\n        '2000': '2000ms',\n      },\n      animation: {\n        'float-subtle': 'float-subtle 3s ease-in-out infinite',\n        'dash-draw': 'dashdraw 0.5s linear infinite',\n      },\n      keyframes: {\n        'float-subtle': {\n          '0%, 100%': { transform: 'translateY(0)' },\n          '50%': { transform: 'translateY(-3px)' },\n        },\n        'dashdraw': {\n          '0%': { strokeDashoffset: '10' },\n        },\n      },\n      boxShadow: {\n        'focus': '0 0 0 3px rgba(255, 72, 1, 0.1)',\n        'card': '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',\n      },\n      maxWidth: {\n        '5xl': '64rem',\n        '8xl': '1480px',\n      },\n    },\n  },\n  plugins: [],\n}\n```\n\n#### CSS Variables Block\n\n```css\n:root {\n  /* === COLORS === */\n  /* Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.06);\n  \n  /* Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.7);\n  --lp-text-subtle: rgba(82, 16, 0, 0.4);\n  \n  /* Backgrounds */\n  --lp-bg-page: #FFFBF5;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Semantic */\n  --lp-success: #16A34A;\n  --lp-success-bg: #DCF7E3;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  \n  /* Provider Colors */\n  --aws-orange: #FF9900;\n  --gcp-blue: #4285F4;\n  \n  /* === TYPOGRAPHY === */\n  --font-sans: \"Inter\", -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Monaco, Consolas, monospace;\n  \n  /* === SPACING === */\n  --spacing-unit: 4px;\n  \n  /* === BORDER RADIUS === */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-full: 9999px;\n  \n  /* === SHADOWS === */\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.1);\n  --shadow-card: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);\n  --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);\n  \n  /* === TRANSITIONS === */\n  --ease-standard: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n  --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n  \n  --duration-instant: 100ms;\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 500ms;\n  \n  /* === CONTAINERS === */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1480px;\n}\n\n/* Dark Mode */\n:root.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-page: #0D0D0D;\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-bg-300: #2A2927;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n\n/* Base Styles */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n  margin: 0;\n  background-color: var(--lp-bg-page);\n  color: var(--lp-text);\n  font-family: var(--font-sans);\n  overflow-x: hidden;\n}\n\n/* Focus States */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus,\nselect:focus {\n  border-color: var(--lp-accent);\n  box-shadow: var(--shadow-focus);\n}\n\n/* Transitions */\nbutton,\na,\ninput,\nselect {\n  transition: all 0.2s ease-in-out;\n}\n```\n\n#### Framer Motion Variants\n\n```javascript\n// framer-motion-variants.js\n\n// Page entrance animation\nexport const pageEntrance = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 0.5, \n      ease: [0.16, 1, 0.3, 1] \n    }\n  }\n};\n\n// Section slide-up\nexport const sectionSlideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.4, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Stagger container (for card grids)\nexport const staggerContainer = {\n  animate: {\n    transition: {\n      staggerChildren: 0.08,\n      delayChildren: 0.1\n    }\n  }\n};\n\n// Stagger child item\nexport const staggerItem = {\n  initial: { opacity: 0, y: 16 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.35, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Button hover and tap\nexport const buttonInteraction = {\n  whileHover: { scale: 1.01 },\n  whileTap: { \n    scale: 0.98, \n    y: 1,\n    transition: { \n      duration: 0.16, \n      ease: [0.55, 0.085, 0.68, 0.53] \n    }\n  }\n};\n\n// Card hover\nexport const cardHover = {\n  initial: { scale: 1 },\n  whileHover: { \n    scale: 1.01,\n    transition: { \n      duration: 0.2, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Progress bar fill\nexport const progressFill = {\n  initial: { width: 0 },\n  animate: (width) => ({\n    width: `${width}%`,\n    transition: { \n      duration: 0.5, \n      ease: \"easeOut\" \n    }\n  })\n};\n\n// Floating animation (for icons/decorations)\nexport const floatSubtle = {\n  animate: {\n    y: [0, -3, 0],\n    transition: {\n      duration: 3,\n      ease: \"easeInOut\",\n      repeat: Infinity\n    }\n  }\n};\n\n// Background fade-in (slow entrance)\nexport const backgroundFadeIn = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 2, \n      delay: 0.1,\n      ease: \"easeOut\" \n    }\n  }\n};\n\n// Usage example with React\n/*\nimport { motion } from 'framer-motion';\nimport { pageEntrance, staggerContainer, staggerItem } from './framer-motion-variants';\n\nfunction Page() {\n  return (\n    <motion.main {...pageEntrance}>\n      <motion.div \n        variants={staggerContainer}\n        initial=\"initial\"\n        animate=\"animate\"\n        className=\"grid grid-cols-3 gap-6\"\n      >\n        {items.map((item) => (\n          <motion.div key={item.id} variants={staggerItem}>\n            {item.content}\n          </motion.div>\n        ))}\n      </motion.div>\n    </motion.main>\n  );\n}\n*/\n```\n\n---\n\n*Synthesized from analysis of warm-cream developer-marketing landing pages. The aesthetic — warm cream backgrounds, brown text, single bold accent for CTAs, corner-bracket decorations — is portable to any B2B / developer-tools / dev-adjacent product brand.*\n\n\n\n# ===== SNIPPETS.md =====\n\n# Landing Page Design - Component Snippets\n\n> **Copy-paste ready components** for building warm-cream landing-page interfaces.\n> Each snippet includes React + Tailwind AND Vanilla HTML versions.\n\n---\n\n## Table of Contents\n\n1. [Buttons](#buttons)\n2. [Cards](#cards)\n3. [Forms](#forms)\n4. [Calculator Tools](#calculator-tools)\n5. [Navigation](#navigation)\n6. [Hero Sections](#hero-sections)\n7. [Data Display](#data-display)\n8. [Layout](#layout)\n9. [Decorative](#decorative)\n\n---\n\n# Buttons\n\n## BTN-PRIMARY\n\nPrimary CTA button - cream background, orange text, fully rounded.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFBF5] text-[#FF4801] border border-[#FFFBF5] transition-all duration-150 ease-out hover:bg-transparent hover:border-[#FF4801] active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Get started\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFBF5;\n  color: #FF4801;\n  border: 1px solid #FFFBF5;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.background='transparent'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.background='#FFFBF5'; this.style.borderColor='#FFFBF5';\">\n  Get started\n</button>\n```\n\n---\n\n## BTN-SECONDARY\n\nSecondary button - orange background, white text.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 ease-out hover:opacity-95 hover:border-dashed hover:border-white/50 active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Learn more\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: 1px solid transparent;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.opacity='0.95';\" onmouseout=\"this.style.opacity='1';\">\n  Learn more\n</button>\n```\n\n---\n\n## BTN-GHOST\n\nGhost button - transparent with border, for secondary actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-transparent text-[#FF4801] border border-[#EBD5C1] transition-all duration-150 ease-out hover:border-dashed hover:border-[#FF4801] hover:text-[#521000] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20 disabled:opacity-50 disabled:cursor-not-allowed\">\n  View docs\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: transparent;\n  color: #FF4801;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.borderStyle='dashed'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.borderStyle='solid'; this.style.borderColor='#EBD5C1';\">\n  View docs\n</button>\n```\n\n---\n\n## BTN-OUTLINE\n\nOutline button - for less prominent actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:border-dashed active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#EBD5C1]/50 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Cancel\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\">\n  Cancel\n</button>\n```\n\n---\n\n## BTN-ICON\n\nIcon-only button with tooltip.\n\n### React + Tailwind\n\n```jsx\n<button \n  className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:text-[#FF4801] hover:border-[#FF4801] active:scale-[0.95] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20\"\n  aria-label=\"Settings\"\n  title=\"Settings\"\n>\n  <svg className=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={1.5}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 40px;\n  height: 40px;\n  border-radius: 9999px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" aria-label=\"Settings\" title=\"Settings\">\n  <svg width=\"20\" height=\"20\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n---\n\n## BTN-LINK\n\nLink styled as text with arrow.\n\n### React + Tailwind\n\n```jsx\n<a href=\"#\" className=\"inline-flex items-center gap-1 font-medium text-[#FF4801] hover:underline hover:underline-offset-4 transition-all duration-150\">\n  View documentation\n  <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n### Vanilla HTML\n\n```html\n<a href=\"#\" style=\"\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  color: #FF4801;\n  text-decoration: none;\n  transition: all 0.15s ease;\n\">\n  View documentation\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n---\n\n## BTN-LOADING\n\nButton with loading spinner state.\n\n### React + Tailwind\n\n```jsx\n<button \n  disabled\n  className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 disabled:opacity-70 disabled:cursor-not-allowed\"\n>\n  <svg className=\"animate-spin h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n    <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n    <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button disabled style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: none;\n  opacity: 0.7;\n  cursor: not-allowed;\n\">\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" style=\"animation: spin 1s linear infinite;\">\n    <circle opacity=\"0.25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\" />\n    <path opacity=\"0.75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n<style>\n  @keyframes spin { to { transform: rotate(360deg); } }\n</style>\n```\n\n---\n\n# Cards\n\n## CARD-DEFAULT\n\nStandard card with corner bracket decorations.\n\n### React + Tailwind\n\n```jsx\n<div className=\"relative bg-[#FFFDFB] border border-[#EBD5C1] p-6 shadow-[0_1px_3px_rgba(82,16,0,0.04),0_4px_12px_rgba(82,16,0,0.02)]\">\n  {/* Corner brackets */}\n  <div className=\"absolute -top-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -top-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  \n  <h3 className=\"text-lg font-medium text-[#521000] mb-2\">Card Title</h3>\n  <p className=\"text-sm text-[#521000]/60 leading-relaxed\">\n    Card description goes here. This is a standard card with the signature corner bracket decorations.\n  </p>\n</div>\n```\n\n### Vanilla HTML\n\n```html\n<div \n\n<!-- [TRUNCATED AT 50KB] -->","worker/agents/think/skills/frontend-design-saas/SKILL.md":"---\nname: frontend-design-saas\ndescription: S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear.\n---\n\n# SaaS Design System (Stripe / Linear / Vercel lineage)\n\n> **AI-Optimized Design Reference** for building S-tier SaaS dashboards, admin consoles, billing pages, settings UIs, data-dense application shells, and product surfaces.\n>\n> Tonal anchors: `stripe.com/dashboard`, `linear.app`, `vercel.com/dashboard`, `notion.so/teamspace`, `clerk.com`, `planetscale.com/console`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #5E6AD2 (indigo) - replace with product brand\nNeutrals:        #FFFFFF → #F8FAFC → #F1F5F9 → #E2E8F0 → #94A3B8 → #475569 → #0F172A\nText:            #0F172A (primary) / #475569 (muted) / #94A3B8 (subtle)\nBorder:          #E2E8F0 (default) / #CBD5E1 (strong)\nFont Sans:       \"Inter\", \"Geist\", system-ui (NOT Arial, NOT Roboto)\nFont Mono:       \"JetBrains Mono\", \"Geist Mono\", ui-monospace\nBase Spacing:    4px (scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64)\nBorder Radius:   Buttons/inputs = 6-8px (NOT pill), Cards = 8-12px, Modals = 12-16px\nShadow:          Tight, low-spread, single-direction (NOT diffuse marketing shadows)\nDensity:         Compact-to-comfortable; row heights 36-44px, button heights 32-36px\n```\n\nThe fastest way to get this wrong: use marketing-page aesthetics (huge type, generous whitespace, pill buttons, gradient hero shadows) in a product surface. SaaS UIs are **dense, neutral, fast, and quiet**. The brand color appears in <5% of the pixels and earns its weight by marking only what's interactive or active.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Users First** | Workflows, keyboard navigation, and information density beat decoration. |\n| **Quiet by Default** | Neutrals dominate; the brand color is reserved for primary action, active state, and focus. |\n| **Speed & Density** | Snappy transitions (≤200ms), compact row heights, no entrance animations on every page load. |\n| **Opinionated Defaults** | Single canonical pattern per surface (one button hierarchy, one date picker, one empty state). |\n| **Meticulous Craft** | 1px borders aligned to pixel grid, focus rings 2px offset 2px, motion uses ease-out not linear. |\n| **Accessible (WCAG AA+)** | 4.5:1 text contrast, 3:1 UI element contrast, every interactive surface keyboard-reachable. |\n| **Predictable** | Same component looks/behaves identically on every page; no marketing-grade variants. |\n\n### Visual Identity Rules\n\n- **Do not use pure black on pure white.** Use `#0F172A` on `#FFFFFF` (or `#0B1220` on `#0A0E1A` in dark mode).\n- **Brand color is the accent, never the dominant.** If the brand color fills more than ~5% of the viewport in steady state, something is wrong.\n- **Avoid pill buttons** (`border-radius: 9999px`) for SaaS actions. Pills read as marketing/consumer; SaaS uses `6-8px` radius for buttons and inputs.\n- **No glassmorphism, no blur, no gradient borders.** The aesthetic is precision, not richness.\n- **Shadows are tight and downward.** Not diffuse, not multi-direction. Reserved for floating layers (popovers, modals, dropdowns).\n- **One font.** Inter / Geist / IBM Plex Sans / native system stack. Avoid mixing display + body fonts; SaaS uses a single workhorse sans.\n- **Mono for IDs, code, tokens, money.** Tabular figures (`font-variant-numeric: tabular-nums`) for any column of numbers.\n\n### Reference apps to mentally calibrate against\n\n| Surface to build | Look at |\n|---|---|\n| Application shell | Linear, Vercel, Stripe Dashboard |\n| Data table | Stripe `/payments`, Linear backlog, PlanetScale branches |\n| Settings page | Vercel project settings, Clerk dashboard, GitHub repo settings |\n| Billing / invoice | Stripe billing, Vercel usage |\n| Empty state | Linear, Notion, Vercel deployments |\n| Command palette | Linear ⌘K, Vercel ⌘K, Raycast |\n| Auth | Clerk hosted pages, Stripe Connect onboarding |\n| Onboarding checklist | Stripe Atlas, Vercel project import |\n\n---\n\n## 2. Color System\n\n### 2.1 Neutral Scale (the backbone)\n\nSaaS UIs are 80–90% neutrals. The neutral scale carries the entire interface; brand is sprinkled on top. Use a slightly cool neutral (slate) by default — it reads more \"product\" than warm grays which feel marketing/lifestyle.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--neutral-0` | `#FFFFFF` | Page background (light mode), surface lifted onto canvas |\n| `--neutral-50` | `#F8FAFC` | Canvas background, alternating row stripe |\n| `--neutral-100` | `#F1F5F9` | Hover background, subtle fill, table header |\n| `--neutral-200` | `#E2E8F0` | **Default border**, divider |\n| `--neutral-300` | `#CBD5E1` | Strong border, input border on focus-within parent |\n| `--neutral-400` | `#94A3B8` | **Subtle text** (placeholders, disabled, captions) |\n| `--neutral-500` | `#64748B` | Tertiary text, icon default |\n| `--neutral-600` | `#475569` | **Muted text** (descriptions, secondary labels) |\n| `--neutral-700` | `#334155` | Strong icon, hover text |\n| `--neutral-800` | `#1E293B` | Heading in dense UIs, code text |\n| `--neutral-900` | `#0F172A` | **Primary text**, primary icons |\n| `--neutral-950` | `#020617` | Highest contrast (rare; only for critical labels) |\n\n### 2.2 Brand Accent (single color, used sparingly)\n\nPick **one** accent. Use a 6-step ramp so hover/active/focused/disabled all derive from the same hue. Defaults below use indigo (`#5E6AD2`, Linear's accent) — swap the hue for your brand but keep the structure.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--brand-50` | `#EEF0FB` | Selected row background, badge background, focus ring background |\n| `--brand-100` | `#DDE0F7` | Hover on brand surfaces |\n| `--brand-200` | `#B9C0EF` | Disabled brand button, decorative |\n| `--brand-500` | `#7B85DC` | Brand hover surface stroke |\n| `--brand-600` | `#5E6AD2` | **Primary brand** — default button bg, link text, focus ring, active nav item |\n| `--brand-700` | `#4A56C0` | Hover state for primary button |\n| `--brand-800` | `#3A45A8` | Active/pressed state |\n| `--brand-900` | `#2A3284` | High-contrast brand text on light background |\n\n### 2.3 Semantic / Status Colors\n\nUsed for state — never for decoration. Each has a `text`, `bg`, `border` triplet so badges and inline messages stay legible.\n\n| State | `text` | `bg` (filled) | `bg-subtle` (badge) | `border` |\n|---|---|---|---|---|\n| **Success** | `#15803D` | `#16A34A` | `#DCFCE7` | `#86EFAC` |\n| **Warning** | `#A16207` | `#CA8A04` | `#FEF9C3` | `#FDE68A` |\n| **Error / Destructive** | `#B91C1C` | `#DC2626` | `#FEE2E2` | `#FCA5A5` |\n| **Info** | `#1D4ED8` | `#2563EB` | `#DBEAFE` | `#93C5FD` |\n| **Neutral (default badge)** | `#334155` | `#64748B` | `#F1F5F9` | `#CBD5E1` |\n\nRules:\n- Inline form errors: `text` color on `bg-subtle` background, `border` for the input.\n- Toasts: `bg-subtle` panel, `text` heading, `--neutral-700` body.\n- Status badges: `text` on `bg-subtle` + 1px `border`. Never use solid `bg` for badges (too loud).\n\n### 2.4 Data Visualization Palette\n\nWhen you need >1 color for charts. Categorical — visually distinct, similar luminance. Avoid the brand color in this palette so brand reads as \"interactive\" elsewhere.\n\n```\n--chart-1: #2563EB  (blue)\n--chart-2: #16A34A  (green)\n--chart-3: #CA8A04  (amber)\n--chart-4: #DC2626  (red)\n--chart-5: #9333EA  (violet)\n--chart-6: #0891B2  (cyan)\n--chart-7: #DB2777  (pink)\n--chart-8: #65A30D  (lime)\n```\n\n### 2.5 Dark Mode Mapping\n\nSaaS dark modes are **not just inverted**. They are slightly desaturated, slightly lifted, with carefully tuned elevation. Use `#0A0E1A` (or `#0B0F1C`) as the canvas — NEVER `#000000`. Pure black creates harsh contrast and exposes any banding in shadows.\n\n| Token | Light | Dark |\n|---|---|---|\n| `--bg-canvas` | `#F8FAFC` | `#0A0E1A` |\n| `--bg-surface` | `#FFFFFF` | `#111827` |\n| `--bg-elevated` | `#FFFFFF` | `#1F2937` |\n| `--bg-overlay` (popover/modal) | `#FFFFFF` | `#1F2937` |\n| `--text-primary` | `#0F172A` | `#F1F5F9` |\n| `--text-muted` | `#475569` | `#94A3B8` |\n| `--text-subtle` | `#94A3B8` | `#64748B` |\n| `--border-default` | `#E2E8F0` | `#1F2937` |\n| `--border-strong` | `#CBD5E1` | `#374151` |\n| `--brand-bg-subtle` | `#EEF0FB` | `rgba(94, 106, 210, 0.16)` |\n| `--brand-fg` | `#5E6AD2` | `#A5B4FC` |\n\nIn dark mode the **brand color shifts lighter** (toward `--brand-500/400` instead of `--brand-600/700`) to maintain contrast against the dark canvas.\n\n---\n\n## 3. Typography\n\n### 3.1 Font Stack\n\n```css\n--font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n             \"SF Pro Text\", system-ui, sans-serif;\n--font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n             ui-monospace, monospace;\n```\n\nHard rules:\n- **Never use `Arial`, `Helvetica`, `Roboto`, or `Times`** in a SaaS UI. These read as default/unstyled. If you can't load a webfont, fall through to `system-ui` instead.\n- **Never mix two display fonts.** One sans + one mono is the entire stack.\n- **`font-feature-settings: \"cv11\", \"ss01\", \"ss03\"`** for Inter (or whatever stylistic sets your font has) — small caps and alternate forms make tables look intentional.\n- **`font-variant-numeric: tabular-nums`** on EVERY column that contains numbers (money, dates, IDs, counts). Non-tabular figures make rows shimmer.\n\n### 3.2 Type Scale (SaaS-tuned — denser than marketing)\n\n| Token | Size | Line Height | Weight | Letter Spacing | Use |\n|---|---|---|---|---|---|\n| `text-2xs` | 11px / 0.6875rem | 1.36 (15px) | 500 | 0.04em | Microlabels (uppercase metadata, table column tags) |\n| `text-xs` | 12px / 0.75rem | 1.33 (16px) | 400/500 | 0 | Captions, badge text, table secondary |\n| `text-sm` | 13px / 0.8125rem | 1.38 (18px) | 400/500 | 0 | **Default UI text**: table rows, buttons, form labels |\n| `text-base` | 14px / 0.875rem | 1.43 (20px) | 400/500 | 0 | Body text, paragraph copy in panels |\n| `text-md` | 15px / 0.9375rem | 1.47 (22px) | 400/500 | 0 | Comfortable reading (descriptions, modals) |\n| `text-lg` | 16px / 1rem | 1.5 (24px) | 500/600 | -0.005em | Card headings, sidebar section labels |\n| `text-xl` | 18px / 1.125rem | 1.44 (26px) | 600 | -0.01em | Subsection headings |\n| `text-2xl` | 20px / 1.25rem | 1.4 (28px) | 600 | -0.015em | Page subheadings |\n| `text-3xl` | 24px / 1.5rem | 1.33 (32px) | 600 | -0.02em | Page title (h1 in dashboards) |\n| `text-4xl` | 30px / 1.875rem | 1.2 (36px) | 600/700 | -0.025em | Marketing-adjacent hero in settings |\n\n**Critical**: the default UI size in SaaS is **13px or 14px**, NOT 16px. Stripe is 14px. Linear is 13px. Vercel is 14px. 16px feels too big and creates pages that scroll forever.\n\n### 3.3 Weights\n\n| Weight | Value | Use |\n|---|---|---|\n| Regular | 400 | Body, table cells, descriptions |\n| Medium | 500 | Default for buttons, labels, table headers, navigation, badge text |\n| Semibold | 600 | Headings, emphasized stats, active nav |\n| Bold | 700 | Rare — only marketing-adjacent surfaces or KPI numbers |\n\n**Never** use weight 800 or 900 in a SaaS UI.\n\n### 3.4 Letter Spacing\n\n| Context | Value |\n|---|---|\n| Body / table | 0 |\n| Headings ≥18px | -0.01em to -0.025em (tighter as size grows) |\n| Microlabels / uppercase | 0.04em to 0.06em |\n| Mono | 0 |\n\n### 3.5 Text Rendering\n\n```css\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1;\n}\n\n.numeric, table, [data-numeric] {\n  font-variant-numeric: tabular-nums;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--space-unit: 4px;\n```\n\n### 4.2 Scale\n\n| Token | px | rem | Common Use |\n|---|---|---|---|\n| `space-0` | 0 | 0 | Reset |\n| `space-px` | 1 | — | Hairlines, dividers |\n| `space-0.5` | 2 | 0.125 | Tight icon-to-text gaps |\n| `space-1` | 4 | 0.25 | Inline tight |\n| `space-1.5` | 6 | 0.375 | — |\n| `space-2` | 8 | 0.5 | Default tight gap, icon-text, badge padding |\n| `space-2.5` | 10 | 0.625 | Input vertical padding (compact) |\n| `space-3` | 12 | 0.75 | Input vertical padding (default), small card |\n| `space-4` | 16 | 1 | **Default card/panel padding**, form field gaps |\n| `space-5` | 20 | 1.25 | Section subheading gap |\n| `space-6` | 24 | 1.5 | Card padding (comfortable), section internal |\n| `space-8` | 32 | 2 | Page section gap, large card padding |\n| `space-10` | 40 | 2.5 | Major section gap |\n| `space-12` | 48 | 3 | Page-level section gap |\n| `space-16` | 64 | 4 | Hero, max page-section |\n\n**Density convention** (which scale value to default to):\n\n- Buttons: `8px 12px` (sm), `8px 14px` (md), `10px 16px` (lg) — vertical padding stays tight\n- Inputs: `8px 12px` (sm), `9px 12px` (md), `10px 14px` (lg)\n- Cards: `16px` (sm), `20-24px` (md), `32px` (lg / settings panel)\n- Table cells: `10px 16px` (compact), `12px 16px` (default), `16px 20px` (comfortable)\n- Page padding: `24px` (mobile) → `32px` (desktop) → `48px` (wide settings)\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Use |\n|---|---|---|\n| `--radius-none` | 0 | Table cells, full-bleed sections |\n| `--radius-sm` | 4px | Badges, tags, small chips |\n| `--radius-md` | 6px | **Default**: buttons, inputs, dropdowns, menu items |\n| `--radius-lg` | 8px | Cards, panels, popovers |\n| `--radius-xl` | 12px | Modals, large surfaces, sheets |\n| `--radius-2xl` | 16px | Marketing-adjacent (pricing card, onboarding) |\n| `--radius-full` | 9999px | Avatars, status dots, **NOT buttons** |\n\nRules:\n- **Buttons in SaaS UIs use 6px, not pill (`9999px`).** Pills are for marketing/consumer apps. The exception is a \"tag\" or \"chip\" inside a table cell.\n- **Match input and button radius.** If button = 6px, input = 6px. Visual consistency in form rows.\n- **Cards never share radius with modals.** Cards = 8px, modals = 12px+. This is how the modal reads as a different elevation layer.\n\n---\n\n## 6. Shadow System (Elevation)\n\nSaaS shadows are **tight, downward, single-direction**. They mark elevation; they do not decorate. No glows, no colored shadows, no inset highlights, no large spread radii.\n\n### 6.1 Elevation Tokens\n\n```css\n/* Light mode */\n--shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n--shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05),\n              0 1px 3px 0 rgba(15, 23, 42, 0.06);\n--shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06),\n              0 4px 6px -2px rgba(15, 23, 42, 0.04);\n--shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05),\n              0 10px 15px -3px rgba(15, 23, 42, 0.08);\n--shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06),\n              0 20px 25px -5px rgba(15, 23, 42, 0.10);\n--shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n\n/* Focus ring (NOT a shadow, but the only \"glow\" allowed) */\n--ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n--ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n/* Dark mode: shadows almost invisible; elevation is conveyed via background lightening */\n--shadow-xs-dark:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n--shadow-sm-dark:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n--shadow-md-dark:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n--shadow-lg-dark:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n```\n\n### 6.2 Elevation → Component Mapping\n\n| Layer | Shadow | Examples |\n|---|---|---|\n| 0 (flat on canvas) | none | Sidebar, page body, inline rows |\n| 1 (resting card) | `--shadow-xs` or none + 1px border | Default card on `--bg-canvas` |\n| 2 (hovered card) | `--shadow-sm` | Card with `:hover` state |\n| 3 (dropdown, popover) | `--shadow-md` | Menu, popover, select dropdown |\n| 4 (modal, dialog) | `--shadow-xl` | Dialog, slide-over |\n| 5 (toast over modal) | `--shadow-2xl` | Toast, command palette over modal backdrop |\n\n### 6.3 Border-vs-Shadow Decision\n\nFor most cards, **prefer a 1px border over a shadow**. Borders are crisper at any DPI and read more \"product\". Use shadow only when:\n- The element is floating over content (popover, modal, dropdown, toast)\n- A drag-and-drop ghost is rendered\n- A row is \"lifted\" to indicate selection while dragging\n\n---\n\n## 7. Animation System\n\n### 7.1 Principle\n\nSaaS motion is **fast, ease-out, and only on user-initiated events**. No entrance animations on page mount, no decorative parallax, no stagger on every grid. Motion's job is to confirm an action happened, not to entertain.\n\n### 7.2 Easing\n\n```css\n/* Default — used for most state transitions */\n--ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out, snappy */\n\n/* For things that recoil (modal entrance, popover) */\n--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);\n\n/* Press / active (deceleration into rest) */\n--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n/* Linear — for indeterminate progress / spinners only */\n--ease-linear: linear;\n```\n\n**Do NOT use** `ease`, `ease-in`, or unspecified defaults. They feel sluggish in SaaS contexts.\n\n### 7.3 Duration Scale\n\n| Token | ms | Use |\n|---|---|---|\n| `--duration-75` | 75 | Color change, opacity flip (button press color) |\n| `--duration-100` | 100 | Hover background fill |\n| `--duration-150` | 150 | **Default** — most transitions |\n| `--duration-200` | 200 | Dropdown/popover enter |\n| `--duration-250` | 250 | Modal/dialog enter |\n| `--duration-300` | 300 | Slide-over (drawer) enter |\n| `--duration-500` | 500 | Skeleton shimmer cycle |\n\n**Anything longer than 300ms in a product UI is wrong.** Marketing pages can take more time; product UIs cannot.\n\n### 7.4 Standard Transitions\n\n```css\n/* Color/border (default for all interactive elements) */\nbutton, a, input, select, textarea, [role=\"button\"] {\n  transition:\n    background-color var(--duration-150) var(--ease-out),\n    border-color var(--duration-150) var(--ease-out),\n    color var(--duration-150) var(--ease-out),\n    box-shadow var(--duration-150) var(--ease-out);\n}\n\n/* Modal/dialog */\n.dialog-enter { animation: dialog-enter 250ms var(--ease-out-back); }\n@keyframes dialog-enter {\n  from { opacity: 0; transform: translateY(8px) scale(0.98); }\n  to   { opacity: 1; transform: translateY(0) scale(1); }\n}\n\n/* Popover/dropdown */\n.popover-enter { animation: popover-enter 150ms var(--ease-out); }\n@keyframes popover-enter {\n  from { opacity: 0; transform: translateY(-4px); }\n  to   { opacity: 1; transform: translateY(0); }\n}\n\n/* Toast slide-in */\n.toast-enter { animation: toast-enter 200ms var(--ease-out); }\n@keyframes toast-enter {\n  from { opacity: 0; transform: translateX(8px); }\n  to   { opacity: 1; transform: translateX(0); }\n}\n\n/* Skeleton shimmer */\n.skeleton {\n  background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);\n  background-size: 200% 100%;\n  animation: shimmer 1.5s ease-in-out infinite;\n}\n@keyframes shimmer {\n  from { background-position: 200% 0; }\n  to   { background-position: -200% 0; }\n}\n```\n\n### 7.5 What NOT to Animate\n\n- **Page mount.** Render synchronously. The page is the priority, not the motion.\n- **Sidebar nav items individually staggering in.** Render the whole nav at once.\n- **Tab content fade.** Switching tabs is an instant context change; fade hides the change.\n- **Table rows.** A 1000-row table that fades in row-by-row is unusable.\n\n### 7.6 What SHOULD Animate\n\n- Button hover (background-color 100ms)\n- Focus ring (box-shadow 150ms)\n- Modal/dialog enter and exit (250ms)\n- Popover/dropdown enter (150ms)\n- Toast slide-in (200ms)\n- Skeleton shimmer (loading states)\n- Accordion expand/collapse (height 200ms)\n- Optimistic UI confirmations (checkmark scale-in 150ms)\n\n### 7.7 `prefers-reduced-motion`\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n```\n\nAlways include. SaaS users may have vestibular sensitivity or use the OS-level reduce-motion setting.\n\n---\n\n## 8. Layout System\n\n### 8.1 The Canonical SaaS Shell\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ [Topbar: workspace switcher | search ⌘K | user menu] 48px       │\n├──────────────┬──────────────────────────────────────────────────┤\n│              │                                                  │\n│              │   Page header                                    │\n│              │   ┌────────────────────────────────────────────┐ │\n│              │   │ Title             [primary action] [⋯ menu]│ │\n│   Sidebar    │   │ Description / breadcrumb                   │ │\n│   (240-     │   └────────────────────────────────────────────┘ │\n│    280px)    │                                                  │\n│              │   Page body (cards, tables, settings)            │\n│   Nav items  │                                                  │\n│   Sections   │   ┌──────────────────────────────────────────┐   │\n│   Footer     │   │  Card / Panel                            │   │\n│              │   └──────────────────────────────────────────┘   │\n│              │                                                  │\n└──────────────┴──────────────────────────────────────────────────┘\n```\n\nThis is the Stripe/Linear/Vercel/Notion layout. Three regions:\n\n1. **Topbar** (48–56px): workspace/team switcher on the left, global search (`⌘K`) center or right, user menu on far right. Stays fixed during scroll.\n2. **Sidebar** (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.\n3. **Content** (remaining width, max ~1200px centered for readability OR full-width for tables): page header → page body.\n\nVariations:\n- **Linear-style** has no topbar — the sidebar takes the whole left edge, search lives inside the content header.\n- **Notion-style** has only a sidebar, page-level breadcrumbs at top of content.\n- **Settings-style** uses a *secondary* sidebar (settings nav) inside the content area, so two sidebars are visible.\n\n### 8.2 Container Widths\n\n| Token | px | Use |\n|---|---|---|\n| `--container-sm` | 640 | Single-column forms, auth, dialogs |\n| `--container-md` | 768 | Settings forms, account pages |\n| `--container-lg` | 1024 | Dashboard pages, default content max |\n| `--container-xl` | 1280 | Wide dashboards with sidebars-in-content |\n| `--container-2xl` | 1536 | Data tables, monitoring views (full-bleed) |\n\n**Tables get `--container-2xl` or full-bleed.** Settings forms get `--container-md` (max ~720px reading column).\n\n### 8.3 Breakpoints\n\n| Name | min-width | Behavior |\n|---|---|---|\n| `sm` | 640px | Form inputs reach full width on phones |\n| `md` | 768px | Two-column form layouts kick in |\n| `lg` | 1024px | Sidebar appears (below this, sidebar becomes a sheet/drawer) |\n| `xl` | 1280px | Sidebar stays expanded; data tables show all columns |\n| `2xl` | 1536px | Multi-column dashboards |\n\nMobile rule: the sidebar collapses to a hamburger that opens a full-height sheet. The topbar's search and user menu remain.\n\n### 8.4 Grid Patterns\n\n```css\n/* Page header — title left, actions right */\n.page-header {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 16px;\n  margin-bottom: 24px;\n}\n\n/* Two-column form (label left, input right) — settings page idiom */\n.form-row {\n  display: grid;\n  grid-template-columns: minmax(0, 240px) minmax(0, 1fr);\n  gap: 24px;\n  padding: 20px 0;\n  border-bottom: 1px solid var(--border-default);\n}\n.form-row:last-child { border-bottom: none; }\n.form-row__label { font-size: 13px; font-weight: 500; color: var(--text-primary); }\n.form-row__hint  { font-size: 12px; color: var(--text-muted); margin-top: 4px; }\n\n/* Card grid (3-col on desktop, 2 on tablet, 1 on mobile) */\n.card-grid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: 1fr;\n}\n@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }\n@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }\n\n/* KPI strip — auto-fit, never wider than 280, never narrower than 200 */\n.kpi-strip {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n  gap: 12px;\n}\n```\n\n### 8.5 Sidebar Anatomy\n\n```\n[Workspace switcher]      32px row, hover bg, dropdown chevron\n─────────────────────\nSECTION LABEL             11px uppercase, --neutral-500, 8px y-padding\n  [icon] Item 1           36px row, 13px text, 8px gap, hover bg-100\n  [icon] Item 2           selected: bg-brand-50 + brand-700 text\n  [icon] Item 3\n─────────────────────\nSECTION LABEL\n  [icon] Item 4\n─────────────────────\n                          (stretches to fill)\n[avatar] user@email       Bottom; opens user menu upward\n```\n\nKey spec:\n- Sidebar bg: `--bg-canvas` (slightly tinted from page surface) OR `--bg-surface` flat\n- Nav items: 36px tall, 8px horizontal padding, 6px radius, gap 8px between icon + text\n- Active item: `bg: var(--brand-50)`, `color: var(--brand-700)`, **no border, no left-accent stripe** (Linear-style minimal)\n- Section labels: `text-2xs` uppercase, `--neutral-500`, 8px y-padding\n- Width: 256px default, 56px collapsed (icons only)\n\n---\n\n## 9. Dark Mode\n\n### 9.1 Detection\n\n```html\n<script>\n  (function() {\n    var stored = localStorage.getItem('theme');\n    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n    var dark = stored === 'dark' || (stored !== 'light' && prefersDark);\n    document.documentElement.classList.toggle('dark', dark);\n  })();\n</script>\n```\n\nInline in `<head>` BEFORE any stylesheets to prevent flash-of-light-theme on dark-preferred users.\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --bg-canvas: #F8FAFC;\n  --bg-surface: #FFFFFF;\n  --bg-elevated: #FFFFFF;\n  --text-primary: #0F172A;\n  --text-muted: #475569;\n  --text-subtle: #94A3B8;\n  --border-default: #E2E8F0;\n  --border-strong: #CBD5E1;\n  --brand-fg: #5E6AD2;\n  --brand-bg-subtle: #EEF0FB;\n}\n\n.dark {\n  --bg-canvas: #0A0E1A;\n  --bg-surface: #111827;\n  --bg-elevated: #1F2937;\n  --text-primary: #F1F5F9;\n  --text-muted: #94A3B8;\n  --text-subtle: #64748B;\n  --border-default: #1F2937;\n  --border-strong: #374151;\n  --brand-fg: #A5B4FC;\n  --brand-bg-subtle: rgba(94, 106, 210, 0.16);\n}\n```\n\n### 9.3 Three-Way Theme Toggle\n\nAlways offer `system | light | dark`. The user expects to match OS preference by default but be able to override.\n\n```javascript\nfunction setTheme(mode /* 'system' | 'light' | 'dark' */) {\n  if (mode === 'system') {\n    localStorage.removeItem('theme');\n    const dark = matchMedia('(prefers-color-scheme: dark)').matches;\n    document.documentElement.classList.toggle('dark', dark);\n  } else {\n    localStorage.setItem('theme', mode);\n    document.documentElement.classList.toggle('dark', mode === 'dark');\n  }\n}\n\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {\n  if (!localStorage.getItem('theme')) {\n    document.documentElement.classList.toggle('dark', e.matches);\n  }\n});\n```\n\n### 9.4 Dark-Mode-Specific Rules\n\n- **Borders go invisible.** In dark mode, a 1px border with `rgba(255, 255, 255, 0.08)` reads cleaner than the inverted neutral. Use a slightly translucent border for surfaces, solid `--border-default` for input fields.\n- **No drop shadows.** Or, dramatically reduce them. In dark mode, elevation is conveyed by *lighter* surface backgrounds, not by darker shadows underneath.\n- **Brand color shifts lighter.** `--brand-fg` becomes `#A5B4FC` (brand-300/400 range) so it has enough contrast against the dark canvas.\n- **Image / chart luminance.** If you render charts/screenshots inside a card, the card background should be `--bg-elevated` (lighter than canvas) so the artwork doesn't appear floating on void.\n\n---\n\n## 10. Accessibility\n\n### 10.1 Color Contrast\n\nMinimum ratios:\n- **Body text on background: 4.5:1** (WCAG AA Normal Text)\n- **Large text (≥18px or 14px bold) on background: 3:1**\n- **UI components (button border, input border, focus ring) against adjacent color: 3:1**\n- **Non-text indicators (icons that convey state): 3:1**\n\nVerify the included palette:\n- `#0F172A` on `#FFFFFF` = 19.3:1 ✓\n- `#475569` (muted) on `#FFFFFF` = 7.5:1 ✓\n- `#94A3B8` (subtle) on `#FFFFFF` = 3.6:1 ✓ (large text / decorative only)\n- `#5E6AD2` (brand) on `#FFFFFF` = 5.7:1 ✓\n- `#FFFFFF` on `#5E6AD2` (brand button) = 5.7:1 ✓\n\n### 10.2 Focus States\n\n```css\n/* Default focus ring for all interactive elements */\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n  border-radius: var(--radius-sm);\n}\n\n/* For elements with their own visual treatment (buttons, inputs), use box-shadow ring */\nbutton:focus-visible,\n[role=\"button\"]:focus-visible,\na:focus-visible {\n  outline: none;\n  box-shadow: var(--ring-focus);\n}\n\ninput:focus-visible,\ntextarea:focus-visible,\nselect:focus-visible {\n  outline: none;\n  border-color: var(--brand-fg);\n  box-shadow: var(--ring-focus);\n}\n```\n\n**Never remove the focus ring without replacing it.** This is the single most common SaaS accessibility regression.\n\n### 10.3 Keyboard Map (every SaaS app should implement)\n\n| Key | Action |\n|---|---|\n| `⌘K` / `Ctrl+K` | Open command palette |\n| `/` | Focus the page search input |\n| `g` then `d` | Go to dashboard (vim-style nav, optional but Linear/GitHub do it) |\n| `?` | Show keyboard shortcut cheat sheet |\n| `Esc` | Close modal, dismiss popover, blur input |\n| `Tab` / `Shift+Tab` | Move focus forward/back |\n| `Enter` | Activate focused button/link; submit form when input focused |\n| `Space` | Toggle checkbox/switch; activate button |\n| `↑` / `↓` | Navigate list items, table rows, menu items |\n| `j` / `k` | Same as ↓ / ↑ (vim-style; optional) |\n\n### 10.4 Semantic Patterns\n\n- Buttons use `<button>`, links use `<a href>`. **Never** use a `<div onClick>` for either.\n- Form fields have a visible `<label>` (or `aria-label` if visually replaced by a placeholder pattern).\n- Required fields: visible `*` marker AND `aria-required=\"true\"`.\n- Error messages: linked via `aria-describedby`, role `alert` for the first error.\n- Tables: `<th scope=\"col\">` headers, optional `<caption>` for context.\n- Modals: trap focus, restore focus to trigger on close, `aria-modal=\"true\"`, `role=\"dialog\"`, labeled by heading.\n- Toasts: `role=\"status\"` for non-critical, `role=\"alert\"` for errors.\n\n### 10.5 Touch Targets\n\nMinimum 44×44px (WCAG 2.5.5). On desktop SaaS, buttons can be 32-36px tall as long as the **clickable hit area** (via padding) reaches 44px.\n\n```css\n.icon-button {\n  position: relative;\n  width: 32px;\n  height: 32px;\n}\n.icon-button::before {\n  content: '';\n  position: absolute;\n  inset: -6px;  /* extends hit area to 44x44 */\n}\n```\n\n### 10.6 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n.sr-only-focusable:focus {\n  position: static;\n  width: auto;\n  height: auto;\n  clip: auto;\n  white-space: normal;\n}\n```\n\nUse `.sr-only` for \"Skip to main content\" links and icon-only button labels.\n\n---\n\n## 11. Decorative Elements (Used Sparingly)\n\nSaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:\n\n### 11.1 Section Dividers\n\n```css\n.divider {\n  height: 1px;\n  background: var(--border-default);\n  margin: 24px 0;\n}\n\n/* Vertical divider (e.g., between toolbar groups) */\n.divider-v {\n  width: 1px;\n  background: var(--border-default);\n  align-self: stretch;\n  margin: 0 8px;\n}\n```\n\n### 11.2 Inline Pills / Status Dots\n\n```css\n/* Status dot (used in tables: ● Active) */\n.status-dot {\n  display: inline-block;\n  width: 8px;\n  height: 8px;\n  border-radius: 9999px;\n  vertical-align: middle;\n  margin-right: 6px;\n}\n.status-dot--success { background: #16A34A; }\n.status-dot--warning { background: #CA8A04; }\n.status-dot--error   { background: #DC2626; }\n.status-dot--neutral { background: #94A3B8; }\n\n/* Pulsing dot for \"live\" / \"syncing\" */\n.status-dot--pulse {\n  position: relative;\n}\n.status-dot--pulse::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: inherit;\n  animation: pulse-ring 1.6s ease-out infinite;\n}\n@keyframes pulse-ring {\n  0%   { transform: scale(1);   opacity: 0.6; }\n  100% { transform: scale(2.5); opacity: 0; }\n}\n```\n\n### 11.3 Empty-State Iconography Placeholder\n\nFor empty states, draw a single 1.5px-stroke line icon at 48–64px size in `--neutral-300`. Do not use 3D illustrations or stock vectors — they break the \"quiet product\" register.\n\n```html\n<div class=\"empty-state\">\n  <svg width=\"48\" height=\"48\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n       stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n       style=\"color: var(--neutral-300);\">\n    <!-- single line-art icon (e.g., a Lucide icon) -->\n  </svg>\n  <h3>No customers yet</h3>\n  <p>Customers appear here after their first successful payment.</p>\n  <button>Create test customer</button>\n</div>\n```\n\n### 11.4 Subtle Background Grid (for empty canvases)\n\nFor dashboard backgrounds, an OPTIONAL very subtle dot grid is acceptable:\n\n```css\n.canvas-grid {\n  background-color: var(--bg-canvas);\n  background-image: radial-gradient(circle, var(--neutral-200) 1px, transparent 1px);\n  background-size: 24px 24px;\n  background-position: 0 0;\n}\n```\n\nOpacity: never above 30%. Most SaaS apps omit this entirely.\n\n### 11.5 Keyboard Shortcut Chips\n\n```css\n.kbd {\n  display: inline-flex;\n  align-items: center;\n  gap: 2px;\n  padding: 2px 6px;\n  font-family: var(--font-mono);\n  font-size: 11px;\n  line-height: 1.2;\n  color: var(--text-muted);\n  background: var(--bg-surface);\n  border: 1px solid var(--border-default);\n  border-bottom-width: 2px;  /* mechanical key look */\n  border-radius: 4px;\n  vertical-align: middle;\n}\n```\n\nUsage: `<span class=\"kbd\">⌘</span><span class=\"kbd\">K</span>` inside tooltips, menu items, command palette.\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border | Use |\n|---|---|---|---|---|\n| `primary` | `--brand-600` | `#FFFFFF` | none | The one primary action per surface |\n| `secondary` | `--bg-surface` | `--text-primary` | `--border-default` 1px | Most actions (\"Cancel\", \"Save\", \"Edit\") |\n| `ghost` | transparent | `--text-primary` | none | Toolbar buttons, table row actions |\n| `destructive` | `#DC2626` | `#FFFFFF` | none | Delete, revoke, irreversible |\n| `destructive-outline` | `--bg-surface` | `#B91C1C` | `#FCA5A5` | Less aggressive destructive |\n| `link` | none | `--brand-600` | none, underline on hover | Inline navigation actions |\n\nSizes: `sm` (28px tall, 12px text), `md` (32px tall, 13px text — **default**), `lg` (40px tall, 14px text).\n\n### Inputs\n\n| State | Border | Background | Shadow |\n|---|---|---|---|\n| default | `--border-default` | `--bg-surface` | none |\n| hover | `--border-strong` | `--bg-surface` | none |\n| focus | `--brand-fg` | `--bg-surface` | `--ring-focus` |\n| error | `#DC2626` | `--bg-surface` | `--ring-error` |\n| disabled | `--border-default` | `--neutral-50` | none, `opacity: 0.6` |\n\nSizes match buttons: `sm` (28px), `md` (32px default), `lg` (40px).\n\n### Cards\n\n| Variant | Background | Border | Padding | Shadow |\n|---|---|---|---|---|\n| default | `--bg-surface` | 1px `--border-default` | 20px | none |\n| interactive | `--bg-surface` | 1px `--border-default` | 20px | hover: `--shadow-sm` |\n| ghost | none | none | 0 | none — for grouping without enclosure |\n| elevated | `--bg-elevated` | none | 24px | `--shadow-md` |\n| outline-only | none | 1px `--border-default` | 16px | none — for placeholder/empty slots |\n\n### Badges / Status Pills\n\n| Variant | Background | Text | Border |\n|---|---|---|---|\n| neutral | `--neutral-100` | `--neutral-700` | none |\n| brand | `--brand-50` | `--brand-700` | none |\n| success | `#DCFCE7` | `#15803D` | none |\n| warning | `#FEF9C3` | `#A16207` | none |\n| error | `#FEE2E2` | `#B91C1C` | none |\n| info | `#DBEAFE` | `#1D4ED8` | none |\n\nSizing: padding `2px 8px`, font 11–12px, weight 500, radius 4px (NOT pill). Optional leading status dot.\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── src/\n│   ├── styles/\n│   │   ├── tokens.css           # All CSS custom properties (one file, single source of truth)\n│   │   ├── base.css             # Reset, html/body, typography defaults, focus\n│   │   └── globals.css          # @import the above + utility classes\n│   ├── components/\n│   │   ├── primitives/          # Lowest-level, design-token-aware\n│   │   │   ├── Button.tsx\n│   │   │   ├── Input.tsx\n│   │   │   ├── Select.tsx\n│   │   │   ├── Checkbox.tsx\n│   │   │   ├── Switch.tsx\n│   │   │   ├── Badge.tsx\n│   │   │   ├── Card.tsx\n│   │   │   ├── Avatar.tsx\n│   │   │   ├── Tooltip.tsx\n│   │   │   ├── Kbd.tsx\n│   │   │   └── Spinner.tsx\n│   │   ├── overlays/            # Floating layers\n│   │   │   ├── Dialog.tsx\n│   │   │   ├── Sheet.tsx\n│   │   │   ├── Popover.tsx\n│   │   │   ├── DropdownMenu.tsx\n│   │   │   ├── Toast.tsx\n│   │   │   └── CommandPalette.tsx\n│   │   ├── data/                # Data display\n│   │   │   ├── DataTable.tsx\n│   │   │   ├── Pagination.tsx\n│   │   │   ├── KpiCard.tsx\n│   │   │   └── EmptyState.tsx\n│   │   ├── forms/\n│   │   │   ├── FormField.tsx\n│   │   │   ├── FormRow.tsx\n│   │   │   └── FormSection.tsx\n│   │   └── shell/               # The app frame\n│   │       ├── AppShell.tsx\n│   │       ├── Sidebar.tsx\n│   │       ├── Topbar.tsx\n│   │       ├── PageHeader.tsx\n│   │       └── Breadcrumb.tsx\n│   └── lib/\n│       ├── theme.ts             # Theme toggle (system/light/dark)\n│       ├── shortcuts.ts         # Global keyboard handler\n│       └── format.ts            # Money, date, relative time helpers\n├── tailwind.config.ts           # Tokens mirrored from tokens.css\n└── tsconfig.json\n```\n\nRules:\n- **`tokens.css` is the single source of truth.** `tailwind.config.ts` reads from it (or duplicates its values, but only as a mirror).\n- **Primitives have no business logic.** They accept props, render styled DOM. State lives in feature components that wrap them.\n- **No component crosses layer boundaries.** A primitive can't import from `shell/`; `shell/` can't import from `data/`.\n\n---\n\n## 14. Full CSS Custom Properties\n\nCopy into `styles/tokens.css`:\n\n```css\n:root {\n  /* ── Brand ── */\n  --brand-50:  #EEF0FB;\n  --brand-100: #DDE0F7;\n  --brand-200: #B9C0EF;\n  --brand-500: #7B85DC;\n  --brand-600: #5E6AD2;\n  --brand-700: #4A56C0;\n  --brand-800: #3A45A8;\n  --brand-900: #2A3284;\n  --brand-fg: var(--brand-600);\n  --brand-bg-subtle: var(--brand-50);\n\n  /* ── Neutrals (slate) ── */\n  --neutral-0:   #FFFFFF;\n  --neutral-50:  #F8FAFC;\n  --neutral-100: #F1F5F9;\n  --neutral-200: #E2E8F0;\n  --neutral-300: #CBD5E1;\n  --neutral-400: #94A3B8;\n  --neutral-500: #64748B;\n  --neutral-600: #475569;\n  --neutral-700: #334155;\n  --neutral-800: #1E293B;\n  --neutral-900: #0F172A;\n  --neutral-950: #020617;\n\n  /* ── Semantic surfaces ── */\n  --bg-canvas:   var(--neutral-50);\n  --bg-surface:  var(--neutral-0);\n  --bg-elevated: var(--neutral-0);\n  --bg-hover:    var(--neutral-100);\n\n  --text-primary: var(--neutral-900);\n  --text-muted:   var(--neutral-600);\n  --text-subtle:  var(--neutral-400);\n\n  --border-default: var(--neutral-200);\n  --border-strong:  var(--neutral-300);\n\n  /* ── State ── */\n  --success-fg:  #15803D;\n  --success-bg:  #DCFCE7;\n  --warning-fg:  #A16207;\n  --warning-bg:  #FEF9C3;\n  --error-fg:    #B91C1C;\n  --error-bg:    #FEE2E2;\n  --info-fg:     #1D4ED8;\n  --info-bg:     #DBEAFE;\n\n  /* ── Chart palette ── */\n  --chart-1: #2563EB;\n  --chart-2: #16A34A;\n  --chart-3: #CA8A04;\n  --chart-4: #DC2626;\n  --chart-5: #9333EA;\n  --chart-6: #0891B2;\n  --chart-7: #DB2777;\n  --chart-8: #65A30D;\n\n  /* ── Typography ── */\n  --font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n               \"SF Pro Text\", system-ui, sans-serif;\n  --font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n               ui-monospace, monospace;\n\n  /* ── Spacing (multiples of 4) ── */\n  --space-unit: 4px;\n\n  /* ── Radius ── */\n  --radius-none: 0;\n  --radius-sm:   4px;\n  --radius-md:   6px;\n  --radius-lg:   8px;\n  --radius-xl:   12px;\n  --radius-2xl:  16px;\n  --radius-full: 9999px;\n\n  /* ── Shadows ── */\n  --shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n  --shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06);\n  --shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04);\n  --shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08);\n  --shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10);\n  --shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n  --ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n  --ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n  /* ── Motion ── */\n  --ease-out:        cubic-bezier(0.16, 1, 0.3, 1);\n  --ease-out-back:   cubic-bezier(0.34, 1.56, 0.64, 1);\n  --ease-in-out:     cubic-bezier(0.4, 0, 0.2, 1);\n  --duration-75:  75ms;\n  --duration-100: 100ms;\n  --duration-150: 150ms;\n  --duration-200: 200ms;\n  --duration-250: 250ms;\n  --duration-300: 300ms;\n\n  /* ── Layout ── */\n  --container-sm:  640px;\n  --container-md:  768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1536px;\n  --sidebar-w:        256px;\n  --sidebar-w-collapsed: 56px;\n  --topbar-h:         48px;\n}\n\n.dark {\n  --bg-canvas:   #0A0E1A;\n  --bg-surface:  #111827;\n  --bg-elevated: #1F2937;\n  --bg-hover:    rgba(255, 255, 255, 0.04);\n\n  --text-primary: #F1F5F9;\n  --text-muted:   #94A3B8;\n  --text-subtle:  #64748B;\n\n  --border-default: #1F2937;\n  --border-strong:  #374151;\n\n  --brand-fg:         #A5B4FC;\n  --brand-bg-subtle:  rgba(94, 106, 210, 0.16);\n\n  --success-fg: #4ADE80;\n  --success-bg: rgba(22, 163, 74, 0.16);\n  --warning-fg: #FACC15;\n  --warning-bg: rgba(202, 138, 4, 0.16);\n  --error-fg:   #F87171;\n  --error-bg:   rgba(220, 38, 38, 0.16);\n  --info-fg:    #60A5FA;\n  --info-bg:    rgba(37, 99, 235, 0.16);\n\n  --shadow-xs:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n  --shadow-sm:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n  --shadow-md:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n  --shadow-lg:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n  --shadow-xl:  0 20px 32px -10px rgba(0, 0, 0, 0.60);\n  --shadow-2xl: 0 30px 60px -15px rgba(0, 0, 0, 0.70);\n}\n\n/* ── Base ── */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1, \"cv11\" 1, \"ss01\" 1;\n}\n\nbody {\n  margin: 0;\n  background: var(--bg-canvas);\n  color: var(--text-primary);\n  font-family: var(--font-sans);\n  font-size: 14px;\n  line-height: 1.43;\n}\n\n.numeric, table, [data-numeric=\"true\"] {\n  font-variant-numeric: tabular-nums;\n}\n\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Implementation Physics**\n> Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Density Tier\n\n| Tier | Row Height | Button Height | Default Text | When |\n|---|---|---|---|---|\n| **Compact** (Linear, dev tools) | 32px | 28px | 13px | Power-user surfaces, data-heavy tables |\n| **Default** (Stripe, Vercel) | 40px | 32px | 14px | The default SaaS product UI |\n| **Comfortable** (Notion, Clerk) | 48px | 36px | 15px | Content-heavy, settings, billing |\n| **Spacious** (Auth, onboarding) | 56px+ | 40px+ | 16px | Single-purpose conversion pages |\n\n**Pick one tier per product, not per page.** Mixing density breaks the rhythm.\n\n#### Layout Constants\n\n```\nContent width:\n├── Tables / lists:       full bleed within --container-2xl (1536px)\n├── Dashboards:           --container-xl (1280px) centered\n├── Settings forms:       --container-md (768px) — readable column\n└── Auth / onboarding:    --container-sm (640px) centered\n\nPage padding (X):\n├── Mobile:    16px (px-4)\n├── Tablet:    24px (px-6)\n└── Desktop:   32px (px-8)\n\nPage padding (Y):\n├── Page header → content:     16-24px\n├── Section → section:         32-40px\n└── Last section → page end:   64px (so footer doesn't crowd)\n\nGrid gaps:\n├── KPI cards:           12px\n├── Card grids:          16px (sm) → 20px (md) → 24px (lg)\n├── Form fields:         16px (tight) or 20px (default)\n└── Settings rows:       0 — separated by 1px bottom border\n```\n\n#### The \"Three Heights\" Rule\n\nIn a SaaS UI, three vertical rhythms coexist:\n\n1. **Page rhythm**: 24px / 32px / 48px between page sections.\n2. **Component rhythm**: 12px / 16px / 20px inside cards and panels.\n3. **Inline rhythm**: 4px / 6px / 8px between adjacent elements (icon + label, badge + text).\n\nEach rhythm is a multiple of 4 but **does not reuse** the same numbers from another rhythm. Inline gaps stay ≤8px, component gaps stay 12–20px, page gaps stay ≥24px. This is what makes a SaaS UI feel \"organized\" without conscious effort.\n\n### 15.2 Color Science & Elevation\n\n#### How elevation is conveyed (without heavy shadows)\n\n| Layer | Light Mode | Dark Mode |\n|---|---|---|\n| Canvas | `#F8FAFC` (1 step down from white) | `#0A0E1A` |\n| Surface (default card) | `#FFFFFF` + 1px border | `#111827` + 1px `rgba(255,255,255,0.04)` border |\n| Elevated (popover) | `#FFFFFF` + `--shadow-md` | `#1F2937` + low shadow |\n| Modal | `#FFFFFF` + `--shadow-xl` + backdrop blur | `#1F2937` + `--shadow-xl` + backdrop blur |\n\nIn light mode, elevation = subtle shadow + 1px border.\nIn dark mode, elevation = LIGHTER background (the higher the layer, the lighter the bg) + nearly-invisible shadow.\n\n#### Brand color is \"interaction language\"\n\n- Brand color = \"this is interactive, this is selected, this is focused.\"\n- Anything else uses neutrals.\n- Status colors (success/error/etc.) are ONLY for status — never for buttons that don't represent that status.\n\nA useful test: **squint at the UI**. You should see 90% gray-on-white, with brand pinpricks marking exactly the interactive surface you'd click.\n\n#### Selected-state convention\n\n| State | Convention |\n|---|---|\n| Hover | Background shifts by 1 step (`--bg-surface` → `--bg-hover`) |\n| Active (pressed) | Background shifts by 2 steps OR scale(0.98) |\n| Selected (persistent) | `--brand-bg-subtle` background + `--brand-fg` text. **No border change.** |\n| Focused (keyboard) | `--ring-focus` box-shadow ring. Independent of hover/selected. |\n| Disabled | `opacity: 0.5` + `cursor: not-allowed`. Color unchanged. |\n\n### 15.3 Typography & Micro-Copy\n\n#### The \"13/14 default\" rule\n\nThe default body/UI size is **13px or 14px**. Headings step up modestly (no 48px headlines in a dashboard). The biggest text in a typical product page is the H1 at 24–30px.\n\n| Element | Size | Weight |\n|---|---|---|\n| H1 (page title) | 24px | 600 |\n| H2 (section) | 18px | 600 |\n| H3 (card heading) | 15-16px | 600 |\n| Body / table cell | 13-14px | 400 |\n| Label / button | 13-14px | 500 |\n| Microlabel / caption | 11-12px | 500 (uppercase optional) |\n| Mono (id, code, money) | 12-13px | 400 |\n\n#### Micro-copy patterns\n\n**Buttons** — verb + object, never just verb:\n- ✅ \"Create customer\", \"Save changes\", \"Delete invoice\"\n- ❌ \"Submit\", \"OK\", \"Click here\"\n\n**Empty state headings** — declarative, not interrogative:\n- ✅ \"No active subscriptions\"\n- ❌ \"You don't have any subscriptions yet, would you like to create one?\"\n\n**Inline help / hints** — single sentence, no period (because it's not a sentence in form context):\n- ✅ \"Used for receipts and customer emails\"\n- ❌ \"This email will be used for receipts and customer emails.\"\n\n**Destructive confirmation** — type-to-confirm for irreversible:\n- \"Type `acme-corp` to confirm deletion\" — see GitHub's repo deletion modal.\n\n**Toast text** — past tense, with optional action:\n- ✅ \"Invoice sent. [Undo]\"\n- ❌ \"Sending invoice…\" (use a loading state for in-flight)\n\n### 15.4 Component Anatomy\n\n#### Button (the most-touched primitive)\n\n```\n┌────────────────────────────────────┐\n│  [icon] [label]            [⌘K]    │   ← gap 8px between icon/label, 12px to shortcut\n└────────────────────────────────────┘\n   ↑ padding 12-14px              ↑ padding 12-14px\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ font: 13px / 500\n```\n\nVariants:\n- `primary` — solid brand bg, white text. ONE per surface.\n- `secondary` — surface bg, neutral border, primary text. Most actions.\n- `ghost` — no bg, no border. Toolbar / row actions.\n- `destructive` — solid red bg. Reserved for irreversible.\n\nStates:\n- default → hover (bg shifts) → active (bg shifts more / scale 0.98) → focus (ring) → disabled (opacity 0.5)\n\nForbidden:\n- Pill radius (`9999px`) — that's marketing\n- Gradient background — that's marketing\n- Drop shadow — buttons are flat against the surface\n\n#### Input\n\n```\n┌────────────────────────────────────┐\n│  [icon] placeholder text      [×]  │   ← optional leading icon, trailing clear\n└────────────────────────────────────┘\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ border: 1px --border-default\n   ─ padding: 0 12px (or 0 36px when icon present)\n   ─ font: 14px / 400\n```\n\nFocus state: border → `--brand-fg`, add `--ring-focus` box-shadow. **Never use solid background change for focus** — borders + ring are the convention.\n\nError state: border → `#DC2626`, ring → `--ring-error`. Show error message below input in `#B91C1C`, 12px, with `aria-describedby` linking.\n\n#### Card\n\n```\n┌──────────────────────────────────┐\n│  Heading            [actions]    │   ← header row, 16-20px from edges\n│  Optional description            │\n├──────────────────────────────────┤   ← 1px divider OR padding gap\n│                                  │\n│  Card body                       │\n│                                  │\n└──────\n\n<!-- [TRUNCATED AT 50KB] -->","worker/agents/think/skills/frontend-design/SKILL.md":"---\nname: frontend-design\ndescription: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.\nlicense: Complete terms in LICENSE.txt\n---\n\nThis skill guides creation of distinctive, production-grade frontend interfaces that avoid generic \"AI slop\" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.\n\nThe user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.\n\n## Design Thinking\n\nBefore coding, understand the context and commit to a BOLD aesthetic direction:\n- **Purpose**: What problem does this interface solve? Who uses it?\n- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.\n- **Constraints**: Technical requirements (framework, performance, accessibility).\n- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?\n\n**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.\n\nThen implement working code (HTML/CSS/JS, React, Vue, etc.) that is:\n- Production-grade and functional\n- Visually striking and memorable\n- Cohesive with a clear aesthetic point-of-view\n- Meticulously refined in every detail\n\n## Frontend Aesthetics Guidelines\n\nFocus on:\n- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.\n- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.\n- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.\n- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.\n- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.\n\nNEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.\n\nInterpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.\n\n**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.\n\nRemember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision."},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AGENTS.md\n\n## Tooling\n- Use Bun from the repository root. The tracked lockfile is `bun.lock`, the `space` workspace dependency uses `workspace:*`, and install/build hooks invoke Bun even when started through npm.\n- `bun run setup` is the interactive Cloudflare/resource bootstrap. Local development expects the generated `.dev.vars`; never commit `.dev.vars*` or `.prod.vars`.\n- `bun run dev` starts the React frontend and Worker together through `@cloudflare/vite-plugin` at `http://localhost:5173`. There is no separate Worker dev command.\n- `bun run dev:browser` is an optional local Chromium sidecar for the think agent's browser-console tool; absence only produces a warning.\n\n## Verification\n- Root checks: `bun run typecheck`, `bun run lint`, `bun run test`, `bun run build`.\n- `bun run build` builds `space` and the Vite/Worker bundle; it does not typecheck. Run `bun run typecheck` separately.\n- Focus a root test with `bunx vitest run path/to/file.test.ts`; test execution uses the Workers pool and `wrangler.test.jsonc`.\n- The root Vitest suite excludes all `sdk/test/**` and `container/monitor-cli.test.ts`. SDK tests use Bun: `bun run --cwd sdk test`.\n- SDK integration tests require a running root dev server and `VIBESDK_INTEGRATION_API_KEY`; run `bun run --cwd sdk test:integration`. They can take 5-10 minutes; `VIBESDK_INTEGRATION_RUN_PREVIEW=1` enables the slower preview case.\n- Root typecheck/lint do not validate `space` or `sdk`. For touched packages run `bun run --cwd space typecheck` / `bun run --cwd space build` and `bun run --cwd sdk package` as appropriate.\n- ESLint checks only `src/**` and `worker/**` and deliberately ignores tests; do not treat `bun run lint` as repository-wide validation.\n- Pre-commit typechecks staged TypeScript and runs related Vitest tests. `RUN_ALL_TESTS=1` selects its broader suite; `SKIP_TESTS=1` bypasses the hook.\n\n## Frontend UI\n- Tailwind CSS v4 via CSS-first setup in `src/index.css` (`@import 'tailwindcss'`, `@theme`, Kumo tokens); no `tailwind.config.*`.\n- Prefer `@cloudflare/kumo` for new UI. List components with `bun kumo ls`; component docs via `bun kumo doc Button` (swap name as needed). Legacy shadcn/Radix under `src/components/ui/` still exists—do not add new primitives there when Kumo covers the case.\n- Icons: `@phosphor-icons/react`. Dark mode is `data-mode=\"dark\"` on the root (not a `class` strategy).\n- Path aliases: `@/*` → `src/*`, `shared/*`, `worker/*` (see `tsconfig.app.json`).\n\n## Frontend Data Fetching\n- Use TanStack Query for frontend server state and network-call caching. `QueryClientProvider` is wired at the React root; configure shared defaults in `src/lib/query-client.ts`.\n- Keep TanStack query keys centralized in `src/lib/query-keys.ts`. Use hierarchical keys so broad invalidation works, for example `queryKeys.apps.all` should invalidate app list/favorite variants.\n- Frontend HTTP still goes through `src/lib/api-client.ts`; query functions should wrap existing `apiClient` methods rather than calling `fetch` directly from components.\n- Include user/account identity in query keys when cached data is user-specific, or explicitly clear/remove those queries on logout/user switch. `enabled: !!user` prevents fetching but does not clear old cached data.\n- Mutations that change cached server state must update cache with `queryClient.setQueryData` or invalidate the relevant `queryKeys` on success. Do not rely on a local `refetch()` in one component if sidebar or other shared UI consumes the same data.\n- Prefer query hooks (`useQuery`, `useMutation`) over ad-hoc loading/error state in React contexts. Context remains appropriate for client-only UI state or providers required by libraries.\n\n## Boundaries\n- `src/` is the React app (`src/main.tsx`, routes in `src/routes.tsx`). API contracts live in `src/api-types.ts`; frontend HTTP calls belong in `src/lib/api-client.ts`.\n- `worker/index.ts` is the Worker entrypoint and Durable Object export surface. Hono middleware/routes are wired by `worker/app.ts` and `worker/api/routes/index.ts`.\n- `space/` is the only declared workspace package. It provides the `SpaceDO` workspace and file layer used by the think agent, with durable git history stored through Cloudflare Artifacts, and is bundled before the root app; edit implementation in `space/src`, never generated `space/dist`, and keep the hand-maintained `space/types/index.d.ts` aligned with public exports.\n- `sdk/` is an independent Bun package with its own lockfile, scripts, and tests. It imports the platform WebSocket protocol from `worker/api/websocketTypes.ts`, so protocol changes must remain SDK-compatible.\n- Shared frontend/backend types belong in `shared/`; Worker-only types stay under `worker/`.\n- Architecture overview (ThinkAgent, SpaceDO, Artifacts, Dynamic Worker previews): `docs/llm.md`. Production deploy: `bun run deploy` (needs `.prod.vars`).\n\n## Change Paths\n- API endpoint: update `src/api-types.ts` -> `src/lib/api-client.ts` -> `worker/database/services/` (when persistence is needed) -> `worker/api/controllers/` -> `worker/api/routes/`, then register the route in `worker/api/routes/index.ts`.\n- WebSocket message: update `worker/api/websocketTypes.ts`, backend handling in `worker/agents/core/websocket.ts`, and frontend handling in `src/routes/chat/utils/handle-websocket-message.ts`; verify SDK tests because its protocol re-exports these types.\n- LLM tool: add it under `worker/agents/tools/toolkit/` and register it in `worker/agents/tools/customTools.ts` (`buildTools` or `buildDebugTools`). The think behavior has a separate tool path and bypasses `buildTools`.\n- Think tool: create it under `worker/agents/think/`, add SpaceDO RPC typing if needed, register it in `ThinkAgent.getTools()`, and update the relevant prompt or skill.\n- D1 schema source is `worker/database/schema.ts`; generate migrations into `migrations/` with `bun run db:generate`, then apply locally with `bun run db:migrate:local`.\n- After changing Wrangler bindings, run `bun run cf-typegen`; `worker-configuration.d.ts` is consumed by setup and TypeScript configs.\n\n## Constraints\n- Do not introduce new `any` types even though ESLint currently permits existing ones; find or define a concrete type. Frontend API types should import from `@/api-types`.\n- Worker code reads bindings from `env`; do not use Vite environment variables there.\n- All `/api/*` routes are owner-only by default in `worker/app.ts`; public routes must explicitly follow the existing auth override pattern.\n- User secrets RPC methods return `null`/`boolean` on failure rather than throwing; preserve that contract when editing `worker/services/secrets/`.\n- For usage-limit UI behavior and its cross-component invariants, read `docs/usage-limits-ui.md` before editing the badge, credits banner, or limit popups.\n","isInternal":false,"tokens":1616,"sizeBytes":6815},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code when working with code in this repository.\n\n## Communication Style\n- Be professional, concise, and direct\n- Do NOT use emojis in code reviews, changelogs, or any generated content. You may use professional visual indicators or favor markdown formatting over emojis.\n- Focus on substance over style\n- Use clear technical language\n\n## Project Overview\n\nVibeSDK is an agentic full-stack application builder on Cloudflare.\n\n**Tech Stack:**\n- Frontend: React 19, TypeScript, Vite, TailwindCSS, React Router v7\n- Backend: Cloudflare Workers, Durable Objects, Hono, D1, R2, and KV\n- Agent: Cloudflare Think with AI Gateway model routing\n- Workspace: SpaceDO Durable Objects\n- Version history: Cloudflare Artifacts\n- Preview runtime: Worker Loader bindings and Dynamic Workers\n- Generated app data: Durable Object Facets with isolated SQLite\n- WebSocket: PartySocket for real-time communication\n\n**Project Structure:**\n- `/src` - React frontend, API types, and API client\n- `/worker/agents/think` - ThinkAgent, prompts, skills, workspace adapter, and tools\n- `/worker/agents/core/behaviors/think.ts` - Think host orchestration\n- `/worker/api` - Routes, controllers, handlers, and WebSocket types\n- `/worker/database` - D1 schema and services\n- `/space` - SpaceDO, Artifacts synchronization, preview bundling, and App Facets\n- `/sdk` - TypeScript client SDK\n- `/migrations` - D1 migrations\n- `/scripts` - Setup and deployment utilities\n\n## Key Architectural Patterns\n\n**ThinkAgent:**\n- One Agent backed by a Durable Object per app session\n- Owns conversation, context selection, skills, streaming, tools, and step limits\n- Uses explicit SpaceDO-backed tools; workspace bash is disabled\n\n**Workspace and Versioning:**\n- SpaceDO owns the isolated live workspace and files\n- Cloudflare Artifacts owns durable commits, branches, history, and restore points\n- `commit` saves without deploying; `deploy_space` commits and rebuilds the preview\n- Rollback applies a selected tree, creates a new commit, and redeploys\n\n**Preview Runtime:**\n- `@cloudflare/worker-bundler` builds committed project files\n- Worker Loader loads bundled modules as a Dynamic Worker\n- Generated `App` classes run as Durable Object Facets with isolated SQLite\n\n**WebSocket Communication:**\n- PartySocket carries realtime agent output, tools, files, and deployment state\n- Session state is restored on reconnect\n\n## Common Development Tasks\n\n**Change LLM Model for Operation:**\nEdit `/worker/agents/inferutils/config.ts` → `AGENT_CONFIG` object\n\n**Modify Think Agent Behavior:**\nEdit `worker/agents/think/ThinkAgent.ts`, the host behavior in `worker/agents/core/behaviors/think.ts`, and the relevant prompt or skill.\n\n**Add New WebSocket Message:**\n1. Add type to `worker/api/websocketTypes.ts`\n2. Handle in `worker/agents/core/websocket.ts`\n3. Handle in `src/routes/chat/utils/handle-websocket-message.ts`\n\n**Add New Think Tool:**\n1. Create the tool under `worker/agents/think/`\n2. Add required SpaceDO RPC typing to `space-workspace-ops.ts`\n3. Register it in `ThinkAgent.getTools()`\n4. Update the relevant prompt or skill\n5. Add focused tests\n\n**Add API Endpoint:**\n1. Define types in `src/api-types.ts`\n2. Add to `src/lib/api-client.ts`\n3. Create service in `worker/database/services/`\n4. Create controller in `worker/api/controllers/`\n5. Add route in `worker/api/routes/`\n6. Register in `worker/api/routes/index.ts`\n\n## Important Context\n\n**User Secrets Store (Durable Object):**\n- Location: `/worker/services/secrets/`\n- Purpose: Encrypted storage for user API keys with key rotation\n- Architecture: One DO per user, XChaCha20-Poly1305 encryption, SQLite backend\n- Key derivation: MEK → UMK → DEK (hierarchical PBKDF2)\n- Features: Key rotation, soft deletion, access tracking, expiration support\n- RPC Methods: Return `null`/`boolean` on error, never throw exceptions\n- Testing: 90 comprehensive tests in `/test/worker/services/secrets/`\n\n**Workspace and Git:**\n- SpaceDO provides workspace and file operations\n- Cloudflare Artifacts stores durable git history\n- Artifacts synchronization lives in `space/src/space/artifacts-sync.ts`\n- Rollback preserves history by creating a new commit\n\n**Abort Controller Pattern:**\n- `getOrCreateAbortController()` reuses controller for nested operations\n- Cleared after top-level operations complete\n- Shared by parent and nested tool calls\n- User abort cancels entire operation tree\n\n**Message Deduplication:**\n- Tool execution causes duplicate AI messages\n- Backend skips redundant LLM calls (empty tool results)\n- Frontend utilities deduplicate live and restored messages\n- System prompt teaches LLM not to repeat\n\n## Core Rules (Non-Negotiable)\n\n**1. Strict Type Safety**\n- NEVER use `any` type\n- Frontend imports types from `@/api-types` (single source of truth)\n- Search codebase for existing types before creating new ones\n\n**2. DRY Principle**\n- Search for similar functionality before implementing\n- Extract reusable utilities, hooks, and components\n- Never copy-paste code - refactor into shared functions\n\n**3. Follow Existing Patterns**\n- Frontend APIs: All in `/src/lib/api-client.ts`\n- Backend Routes: Controllers in `worker/api/controllers/`, routes in `worker/api/routes/`\n- Database Services: In `worker/database/services/`\n- Types: Shared in `shared/types/`, API in `src/api-types.ts`\n\n**4. Code Quality**\n- Production-ready code only - no TODOs or placeholders\n- No hacky workarounds\n- Comments explain purpose, not narration\n- No overly verbose AI-like comments\n\n**5. File Naming**\n- React Components: PascalCase.tsx\n- Utilities/Hooks: kebab-case.ts\n- Backend Services: PascalCase.ts\n\n## Common Pitfalls\n\n**Don't:**\n- Use `any` type (find or create proper types)\n- Copy-paste code (extract to utilities)\n- Use Vite env variables in Worker code\n- Forget to update types when changing APIs\n- Create new implementations without searching for existing ones\n- Use emojis in code or comments\n- Write verbose AI-like comments\n\n**Do:**\n- Search codebase thoroughly before creating new code\n- Follow existing patterns consistently\n- Keep comments concise and purposeful\n- Write production-ready code\n- Test thoroughly before submitting","isInternal":false,"tokens":1444,"sizeBytes":6208},{"name":"SKILL.md","path":"worker/agents/think/skills/app-ai-binding/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/worker/agents/think/skills/app-ai-binding/SKILL.md","title":"app-ai-binding","category":"anthropic-skill","format":"markdown","content":"---\nname: app-ai-binding\ndescription: Build AI features in generated apps using the platform-provided Workers AI binding. Load this skill for chatbots, summarization, classification, embeddings, semantic search, image understanding, transcription, translation, or other model inference.\n---\n\nGenerated apps can call Workers AI from the `App` Durable Object through `this.env.AI`. The platform provides this binding automatically in Dynamic Worker previews and native Cloudflare deployments. Do not add an `ai` section to the generated app's `wrangler.json`.\n\nUse only `AI.run(model, inputs, options?)`. Preview uses a restricted proxy with this method, while deployed Workers receive the native Workers AI binding. Methods such as `AI.gateway()`, `AI.models()`, `AI.autorag()`, and `AI.toMarkdown()` are not portable between preview and deployment.\n\n## Type and usage\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\n\ninterface Env {\n  AI: Ai;\n}\n\nexport class App extends DurableObject<Env> {\n  async fetch(request: Request): Promise<Response> {\n    const { prompt } = await request.json<{ prompt: string }>();\n    const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n      messages: [\n        { role: \"system\", content: \"Answer clearly and concisely.\" },\n        { role: \"user\", content: prompt },\n      ],\n    });\n    return Response.json(result);\n  }\n}\n```\n\nKeep AI calls in the server-side `App` class. Browser code should call an app API route; it must never receive model credentials or call the binding directly.\n\n## Common tasks\n\n### Text generation\n\nUse `@cf/meta/llama-4-scout-17b-16e-instruct` with either `prompt` or `messages`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n  messages: [{ role: \"user\", content: userPrompt }],\n  max_tokens: 512,\n});\n```\n\n### Embeddings\n\nUse `@cf/baai/bge-base-en-v1.5`. The response contains embedding vectors in `data`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/baai/bge-base-en-v1.5\", {\n  text: [\"first document\", \"second document\"],\n});\n```\n\n### Image classification\n\nUse `@cf/microsoft/resnet-50` with image bytes converted to a number array:\n\n```ts\nconst image = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/microsoft/resnet-50\", { image });\n```\n\n### Speech transcription\n\nUse `@cf/openai/whisper` with audio bytes:\n\n```ts\nconst audio = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/openai/whisper\", { audio });\n```\n\nModel input and output shapes differ. Keep model-specific parsing close to the call and validate user input before inference. Use only active model IDs from the current Workers AI model catalog; never generate code with a deprecated model.\n\n## Limits and errors\n\nDynamic Worker previews enforce a per-app AI call limit. AI features must fail transparently when inference is unavailable.\n\n- Never return canned, random, mock, placeholder, or hard-coded content as a fallback for an AI response.\n- Never catch an `AI.run()` error and continue with an apparently successful response.\n- Never return `200` with fields such as `fallbackUsed` after inference fails.\n- Return a non-2xx JSON response containing a safe, useful error message. The frontend must display that error to the user.\n- Do not retry rate-limit failures in a loop.\n\n```ts\ntry {\n  const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n    messages: [{ role: \"user\", content: userPrompt }],\n  });\n  return Response.json(result);\n} catch (error) {\n  const message = error instanceof Error ? error.message : \"AI inference failed\";\n  return Response.json({ error: \"AI inference failed\", details: message }, { status: 503 });\n}\n```\n\nFrontend callers must inspect both the HTTP status and error body:\n\n```ts\nconst response = await fetch(\"./api/chat\", requestOptions);\nconst body = await response.json<{ content?: string; error?: string; details?: string }>();\nif (!response.ok) throw new Error(body.details || body.error || \"AI request failed\");\n```\n\nUse non-streaming calls for preview-compatible code. Native Workers AI deployments support `{ stream: true }`, but streaming across the preview proxy is not guaranteed.\n","frontmatter":{"name":"app-ai-binding","description":"Build AI features in generated apps using the platform-provided Workers AI binding. Load this skill for chatbots, summarization, classification, embeddings, semantic search, image understanding, transcription, translation, or other model inference."},"isInternal":false,"tokens":1002,"sizeBytes":4257},{"name":"SKILL.md","path":"worker/agents/think/skills/app-file-structure/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/worker/agents/think/skills/app-file-structure/SKILL.md","title":"cloudflare-bundler-apps","category":"anthropic-skill","format":"markdown","content":"---\nname: cloudflare-bundler-apps\ndescription: Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts).\n---\n\nThis skill teaches how to build apps that deploy cleanly through the space deploy pipeline. Every project committed to a space is built by `@cloudflare/worker-bundler` (`createApp` when there are static assets, `createWorker` when there are none) and served on a Dynamic Worker via `WorkerLoader`. Get the conventions right and the preview \"just works\". Get them wrong and you get a blank page, a 500, or a `Build failed` error.\n\n## How the pipeline works\n\nWhen `deploy_space(branch)` is called, the space DO:\n\n1. Reads every file from the branch's working tree (skipping `.git/`).\n2. Parses `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` for `main`, `compatibility_date`, `compatibility_flags`, and `[assets]`.\n3. If `[assets].directory` is set, files under that directory become static assets served host-side by `handleAssetRequest`. Everything else is built into a Worker via `createApp` (with `server: <main>`).\n4. If no assets directory is configured, only `createWorker({ entryPoint: <main> })` is run. The output is loaded as a Dynamic Worker; all requests go to the Worker.\n5. Previews are served at `/space/:name/preview/:branch/*`. Responses with `content-type: text/html` are run through `HTMLRewriter`, which prefixes root-relative `src` / `href` / `action` attributes with the preview base path. **JS-side fetches and dynamic imports are NOT rewritten.**\n\nPractical consequences:\n\n- **You can rely on root-relative `src=\"/foo.js\"` and `href=\"/style.css\"` in HTML.** They will be rewritten to the preview path automatically.\n- **You cannot rely on root-relative paths inside JS strings**: `fetch(\"/api/x\")`, `new URL(\"/foo\", location.origin)`, dynamic `import(\"/lib.js\")`. These hit the wrong path under the preview prefix. Use relative paths (`./api/x`, `import.meta.url`) or read the base path from `<base href>` / a meta tag injected at build time.\n- The `<base href>` tag is also rewritten if present — using it lets all relative URLs resolve against the preview path.\n\n## Project layout\n\nA typical full-stack space project:\n\n```\n/\n├── wrangler.json          # required for non-trivial setups\n├── package.json           # npm deps (text-only packages, see limits below)\n├── src/\n│   └── index.ts           # server entry: export default { fetch }\n└── public/                # static assets directory (configurable)\n    ├── index.html\n    ├── app.js             # compiled, no JSX\n    └── styles.css\n```\n\nA static-only SPA can skip `src/` entirely — just `wrangler.json` + `public/` is enough as long as `main` points to a minimal pass-through worker or you accept that all requests fall through assets.\n\n## Wrangler config\n\nMinimum viable `wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\nNotes:\n\n- `main` is required when there's any server code. The bundler also auto-detects `src/index.ts`, `src/index.js`, `index.ts`, `index.js` if missing.\n- `compatibility_date` defaults to `2025-04-01` if omitted. Set it explicitly for newer features.\n- Add `\"compatibility_flags\": [\"nodejs_compat\"]` only if you actually need Node built-ins.\n- `assets.directory` is the **only** way to ship static files. Files outside this directory are bundled into the Worker or ignored — they will **not** be reachable via URL.\n- `html_handling: \"auto-trailing-slash\"` is usually what you want for multi-page sites; SPAs should also set `not_found_handling: \"single-page-application\"` so deep links return `index.html`.\n\nTOML works too (`wrangler.toml`), but the parser only handles top-level scalar fields plus an `[assets]` table — no inline tables, no env overrides. Prefer JSON.\n\n## Server entry: one Durable Object class named `App`\n\n**Your entire backend is one Durable Object.** Your main module exports\na class named `App` extending `DurableObject`. The platform loads it as\na [Cloudflare Durable Object\nFacet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/)\nof the host SpaceDO and forwards every non-asset request — including\nWebSocket upgrades — to your `App.fetch(request)`.\n\n**Do not `export default { fetch }`.** A default fetch handler will be\nignored. There is no separate worker; the DO is the worker.\n\n```ts\n// src/index.ts\nimport { DurableObject } from \"cloudflare:workers\";\n\nexport class App extends DurableObject {\n  async fetch(request: Request): Promise<Response> {\n    const url = new URL(request.url);\n\n    // Lazy schema init on the first request. Idempotent.\n    this.ctx.storage.sql.exec(`\n      CREATE TABLE IF NOT EXISTS notes (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        body TEXT NOT NULL\n      )\n    `);\n\n    if (url.pathname === \"/api/notes\" && request.method === \"POST\") {\n      const { body } = await request.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return new Response(null, { status: 201 });\n    }\n    if (url.pathname === \"/api/notes\") {\n      const rows = this.ctx.storage.sql\n        .exec(`SELECT id, body FROM notes ORDER BY id DESC`)\n        .toArray();\n      return Response.json(rows);\n    }\n    return new Response(\"Not found\", { status: 404 });\n  }\n}\n```\n\nOr with Hono inside the DO:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/hello\", (c) => c.json({ msg: \"hi\" }))\n    .post(\"/api/notes\", async (c) => {\n      const { body } = await c.req.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return c.json({ ok: true });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\nWhen `[assets]` is configured, static files take priority — your `fetch`\nonly sees requests that didn't match an asset. For a SPA, put\n`index.html` in `public/` and `App.fetch` only handles `/api/*`.\n\n### State and storage\n\nState lives on `this.ctx.storage`. No `env.DB` exists; you do not need\nto declare any binding. The DO's storage is a per-(space, branch)\nSQLite database that survives redeploys.\n\n- `this.ctx.storage.sql.exec(sql, ...params)` — full SQLite. Returns a\n  cursor with `.toArray()`, `.one()`, `.columnNames`, etc.\n- `this.ctx.storage.kv.get(key)` / `.put(key, value)` — simple KV\n  backed by SQLite.\n- `this.ctx.storage.transactionSync(() => { ... })` — atomic batch\n  inside the same DO instance.\n\n```ts\n// SQL\nthis.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS counter (n INTEGER)`);\nconst cur = this.ctx.storage.sql.exec(`SELECT n FROM counter LIMIT 1`).toArray();\nconst n = (cur[0]?.n as number) ?? 0;\n\n// KV\nconst last = this.ctx.storage.kv.get<number>(\"last_seen\") ?? 0;\nthis.ctx.storage.kv.put(\"last_seen\", Date.now());\n```\n\n**Use the DO's storage for all persistent state** — users, sessions,\ncontent, settings, anything that should survive a reload. Do not use\n`localStorage` or `sessionStorage` for primary data; they are\nper-browser, per-origin, and lost on incognito or any other browser.\n\n### WebSockets\n\nThe platform forwards `Upgrade: websocket` requests transparently into\n`App.fetch`. Use the standard Durable Object hibernation API:\n\n```ts\nexport class App extends DurableObject {\n  async fetch(request: Request) {\n    const upgrade = request.headers.get(\"Upgrade\");\n    if (upgrade === \"websocket\") {\n      const { 0: client, 1: server } = new WebSocketPair();\n      this.ctx.acceptWebSocket(server);\n      return new Response(null, { status: 101, webSocket: client });\n    }\n    return new Response(\"not found\", { status: 404 });\n  }\n\n  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {\n    // Broadcast to every connected client (chat-style fan-out).\n    for (const peer of this.ctx.getWebSockets()) {\n      peer.send(typeof message === \"string\" ? message : new Uint8Array(message));\n    }\n  }\n\n  webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {\n    // Cleanup if needed.\n  }\n}\n```\n\n`acceptWebSocket` + `webSocketMessage` is the right pattern for\nmulti-connection use cases (chat rooms, multiplayer, presence) because\nthe DO can hibernate between messages without losing connections.\n\n### Per-entity grouping (chat rooms, per-user records)\n\nYou get **one App DO per (space, branch)** — a single instance, not a\nnamespace. Do not call `idFromName` or try to declare a DO binding in\n`wrangler.json`; both are rejected. For multi-room / multi-user state,\n**partition rows with a column** in the App's SQLite:\n\n```sql\nCREATE TABLE messages (\n  room_id TEXT NOT NULL,\n  user_id TEXT NOT NULL,\n  body TEXT NOT NULL,\n  ts INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)\n);\nCREATE INDEX messages_by_room ON messages (room_id, ts);\n```\n\nThen route by `room_id` inside `App.fetch`. SQLite is fast enough that\nsharing the same table across rooms is fine for prototyping; if you\nlater need stronger isolation, attach `room_id` to a tag and use\n`acceptWebSocket(ws, [room_id])` so `getWebSockets(room_id)` returns\nonly that room's sockets.\n\n### What you cannot declare\n\n- **`durable_objects.bindings` in `wrangler.json`.** The platform\n  extracts your `App` class automatically. Adding bindings fails the\n  deploy with a clear error.\n- **`new_sqlite_classes` / migrations.** Same reason. Your App's\n  storage is set up by the platform.\n- **`d1_databases`, `r2_buckets`, `kv_namespaces`.** Not available.\n  Everything goes in `this.ctx.storage`.\n\n## Static asset rules (read this twice)\n\nThis is where most preview failures happen. The browser eventually runs your assets — the bundler does **not** transform them. So:\n\n### **NEVER ship JSX in a `<script type=\"module\">` (or any other script tag).** Browsers cannot parse JSX.\n\nThis page **will be blank** with a `SyntaxError: Unexpected token '<'`:\n\n```html\n<script type=\"module\">\n  import React from \"https://esm.sh/react@18\";\n  const App = () => <div>hi</div>;   // ← browser dies here\n</script>\n```\n\nFix one of these three ways:\n\n1. **Pre-compile** — write JSX in a build step (Vite/esbuild/tsup) and emit plain JS into `public/`. Best for production.\n2. **`React.createElement` by hand** — works without a build step but is verbose.\n3. **`@babel/standalone`** — only for prototypes. Load Babel **before** the script and use `type=\"text/babel\"`. The modern `@babel/preset-react` defaults to the **automatic JSX runtime**, which emits `import { jsx } from \"react/jsx-runtime\"`. Browsers will reject that bare specifier unless your importmap maps it. **Always ship a complete importmap alongside the Babel script:**\n\n   ```html\n   <script type=\"importmap\">\n   {\n     \"imports\": {\n       \"react\": \"https://esm.sh/react@18.3.1\",\n       \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n       \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n       \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\"\n     }\n   }\n   </script>\n   <script src=\"https://unpkg.com/@babel/standalone/babel.min.js\"></script>\n   <script type=\"text/babel\" data-type=\"module\" data-presets=\"react\">\n     import React from \"react\";\n     import { createRoot } from \"react-dom/client\";\n     const App = () => <div>hi</div>;\n     createRoot(document.getElementById(\"root\")).render(<App />);\n   </script>\n   ```\n\n   `data-type=\"module\"` is required for `import` to work inside the Babel script. Import React from the importmap key (`\"react\"`) — not from a hard-coded `esm.sh` URL — so your code and Babel's emitted `react/jsx-runtime` import resolve to the **same** React instance.\n\n   If you really cannot ship `react/jsx-runtime` in the importmap, force Babel to the legacy classic runtime instead so it emits `React.createElement` calls and never touches `react/jsx-runtime`:\n\n   ```html\n   <script\n     type=\"text/babel\"\n     data-type=\"module\"\n     data-presets=\"react\"\n     data-plugins='[[\"transform-react-jsx\", { \"runtime\": \"classic\" }]]'\n   >\n     import React from \"https://esm.sh/react@18.3.1\";  /* must be in scope */\n     const App = () => <div>hi</div>;\n   </script>\n   ```\n\n   The classic runtime requires `React` to be in lexical scope (because `<div>` becomes `React.createElement(\"div\")`). The automatic runtime (default) does not, but needs `react/jsx-runtime` resolvable.\n\n### Symptom-to-fix index\n\n| Console error | Cause | Fix |\n| --- | --- | --- |\n| `Uncaught SyntaxError: Unexpected token '<'` (inside `<script type=\"module\">`) | JSX shipped raw to the browser | Pre-compile, or use `@babel/standalone` with `type=\"text/babel\"` |\n| `Uncaught TypeError: Failed to resolve module specifier \"react/jsx-runtime\". Relative references must start with either \"/\", \"./\", or \"../\".` | Automatic JSX runtime emits `import \"react/jsx-runtime\"` but importmap doesn't map it | Add `\"react/jsx-runtime\": \"https://esm.sh/react@<same-version>/jsx-runtime\"` to the importmap **before** the Babel/script tag — or force the classic runtime (see above) |\n| `Uncaught TypeError: Failed to resolve module specifier \"react\"` (or any bare name) | No importmap entry for that package | Add it to the importmap; importmap script tag must appear **before** any module script that uses the specifier |\n| `TypeError: Cannot read properties of null (reading 'useContext')` | Dual-React (two copies loaded) | Append `?external=react,react-dom` to every esm.sh URL with React as a peer dep — see the dual-React trap below |\n\n### Other asset gotchas\n\n- **Binary assets are not extracted from npm packages.** Fonts, images, and `.wasm` shipped via npm tarballs will be missing. Put binaries directly in `public/` instead.\n- **Paths in JS need care under preview**: prefer `import.meta.url`, relative paths, or a runtime base detected from `document.baseURI`. Don't hard-code `/api/...` in client code; use `./api/...` or read a base from a `<meta>` tag.\n- **Trailing slashes matter** for `auto-trailing-slash` mode: `/about` serves `about.html`, `/about/` serves `about/index.html`. Pick one shape and link consistently.\n- **No CSS preprocessors at runtime** — ship `.css`, not `.scss`. Compile first if you need Sass.\n- **Importmaps work** since they're inline JSON. Use them to avoid bundling React/etc. for prototypes — but watch the dual-React trap below.\n\n### The dual-React trap (the #1 cause of \"blank page + `useContext` is null`)\n\n`esm.sh` bundles a package's peer dependencies *into the package itself* unless you tell it otherwise. So this importmap looks correct but ships **two copies of React** — one for your app, another nested inside `framer-motion`:\n\n```html\n<!-- BROKEN: framer-motion has its own React inside, hooks crash with `Cannot read properties of null (reading 'useContext')` -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.2.0\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.2.0/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11\"\n  }\n}\n</script>\n```\n\nThe symptom is exactly: blank background + console errors like\n\n```\nTypeError: Cannot read properties of null (reading 'useContext')\n  at u (SwitchLayoutGroupContext.mjs:1:1)\n```\n\nbecause the nested React instance has no provider in the tree.\n\n**Fix: append `?external=react,react-dom` (and any other shared peers) to every esm.sh URL that has React as a peer dep.** Pin the exact same React version everywhere too.\n\n```html\n<!-- CORRECT: one React shared across the whole importmap -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.3.1\",\n    \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n    \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11?external=react,react-dom\",\n    \"lucide-react\": \"https://esm.sh/lucide-react@0.400.0?external=react,react-dom\"\n  }\n}\n</script>\n```\n\nApply `?external=...` to **every** React-consuming dep (UI libraries, icon sets, animation libs, headless components). Same rule for `vue`, `solid-js`, etc. if you swap the framework.\n\nIf a project needs more than two or three of these packages, stop using the importmap path — switch to option 1 (pre-compile with Vite/esbuild) so the bundler can dedupe React for you.\n\n## npm dependencies\n\nThe bundler installs deps from npm at build time. Limits to respect:\n\n- **Flat node_modules.** No two versions of the same package can coexist — peer-dep conflicts will pick one and break the other. Keep dep graphs shallow.\n- **Text-only files** are extracted from tarballs. `.js`, `.ts`, `.json`, `.css`, `.md` work. `.wasm`, `.node`, native binaries, fonts, images do not.\n- **No PAX tar headers** — packages whose internal paths exceed 100 chars may have those files silently dropped. Avoid deeply-nested monorepo packages.\n- **No build scripts run** — `postinstall`, `prepare`, etc. are ignored. Packages that compile native code or run codegen at install time will not work.\n- **`cloudflare:*` imports are always external** and resolved by the runtime (`cloudflare:workers`, etc.). Don't add them to `package.json`.\n\nSafe and well-tested deps: `hono`, `zod`, `itty-router`, `nanoid`, `valibot`, `@hono/zod-validator`. Avoid anything that needs node-native modules unless you set `compatibility_flags: [\"nodejs_compat\"]` and the package is pure JS under that flag.\n\n## Project shapes\n\n| Project shape                       | What you need                                                                          |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| Pure static site (HTML+JS+CSS only) | `wrangler.json` with `[assets]`, files in `public/`. No `main`. No App class.          |\n| SPA + API                           | `[assets]` for the SPA, `main` exporting `class App extends DurableObject`             |\n| API only (JSON, no frontend)        | `main` exporting `class App extends DurableObject`, no `[assets]`                      |\n| Realtime (WebSocket / multiplayer)  | Same as SPA + API; `App.fetch` upgrades to WebSocket and uses `ctx.acceptWebSocket`    |\n\n## Pre-flight checklist before `deploy_space`\n\nRun through every item — most \"preview is broken\" reports trace back to one of these.\n\n- `wrangler.json` exists at repo root with `main` and (if static assets exist) `[assets].directory`.\n- Every browser-loaded `.js` / `.mjs` / `<script type=\"module\">` is **plain JS, no JSX**, or wrapped with `@babel/standalone` + `type=\"text/babel\"`.\n- If you use React via importmap (Babel-standalone or pre-compiled with the automatic JSX runtime), the importmap maps **all** of `react`, `react/jsx-runtime`, `react-dom`, and `react-dom/client` — same version on every entry. Missing `react/jsx-runtime` produces `Failed to resolve module specifier \"react/jsx-runtime\"` and a blank page.\n- The `<script type=\"importmap\">` tag appears **before** any `<script type=\"module\">` (or `type=\"text/babel\" data-type=\"module\"`) that depends on the mapped specifiers.\n- Static files live under the configured `assets.directory` (default suggestion: `public/`).\n- HTML uses root-relative paths (`/foo.js`) — they get rewritten. JS uses relative paths (`./foo`).\n- No binary assets are imported from npm packages. Binaries live in `public/`.\n- `package.json` deps are pure-JS, no native modules, no install scripts.\n- SPA routing? Set `not_found_handling: \"single-page-application\"`.\n- Server entry exports `class App extends DurableObject` from your `main` module (no `export default { fetch }`).\n- No `durable_objects` / `d1_databases` / `kv_namespaces` / `r2_buckets` blocks in `wrangler.json`.\n- `compatibility_date` is set if you use APIs newer than the default.\n\nIf any of these are off, fix them in the working tree, commit, and redeploy. The preview will pick up the new build on the next `deploy_space` call (the dynamic worker is keyed by commit hash, so old builds are not reused).\n\n## Minimal end-to-end example\n\nA working SPA with a tiny API:\n\n```\nwrangler.json\npackage.json\nsrc/index.ts\npublic/index.html\npublic/app.js\npublic/style.css\n```\n\n`wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\n`package.json`:\n\n```json\n{ \"name\": \"demo\", \"type\": \"module\", \"dependencies\": { \"hono\": \"^4.6.0\" } }\n```\n\n`src/index.ts`:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/time\", (c) => c.json({ now: new Date().toISOString() }))\n    .post(\"/api/visit\", async (c) => {\n      this.ctx.storage.sql.exec(\n        `CREATE TABLE IF NOT EXISTS visits (id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER)`\n      );\n      this.ctx.storage.sql.exec(`INSERT INTO visits (ts) VALUES (?)`, Date.now());\n      const cnt = this.ctx.storage.sql\n        .exec(`SELECT COUNT(*) AS c FROM visits`)\n        .one().c as number;\n      return c.json({ count: cnt });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\n`public/index.html`:\n\n```html\n<!doctype html>\n<html>\n<head>\n  <link rel=\"stylesheet\" href=\"/style.css\">\n</head>\n<body>\n  <div id=\"root\">Loading…</div>\n  <script type=\"module\" src=\"/app.js\"></script>\n</body>\n</html>\n```\n\n`public/app.js`:\n\n```js\nconst res = await fetch(\"./api/time\");\nconst data = await res.json();\ndocument.getElementById(\"root\").textContent = data.now;\n\n// Increment + display visit counter (server state lives in App.ctx.storage.sql)\nconst v = await fetch(\"./api/visit\", { method: \"POST\" }).then((r) => r.json());\ndocument.getElementById(\"root\").textContent += `  ·  visits: ${v.count}`;\n```\n\nNote `./api/time` (relative) in JS, `/style.css` (root-relative) in HTML. The HTML href is rewritten by the preview; the JS fetch resolves against the document's base URL.\n\nCommit this, call `deploy_space(\"main\")`, and the preview at `/space/<name>/preview/main/` will render the timestamp and an incrementing visit counter persisted in the App DO's SQLite.\n","frontmatter":{"name":"cloudflare-bundler-apps","description":"Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts)."},"isInternal":false,"tokens":5751,"sizeBytes":22863},{"name":"SKILL.md","path":"worker/agents/think/skills/frontend-design-landing-page/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/worker/agents/think/skills/frontend-design-landing-page/SKILL.md","title":"frontend-design-landing-page","category":"anthropic-skill","format":"markdown","content":"---\nname: frontend-design-landing-page\ndescription: Marketing landing page and conversion-focused product page reference. Use this skill when building hero sections, feature grids, pricing pages, testimonials, CTAs, footers, navigation bars, or any public-facing marketing surface. Covers a warm, professional, developer-friendly design language (cream backgrounds, generous whitespace, pill CTAs, corner-bracket card decorations) and a complete token set, animation system, and copy-paste component snippets. NOT for product/dashboard UIs — use frontend-design-saas for those.\n---\n\n# Landing Page Design System\n\n> **AI-Optimized Design Reference** for building warm, professional, conversion-focused marketing landing pages, product pages, feature pages, and pricing pages.\n\nThis skill captures a specific landing-page register: warm cream backgrounds (NOT pure white), brown text (NOT pure black), a single bold accent for CTAs, pill-rounded buttons, and signature corner-bracket card decorations. It's tuned for developer-adjacent / B2B products — the aesthetic feels human and crafted without being playful or consumer-y. Pin the accent color to your brand; the rest of the system is reusable as-is.\n\n> **When to use this vs. `frontend-design-saas`:** Landing-page surfaces (homepage, /features, /pricing, /about, blog) → this skill. Product UI behind the login (dashboards, settings, tables) → `frontend-design-saas`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #FF4801 (replace with your brand) / #FF7038 (hover)\nBackground:      #FFFBF5 (warm cream) / #121212 (dark mode)\nText:            #521000 (warm brown) / #F0E3DE (dark mode)\nBorder:          #EBD5C1 (cream-tinted)\nFont Sans:       \"Inter\" / \"Geist\" / \"Manrope\" / any modern grotesk\nFont Mono:       \"JetBrains Mono\" / \"IBM Plex Mono\" / \"Geist Mono\"\nBase Spacing:    4px (multiples: 8, 12, 16, 24, 32, 48, 64)\nBorder Radius:   Buttons = pill (9999px), Cards = 12-16px, Inputs = 8px, Hero = 16-20px\nSignature:       Corner brackets on cards (8px decorative squares at corners)\n```\n\nThis is a **landing-page** register, not a product-UI register. Buttons are pills (consumer/marketing read), backgrounds are warm cream (inviting, not sterile), and decorative idioms like corner brackets are welcome. For product surfaces behind auth, use the SaaS skill instead.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Warm but Technical** | Cream tones soften technical content |\n| **Professional yet Approachable** | Modern typography, generous whitespace |\n| **Developer-Focused** | Monospace for code, terminal aesthetics |\n| **Performance-Oriented** | Smooth animations convey speed |\n\n### Visual Identity\n\n- **Never use pure white** (`#FFFFFF`) for backgrounds — always warm cream (`#FFFBF5`)\n- **Never use pure black** (`#000000`) for text — always warm brown (`#521000`)\n- **Orange is the accent**, not the dominant color\n- **Corner brackets** on cards are a signature decorative element\n- **Dot patterns** and **dashed lines** add visual texture\n\n---\n\n## 2. Color System\n\n### 2.1 Primary Palette (Light Mode)\n\n| Token | Hex | RGB | Usage |\n|-------|-----|-----|-------|\n| `--lp-accent` | `#FF4801` | `rgb(255, 72, 1)` | Primary accent, CTAs, links |\n| `--lp-accent-hover` | `#FF7038` | `rgb(255, 112, 56)` | Hover states |\n| `--lp-accent-light` | `rgba(255, 72, 1, 0.1)` | — | Badges, light backgrounds |\n| `--lp-text` | `#521000` | `rgb(82, 16, 0)` | Primary text |\n| `--lp-text-muted` | `rgba(82, 16, 0, 0.6)` | — | Secondary text |\n| `--lp-text-subtle` | `rgba(82, 16, 0, 0.38)` | — | Tertiary text, placeholders |\n| `--lp-bg-page` | `#F5F1EB` | `rgb(245, 241, 235)` | Page background (outer) |\n| `--lp-bg-100` | `#FFFBF5` | `rgb(255, 251, 245)` | Primary background |\n| `--lp-bg-200` | `#FFFDFB` | `rgb(255, 253, 251)` | Card backgrounds |\n| `--lp-bg-300` | `#FEF7ED` | `rgb(254, 247, 237)` | Hover backgrounds |\n| `--lp-border` | `#EBD5C1` | `rgb(235, 213, 193)` | Borders, dividers |\n| `--lp-border-light` | `rgba(235, 213, 193, 0.5)` | — | Subtle borders |\n\n### 2.2 Primary Palette (Dark Mode)\n\n| Token | Hex | Usage |\n|-------|-----|-------|\n| `--lp-accent` | `#F14602` | Primary accent |\n| `--lp-accent-hover` | `#FF6D33` | Hover states |\n| `--lp-text` | `#F0E3DE` | Primary text |\n| `--lp-text-muted` | `rgba(255, 253, 251, 0.56)` | Secondary text |\n| `--lp-bg-100` | `#121212` | Primary background |\n| `--lp-bg-200` | `#191817` | Card backgrounds |\n| `--lp-bg-300` | `#2A2927` | Hover backgrounds |\n| `--lp-border` | `rgba(240, 227, 222, 0.13)` | Borders |\n\n### 2.3 Category / Tag Colors (optional)\n\nIf your product has multiple feature categories that appear as tags or pills throughout the page (feature cards, navigation, etc.), define a small categorical palette. Keep luminance similar across the set so no single color overpowers the others.\n\n| Slot | Primary | Background | Example use |\n|----------|---------|------------|-------|\n| Category A | `#0A95FF` | `rgba(10, 149, 255, 0.1)` | \"Compute\" / \"Performance\" |\n| Category B | `#EE0DDB` | `rgba(238, 13, 219, 0.1)` | \"Storage\" / \"Data\" |\n| Category C | `#19E306` | `#F2F5E1` | \"AI\" / \"Intelligence\" |\n| Category D | `#9616FF` | `#F8EBEE` | \"Media\" / \"Edge\" |\n\nThese are placeholders — swap to your product's category palette. The goal is a small set of visually distinct accent colors used only on category labels, NEVER on primary CTAs (the brand accent owns that role).\n\n### 2.4 Semantic Colors\n\n| Purpose | Light Mode | Dark Mode |\n|---------|------------|-----------|\n| Success | `#16A34A` | `#4ADE80` |\n| Warning | `#EAB308` | `#FACC15` |\n| Error | `#DC2626` | `#F87171` |\n| Info | `#2563EB` | `#60A5FA` |\n\n---\n\n## 3. Typography\n\n### 3.1 Font Families\n\n```css\n--font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n--font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", \"Consolas\", monospace;\n```\n\n**Font Files:**\n- `Kunst Grotesk Regular.woff2` (400)\n- `Kunst Grotesk Medium.woff2` (500)\n- `JetBrains Mono Regular.woff2` (400)\n\n### 3.2 Type Scale\n\n| Name | Size | Line Height | Weight | Usage |\n|------|------|-------------|--------|-------|\n| `xs` | 12px (0.75rem) | 1.33 | 400 | Badges, captions, footnotes |\n| `sm` | 14px (0.875rem) | 1.43 | 400 | Secondary text, labels |\n| `base` | 16px (1rem) | 1.5 | 400 | Body text |\n| `lg` | 18px (1.125rem) | 1.56 | 400/500 | Large body, subheadings |\n| `xl` | 20px (1.25rem) | 1.4 | 500 | Section titles |\n| `2xl` | 24px (1.5rem) | 1.33 | 500 | Card headings |\n| `3xl` | 30px (1.875rem) | 1.2 | 500 | Section headings |\n| `4xl` | 36px (2.25rem) | 1.11 | 500 | Page headings |\n| `5xl` | 48px (3rem) | 1.0 | 500 | Hero headings |\n\n### 3.3 Font Weights\n\n| Weight | Value | Usage |\n|--------|-------|-------|\n| Normal | 400 | Body text, descriptions |\n| Medium | 500 | Headings, buttons, emphasis |\n\n### 3.4 Letter Spacing\n\n| Context | Value | CSS |\n|---------|-------|-----|\n| Headings | -0.02em | `letter-spacing: -0.02em` |\n| Body | Normal | `letter-spacing: normal` |\n| Uppercase labels | 0.05em | `letter-spacing: 0.05em` |\n| Logo text | -0.46px | `letter-spacing: -0.46px` |\n\n### 3.5 Text Rendering\n\n```css\nbody {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--spacing-unit: 4px;\n```\n\n### 4.2 Spacing Scale\n\n| Token | Value | Pixels | Common Usage |\n|-------|-------|--------|--------------|\n| `0` | 0 | 0px | Reset |\n| `0.5` | 0.125rem | 2px | Tiny gaps |\n| `1` | 0.25rem | 4px | Tight spacing |\n| `1.5` | 0.375rem | 6px | Small gaps |\n| `2` | 0.5rem | 8px | Default small padding |\n| `3` | 0.75rem | 12px | Input padding, card gaps |\n| `4` | 1rem | 16px | Standard padding |\n| `5` | 1.25rem | 20px | Medium spacing |\n| `6` | 1.5rem | 24px | Section padding (mobile) |\n| `8` | 2rem | 32px | Large padding |\n| `10` | 2.5rem | 40px | Section gaps |\n| `12` | 3rem | 48px | Large section gaps |\n| `16` | 4rem | 64px | Hero padding |\n| `20` | 5rem | 80px | Major sections |\n| `24` | 6rem | 96px | Max section spacing |\n\n### 4.3 Common Spacing Patterns\n\n```css\n/* Card padding */\npadding: 24px;  /* p-6 */\n\n/* Input padding */\npadding: 12px;  /* p-3 */\n\n/* Button padding */\npadding: 12px 24px;  /* py-3 px-6 */\n\n/* Section padding (responsive) */\npadding: 32px 16px;  /* Mobile */\npadding: 48px 32px;  /* Tablet */\npadding: 64px 48px;  /* Desktop */\n\n/* Grid gaps */\ngap: 16px;  /* Cards */\ngap: 24px;  /* Sections */\n```\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `rounded-sm` | 4px | Small badges |\n| `rounded` | 6px | Tags, small elements |\n| `rounded-md` | 8px | Inputs |\n| `rounded-lg` | 12px | Icon containers, panels |\n| `rounded-xl` | 16px | Hero sections |\n| `rounded-2xl` | 20px | Large hero sections |\n| `rounded-full` | 9999px | Buttons, pills, avatars |\n| `rounded-none` | 0 | Cards (sharp edges) |\n\n### Common Patterns\n\n```css\n/* Buttons - always fully rounded */\nborder-radius: 9999px;\n\n/* Cards - sharp edges */\nborder-radius: 0;\n\n/* Inputs */\nborder-radius: 8px;\n\n/* Hero sections (desktop) */\nborder-radius: 16px;\n\n/* Progress bars */\nborder-radius: 9999px;\n\n/* Icon containers */\nborder-radius: 8px;\n```\n\n---\n\n## 6. Shadow System\n\n### 6.1 Shadow Stack (Signature Effect)\n\nUsed on hero sections and elevated cards for depth with inner glow.\n\n```css\n/* Light Mode */\n--shadow-stack: \n  1px 6px 6px 0 rgba(255, 255, 255, 0.2) inset,\n  0 0 0px 0 rgba(255, 255, 255, 0.35) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n\n/* Dark Mode */\n--shadow-stack-dark:\n  1px 6px 16px 0 rgba(255, 255, 255, 0.05) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n```\n\n### 6.2 Utility Shadows\n\n```css\n/* Subtle shadow for cards */\n--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n\n/* Standard card shadow */\n--shadow-card: \n  0 1px 3px rgba(82, 16, 0, 0.04),\n  0 4px 12px rgba(82, 16, 0, 0.02);\n\n/* Elevated shadow */\n--shadow-lg: \n  0 10px 15px -3px rgba(0, 0, 0, 0.1),\n  0 4px 6px -4px rgba(0, 0, 0, 0.1);\n\n/* Focus ring shadow */\n--shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n```\n\n---\n\n## 7. Animation System\n\n### 7.1 Timing Functions\n\n```css\n/* Standard ease-out (default for most transitions) */\n--ease-out: cubic-bezier(0, 0, 0.2, 1);\n\n/* Button interactions */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press states */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Smooth deceleration */\n--ease-decel: cubic-bezier(0.4, 0, 0.2, 1);\n```\n\n### 7.2 Duration Scale\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--duration-instant` | 100ms | Micro-interactions |\n| `--duration-fast` | 150ms | Default transitions |\n| `--duration-normal` | 200ms | Hover states |\n| `--duration-medium` | 300ms | Theme transitions |\n| `--duration-slow` | 500ms | Complex animations |\n| `--duration-long` | 1000ms | Page transitions |\n\n### 7.3 Standard Transitions\n\n```css\n/* Color transitions (default) */\ntransition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;\n\n/* Button transitions */\ntransition: all 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Card hover */\ntransition: box-shadow 0.2s ease, transform 0.2s ease;\n\n/* Input focus */\ntransition: border-color 0.15s ease, box-shadow 0.15s ease;\n```\n\n### 7.4 Keyframe Animations\n\n```css\n/* Fade in */\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n\n/* Slide up */\n@keyframes slideUp {\n  from { opacity: 0; transform: translateY(10px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n/* Pulse (loading) */\n@keyframes pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.5; }\n}\n\n/* Progress bar fill */\n@keyframes progressFill {\n  from { width: 0; }\n  to { width: var(--progress-width); }\n}\n\n/* Infinite scroll (logos) */\n@keyframes infiniteScroll {\n  from { transform: translateX(0); }\n  to { transform: translateX(-50%); }\n}\n```\n\n### 7.5 Framer Motion Presets (React)\n\n```javascript\n// Fade in\nconst fadeIn = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  transition: { duration: 0.3 }\n};\n\n// Slide up\nconst slideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { opacity: 1, y: 0 },\n  transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }\n};\n\n// Stagger children\nconst staggerContainer = {\n  animate: { transition: { staggerChildren: 0.1 } }\n};\n\n// Scale on hover\nconst scaleHover = {\n  whileHover: { scale: 1.02 },\n  whileTap: { scale: 0.98 }\n};\n\n// Button press\nconst buttonPress = {\n  whileTap: { scale: 0.98, y: 1 }\n};\n```\n\n---\n\n## 8. Layout System\n\n### 8.1 Container Widths\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--container-sm` | 640px | Narrow content |\n| `--container-md` | 768px | Medium content |\n| `--container-lg` | 1024px | Standard content |\n| `--container-xl` | 1200px | Wide content |\n| `--container-2xl` | 1480px | Full-width sections |\n\n### 8.2 Breakpoints\n\n| Name | Min Width | Usage |\n|------|-----------|-------|\n| `sm` | 640px | Large phones |\n| `md` | 768px | Tablets |\n| `lg` | 1024px | Laptops |\n| `xl` | 1280px | Desktops |\n| `2xl` | 1536px | Large screens |\n\n### 8.3 Grid Patterns\n\n```css\n/* 2-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(2, 1fr);\ngap: 16px;\n\n/* 3-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(3, 1fr);\ngap: 16px;\n\n/* 4-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(4, 1fr);\ngap: 16px;\n\n/* Calculator layout (2 columns, different widths) */\ndisplay: grid;\ngrid-template-columns: 1fr 1fr;\ngap: 24px;\n\n/* Responsive grid */\ndisplay: grid;\ngrid-template-columns: repeat(1, 1fr);  /* Mobile */\ngrid-template-columns: repeat(2, 1fr);  /* md: */\ngrid-template-columns: repeat(3, 1fr);  /* lg: */\n```\n\n### 8.4 Bento Grid\n\n```css\n/* Bento layout with varying sizes */\ndisplay: grid;\ngrid-template-columns: repeat(12, 1fr);\ngap: 8px;\n\n/* Bento cell sizes */\n.bento-sm { grid-column: span 4; }    /* 1/3 width */\n.bento-md { grid-column: span 6; }    /* 1/2 width */\n.bento-lg { grid-column: span 8; }    /* 2/3 width */\n.bento-full { grid-column: span 12; } /* Full width */\n```\n\n---\n\n## 9. Dark Mode Implementation\n\n### 9.1 Detection Script\n\nPlace in `<head>` before any styles load:\n\n```html\n<script>\n(function() {\n  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n    document.documentElement.classList.add('dark');\n  }\n})();\n</script>\n```\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --lp-accent: #FF4801;\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-border: #EBD5C1;\n}\n\n:root.dark, html.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n```\n\n### 9.3 Theme Transition\n\n```css\nhtml.theme-transitioning,\nhtml.theme-transitioning * {\n  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;\n}\n```\n\n### 9.4 System Preference Listener\n\n```javascript\nconst mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\nmediaQuery.addEventListener('change', (e) => {\n  document.documentElement.classList.toggle('dark', e.matches);\n});\n```\n\n---\n\n## 10. Accessibility\n\n### 10.1 Focus States\n\n```css\n/* Default focus ring */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\n/* Button focus */\nbutton:focus-visible {\n  outline: none;\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.3);\n}\n\n/* Input focus */\ninput:focus-visible,\nselect:focus-visible,\ntextarea:focus-visible {\n  outline: none;\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n### 10.2 Disabled States\n\n```css\n:disabled,\n[disabled],\n.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  pointer-events: none;\n}\n```\n\n### 10.3 Error States\n\n```css\n[aria-invalid=\"true\"],\n.input-error {\n  border-color: #DC2626;\n  box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);\n}\n\n.error-message {\n  color: #DC2626;\n  font-size: 14px;\n  margin-top: 4px;\n}\n```\n\n### 10.4 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n```\n\n### 10.5 Selection Styling\n\n```css\n::selection {\n  background-color: rgba(255, 72, 1, 0.2);\n  color: var(--lp-text);\n}\n```\n\n---\n\n## 11. Decorative Elements\n\n### 11.1 Corner Brackets\n\nSignature decorative element for cards:\n\n```css\n/* Corner bracket container */\n.corner-brackets {\n  position: relative;\n}\n\n/* Individual bracket */\n.corner-bracket {\n  position: absolute;\n  width: 8px;\n  height: 8px;\n  border: 1px solid var(--lp-border);\n  border-radius: 1.5px;\n  background: var(--lp-bg-100);\n}\n\n/* Positions */\n.corner-bracket.top-left { top: -4px; left: -4px; }\n.corner-bracket.top-right { top: -4px; right: -4px; }\n.corner-bracket.bottom-left { bottom: -4px; left: -4px; }\n.corner-bracket.bottom-right { bottom: -4px; right: -4px; }\n```\n\n### 11.2 Dot Pattern Background\n\n```css\n.dot-pattern {\n  background-image: radial-gradient(\n    circle,\n    var(--lp-border) 0.75px,\n    transparent 0.75px\n  );\n  background-size: 12px 12px;\n}\n```\n\n### 11.3 Dashed Line Borders\n\n```css\n/* Vertical dashed line */\n.dashed-line-vertical {\n  width: 1px;\n  background-image: linear-gradient(\n    to bottom,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 1px 16px;\n  background-repeat: repeat-y;\n}\n\n/* Horizontal dashed line */\n.dashed-line-horizontal {\n  height: 1px;\n  background-image: linear-gradient(\n    to right,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 16px 1px;\n  background-repeat: repeat-x;\n}\n```\n\n### 11.4 Gradient Masks\n\n```css\n/* Fade edges */\n.fade-left {\n  mask-image: linear-gradient(to right, transparent, black 20%);\n}\n\n.fade-right {\n  mask-image: linear-gradient(to left, transparent, black 20%);\n}\n\n.fade-both {\n  mask-image: linear-gradient(\n    to right,\n    transparent,\n    black 15%,\n    black 85%,\n    transparent\n  );\n}\n```\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border |\n|---------|------------|------|--------|\n| Primary | `#FFFBF5` | `#FF4801` | `#FFFBF5` |\n| Secondary | `#FF4801` | `#FFFBF5` | transparent |\n| Ghost | transparent | `#FF4801` | `#EBD5C1` |\n| Outline | transparent | `#521000` | `#EBD5C1` |\n\n### Cards\n\n| Variant | Background | Border | Shadow |\n|---------|------------|--------|--------|\n| Default | `#FFFDFB` | `#EBD5C1` | shadow-card |\n| Elevated | `#FFFBF5` | `#EBD5C1` | shadow-lg |\n| Interactive | `#FFFDFB` | `#EBD5C1` | hover: shadow-lg |\n\n### Inputs\n\n| State | Border | Shadow |\n|-------|--------|--------|\n| Default | `#EBD5C1` | none |\n| Focus | `#FF4801` | `0 0 0 3px rgba(255,72,1,0.1)` |\n| Error | `#DC2626` | `0 0 0 3px rgba(220,38,38,0.1)` |\n| Disabled | `#EBD5C1` | none, opacity: 0.5 |\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── styles/\n│   ├── tokens.css          # CSS custom properties\n│   ├── base.css            # Reset, typography, global styles\n│   ├── components/\n│   │   ├── buttons.css\n│   │   ├── cards.css\n│   │   ├── forms.css\n│   │   ├── navigation.css\n│   │   └── calculator.css\n│   └── utilities.css       # Helper classes\n├── fonts/\n│   ├── Kunst Grotesk Regular.woff2\n│   ├── Kunst Grotesk Medium.woff2\n│   └── JetBrains Mono Regular.woff2\n└── components/             # React/Vue components\n    ├── Button.tsx\n    ├── Card.tsx\n    ├── Input.tsx\n    └── Calculator/\n        ├── InputPanel.tsx\n        ├── OutputPanel.tsx\n        └── ComparisonBar.tsx\n```\n\n---\n\n## 14. CSS Custom Properties (Full Set)\n\nCopy this into your project's CSS:\n\n```css\n:root {\n  /* Colors - Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.1);\n  \n  /* Colors - Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-text-subtle: rgba(82, 16, 0, 0.38);\n  \n  /* Colors - Backgrounds */\n  --lp-bg-page: #F5F1EB;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Colors - Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Colors - Semantic */\n  --lp-success: #16A34A;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  --lp-info: #2563EB;\n  \n  /* Colors - Product Categories */\n  --lp-compute: #0A95FF;\n  --lp-storage: #EE0DDB;\n  --lp-ai: #19E306;\n  --lp-media: #9616FF;\n  \n  /* Colors - Provider Comparisons */\n  --lp-aws: #FF9900;\n  --lp-gcp: #4285F4;\n  --lp-azure: #0078D4;\n  \n  /* Typography */\n  --font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", monospace;\n  \n  /* Spacing */\n  --spacing-unit: 4px;\n  \n  /* Border Radius */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-xl: 16px;\n  --radius-full: 9999px;\n  \n  /* Shadows */\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);\n  --shadow-card: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02);\n  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n  \n  /* Transitions */\n  --ease-out: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 300ms;\n  \n  /* Containers */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1200px;\n  --container-2xl: 1480px;\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Animation Physics**\n> A specification so precise that a developer could rebuild it without seeing the original.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Grid System\n\n| Aspect | Value | Notes |\n|--------|-------|-------|\n| **Layout Type** | Asymmetrical fluid with max-width constraints | NOT a strict 12-column grid |\n| **Max Container** | `1480px` (homepage / wide marketing) / `1024px` (focused content / docs) | Content centered with `mx-auto` |\n| **Content Width** | `64rem` (1024px) for tools/calculators | Narrower for focused interfaces |\n| **Grid Columns** | 1-col mobile → 2-col tablet → responsive desktop | Uses CSS Grid, not flexbox grids |\n\n#### Spacing Constants (The \"Airiness\")\n\n```\nSection Padding (Y-axis):\n├── Mobile:   32px (py-8)\n├── Tablet:   48px (pt-12)\n└── Desktop:  64px-80px (py-16 to py-20)\n\nGrid Gaps (X-axis):\n├── Card grids:     24px (gap-6)\n├── Form elements:  24px (gap-6)\n├── Tight grids:    12px (gap-3)\n└── Use-case cards: 12px (gap-3)\n\nComponent Internal Padding:\n├── Cards:          24px (p-6) to 32px (p-8)\n├── Buttons:        12px 24px (py-3 px-6)\n├── Inputs:         12px (p-3)\n└── Hero sections:  48px-64px (p-12 to p-16)\n```\n\n#### Density Classification\n\n| Context | Density | Characteristics |\n|---------|---------|-----------------|\n| **Landing pages** | Expressive (Marketing) | Generous whitespace, large text, breathing room |\n| **Calculator tools** | Moderate | Balanced density, functional spacing |\n| **Data tables** | Compact | Tighter padding (py-3 pr-4) |\n\n### 15.2 Color Science & Elevation\n\n#### Primary Palette (Extracted Exact Values)\n\n```css\n/* Core landing-page palette */\n:root {\n  --lp-accent: #ff4801;        /* Primary accent - EXACT */\n  --lp-text: #521000;          /* Primary text - warm brown */\n  --lp-bg-page: #fffbf5;       /* Page background - warm cream */\n  --lp-border: #EBD5C1;        /* Border color */\n}\n\n/* Background Layers (Light Mode) */\n--lp-bg-100: rgb(255, 251, 245);  /* #FFFBF5 - Primary */\n--lp-bg-200: rgb(255, 253, 251);  /* #FFFDFB - Cards/elevated */\n--lp-bg-300: rgb(254, 247, 237);  /* #FEF7ED - Hover states */\n\n/* Text Opacity Variations */\n--lp-text-muted: rgba(82, 16, 0, 0.7);   /* #521000b3 - Secondary */\n--lp-text-subtle: rgba(82, 16, 0, 0.4);  /* #52100066 - Tertiary */\n```\n\n#### Semantic Colors (from components)\n\n| Purpose | Color | Usage Example |\n|---------|-------|---------------|\n| Success | `#16A34A` (green-600) | Savings badges, positive indicators |\n| Success Background | `#DCF7E3` (green-100) | Badge backgrounds |\n| Warning | `#EAB308` | Caution states |\n| Error | `#DC2626` | Error borders, messages |\n| Info | `#2563EB` | Informational highlights |\n\n#### Depth Strategy\n\n**NO Glassmorphism** - The design uses:\n\n1. **Solid backgrounds** with subtle layering (`bg-100` → `bg-200` → `bg-300`)\n2. **Border lines** for separation (1px solid `#EBD5C1`)\n3. **Minimal shadows** - shadows are subtle, not dramatic\n\n```css\n/* Card Shadow (Light/Subtle) */\n--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);\n\n/* Focus Shadow */\nbox-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n\n/* NO backdrop-filter: blur() usage */\n/* NO heavy drop shadows */\n```\n\n### 15.3 Typography & Micro-Copy Specs\n\n#### Type Personality: **Grotesk Sans**\n\nThe typography uses **Inter** - a modern grotesk with humanist touches. Fallback chain:\n```css\nfont-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, \n             Segoe UI, sans-serif, ui-sans-serif, system-ui, sans-serif;\n```\n\n#### Monospace for Code\n```css\nfont-family: JetBrains Mono, ui-monospace, SFMono-Regular, SF Mono, \n             Monaco, Consolas, monospace;\n```\n\n#### Hierarchy Definition\n\n| Level | Size | Weight | Line Height | Letter Spacing | Example |\n|-------|------|--------|-------------|----------------|---------|\n| **h1** | 24px-30px (`text-2xl` to `text-3xl`) | 500 (medium) | 1.2-1.33 | `-0.035em` | \"Pricing\", \"Build faster\" |\n| **h2** | 18px (`text-lg`) | 500 | 1.4 | normal | \"Pricing Details\" |\n| **h3** | 16px (`text-base`) | 500 | 1.5 | normal | Form labels |\n| **p** (body) | 14px-16px (`text-sm` to `text-base`) | 400 | 1.4-1.5 | normal | Descriptions |\n| **p** (muted) | 14px (`text-sm`) | 400 | 1.4 | normal | Secondary info |\n| **small** | 12px (`text-xs`) | 400 | 1.33 | normal | Footnotes, captions |\n\n#### Typography Style: **Tight and Medium Weight**\n\n- **Headings**: Tighter tracking (`letter-spacing: -0.035em`)\n- **Body**: Normal tracking\n- **Weight distribution**: Primarily 400 (regular) and 500 (medium)\n- **NO bold (700)** used in the interfaces\n\n### 15.4 Component Anatomy\n\n#### The \"Radius\" Strategy\n\n| Element | Radius | CSS |\n|---------|--------|-----|\n| **Buttons** | Hyper-rounded | `border-radius: 9999px` (rounded-full) |\n| **Inputs** | Soft | `border-radius: 8px` (rounded-lg) |\n| **Cards** | Sharp | `border-radius: 0` (no rounding) |\n| **Progress bars** | Hyper-rounded | `border-radius: 9999px` |\n| **Dropdowns** | Soft | `border-radius: 8px` |\n| **Badges/Pills** | Hyper-rounded | `border-radius: 9999px` |\n| **Hero sections** | Large soft | `border-radius: 16px` (md:rounded-2xl) |\n\n#### Interactive States\n\n**Hover Effects:**\n```css\n/* Buttons - Dashed border reveal */\n.button:hover {\n  border-style: dashed;\n  opacity: 0.95;\n}\n\n/* Cards - Dashed border */\n.card:hover {\n  border-style: dashed;\n}\n\n/* Links - Underline */\n.link:hover {\n  text-decoration: underline;\n}\n\n/* Background shift */\n.interactive:hover {\n  background-color: var(--lp-bg-300);  /* Warmer cream */\n}\n```\n\n**Active/Press States:**\n```css\nbutton:active {\n  transform: translateY(1px);\n  scale: 0.98;\n}\n```\n\n**Focus States:**\n```css\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus {\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n**NO \"Grow\" effects, \"Glow\" borders, or \"Shimmer\" overlays** - The design is subtle and professional.\n\n#### Corner Brackets (Signature Element)\n\nThe 8px corner bracket decorations are the **signature decorative element** of this design language — they appear at the four outer corners of cards and feature blocks, suggesting precision and crafted technical detail without adding visual noise:\n\n```html\n<!-- Corner bracket structure -->\n<div class=\"pointer-events-none absolute inset-0 z-10 select-none\">\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;left:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;right:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"left:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"right:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n</div>\n```\n\n### 15.5 Animation Physics (The Motion Signature)\n\n#### The Easing Curves\n\n```css\n/* Standard ease-out (most transitions) */\n--ease-standard: cubic-bezier(0, 0, 0.2, 1);  /* Tailwind's ease-out */\n\n/* Button interactions - High-end feel */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press response */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Page entrance - Apple-style smooth deceleration */\n--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n```\n\n#### Duration Scale\n\n| Context | Duration | Usage |\n|---------|----------|-------|\n| Instant feedback | `0.15s` (150ms) | Color changes, opacity |\n| Standard transitions | `0.16s` (160ms) | Button presses |\n| Hover effects | `0.2s` (200ms) | Background color shifts |\n| Complex animations | `0.5s` (500ms) | Progress bars, entrance |\n| Page transitions | `2s` (2000ms) | Background fade-in |\n\n#### Orchestration: **Fade-Slide as Single Block**\n\nThe content does NOT stagger in one-by-one. Instead, entire sections fade and slide together:\n\n```css\n/* Page entrance animation */\n.animate-in {\n  animation: fadeSlideUp 0.5s ease-out forwards;\n}\n\n@keyframes fadeSlideUp {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Background lines fade in slowly */\n.fade-in {\n  transition: opacity 2000ms ease-out;\n  transition-delay: 100ms;\n}\n```\n\n#### Micro-interactions\n\n**Button Press Physics:**\n```css\nbutton {\n  transition: scale 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94),\n              translate 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n}\n\nbutton:active {\n  scale: 0.98;\n  transform: translateY(1px);\n}\n```\n\n**Progress Bar Animation:**\n```css\n.progress-bar {\n  transition: width 0.5s ease-out;\n}\n```\n\n**Slider Thumb:**\n```css\n.slider-thumb {\n  cursor: grab;\n  transition: box-shadow 0.15s ease;\n}\n\n.slider-thumb:active {\n  cursor: grabbing;\n}\n\n.slider-thumb:hover {\n  box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);\n}\n```\n\n**NO \"Magnetic\" follow effects or \"Tilt\" effects** - Interactions are clean and direct.\n\n### 15.6 Technical Deliverables\n\n#### Tailwind Configuration\n\n```javascript\n// tailwind.config.js\nmodule.exports = {\n  theme: {\n    extend: {\n      colors: {\n        'cf': {\n          'orange': '#FF4801',\n          'orange-hover': '#FF7038',\n          'orange-light': 'rgba(255, 72, 1, 0.06)',\n          'text': '#521000',\n          'text-muted': 'rgba(82, 16, 0, 0.7)',\n          'text-subtle': 'rgba(82, 16, 0, 0.4)',\n          'bg-page': '#FFFBF5',\n          'bg-100': '#FFFBF5',\n          'bg-200': '#FFFDFB',\n          'bg-300': '#FEF7ED',\n          'border': '#EBD5C1',\n          'border-light': 'rgba(235, 213, 193, 0.5)',\n        },\n        'aws-orange': '#FF9900',\n        'gcp-blue': '#4285F4',\n      },\n      fontFamily: {\n        sans: ['Inter', '-apple-system', 'system-ui', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],\n        mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Monaco', 'Consolas', 'monospace'],\n      },\n      fontSize: {\n        'sm': ['0.9rem', { lineHeight: '1.4' }],\n        'base': ['1rem', { lineHeight: '1.5' }],\n        'lg': ['1.125rem', { lineHeight: '1.75rem' }],\n        '2xl': ['1.5rem', { lineHeight: '2rem' }],\n        '3xl': ['1.875rem', { lineHeight: '2.25rem' }],\n      },\n      letterSpacing: {\n        'tight-heading': '-0.035em',\n        'logo': '-0.46px',\n      },\n      borderRadius: {\n        'lg': '0.5rem',\n        'xl': '0.75rem',\n        '2xl': '1rem',\n      },\n      transitionTimingFunction: {\n        'button': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n        'active': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)',\n        'entrance': 'cubic-bezier(0.16, 1, 0.3, 1)',\n      },\n      transitionDuration: {\n        '160': '160ms',\n        '2000': '2000ms',\n      },\n      animation: {\n        'float-subtle': 'float-subtle 3s ease-in-out infinite',\n        'dash-draw': 'dashdraw 0.5s linear infinite',\n      },\n      keyframes: {\n        'float-subtle': {\n          '0%, 100%': { transform: 'translateY(0)' },\n          '50%': { transform: 'translateY(-3px)' },\n        },\n        'dashdraw': {\n          '0%': { strokeDashoffset: '10' },\n        },\n      },\n      boxShadow: {\n        'focus': '0 0 0 3px rgba(255, 72, 1, 0.1)',\n        'card': '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',\n      },\n      maxWidth: {\n        '5xl': '64rem',\n        '8xl': '1480px',\n      },\n    },\n  },\n  plugins: [],\n}\n```\n\n#### CSS Variables Block\n\n```css\n:root {\n  /* === COLORS === */\n  /* Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.06);\n  \n  /* Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.7);\n  --lp-text-subtle: rgba(82, 16, 0, 0.4);\n  \n  /* Backgrounds */\n  --lp-bg-page: #FFFBF5;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Semantic */\n  --lp-success: #16A34A;\n  --lp-success-bg: #DCF7E3;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  \n  /* Provider Colors */\n  --aws-orange: #FF9900;\n  --gcp-blue: #4285F4;\n  \n  /* === TYPOGRAPHY === */\n  --font-sans: \"Inter\", -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Monaco, Consolas, monospace;\n  \n  /* === SPACING === */\n  --spacing-unit: 4px;\n  \n  /* === BORDER RADIUS === */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-full: 9999px;\n  \n  /* === SHADOWS === */\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.1);\n  --shadow-card: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);\n  --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);\n  \n  /* === TRANSITIONS === */\n  --ease-standard: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n  --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n  \n  --duration-instant: 100ms;\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 500ms;\n  \n  /* === CONTAINERS === */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1480px;\n}\n\n/* Dark Mode */\n:root.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-page: #0D0D0D;\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-bg-300: #2A2927;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n\n/* Base Styles */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n  margin: 0;\n  background-color: var(--lp-bg-page);\n  color: var(--lp-text);\n  font-family: var(--font-sans);\n  overflow-x: hidden;\n}\n\n/* Focus States */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus,\nselect:focus {\n  border-color: var(--lp-accent);\n  box-shadow: var(--shadow-focus);\n}\n\n/* Transitions */\nbutton,\na,\ninput,\nselect {\n  transition: all 0.2s ease-in-out;\n}\n```\n\n#### Framer Motion Variants\n\n```javascript\n// framer-motion-variants.js\n\n// Page entrance animation\nexport const pageEntrance = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 0.5, \n      ease: [0.16, 1, 0.3, 1] \n    }\n  }\n};\n\n// Section slide-up\nexport const sectionSlideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.4, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Stagger container (for card grids)\nexport const staggerContainer = {\n  animate: {\n    transition: {\n      staggerChildren: 0.08,\n      delayChildren: 0.1\n    }\n  }\n};\n\n// Stagger child item\nexport const staggerItem = {\n  initial: { opacity: 0, y: 16 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.35, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Button hover and tap\nexport const buttonInteraction = {\n  whileHover: { scale: 1.01 },\n  whileTap: { \n    scale: 0.98, \n    y: 1,\n    transition: { \n      duration: 0.16, \n      ease: [0.55, 0.085, 0.68, 0.53] \n    }\n  }\n};\n\n// Card hover\nexport const cardHover = {\n  initial: { scale: 1 },\n  whileHover: { \n    scale: 1.01,\n    transition: { \n      duration: 0.2, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Progress bar fill\nexport const progressFill = {\n  initial: { width: 0 },\n  animate: (width) => ({\n    width: `${width}%`,\n    transition: { \n      duration: 0.5, \n      ease: \"easeOut\" \n    }\n  })\n};\n\n// Floating animation (for icons/decorations)\nexport const floatSubtle = {\n  animate: {\n    y: [0, -3, 0],\n    transition: {\n      duration: 3,\n      ease: \"easeInOut\",\n      repeat: Infinity\n    }\n  }\n};\n\n// Background fade-in (slow entrance)\nexport const backgroundFadeIn = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 2, \n      delay: 0.1,\n      ease: \"easeOut\" \n    }\n  }\n};\n\n// Usage example with React\n/*\nimport { motion } from 'framer-motion';\nimport { pageEntrance, staggerContainer, staggerItem } from './framer-motion-variants';\n\nfunction Page() {\n  return (\n    <motion.main {...pageEntrance}>\n      <motion.div \n        variants={staggerContainer}\n        initial=\"initial\"\n        animate=\"animate\"\n        className=\"grid grid-cols-3 gap-6\"\n      >\n        {items.map((item) => (\n          <motion.div key={item.id} variants={staggerItem}>\n            {item.content}\n          </motion.div>\n        ))}\n      </motion.div>\n    </motion.main>\n  );\n}\n*/\n```\n\n---\n\n*Synthesized from analysis of warm-cream developer-marketing landing pages. The aesthetic — warm cream backgrounds, brown text, single bold accent for CTAs, corner-bracket decorations — is portable to any B2B / developer-tools / dev-adjacent product brand.*\n\n\n\n# ===== SNIPPETS.md =====\n\n# Landing Page Design - Component Snippets\n\n> **Copy-paste ready components** for building warm-cream landing-page interfaces.\n> Each snippet includes React + Tailwind AND Vanilla HTML versions.\n\n---\n\n## Table of Contents\n\n1. [Buttons](#buttons)\n2. [Cards](#cards)\n3. [Forms](#forms)\n4. [Calculator Tools](#calculator-tools)\n5. [Navigation](#navigation)\n6. [Hero Sections](#hero-sections)\n7. [Data Display](#data-display)\n8. [Layout](#layout)\n9. [Decorative](#decorative)\n\n---\n\n# Buttons\n\n## BTN-PRIMARY\n\nPrimary CTA button - cream background, orange text, fully rounded.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFBF5] text-[#FF4801] border border-[#FFFBF5] transition-all duration-150 ease-out hover:bg-transparent hover:border-[#FF4801] active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Get started\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFBF5;\n  color: #FF4801;\n  border: 1px solid #FFFBF5;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.background='transparent'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.background='#FFFBF5'; this.style.borderColor='#FFFBF5';\">\n  Get started\n</button>\n```\n\n---\n\n## BTN-SECONDARY\n\nSecondary button - orange background, white text.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 ease-out hover:opacity-95 hover:border-dashed hover:border-white/50 active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Learn more\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: 1px solid transparent;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.opacity='0.95';\" onmouseout=\"this.style.opacity='1';\">\n  Learn more\n</button>\n```\n\n---\n\n## BTN-GHOST\n\nGhost button - transparent with border, for secondary actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-transparent text-[#FF4801] border border-[#EBD5C1] transition-all duration-150 ease-out hover:border-dashed hover:border-[#FF4801] hover:text-[#521000] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20 disabled:opacity-50 disabled:cursor-not-allowed\">\n  View docs\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: transparent;\n  color: #FF4801;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.borderStyle='dashed'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.borderStyle='solid'; this.style.borderColor='#EBD5C1';\">\n  View docs\n</button>\n```\n\n---\n\n## BTN-OUTLINE\n\nOutline button - for less prominent actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:border-dashed active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#EBD5C1]/50 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Cancel\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\">\n  Cancel\n</button>\n```\n\n---\n\n## BTN-ICON\n\nIcon-only button with tooltip.\n\n### React + Tailwind\n\n```jsx\n<button \n  className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:text-[#FF4801] hover:border-[#FF4801] active:scale-[0.95] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20\"\n  aria-label=\"Settings\"\n  title=\"Settings\"\n>\n  <svg className=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={1.5}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 40px;\n  height: 40px;\n  border-radius: 9999px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" aria-label=\"Settings\" title=\"Settings\">\n  <svg width=\"20\" height=\"20\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n---\n\n## BTN-LINK\n\nLink styled as text with arrow.\n\n### React + Tailwind\n\n```jsx\n<a href=\"#\" className=\"inline-flex items-center gap-1 font-medium text-[#FF4801] hover:underline hover:underline-offset-4 transition-all duration-150\">\n  View documentation\n  <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n### Vanilla HTML\n\n```html\n<a href=\"#\" style=\"\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  color: #FF4801;\n  text-decoration: none;\n  transition: all 0.15s ease;\n\">\n  View documentation\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n---\n\n## BTN-LOADING\n\nButton with loading spinner state.\n\n### React + Tailwind\n\n```jsx\n<button \n  disabled\n  className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 disabled:opacity-70 disabled:cursor-not-allowed\"\n>\n  <svg className=\"animate-spin h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n    <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n    <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button disabled style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: none;\n  opacity: 0.7;\n  cursor: not-allowed;\n\">\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" style=\"animation: spin 1s linear infinite;\">\n    <circle opacity=\"0.25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\" />\n    <path opacity=\"0.75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n<style>\n  @keyframes spin { to { transform: rotate(360deg); } }\n</style>\n```\n\n---\n\n# Cards\n\n## CARD-DEFAULT\n\nStandard card with corner bracket decorations.\n\n### React + Tailwind\n\n```jsx\n<div className=\"relative bg-[#FFFDFB] border border-[#EBD5C1] p-6 shadow-[0_1px_3px_rgba(82,16,0,0.04),0_4px_12px_rgba(82,16,0,0.02)]\">\n  {/* Corner brackets */}\n  <div className=\"absolute -top-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -top-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  \n  <h3 className=\"text-lg font-medium text-[#521000] mb-2\">Card Title</h3>\n  <p className=\"text-sm text-[#521000]/60 leading-relaxed\">\n    Card description goes here. This is a standard card with the signature corner bracket decorations.\n  </p>\n</div>\n```\n\n### Vanilla HTML\n\n```html\n<div \n\n<!-- [TRUNCATED AT 50KB] -->","frontmatter":{"name":"frontend-design-landing-page","description":"Marketing landing page and conversion-focused product page reference. Use this skill when building hero sections, feature grids, pricing pages, testimonials, CTAs, footers, navigation bars, or any public-facing marketing surface. Covers a warm, professional, developer-friendly design language (cream backgrounds, generous whitespace, pill CTAs, corner-bracket card decorations) and a complete token set, animation system, and copy-paste component snippets. NOT for product/dashboard UIs — use frontend-design-saas for those."},"isInternal":false,"tokens":17186,"sizeBytes":50298},{"name":"SKILL.md","path":"worker/agents/think/skills/frontend-design-saas/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/worker/agents/think/skills/frontend-design-saas/SKILL.md","title":"frontend-design-saas","category":"anthropic-skill","format":"markdown","content":"---\nname: frontend-design-saas\ndescription: S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear.\n---\n\n# SaaS Design System (Stripe / Linear / Vercel lineage)\n\n> **AI-Optimized Design Reference** for building S-tier SaaS dashboards, admin consoles, billing pages, settings UIs, data-dense application shells, and product surfaces.\n>\n> Tonal anchors: `stripe.com/dashboard`, `linear.app`, `vercel.com/dashboard`, `notion.so/teamspace`, `clerk.com`, `planetscale.com/console`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #5E6AD2 (indigo) - replace with product brand\nNeutrals:        #FFFFFF → #F8FAFC → #F1F5F9 → #E2E8F0 → #94A3B8 → #475569 → #0F172A\nText:            #0F172A (primary) / #475569 (muted) / #94A3B8 (subtle)\nBorder:          #E2E8F0 (default) / #CBD5E1 (strong)\nFont Sans:       \"Inter\", \"Geist\", system-ui (NOT Arial, NOT Roboto)\nFont Mono:       \"JetBrains Mono\", \"Geist Mono\", ui-monospace\nBase Spacing:    4px (scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64)\nBorder Radius:   Buttons/inputs = 6-8px (NOT pill), Cards = 8-12px, Modals = 12-16px\nShadow:          Tight, low-spread, single-direction (NOT diffuse marketing shadows)\nDensity:         Compact-to-comfortable; row heights 36-44px, button heights 32-36px\n```\n\nThe fastest way to get this wrong: use marketing-page aesthetics (huge type, generous whitespace, pill buttons, gradient hero shadows) in a product surface. SaaS UIs are **dense, neutral, fast, and quiet**. The brand color appears in <5% of the pixels and earns its weight by marking only what's interactive or active.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Users First** | Workflows, keyboard navigation, and information density beat decoration. |\n| **Quiet by Default** | Neutrals dominate; the brand color is reserved for primary action, active state, and focus. |\n| **Speed & Density** | Snappy transitions (≤200ms), compact row heights, no entrance animations on every page load. |\n| **Opinionated Defaults** | Single canonical pattern per surface (one button hierarchy, one date picker, one empty state). |\n| **Meticulous Craft** | 1px borders aligned to pixel grid, focus rings 2px offset 2px, motion uses ease-out not linear. |\n| **Accessible (WCAG AA+)** | 4.5:1 text contrast, 3:1 UI element contrast, every interactive surface keyboard-reachable. |\n| **Predictable** | Same component looks/behaves identically on every page; no marketing-grade variants. |\n\n### Visual Identity Rules\n\n- **Do not use pure black on pure white.** Use `#0F172A` on `#FFFFFF` (or `#0B1220` on `#0A0E1A` in dark mode).\n- **Brand color is the accent, never the dominant.** If the brand color fills more than ~5% of the viewport in steady state, something is wrong.\n- **Avoid pill buttons** (`border-radius: 9999px`) for SaaS actions. Pills read as marketing/consumer; SaaS uses `6-8px` radius for buttons and inputs.\n- **No glassmorphism, no blur, no gradient borders.** The aesthetic is precision, not richness.\n- **Shadows are tight and downward.** Not diffuse, not multi-direction. Reserved for floating layers (popovers, modals, dropdowns).\n- **One font.** Inter / Geist / IBM Plex Sans / native system stack. Avoid mixing display + body fonts; SaaS uses a single workhorse sans.\n- **Mono for IDs, code, tokens, money.** Tabular figures (`font-variant-numeric: tabular-nums`) for any column of numbers.\n\n### Reference apps to mentally calibrate against\n\n| Surface to build | Look at |\n|---|---|\n| Application shell | Linear, Vercel, Stripe Dashboard |\n| Data table | Stripe `/payments`, Linear backlog, PlanetScale branches |\n| Settings page | Vercel project settings, Clerk dashboard, GitHub repo settings |\n| Billing / invoice | Stripe billing, Vercel usage |\n| Empty state | Linear, Notion, Vercel deployments |\n| Command palette | Linear ⌘K, Vercel ⌘K, Raycast |\n| Auth | Clerk hosted pages, Stripe Connect onboarding |\n| Onboarding checklist | Stripe Atlas, Vercel project import |\n\n---\n\n## 2. Color System\n\n### 2.1 Neutral Scale (the backbone)\n\nSaaS UIs are 80–90% neutrals. The neutral scale carries the entire interface; brand is sprinkled on top. Use a slightly cool neutral (slate) by default — it reads more \"product\" than warm grays which feel marketing/lifestyle.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--neutral-0` | `#FFFFFF` | Page background (light mode), surface lifted onto canvas |\n| `--neutral-50` | `#F8FAFC` | Canvas background, alternating row stripe |\n| `--neutral-100` | `#F1F5F9` | Hover background, subtle fill, table header |\n| `--neutral-200` | `#E2E8F0` | **Default border**, divider |\n| `--neutral-300` | `#CBD5E1` | Strong border, input border on focus-within parent |\n| `--neutral-400` | `#94A3B8` | **Subtle text** (placeholders, disabled, captions) |\n| `--neutral-500` | `#64748B` | Tertiary text, icon default |\n| `--neutral-600` | `#475569` | **Muted text** (descriptions, secondary labels) |\n| `--neutral-700` | `#334155` | Strong icon, hover text |\n| `--neutral-800` | `#1E293B` | Heading in dense UIs, code text |\n| `--neutral-900` | `#0F172A` | **Primary text**, primary icons |\n| `--neutral-950` | `#020617` | Highest contrast (rare; only for critical labels) |\n\n### 2.2 Brand Accent (single color, used sparingly)\n\nPick **one** accent. Use a 6-step ramp so hover/active/focused/disabled all derive from the same hue. Defaults below use indigo (`#5E6AD2`, Linear's accent) — swap the hue for your brand but keep the structure.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--brand-50` | `#EEF0FB` | Selected row background, badge background, focus ring background |\n| `--brand-100` | `#DDE0F7` | Hover on brand surfaces |\n| `--brand-200` | `#B9C0EF` | Disabled brand button, decorative |\n| `--brand-500` | `#7B85DC` | Brand hover surface stroke |\n| `--brand-600` | `#5E6AD2` | **Primary brand** — default button bg, link text, focus ring, active nav item |\n| `--brand-700` | `#4A56C0` | Hover state for primary button |\n| `--brand-800` | `#3A45A8` | Active/pressed state |\n| `--brand-900` | `#2A3284` | High-contrast brand text on light background |\n\n### 2.3 Semantic / Status Colors\n\nUsed for state — never for decoration. Each has a `text`, `bg`, `border` triplet so badges and inline messages stay legible.\n\n| State | `text` | `bg` (filled) | `bg-subtle` (badge) | `border` |\n|---|---|---|---|---|\n| **Success** | `#15803D` | `#16A34A` | `#DCFCE7` | `#86EFAC` |\n| **Warning** | `#A16207` | `#CA8A04` | `#FEF9C3` | `#FDE68A` |\n| **Error / Destructive** | `#B91C1C` | `#DC2626` | `#FEE2E2` | `#FCA5A5` |\n| **Info** | `#1D4ED8` | `#2563EB` | `#DBEAFE` | `#93C5FD` |\n| **Neutral (default badge)** | `#334155` | `#64748B` | `#F1F5F9` | `#CBD5E1` |\n\nRules:\n- Inline form errors: `text` color on `bg-subtle` background, `border` for the input.\n- Toasts: `bg-subtle` panel, `text` heading, `--neutral-700` body.\n- Status badges: `text` on `bg-subtle` + 1px `border`. Never use solid `bg` for badges (too loud).\n\n### 2.4 Data Visualization Palette\n\nWhen you need >1 color for charts. Categorical — visually distinct, similar luminance. Avoid the brand color in this palette so brand reads as \"interactive\" elsewhere.\n\n```\n--chart-1: #2563EB  (blue)\n--chart-2: #16A34A  (green)\n--chart-3: #CA8A04  (amber)\n--chart-4: #DC2626  (red)\n--chart-5: #9333EA  (violet)\n--chart-6: #0891B2  (cyan)\n--chart-7: #DB2777  (pink)\n--chart-8: #65A30D  (lime)\n```\n\n### 2.5 Dark Mode Mapping\n\nSaaS dark modes are **not just inverted**. They are slightly desaturated, slightly lifted, with carefully tuned elevation. Use `#0A0E1A` (or `#0B0F1C`) as the canvas — NEVER `#000000`. Pure black creates harsh contrast and exposes any banding in shadows.\n\n| Token | Light | Dark |\n|---|---|---|\n| `--bg-canvas` | `#F8FAFC` | `#0A0E1A` |\n| `--bg-surface` | `#FFFFFF` | `#111827` |\n| `--bg-elevated` | `#FFFFFF` | `#1F2937` |\n| `--bg-overlay` (popover/modal) | `#FFFFFF` | `#1F2937` |\n| `--text-primary` | `#0F172A` | `#F1F5F9` |\n| `--text-muted` | `#475569` | `#94A3B8` |\n| `--text-subtle` | `#94A3B8` | `#64748B` |\n| `--border-default` | `#E2E8F0` | `#1F2937` |\n| `--border-strong` | `#CBD5E1` | `#374151` |\n| `--brand-bg-subtle` | `#EEF0FB` | `rgba(94, 106, 210, 0.16)` |\n| `--brand-fg` | `#5E6AD2` | `#A5B4FC` |\n\nIn dark mode the **brand color shifts lighter** (toward `--brand-500/400` instead of `--brand-600/700`) to maintain contrast against the dark canvas.\n\n---\n\n## 3. Typography\n\n### 3.1 Font Stack\n\n```css\n--font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n             \"SF Pro Text\", system-ui, sans-serif;\n--font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n             ui-monospace, monospace;\n```\n\nHard rules:\n- **Never use `Arial`, `Helvetica`, `Roboto`, or `Times`** in a SaaS UI. These read as default/unstyled. If you can't load a webfont, fall through to `system-ui` instead.\n- **Never mix two display fonts.** One sans + one mono is the entire stack.\n- **`font-feature-settings: \"cv11\", \"ss01\", \"ss03\"`** for Inter (or whatever stylistic sets your font has) — small caps and alternate forms make tables look intentional.\n- **`font-variant-numeric: tabular-nums`** on EVERY column that contains numbers (money, dates, IDs, counts). Non-tabular figures make rows shimmer.\n\n### 3.2 Type Scale (SaaS-tuned — denser than marketing)\n\n| Token | Size | Line Height | Weight | Letter Spacing | Use |\n|---|---|---|---|---|---|\n| `text-2xs` | 11px / 0.6875rem | 1.36 (15px) | 500 | 0.04em | Microlabels (uppercase metadata, table column tags) |\n| `text-xs` | 12px / 0.75rem | 1.33 (16px) | 400/500 | 0 | Captions, badge text, table secondary |\n| `text-sm` | 13px / 0.8125rem | 1.38 (18px) | 400/500 | 0 | **Default UI text**: table rows, buttons, form labels |\n| `text-base` | 14px / 0.875rem | 1.43 (20px) | 400/500 | 0 | Body text, paragraph copy in panels |\n| `text-md` | 15px / 0.9375rem | 1.47 (22px) | 400/500 | 0 | Comfortable reading (descriptions, modals) |\n| `text-lg` | 16px / 1rem | 1.5 (24px) | 500/600 | -0.005em | Card headings, sidebar section labels |\n| `text-xl` | 18px / 1.125rem | 1.44 (26px) | 600 | -0.01em | Subsection headings |\n| `text-2xl` | 20px / 1.25rem | 1.4 (28px) | 600 | -0.015em | Page subheadings |\n| `text-3xl` | 24px / 1.5rem | 1.33 (32px) | 600 | -0.02em | Page title (h1 in dashboards) |\n| `text-4xl` | 30px / 1.875rem | 1.2 (36px) | 600/700 | -0.025em | Marketing-adjacent hero in settings |\n\n**Critical**: the default UI size in SaaS is **13px or 14px**, NOT 16px. Stripe is 14px. Linear is 13px. Vercel is 14px. 16px feels too big and creates pages that scroll forever.\n\n### 3.3 Weights\n\n| Weight | Value | Use |\n|---|---|---|\n| Regular | 400 | Body, table cells, descriptions |\n| Medium | 500 | Default for buttons, labels, table headers, navigation, badge text |\n| Semibold | 600 | Headings, emphasized stats, active nav |\n| Bold | 700 | Rare — only marketing-adjacent surfaces or KPI numbers |\n\n**Never** use weight 800 or 900 in a SaaS UI.\n\n### 3.4 Letter Spacing\n\n| Context | Value |\n|---|---|\n| Body / table | 0 |\n| Headings ≥18px | -0.01em to -0.025em (tighter as size grows) |\n| Microlabels / uppercase | 0.04em to 0.06em |\n| Mono | 0 |\n\n### 3.5 Text Rendering\n\n```css\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1;\n}\n\n.numeric, table, [data-numeric] {\n  font-variant-numeric: tabular-nums;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--space-unit: 4px;\n```\n\n### 4.2 Scale\n\n| Token | px | rem | Common Use |\n|---|---|---|---|\n| `space-0` | 0 | 0 | Reset |\n| `space-px` | 1 | — | Hairlines, dividers |\n| `space-0.5` | 2 | 0.125 | Tight icon-to-text gaps |\n| `space-1` | 4 | 0.25 | Inline tight |\n| `space-1.5` | 6 | 0.375 | — |\n| `space-2` | 8 | 0.5 | Default tight gap, icon-text, badge padding |\n| `space-2.5` | 10 | 0.625 | Input vertical padding (compact) |\n| `space-3` | 12 | 0.75 | Input vertical padding (default), small card |\n| `space-4` | 16 | 1 | **Default card/panel padding**, form field gaps |\n| `space-5` | 20 | 1.25 | Section subheading gap |\n| `space-6` | 24 | 1.5 | Card padding (comfortable), section internal |\n| `space-8` | 32 | 2 | Page section gap, large card padding |\n| `space-10` | 40 | 2.5 | Major section gap |\n| `space-12` | 48 | 3 | Page-level section gap |\n| `space-16` | 64 | 4 | Hero, max page-section |\n\n**Density convention** (which scale value to default to):\n\n- Buttons: `8px 12px` (sm), `8px 14px` (md), `10px 16px` (lg) — vertical padding stays tight\n- Inputs: `8px 12px` (sm), `9px 12px` (md), `10px 14px` (lg)\n- Cards: `16px` (sm), `20-24px` (md), `32px` (lg / settings panel)\n- Table cells: `10px 16px` (compact), `12px 16px` (default), `16px 20px` (comfortable)\n- Page padding: `24px` (mobile) → `32px` (desktop) → `48px` (wide settings)\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Use |\n|---|---|---|\n| `--radius-none` | 0 | Table cells, full-bleed sections |\n| `--radius-sm` | 4px | Badges, tags, small chips |\n| `--radius-md` | 6px | **Default**: buttons, inputs, dropdowns, menu items |\n| `--radius-lg` | 8px | Cards, panels, popovers |\n| `--radius-xl` | 12px | Modals, large surfaces, sheets |\n| `--radius-2xl` | 16px | Marketing-adjacent (pricing card, onboarding) |\n| `--radius-full` | 9999px | Avatars, status dots, **NOT buttons** |\n\nRules:\n- **Buttons in SaaS UIs use 6px, not pill (`9999px`).** Pills are for marketing/consumer apps. The exception is a \"tag\" or \"chip\" inside a table cell.\n- **Match input and button radius.** If button = 6px, input = 6px. Visual consistency in form rows.\n- **Cards never share radius with modals.** Cards = 8px, modals = 12px+. This is how the modal reads as a different elevation layer.\n\n---\n\n## 6. Shadow System (Elevation)\n\nSaaS shadows are **tight, downward, single-direction**. They mark elevation; they do not decorate. No glows, no colored shadows, no inset highlights, no large spread radii.\n\n### 6.1 Elevation Tokens\n\n```css\n/* Light mode */\n--shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n--shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05),\n              0 1px 3px 0 rgba(15, 23, 42, 0.06);\n--shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06),\n              0 4px 6px -2px rgba(15, 23, 42, 0.04);\n--shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05),\n              0 10px 15px -3px rgba(15, 23, 42, 0.08);\n--shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06),\n              0 20px 25px -5px rgba(15, 23, 42, 0.10);\n--shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n\n/* Focus ring (NOT a shadow, but the only \"glow\" allowed) */\n--ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n--ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n/* Dark mode: shadows almost invisible; elevation is conveyed via background lightening */\n--shadow-xs-dark:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n--shadow-sm-dark:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n--shadow-md-dark:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n--shadow-lg-dark:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n```\n\n### 6.2 Elevation → Component Mapping\n\n| Layer | Shadow | Examples |\n|---|---|---|\n| 0 (flat on canvas) | none | Sidebar, page body, inline rows |\n| 1 (resting card) | `--shadow-xs` or none + 1px border | Default card on `--bg-canvas` |\n| 2 (hovered card) | `--shadow-sm` | Card with `:hover` state |\n| 3 (dropdown, popover) | `--shadow-md` | Menu, popover, select dropdown |\n| 4 (modal, dialog) | `--shadow-xl` | Dialog, slide-over |\n| 5 (toast over modal) | `--shadow-2xl` | Toast, command palette over modal backdrop |\n\n### 6.3 Border-vs-Shadow Decision\n\nFor most cards, **prefer a 1px border over a shadow**. Borders are crisper at any DPI and read more \"product\". Use shadow only when:\n- The element is floating over content (popover, modal, dropdown, toast)\n- A drag-and-drop ghost is rendered\n- A row is \"lifted\" to indicate selection while dragging\n\n---\n\n## 7. Animation System\n\n### 7.1 Principle\n\nSaaS motion is **fast, ease-out, and only on user-initiated events**. No entrance animations on page mount, no decorative parallax, no stagger on every grid. Motion's job is to confirm an action happened, not to entertain.\n\n### 7.2 Easing\n\n```css\n/* Default — used for most state transitions */\n--ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out, snappy */\n\n/* For things that recoil (modal entrance, popover) */\n--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);\n\n/* Press / active (deceleration into rest) */\n--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n/* Linear — for indeterminate progress / spinners only */\n--ease-linear: linear;\n```\n\n**Do NOT use** `ease`, `ease-in`, or unspecified defaults. They feel sluggish in SaaS contexts.\n\n### 7.3 Duration Scale\n\n| Token | ms | Use |\n|---|---|---|\n| `--duration-75` | 75 | Color change, opacity flip (button press color) |\n| `--duration-100` | 100 | Hover background fill |\n| `--duration-150` | 150 | **Default** — most transitions |\n| `--duration-200` | 200 | Dropdown/popover enter |\n| `--duration-250` | 250 | Modal/dialog enter |\n| `--duration-300` | 300 | Slide-over (drawer) enter |\n| `--duration-500` | 500 | Skeleton shimmer cycle |\n\n**Anything longer than 300ms in a product UI is wrong.** Marketing pages can take more time; product UIs cannot.\n\n### 7.4 Standard Transitions\n\n```css\n/* Color/border (default for all interactive elements) */\nbutton, a, input, select, textarea, [role=\"button\"] {\n  transition:\n    background-color var(--duration-150) var(--ease-out),\n    border-color var(--duration-150) var(--ease-out),\n    color var(--duration-150) var(--ease-out),\n    box-shadow var(--duration-150) var(--ease-out);\n}\n\n/* Modal/dialog */\n.dialog-enter { animation: dialog-enter 250ms var(--ease-out-back); }\n@keyframes dialog-enter {\n  from { opacity: 0; transform: translateY(8px) scale(0.98); }\n  to   { opacity: 1; transform: translateY(0) scale(1); }\n}\n\n/* Popover/dropdown */\n.popover-enter { animation: popover-enter 150ms var(--ease-out); }\n@keyframes popover-enter {\n  from { opacity: 0; transform: translateY(-4px); }\n  to   { opacity: 1; transform: translateY(0); }\n}\n\n/* Toast slide-in */\n.toast-enter { animation: toast-enter 200ms var(--ease-out); }\n@keyframes toast-enter {\n  from { opacity: 0; transform: translateX(8px); }\n  to   { opacity: 1; transform: translateX(0); }\n}\n\n/* Skeleton shimmer */\n.skeleton {\n  background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);\n  background-size: 200% 100%;\n  animation: shimmer 1.5s ease-in-out infinite;\n}\n@keyframes shimmer {\n  from { background-position: 200% 0; }\n  to   { background-position: -200% 0; }\n}\n```\n\n### 7.5 What NOT to Animate\n\n- **Page mount.** Render synchronously. The page is the priority, not the motion.\n- **Sidebar nav items individually staggering in.** Render the whole nav at once.\n- **Tab content fade.** Switching tabs is an instant context change; fade hides the change.\n- **Table rows.** A 1000-row table that fades in row-by-row is unusable.\n\n### 7.6 What SHOULD Animate\n\n- Button hover (background-color 100ms)\n- Focus ring (box-shadow 150ms)\n- Modal/dialog enter and exit (250ms)\n- Popover/dropdown enter (150ms)\n- Toast slide-in (200ms)\n- Skeleton shimmer (loading states)\n- Accordion expand/collapse (height 200ms)\n- Optimistic UI confirmations (checkmark scale-in 150ms)\n\n### 7.7 `prefers-reduced-motion`\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n```\n\nAlways include. SaaS users may have vestibular sensitivity or use the OS-level reduce-motion setting.\n\n---\n\n## 8. Layout System\n\n### 8.1 The Canonical SaaS Shell\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ [Topbar: workspace switcher | search ⌘K | user menu] 48px       │\n├──────────────┬──────────────────────────────────────────────────┤\n│              │                                                  │\n│              │   Page header                                    │\n│              │   ┌────────────────────────────────────────────┐ │\n│              │   │ Title             [primary action] [⋯ menu]│ │\n│   Sidebar    │   │ Description / breadcrumb                   │ │\n│   (240-     │   └────────────────────────────────────────────┘ │\n│    280px)    │                                                  │\n│              │   Page body (cards, tables, settings)            │\n│   Nav items  │                                                  │\n│   Sections   │   ┌──────────────────────────────────────────┐   │\n│   Footer     │   │  Card / Panel                            │   │\n│              │   └──────────────────────────────────────────┘   │\n│              │                                                  │\n└──────────────┴──────────────────────────────────────────────────┘\n```\n\nThis is the Stripe/Linear/Vercel/Notion layout. Three regions:\n\n1. **Topbar** (48–56px): workspace/team switcher on the left, global search (`⌘K`) center or right, user menu on far right. Stays fixed during scroll.\n2. **Sidebar** (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.\n3. **Content** (remaining width, max ~1200px centered for readability OR full-width for tables): page header → page body.\n\nVariations:\n- **Linear-style** has no topbar — the sidebar takes the whole left edge, search lives inside the content header.\n- **Notion-style** has only a sidebar, page-level breadcrumbs at top of content.\n- **Settings-style** uses a *secondary* sidebar (settings nav) inside the content area, so two sidebars are visible.\n\n### 8.2 Container Widths\n\n| Token | px | Use |\n|---|---|---|\n| `--container-sm` | 640 | Single-column forms, auth, dialogs |\n| `--container-md` | 768 | Settings forms, account pages |\n| `--container-lg` | 1024 | Dashboard pages, default content max |\n| `--container-xl` | 1280 | Wide dashboards with sidebars-in-content |\n| `--container-2xl` | 1536 | Data tables, monitoring views (full-bleed) |\n\n**Tables get `--container-2xl` or full-bleed.** Settings forms get `--container-md` (max ~720px reading column).\n\n### 8.3 Breakpoints\n\n| Name | min-width | Behavior |\n|---|---|---|\n| `sm` | 640px | Form inputs reach full width on phones |\n| `md` | 768px | Two-column form layouts kick in |\n| `lg` | 1024px | Sidebar appears (below this, sidebar becomes a sheet/drawer) |\n| `xl` | 1280px | Sidebar stays expanded; data tables show all columns |\n| `2xl` | 1536px | Multi-column dashboards |\n\nMobile rule: the sidebar collapses to a hamburger that opens a full-height sheet. The topbar's search and user menu remain.\n\n### 8.4 Grid Patterns\n\n```css\n/* Page header — title left, actions right */\n.page-header {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 16px;\n  margin-bottom: 24px;\n}\n\n/* Two-column form (label left, input right) — settings page idiom */\n.form-row {\n  display: grid;\n  grid-template-columns: minmax(0, 240px) minmax(0, 1fr);\n  gap: 24px;\n  padding: 20px 0;\n  border-bottom: 1px solid var(--border-default);\n}\n.form-row:last-child { border-bottom: none; }\n.form-row__label { font-size: 13px; font-weight: 500; color: var(--text-primary); }\n.form-row__hint  { font-size: 12px; color: var(--text-muted); margin-top: 4px; }\n\n/* Card grid (3-col on desktop, 2 on tablet, 1 on mobile) */\n.card-grid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: 1fr;\n}\n@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }\n@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }\n\n/* KPI strip — auto-fit, never wider than 280, never narrower than 200 */\n.kpi-strip {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n  gap: 12px;\n}\n```\n\n### 8.5 Sidebar Anatomy\n\n```\n[Workspace switcher]      32px row, hover bg, dropdown chevron\n─────────────────────\nSECTION LABEL             11px uppercase, --neutral-500, 8px y-padding\n  [icon] Item 1           36px row, 13px text, 8px gap, hover bg-100\n  [icon] Item 2           selected: bg-brand-50 + brand-700 text\n  [icon] Item 3\n─────────────────────\nSECTION LABEL\n  [icon] Item 4\n─────────────────────\n                          (stretches to fill)\n[avatar] user@email       Bottom; opens user menu upward\n```\n\nKey spec:\n- Sidebar bg: `--bg-canvas` (slightly tinted from page surface) OR `--bg-surface` flat\n- Nav items: 36px tall, 8px horizontal padding, 6px radius, gap 8px between icon + text\n- Active item: `bg: var(--brand-50)`, `color: var(--brand-700)`, **no border, no left-accent stripe** (Linear-style minimal)\n- Section labels: `text-2xs` uppercase, `--neutral-500`, 8px y-padding\n- Width: 256px default, 56px collapsed (icons only)\n\n---\n\n## 9. Dark Mode\n\n### 9.1 Detection\n\n```html\n<script>\n  (function() {\n    var stored = localStorage.getItem('theme');\n    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n    var dark = stored === 'dark' || (stored !== 'light' && prefersDark);\n    document.documentElement.classList.toggle('dark', dark);\n  })();\n</script>\n```\n\nInline in `<head>` BEFORE any stylesheets to prevent flash-of-light-theme on dark-preferred users.\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --bg-canvas: #F8FAFC;\n  --bg-surface: #FFFFFF;\n  --bg-elevated: #FFFFFF;\n  --text-primary: #0F172A;\n  --text-muted: #475569;\n  --text-subtle: #94A3B8;\n  --border-default: #E2E8F0;\n  --border-strong: #CBD5E1;\n  --brand-fg: #5E6AD2;\n  --brand-bg-subtle: #EEF0FB;\n}\n\n.dark {\n  --bg-canvas: #0A0E1A;\n  --bg-surface: #111827;\n  --bg-elevated: #1F2937;\n  --text-primary: #F1F5F9;\n  --text-muted: #94A3B8;\n  --text-subtle: #64748B;\n  --border-default: #1F2937;\n  --border-strong: #374151;\n  --brand-fg: #A5B4FC;\n  --brand-bg-subtle: rgba(94, 106, 210, 0.16);\n}\n```\n\n### 9.3 Three-Way Theme Toggle\n\nAlways offer `system | light | dark`. The user expects to match OS preference by default but be able to override.\n\n```javascript\nfunction setTheme(mode /* 'system' | 'light' | 'dark' */) {\n  if (mode === 'system') {\n    localStorage.removeItem('theme');\n    const dark = matchMedia('(prefers-color-scheme: dark)').matches;\n    document.documentElement.classList.toggle('dark', dark);\n  } else {\n    localStorage.setItem('theme', mode);\n    document.documentElement.classList.toggle('dark', mode === 'dark');\n  }\n}\n\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {\n  if (!localStorage.getItem('theme')) {\n    document.documentElement.classList.toggle('dark', e.matches);\n  }\n});\n```\n\n### 9.4 Dark-Mode-Specific Rules\n\n- **Borders go invisible.** In dark mode, a 1px border with `rgba(255, 255, 255, 0.08)` reads cleaner than the inverted neutral. Use a slightly translucent border for surfaces, solid `--border-default` for input fields.\n- **No drop shadows.** Or, dramatically reduce them. In dark mode, elevation is conveyed by *lighter* surface backgrounds, not by darker shadows underneath.\n- **Brand color shifts lighter.** `--brand-fg` becomes `#A5B4FC` (brand-300/400 range) so it has enough contrast against the dark canvas.\n- **Image / chart luminance.** If you render charts/screenshots inside a card, the card background should be `--bg-elevated` (lighter than canvas) so the artwork doesn't appear floating on void.\n\n---\n\n## 10. Accessibility\n\n### 10.1 Color Contrast\n\nMinimum ratios:\n- **Body text on background: 4.5:1** (WCAG AA Normal Text)\n- **Large text (≥18px or 14px bold) on background: 3:1**\n- **UI components (button border, input border, focus ring) against adjacent color: 3:1**\n- **Non-text indicators (icons that convey state): 3:1**\n\nVerify the included palette:\n- `#0F172A` on `#FFFFFF` = 19.3:1 ✓\n- `#475569` (muted) on `#FFFFFF` = 7.5:1 ✓\n- `#94A3B8` (subtle) on `#FFFFFF` = 3.6:1 ✓ (large text / decorative only)\n- `#5E6AD2` (brand) on `#FFFFFF` = 5.7:1 ✓\n- `#FFFFFF` on `#5E6AD2` (brand button) = 5.7:1 ✓\n\n### 10.2 Focus States\n\n```css\n/* Default focus ring for all interactive elements */\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n  border-radius: var(--radius-sm);\n}\n\n/* For elements with their own visual treatment (buttons, inputs), use box-shadow ring */\nbutton:focus-visible,\n[role=\"button\"]:focus-visible,\na:focus-visible {\n  outline: none;\n  box-shadow: var(--ring-focus);\n}\n\ninput:focus-visible,\ntextarea:focus-visible,\nselect:focus-visible {\n  outline: none;\n  border-color: var(--brand-fg);\n  box-shadow: var(--ring-focus);\n}\n```\n\n**Never remove the focus ring without replacing it.** This is the single most common SaaS accessibility regression.\n\n### 10.3 Keyboard Map (every SaaS app should implement)\n\n| Key | Action |\n|---|---|\n| `⌘K` / `Ctrl+K` | Open command palette |\n| `/` | Focus the page search input |\n| `g` then `d` | Go to dashboard (vim-style nav, optional but Linear/GitHub do it) |\n| `?` | Show keyboard shortcut cheat sheet |\n| `Esc` | Close modal, dismiss popover, blur input |\n| `Tab` / `Shift+Tab` | Move focus forward/back |\n| `Enter` | Activate focused button/link; submit form when input focused |\n| `Space` | Toggle checkbox/switch; activate button |\n| `↑` / `↓` | Navigate list items, table rows, menu items |\n| `j` / `k` | Same as ↓ / ↑ (vim-style; optional) |\n\n### 10.4 Semantic Patterns\n\n- Buttons use `<button>`, links use `<a href>`. **Never** use a `<div onClick>` for either.\n- Form fields have a visible `<label>` (or `aria-label` if visually replaced by a placeholder pattern).\n- Required fields: visible `*` marker AND `aria-required=\"true\"`.\n- Error messages: linked via `aria-describedby`, role `alert` for the first error.\n- Tables: `<th scope=\"col\">` headers, optional `<caption>` for context.\n- Modals: trap focus, restore focus to trigger on close, `aria-modal=\"true\"`, `role=\"dialog\"`, labeled by heading.\n- Toasts: `role=\"status\"` for non-critical, `role=\"alert\"` for errors.\n\n### 10.5 Touch Targets\n\nMinimum 44×44px (WCAG 2.5.5). On desktop SaaS, buttons can be 32-36px tall as long as the **clickable hit area** (via padding) reaches 44px.\n\n```css\n.icon-button {\n  position: relative;\n  width: 32px;\n  height: 32px;\n}\n.icon-button::before {\n  content: '';\n  position: absolute;\n  inset: -6px;  /* extends hit area to 44x44 */\n}\n```\n\n### 10.6 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n.sr-only-focusable:focus {\n  position: static;\n  width: auto;\n  height: auto;\n  clip: auto;\n  white-space: normal;\n}\n```\n\nUse `.sr-only` for \"Skip to main content\" links and icon-only button labels.\n\n---\n\n## 11. Decorative Elements (Used Sparingly)\n\nSaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:\n\n### 11.1 Section Dividers\n\n```css\n.divider {\n  height: 1px;\n  background: var(--border-default);\n  margin: 24px 0;\n}\n\n/* Vertical divider (e.g., between toolbar groups) */\n.divider-v {\n  width: 1px;\n  background: var(--border-default);\n  align-self: stretch;\n  margin: 0 8px;\n}\n```\n\n### 11.2 Inline Pills / Status Dots\n\n```css\n/* Status dot (used in tables: ● Active) */\n.status-dot {\n  display: inline-block;\n  width: 8px;\n  height: 8px;\n  border-radius: 9999px;\n  vertical-align: middle;\n  margin-right: 6px;\n}\n.status-dot--success { background: #16A34A; }\n.status-dot--warning { background: #CA8A04; }\n.status-dot--error   { background: #DC2626; }\n.status-dot--neutral { background: #94A3B8; }\n\n/* Pulsing dot for \"live\" / \"syncing\" */\n.status-dot--pulse {\n  position: relative;\n}\n.status-dot--pulse::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: inherit;\n  animation: pulse-ring 1.6s ease-out infinite;\n}\n@keyframes pulse-ring {\n  0%   { transform: scale(1);   opacity: 0.6; }\n  100% { transform: scale(2.5); opacity: 0; }\n}\n```\n\n### 11.3 Empty-State Iconography Placeholder\n\nFor empty states, draw a single 1.5px-stroke line icon at 48–64px size in `--neutral-300`. Do not use 3D illustrations or stock vectors — they break the \"quiet product\" register.\n\n```html\n<div class=\"empty-state\">\n  <svg width=\"48\" height=\"48\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n       stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n       style=\"color: var(--neutral-300);\">\n    <!-- single line-art icon (e.g., a Lucide icon) -->\n  </svg>\n  <h3>No customers yet</h3>\n  <p>Customers appear here after their first successful payment.</p>\n  <button>Create test customer</button>\n</div>\n```\n\n### 11.4 Subtle Background Grid (for empty canvases)\n\nFor dashboard backgrounds, an OPTIONAL very subtle dot grid is acceptable:\n\n```css\n.canvas-grid {\n  background-color: var(--bg-canvas);\n  background-image: radial-gradient(circle, var(--neutral-200) 1px, transparent 1px);\n  background-size: 24px 24px;\n  background-position: 0 0;\n}\n```\n\nOpacity: never above 30%. Most SaaS apps omit this entirely.\n\n### 11.5 Keyboard Shortcut Chips\n\n```css\n.kbd {\n  display: inline-flex;\n  align-items: center;\n  gap: 2px;\n  padding: 2px 6px;\n  font-family: var(--font-mono);\n  font-size: 11px;\n  line-height: 1.2;\n  color: var(--text-muted);\n  background: var(--bg-surface);\n  border: 1px solid var(--border-default);\n  border-bottom-width: 2px;  /* mechanical key look */\n  border-radius: 4px;\n  vertical-align: middle;\n}\n```\n\nUsage: `<span class=\"kbd\">⌘</span><span class=\"kbd\">K</span>` inside tooltips, menu items, command palette.\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border | Use |\n|---|---|---|---|---|\n| `primary` | `--brand-600` | `#FFFFFF` | none | The one primary action per surface |\n| `secondary` | `--bg-surface` | `--text-primary` | `--border-default` 1px | Most actions (\"Cancel\", \"Save\", \"Edit\") |\n| `ghost` | transparent | `--text-primary` | none | Toolbar buttons, table row actions |\n| `destructive` | `#DC2626` | `#FFFFFF` | none | Delete, revoke, irreversible |\n| `destructive-outline` | `--bg-surface` | `#B91C1C` | `#FCA5A5` | Less aggressive destructive |\n| `link` | none | `--brand-600` | none, underline on hover | Inline navigation actions |\n\nSizes: `sm` (28px tall, 12px text), `md` (32px tall, 13px text — **default**), `lg` (40px tall, 14px text).\n\n### Inputs\n\n| State | Border | Background | Shadow |\n|---|---|---|---|\n| default | `--border-default` | `--bg-surface` | none |\n| hover | `--border-strong` | `--bg-surface` | none |\n| focus | `--brand-fg` | `--bg-surface` | `--ring-focus` |\n| error | `#DC2626` | `--bg-surface` | `--ring-error` |\n| disabled | `--border-default` | `--neutral-50` | none, `opacity: 0.6` |\n\nSizes match buttons: `sm` (28px), `md` (32px default), `lg` (40px).\n\n### Cards\n\n| Variant | Background | Border | Padding | Shadow |\n|---|---|---|---|---|\n| default | `--bg-surface` | 1px `--border-default` | 20px | none |\n| interactive | `--bg-surface` | 1px `--border-default` | 20px | hover: `--shadow-sm` |\n| ghost | none | none | 0 | none — for grouping without enclosure |\n| elevated | `--bg-elevated` | none | 24px | `--shadow-md` |\n| outline-only | none | 1px `--border-default` | 16px | none — for placeholder/empty slots |\n\n### Badges / Status Pills\n\n| Variant | Background | Text | Border |\n|---|---|---|---|\n| neutral | `--neutral-100` | `--neutral-700` | none |\n| brand | `--brand-50` | `--brand-700` | none |\n| success | `#DCFCE7` | `#15803D` | none |\n| warning | `#FEF9C3` | `#A16207` | none |\n| error | `#FEE2E2` | `#B91C1C` | none |\n| info | `#DBEAFE` | `#1D4ED8` | none |\n\nSizing: padding `2px 8px`, font 11–12px, weight 500, radius 4px (NOT pill). Optional leading status dot.\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── src/\n│   ├── styles/\n│   │   ├── tokens.css           # All CSS custom properties (one file, single source of truth)\n│   │   ├── base.css             # Reset, html/body, typography defaults, focus\n│   │   └── globals.css          # @import the above + utility classes\n│   ├── components/\n│   │   ├── primitives/          # Lowest-level, design-token-aware\n│   │   │   ├── Button.tsx\n│   │   │   ├── Input.tsx\n│   │   │   ├── Select.tsx\n│   │   │   ├── Checkbox.tsx\n│   │   │   ├── Switch.tsx\n│   │   │   ├── Badge.tsx\n│   │   │   ├── Card.tsx\n│   │   │   ├── Avatar.tsx\n│   │   │   ├── Tooltip.tsx\n│   │   │   ├── Kbd.tsx\n│   │   │   └── Spinner.tsx\n│   │   ├── overlays/            # Floating layers\n│   │   │   ├── Dialog.tsx\n│   │   │   ├── Sheet.tsx\n│   │   │   ├── Popover.tsx\n│   │   │   ├── DropdownMenu.tsx\n│   │   │   ├── Toast.tsx\n│   │   │   └── CommandPalette.tsx\n│   │   ├── data/                # Data display\n│   │   │   ├── DataTable.tsx\n│   │   │   ├── Pagination.tsx\n│   │   │   ├── KpiCard.tsx\n│   │   │   └── EmptyState.tsx\n│   │   ├── forms/\n│   │   │   ├── FormField.tsx\n│   │   │   ├── FormRow.tsx\n│   │   │   └── FormSection.tsx\n│   │   └── shell/               # The app frame\n│   │       ├── AppShell.tsx\n│   │       ├── Sidebar.tsx\n│   │       ├── Topbar.tsx\n│   │       ├── PageHeader.tsx\n│   │       └── Breadcrumb.tsx\n│   └── lib/\n│       ├── theme.ts             # Theme toggle (system/light/dark)\n│       ├── shortcuts.ts         # Global keyboard handler\n│       └── format.ts            # Money, date, relative time helpers\n├── tailwind.config.ts           # Tokens mirrored from tokens.css\n└── tsconfig.json\n```\n\nRules:\n- **`tokens.css` is the single source of truth.** `tailwind.config.ts` reads from it (or duplicates its values, but only as a mirror).\n- **Primitives have no business logic.** They accept props, render styled DOM. State lives in feature components that wrap them.\n- **No component crosses layer boundaries.** A primitive can't import from `shell/`; `shell/` can't import from `data/`.\n\n---\n\n## 14. Full CSS Custom Properties\n\nCopy into `styles/tokens.css`:\n\n```css\n:root {\n  /* ── Brand ── */\n  --brand-50:  #EEF0FB;\n  --brand-100: #DDE0F7;\n  --brand-200: #B9C0EF;\n  --brand-500: #7B85DC;\n  --brand-600: #5E6AD2;\n  --brand-700: #4A56C0;\n  --brand-800: #3A45A8;\n  --brand-900: #2A3284;\n  --brand-fg: var(--brand-600);\n  --brand-bg-subtle: var(--brand-50);\n\n  /* ── Neutrals (slate) ── */\n  --neutral-0:   #FFFFFF;\n  --neutral-50:  #F8FAFC;\n  --neutral-100: #F1F5F9;\n  --neutral-200: #E2E8F0;\n  --neutral-300: #CBD5E1;\n  --neutral-400: #94A3B8;\n  --neutral-500: #64748B;\n  --neutral-600: #475569;\n  --neutral-700: #334155;\n  --neutral-800: #1E293B;\n  --neutral-900: #0F172A;\n  --neutral-950: #020617;\n\n  /* ── Semantic surfaces ── */\n  --bg-canvas:   var(--neutral-50);\n  --bg-surface:  var(--neutral-0);\n  --bg-elevated: var(--neutral-0);\n  --bg-hover:    var(--neutral-100);\n\n  --text-primary: var(--neutral-900);\n  --text-muted:   var(--neutral-600);\n  --text-subtle:  var(--neutral-400);\n\n  --border-default: var(--neutral-200);\n  --border-strong:  var(--neutral-300);\n\n  /* ── State ── */\n  --success-fg:  #15803D;\n  --success-bg:  #DCFCE7;\n  --warning-fg:  #A16207;\n  --warning-bg:  #FEF9C3;\n  --error-fg:    #B91C1C;\n  --error-bg:    #FEE2E2;\n  --info-fg:     #1D4ED8;\n  --info-bg:     #DBEAFE;\n\n  /* ── Chart palette ── */\n  --chart-1: #2563EB;\n  --chart-2: #16A34A;\n  --chart-3: #CA8A04;\n  --chart-4: #DC2626;\n  --chart-5: #9333EA;\n  --chart-6: #0891B2;\n  --chart-7: #DB2777;\n  --chart-8: #65A30D;\n\n  /* ── Typography ── */\n  --font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n               \"SF Pro Text\", system-ui, sans-serif;\n  --font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n               ui-monospace, monospace;\n\n  /* ── Spacing (multiples of 4) ── */\n  --space-unit: 4px;\n\n  /* ── Radius ── */\n  --radius-none: 0;\n  --radius-sm:   4px;\n  --radius-md:   6px;\n  --radius-lg:   8px;\n  --radius-xl:   12px;\n  --radius-2xl:  16px;\n  --radius-full: 9999px;\n\n  /* ── Shadows ── */\n  --shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n  --shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06);\n  --shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04);\n  --shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08);\n  --shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10);\n  --shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n  --ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n  --ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n  /* ── Motion ── */\n  --ease-out:        cubic-bezier(0.16, 1, 0.3, 1);\n  --ease-out-back:   cubic-bezier(0.34, 1.56, 0.64, 1);\n  --ease-in-out:     cubic-bezier(0.4, 0, 0.2, 1);\n  --duration-75:  75ms;\n  --duration-100: 100ms;\n  --duration-150: 150ms;\n  --duration-200: 200ms;\n  --duration-250: 250ms;\n  --duration-300: 300ms;\n\n  /* ── Layout ── */\n  --container-sm:  640px;\n  --container-md:  768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1536px;\n  --sidebar-w:        256px;\n  --sidebar-w-collapsed: 56px;\n  --topbar-h:         48px;\n}\n\n.dark {\n  --bg-canvas:   #0A0E1A;\n  --bg-surface:  #111827;\n  --bg-elevated: #1F2937;\n  --bg-hover:    rgba(255, 255, 255, 0.04);\n\n  --text-primary: #F1F5F9;\n  --text-muted:   #94A3B8;\n  --text-subtle:  #64748B;\n\n  --border-default: #1F2937;\n  --border-strong:  #374151;\n\n  --brand-fg:         #A5B4FC;\n  --brand-bg-subtle:  rgba(94, 106, 210, 0.16);\n\n  --success-fg: #4ADE80;\n  --success-bg: rgba(22, 163, 74, 0.16);\n  --warning-fg: #FACC15;\n  --warning-bg: rgba(202, 138, 4, 0.16);\n  --error-fg:   #F87171;\n  --error-bg:   rgba(220, 38, 38, 0.16);\n  --info-fg:    #60A5FA;\n  --info-bg:    rgba(37, 99, 235, 0.16);\n\n  --shadow-xs:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n  --shadow-sm:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n  --shadow-md:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n  --shadow-lg:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n  --shadow-xl:  0 20px 32px -10px rgba(0, 0, 0, 0.60);\n  --shadow-2xl: 0 30px 60px -15px rgba(0, 0, 0, 0.70);\n}\n\n/* ── Base ── */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1, \"cv11\" 1, \"ss01\" 1;\n}\n\nbody {\n  margin: 0;\n  background: var(--bg-canvas);\n  color: var(--text-primary);\n  font-family: var(--font-sans);\n  font-size: 14px;\n  line-height: 1.43;\n}\n\n.numeric, table, [data-numeric=\"true\"] {\n  font-variant-numeric: tabular-nums;\n}\n\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Implementation Physics**\n> Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Density Tier\n\n| Tier | Row Height | Button Height | Default Text | When |\n|---|---|---|---|---|\n| **Compact** (Linear, dev tools) | 32px | 28px | 13px | Power-user surfaces, data-heavy tables |\n| **Default** (Stripe, Vercel) | 40px | 32px | 14px | The default SaaS product UI |\n| **Comfortable** (Notion, Clerk) | 48px | 36px | 15px | Content-heavy, settings, billing |\n| **Spacious** (Auth, onboarding) | 56px+ | 40px+ | 16px | Single-purpose conversion pages |\n\n**Pick one tier per product, not per page.** Mixing density breaks the rhythm.\n\n#### Layout Constants\n\n```\nContent width:\n├── Tables / lists:       full bleed within --container-2xl (1536px)\n├── Dashboards:           --container-xl (1280px) centered\n├── Settings forms:       --container-md (768px) — readable column\n└── Auth / onboarding:    --container-sm (640px) centered\n\nPage padding (X):\n├── Mobile:    16px (px-4)\n├── Tablet:    24px (px-6)\n└── Desktop:   32px (px-8)\n\nPage padding (Y):\n├── Page header → content:     16-24px\n├── Section → section:         32-40px\n└── Last section → page end:   64px (so footer doesn't crowd)\n\nGrid gaps:\n├── KPI cards:           12px\n├── Card grids:          16px (sm) → 20px (md) → 24px (lg)\n├── Form fields:         16px (tight) or 20px (default)\n└── Settings rows:       0 — separated by 1px bottom border\n```\n\n#### The \"Three Heights\" Rule\n\nIn a SaaS UI, three vertical rhythms coexist:\n\n1. **Page rhythm**: 24px / 32px / 48px between page sections.\n2. **Component rhythm**: 12px / 16px / 20px inside cards and panels.\n3. **Inline rhythm**: 4px / 6px / 8px between adjacent elements (icon + label, badge + text).\n\nEach rhythm is a multiple of 4 but **does not reuse** the same numbers from another rhythm. Inline gaps stay ≤8px, component gaps stay 12–20px, page gaps stay ≥24px. This is what makes a SaaS UI feel \"organized\" without conscious effort.\n\n### 15.2 Color Science & Elevation\n\n#### How elevation is conveyed (without heavy shadows)\n\n| Layer | Light Mode | Dark Mode |\n|---|---|---|\n| Canvas | `#F8FAFC` (1 step down from white) | `#0A0E1A` |\n| Surface (default card) | `#FFFFFF` + 1px border | `#111827` + 1px `rgba(255,255,255,0.04)` border |\n| Elevated (popover) | `#FFFFFF` + `--shadow-md` | `#1F2937` + low shadow |\n| Modal | `#FFFFFF` + `--shadow-xl` + backdrop blur | `#1F2937` + `--shadow-xl` + backdrop blur |\n\nIn light mode, elevation = subtle shadow + 1px border.\nIn dark mode, elevation = LIGHTER background (the higher the layer, the lighter the bg) + nearly-invisible shadow.\n\n#### Brand color is \"interaction language\"\n\n- Brand color = \"this is interactive, this is selected, this is focused.\"\n- Anything else uses neutrals.\n- Status colors (success/error/etc.) are ONLY for status — never for buttons that don't represent that status.\n\nA useful test: **squint at the UI**. You should see 90% gray-on-white, with brand pinpricks marking exactly the interactive surface you'd click.\n\n#### Selected-state convention\n\n| State | Convention |\n|---|---|\n| Hover | Background shifts by 1 step (`--bg-surface` → `--bg-hover`) |\n| Active (pressed) | Background shifts by 2 steps OR scale(0.98) |\n| Selected (persistent) | `--brand-bg-subtle` background + `--brand-fg` text. **No border change.** |\n| Focused (keyboard) | `--ring-focus` box-shadow ring. Independent of hover/selected. |\n| Disabled | `opacity: 0.5` + `cursor: not-allowed`. Color unchanged. |\n\n### 15.3 Typography & Micro-Copy\n\n#### The \"13/14 default\" rule\n\nThe default body/UI size is **13px or 14px**. Headings step up modestly (no 48px headlines in a dashboard). The biggest text in a typical product page is the H1 at 24–30px.\n\n| Element | Size | Weight |\n|---|---|---|\n| H1 (page title) | 24px | 600 |\n| H2 (section) | 18px | 600 |\n| H3 (card heading) | 15-16px | 600 |\n| Body / table cell | 13-14px | 400 |\n| Label / button | 13-14px | 500 |\n| Microlabel / caption | 11-12px | 500 (uppercase optional) |\n| Mono (id, code, money) | 12-13px | 400 |\n\n#### Micro-copy patterns\n\n**Buttons** — verb + object, never just verb:\n- ✅ \"Create customer\", \"Save changes\", \"Delete invoice\"\n- ❌ \"Submit\", \"OK\", \"Click here\"\n\n**Empty state headings** — declarative, not interrogative:\n- ✅ \"No active subscriptions\"\n- ❌ \"You don't have any subscriptions yet, would you like to create one?\"\n\n**Inline help / hints** — single sentence, no period (because it's not a sentence in form context):\n- ✅ \"Used for receipts and customer emails\"\n- ❌ \"This email will be used for receipts and customer emails.\"\n\n**Destructive confirmation** — type-to-confirm for irreversible:\n- \"Type `acme-corp` to confirm deletion\" — see GitHub's repo deletion modal.\n\n**Toast text** — past tense, with optional action:\n- ✅ \"Invoice sent. [Undo]\"\n- ❌ \"Sending invoice…\" (use a loading state for in-flight)\n\n### 15.4 Component Anatomy\n\n#### Button (the most-touched primitive)\n\n```\n┌────────────────────────────────────┐\n│  [icon] [label]            [⌘K]    │   ← gap 8px between icon/label, 12px to shortcut\n└────────────────────────────────────┘\n   ↑ padding 12-14px              ↑ padding 12-14px\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ font: 13px / 500\n```\n\nVariants:\n- `primary` — solid brand bg, white text. ONE per surface.\n- `secondary` — surface bg, neutral border, primary text. Most actions.\n- `ghost` — no bg, no border. Toolbar / row actions.\n- `destructive` — solid red bg. Reserved for irreversible.\n\nStates:\n- default → hover (bg shifts) → active (bg shifts more / scale 0.98) → focus (ring) → disabled (opacity 0.5)\n\nForbidden:\n- Pill radius (`9999px`) — that's marketing\n- Gradient background — that's marketing\n- Drop shadow — buttons are flat against the surface\n\n#### Input\n\n```\n┌────────────────────────────────────┐\n│  [icon] placeholder text      [×]  │   ← optional leading icon, trailing clear\n└────────────────────────────────────┘\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ border: 1px --border-default\n   ─ padding: 0 12px (or 0 36px when icon present)\n   ─ font: 14px / 400\n```\n\nFocus state: border → `--brand-fg`, add `--ring-focus` box-shadow. **Never use solid background change for focus** — borders + ring are the convention.\n\nError state: border → `#DC2626`, ring → `--ring-error`. Show error message below input in `#B91C1C`, 12px, with `aria-describedby` linking.\n\n#### Card\n\n```\n┌──────────────────────────────────┐\n│  Heading            [actions]    │   ← header row, 16-20px from edges\n│  Optional description            │\n├──────────────────────────────────┤   ← 1px divider OR padding gap\n│                                  │\n│  Card body                       │\n│                                  │\n└──────\n\n<!-- [TRUNCATED AT 50KB] -->","frontmatter":{"name":"frontend-design-saas","description":"S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear."},"isInternal":false,"tokens":16162,"sizeBytes":52400},{"name":"SKILL.md","path":"worker/agents/think/skills/frontend-design/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/worker/agents/think/skills/frontend-design/SKILL.md","title":"frontend-design","category":"anthropic-skill","format":"markdown","content":"---\nname: frontend-design\ndescription: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.\nlicense: Complete terms in LICENSE.txt\n---\n\nThis skill guides creation of distinctive, production-grade frontend interfaces that avoid generic \"AI slop\" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.\n\nThe user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.\n\n## Design Thinking\n\nBefore coding, understand the context and commit to a BOLD aesthetic direction:\n- **Purpose**: What problem does this interface solve? Who uses it?\n- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.\n- **Constraints**: Technical requirements (framework, performance, accessibility).\n- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?\n\n**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.\n\nThen implement working code (HTML/CSS/JS, React, Vue, etc.) that is:\n- Production-grade and functional\n- Visually striking and memorable\n- Cohesive with a clear aesthetic point-of-view\n- Meticulously refined in every detail\n\n## Frontend Aesthetics Guidelines\n\nFocus on:\n- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.\n- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.\n- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.\n- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.\n- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.\n\nNEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.\n\nInterpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.\n\n**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.\n\nRemember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.","frontmatter":{"name":"frontend-design","description":"Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.","license":"Complete terms in LICENSE.txt"},"isInternal":false,"tokens":858,"sizeBytes":4439},{"name":"README.md","path":"packages/artifacts-viewer/.changeset/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/packages/artifacts-viewer/.changeset/README.md","title":".changeset Documentation","category":"plugin-manifest","format":"markdown","content":"# Change intents\n\nRun `pnpm change` for every change that affects the published package. Commit the generated Markdown file with the code change.\n\nDocumentation, tests, and changes limited to private workspace applications do not require an intent.\n","isInternal":false,"tokens":46,"sizeBytes":249},{"name":"README.md","path":"packages/artifacts-viewer/packages/artifacts-viewer/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/packages/artifacts-viewer/packages/artifacts-viewer/README.md","title":"artifacts-viewer Documentation","category":"plugin-manifest","format":"markdown","content":"# artifacts-viewer\n\nA read-only repository browser for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/). Ships a server-side proxy, a typed HTTP client, and unstyled React components.\n\nYour Cloudflare API token stays on the server. The browser only ever talks to your own origin.\n\n> Pre-1.0. The API is still moving; pin an exact version.\n\n## Contents\n\n- [artifacts-viewer](#artifacts-viewer)\n  - [Contents](#contents)\n  - [How it works](#how-it-works)\n    - [Request flow](#request-flow)\n  - [Install](#install)\n  - [Quick start](#quick-start)\n    - [1. Mount the router](#1-mount-the-router)\n    - [2. Render the viewer](#2-render-the-viewer)\n  - [`artifacts-viewer` — server router](#artifacts-viewer--server-router)\n    - [Authorization](#authorization)\n    - [Response behaviour](#response-behaviour)\n    - [CORS](#cors)\n  - [`artifacts-viewer/server/cache` — cache adapters](#artifacts-viewerservercache--cache-adapters)\n  - [`artifacts-viewer/client` — typed client](#artifacts-viewerclient--typed-client)\n  - [`artifacts-viewer/react` — components and hooks](#artifacts-viewerreact--components-and-hooks)\n    - [Components](#components)\n      - [`renderStatus` — loading, empty, and error states](#renderstatus--loading-empty-and-error-states)\n    - [Hooks](#hooks)\n    - [Building your own UI](#building-your-own-ui)\n  - [Styling](#styling)\n    - [Custom properties](#custom-properties)\n    - [Selector hooks](#selector-hooks)\n  - [Syntax highlighting](#syntax-highlighting)\n    - [Loading](#loading)\n      - [`renderCodeFallback` — what the wait looks like](#rendercodefallback--what-the-wait-looks-like)\n      - [`preloadCodeView` — how long the wait is](#preloadcodeview--how-long-the-wait-is)\n    - [Theming](#theming)\n  - [Limits and behaviour](#limits-and-behaviour)\n  - [Not included](#not-included)\n  - [License](#license)\n\n## How it works\n\n```\nbrowser                    your server                     Cloudflare\n┌──────────────────┐      ┌──────────────────────┐      ┌──────────────┐\n│ArtifactRepoViewer│      │ routeArtifactRequest │      │ Artifacts    │\n│        ↓         │─────▶│  · validates         │─────▶│ REST API     │\n│ ArtifactsClient  │      │  · adds API token    │      │              │\n└──────────────────┘      │  · optional cache    │      └──────────────┘\n     GET /artifacts/…     └──────────────────────┘        Bearer token\n```\n\nThe router mirrors Cloudflare's namespace-relative paths 1:1 and proxies **only** these seven reads. `accountId` and `namespace` are deliberately absent from the browser-facing URL.\n\n| Mounted route                                        | Purpose                                     |\n| ---------------------------------------------------- | ------------------------------------------- |\n| `GET {apiPath}/repos/{repo}`                         | Repository metadata                         |\n| `GET {apiPath}/repos/{repo}/log?ref=&limit=&offset=` | Commit log                                  |\n| `GET {apiPath}/repos/{repo}/commit/{hash}`           | One commit                                  |\n| `GET {apiPath}/repos/{repo}/tree/{hash}`             | One directory level                         |\n| `GET {apiPath}/repos/{repo}/blob/{hash}`             | File bytes by hash                          |\n| `GET {apiPath}/repos/{repo}/file?ref=&path=`         | File bytes by path                          |\n| `GET {apiPath}/repos/{repo}/raw/{ref}/{path}`        | File bytes with a browser-safe content type |\n\nNothing else is reachable. Writes, repository creation, and token endpoints are not proxied at all.\n\n### Request flow\n\nRendering a repository takes two round trips, then goes content-addressed:\n\n```\ngetLog({ limit: 1 })       →  commits[0].treeHash    (ref omitted = default branch)\nreadTree(commit.treeHash)  →  root entries\n```\n\nAfter that every navigation is addressed by git hash — `readTree(entry.hash)` for a directory, `readBlob(entry.hash)` for a file — so responses are immutable and cacheable forever.\n\n## Install\n\n```bash\nnpm install artifacts-viewer\n# or\npnpm install artifacts-viewer\n# or\nbun install artifacts-viewer\n```\n\n`react` and `react-dom` are peer dependencies (`^18.2.0 || ^19.0.0`). `@pierre/diffs` is a direct dependency. It is split from the initial bundle and proactively prefetched when `ArtifactRepoViewer` mounts.\n\n## Quick start\n\n### 1. Mount the router\n\n> **The router allows every request by default.** Add a `beforeRequest` policy before exposing this endpoint; otherwise anyone who can reach it can read any valid repository name in the configured namespace.\n\n```ts\n// worker/index.ts\nimport { routeArtifactRequest } from \"artifacts-viewer\";\nimport { createCacheApiAdapter } from \"artifacts-viewer/server/cache\";\n\nexport default {\n  async fetch(request, env, ctx) {\n    const handled = await routeArtifactRequest(request, {\n      accountId: env.ARTIFACTS_ACCOUNT_ID,\n      namespace: env.ARTIFACTS_NAMESPACE,\n      apiToken: env.ARTIFACTS_API_TOKEN,\n      beforeRequest: async ({ request, read }) => {\n        const user = await getSessionUser(request);\n        if (user === null || !user.repositories.includes(read.repoName)) {\n          return new Response(\"Forbidden\", { status: 403 });\n        }\n      },\n      cache: createCacheApiAdapter({\n        cache: caches.default,\n        baseUrl: new URL(request.url).origin,\n      }),\n      waitUntil: (promise) => {\n        ctx.waitUntil(promise);\n      },\n    });\n\n    return handled ?? new Response(\"Not found\", { status: 404 });\n  },\n} satisfies ExportedHandler<Env>;\n```\n\n`routeArtifactRequest` returns `null` when the request is outside `apiPath`, so you can fall through to your own routing.\n\nIf you serve static assets from the same Worker, route the API to the Worker first or the asset handler will answer instead:\n\n```jsonc\n// wrangler.jsonc\n{\n  \"assets\": {\n    \"not_found_handling\": \"single-page-application\",\n    \"run_worker_first\": [\"/artifacts/*\"],\n  },\n}\n```\n\n### 2. Render the viewer\n\n```tsx\nimport { createArtifactsClient } from \"artifacts-viewer/client\";\nimport { ArtifactRepoViewer } from \"artifacts-viewer/react\";\nimport \"artifacts-viewer/styles.css\";\n\n// Module scope: the hooks treat `client` as a dependency, so a new instance\n// per render would refetch forever.\nconst client = createArtifactsClient();\n\nexport function Repository() {\n  return <ArtifactRepoViewer client={client} repoName=\"my-repository\" />;\n}\n```\n\n## `artifacts-viewer` — server router\n\nReact-free and platform-neutral. Uses only `Request`, `Response`, `Headers`, `URL`, and `fetch`, so it runs on Workers, Node, Deno, or Bun.\n\n```ts\nfunction routeArtifactRequest(\n  request: Request,\n  options: ArtifactRouterOptions,\n): Promise<Response | null>;\n```\n\n| Option          | Type                            | Notes                                                     |\n| --------------- | ------------------------------- | --------------------------------------------------------- |\n| `accountId`     | `string`                        | Required                                                  |\n| `namespace`     | `string`                        | Required                                                  |\n| `apiToken`      | `string`                        | Required. Cloudflare API token with Artifacts read access |\n| `apiPath`       | `string`                        | Default `\"/artifacts\"`. Matched on a segment boundary     |\n| `fetch`         | `typeof fetch`                  | Optional injection point for tests and tracing            |\n| `beforeRequest` | `ArtifactBeforeRequestHook`     | Authorization hook. Return a `Response` to deny           |\n| `cache`         | `ArtifactsCacheAdapter`         | Consulted for content-addressed reads only                |\n| `waitUntil`     | `(p: Promise<unknown>) => void` | Keeps cache writes alive past the response                |\n| `onCacheError`  | `(error: unknown) => void`      | Cache failures are reported here, never thrown            |\n\n### Authorization\n\n`beforeRequest` runs **before** the cache, so a cache hit can never bypass it.\n\n```ts\nbeforeRequest: async ({ request, read, operation }) => {\n  if (!(await mayRead(request, read.repoName))) {\n    return new Response(\"Forbidden\", { status: 403 });\n  }\n},\n```\n\n`read` is a fully validated discriminated union, so `read.repoName`, `read.hash`, `read.ref`, and `read.path` are safe to use in a policy decision.\n\n### Response behaviour\n\n- Outside `apiPath` → `null`.\n- Inside `apiPath` but not a known route → `404`.\n- Known route, wrong method → `405` with an `Allow` header.\n- Malformed argument → `400`. Nothing is sent upstream.\n- Errors use the Cloudflare v4 envelope, so the client has one parser.\n\nOutbound requests are built from scratch with only `Accept` and `Authorization`. A caller's `Authorization`, `Cookie`, and every other inbound header are dropped. Response headers are copied through an allowlist (`Content-Type`, `Content-Length`, `Content-Disposition`, `ETag`, `Last-Modified`); bodies stream and are never buffered.\n\n### CORS\n\nNot handled. You own the response, so add headers after the call. Note that `OPTIONS` returns `405`, so intercept preflight before delegating:\n\n```ts\nif (request.method === \"OPTIONS\") return myPreflight(request);\n```\n\n## `artifacts-viewer/server/cache` — cache adapters\n\nOnly content-addressed reads (`commit`, `tree`, `blob`) are ever cached. Their values cannot become semantically stale, but the bundled Cache API and KV adapters use one-year freshness or expiration settings and either backend may evict an entry earlier. Ref-addressed reads are never cached.\n\n```ts\ntype ArtifactsCacheAdapter = {\n  get(key: string): Promise<Response | undefined>;\n  set(key: string, response: Response): Promise<void>;\n};\n```\n\n```ts\nimport { createCacheApiAdapter, createKvCacheAdapter } from \"artifacts-viewer/server/cache\";\n\ncreateCacheApiAdapter({ cache: caches.default, baseUrl: new URL(request.url).origin });\ncreateKvCacheAdapter({ kv: env.ARTIFACTS_CACHE });\n```\n\n`createCacheApiAdapter` is the recommended backend: it streams, so large blobs never sit in memory. `baseUrl` must be an origin your Worker actually serves — the Cache API is keyed by URL and scoped to the zone.\n\nAdapters may throw. A failing cache degrades to a slower request, never a failed one.\n\n## `artifacts-viewer/client` — typed client\n\nFramework-independent and free of Worker globals.\n\n```ts\nconst client = createArtifactsClient({\n  apiPath: \"/artifacts\", // must match the router\n  fetch: myFetch, // optional\n});\n```\n\nEvery asynchronous request method returns a result union rather than throwing. The synchronous `getRawUrl` helper returns a string:\n\n```ts\ntype ArtifactsResult<T> = { ok: true; value: T } | { ok: false; error: ArtifactsClientError };\n\ntype ArtifactsClientError =\n  | { kind: \"network\"; message: string; cause: unknown }\n  | { kind: \"not-found\"; message: string }\n  | { kind: \"http\"; message: string; status: number }\n  | { kind: \"malformed\"; message: string };\n```\n\n| Method                                                 | Returns                     |\n| ------------------------------------------------------ | --------------------------- |\n| `getRepository({ repoName, signal? })`                 | `ArtifactsRepository`       |\n| `getLog({ repoName, ref?, limit?, offset?, signal? })` | `ArtifactsCommitMetadata[]` |\n| `readCommit({ repoName, hash, signal? })`              | `ArtifactsCommitMetadata`   |\n| `readTree({ repoName, hash, signal? })`                | `ArtifactsTreeEntry[]`      |\n| `readBlob({ repoName, hash, signal? })`                | `Response`                  |\n| `readFile({ repoName, ref, path, signal? })`           | `Response`                  |\n| `getRawUrl({ repoName, ref, path })`                   | `string` (synchronous)      |\n\nBinary reads hand back the `Response` untouched, so you can stream it, size-check it, or discard it. Nothing is buffered or base64-encoded.\n\nJSON payloads are narrowed field by field at the boundary — there are no type assertions — and repository metadata is normalized from the wire's snake_case to camelCase.\n\n```ts\ntype ArtifactsTreeEntry = {\n  name: string;\n  mode: string;\n  hash: string;\n  type: \"tree\" | \"blob\" | \"symlink\" | \"gitlink\" | \"exec\";\n};\n```\n\n`exec` and `symlink` behave like blobs. `gitlink` is a submodule pointer with no content in this repository, and renders as an inert row.\n\n## `artifacts-viewer/react` — components and hooks\n\n### Components\n\n| Component               | Purpose                                                  |\n| ----------------------- | -------------------------------------------------------- |\n| `ArtifactRepoViewer`    | Two-pane browser: sidebar tree plus content pane         |\n| `ArtifactFileTree`      | Lazily expanding sidebar tree                            |\n| `ArtifactDirectoryView` | Flat listing of one directory                            |\n| `ArtifactFileView`      | One file, with Raw/Download and content rendering        |\n| `CodeView`              | Syntax-highlighted text, given contents you already have |\n\nIn `ArtifactRepoViewer` the content pane shows a file when one is selected, and a listing for any directory below the root. The root itself is left blank, because the sidebar already lists it and a second copy reads as a duplicate. `ArtifactDirectoryView` has no such rule — used directly, it lists whatever tree you give it.\n\n`ArtifactRepoViewer` props:\n\n| Prop                  | Type                                    | Notes                                                             |\n| --------------------- | --------------------------------------- | ----------------------------------------------------------------- |\n| `client`              | `ArtifactsClient`                       | Required. Keep the identity stable                                |\n| `repoName`            | `string`                                | Required                                                          |\n| `gitRef`              | `string`                                | Branch, tag, or commit. Omitted resolves the default branch       |\n| `onSelect`            | `(s: ArtifactSelection) => void`        | Fires initially for root, then for each non-gitlink row selection |\n| `buildHref`           | `(s: ArtifactSelection) => string`      | When set, rows render as `<a>` instead of `<button>`              |\n| `icons`               | `Partial<ArtifactIconSlots>`            | `file`, `folder`, `folderOpen`, `submodule`                       |\n| `className` / `style` | —                                       | Applied to the root                                               |\n| `classNames`          | `Partial<Record<ArtifactSlot, string>>` | Per-slot classes                                                  |\n| `colorMode`           | `\"light\" \\| \"dark\" \\| \"system\"`         | Emitted as `data-mode`                                            |\n| `maxInlineBytes`      | `number`                                | Default `524288` (512 KiB)                                        |\n| `pierreDiffsOptions`  | `ArtifactPierreDiffsOptions`            | Code-view theming                                                 |\n| `renderCodeFallback`  | `ArtifactCodeFallbackRenderer`          | Placeholder shown while the code view is prepared                 |\n| `renderStatus`        | `ArtifactStatusRenderers`               | Replaces the loading, empty, and error markup                     |\n\nThe prop is `gitRef`, not `ref`, because React reserves `ref`.\n\n#### `renderStatus` — loading, empty, and error states\n\nBy default every pane renders a plain `<p>` with English text: `Loading repository…`,\n`This directory is empty.`, `Binary file (12.4 KiB).`, and so on. `renderStatus`\nreplaces that markup, which is also how you localise the copy.\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"website\"\n  renderStatus={{\n    loading: (context) => <Spinner label={context.scope} />,\n    empty: (context, kind) => <Notice kind={kind} scope={context.scope} />,\n    error: (context, error) => <Notice tone=\"danger\">{error.message}</Notice>,\n  }}\n/>\n```\n\nEvery renderer receives a discriminated context, so one function can speak\ndifferently per pane:\n\n```ts\ntype ArtifactStatusContext =\n  | { scope: \"repository\"; repoName: string }\n  | { scope: \"tree\"; repoName: string; path: string }\n  | { scope: \"file\"; repoName: string; path: string; name: string };\n\ntype ArtifactEmptyKind = \"empty\" | \"binary\" | \"oversized\";\n\ntype ArtifactStatusRenderers = {\n  loading?: (context: ArtifactStatusContext) => ReactNode;\n  empty?: (context: ArtifactStatusContext, kind?: ArtifactEmptyKind) => ReactNode;\n  error?: (context: ArtifactStatusContext, error: ArtifactsClientError) => ReactNode;\n};\n```\n\n`kind` is only set for files, because a repository or directory is already\nidentified by `context.scope`.\n\nThree things worth knowing:\n\n- **The map is partial.** A renderer returning `undefined` — including one you\n  simply did not name — keeps the default. Return `null` to render nothing.\n- **The slot survives.** Your output is wrapped in the matching slot element, so\n  `data-artifacts-viewer-slot`, `aria-busy`, `role=\"alert\"`, and `data-kind` are\n  still there to target and still announced. You do not repeat them.\n- **`renderStatus` is also accepted by** `ArtifactFileTree`, `ArtifactDirectoryView`,\n  and `ArtifactFileView`, so it works when you compose the pieces yourself.\n\nThe code view is deliberately not covered by `renderStatus`: it waits on the\nhighlighter rather than the network, and [`renderCodeFallback`](#rendercodefallback--what-the-wait-looks-like)\nalready owns that placeholder.\n\n### Hooks\n\nHooks return a discriminated state union — no `isLoading` booleans:\n\n```ts\ntype ArtifactQueryState<T> =\n  | { status: \"idle\" }\n  | { status: \"loading\" }\n  | { status: \"success\"; data: T }\n  | { status: \"error\"; error: ArtifactsClientError };\n```\n\n| Hook                                                                 | Returns                                     |\n| -------------------------------------------------------------------- | ------------------------------------------- |\n| `useArtifactRepository(client, repoName)`                            | `ArtifactsRepository`                       |\n| `useArtifactLog(client, { repoName, ref?, limit?, offset? })`        | `ArtifactsCommitMetadata[]`                 |\n| `useArtifactHeadCommit(client, repoName, ref?)`                      | `ArtifactsCommitMetadata \\| null`           |\n| `useArtifactTree(client, repoName, treeHash \\| null)`                | `ArtifactsTreeEntry[]`                      |\n| `useArtifactBlob(client, { repoName, name, hash, maxInlineBytes? })` | `ArtifactBlobRender`                        |\n| `useArtifactQuery(run, deps)`                                        | Anything; the primitive the others build on |\n\n**There is no cache and no request deduplication.** One request per dependency change, cancelled via `AbortSignal` when dependencies change or the component unmounts. Caching belongs to your data layer; pass a `fetch` that consults it, or wrap the client.\n\n`useArtifactHeadCommit` resolves `null` for an empty repository. Do not use `lastPushAt` for this — the Artifacts API always returns `null` there.\n\n`useArtifactBlob` produces the render classification:\n\n```ts\ntype ArtifactBlobRender =\n  | { kind: \"empty\" }\n  | { kind: \"text\"; contents: string }\n  | { kind: \"image\"; contentType: string }\n  | { kind: \"binary\"; sizeBytes: number }\n  | { kind: \"oversized\" };\n```\n\n### Building your own UI\n\nThe components are composable and customizable, and the hooks are public, so you can replace the markup entirely. `ArtifactRepoViewer` owns its current selection internally.\n\n```tsx\nfunction MyTree({ client, repoName, treeHash }) {\n  const tree = useArtifactTree(client, repoName, treeHash);\n  if (tree.status !== \"success\") return null;\n  return (\n    <ul>\n      {tree.data.map((e) => (\n        <li key={e.hash}>{e.name}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\n## Styling\n\n`artifacts-viewer/styles.css` is **structural only**. It picks no theme, and its main layout values are exposed as custom properties. Its selectors are intentionally low-specificity, using `:where()` for public hooks where practical, so ordinary application rules can override them without `!important`.\n\nImport it once:\n\n```ts\nimport \"artifacts-viewer/styles.css\";\n```\n\n### Custom properties\n\n| Property                                | Default                  |\n| --------------------------------------- | ------------------------ |\n| `--artifacts-viewer-color`              | `inherit`                |\n| `--artifacts-viewer-background`         | `transparent`            |\n| `--artifacts-viewer-font`               | `inherit`                |\n| `--artifacts-viewer-font-size`          | `inherit`                |\n| `--artifacts-viewer-mono-font`          | `monospace`              |\n| `--artifacts-viewer-muted-color`        | `inherit`                |\n| `--artifacts-viewer-error-color`        | `inherit`                |\n| `--artifacts-viewer-icon-color`         | `currentColor`           |\n| `--artifacts-viewer-toolbar-background` | `inherit`                |\n| `--artifacts-viewer-gap`                | `0.5rem`                 |\n| `--artifacts-viewer-indent`             | `1rem`                   |\n| `--artifacts-viewer-row-padding`        | `0.375rem 0.75rem`       |\n| `--artifacts-viewer-sidebar-width`      | `16rem`                  |\n| `--artifacts-viewer-pane-height`        | `none`                   |\n| `--artifacts-viewer-pane-overflow`      | `visible`                |\n| `--artifacts-viewer-image-max-height`   | `80vh`                   |\n| `--artifacts-viewer-focus-outline`      | `2px solid currentColor` |\n| `--artifacts-viewer-focus-offset`       | `-2px`                   |\n\n```css\n.my-viewer {\n  --artifacts-viewer-font: ui-monospace, monospace;\n  --artifacts-viewer-sidebar-width: 18rem;\n  --artifacts-viewer-pane-height: 32rem;\n  --artifacts-viewer-pane-overflow: auto;\n  --artifacts-viewer-muted-color: #6b7280;\n}\n```\n\n### Selector hooks\n\nPublic slots and parts carry stable attributes. Slots also accept a class through `classNames`; internal wrapper elements are not part of this selector contract.\n\n`data-artifacts-viewer-slot`: `root`, `toolbar`, `sidebar`, `tree`, `treeItem`, `content`, `directory`, `directoryItem`, `file`, `loading`, `empty`, `error`\n\n`data-artifacts-viewer-part`: `header`, `name`, `icon`, `actions`, `raw`, `download`, `code`, `code-pending`, `code-fallback`, `code-highlight`, `image`\n\nState attributes:\n\n| Attribute                    | Where                                                                       |\n| ---------------------------- | --------------------------------------------------------------------------- |\n| `data-artifacts-viewer-root` | The root element                                                            |\n| `data-mode`                  | Root; mirrors `colorMode`                                                   |\n| `data-kind`                  | Rows (`tree`, `blob`, `exec`, `symlink`, `gitlink`), and error/notice kinds |\n| `data-selected`              | The selected row                                                            |\n| `data-disabled`              | Submodule rows                                                              |\n\n`data-selected` is a **bare** attribute, so match `[data-selected]` and not `[data-selected=\"true\"]`:\n\n```css\n[data-artifacts-viewer-slot=\"treeItem\"][data-selected] {\n  background: #eef2ff;\n}\n```\n\nThe layout is container-query driven: the two panes collapse to one column below `40rem` of the root's own width, not the viewport's.\n\n## Syntax highlighting\n\nText files render through [`@pierre/diffs`](https://www.npmjs.com/package/@pierre/diffs), which wraps Shiki. It is loaded with a dynamic `import()`, so neither Pierre nor Shiki lands in your initial bundle. `ArtifactRepoViewer` proactively starts fetching the chunk when it mounts.\n\n### Loading\n\nUnhighlighted text is never shown while highlighting is still on its way. Pierre paints an empty node if it is mounted before its theme and grammar are attached, so the code view waits for both, then reveals the highlighted copy only once it has real output. Placeholder and file share one grid cell, so nothing shifts.\n\nThree states, in order:\n\n| State       | Rendered                                                                                            |\n| ----------- | --------------------------------------------------------------------------------------------------- |\n| Preparing   | `renderCodeFallback`, or a loading message by default — `data-artifacts-viewer-part=\"code-pending\"` |\n| Ready       | The highlighted file — `data-artifacts-viewer-part=\"code-highlight\"`                                |\n| Unavailable | Plain `<pre>` holding the file contents — `data-artifacts-viewer-part=\"code-fallback\"`              |\n\nThe unavailable state renders readable text when preloading reports that highlighting could not be initialized. It is not a general error boundary for failures after the lazy component starts rendering.\n\n#### `renderCodeFallback` — what the wait looks like\n\nA render prop, accepted by `ArtifactRepoViewer`, `ArtifactFileView`, and `CodeView`:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  renderCodeFallback={({ name }) => <MySpinner label={`Preparing ${name}`} />}\n/>\n```\n\n```ts\ntype ArtifactCodeFallbackRenderer = (file: { name: string; contents: string }) => ReactNode;\n```\n\n`contents` is the decoded file, so a skeleton can be sized to the real line count.\n\n#### `preloadCodeView` — how long the wait is\n\n**A function, not a prop.** It warms the lazy chunk, the shared Shiki engine, the themes, and — given a file name — that file's grammar.\n\n```ts\nimport { preloadCodeView } from \"artifacts-viewer/react\";\n\nawait preloadCodeView({ theme: { light: \"github-light\", dark: \"vesper\" } });\n```\n\n`ArtifactRepoViewer` already calls it on mount, so warming overlaps with browsing the tree and the first file opened is usually highlighted straight away. Call it yourself to start earlier still — on route entry, or when a row is hovered:\n\n```tsx\n<li onPointerEnter={() => void preloadCodeView({ theme, name: entry.name })}>\n```\n\nMemoized per theme and grammar set, so repeat calls cost nothing. It resolves `boolean`; `false` means highlighting is unavailable, which is what makes the plain-text state above reachable.\n\n### Theming\n\n`@pierre/diffs` renders into an open Shadow DOM. **Your CSS cannot reach inside it.** Theming has to go through options:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  pierreDiffsOptions={{\n    theme: { light: \"github-light\", dark: \"vesper\" },\n    themeType: \"system\",\n  }}\n/>\n```\n\n| Option      | Notes                                                                 |\n| ----------- | --------------------------------------------------------------------- |\n| `theme`     | A Shiki theme name, or a `{ light, dark }` pair                       |\n| `themeType` | `\"system\"` (default), `\"light\"`, or `\"dark\"`                          |\n| `unsafeCSS` | Raw CSS injected into the Shadow DOM. Unstable across Pierre releases |\n\nKeep the object identity stable, or memoize it, to avoid re-rendering the code view.\n\n## Limits and behaviour\n\n**Large files.** `maxInlineBytes` defaults to 512 KiB. If the response declares a larger `Content-Length` the body is never downloaded. Otherwise the stream is read and cancelled the moment the cap is crossed. Either way the state becomes `oversized` and the Raw and Download links still work.\n\n**Images** are detected from the file extension, so they never download through the client — they render straight from the raw URL via `<img src>`. SVG stays in image context and is never inlined. Recognized: `apng`, `avif`, `bmp`, `gif`, `ico`, `jfif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.\n\n**Text vs binary** is decided by a strict UTF-8 decode plus a NUL-byte scan over the first 8 KiB, not by an extension list. Anything that fails is `binary` and never reaches the highlighter.\n\n**Raw and download URLs** are pinned to the commit hash rather than a branch name, so a link keeps resolving — and stays cacheable — even if someone pushes mid-session.\n\n**Tree expansion** is keyed by path, not by tree hash, so two identical subtrees at different paths expand independently.\n\n**Accessibility.** The sidebar is a `<nav>` of nested disclosure buttons rather than `role=\"tree\"`, because full WAI-ARIA tree keyboard navigation is not implemented and claiming the role without it would be worse than not claiming it. Rows are real buttons, or real anchors when `buildHref` is supplied.\n\n## Not included\n\nDeliberately out of scope for now:\n\n- **Markdown rendering.** `.md` files currently display as highlighted plain text.\n- **Diff viewing** and commit history UI.\n- **Branch switching.** The Artifacts REST API exposes no ref-listing operation; enumerating branches needs Git protocol v2 `ls-refs`. Pass a known branch to `gitRef`, or omit it for the default branch.\n- **Writes** of any kind.\n\n## License\n\nMIT\n","isInternal":false,"tokens":6575,"sizeBytes":29621},{"name":"README.md","path":"packages/artifacts-viewer/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/vibesdk/HEAD/packages/artifacts-viewer/README.md","title":"artifacts-viewer Documentation","category":"plugin-manifest","format":"markdown","content":"# artifacts-viewer\n\nMonorepo for [`artifacts-viewer`](./packages/artifacts-viewer), a read-only React\nrepository viewer for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/).\n\nFor package documentation — installation, API reference, styling, theming — see\n[`packages/artifacts-viewer/README.md`](./packages/artifacts-viewer/README.md).\nFor the release procedure, see [`PUBLISH.md`](./PUBLISH.md).\n\n## Layout\n\n```text\napps/example                 Cloudflare Workers + React app that consumes the package\npackages/artifacts-viewer    The published npm package\n```\n\nThe package ships four JavaScript entry points, one CSS export, and package metadata, each with a deliberate boundary:\n\n| Entry point                     | Contains                                                | Must not import       |\n| ------------------------------- | ------------------------------------------------------- | --------------------- |\n| `artifacts-viewer`              | `routeArtifactRequest`, the server-side read-only proxy | React                 |\n| `artifacts-viewer/server/cache` | Workers Cache API and KV adapters                       | React                 |\n| `artifacts-viewer/client`       | `createArtifactsClient`, framework-independent          | React, Worker globals |\n| `artifacts-viewer/react`        | Hooks and components                                    | The server router     |\n| `artifacts-viewer/styles.css`   | Structural CSS only                                     | —                     |\n\nPlatform-neutral code shared by both sides (wire types, path helpers, blob\nclassification) lives in `packages/artifacts-viewer/src/shared/`. The entry\npoints are designed to preserve those boundaries: the server and client builds\ndo not import React, and the React build does not import the server router.\n\n## Toolchain\n\nThis workspace uses [Vite+](https://viteplus.dev), which wraps runtime\nmanagement, package management, and frontend tooling behind a single `vp` CLI.\nRun `vp help` for the command list. Docs are vendored at\n`node_modules/vite-plus/docs`.\n\nDependency versions are centralised in the `catalog:` block of\n`pnpm-workspace.yaml`. Add a version there and reference it as `\"catalog:\"` from\na package rather than pinning per package.\n\n## Development\n\n```bash\nvp install          # install; run after pulling\nvp run dev          # start apps/example with source-level library HMR\nvp run ready        # vp check && vp run -r build — the CI gate\nvp run -r build     # build every workspace project\n```\n\nThere is no test suite. `vp check` covers formatting, linting, and type checking\nacross all projects.\n\n### Running the example app\n\nThe example needs credentials for a real Artifacts namespace. The API token\nlives only in the Worker; the browser never sees it.\n\n```bash\ncd apps/example\ncp .dev.vars.example .dev.vars   # then fill in the three values\n```\n\n`wrangler types` reads `.dev.vars` to generate `Env`, so rerun\n`vp run cf-typegen` from `apps/example` after adding or renaming a variable.\n\n`apps/example/wrangler.jsonc` sets `assets.run_worker_first: [\"/api/*\"]`.\nWithout it, `not_found_handling: \"single-page-application\"` would serve\n`index.html` for API routes and the Worker would never run.\n\n## Releases\n\nVersions and changelogs are generated by pnpm's native release tooling; never\nhand-edit `version` or `CHANGELOG.md`.\n\n```bash\npnpm change         # record a change intent, committed with the code\npnpm version -r     # consume intents: bump, write changelogs, update the ledger\n```\n\nPublishing runs on GitHub Actions via npm OIDC trusted publishing, triggered by\na published GitHub Release whose tag matches `v<version>`. No npm token exists\nanywhere in the repository. [`PUBLISH.md`](./PUBLISH.md) has the full procedure\nand the failure modes worth knowing about.\n\n## License\n\nMIT\n","isInternal":false,"tokens":833,"sizeBytes":3848}],"systemPromptSnippet":"<yakaai_skills repo=\"cloudflare/vibesdk\">\n<!-- File: AGENTS.md (Tokens: ~1616 | Category: root-instruction) -->\n# AGENTS.md\n\n## Tooling\n- Use Bun from the repository root. The tracked lockfile is `bun.lock`, the `space` workspace dependency uses `workspace:*`, and install/build hooks invoke Bun even when started through npm.\n- `bun run setup` is the interactive Cloudflare/resource bootstrap. Local development expects the generated `.dev.vars`; never commit `.dev.vars*` or `.prod.vars`.\n- `bun run dev` starts the React frontend and Worker together through `@cloudflare/vite-plugin` at `http://localhost:5173`. There is no separate Worker dev command.\n- `bun run dev:browser` is an optional local Chromium sidecar for the think agent's browser-console tool; absence only produces a warning.\n\n## Verification\n- Root checks: `bun run typecheck`, `bun run lint`, `bun run test`, `bun run build`.\n- `bun run build` builds `space` and the Vite/Worker bundle; it does not typecheck. Run `bun run typecheck` separately.\n- Focus a root test with `bunx vitest run path/to/file.test.ts`; test execution uses the Workers pool and `wrangler.test.jsonc`.\n- The root Vitest suite excludes all `sdk/test/**` and `container/monitor-cli.test.ts`. SDK tests use Bun: `bun run --cwd sdk test`.\n- SDK integration tests require a running root dev server and `VIBESDK_INTEGRATION_API_KEY`; run `bun run --cwd sdk test:integration`. They can take 5-10 minutes; `VIBESDK_INTEGRATION_RUN_PREVIEW=1` enables the slower preview case.\n- Root typecheck/lint do not validate `space` or `sdk`. For touched packages run `bun run --cwd space typecheck` / `bun run --cwd space build` and `bun run --cwd sdk package` as appropriate.\n- ESLint checks only `src/**` and `worker/**` and deliberately ignores tests; do not treat `bun run lint` as repository-wide validation.\n- Pre-commit typechecks staged TypeScript and runs related Vitest tests. `RUN_ALL_TESTS=1` selects its broader suite; `SKIP_TESTS=1` bypasses the hook.\n\n## Frontend UI\n- Tailwind CSS v4 via CSS-first setup in `src/index.css` (`@import 'tailwindcss'`, `@theme`, Kumo tokens); no `tailwind.config.*`.\n- Prefer `@cloudflare/kumo` for new UI. List components with `bun kumo ls`; component docs via `bun kumo doc Button` (swap name as needed). Legacy shadcn/Radix under `src/components/ui/` still exists—do not add new primitives there when Kumo covers the case.\n- Icons: `@phosphor-icons/react`. Dark mode is `data-mode=\"dark\"` on the root (not a `class` strategy).\n- Path aliases: `@/*` → `src/*`, `shared/*`, `worker/*` (see `tsconfig.app.json`).\n\n## Frontend Data Fetching\n- Use TanStack Query for frontend server state and network-call caching. `QueryClientProvider` is wired at the React root; configure shared defaults in `src/lib/query-client.ts`.\n- Keep TanStack query keys centralized in `src/lib/query-keys.ts`. Use hierarchical keys so broad invalidation works, for example `queryKeys.apps.all` should invalidate app list/favorite variants.\n- Frontend HTTP still goes through `src/lib/api-client.ts`; query functions should wrap existing `apiClient` methods rather than calling `fetch` directly from components.\n- Include user/account identity in query keys when cached data is user-specific, or explicitly clear/remove those queries on logout/user switch. `enabled: !!user` prevents fetching but does not clear old cached data.\n- Mutations that change cached server state must update cache with `queryClient.setQueryData` or invalidate the relevant `queryKeys` on success. Do not rely on a local `refetch()` in one component if sidebar or other shared UI consumes the same data.\n- Prefer query hooks (`useQuery`, `useMutation`) over ad-hoc loading/error state in React contexts. Context remains appropriate for client-only UI state or providers required by libraries.\n\n## Boundaries\n- `src/` is the React app (`src/main.tsx`, routes in `src/routes.tsx`). API contracts live in `src/api-types.ts`; frontend HTTP calls belong in `src/lib/api-client.ts`.\n- `worker/index.ts` is the Worker entrypoint and Durable Object export surface. Hono middleware/routes are wired by `worker/app.ts` and `worker/api/routes/index.ts`.\n- `space/` is the only declared workspace package. It provides the `SpaceDO` workspace and file layer used by the think agent, with durable git history stored through Cloudflare Artifacts, and is bundled before the root app; edit implementation in `space/src`, never generated `space/dist`, and keep the hand-maintained `space/types/index.d.ts` aligned with public exports.\n- `sdk/` is an independent Bun package with its own lockfile, scripts, and tests. It imports the platform WebSocket protocol from `worker/api/websocketTypes.ts`, so protocol changes must remain SDK-compatible.\n- Shared frontend/backend types belong in `shared/`; Worker-only types stay under `worker/`.\n- Architecture overview (ThinkAgent, SpaceDO, Artifacts, Dynamic Worker previews): `docs/llm.md`. Production deploy: `bun run deploy` (needs `.prod.vars`).\n\n## Change Paths\n- API endpoint: update `src/api-types.ts` -> `src/lib/api-client.ts` -> `worker/database/services/` (when persistence is needed) -> `worker/api/controllers/` -> `worker/api/routes/`, then register the route in `worker/api/routes/index.ts`.\n- WebSocket message: update `worker/api/websocketTypes.ts`, backend handling in `worker/agents/core/websocket.ts`, and frontend handling in `src/routes/chat/utils/handle-websocket-message.ts`; verify SDK tests because its protocol re-exports these types.\n- LLM tool: add it under `worker/agents/tools/toolkit/` and register it in `worker/agents/tools/customTools.ts` (`buildTools` or `buildDebugTools`). The think behavior has a separate tool path and bypasses `buildTools`.\n- Think tool: create it under `worker/agents/think/`, add SpaceDO RPC typing if needed, register it in `ThinkAgent.getTools()`, and update the relevant prompt or skill.\n- D1 schema source is `worker/database/schema.ts`; generate migrations into `migrations/` with `bun run db:generate`, then apply locally with `bun run db:migrate:local`.\n- After changing Wrangler bindings, run `bun run cf-typegen`; `worker-configuration.d.ts` is consumed by setup and TypeScript configs.\n\n## Constraints\n- Do not introduce new `any` types even though ESLint currently permits existing ones; find or define a concrete type. Frontend API types should import from `@/api-types`.\n- Worker code reads bindings from `env`; do not use Vite environment variables there.\n- All `/api/*` routes are owner-only by default in `worker/app.ts`; public routes must explicitly follow the existing auth override pattern.\n- User secrets RPC methods return `null`/`boolean` on failure rather than throwing; preserve that contract when editing `worker/services/secrets/`.\n- For usage-limit UI behavior and its cross-component invariants, read `docs/usage-limits-ui.md` before editing the badge, credits banner, or limit popups.\n\n<!-- File: CLAUDE.md (Tokens: ~1444 | Category: claude-rule) -->\n# CLAUDE.md\n\nThis file provides guidance to Claude Code when working with code in this repository.\n\n## Communication Style\n- Be professional, concise, and direct\n- Do NOT use emojis in code reviews, changelogs, or any generated content. You may use professional visual indicators or favor markdown formatting over emojis.\n- Focus on substance over style\n- Use clear technical language\n\n## Project Overview\n\nVibeSDK is an agentic full-stack application builder on Cloudflare.\n\n**Tech Stack:**\n- Frontend: React 19, TypeScript, Vite, TailwindCSS, React Router v7\n- Backend: Cloudflare Workers, Durable Objects, Hono, D1, R2, and KV\n- Agent: Cloudflare Think with AI Gateway model routing\n- Workspace: SpaceDO Durable Objects\n- Version history: Cloudflare Artifacts\n- Preview runtime: Worker Loader bindings and Dynamic Workers\n- Generated app data: Durable Object Facets with isolated SQLite\n- WebSocket: PartySocket for real-time communication\n\n**Project Structure:**\n- `/src` - React frontend, API types, and API client\n- `/worker/agents/think` - ThinkAgent, prompts, skills, workspace adapter, and tools\n- `/worker/agents/core/behaviors/think.ts` - Think host orchestration\n- `/worker/api` - Routes, controllers, handlers, and WebSocket types\n- `/worker/database` - D1 schema and services\n- `/space` - SpaceDO, Artifacts synchronization, preview bundling, and App Facets\n- `/sdk` - TypeScript client SDK\n- `/migrations` - D1 migrations\n- `/scripts` - Setup and deployment utilities\n\n## Key Architectural Patterns\n\n**ThinkAgent:**\n- One Agent backed by a Durable Object per app session\n- Owns conversation, context selection, skills, streaming, tools, and step limits\n- Uses explicit SpaceDO-backed tools; workspace bash is disabled\n\n**Workspace and Versioning:**\n- SpaceDO owns the isolated live workspace and files\n- Cloudflare Artifacts owns durable commits, branches, history, and restore points\n- `commit` saves without deploying; `deploy_space` commits and rebuilds the preview\n- Rollback applies a selected tree, creates a new commit, and redeploys\n\n**Preview Runtime:**\n- `@cloudflare/worker-bundler` builds committed project files\n- Worker Loader loads bundled modules as a Dynamic Worker\n- Generated `App` classes run as Durable Object Facets with isolated SQLite\n\n**WebSocket Communication:**\n- PartySocket carries realtime agent output, tools, files, and deployment state\n- Session state is restored on reconnect\n\n## Common Development Tasks\n\n**Change LLM Model for Operation:**\nEdit `/worker/agents/inferutils/config.ts` → `AGENT_CONFIG` object\n\n**Modify Think Agent Behavior:**\nEdit `worker/agents/think/ThinkAgent.ts`, the host behavior in `worker/agents/core/behaviors/think.ts`, and the relevant prompt or skill.\n\n**Add New WebSocket Message:**\n1. Add type to `worker/api/websocketTypes.ts`\n2. Handle in `worker/agents/core/websocket.ts`\n3. Handle in `src/routes/chat/utils/handle-websocket-message.ts`\n\n**Add New Think Tool:**\n1. Create the tool under `worker/agents/think/`\n2. Add required SpaceDO RPC typing to `space-workspace-ops.ts`\n3. Register it in `ThinkAgent.getTools()`\n4. Update the relevant prompt or skill\n5. Add focused tests\n\n**Add API Endpoint:**\n1. Define types in `src/api-types.ts`\n2. Add to `src/lib/api-client.ts`\n3. Create service in `worker/database/services/`\n4. Create controller in `worker/api/controllers/`\n5. Add route in `worker/api/routes/`\n6. Register in `worker/api/routes/index.ts`\n\n## Important Context\n\n**User Secrets Store (Durable Object):**\n- Location: `/worker/services/secrets/`\n- Purpose: Encrypted storage for user API keys with key rotation\n- Architecture: One DO per user, XChaCha20-Poly1305 encryption, SQLite backend\n- Key derivation: MEK → UMK → DEK (hierarchical PBKDF2)\n- Features: Key rotation, soft deletion, access tracking, expiration support\n- RPC Methods: Return `null`/`boolean` on error, never throw exceptions\n- Testing: 90 comprehensive tests in `/test/worker/services/secrets/`\n\n**Workspace and Git:**\n- SpaceDO provides workspace and file operations\n- Cloudflare Artifacts stores durable git history\n- Artifacts synchronization lives in `space/src/space/artifacts-sync.ts`\n- Rollback preserves history by creating a new commit\n\n**Abort Controller Pattern:**\n- `getOrCreateAbortController()` reuses controller for nested operations\n- Cleared after top-level operations complete\n- Shared by parent and nested tool calls\n- User abort cancels entire operation tree\n\n**Message Deduplication:**\n- Tool execution causes duplicate AI messages\n- Backend skips redundant LLM calls (empty tool results)\n- Frontend utilities deduplicate live and restored messages\n- System prompt teaches LLM not to repeat\n\n## Core Rules (Non-Negotiable)\n\n**1. Strict Type Safety**\n- NEVER use `any` type\n- Frontend imports types from `@/api-types` (single source of truth)\n- Search codebase for existing types before creating new ones\n\n**2. DRY Principle**\n- Search for similar functionality before implementing\n- Extract reusable utilities, hooks, and components\n- Never copy-paste code - refactor into shared functions\n\n**3. Follow Existing Patterns**\n- Frontend APIs: All in `/src/lib/api-client.ts`\n- Backend Routes: Controllers in `worker/api/controllers/`, routes in `worker/api/routes/`\n- Database Services: In `worker/database/services/`\n- Types: Shared in `shared/types/`, API in `src/api-types.ts`\n\n**4. Code Quality**\n- Production-ready code only - no TODOs or placeholders\n- No hacky workarounds\n- Comments explain purpose, not narration\n- No overly verbose AI-like comments\n\n**5. File Naming**\n- React Components: PascalCase.tsx\n- Utilities/Hooks: kebab-case.ts\n- Backend Services: PascalCase.ts\n\n## Common Pitfalls\n\n**Don't:**\n- Use `any` type (find or create proper types)\n- Copy-paste code (extract to utilities)\n- Use Vite env variables in Worker code\n- Forget to update types when changing APIs\n- Create new implementations without searching for existing ones\n- Use emojis in code or comments\n- Write verbose AI-like comments\n\n**Do:**\n- Search codebase thoroughly before creating new code\n- Follow existing patterns consistently\n- Keep comments concise and purposeful\n- Write production-ready code\n- Test thoroughly before submitting\n\n<!-- File: worker/agents/think/skills/app-ai-binding/SKILL.md (Tokens: ~1002 | Category: anthropic-skill) -->\n---\nname: app-ai-binding\ndescription: Build AI features in generated apps using the platform-provided Workers AI binding. Load this skill for chatbots, summarization, classification, embeddings, semantic search, image understanding, transcription, translation, or other model inference.\n---\n\nGenerated apps can call Workers AI from the `App` Durable Object through `this.env.AI`. The platform provides this binding automatically in Dynamic Worker previews and native Cloudflare deployments. Do not add an `ai` section to the generated app's `wrangler.json`.\n\nUse only `AI.run(model, inputs, options?)`. Preview uses a restricted proxy with this method, while deployed Workers receive the native Workers AI binding. Methods such as `AI.gateway()`, `AI.models()`, `AI.autorag()`, and `AI.toMarkdown()` are not portable between preview and deployment.\n\n## Type and usage\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\n\ninterface Env {\n  AI: Ai;\n}\n\nexport class App extends DurableObject<Env> {\n  async fetch(request: Request): Promise<Response> {\n    const { prompt } = await request.json<{ prompt: string }>();\n    const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n      messages: [\n        { role: \"system\", content: \"Answer clearly and concisely.\" },\n        { role: \"user\", content: prompt },\n      ],\n    });\n    return Response.json(result);\n  }\n}\n```\n\nKeep AI calls in the server-side `App` class. Browser code should call an app API route; it must never receive model credentials or call the binding directly.\n\n## Common tasks\n\n### Text generation\n\nUse `@cf/meta/llama-4-scout-17b-16e-instruct` with either `prompt` or `messages`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n  messages: [{ role: \"user\", content: userPrompt }],\n  max_tokens: 512,\n});\n```\n\n### Embeddings\n\nUse `@cf/baai/bge-base-en-v1.5`. The response contains embedding vectors in `data`:\n\n```ts\nconst result = await this.env.AI.run(\"@cf/baai/bge-base-en-v1.5\", {\n  text: [\"first document\", \"second document\"],\n});\n```\n\n### Image classification\n\nUse `@cf/microsoft/resnet-50` with image bytes converted to a number array:\n\n```ts\nconst image = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/microsoft/resnet-50\", { image });\n```\n\n### Speech transcription\n\nUse `@cf/openai/whisper` with audio bytes:\n\n```ts\nconst audio = [...new Uint8Array(await request.arrayBuffer())];\nconst result = await this.env.AI.run(\"@cf/openai/whisper\", { audio });\n```\n\nModel input and output shapes differ. Keep model-specific parsing close to the call and validate user input before inference. Use only active model IDs from the current Workers AI model catalog; never generate code with a deprecated model.\n\n## Limits and errors\n\nDynamic Worker previews enforce a per-app AI call limit. AI features must fail transparently when inference is unavailable.\n\n- Never return canned, random, mock, placeholder, or hard-coded content as a fallback for an AI response.\n- Never catch an `AI.run()` error and continue with an apparently successful response.\n- Never return `200` with fields such as `fallbackUsed` after inference fails.\n- Return a non-2xx JSON response containing a safe, useful error message. The frontend must display that error to the user.\n- Do not retry rate-limit failures in a loop.\n\n```ts\ntry {\n  const result = await this.env.AI.run(\"@cf/meta/llama-4-scout-17b-16e-instruct\", {\n    messages: [{ role: \"user\", content: userPrompt }],\n  });\n  return Response.json(result);\n} catch (error) {\n  const message = error instanceof Error ? error.message : \"AI inference failed\";\n  return Response.json({ error: \"AI inference failed\", details: message }, { status: 503 });\n}\n```\n\nFrontend callers must inspect both the HTTP status and error body:\n\n```ts\nconst response = await fetch(\"./api/chat\", requestOptions);\nconst body = await response.json<{ content?: string; error?: string; details?: string }>();\nif (!response.ok) throw new Error(body.details || body.error || \"AI request failed\");\n```\n\nUse non-streaming calls for preview-compatible code. Native Workers AI deployments support `{ stream: true }`, but streaming across the preview proxy is not guaranteed.\n\n<!-- File: worker/agents/think/skills/app-file-structure/SKILL.md (Tokens: ~5751 | Category: anthropic-skill) -->\n---\nname: cloudflare-bundler-apps\ndescription: Author Cloudflare Worker Bundler-compatible apps that build and preview correctly inside a space. Use this skill whenever you scaffold, modify, or deploy a project that will be built with `@cloudflare/worker-bundler` (i.e. anything served from `/space/:name/preview/:branch/`). Covers wrangler config, project layout, static asset rules, server entry conventions, npm dependency limits, and the most common cause of blank previews (JSX in browser scripts).\n---\n\nThis skill teaches how to build apps that deploy cleanly through the space deploy pipeline. Every project committed to a space is built by `@cloudflare/worker-bundler` (`createApp` when there are static assets, `createWorker` when there are none) and served on a Dynamic Worker via `WorkerLoader`. Get the conventions right and the preview \"just works\". Get them wrong and you get a blank page, a 500, or a `Build failed` error.\n\n## How the pipeline works\n\nWhen `deploy_space(branch)` is called, the space DO:\n\n1. Reads every file from the branch's working tree (skipping `.git/`).\n2. Parses `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` for `main`, `compatibility_date`, `compatibility_flags`, and `[assets]`.\n3. If `[assets].directory` is set, files under that directory become static assets served host-side by `handleAssetRequest`. Everything else is built into a Worker via `createApp` (with `server: <main>`).\n4. If no assets directory is configured, only `createWorker({ entryPoint: <main> })` is run. The output is loaded as a Dynamic Worker; all requests go to the Worker.\n5. Previews are served at `/space/:name/preview/:branch/*`. Responses with `content-type: text/html` are run through `HTMLRewriter`, which prefixes root-relative `src` / `href` / `action` attributes with the preview base path. **JS-side fetches and dynamic imports are NOT rewritten.**\n\nPractical consequences:\n\n- **You can rely on root-relative `src=\"/foo.js\"` and `href=\"/style.css\"` in HTML.** They will be rewritten to the preview path automatically.\n- **You cannot rely on root-relative paths inside JS strings**: `fetch(\"/api/x\")`, `new URL(\"/foo\", location.origin)`, dynamic `import(\"/lib.js\")`. These hit the wrong path under the preview prefix. Use relative paths (`./api/x`, `import.meta.url`) or read the base path from `<base href>` / a meta tag injected at build time.\n- The `<base href>` tag is also rewritten if present — using it lets all relative URLs resolve against the preview path.\n\n## Project layout\n\nA typical full-stack space project:\n\n```\n/\n├── wrangler.json          # required for non-trivial setups\n├── package.json           # npm deps (text-only packages, see limits below)\n├── src/\n│   └── index.ts           # server entry: export default { fetch }\n└── public/                # static assets directory (configurable)\n    ├── index.html\n    ├── app.js             # compiled, no JSX\n    └── styles.css\n```\n\nA static-only SPA can skip `src/` entirely — just `wrangler.json` + `public/` is enough as long as `main` points to a minimal pass-through worker or you accept that all requests fall through assets.\n\n## Wrangler config\n\nMinimum viable `wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\nNotes:\n\n- `main` is required when there's any server code. The bundler also auto-detects `src/index.ts`, `src/index.js`, `index.ts`, `index.js` if missing.\n- `compatibility_date` defaults to `2025-04-01` if omitted. Set it explicitly for newer features.\n- Add `\"compatibility_flags\": [\"nodejs_compat\"]` only if you actually need Node built-ins.\n- `assets.directory` is the **only** way to ship static files. Files outside this directory are bundled into the Worker or ignored — they will **not** be reachable via URL.\n- `html_handling: \"auto-trailing-slash\"` is usually what you want for multi-page sites; SPAs should also set `not_found_handling: \"single-page-application\"` so deep links return `index.html`.\n\nTOML works too (`wrangler.toml`), but the parser only handles top-level scalar fields plus an `[assets]` table — no inline tables, no env overrides. Prefer JSON.\n\n## Server entry: one Durable Object class named `App`\n\n**Your entire backend is one Durable Object.** Your main module exports\na class named `App` extending `DurableObject`. The platform loads it as\na [Cloudflare Durable Object\nFacet](https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/)\nof the host SpaceDO and forwards every non-asset request — including\nWebSocket upgrades — to your `App.fetch(request)`.\n\n**Do not `export default { fetch }`.** A default fetch handler will be\nignored. There is no separate worker; the DO is the worker.\n\n```ts\n// src/index.ts\nimport { DurableObject } from \"cloudflare:workers\";\n\nexport class App extends DurableObject {\n  async fetch(request: Request): Promise<Response> {\n    const url = new URL(request.url);\n\n    // Lazy schema init on the first request. Idempotent.\n    this.ctx.storage.sql.exec(`\n      CREATE TABLE IF NOT EXISTS notes (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        body TEXT NOT NULL\n      )\n    `);\n\n    if (url.pathname === \"/api/notes\" && request.method === \"POST\") {\n      const { body } = await request.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return new Response(null, { status: 201 });\n    }\n    if (url.pathname === \"/api/notes\") {\n      const rows = this.ctx.storage.sql\n        .exec(`SELECT id, body FROM notes ORDER BY id DESC`)\n        .toArray();\n      return Response.json(rows);\n    }\n    return new Response(\"Not found\", { status: 404 });\n  }\n}\n```\n\nOr with Hono inside the DO:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/hello\", (c) => c.json({ msg: \"hi\" }))\n    .post(\"/api/notes\", async (c) => {\n      const { body } = await c.req.json<{ body: string }>();\n      this.ctx.storage.sql.exec(`INSERT INTO notes (body) VALUES (?)`, body);\n      return c.json({ ok: true });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\nWhen `[assets]` is configured, static files take priority — your `fetch`\nonly sees requests that didn't match an asset. For a SPA, put\n`index.html` in `public/` and `App.fetch` only handles `/api/*`.\n\n### State and storage\n\nState lives on `this.ctx.storage`. No `env.DB` exists; you do not need\nto declare any binding. The DO's storage is a per-(space, branch)\nSQLite database that survives redeploys.\n\n- `this.ctx.storage.sql.exec(sql, ...params)` — full SQLite. Returns a\n  cursor with `.toArray()`, `.one()`, `.columnNames`, etc.\n- `this.ctx.storage.kv.get(key)` / `.put(key, value)` — simple KV\n  backed by SQLite.\n- `this.ctx.storage.transactionSync(() => { ... })` — atomic batch\n  inside the same DO instance.\n\n```ts\n// SQL\nthis.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS counter (n INTEGER)`);\nconst cur = this.ctx.storage.sql.exec(`SELECT n FROM counter LIMIT 1`).toArray();\nconst n = (cur[0]?.n as number) ?? 0;\n\n// KV\nconst last = this.ctx.storage.kv.get<number>(\"last_seen\") ?? 0;\nthis.ctx.storage.kv.put(\"last_seen\", Date.now());\n```\n\n**Use the DO's storage for all persistent state** — users, sessions,\ncontent, settings, anything that should survive a reload. Do not use\n`localStorage` or `sessionStorage` for primary data; they are\nper-browser, per-origin, and lost on incognito or any other browser.\n\n### WebSockets\n\nThe platform forwards `Upgrade: websocket` requests transparently into\n`App.fetch`. Use the standard Durable Object hibernation API:\n\n```ts\nexport class App extends DurableObject {\n  async fetch(request: Request) {\n    const upgrade = request.headers.get(\"Upgrade\");\n    if (upgrade === \"websocket\") {\n      const { 0: client, 1: server } = new WebSocketPair();\n      this.ctx.acceptWebSocket(server);\n      return new Response(null, { status: 101, webSocket: client });\n    }\n    return new Response(\"not found\", { status: 404 });\n  }\n\n  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {\n    // Broadcast to every connected client (chat-style fan-out).\n    for (const peer of this.ctx.getWebSockets()) {\n      peer.send(typeof message === \"string\" ? message : new Uint8Array(message));\n    }\n  }\n\n  webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean) {\n    // Cleanup if needed.\n  }\n}\n```\n\n`acceptWebSocket` + `webSocketMessage` is the right pattern for\nmulti-connection use cases (chat rooms, multiplayer, presence) because\nthe DO can hibernate between messages without losing connections.\n\n### Per-entity grouping (chat rooms, per-user records)\n\nYou get **one App DO per (space, branch)** — a single instance, not a\nnamespace. Do not call `idFromName` or try to declare a DO binding in\n`wrangler.json`; both are rejected. For multi-room / multi-user state,\n**partition rows with a column** in the App's SQLite:\n\n```sql\nCREATE TABLE messages (\n  room_id TEXT NOT NULL,\n  user_id TEXT NOT NULL,\n  body TEXT NOT NULL,\n  ts INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)\n);\nCREATE INDEX messages_by_room ON messages (room_id, ts);\n```\n\nThen route by `room_id` inside `App.fetch`. SQLite is fast enough that\nsharing the same table across rooms is fine for prototyping; if you\nlater need stronger isolation, attach `room_id` to a tag and use\n`acceptWebSocket(ws, [room_id])` so `getWebSockets(room_id)` returns\nonly that room's sockets.\n\n### What you cannot declare\n\n- **`durable_objects.bindings` in `wrangler.json`.** The platform\n  extracts your `App` class automatically. Adding bindings fails the\n  deploy with a clear error.\n- **`new_sqlite_classes` / migrations.** Same reason. Your App's\n  storage is set up by the platform.\n- **`d1_databases`, `r2_buckets`, `kv_namespaces`.** Not available.\n  Everything goes in `this.ctx.storage`.\n\n## Static asset rules (read this twice)\n\nThis is where most preview failures happen. The browser eventually runs your assets — the bundler does **not** transform them. So:\n\n### **NEVER ship JSX in a `<script type=\"module\">` (or any other script tag).** Browsers cannot parse JSX.\n\nThis page **will be blank** with a `SyntaxError: Unexpected token '<'`:\n\n```html\n<script type=\"module\">\n  import React from \"https://esm.sh/react@18\";\n  const App = () => <div>hi</div>;   // ← browser dies here\n</script>\n```\n\nFix one of these three ways:\n\n1. **Pre-compile** — write JSX in a build step (Vite/esbuild/tsup) and emit plain JS into `public/`. Best for production.\n2. **`React.createElement` by hand** — works without a build step but is verbose.\n3. **`@babel/standalone`** — only for prototypes. Load Babel **before** the script and use `type=\"text/babel\"`. The modern `@babel/preset-react` defaults to the **automatic JSX runtime**, which emits `import { jsx } from \"react/jsx-runtime\"`. Browsers will reject that bare specifier unless your importmap maps it. **Always ship a complete importmap alongside the Babel script:**\n\n   ```html\n   <script type=\"importmap\">\n   {\n     \"imports\": {\n       \"react\": \"https://esm.sh/react@18.3.1\",\n       \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n       \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n       \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\"\n     }\n   }\n   </script>\n   <script src=\"https://unpkg.com/@babel/standalone/babel.min.js\"></script>\n   <script type=\"text/babel\" data-type=\"module\" data-presets=\"react\">\n     import React from \"react\";\n     import { createRoot } from \"react-dom/client\";\n     const App = () => <div>hi</div>;\n     createRoot(document.getElementById(\"root\")).render(<App />);\n   </script>\n   ```\n\n   `data-type=\"module\"` is required for `import` to work inside the Babel script. Import React from the importmap key (`\"react\"`) — not from a hard-coded `esm.sh` URL — so your code and Babel's emitted `react/jsx-runtime` import resolve to the **same** React instance.\n\n   If you really cannot ship `react/jsx-runtime` in the importmap, force Babel to the legacy classic runtime instead so it emits `React.createElement` calls and never touches `react/jsx-runtime`:\n\n   ```html\n   <script\n     type=\"text/babel\"\n     data-type=\"module\"\n     data-presets=\"react\"\n     data-plugins='[[\"transform-react-jsx\", { \"runtime\": \"classic\" }]]'\n   >\n     import React from \"https://esm.sh/react@18.3.1\";  /* must be in scope */\n     const App = () => <div>hi</div>;\n   </script>\n   ```\n\n   The classic runtime requires `React` to be in lexical scope (because `<div>` becomes `React.createElement(\"div\")`). The automatic runtime (default) does not, but needs `react/jsx-runtime` resolvable.\n\n### Symptom-to-fix index\n\n| Console error | Cause | Fix |\n| --- | --- | --- |\n| `Uncaught SyntaxError: Unexpected token '<'` (inside `<script type=\"module\">`) | JSX shipped raw to the browser | Pre-compile, or use `@babel/standalone` with `type=\"text/babel\"` |\n| `Uncaught TypeError: Failed to resolve module specifier \"react/jsx-runtime\". Relative references must start with either \"/\", \"./\", or \"../\".` | Automatic JSX runtime emits `import \"react/jsx-runtime\"` but importmap doesn't map it | Add `\"react/jsx-runtime\": \"https://esm.sh/react@<same-version>/jsx-runtime\"` to the importmap **before** the Babel/script tag — or force the classic runtime (see above) |\n| `Uncaught TypeError: Failed to resolve module specifier \"react\"` (or any bare name) | No importmap entry for that package | Add it to the importmap; importmap script tag must appear **before** any module script that uses the specifier |\n| `TypeError: Cannot read properties of null (reading 'useContext')` | Dual-React (two copies loaded) | Append `?external=react,react-dom` to every esm.sh URL with React as a peer dep — see the dual-React trap below |\n\n### Other asset gotchas\n\n- **Binary assets are not extracted from npm packages.** Fonts, images, and `.wasm` shipped via npm tarballs will be missing. Put binaries directly in `public/` instead.\n- **Paths in JS need care under preview**: prefer `import.meta.url`, relative paths, or a runtime base detected from `document.baseURI`. Don't hard-code `/api/...` in client code; use `./api/...` or read a base from a `<meta>` tag.\n- **Trailing slashes matter** for `auto-trailing-slash` mode: `/about` serves `about.html`, `/about/` serves `about/index.html`. Pick one shape and link consistently.\n- **No CSS preprocessors at runtime** — ship `.css`, not `.scss`. Compile first if you need Sass.\n- **Importmaps work** since they're inline JSON. Use them to avoid bundling React/etc. for prototypes — but watch the dual-React trap below.\n\n### The dual-React trap (the #1 cause of \"blank page + `useContext` is null`)\n\n`esm.sh` bundles a package's peer dependencies *into the package itself* unless you tell it otherwise. So this importmap looks correct but ships **two copies of React** — one for your app, another nested inside `framer-motion`:\n\n```html\n<!-- BROKEN: framer-motion has its own React inside, hooks crash with `Cannot read properties of null (reading 'useContext')` -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.2.0\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.2.0/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11\"\n  }\n}\n</script>\n```\n\nThe symptom is exactly: blank background + console errors like\n\n```\nTypeError: Cannot read properties of null (reading 'useContext')\n  at u (SwitchLayoutGroupContext.mjs:1:1)\n```\n\nbecause the nested React instance has no provider in the tree.\n\n**Fix: append `?external=react,react-dom` (and any other shared peers) to every esm.sh URL that has React as a peer dep.** Pin the exact same React version everywhere too.\n\n```html\n<!-- CORRECT: one React shared across the whole importmap -->\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"react\": \"https://esm.sh/react@18.3.1\",\n    \"react/jsx-runtime\": \"https://esm.sh/react@18.3.1/jsx-runtime\",\n    \"react-dom\": \"https://esm.sh/react-dom@18.3.1\",\n    \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\",\n    \"framer-motion\": \"https://esm.sh/framer-motion@11?external=react,react-dom\",\n    \"lucide-react\": \"https://esm.sh/lucide-react@0.400.0?external=react,react-dom\"\n  }\n}\n</script>\n```\n\nApply `?external=...` to **every** React-consuming dep (UI libraries, icon sets, animation libs, headless components). Same rule for `vue`, `solid-js`, etc. if you swap the framework.\n\nIf a project needs more than two or three of these packages, stop using the importmap path — switch to option 1 (pre-compile with Vite/esbuild) so the bundler can dedupe React for you.\n\n## npm dependencies\n\nThe bundler installs deps from npm at build time. Limits to respect:\n\n- **Flat node_modules.** No two versions of the same package can coexist — peer-dep conflicts will pick one and break the other. Keep dep graphs shallow.\n- **Text-only files** are extracted from tarballs. `.js`, `.ts`, `.json`, `.css`, `.md` work. `.wasm`, `.node`, native binaries, fonts, images do not.\n- **No PAX tar headers** — packages whose internal paths exceed 100 chars may have those files silently dropped. Avoid deeply-nested monorepo packages.\n- **No build scripts run** — `postinstall`, `prepare`, etc. are ignored. Packages that compile native code or run codegen at install time will not work.\n- **`cloudflare:*` imports are always external** and resolved by the runtime (`cloudflare:workers`, etc.). Don't add them to `package.json`.\n\nSafe and well-tested deps: `hono`, `zod`, `itty-router`, `nanoid`, `valibot`, `@hono/zod-validator`. Avoid anything that needs node-native modules unless you set `compatibility_flags: [\"nodejs_compat\"]` and the package is pure JS under that flag.\n\n## Project shapes\n\n| Project shape                       | What you need                                                                          |\n| ----------------------------------- | -------------------------------------------------------------------------------------- |\n| Pure static site (HTML+JS+CSS only) | `wrangler.json` with `[assets]`, files in `public/`. No `main`. No App class.          |\n| SPA + API                           | `[assets]` for the SPA, `main` exporting `class App extends DurableObject`             |\n| API only (JSON, no frontend)        | `main` exporting `class App extends DurableObject`, no `[assets]`                      |\n| Realtime (WebSocket / multiplayer)  | Same as SPA + API; `App.fetch` upgrades to WebSocket and uses `ctx.acceptWebSocket`    |\n\n## Pre-flight checklist before `deploy_space`\n\nRun through every item — most \"preview is broken\" reports trace back to one of these.\n\n- `wrangler.json` exists at repo root with `main` and (if static assets exist) `[assets].directory`.\n- Every browser-loaded `.js` / `.mjs` / `<script type=\"module\">` is **plain JS, no JSX**, or wrapped with `@babel/standalone` + `type=\"text/babel\"`.\n- If you use React via importmap (Babel-standalone or pre-compiled with the automatic JSX runtime), the importmap maps **all** of `react`, `react/jsx-runtime`, `react-dom`, and `react-dom/client` — same version on every entry. Missing `react/jsx-runtime` produces `Failed to resolve module specifier \"react/jsx-runtime\"` and a blank page.\n- The `<script type=\"importmap\">` tag appears **before** any `<script type=\"module\">` (or `type=\"text/babel\" data-type=\"module\"`) that depends on the mapped specifiers.\n- Static files live under the configured `assets.directory` (default suggestion: `public/`).\n- HTML uses root-relative paths (`/foo.js`) — they get rewritten. JS uses relative paths (`./foo`).\n- No binary assets are imported from npm packages. Binaries live in `public/`.\n- `package.json` deps are pure-JS, no native modules, no install scripts.\n- SPA routing? Set `not_found_handling: \"single-page-application\"`.\n- Server entry exports `class App extends DurableObject` from your `main` module (no `export default { fetch }`).\n- No `durable_objects` / `d1_databases` / `kv_namespaces` / `r2_buckets` blocks in `wrangler.json`.\n- `compatibility_date` is set if you use APIs newer than the default.\n\nIf any of these are off, fix them in the working tree, commit, and redeploy. The preview will pick up the new build on the next `deploy_space` call (the dynamic worker is keyed by commit hash, so old builds are not reused).\n\n## Minimal end-to-end example\n\nA working SPA with a tiny API:\n\n```\nwrangler.json\npackage.json\nsrc/index.ts\npublic/index.html\npublic/app.js\npublic/style.css\n```\n\n`wrangler.json`:\n\n```json\n{\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2025-04-01\",\n  \"assets\": {\n    \"directory\": \"./public\",\n    \"html_handling\": \"auto-trailing-slash\",\n    \"not_found_handling\": \"single-page-application\"\n  }\n}\n```\n\n`package.json`:\n\n```json\n{ \"name\": \"demo\", \"type\": \"module\", \"dependencies\": { \"hono\": \"^4.6.0\" } }\n```\n\n`src/index.ts`:\n\n```ts\nimport { DurableObject } from \"cloudflare:workers\";\nimport { Hono } from \"hono\";\n\nexport class App extends DurableObject {\n  private app = new Hono()\n    .get(\"/api/time\", (c) => c.json({ now: new Date().toISOString() }))\n    .post(\"/api/visit\", async (c) => {\n      this.ctx.storage.sql.exec(\n        `CREATE TABLE IF NOT EXISTS visits (id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER)`\n      );\n      this.ctx.storage.sql.exec(`INSERT INTO visits (ts) VALUES (?)`, Date.now());\n      const cnt = this.ctx.storage.sql\n        .exec(`SELECT COUNT(*) AS c FROM visits`)\n        .one().c as number;\n      return c.json({ count: cnt });\n    });\n\n  async fetch(request: Request) {\n    return this.app.fetch(request);\n  }\n}\n```\n\n`public/index.html`:\n\n```html\n<!doctype html>\n<html>\n<head>\n  <link rel=\"stylesheet\" href=\"/style.css\">\n</head>\n<body>\n  <div id=\"root\">Loading…</div>\n  <script type=\"module\" src=\"/app.js\"></script>\n</body>\n</html>\n```\n\n`public/app.js`:\n\n```js\nconst res = await fetch(\"./api/time\");\nconst data = await res.json();\ndocument.getElementById(\"root\").textContent = data.now;\n\n// Increment + display visit counter (server state lives in App.ctx.storage.sql)\nconst v = await fetch(\"./api/visit\", { method: \"POST\" }).then((r) => r.json());\ndocument.getElementById(\"root\").textContent += `  ·  visits: ${v.count}`;\n```\n\nNote `./api/time` (relative) in JS, `/style.css` (root-relative) in HTML. The HTML href is rewritten by the preview; the JS fetch resolves against the document's base URL.\n\nCommit this, call `deploy_space(\"main\")`, and the preview at `/space/<name>/preview/main/` will render the timestamp and an incrementing visit counter persisted in the App DO's SQLite.\n\n<!-- File: worker/agents/think/skills/frontend-design-landing-page/SKILL.md (Tokens: ~17186 | Category: anthropic-skill) -->\n---\nname: frontend-design-landing-page\ndescription: Marketing landing page and conversion-focused product page reference. Use this skill when building hero sections, feature grids, pricing pages, testimonials, CTAs, footers, navigation bars, or any public-facing marketing surface. Covers a warm, professional, developer-friendly design language (cream backgrounds, generous whitespace, pill CTAs, corner-bracket card decorations) and a complete token set, animation system, and copy-paste component snippets. NOT for product/dashboard UIs — use frontend-design-saas for those.\n---\n\n# Landing Page Design System\n\n> **AI-Optimized Design Reference** for building warm, professional, conversion-focused marketing landing pages, product pages, feature pages, and pricing pages.\n\nThis skill captures a specific landing-page register: warm cream backgrounds (NOT pure white), brown text (NOT pure black), a single bold accent for CTAs, pill-rounded buttons, and signature corner-bracket card decorations. It's tuned for developer-adjacent / B2B products — the aesthetic feels human and crafted without being playful or consumer-y. Pin the accent color to your brand; the rest of the system is reusable as-is.\n\n> **When to use this vs. `frontend-design-saas`:** Landing-page surfaces (homepage, /features, /pricing, /about, blog) → this skill. Product UI behind the login (dashboards, settings, tables) → `frontend-design-saas`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #FF4801 (replace with your brand) / #FF7038 (hover)\nBackground:      #FFFBF5 (warm cream) / #121212 (dark mode)\nText:            #521000 (warm brown) / #F0E3DE (dark mode)\nBorder:          #EBD5C1 (cream-tinted)\nFont Sans:       \"Inter\" / \"Geist\" / \"Manrope\" / any modern grotesk\nFont Mono:       \"JetBrains Mono\" / \"IBM Plex Mono\" / \"Geist Mono\"\nBase Spacing:    4px (multiples: 8, 12, 16, 24, 32, 48, 64)\nBorder Radius:   Buttons = pill (9999px), Cards = 12-16px, Inputs = 8px, Hero = 16-20px\nSignature:       Corner brackets on cards (8px decorative squares at corners)\n```\n\nThis is a **landing-page** register, not a product-UI register. Buttons are pills (consumer/marketing read), backgrounds are warm cream (inviting, not sterile), and decorative idioms like corner brackets are welcome. For product surfaces behind auth, use the SaaS skill instead.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Warm but Technical** | Cream tones soften technical content |\n| **Professional yet Approachable** | Modern typography, generous whitespace |\n| **Developer-Focused** | Monospace for code, terminal aesthetics |\n| **Performance-Oriented** | Smooth animations convey speed |\n\n### Visual Identity\n\n- **Never use pure white** (`#FFFFFF`) for backgrounds — always warm cream (`#FFFBF5`)\n- **Never use pure black** (`#000000`) for text — always warm brown (`#521000`)\n- **Orange is the accent**, not the dominant color\n- **Corner brackets** on cards are a signature decorative element\n- **Dot patterns** and **dashed lines** add visual texture\n\n---\n\n## 2. Color System\n\n### 2.1 Primary Palette (Light Mode)\n\n| Token | Hex | RGB | Usage |\n|-------|-----|-----|-------|\n| `--lp-accent` | `#FF4801` | `rgb(255, 72, 1)` | Primary accent, CTAs, links |\n| `--lp-accent-hover` | `#FF7038` | `rgb(255, 112, 56)` | Hover states |\n| `--lp-accent-light` | `rgba(255, 72, 1, 0.1)` | — | Badges, light backgrounds |\n| `--lp-text` | `#521000` | `rgb(82, 16, 0)` | Primary text |\n| `--lp-text-muted` | `rgba(82, 16, 0, 0.6)` | — | Secondary text |\n| `--lp-text-subtle` | `rgba(82, 16, 0, 0.38)` | — | Tertiary text, placeholders |\n| `--lp-bg-page` | `#F5F1EB` | `rgb(245, 241, 235)` | Page background (outer) |\n| `--lp-bg-100` | `#FFFBF5` | `rgb(255, 251, 245)` | Primary background |\n| `--lp-bg-200` | `#FFFDFB` | `rgb(255, 253, 251)` | Card backgrounds |\n| `--lp-bg-300` | `#FEF7ED` | `rgb(254, 247, 237)` | Hover backgrounds |\n| `--lp-border` | `#EBD5C1` | `rgb(235, 213, 193)` | Borders, dividers |\n| `--lp-border-light` | `rgba(235, 213, 193, 0.5)` | — | Subtle borders |\n\n### 2.2 Primary Palette (Dark Mode)\n\n| Token | Hex | Usage |\n|-------|-----|-------|\n| `--lp-accent` | `#F14602` | Primary accent |\n| `--lp-accent-hover` | `#FF6D33` | Hover states |\n| `--lp-text` | `#F0E3DE` | Primary text |\n| `--lp-text-muted` | `rgba(255, 253, 251, 0.56)` | Secondary text |\n| `--lp-bg-100` | `#121212` | Primary background |\n| `--lp-bg-200` | `#191817` | Card backgrounds |\n| `--lp-bg-300` | `#2A2927` | Hover backgrounds |\n| `--lp-border` | `rgba(240, 227, 222, 0.13)` | Borders |\n\n### 2.3 Category / Tag Colors (optional)\n\nIf your product has multiple feature categories that appear as tags or pills throughout the page (feature cards, navigation, etc.), define a small categorical palette. Keep luminance similar across the set so no single color overpowers the others.\n\n| Slot | Primary | Background | Example use |\n|----------|---------|------------|-------|\n| Category A | `#0A95FF` | `rgba(10, 149, 255, 0.1)` | \"Compute\" / \"Performance\" |\n| Category B | `#EE0DDB` | `rgba(238, 13, 219, 0.1)` | \"Storage\" / \"Data\" |\n| Category C | `#19E306` | `#F2F5E1` | \"AI\" / \"Intelligence\" |\n| Category D | `#9616FF` | `#F8EBEE` | \"Media\" / \"Edge\" |\n\nThese are placeholders — swap to your product's category palette. The goal is a small set of visually distinct accent colors used only on category labels, NEVER on primary CTAs (the brand accent owns that role).\n\n### 2.4 Semantic Colors\n\n| Purpose | Light Mode | Dark Mode |\n|---------|------------|-----------|\n| Success | `#16A34A` | `#4ADE80` |\n| Warning | `#EAB308` | `#FACC15` |\n| Error | `#DC2626` | `#F87171` |\n| Info | `#2563EB` | `#60A5FA` |\n\n---\n\n## 3. Typography\n\n### 3.1 Font Families\n\n```css\n--font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n--font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", \"Consolas\", monospace;\n```\n\n**Font Files:**\n- `Kunst Grotesk Regular.woff2` (400)\n- `Kunst Grotesk Medium.woff2` (500)\n- `JetBrains Mono Regular.woff2` (400)\n\n### 3.2 Type Scale\n\n| Name | Size | Line Height | Weight | Usage |\n|------|------|-------------|--------|-------|\n| `xs` | 12px (0.75rem) | 1.33 | 400 | Badges, captions, footnotes |\n| `sm` | 14px (0.875rem) | 1.43 | 400 | Secondary text, labels |\n| `base` | 16px (1rem) | 1.5 | 400 | Body text |\n| `lg` | 18px (1.125rem) | 1.56 | 400/500 | Large body, subheadings |\n| `xl` | 20px (1.25rem) | 1.4 | 500 | Section titles |\n| `2xl` | 24px (1.5rem) | 1.33 | 500 | Card headings |\n| `3xl` | 30px (1.875rem) | 1.2 | 500 | Section headings |\n| `4xl` | 36px (2.25rem) | 1.11 | 500 | Page headings |\n| `5xl` | 48px (3rem) | 1.0 | 500 | Hero headings |\n\n### 3.3 Font Weights\n\n| Weight | Value | Usage |\n|--------|-------|-------|\n| Normal | 400 | Body text, descriptions |\n| Medium | 500 | Headings, buttons, emphasis |\n\n### 3.4 Letter Spacing\n\n| Context | Value | CSS |\n|---------|-------|-----|\n| Headings | -0.02em | `letter-spacing: -0.02em` |\n| Body | Normal | `letter-spacing: normal` |\n| Uppercase labels | 0.05em | `letter-spacing: 0.05em` |\n| Logo text | -0.46px | `letter-spacing: -0.46px` |\n\n### 3.5 Text Rendering\n\n```css\nbody {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--spacing-unit: 4px;\n```\n\n### 4.2 Spacing Scale\n\n| Token | Value | Pixels | Common Usage |\n|-------|-------|--------|--------------|\n| `0` | 0 | 0px | Reset |\n| `0.5` | 0.125rem | 2px | Tiny gaps |\n| `1` | 0.25rem | 4px | Tight spacing |\n| `1.5` | 0.375rem | 6px | Small gaps |\n| `2` | 0.5rem | 8px | Default small padding |\n| `3` | 0.75rem | 12px | Input padding, card gaps |\n| `4` | 1rem | 16px | Standard padding |\n| `5` | 1.25rem | 20px | Medium spacing |\n| `6` | 1.5rem | 24px | Section padding (mobile) |\n| `8` | 2rem | 32px | Large padding |\n| `10` | 2.5rem | 40px | Section gaps |\n| `12` | 3rem | 48px | Large section gaps |\n| `16` | 4rem | 64px | Hero padding |\n| `20` | 5rem | 80px | Major sections |\n| `24` | 6rem | 96px | Max section spacing |\n\n### 4.3 Common Spacing Patterns\n\n```css\n/* Card padding */\npadding: 24px;  /* p-6 */\n\n/* Input padding */\npadding: 12px;  /* p-3 */\n\n/* Button padding */\npadding: 12px 24px;  /* py-3 px-6 */\n\n/* Section padding (responsive) */\npadding: 32px 16px;  /* Mobile */\npadding: 48px 32px;  /* Tablet */\npadding: 64px 48px;  /* Desktop */\n\n/* Grid gaps */\ngap: 16px;  /* Cards */\ngap: 24px;  /* Sections */\n```\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `rounded-sm` | 4px | Small badges |\n| `rounded` | 6px | Tags, small elements |\n| `rounded-md` | 8px | Inputs |\n| `rounded-lg` | 12px | Icon containers, panels |\n| `rounded-xl` | 16px | Hero sections |\n| `rounded-2xl` | 20px | Large hero sections |\n| `rounded-full` | 9999px | Buttons, pills, avatars |\n| `rounded-none` | 0 | Cards (sharp edges) |\n\n### Common Patterns\n\n```css\n/* Buttons - always fully rounded */\nborder-radius: 9999px;\n\n/* Cards - sharp edges */\nborder-radius: 0;\n\n/* Inputs */\nborder-radius: 8px;\n\n/* Hero sections (desktop) */\nborder-radius: 16px;\n\n/* Progress bars */\nborder-radius: 9999px;\n\n/* Icon containers */\nborder-radius: 8px;\n```\n\n---\n\n## 6. Shadow System\n\n### 6.1 Shadow Stack (Signature Effect)\n\nUsed on hero sections and elevated cards for depth with inner glow.\n\n```css\n/* Light Mode */\n--shadow-stack: \n  1px 6px 6px 0 rgba(255, 255, 255, 0.2) inset,\n  0 0 0px 0 rgba(255, 255, 255, 0.35) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n\n/* Dark Mode */\n--shadow-stack-dark:\n  1px 6px 16px 0 rgba(255, 255, 255, 0.05) inset,\n  0 4px 12px 0 rgba(0, 0, 0, 0.02),\n  0 2px 12px 0 rgba(0, 0, 0, 0.03);\n```\n\n### 6.2 Utility Shadows\n\n```css\n/* Subtle shadow for cards */\n--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n\n/* Standard card shadow */\n--shadow-card: \n  0 1px 3px rgba(82, 16, 0, 0.04),\n  0 4px 12px rgba(82, 16, 0, 0.02);\n\n/* Elevated shadow */\n--shadow-lg: \n  0 10px 15px -3px rgba(0, 0, 0, 0.1),\n  0 4px 6px -4px rgba(0, 0, 0, 0.1);\n\n/* Focus ring shadow */\n--shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n```\n\n---\n\n## 7. Animation System\n\n### 7.1 Timing Functions\n\n```css\n/* Standard ease-out (default for most transitions) */\n--ease-out: cubic-bezier(0, 0, 0.2, 1);\n\n/* Button interactions */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press states */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Smooth deceleration */\n--ease-decel: cubic-bezier(0.4, 0, 0.2, 1);\n```\n\n### 7.2 Duration Scale\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--duration-instant` | 100ms | Micro-interactions |\n| `--duration-fast` | 150ms | Default transitions |\n| `--duration-normal` | 200ms | Hover states |\n| `--duration-medium` | 300ms | Theme transitions |\n| `--duration-slow` | 500ms | Complex animations |\n| `--duration-long` | 1000ms | Page transitions |\n\n### 7.3 Standard Transitions\n\n```css\n/* Color transitions (default) */\ntransition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;\n\n/* Button transitions */\ntransition: all 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Card hover */\ntransition: box-shadow 0.2s ease, transform 0.2s ease;\n\n/* Input focus */\ntransition: border-color 0.15s ease, box-shadow 0.15s ease;\n```\n\n### 7.4 Keyframe Animations\n\n```css\n/* Fade in */\n@keyframes fadeIn {\n  from { opacity: 0; }\n  to { opacity: 1; }\n}\n\n/* Slide up */\n@keyframes slideUp {\n  from { opacity: 0; transform: translateY(10px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n/* Pulse (loading) */\n@keyframes pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.5; }\n}\n\n/* Progress bar fill */\n@keyframes progressFill {\n  from { width: 0; }\n  to { width: var(--progress-width); }\n}\n\n/* Infinite scroll (logos) */\n@keyframes infiniteScroll {\n  from { transform: translateX(0); }\n  to { transform: translateX(-50%); }\n}\n```\n\n### 7.5 Framer Motion Presets (React)\n\n```javascript\n// Fade in\nconst fadeIn = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  transition: { duration: 0.3 }\n};\n\n// Slide up\nconst slideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { opacity: 1, y: 0 },\n  transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] }\n};\n\n// Stagger children\nconst staggerContainer = {\n  animate: { transition: { staggerChildren: 0.1 } }\n};\n\n// Scale on hover\nconst scaleHover = {\n  whileHover: { scale: 1.02 },\n  whileTap: { scale: 0.98 }\n};\n\n// Button press\nconst buttonPress = {\n  whileTap: { scale: 0.98, y: 1 }\n};\n```\n\n---\n\n## 8. Layout System\n\n### 8.1 Container Widths\n\n| Token | Value | Usage |\n|-------|-------|-------|\n| `--container-sm` | 640px | Narrow content |\n| `--container-md` | 768px | Medium content |\n| `--container-lg` | 1024px | Standard content |\n| `--container-xl` | 1200px | Wide content |\n| `--container-2xl` | 1480px | Full-width sections |\n\n### 8.2 Breakpoints\n\n| Name | Min Width | Usage |\n|------|-----------|-------|\n| `sm` | 640px | Large phones |\n| `md` | 768px | Tablets |\n| `lg` | 1024px | Laptops |\n| `xl` | 1280px | Desktops |\n| `2xl` | 1536px | Large screens |\n\n### 8.3 Grid Patterns\n\n```css\n/* 2-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(2, 1fr);\ngap: 16px;\n\n/* 3-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(3, 1fr);\ngap: 16px;\n\n/* 4-column grid */\ndisplay: grid;\ngrid-template-columns: repeat(4, 1fr);\ngap: 16px;\n\n/* Calculator layout (2 columns, different widths) */\ndisplay: grid;\ngrid-template-columns: 1fr 1fr;\ngap: 24px;\n\n/* Responsive grid */\ndisplay: grid;\ngrid-template-columns: repeat(1, 1fr);  /* Mobile */\ngrid-template-columns: repeat(2, 1fr);  /* md: */\ngrid-template-columns: repeat(3, 1fr);  /* lg: */\n```\n\n### 8.4 Bento Grid\n\n```css\n/* Bento layout with varying sizes */\ndisplay: grid;\ngrid-template-columns: repeat(12, 1fr);\ngap: 8px;\n\n/* Bento cell sizes */\n.bento-sm { grid-column: span 4; }    /* 1/3 width */\n.bento-md { grid-column: span 6; }    /* 1/2 width */\n.bento-lg { grid-column: span 8; }    /* 2/3 width */\n.bento-full { grid-column: span 12; } /* Full width */\n```\n\n---\n\n## 9. Dark Mode Implementation\n\n### 9.1 Detection Script\n\nPlace in `<head>` before any styles load:\n\n```html\n<script>\n(function() {\n  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n    document.documentElement.classList.add('dark');\n  }\n})();\n</script>\n```\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --lp-accent: #FF4801;\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-border: #EBD5C1;\n}\n\n:root.dark, html.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n```\n\n### 9.3 Theme Transition\n\n```css\nhtml.theme-transitioning,\nhtml.theme-transitioning * {\n  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important;\n}\n```\n\n### 9.4 System Preference Listener\n\n```javascript\nconst mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\nmediaQuery.addEventListener('change', (e) => {\n  document.documentElement.classList.toggle('dark', e.matches);\n});\n```\n\n---\n\n## 10. Accessibility\n\n### 10.1 Focus States\n\n```css\n/* Default focus ring */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\n/* Button focus */\nbutton:focus-visible {\n  outline: none;\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.3);\n}\n\n/* Input focus */\ninput:focus-visible,\nselect:focus-visible,\ntextarea:focus-visible {\n  outline: none;\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n### 10.2 Disabled States\n\n```css\n:disabled,\n[disabled],\n.disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  pointer-events: none;\n}\n```\n\n### 10.3 Error States\n\n```css\n[aria-invalid=\"true\"],\n.input-error {\n  border-color: #DC2626;\n  box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);\n}\n\n.error-message {\n  color: #DC2626;\n  font-size: 14px;\n  margin-top: 4px;\n}\n```\n\n### 10.4 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n```\n\n### 10.5 Selection Styling\n\n```css\n::selection {\n  background-color: rgba(255, 72, 1, 0.2);\n  color: var(--lp-text);\n}\n```\n\n---\n\n## 11. Decorative Elements\n\n### 11.1 Corner Brackets\n\nSignature decorative element for cards:\n\n```css\n/* Corner bracket container */\n.corner-brackets {\n  position: relative;\n}\n\n/* Individual bracket */\n.corner-bracket {\n  position: absolute;\n  width: 8px;\n  height: 8px;\n  border: 1px solid var(--lp-border);\n  border-radius: 1.5px;\n  background: var(--lp-bg-100);\n}\n\n/* Positions */\n.corner-bracket.top-left { top: -4px; left: -4px; }\n.corner-bracket.top-right { top: -4px; right: -4px; }\n.corner-bracket.bottom-left { bottom: -4px; left: -4px; }\n.corner-bracket.bottom-right { bottom: -4px; right: -4px; }\n```\n\n### 11.2 Dot Pattern Background\n\n```css\n.dot-pattern {\n  background-image: radial-gradient(\n    circle,\n    var(--lp-border) 0.75px,\n    transparent 0.75px\n  );\n  background-size: 12px 12px;\n}\n```\n\n### 11.3 Dashed Line Borders\n\n```css\n/* Vertical dashed line */\n.dashed-line-vertical {\n  width: 1px;\n  background-image: linear-gradient(\n    to bottom,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 1px 16px;\n  background-repeat: repeat-y;\n}\n\n/* Horizontal dashed line */\n.dashed-line-horizontal {\n  height: 1px;\n  background-image: linear-gradient(\n    to right,\n    var(--lp-border) 50%,\n    transparent 50%\n  );\n  background-size: 16px 1px;\n  background-repeat: repeat-x;\n}\n```\n\n### 11.4 Gradient Masks\n\n```css\n/* Fade edges */\n.fade-left {\n  mask-image: linear-gradient(to right, transparent, black 20%);\n}\n\n.fade-right {\n  mask-image: linear-gradient(to left, transparent, black 20%);\n}\n\n.fade-both {\n  mask-image: linear-gradient(\n    to right,\n    transparent,\n    black 15%,\n    black 85%,\n    transparent\n  );\n}\n```\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border |\n|---------|------------|------|--------|\n| Primary | `#FFFBF5` | `#FF4801` | `#FFFBF5` |\n| Secondary | `#FF4801` | `#FFFBF5` | transparent |\n| Ghost | transparent | `#FF4801` | `#EBD5C1` |\n| Outline | transparent | `#521000` | `#EBD5C1` |\n\n### Cards\n\n| Variant | Background | Border | Shadow |\n|---------|------------|--------|--------|\n| Default | `#FFFDFB` | `#EBD5C1` | shadow-card |\n| Elevated | `#FFFBF5` | `#EBD5C1` | shadow-lg |\n| Interactive | `#FFFDFB` | `#EBD5C1` | hover: shadow-lg |\n\n### Inputs\n\n| State | Border | Shadow |\n|-------|--------|--------|\n| Default | `#EBD5C1` | none |\n| Focus | `#FF4801` | `0 0 0 3px rgba(255,72,1,0.1)` |\n| Error | `#DC2626` | `0 0 0 3px rgba(220,38,38,0.1)` |\n| Disabled | `#EBD5C1` | none, opacity: 0.5 |\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── styles/\n│   ├── tokens.css          # CSS custom properties\n│   ├── base.css            # Reset, typography, global styles\n│   ├── components/\n│   │   ├── buttons.css\n│   │   ├── cards.css\n│   │   ├── forms.css\n│   │   ├── navigation.css\n│   │   └── calculator.css\n│   └── utilities.css       # Helper classes\n├── fonts/\n│   ├── Kunst Grotesk Regular.woff2\n│   ├── Kunst Grotesk Medium.woff2\n│   └── JetBrains Mono Regular.woff2\n└── components/             # React/Vue components\n    ├── Button.tsx\n    ├── Card.tsx\n    ├── Input.tsx\n    └── Calculator/\n        ├── InputPanel.tsx\n        ├── OutputPanel.tsx\n        └── ComparisonBar.tsx\n```\n\n---\n\n## 14. CSS Custom Properties (Full Set)\n\nCopy this into your project's CSS:\n\n```css\n:root {\n  /* Colors - Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.1);\n  \n  /* Colors - Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.6);\n  --lp-text-subtle: rgba(82, 16, 0, 0.38);\n  \n  /* Colors - Backgrounds */\n  --lp-bg-page: #F5F1EB;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Colors - Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Colors - Semantic */\n  --lp-success: #16A34A;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  --lp-info: #2563EB;\n  \n  /* Colors - Product Categories */\n  --lp-compute: #0A95FF;\n  --lp-storage: #EE0DDB;\n  --lp-ai: #19E306;\n  --lp-media: #9616FF;\n  \n  /* Colors - Provider Comparisons */\n  --lp-aws: #FF9900;\n  --lp-gcp: #4285F4;\n  --lp-azure: #0078D4;\n  \n  /* Typography */\n  --font-sans: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", \"SF Mono\", \"Fira Code\", monospace;\n  \n  /* Spacing */\n  --spacing-unit: 4px;\n  \n  /* Border Radius */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-xl: 16px;\n  --radius-full: 9999px;\n  \n  /* Shadows */\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);\n  --shadow-card: 0 1px 3px rgba(82, 16, 0, 0.04), 0 4px 12px rgba(82, 16, 0, 0.02);\n  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.2);\n  \n  /* Transitions */\n  --ease-out: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 300ms;\n  \n  /* Containers */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1200px;\n  --container-2xl: 1480px;\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Animation Physics**\n> A specification so precise that a developer could rebuild it without seeing the original.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Grid System\n\n| Aspect | Value | Notes |\n|--------|-------|-------|\n| **Layout Type** | Asymmetrical fluid with max-width constraints | NOT a strict 12-column grid |\n| **Max Container** | `1480px` (homepage / wide marketing) / `1024px` (focused content / docs) | Content centered with `mx-auto` |\n| **Content Width** | `64rem` (1024px) for tools/calculators | Narrower for focused interfaces |\n| **Grid Columns** | 1-col mobile → 2-col tablet → responsive desktop | Uses CSS Grid, not flexbox grids |\n\n#### Spacing Constants (The \"Airiness\")\n\n```\nSection Padding (Y-axis):\n├── Mobile:   32px (py-8)\n├── Tablet:   48px (pt-12)\n└── Desktop:  64px-80px (py-16 to py-20)\n\nGrid Gaps (X-axis):\n├── Card grids:     24px (gap-6)\n├── Form elements:  24px (gap-6)\n├── Tight grids:    12px (gap-3)\n└── Use-case cards: 12px (gap-3)\n\nComponent Internal Padding:\n├── Cards:          24px (p-6) to 32px (p-8)\n├── Buttons:        12px 24px (py-3 px-6)\n├── Inputs:         12px (p-3)\n└── Hero sections:  48px-64px (p-12 to p-16)\n```\n\n#### Density Classification\n\n| Context | Density | Characteristics |\n|---------|---------|-----------------|\n| **Landing pages** | Expressive (Marketing) | Generous whitespace, large text, breathing room |\n| **Calculator tools** | Moderate | Balanced density, functional spacing |\n| **Data tables** | Compact | Tighter padding (py-3 pr-4) |\n\n### 15.2 Color Science & Elevation\n\n#### Primary Palette (Extracted Exact Values)\n\n```css\n/* Core landing-page palette */\n:root {\n  --lp-accent: #ff4801;        /* Primary accent - EXACT */\n  --lp-text: #521000;          /* Primary text - warm brown */\n  --lp-bg-page: #fffbf5;       /* Page background - warm cream */\n  --lp-border: #EBD5C1;        /* Border color */\n}\n\n/* Background Layers (Light Mode) */\n--lp-bg-100: rgb(255, 251, 245);  /* #FFFBF5 - Primary */\n--lp-bg-200: rgb(255, 253, 251);  /* #FFFDFB - Cards/elevated */\n--lp-bg-300: rgb(254, 247, 237);  /* #FEF7ED - Hover states */\n\n/* Text Opacity Variations */\n--lp-text-muted: rgba(82, 16, 0, 0.7);   /* #521000b3 - Secondary */\n--lp-text-subtle: rgba(82, 16, 0, 0.4);  /* #52100066 - Tertiary */\n```\n\n#### Semantic Colors (from components)\n\n| Purpose | Color | Usage Example |\n|---------|-------|---------------|\n| Success | `#16A34A` (green-600) | Savings badges, positive indicators |\n| Success Background | `#DCF7E3` (green-100) | Badge backgrounds |\n| Warning | `#EAB308` | Caution states |\n| Error | `#DC2626` | Error borders, messages |\n| Info | `#2563EB` | Informational highlights |\n\n#### Depth Strategy\n\n**NO Glassmorphism** - The design uses:\n\n1. **Solid backgrounds** with subtle layering (`bg-100` → `bg-200` → `bg-300`)\n2. **Border lines** for separation (1px solid `#EBD5C1`)\n3. **Minimal shadows** - shadows are subtle, not dramatic\n\n```css\n/* Card Shadow (Light/Subtle) */\n--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);\n\n/* Focus Shadow */\nbox-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n\n/* NO backdrop-filter: blur() usage */\n/* NO heavy drop shadows */\n```\n\n### 15.3 Typography & Micro-Copy Specs\n\n#### Type Personality: **Grotesk Sans**\n\nThe typography uses **Inter** - a modern grotesk with humanist touches. Fallback chain:\n```css\nfont-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, \n             Segoe UI, sans-serif, ui-sans-serif, system-ui, sans-serif;\n```\n\n#### Monospace for Code\n```css\nfont-family: JetBrains Mono, ui-monospace, SFMono-Regular, SF Mono, \n             Monaco, Consolas, monospace;\n```\n\n#### Hierarchy Definition\n\n| Level | Size | Weight | Line Height | Letter Spacing | Example |\n|-------|------|--------|-------------|----------------|---------|\n| **h1** | 24px-30px (`text-2xl` to `text-3xl`) | 500 (medium) | 1.2-1.33 | `-0.035em` | \"Pricing\", \"Build faster\" |\n| **h2** | 18px (`text-lg`) | 500 | 1.4 | normal | \"Pricing Details\" |\n| **h3** | 16px (`text-base`) | 500 | 1.5 | normal | Form labels |\n| **p** (body) | 14px-16px (`text-sm` to `text-base`) | 400 | 1.4-1.5 | normal | Descriptions |\n| **p** (muted) | 14px (`text-sm`) | 400 | 1.4 | normal | Secondary info |\n| **small** | 12px (`text-xs`) | 400 | 1.33 | normal | Footnotes, captions |\n\n#### Typography Style: **Tight and Medium Weight**\n\n- **Headings**: Tighter tracking (`letter-spacing: -0.035em`)\n- **Body**: Normal tracking\n- **Weight distribution**: Primarily 400 (regular) and 500 (medium)\n- **NO bold (700)** used in the interfaces\n\n### 15.4 Component Anatomy\n\n#### The \"Radius\" Strategy\n\n| Element | Radius | CSS |\n|---------|--------|-----|\n| **Buttons** | Hyper-rounded | `border-radius: 9999px` (rounded-full) |\n| **Inputs** | Soft | `border-radius: 8px` (rounded-lg) |\n| **Cards** | Sharp | `border-radius: 0` (no rounding) |\n| **Progress bars** | Hyper-rounded | `border-radius: 9999px` |\n| **Dropdowns** | Soft | `border-radius: 8px` |\n| **Badges/Pills** | Hyper-rounded | `border-radius: 9999px` |\n| **Hero sections** | Large soft | `border-radius: 16px` (md:rounded-2xl) |\n\n#### Interactive States\n\n**Hover Effects:**\n```css\n/* Buttons - Dashed border reveal */\n.button:hover {\n  border-style: dashed;\n  opacity: 0.95;\n}\n\n/* Cards - Dashed border */\n.card:hover {\n  border-style: dashed;\n}\n\n/* Links - Underline */\n.link:hover {\n  text-decoration: underline;\n}\n\n/* Background shift */\n.interactive:hover {\n  background-color: var(--lp-bg-300);  /* Warmer cream */\n}\n```\n\n**Active/Press States:**\n```css\nbutton:active {\n  transform: translateY(1px);\n  scale: 0.98;\n}\n```\n\n**Focus States:**\n```css\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus {\n  border-color: var(--lp-accent);\n  box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.1);\n}\n```\n\n**NO \"Grow\" effects, \"Glow\" borders, or \"Shimmer\" overlays** - The design is subtle and professional.\n\n#### Corner Brackets (Signature Element)\n\nThe 8px corner bracket decorations are the **signature decorative element** of this design language — they appear at the four outer corners of cards and feature blocks, suggesting precision and crafted technical detail without adding visual noise:\n\n```html\n<!-- Corner bracket structure -->\n<div class=\"pointer-events-none absolute inset-0 z-10 select-none\">\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;left:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"top:-4px;right:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"left:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n  <div class=\"absolute bg-cf-bg-100\" \n       style=\"right:-4px;bottom:-4px;width:8px;height:8px;border:1px solid #EBD5C1;border-radius:1.5px\"></div>\n</div>\n```\n\n### 15.5 Animation Physics (The Motion Signature)\n\n#### The Easing Curves\n\n```css\n/* Standard ease-out (most transitions) */\n--ease-standard: cubic-bezier(0, 0, 0.2, 1);  /* Tailwind's ease-out */\n\n/* Button interactions - High-end feel */\n--ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n\n/* Active/press response */\n--ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n\n/* Page entrance - Apple-style smooth deceleration */\n--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n```\n\n#### Duration Scale\n\n| Context | Duration | Usage |\n|---------|----------|-------|\n| Instant feedback | `0.15s` (150ms) | Color changes, opacity |\n| Standard transitions | `0.16s` (160ms) | Button presses |\n| Hover effects | `0.2s` (200ms) | Background color shifts |\n| Complex animations | `0.5s` (500ms) | Progress bars, entrance |\n| Page transitions | `2s` (2000ms) | Background fade-in |\n\n#### Orchestration: **Fade-Slide as Single Block**\n\nThe content does NOT stagger in one-by-one. Instead, entire sections fade and slide together:\n\n```css\n/* Page entrance animation */\n.animate-in {\n  animation: fadeSlideUp 0.5s ease-out forwards;\n}\n\n@keyframes fadeSlideUp {\n  from {\n    opacity: 0;\n    transform: translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n/* Background lines fade in slowly */\n.fade-in {\n  transition: opacity 2000ms ease-out;\n  transition-delay: 100ms;\n}\n```\n\n#### Micro-interactions\n\n**Button Press Physics:**\n```css\nbutton {\n  transition: scale 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94),\n              translate 0.16s cubic-bezier(0.25, 0.46, 0.45, 0.94);\n}\n\nbutton:active {\n  scale: 0.98;\n  transform: translateY(1px);\n}\n```\n\n**Progress Bar Animation:**\n```css\n.progress-bar {\n  transition: width 0.5s ease-out;\n}\n```\n\n**Slider Thumb:**\n```css\n.slider-thumb {\n  cursor: grab;\n  transition: box-shadow 0.15s ease;\n}\n\n.slider-thumb:active {\n  cursor: grabbing;\n}\n\n.slider-thumb:hover {\n  box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);\n}\n```\n\n**NO \"Magnetic\" follow effects or \"Tilt\" effects** - Interactions are clean and direct.\n\n### 15.6 Technical Deliverables\n\n#### Tailwind Configuration\n\n```javascript\n// tailwind.config.js\nmodule.exports = {\n  theme: {\n    extend: {\n      colors: {\n        'cf': {\n          'orange': '#FF4801',\n          'orange-hover': '#FF7038',\n          'orange-light': 'rgba(255, 72, 1, 0.06)',\n          'text': '#521000',\n          'text-muted': 'rgba(82, 16, 0, 0.7)',\n          'text-subtle': 'rgba(82, 16, 0, 0.4)',\n          'bg-page': '#FFFBF5',\n          'bg-100': '#FFFBF5',\n          'bg-200': '#FFFDFB',\n          'bg-300': '#FEF7ED',\n          'border': '#EBD5C1',\n          'border-light': 'rgba(235, 213, 193, 0.5)',\n        },\n        'aws-orange': '#FF9900',\n        'gcp-blue': '#4285F4',\n      },\n      fontFamily: {\n        sans: ['Inter', '-apple-system', 'system-ui', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],\n        mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Monaco', 'Consolas', 'monospace'],\n      },\n      fontSize: {\n        'sm': ['0.9rem', { lineHeight: '1.4' }],\n        'base': ['1rem', { lineHeight: '1.5' }],\n        'lg': ['1.125rem', { lineHeight: '1.75rem' }],\n        '2xl': ['1.5rem', { lineHeight: '2rem' }],\n        '3xl': ['1.875rem', { lineHeight: '2.25rem' }],\n      },\n      letterSpacing: {\n        'tight-heading': '-0.035em',\n        'logo': '-0.46px',\n      },\n      borderRadius: {\n        'lg': '0.5rem',\n        'xl': '0.75rem',\n        '2xl': '1rem',\n      },\n      transitionTimingFunction: {\n        'button': 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',\n        'active': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)',\n        'entrance': 'cubic-bezier(0.16, 1, 0.3, 1)',\n      },\n      transitionDuration: {\n        '160': '160ms',\n        '2000': '2000ms',\n      },\n      animation: {\n        'float-subtle': 'float-subtle 3s ease-in-out infinite',\n        'dash-draw': 'dashdraw 0.5s linear infinite',\n      },\n      keyframes: {\n        'float-subtle': {\n          '0%, 100%': { transform: 'translateY(0)' },\n          '50%': { transform: 'translateY(-3px)' },\n        },\n        'dashdraw': {\n          '0%': { strokeDashoffset: '10' },\n        },\n      },\n      boxShadow: {\n        'focus': '0 0 0 3px rgba(255, 72, 1, 0.1)',\n        'card': '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',\n      },\n      maxWidth: {\n        '5xl': '64rem',\n        '8xl': '1480px',\n      },\n    },\n  },\n  plugins: [],\n}\n```\n\n#### CSS Variables Block\n\n```css\n:root {\n  /* === COLORS === */\n  /* Primary */\n  --lp-accent: #FF4801;\n  --lp-accent-hover: #FF7038;\n  --lp-accent-light: rgba(255, 72, 1, 0.06);\n  \n  /* Text */\n  --lp-text: #521000;\n  --lp-text-muted: rgba(82, 16, 0, 0.7);\n  --lp-text-subtle: rgba(82, 16, 0, 0.4);\n  \n  /* Backgrounds */\n  --lp-bg-page: #FFFBF5;\n  --lp-bg-100: #FFFBF5;\n  --lp-bg-200: #FFFDFB;\n  --lp-bg-300: #FEF7ED;\n  \n  /* Borders */\n  --lp-border: #EBD5C1;\n  --lp-border-light: rgba(235, 213, 193, 0.5);\n  \n  /* Semantic */\n  --lp-success: #16A34A;\n  --lp-success-bg: #DCF7E3;\n  --lp-warning: #EAB308;\n  --lp-error: #DC2626;\n  \n  /* Provider Colors */\n  --aws-orange: #FF9900;\n  --gcp-blue: #4285F4;\n  \n  /* === TYPOGRAPHY === */\n  --font-sans: \"Inter\", -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  --font-mono: \"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Monaco, Consolas, monospace;\n  \n  /* === SPACING === */\n  --spacing-unit: 4px;\n  \n  /* === BORDER RADIUS === */\n  --radius-sm: 4px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-full: 9999px;\n  \n  /* === SHADOWS === */\n  --shadow-focus: 0 0 0 3px rgba(255, 72, 1, 0.1);\n  --shadow-card: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);\n  --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);\n  \n  /* === TRANSITIONS === */\n  --ease-standard: cubic-bezier(0, 0, 0.2, 1);\n  --ease-button: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --ease-active: cubic-bezier(0.55, 0.085, 0.68, 0.53);\n  --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);\n  \n  --duration-instant: 100ms;\n  --duration-fast: 150ms;\n  --duration-normal: 200ms;\n  --duration-slow: 500ms;\n  \n  /* === CONTAINERS === */\n  --container-sm: 640px;\n  --container-md: 768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1480px;\n}\n\n/* Dark Mode */\n:root.dark {\n  --lp-accent: #F14602;\n  --lp-text: #F0E3DE;\n  --lp-text-muted: rgba(255, 253, 251, 0.56);\n  --lp-bg-page: #0D0D0D;\n  --lp-bg-100: #121212;\n  --lp-bg-200: #191817;\n  --lp-bg-300: #2A2927;\n  --lp-border: rgba(240, 227, 222, 0.13);\n}\n\n/* Base Styles */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n  margin: 0;\n  background-color: var(--lp-bg-page);\n  color: var(--lp-text);\n  font-family: var(--font-sans);\n  overflow-x: hidden;\n}\n\n/* Focus States */\n:focus-visible {\n  outline: 2px solid var(--lp-accent);\n  outline-offset: 2px;\n}\n\ninput:focus,\nselect:focus {\n  border-color: var(--lp-accent);\n  box-shadow: var(--shadow-focus);\n}\n\n/* Transitions */\nbutton,\na,\ninput,\nselect {\n  transition: all 0.2s ease-in-out;\n}\n```\n\n#### Framer Motion Variants\n\n```javascript\n// framer-motion-variants.js\n\n// Page entrance animation\nexport const pageEntrance = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 0.5, \n      ease: [0.16, 1, 0.3, 1] \n    }\n  }\n};\n\n// Section slide-up\nexport const sectionSlideUp = {\n  initial: { opacity: 0, y: 20 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.4, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Stagger container (for card grids)\nexport const staggerContainer = {\n  animate: {\n    transition: {\n      staggerChildren: 0.08,\n      delayChildren: 0.1\n    }\n  }\n};\n\n// Stagger child item\nexport const staggerItem = {\n  initial: { opacity: 0, y: 16 },\n  animate: { \n    opacity: 1, \n    y: 0,\n    transition: { \n      duration: 0.35, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Button hover and tap\nexport const buttonInteraction = {\n  whileHover: { scale: 1.01 },\n  whileTap: { \n    scale: 0.98, \n    y: 1,\n    transition: { \n      duration: 0.16, \n      ease: [0.55, 0.085, 0.68, 0.53] \n    }\n  }\n};\n\n// Card hover\nexport const cardHover = {\n  initial: { scale: 1 },\n  whileHover: { \n    scale: 1.01,\n    transition: { \n      duration: 0.2, \n      ease: [0.25, 0.46, 0.45, 0.94] \n    }\n  }\n};\n\n// Progress bar fill\nexport const progressFill = {\n  initial: { width: 0 },\n  animate: (width) => ({\n    width: `${width}%`,\n    transition: { \n      duration: 0.5, \n      ease: \"easeOut\" \n    }\n  })\n};\n\n// Floating animation (for icons/decorations)\nexport const floatSubtle = {\n  animate: {\n    y: [0, -3, 0],\n    transition: {\n      duration: 3,\n      ease: \"easeInOut\",\n      repeat: Infinity\n    }\n  }\n};\n\n// Background fade-in (slow entrance)\nexport const backgroundFadeIn = {\n  initial: { opacity: 0 },\n  animate: { \n    opacity: 1,\n    transition: { \n      duration: 2, \n      delay: 0.1,\n      ease: \"easeOut\" \n    }\n  }\n};\n\n// Usage example with React\n/*\nimport { motion } from 'framer-motion';\nimport { pageEntrance, staggerContainer, staggerItem } from './framer-motion-variants';\n\nfunction Page() {\n  return (\n    <motion.main {...pageEntrance}>\n      <motion.div \n        variants={staggerContainer}\n        initial=\"initial\"\n        animate=\"animate\"\n        className=\"grid grid-cols-3 gap-6\"\n      >\n        {items.map((item) => (\n          <motion.div key={item.id} variants={staggerItem}>\n            {item.content}\n          </motion.div>\n        ))}\n      </motion.div>\n    </motion.main>\n  );\n}\n*/\n```\n\n---\n\n*Synthesized from analysis of warm-cream developer-marketing landing pages. The aesthetic — warm cream backgrounds, brown text, single bold accent for CTAs, corner-bracket decorations — is portable to any B2B / developer-tools / dev-adjacent product brand.*\n\n\n\n# ===== SNIPPETS.md =====\n\n# Landing Page Design - Component Snippets\n\n> **Copy-paste ready components** for building warm-cream landing-page interfaces.\n> Each snippet includes React + Tailwind AND Vanilla HTML versions.\n\n---\n\n## Table of Contents\n\n1. [Buttons](#buttons)\n2. [Cards](#cards)\n3. [Forms](#forms)\n4. [Calculator Tools](#calculator-tools)\n5. [Navigation](#navigation)\n6. [Hero Sections](#hero-sections)\n7. [Data Display](#data-display)\n8. [Layout](#layout)\n9. [Decorative](#decorative)\n\n---\n\n# Buttons\n\n## BTN-PRIMARY\n\nPrimary CTA button - cream background, orange text, fully rounded.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFBF5] text-[#FF4801] border border-[#FFFBF5] transition-all duration-150 ease-out hover:bg-transparent hover:border-[#FF4801] active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Get started\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFBF5;\n  color: #FF4801;\n  border: 1px solid #FFFBF5;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.background='transparent'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.background='#FFFBF5'; this.style.borderColor='#FFFBF5';\">\n  Get started\n</button>\n```\n\n---\n\n## BTN-SECONDARY\n\nSecondary button - orange background, white text.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 ease-out hover:opacity-95 hover:border-dashed hover:border-white/50 active:scale-[0.98] active:translate-y-[1px] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/30 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Learn more\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: 1px solid transparent;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.opacity='0.95';\" onmouseout=\"this.style.opacity='1';\">\n  Learn more\n</button>\n```\n\n---\n\n## BTN-GHOST\n\nGhost button - transparent with border, for secondary actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-transparent text-[#FF4801] border border-[#EBD5C1] transition-all duration-150 ease-out hover:border-dashed hover:border-[#FF4801] hover:text-[#521000] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20 disabled:opacity-50 disabled:cursor-not-allowed\">\n  View docs\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: transparent;\n  color: #FF4801;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" onmouseover=\"this.style.borderStyle='dashed'; this.style.borderColor='#FF4801';\" onmouseout=\"this.style.borderStyle='solid'; this.style.borderColor='#EBD5C1';\">\n  View docs\n</button>\n```\n\n---\n\n## BTN-OUTLINE\n\nOutline button - for less prominent actions.\n\n### React + Tailwind\n\n```jsx\n<button className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:border-dashed active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-[#EBD5C1]/50 disabled:opacity-50 disabled:cursor-not-allowed\">\n  Cancel\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\">\n  Cancel\n</button>\n```\n\n---\n\n## BTN-ICON\n\nIcon-only button with tooltip.\n\n### React + Tailwind\n\n```jsx\n<button \n  className=\"inline-flex items-center justify-center w-10 h-10 rounded-full bg-[#FFFDFB] text-[#521000] border border-[#EBD5C1] transition-all duration-150 ease-out hover:bg-[#FEF7ED] hover:text-[#FF4801] hover:border-[#FF4801] active:scale-[0.95] focus:outline-none focus:ring-2 focus:ring-[#FF4801]/20\"\n  aria-label=\"Settings\"\n  title=\"Settings\"\n>\n  <svg className=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={1.5}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 40px;\n  height: 40px;\n  border-radius: 9999px;\n  background: #FFFDFB;\n  color: #521000;\n  border: 1px solid #EBD5C1;\n  cursor: pointer;\n  transition: all 0.15s ease;\n\" aria-label=\"Settings\" title=\"Settings\">\n  <svg width=\"20\" height=\"20\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z\" />\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z\" />\n  </svg>\n</button>\n```\n\n---\n\n## BTN-LINK\n\nLink styled as text with arrow.\n\n### React + Tailwind\n\n```jsx\n<a href=\"#\" className=\"inline-flex items-center gap-1 font-medium text-[#FF4801] hover:underline hover:underline-offset-4 transition-all duration-150\">\n  View documentation\n  <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n### Vanilla HTML\n\n```html\n<a href=\"#\" style=\"\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  color: #FF4801;\n  text-decoration: none;\n  transition: all 0.15s ease;\n\">\n  View documentation\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\">\n    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3\" />\n  </svg>\n</a>\n```\n\n---\n\n## BTN-LOADING\n\nButton with loading spinner state.\n\n### React + Tailwind\n\n```jsx\n<button \n  disabled\n  className=\"inline-flex items-center justify-center gap-2 px-6 py-3 rounded-full font-medium bg-[#FF4801] text-white border border-transparent transition-all duration-150 disabled:opacity-70 disabled:cursor-not-allowed\"\n>\n  <svg className=\"animate-spin h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\">\n    <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n    <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n```\n\n### Vanilla HTML\n\n```html\n<button disabled style=\"\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  padding: 12px 24px;\n  border-radius: 9999px;\n  font-family: 'Inter', sans-serif;\n  font-weight: 500;\n  font-size: 16px;\n  background: #FF4801;\n  color: white;\n  border: none;\n  opacity: 0.7;\n  cursor: not-allowed;\n\">\n  <svg width=\"16\" height=\"16\" fill=\"none\" viewBox=\"0 0 24 24\" style=\"animation: spin 1s linear infinite;\">\n    <circle opacity=\"0.25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\" />\n    <path opacity=\"0.75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\" />\n  </svg>\n  Processing...\n</button>\n<style>\n  @keyframes spin { to { transform: rotate(360deg); } }\n</style>\n```\n\n---\n\n# Cards\n\n## CARD-DEFAULT\n\nStandard card with corner bracket decorations.\n\n### React + Tailwind\n\n```jsx\n<div className=\"relative bg-[#FFFDFB] border border-[#EBD5C1] p-6 shadow-[0_1px_3px_rgba(82,16,0,0.04),0_4px_12px_rgba(82,16,0,0.02)]\">\n  {/* Corner brackets */}\n  <div className=\"absolute -top-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -top-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -left-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  <div className=\"absolute -bottom-1 -right-1 w-2 h-2 border border-[#EBD5C1] rounded-[1.5px] bg-[#FFFBF5]\" />\n  \n  <h3 className=\"text-lg font-medium text-[#521000] mb-2\">Card Title</h3>\n  <p className=\"text-sm text-[#521000]/60 leading-relaxed\">\n    Card description goes here. This is a standard card with the signature corner bracket decorations.\n  </p>\n</div>\n```\n\n### Vanilla HTML\n\n```html\n<div \n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: worker/agents/think/skills/frontend-design-saas/SKILL.md (Tokens: ~16162 | Category: anthropic-skill) -->\n---\nname: frontend-design-saas\ndescription: S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear.\n---\n\n# SaaS Design System (Stripe / Linear / Vercel lineage)\n\n> **AI-Optimized Design Reference** for building S-tier SaaS dashboards, admin consoles, billing pages, settings UIs, data-dense application shells, and product surfaces.\n>\n> Tonal anchors: `stripe.com/dashboard`, `linear.app`, `vercel.com/dashboard`, `notion.so/teamspace`, `clerk.com`, `planetscale.com/console`.\n\n---\n\n## Quick Reference (TL;DR)\n\n```\nBrand Accent:    #5E6AD2 (indigo) - replace with product brand\nNeutrals:        #FFFFFF → #F8FAFC → #F1F5F9 → #E2E8F0 → #94A3B8 → #475569 → #0F172A\nText:            #0F172A (primary) / #475569 (muted) / #94A3B8 (subtle)\nBorder:          #E2E8F0 (default) / #CBD5E1 (strong)\nFont Sans:       \"Inter\", \"Geist\", system-ui (NOT Arial, NOT Roboto)\nFont Mono:       \"JetBrains Mono\", \"Geist Mono\", ui-monospace\nBase Spacing:    4px (scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64)\nBorder Radius:   Buttons/inputs = 6-8px (NOT pill), Cards = 8-12px, Modals = 12-16px\nShadow:          Tight, low-spread, single-direction (NOT diffuse marketing shadows)\nDensity:         Compact-to-comfortable; row heights 36-44px, button heights 32-36px\n```\n\nThe fastest way to get this wrong: use marketing-page aesthetics (huge type, generous whitespace, pill buttons, gradient hero shadows) in a product surface. SaaS UIs are **dense, neutral, fast, and quiet**. The brand color appears in <5% of the pixels and earns its weight by marking only what's interactive or active.\n\n---\n\n## 1. Brand Foundation\n\n### Design Philosophy\n\n| Principle | Description |\n|-----------|-------------|\n| **Users First** | Workflows, keyboard navigation, and information density beat decoration. |\n| **Quiet by Default** | Neutrals dominate; the brand color is reserved for primary action, active state, and focus. |\n| **Speed & Density** | Snappy transitions (≤200ms), compact row heights, no entrance animations on every page load. |\n| **Opinionated Defaults** | Single canonical pattern per surface (one button hierarchy, one date picker, one empty state). |\n| **Meticulous Craft** | 1px borders aligned to pixel grid, focus rings 2px offset 2px, motion uses ease-out not linear. |\n| **Accessible (WCAG AA+)** | 4.5:1 text contrast, 3:1 UI element contrast, every interactive surface keyboard-reachable. |\n| **Predictable** | Same component looks/behaves identically on every page; no marketing-grade variants. |\n\n### Visual Identity Rules\n\n- **Do not use pure black on pure white.** Use `#0F172A` on `#FFFFFF` (or `#0B1220` on `#0A0E1A` in dark mode).\n- **Brand color is the accent, never the dominant.** If the brand color fills more than ~5% of the viewport in steady state, something is wrong.\n- **Avoid pill buttons** (`border-radius: 9999px`) for SaaS actions. Pills read as marketing/consumer; SaaS uses `6-8px` radius for buttons and inputs.\n- **No glassmorphism, no blur, no gradient borders.** The aesthetic is precision, not richness.\n- **Shadows are tight and downward.** Not diffuse, not multi-direction. Reserved for floating layers (popovers, modals, dropdowns).\n- **One font.** Inter / Geist / IBM Plex Sans / native system stack. Avoid mixing display + body fonts; SaaS uses a single workhorse sans.\n- **Mono for IDs, code, tokens, money.** Tabular figures (`font-variant-numeric: tabular-nums`) for any column of numbers.\n\n### Reference apps to mentally calibrate against\n\n| Surface to build | Look at |\n|---|---|\n| Application shell | Linear, Vercel, Stripe Dashboard |\n| Data table | Stripe `/payments`, Linear backlog, PlanetScale branches |\n| Settings page | Vercel project settings, Clerk dashboard, GitHub repo settings |\n| Billing / invoice | Stripe billing, Vercel usage |\n| Empty state | Linear, Notion, Vercel deployments |\n| Command palette | Linear ⌘K, Vercel ⌘K, Raycast |\n| Auth | Clerk hosted pages, Stripe Connect onboarding |\n| Onboarding checklist | Stripe Atlas, Vercel project import |\n\n---\n\n## 2. Color System\n\n### 2.1 Neutral Scale (the backbone)\n\nSaaS UIs are 80–90% neutrals. The neutral scale carries the entire interface; brand is sprinkled on top. Use a slightly cool neutral (slate) by default — it reads more \"product\" than warm grays which feel marketing/lifestyle.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--neutral-0` | `#FFFFFF` | Page background (light mode), surface lifted onto canvas |\n| `--neutral-50` | `#F8FAFC` | Canvas background, alternating row stripe |\n| `--neutral-100` | `#F1F5F9` | Hover background, subtle fill, table header |\n| `--neutral-200` | `#E2E8F0` | **Default border**, divider |\n| `--neutral-300` | `#CBD5E1` | Strong border, input border on focus-within parent |\n| `--neutral-400` | `#94A3B8` | **Subtle text** (placeholders, disabled, captions) |\n| `--neutral-500` | `#64748B` | Tertiary text, icon default |\n| `--neutral-600` | `#475569` | **Muted text** (descriptions, secondary labels) |\n| `--neutral-700` | `#334155` | Strong icon, hover text |\n| `--neutral-800` | `#1E293B` | Heading in dense UIs, code text |\n| `--neutral-900` | `#0F172A` | **Primary text**, primary icons |\n| `--neutral-950` | `#020617` | Highest contrast (rare; only for critical labels) |\n\n### 2.2 Brand Accent (single color, used sparingly)\n\nPick **one** accent. Use a 6-step ramp so hover/active/focused/disabled all derive from the same hue. Defaults below use indigo (`#5E6AD2`, Linear's accent) — swap the hue for your brand but keep the structure.\n\n| Token | Hex | Usage |\n|---|---|---|\n| `--brand-50` | `#EEF0FB` | Selected row background, badge background, focus ring background |\n| `--brand-100` | `#DDE0F7` | Hover on brand surfaces |\n| `--brand-200` | `#B9C0EF` | Disabled brand button, decorative |\n| `--brand-500` | `#7B85DC` | Brand hover surface stroke |\n| `--brand-600` | `#5E6AD2` | **Primary brand** — default button bg, link text, focus ring, active nav item |\n| `--brand-700` | `#4A56C0` | Hover state for primary button |\n| `--brand-800` | `#3A45A8` | Active/pressed state |\n| `--brand-900` | `#2A3284` | High-contrast brand text on light background |\n\n### 2.3 Semantic / Status Colors\n\nUsed for state — never for decoration. Each has a `text`, `bg`, `border` triplet so badges and inline messages stay legible.\n\n| State | `text` | `bg` (filled) | `bg-subtle` (badge) | `border` |\n|---|---|---|---|---|\n| **Success** | `#15803D` | `#16A34A` | `#DCFCE7` | `#86EFAC` |\n| **Warning** | `#A16207` | `#CA8A04` | `#FEF9C3` | `#FDE68A` |\n| **Error / Destructive** | `#B91C1C` | `#DC2626` | `#FEE2E2` | `#FCA5A5` |\n| **Info** | `#1D4ED8` | `#2563EB` | `#DBEAFE` | `#93C5FD` |\n| **Neutral (default badge)** | `#334155` | `#64748B` | `#F1F5F9` | `#CBD5E1` |\n\nRules:\n- Inline form errors: `text` color on `bg-subtle` background, `border` for the input.\n- Toasts: `bg-subtle` panel, `text` heading, `--neutral-700` body.\n- Status badges: `text` on `bg-subtle` + 1px `border`. Never use solid `bg` for badges (too loud).\n\n### 2.4 Data Visualization Palette\n\nWhen you need >1 color for charts. Categorical — visually distinct, similar luminance. Avoid the brand color in this palette so brand reads as \"interactive\" elsewhere.\n\n```\n--chart-1: #2563EB  (blue)\n--chart-2: #16A34A  (green)\n--chart-3: #CA8A04  (amber)\n--chart-4: #DC2626  (red)\n--chart-5: #9333EA  (violet)\n--chart-6: #0891B2  (cyan)\n--chart-7: #DB2777  (pink)\n--chart-8: #65A30D  (lime)\n```\n\n### 2.5 Dark Mode Mapping\n\nSaaS dark modes are **not just inverted**. They are slightly desaturated, slightly lifted, with carefully tuned elevation. Use `#0A0E1A` (or `#0B0F1C`) as the canvas — NEVER `#000000`. Pure black creates harsh contrast and exposes any banding in shadows.\n\n| Token | Light | Dark |\n|---|---|---|\n| `--bg-canvas` | `#F8FAFC` | `#0A0E1A` |\n| `--bg-surface` | `#FFFFFF` | `#111827` |\n| `--bg-elevated` | `#FFFFFF` | `#1F2937` |\n| `--bg-overlay` (popover/modal) | `#FFFFFF` | `#1F2937` |\n| `--text-primary` | `#0F172A` | `#F1F5F9` |\n| `--text-muted` | `#475569` | `#94A3B8` |\n| `--text-subtle` | `#94A3B8` | `#64748B` |\n| `--border-default` | `#E2E8F0` | `#1F2937` |\n| `--border-strong` | `#CBD5E1` | `#374151` |\n| `--brand-bg-subtle` | `#EEF0FB` | `rgba(94, 106, 210, 0.16)` |\n| `--brand-fg` | `#5E6AD2` | `#A5B4FC` |\n\nIn dark mode the **brand color shifts lighter** (toward `--brand-500/400` instead of `--brand-600/700`) to maintain contrast against the dark canvas.\n\n---\n\n## 3. Typography\n\n### 3.1 Font Stack\n\n```css\n--font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n             \"SF Pro Text\", system-ui, sans-serif;\n--font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n             ui-monospace, monospace;\n```\n\nHard rules:\n- **Never use `Arial`, `Helvetica`, `Roboto`, or `Times`** in a SaaS UI. These read as default/unstyled. If you can't load a webfont, fall through to `system-ui` instead.\n- **Never mix two display fonts.** One sans + one mono is the entire stack.\n- **`font-feature-settings: \"cv11\", \"ss01\", \"ss03\"`** for Inter (or whatever stylistic sets your font has) — small caps and alternate forms make tables look intentional.\n- **`font-variant-numeric: tabular-nums`** on EVERY column that contains numbers (money, dates, IDs, counts). Non-tabular figures make rows shimmer.\n\n### 3.2 Type Scale (SaaS-tuned — denser than marketing)\n\n| Token | Size | Line Height | Weight | Letter Spacing | Use |\n|---|---|---|---|---|---|\n| `text-2xs` | 11px / 0.6875rem | 1.36 (15px) | 500 | 0.04em | Microlabels (uppercase metadata, table column tags) |\n| `text-xs` | 12px / 0.75rem | 1.33 (16px) | 400/500 | 0 | Captions, badge text, table secondary |\n| `text-sm` | 13px / 0.8125rem | 1.38 (18px) | 400/500 | 0 | **Default UI text**: table rows, buttons, form labels |\n| `text-base` | 14px / 0.875rem | 1.43 (20px) | 400/500 | 0 | Body text, paragraph copy in panels |\n| `text-md` | 15px / 0.9375rem | 1.47 (22px) | 400/500 | 0 | Comfortable reading (descriptions, modals) |\n| `text-lg` | 16px / 1rem | 1.5 (24px) | 500/600 | -0.005em | Card headings, sidebar section labels |\n| `text-xl` | 18px / 1.125rem | 1.44 (26px) | 600 | -0.01em | Subsection headings |\n| `text-2xl` | 20px / 1.25rem | 1.4 (28px) | 600 | -0.015em | Page subheadings |\n| `text-3xl` | 24px / 1.5rem | 1.33 (32px) | 600 | -0.02em | Page title (h1 in dashboards) |\n| `text-4xl` | 30px / 1.875rem | 1.2 (36px) | 600/700 | -0.025em | Marketing-adjacent hero in settings |\n\n**Critical**: the default UI size in SaaS is **13px or 14px**, NOT 16px. Stripe is 14px. Linear is 13px. Vercel is 14px. 16px feels too big and creates pages that scroll forever.\n\n### 3.3 Weights\n\n| Weight | Value | Use |\n|---|---|---|\n| Regular | 400 | Body, table cells, descriptions |\n| Medium | 500 | Default for buttons, labels, table headers, navigation, badge text |\n| Semibold | 600 | Headings, emphasized stats, active nav |\n| Bold | 700 | Rare — only marketing-adjacent surfaces or KPI numbers |\n\n**Never** use weight 800 or 900 in a SaaS UI.\n\n### 3.4 Letter Spacing\n\n| Context | Value |\n|---|---|\n| Body / table | 0 |\n| Headings ≥18px | -0.01em to -0.025em (tighter as size grows) |\n| Microlabels / uppercase | 0.04em to 0.06em |\n| Mono | 0 |\n\n### 3.5 Text Rendering\n\n```css\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1;\n}\n\n.numeric, table, [data-numeric] {\n  font-variant-numeric: tabular-nums;\n}\n```\n\n---\n\n## 4. Spacing System\n\n### 4.1 Base Unit\n\n```css\n--space-unit: 4px;\n```\n\n### 4.2 Scale\n\n| Token | px | rem | Common Use |\n|---|---|---|---|\n| `space-0` | 0 | 0 | Reset |\n| `space-px` | 1 | — | Hairlines, dividers |\n| `space-0.5` | 2 | 0.125 | Tight icon-to-text gaps |\n| `space-1` | 4 | 0.25 | Inline tight |\n| `space-1.5` | 6 | 0.375 | — |\n| `space-2` | 8 | 0.5 | Default tight gap, icon-text, badge padding |\n| `space-2.5` | 10 | 0.625 | Input vertical padding (compact) |\n| `space-3` | 12 | 0.75 | Input vertical padding (default), small card |\n| `space-4` | 16 | 1 | **Default card/panel padding**, form field gaps |\n| `space-5` | 20 | 1.25 | Section subheading gap |\n| `space-6` | 24 | 1.5 | Card padding (comfortable), section internal |\n| `space-8` | 32 | 2 | Page section gap, large card padding |\n| `space-10` | 40 | 2.5 | Major section gap |\n| `space-12` | 48 | 3 | Page-level section gap |\n| `space-16` | 64 | 4 | Hero, max page-section |\n\n**Density convention** (which scale value to default to):\n\n- Buttons: `8px 12px` (sm), `8px 14px` (md), `10px 16px` (lg) — vertical padding stays tight\n- Inputs: `8px 12px` (sm), `9px 12px` (md), `10px 14px` (lg)\n- Cards: `16px` (sm), `20-24px` (md), `32px` (lg / settings panel)\n- Table cells: `10px 16px` (compact), `12px 16px` (default), `16px 20px` (comfortable)\n- Page padding: `24px` (mobile) → `32px` (desktop) → `48px` (wide settings)\n\n---\n\n## 5. Border Radius\n\n| Token | Value | Use |\n|---|---|---|\n| `--radius-none` | 0 | Table cells, full-bleed sections |\n| `--radius-sm` | 4px | Badges, tags, small chips |\n| `--radius-md` | 6px | **Default**: buttons, inputs, dropdowns, menu items |\n| `--radius-lg` | 8px | Cards, panels, popovers |\n| `--radius-xl` | 12px | Modals, large surfaces, sheets |\n| `--radius-2xl` | 16px | Marketing-adjacent (pricing card, onboarding) |\n| `--radius-full` | 9999px | Avatars, status dots, **NOT buttons** |\n\nRules:\n- **Buttons in SaaS UIs use 6px, not pill (`9999px`).** Pills are for marketing/consumer apps. The exception is a \"tag\" or \"chip\" inside a table cell.\n- **Match input and button radius.** If button = 6px, input = 6px. Visual consistency in form rows.\n- **Cards never share radius with modals.** Cards = 8px, modals = 12px+. This is how the modal reads as a different elevation layer.\n\n---\n\n## 6. Shadow System (Elevation)\n\nSaaS shadows are **tight, downward, single-direction**. They mark elevation; they do not decorate. No glows, no colored shadows, no inset highlights, no large spread radii.\n\n### 6.1 Elevation Tokens\n\n```css\n/* Light mode */\n--shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n--shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05),\n              0 1px 3px 0 rgba(15, 23, 42, 0.06);\n--shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06),\n              0 4px 6px -2px rgba(15, 23, 42, 0.04);\n--shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05),\n              0 10px 15px -3px rgba(15, 23, 42, 0.08);\n--shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06),\n              0 20px 25px -5px rgba(15, 23, 42, 0.10);\n--shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n\n/* Focus ring (NOT a shadow, but the only \"glow\" allowed) */\n--ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n--ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n/* Dark mode: shadows almost invisible; elevation is conveyed via background lightening */\n--shadow-xs-dark:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n--shadow-sm-dark:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n--shadow-md-dark:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n--shadow-lg-dark:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n```\n\n### 6.2 Elevation → Component Mapping\n\n| Layer | Shadow | Examples |\n|---|---|---|\n| 0 (flat on canvas) | none | Sidebar, page body, inline rows |\n| 1 (resting card) | `--shadow-xs` or none + 1px border | Default card on `--bg-canvas` |\n| 2 (hovered card) | `--shadow-sm` | Card with `:hover` state |\n| 3 (dropdown, popover) | `--shadow-md` | Menu, popover, select dropdown |\n| 4 (modal, dialog) | `--shadow-xl` | Dialog, slide-over |\n| 5 (toast over modal) | `--shadow-2xl` | Toast, command palette over modal backdrop |\n\n### 6.3 Border-vs-Shadow Decision\n\nFor most cards, **prefer a 1px border over a shadow**. Borders are crisper at any DPI and read more \"product\". Use shadow only when:\n- The element is floating over content (popover, modal, dropdown, toast)\n- A drag-and-drop ghost is rendered\n- A row is \"lifted\" to indicate selection while dragging\n\n---\n\n## 7. Animation System\n\n### 7.1 Principle\n\nSaaS motion is **fast, ease-out, and only on user-initiated events**. No entrance animations on page mount, no decorative parallax, no stagger on every grid. Motion's job is to confirm an action happened, not to entertain.\n\n### 7.2 Easing\n\n```css\n/* Default — used for most state transitions */\n--ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out, snappy */\n\n/* For things that recoil (modal entrance, popover) */\n--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);\n\n/* Press / active (deceleration into rest) */\n--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\n\n/* Linear — for indeterminate progress / spinners only */\n--ease-linear: linear;\n```\n\n**Do NOT use** `ease`, `ease-in`, or unspecified defaults. They feel sluggish in SaaS contexts.\n\n### 7.3 Duration Scale\n\n| Token | ms | Use |\n|---|---|---|\n| `--duration-75` | 75 | Color change, opacity flip (button press color) |\n| `--duration-100` | 100 | Hover background fill |\n| `--duration-150` | 150 | **Default** — most transitions |\n| `--duration-200` | 200 | Dropdown/popover enter |\n| `--duration-250` | 250 | Modal/dialog enter |\n| `--duration-300` | 300 | Slide-over (drawer) enter |\n| `--duration-500` | 500 | Skeleton shimmer cycle |\n\n**Anything longer than 300ms in a product UI is wrong.** Marketing pages can take more time; product UIs cannot.\n\n### 7.4 Standard Transitions\n\n```css\n/* Color/border (default for all interactive elements) */\nbutton, a, input, select, textarea, [role=\"button\"] {\n  transition:\n    background-color var(--duration-150) var(--ease-out),\n    border-color var(--duration-150) var(--ease-out),\n    color var(--duration-150) var(--ease-out),\n    box-shadow var(--duration-150) var(--ease-out);\n}\n\n/* Modal/dialog */\n.dialog-enter { animation: dialog-enter 250ms var(--ease-out-back); }\n@keyframes dialog-enter {\n  from { opacity: 0; transform: translateY(8px) scale(0.98); }\n  to   { opacity: 1; transform: translateY(0) scale(1); }\n}\n\n/* Popover/dropdown */\n.popover-enter { animation: popover-enter 150ms var(--ease-out); }\n@keyframes popover-enter {\n  from { opacity: 0; transform: translateY(-4px); }\n  to   { opacity: 1; transform: translateY(0); }\n}\n\n/* Toast slide-in */\n.toast-enter { animation: toast-enter 200ms var(--ease-out); }\n@keyframes toast-enter {\n  from { opacity: 0; transform: translateX(8px); }\n  to   { opacity: 1; transform: translateX(0); }\n}\n\n/* Skeleton shimmer */\n.skeleton {\n  background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);\n  background-size: 200% 100%;\n  animation: shimmer 1.5s ease-in-out infinite;\n}\n@keyframes shimmer {\n  from { background-position: 200% 0; }\n  to   { background-position: -200% 0; }\n}\n```\n\n### 7.5 What NOT to Animate\n\n- **Page mount.** Render synchronously. The page is the priority, not the motion.\n- **Sidebar nav items individually staggering in.** Render the whole nav at once.\n- **Tab content fade.** Switching tabs is an instant context change; fade hides the change.\n- **Table rows.** A 1000-row table that fades in row-by-row is unusable.\n\n### 7.6 What SHOULD Animate\n\n- Button hover (background-color 100ms)\n- Focus ring (box-shadow 150ms)\n- Modal/dialog enter and exit (250ms)\n- Popover/dropdown enter (150ms)\n- Toast slide-in (200ms)\n- Skeleton shimmer (loading states)\n- Accordion expand/collapse (height 200ms)\n- Optimistic UI confirmations (checkmark scale-in 150ms)\n\n### 7.7 `prefers-reduced-motion`\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n```\n\nAlways include. SaaS users may have vestibular sensitivity or use the OS-level reduce-motion setting.\n\n---\n\n## 8. Layout System\n\n### 8.1 The Canonical SaaS Shell\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│ [Topbar: workspace switcher | search ⌘K | user menu] 48px       │\n├──────────────┬──────────────────────────────────────────────────┤\n│              │                                                  │\n│              │   Page header                                    │\n│              │   ┌────────────────────────────────────────────┐ │\n│              │   │ Title             [primary action] [⋯ menu]│ │\n│   Sidebar    │   │ Description / breadcrumb                   │ │\n│   (240-     │   └────────────────────────────────────────────┘ │\n│    280px)    │                                                  │\n│              │   Page body (cards, tables, settings)            │\n│   Nav items  │                                                  │\n│   Sections   │   ┌──────────────────────────────────────────┐   │\n│   Footer     │   │  Card / Panel                            │   │\n│              │   └──────────────────────────────────────────┘   │\n│              │                                                  │\n└──────────────┴──────────────────────────────────────────────────┘\n```\n\nThis is the Stripe/Linear/Vercel/Notion layout. Three regions:\n\n1. **Topbar** (48–56px): workspace/team switcher on the left, global search (`⌘K`) center or right, user menu on far right. Stays fixed during scroll.\n2. **Sidebar** (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.\n3. **Content** (remaining width, max ~1200px centered for readability OR full-width for tables): page header → page body.\n\nVariations:\n- **Linear-style** has no topbar — the sidebar takes the whole left edge, search lives inside the content header.\n- **Notion-style** has only a sidebar, page-level breadcrumbs at top of content.\n- **Settings-style** uses a *secondary* sidebar (settings nav) inside the content area, so two sidebars are visible.\n\n### 8.2 Container Widths\n\n| Token | px | Use |\n|---|---|---|\n| `--container-sm` | 640 | Single-column forms, auth, dialogs |\n| `--container-md` | 768 | Settings forms, account pages |\n| `--container-lg` | 1024 | Dashboard pages, default content max |\n| `--container-xl` | 1280 | Wide dashboards with sidebars-in-content |\n| `--container-2xl` | 1536 | Data tables, monitoring views (full-bleed) |\n\n**Tables get `--container-2xl` or full-bleed.** Settings forms get `--container-md` (max ~720px reading column).\n\n### 8.3 Breakpoints\n\n| Name | min-width | Behavior |\n|---|---|---|\n| `sm` | 640px | Form inputs reach full width on phones |\n| `md` | 768px | Two-column form layouts kick in |\n| `lg` | 1024px | Sidebar appears (below this, sidebar becomes a sheet/drawer) |\n| `xl` | 1280px | Sidebar stays expanded; data tables show all columns |\n| `2xl` | 1536px | Multi-column dashboards |\n\nMobile rule: the sidebar collapses to a hamburger that opens a full-height sheet. The topbar's search and user menu remain.\n\n### 8.4 Grid Patterns\n\n```css\n/* Page header — title left, actions right */\n.page-header {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 16px;\n  margin-bottom: 24px;\n}\n\n/* Two-column form (label left, input right) — settings page idiom */\n.form-row {\n  display: grid;\n  grid-template-columns: minmax(0, 240px) minmax(0, 1fr);\n  gap: 24px;\n  padding: 20px 0;\n  border-bottom: 1px solid var(--border-default);\n}\n.form-row:last-child { border-bottom: none; }\n.form-row__label { font-size: 13px; font-weight: 500; color: var(--text-primary); }\n.form-row__hint  { font-size: 12px; color: var(--text-muted); margin-top: 4px; }\n\n/* Card grid (3-col on desktop, 2 on tablet, 1 on mobile) */\n.card-grid {\n  display: grid;\n  gap: 16px;\n  grid-template-columns: 1fr;\n}\n@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }\n@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }\n\n/* KPI strip — auto-fit, never wider than 280, never narrower than 200 */\n.kpi-strip {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));\n  gap: 12px;\n}\n```\n\n### 8.5 Sidebar Anatomy\n\n```\n[Workspace switcher]      32px row, hover bg, dropdown chevron\n─────────────────────\nSECTION LABEL             11px uppercase, --neutral-500, 8px y-padding\n  [icon] Item 1           36px row, 13px text, 8px gap, hover bg-100\n  [icon] Item 2           selected: bg-brand-50 + brand-700 text\n  [icon] Item 3\n─────────────────────\nSECTION LABEL\n  [icon] Item 4\n─────────────────────\n                          (stretches to fill)\n[avatar] user@email       Bottom; opens user menu upward\n```\n\nKey spec:\n- Sidebar bg: `--bg-canvas` (slightly tinted from page surface) OR `--bg-surface` flat\n- Nav items: 36px tall, 8px horizontal padding, 6px radius, gap 8px between icon + text\n- Active item: `bg: var(--brand-50)`, `color: var(--brand-700)`, **no border, no left-accent stripe** (Linear-style minimal)\n- Section labels: `text-2xs` uppercase, `--neutral-500`, 8px y-padding\n- Width: 256px default, 56px collapsed (icons only)\n\n---\n\n## 9. Dark Mode\n\n### 9.1 Detection\n\n```html\n<script>\n  (function() {\n    var stored = localStorage.getItem('theme');\n    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n    var dark = stored === 'dark' || (stored !== 'light' && prefersDark);\n    document.documentElement.classList.toggle('dark', dark);\n  })();\n</script>\n```\n\nInline in `<head>` BEFORE any stylesheets to prevent flash-of-light-theme on dark-preferred users.\n\n### 9.2 CSS Token Mapping\n\n```css\n:root {\n  --bg-canvas: #F8FAFC;\n  --bg-surface: #FFFFFF;\n  --bg-elevated: #FFFFFF;\n  --text-primary: #0F172A;\n  --text-muted: #475569;\n  --text-subtle: #94A3B8;\n  --border-default: #E2E8F0;\n  --border-strong: #CBD5E1;\n  --brand-fg: #5E6AD2;\n  --brand-bg-subtle: #EEF0FB;\n}\n\n.dark {\n  --bg-canvas: #0A0E1A;\n  --bg-surface: #111827;\n  --bg-elevated: #1F2937;\n  --text-primary: #F1F5F9;\n  --text-muted: #94A3B8;\n  --text-subtle: #64748B;\n  --border-default: #1F2937;\n  --border-strong: #374151;\n  --brand-fg: #A5B4FC;\n  --brand-bg-subtle: rgba(94, 106, 210, 0.16);\n}\n```\n\n### 9.3 Three-Way Theme Toggle\n\nAlways offer `system | light | dark`. The user expects to match OS preference by default but be able to override.\n\n```javascript\nfunction setTheme(mode /* 'system' | 'light' | 'dark' */) {\n  if (mode === 'system') {\n    localStorage.removeItem('theme');\n    const dark = matchMedia('(prefers-color-scheme: dark)').matches;\n    document.documentElement.classList.toggle('dark', dark);\n  } else {\n    localStorage.setItem('theme', mode);\n    document.documentElement.classList.toggle('dark', mode === 'dark');\n  }\n}\n\nmatchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {\n  if (!localStorage.getItem('theme')) {\n    document.documentElement.classList.toggle('dark', e.matches);\n  }\n});\n```\n\n### 9.4 Dark-Mode-Specific Rules\n\n- **Borders go invisible.** In dark mode, a 1px border with `rgba(255, 255, 255, 0.08)` reads cleaner than the inverted neutral. Use a slightly translucent border for surfaces, solid `--border-default` for input fields.\n- **No drop shadows.** Or, dramatically reduce them. In dark mode, elevation is conveyed by *lighter* surface backgrounds, not by darker shadows underneath.\n- **Brand color shifts lighter.** `--brand-fg` becomes `#A5B4FC` (brand-300/400 range) so it has enough contrast against the dark canvas.\n- **Image / chart luminance.** If you render charts/screenshots inside a card, the card background should be `--bg-elevated` (lighter than canvas) so the artwork doesn't appear floating on void.\n\n---\n\n## 10. Accessibility\n\n### 10.1 Color Contrast\n\nMinimum ratios:\n- **Body text on background: 4.5:1** (WCAG AA Normal Text)\n- **Large text (≥18px or 14px bold) on background: 3:1**\n- **UI components (button border, input border, focus ring) against adjacent color: 3:1**\n- **Non-text indicators (icons that convey state): 3:1**\n\nVerify the included palette:\n- `#0F172A` on `#FFFFFF` = 19.3:1 ✓\n- `#475569` (muted) on `#FFFFFF` = 7.5:1 ✓\n- `#94A3B8` (subtle) on `#FFFFFF` = 3.6:1 ✓ (large text / decorative only)\n- `#5E6AD2` (brand) on `#FFFFFF` = 5.7:1 ✓\n- `#FFFFFF` on `#5E6AD2` (brand button) = 5.7:1 ✓\n\n### 10.2 Focus States\n\n```css\n/* Default focus ring for all interactive elements */\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n  border-radius: var(--radius-sm);\n}\n\n/* For elements with their own visual treatment (buttons, inputs), use box-shadow ring */\nbutton:focus-visible,\n[role=\"button\"]:focus-visible,\na:focus-visible {\n  outline: none;\n  box-shadow: var(--ring-focus);\n}\n\ninput:focus-visible,\ntextarea:focus-visible,\nselect:focus-visible {\n  outline: none;\n  border-color: var(--brand-fg);\n  box-shadow: var(--ring-focus);\n}\n```\n\n**Never remove the focus ring without replacing it.** This is the single most common SaaS accessibility regression.\n\n### 10.3 Keyboard Map (every SaaS app should implement)\n\n| Key | Action |\n|---|---|\n| `⌘K` / `Ctrl+K` | Open command palette |\n| `/` | Focus the page search input |\n| `g` then `d` | Go to dashboard (vim-style nav, optional but Linear/GitHub do it) |\n| `?` | Show keyboard shortcut cheat sheet |\n| `Esc` | Close modal, dismiss popover, blur input |\n| `Tab` / `Shift+Tab` | Move focus forward/back |\n| `Enter` | Activate focused button/link; submit form when input focused |\n| `Space` | Toggle checkbox/switch; activate button |\n| `↑` / `↓` | Navigate list items, table rows, menu items |\n| `j` / `k` | Same as ↓ / ↑ (vim-style; optional) |\n\n### 10.4 Semantic Patterns\n\n- Buttons use `<button>`, links use `<a href>`. **Never** use a `<div onClick>` for either.\n- Form fields have a visible `<label>` (or `aria-label` if visually replaced by a placeholder pattern).\n- Required fields: visible `*` marker AND `aria-required=\"true\"`.\n- Error messages: linked via `aria-describedby`, role `alert` for the first error.\n- Tables: `<th scope=\"col\">` headers, optional `<caption>` for context.\n- Modals: trap focus, restore focus to trigger on close, `aria-modal=\"true\"`, `role=\"dialog\"`, labeled by heading.\n- Toasts: `role=\"status\"` for non-critical, `role=\"alert\"` for errors.\n\n### 10.5 Touch Targets\n\nMinimum 44×44px (WCAG 2.5.5). On desktop SaaS, buttons can be 32-36px tall as long as the **clickable hit area** (via padding) reaches 44px.\n\n```css\n.icon-button {\n  position: relative;\n  width: 32px;\n  height: 32px;\n}\n.icon-button::before {\n  content: '';\n  position: absolute;\n  inset: -6px;  /* extends hit area to 44x44 */\n}\n```\n\n### 10.6 Screen Reader Utilities\n\n```css\n.sr-only {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border: 0;\n}\n\n.sr-only-focusable:focus {\n  position: static;\n  width: auto;\n  height: auto;\n  clip: auto;\n  white-space: normal;\n}\n```\n\nUse `.sr-only` for \"Skip to main content\" links and icon-only button labels.\n\n---\n\n## 11. Decorative Elements (Used Sparingly)\n\nSaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:\n\n### 11.1 Section Dividers\n\n```css\n.divider {\n  height: 1px;\n  background: var(--border-default);\n  margin: 24px 0;\n}\n\n/* Vertical divider (e.g., between toolbar groups) */\n.divider-v {\n  width: 1px;\n  background: var(--border-default);\n  align-self: stretch;\n  margin: 0 8px;\n}\n```\n\n### 11.2 Inline Pills / Status Dots\n\n```css\n/* Status dot (used in tables: ● Active) */\n.status-dot {\n  display: inline-block;\n  width: 8px;\n  height: 8px;\n  border-radius: 9999px;\n  vertical-align: middle;\n  margin-right: 6px;\n}\n.status-dot--success { background: #16A34A; }\n.status-dot--warning { background: #CA8A04; }\n.status-dot--error   { background: #DC2626; }\n.status-dot--neutral { background: #94A3B8; }\n\n/* Pulsing dot for \"live\" / \"syncing\" */\n.status-dot--pulse {\n  position: relative;\n}\n.status-dot--pulse::after {\n  content: '';\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: inherit;\n  animation: pulse-ring 1.6s ease-out infinite;\n}\n@keyframes pulse-ring {\n  0%   { transform: scale(1);   opacity: 0.6; }\n  100% { transform: scale(2.5); opacity: 0; }\n}\n```\n\n### 11.3 Empty-State Iconography Placeholder\n\nFor empty states, draw a single 1.5px-stroke line icon at 48–64px size in `--neutral-300`. Do not use 3D illustrations or stock vectors — they break the \"quiet product\" register.\n\n```html\n<div class=\"empty-state\">\n  <svg width=\"48\" height=\"48\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n       stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"\n       style=\"color: var(--neutral-300);\">\n    <!-- single line-art icon (e.g., a Lucide icon) -->\n  </svg>\n  <h3>No customers yet</h3>\n  <p>Customers appear here after their first successful payment.</p>\n  <button>Create test customer</button>\n</div>\n```\n\n### 11.4 Subtle Background Grid (for empty canvases)\n\nFor dashboard backgrounds, an OPTIONAL very subtle dot grid is acceptable:\n\n```css\n.canvas-grid {\n  background-color: var(--bg-canvas);\n  background-image: radial-gradient(circle, var(--neutral-200) 1px, transparent 1px);\n  background-size: 24px 24px;\n  background-position: 0 0;\n}\n```\n\nOpacity: never above 30%. Most SaaS apps omit this entirely.\n\n### 11.5 Keyboard Shortcut Chips\n\n```css\n.kbd {\n  display: inline-flex;\n  align-items: center;\n  gap: 2px;\n  padding: 2px 6px;\n  font-family: var(--font-mono);\n  font-size: 11px;\n  line-height: 1.2;\n  color: var(--text-muted);\n  background: var(--bg-surface);\n  border: 1px solid var(--border-default);\n  border-bottom-width: 2px;  /* mechanical key look */\n  border-radius: 4px;\n  vertical-align: middle;\n}\n```\n\nUsage: `<span class=\"kbd\">⌘</span><span class=\"kbd\">K</span>` inside tooltips, menu items, command palette.\n\n---\n\n## 12. Component Quick Reference\n\n### Buttons\n\n| Variant | Background | Text | Border | Use |\n|---|---|---|---|---|\n| `primary` | `--brand-600` | `#FFFFFF` | none | The one primary action per surface |\n| `secondary` | `--bg-surface` | `--text-primary` | `--border-default` 1px | Most actions (\"Cancel\", \"Save\", \"Edit\") |\n| `ghost` | transparent | `--text-primary` | none | Toolbar buttons, table row actions |\n| `destructive` | `#DC2626` | `#FFFFFF` | none | Delete, revoke, irreversible |\n| `destructive-outline` | `--bg-surface` | `#B91C1C` | `#FCA5A5` | Less aggressive destructive |\n| `link` | none | `--brand-600` | none, underline on hover | Inline navigation actions |\n\nSizes: `sm` (28px tall, 12px text), `md` (32px tall, 13px text — **default**), `lg` (40px tall, 14px text).\n\n### Inputs\n\n| State | Border | Background | Shadow |\n|---|---|---|---|\n| default | `--border-default` | `--bg-surface` | none |\n| hover | `--border-strong` | `--bg-surface` | none |\n| focus | `--brand-fg` | `--bg-surface` | `--ring-focus` |\n| error | `#DC2626` | `--bg-surface` | `--ring-error` |\n| disabled | `--border-default` | `--neutral-50` | none, `opacity: 0.6` |\n\nSizes match buttons: `sm` (28px), `md` (32px default), `lg` (40px).\n\n### Cards\n\n| Variant | Background | Border | Padding | Shadow |\n|---|---|---|---|---|\n| default | `--bg-surface` | 1px `--border-default` | 20px | none |\n| interactive | `--bg-surface` | 1px `--border-default` | 20px | hover: `--shadow-sm` |\n| ghost | none | none | 0 | none — for grouping without enclosure |\n| elevated | `--bg-elevated` | none | 24px | `--shadow-md` |\n| outline-only | none | 1px `--border-default` | 16px | none — for placeholder/empty slots |\n\n### Badges / Status Pills\n\n| Variant | Background | Text | Border |\n|---|---|---|---|\n| neutral | `--neutral-100` | `--neutral-700` | none |\n| brand | `--brand-50` | `--brand-700` | none |\n| success | `#DCFCE7` | `#15803D` | none |\n| warning | `#FEF9C3` | `#A16207` | none |\n| error | `#FEE2E2` | `#B91C1C` | none |\n| info | `#DBEAFE` | `#1D4ED8` | none |\n\nSizing: padding `2px 8px`, font 11–12px, weight 500, radius 4px (NOT pill). Optional leading status dot.\n\n---\n\n## 13. File Structure Recommendation\n\n```\nproject/\n├── src/\n│   ├── styles/\n│   │   ├── tokens.css           # All CSS custom properties (one file, single source of truth)\n│   │   ├── base.css             # Reset, html/body, typography defaults, focus\n│   │   └── globals.css          # @import the above + utility classes\n│   ├── components/\n│   │   ├── primitives/          # Lowest-level, design-token-aware\n│   │   │   ├── Button.tsx\n│   │   │   ├── Input.tsx\n│   │   │   ├── Select.tsx\n│   │   │   ├── Checkbox.tsx\n│   │   │   ├── Switch.tsx\n│   │   │   ├── Badge.tsx\n│   │   │   ├── Card.tsx\n│   │   │   ├── Avatar.tsx\n│   │   │   ├── Tooltip.tsx\n│   │   │   ├── Kbd.tsx\n│   │   │   └── Spinner.tsx\n│   │   ├── overlays/            # Floating layers\n│   │   │   ├── Dialog.tsx\n│   │   │   ├── Sheet.tsx\n│   │   │   ├── Popover.tsx\n│   │   │   ├── DropdownMenu.tsx\n│   │   │   ├── Toast.tsx\n│   │   │   └── CommandPalette.tsx\n│   │   ├── data/                # Data display\n│   │   │   ├── DataTable.tsx\n│   │   │   ├── Pagination.tsx\n│   │   │   ├── KpiCard.tsx\n│   │   │   └── EmptyState.tsx\n│   │   ├── forms/\n│   │   │   ├── FormField.tsx\n│   │   │   ├── FormRow.tsx\n│   │   │   └── FormSection.tsx\n│   │   └── shell/               # The app frame\n│   │       ├── AppShell.tsx\n│   │       ├── Sidebar.tsx\n│   │       ├── Topbar.tsx\n│   │       ├── PageHeader.tsx\n│   │       └── Breadcrumb.tsx\n│   └── lib/\n│       ├── theme.ts             # Theme toggle (system/light/dark)\n│       ├── shortcuts.ts         # Global keyboard handler\n│       └── format.ts            # Money, date, relative time helpers\n├── tailwind.config.ts           # Tokens mirrored from tokens.css\n└── tsconfig.json\n```\n\nRules:\n- **`tokens.css` is the single source of truth.** `tailwind.config.ts` reads from it (or duplicates its values, but only as a mirror).\n- **Primitives have no business logic.** They accept props, render styled DOM. State lives in feature components that wrap them.\n- **No component crosses layer boundaries.** A primitive can't import from `shell/`; `shell/` can't import from `data/`.\n\n---\n\n## 14. Full CSS Custom Properties\n\nCopy into `styles/tokens.css`:\n\n```css\n:root {\n  /* ── Brand ── */\n  --brand-50:  #EEF0FB;\n  --brand-100: #DDE0F7;\n  --brand-200: #B9C0EF;\n  --brand-500: #7B85DC;\n  --brand-600: #5E6AD2;\n  --brand-700: #4A56C0;\n  --brand-800: #3A45A8;\n  --brand-900: #2A3284;\n  --brand-fg: var(--brand-600);\n  --brand-bg-subtle: var(--brand-50);\n\n  /* ── Neutrals (slate) ── */\n  --neutral-0:   #FFFFFF;\n  --neutral-50:  #F8FAFC;\n  --neutral-100: #F1F5F9;\n  --neutral-200: #E2E8F0;\n  --neutral-300: #CBD5E1;\n  --neutral-400: #94A3B8;\n  --neutral-500: #64748B;\n  --neutral-600: #475569;\n  --neutral-700: #334155;\n  --neutral-800: #1E293B;\n  --neutral-900: #0F172A;\n  --neutral-950: #020617;\n\n  /* ── Semantic surfaces ── */\n  --bg-canvas:   var(--neutral-50);\n  --bg-surface:  var(--neutral-0);\n  --bg-elevated: var(--neutral-0);\n  --bg-hover:    var(--neutral-100);\n\n  --text-primary: var(--neutral-900);\n  --text-muted:   var(--neutral-600);\n  --text-subtle:  var(--neutral-400);\n\n  --border-default: var(--neutral-200);\n  --border-strong:  var(--neutral-300);\n\n  /* ── State ── */\n  --success-fg:  #15803D;\n  --success-bg:  #DCFCE7;\n  --warning-fg:  #A16207;\n  --warning-bg:  #FEF9C3;\n  --error-fg:    #B91C1C;\n  --error-bg:    #FEE2E2;\n  --info-fg:     #1D4ED8;\n  --info-bg:     #DBEAFE;\n\n  /* ── Chart palette ── */\n  --chart-1: #2563EB;\n  --chart-2: #16A34A;\n  --chart-3: #CA8A04;\n  --chart-4: #DC2626;\n  --chart-5: #9333EA;\n  --chart-6: #0891B2;\n  --chart-7: #DB2777;\n  --chart-8: #65A30D;\n\n  /* ── Typography ── */\n  --font-sans: \"Inter\", \"Geist\", -apple-system, BlinkMacSystemFont, \"Segoe UI\",\n               \"SF Pro Text\", system-ui, sans-serif;\n  --font-mono: \"JetBrains Mono\", \"Geist Mono\", \"SF Mono\", \"Menlo\", \"Consolas\",\n               ui-monospace, monospace;\n\n  /* ── Spacing (multiples of 4) ── */\n  --space-unit: 4px;\n\n  /* ── Radius ── */\n  --radius-none: 0;\n  --radius-sm:   4px;\n  --radius-md:   6px;\n  --radius-lg:   8px;\n  --radius-xl:   12px;\n  --radius-2xl:  16px;\n  --radius-full: 9999px;\n\n  /* ── Shadows ── */\n  --shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);\n  --shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06);\n  --shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04);\n  --shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08);\n  --shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10);\n  --shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);\n  --ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);\n  --ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);\n\n  /* ── Motion ── */\n  --ease-out:        cubic-bezier(0.16, 1, 0.3, 1);\n  --ease-out-back:   cubic-bezier(0.34, 1.56, 0.64, 1);\n  --ease-in-out:     cubic-bezier(0.4, 0, 0.2, 1);\n  --duration-75:  75ms;\n  --duration-100: 100ms;\n  --duration-150: 150ms;\n  --duration-200: 200ms;\n  --duration-250: 250ms;\n  --duration-300: 300ms;\n\n  /* ── Layout ── */\n  --container-sm:  640px;\n  --container-md:  768px;\n  --container-lg: 1024px;\n  --container-xl: 1280px;\n  --container-2xl: 1536px;\n  --sidebar-w:        256px;\n  --sidebar-w-collapsed: 56px;\n  --topbar-h:         48px;\n}\n\n.dark {\n  --bg-canvas:   #0A0E1A;\n  --bg-surface:  #111827;\n  --bg-elevated: #1F2937;\n  --bg-hover:    rgba(255, 255, 255, 0.04);\n\n  --text-primary: #F1F5F9;\n  --text-muted:   #94A3B8;\n  --text-subtle:  #64748B;\n\n  --border-default: #1F2937;\n  --border-strong:  #374151;\n\n  --brand-fg:         #A5B4FC;\n  --brand-bg-subtle:  rgba(94, 106, 210, 0.16);\n\n  --success-fg: #4ADE80;\n  --success-bg: rgba(22, 163, 74, 0.16);\n  --warning-fg: #FACC15;\n  --warning-bg: rgba(202, 138, 4, 0.16);\n  --error-fg:   #F87171;\n  --error-bg:   rgba(220, 38, 38, 0.16);\n  --info-fg:    #60A5FA;\n  --info-bg:    rgba(37, 99, 235, 0.16);\n\n  --shadow-xs:  0 1px 2px 0 rgba(0, 0, 0, 0.30);\n  --shadow-sm:  0 1px 3px 0 rgba(0, 0, 0, 0.40);\n  --shadow-md:  0 4px 8px -2px rgba(0, 0, 0, 0.50);\n  --shadow-lg:  0 12px 20px -6px rgba(0, 0, 0, 0.55);\n  --shadow-xl:  0 20px 32px -10px rgba(0, 0, 0, 0.60);\n  --shadow-2xl: 0 30px 60px -15px rgba(0, 0, 0, 0.70);\n}\n\n/* ── Base ── */\nhtml {\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-rendering: optimizeLegibility;\n  font-feature-settings: \"kern\" 1, \"liga\" 1, \"calt\" 1, \"cv11\" 1, \"ss01\" 1;\n}\n\nbody {\n  margin: 0;\n  background: var(--bg-canvas);\n  color: var(--text-primary);\n  font-family: var(--font-sans);\n  font-size: 14px;\n  line-height: 1.43;\n}\n\n.numeric, table, [data-numeric=\"true\"] {\n  font-variant-numeric: tabular-nums;\n}\n\n*:focus { outline: none; }\n*:focus-visible {\n  outline: 2px solid var(--brand-fg);\n  outline-offset: 2px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after {\n    animation-duration: 0.01ms !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n```\n\n---\n\n## 15. Forensic Visual Analysis\n\n> **Design Language DNA & Implementation Physics**\n> Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.\n\n### 15.1 Visual Hierarchy & Spatial Logic\n\n#### Density Tier\n\n| Tier | Row Height | Button Height | Default Text | When |\n|---|---|---|---|---|\n| **Compact** (Linear, dev tools) | 32px | 28px | 13px | Power-user surfaces, data-heavy tables |\n| **Default** (Stripe, Vercel) | 40px | 32px | 14px | The default SaaS product UI |\n| **Comfortable** (Notion, Clerk) | 48px | 36px | 15px | Content-heavy, settings, billing |\n| **Spacious** (Auth, onboarding) | 56px+ | 40px+ | 16px | Single-purpose conversion pages |\n\n**Pick one tier per product, not per page.** Mixing density breaks the rhythm.\n\n#### Layout Constants\n\n```\nContent width:\n├── Tables / lists:       full bleed within --container-2xl (1536px)\n├── Dashboards:           --container-xl (1280px) centered\n├── Settings forms:       --container-md (768px) — readable column\n└── Auth / onboarding:    --container-sm (640px) centered\n\nPage padding (X):\n├── Mobile:    16px (px-4)\n├── Tablet:    24px (px-6)\n└── Desktop:   32px (px-8)\n\nPage padding (Y):\n├── Page header → content:     16-24px\n├── Section → section:         32-40px\n└── Last section → page end:   64px (so footer doesn't crowd)\n\nGrid gaps:\n├── KPI cards:           12px\n├── Card grids:          16px (sm) → 20px (md) → 24px (lg)\n├── Form fields:         16px (tight) or 20px (default)\n└── Settings rows:       0 — separated by 1px bottom border\n```\n\n#### The \"Three Heights\" Rule\n\nIn a SaaS UI, three vertical rhythms coexist:\n\n1. **Page rhythm**: 24px / 32px / 48px between page sections.\n2. **Component rhythm**: 12px / 16px / 20px inside cards and panels.\n3. **Inline rhythm**: 4px / 6px / 8px between adjacent elements (icon + label, badge + text).\n\nEach rhythm is a multiple of 4 but **does not reuse** the same numbers from another rhythm. Inline gaps stay ≤8px, component gaps stay 12–20px, page gaps stay ≥24px. This is what makes a SaaS UI feel \"organized\" without conscious effort.\n\n### 15.2 Color Science & Elevation\n\n#### How elevation is conveyed (without heavy shadows)\n\n| Layer | Light Mode | Dark Mode |\n|---|---|---|\n| Canvas | `#F8FAFC` (1 step down from white) | `#0A0E1A` |\n| Surface (default card) | `#FFFFFF` + 1px border | `#111827` + 1px `rgba(255,255,255,0.04)` border |\n| Elevated (popover) | `#FFFFFF` + `--shadow-md` | `#1F2937` + low shadow |\n| Modal | `#FFFFFF` + `--shadow-xl` + backdrop blur | `#1F2937` + `--shadow-xl` + backdrop blur |\n\nIn light mode, elevation = subtle shadow + 1px border.\nIn dark mode, elevation = LIGHTER background (the higher the layer, the lighter the bg) + nearly-invisible shadow.\n\n#### Brand color is \"interaction language\"\n\n- Brand color = \"this is interactive, this is selected, this is focused.\"\n- Anything else uses neutrals.\n- Status colors (success/error/etc.) are ONLY for status — never for buttons that don't represent that status.\n\nA useful test: **squint at the UI**. You should see 90% gray-on-white, with brand pinpricks marking exactly the interactive surface you'd click.\n\n#### Selected-state convention\n\n| State | Convention |\n|---|---|\n| Hover | Background shifts by 1 step (`--bg-surface` → `--bg-hover`) |\n| Active (pressed) | Background shifts by 2 steps OR scale(0.98) |\n| Selected (persistent) | `--brand-bg-subtle` background + `--brand-fg` text. **No border change.** |\n| Focused (keyboard) | `--ring-focus` box-shadow ring. Independent of hover/selected. |\n| Disabled | `opacity: 0.5` + `cursor: not-allowed`. Color unchanged. |\n\n### 15.3 Typography & Micro-Copy\n\n#### The \"13/14 default\" rule\n\nThe default body/UI size is **13px or 14px**. Headings step up modestly (no 48px headlines in a dashboard). The biggest text in a typical product page is the H1 at 24–30px.\n\n| Element | Size | Weight |\n|---|---|---|\n| H1 (page title) | 24px | 600 |\n| H2 (section) | 18px | 600 |\n| H3 (card heading) | 15-16px | 600 |\n| Body / table cell | 13-14px | 400 |\n| Label / button | 13-14px | 500 |\n| Microlabel / caption | 11-12px | 500 (uppercase optional) |\n| Mono (id, code, money) | 12-13px | 400 |\n\n#### Micro-copy patterns\n\n**Buttons** — verb + object, never just verb:\n- ✅ \"Create customer\", \"Save changes\", \"Delete invoice\"\n- ❌ \"Submit\", \"OK\", \"Click here\"\n\n**Empty state headings** — declarative, not interrogative:\n- ✅ \"No active subscriptions\"\n- ❌ \"You don't have any subscriptions yet, would you like to create one?\"\n\n**Inline help / hints** — single sentence, no period (because it's not a sentence in form context):\n- ✅ \"Used for receipts and customer emails\"\n- ❌ \"This email will be used for receipts and customer emails.\"\n\n**Destructive confirmation** — type-to-confirm for irreversible:\n- \"Type `acme-corp` to confirm deletion\" — see GitHub's repo deletion modal.\n\n**Toast text** — past tense, with optional action:\n- ✅ \"Invoice sent. [Undo]\"\n- ❌ \"Sending invoice…\" (use a loading state for in-flight)\n\n### 15.4 Component Anatomy\n\n#### Button (the most-touched primitive)\n\n```\n┌────────────────────────────────────┐\n│  [icon] [label]            [⌘K]    │   ← gap 8px between icon/label, 12px to shortcut\n└────────────────────────────────────┘\n   ↑ padding 12-14px              ↑ padding 12-14px\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ font: 13px / 500\n```\n\nVariants:\n- `primary` — solid brand bg, white text. ONE per surface.\n- `secondary` — surface bg, neutral border, primary text. Most actions.\n- `ghost` — no bg, no border. Toolbar / row actions.\n- `destructive` — solid red bg. Reserved for irreversible.\n\nStates:\n- default → hover (bg shifts) → active (bg shifts more / scale 0.98) → focus (ring) → disabled (opacity 0.5)\n\nForbidden:\n- Pill radius (`9999px`) — that's marketing\n- Gradient background — that's marketing\n- Drop shadow — buttons are flat against the surface\n\n#### Input\n\n```\n┌────────────────────────────────────┐\n│  [icon] placeholder text      [×]  │   ← optional leading icon, trailing clear\n└────────────────────────────────────┘\n   ─ height: 32px (default)\n   ─ radius: 6px\n   ─ border: 1px --border-default\n   ─ padding: 0 12px (or 0 36px when icon present)\n   ─ font: 14px / 400\n```\n\nFocus state: border → `--brand-fg`, add `--ring-focus` box-shadow. **Never use solid background change for focus** — borders + ring are the convention.\n\nError state: border → `#DC2626`, ring → `--ring-error`. Show error message below input in `#B91C1C`, 12px, with `aria-describedby` linking.\n\n#### Card\n\n```\n┌──────────────────────────────────┐\n│  Heading            [actions]    │   ← header row, 16-20px from edges\n│  Optional description            │\n├──────────────────────────────────┤   ← 1px divider OR padding gap\n│                                  │\n│  Card body                       │\n│                                  │\n└──────\n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: worker/agents/think/skills/frontend-design/SKILL.md (Tokens: ~858 | Category: anthropic-skill) -->\n---\nname: frontend-design\ndescription: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.\nlicense: Complete terms in LICENSE.txt\n---\n\nThis skill guides creation of distinctive, production-grade frontend interfaces that avoid generic \"AI slop\" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.\n\nThe user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.\n\n## Design Thinking\n\nBefore coding, understand the context and commit to a BOLD aesthetic direction:\n- **Purpose**: What problem does this interface solve? Who uses it?\n- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.\n- **Constraints**: Technical requirements (framework, performance, accessibility).\n- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?\n\n**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.\n\nThen implement working code (HTML/CSS/JS, React, Vue, etc.) that is:\n- Production-grade and functional\n- Visually striking and memorable\n- Cohesive with a clear aesthetic point-of-view\n- Meticulously refined in every detail\n\n## Frontend Aesthetics Guidelines\n\nFocus on:\n- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.\n- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.\n- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.\n- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.\n- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.\n\nNEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.\n\nInterpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.\n\n**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.\n\nRemember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.\n\n<!-- File: packages/artifacts-viewer/.changeset/README.md (Tokens: ~46 | Category: plugin-manifest) -->\n# Change intents\n\nRun `pnpm change` for every change that affects the published package. Commit the generated Markdown file with the code change.\n\nDocumentation, tests, and changes limited to private workspace applications do not require an intent.\n\n<!-- File: packages/artifacts-viewer/packages/artifacts-viewer/README.md (Tokens: ~6575 | Category: plugin-manifest) -->\n# artifacts-viewer\n\nA read-only repository browser for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/). Ships a server-side proxy, a typed HTTP client, and unstyled React components.\n\nYour Cloudflare API token stays on the server. The browser only ever talks to your own origin.\n\n> Pre-1.0. The API is still moving; pin an exact version.\n\n## Contents\n\n- [artifacts-viewer](#artifacts-viewer)\n  - [Contents](#contents)\n  - [How it works](#how-it-works)\n    - [Request flow](#request-flow)\n  - [Install](#install)\n  - [Quick start](#quick-start)\n    - [1. Mount the router](#1-mount-the-router)\n    - [2. Render the viewer](#2-render-the-viewer)\n  - [`artifacts-viewer` — server router](#artifacts-viewer--server-router)\n    - [Authorization](#authorization)\n    - [Response behaviour](#response-behaviour)\n    - [CORS](#cors)\n  - [`artifacts-viewer/server/cache` — cache adapters](#artifacts-viewerservercache--cache-adapters)\n  - [`artifacts-viewer/client` — typed client](#artifacts-viewerclient--typed-client)\n  - [`artifacts-viewer/react` — components and hooks](#artifacts-viewerreact--components-and-hooks)\n    - [Components](#components)\n      - [`renderStatus` — loading, empty, and error states](#renderstatus--loading-empty-and-error-states)\n    - [Hooks](#hooks)\n    - [Building your own UI](#building-your-own-ui)\n  - [Styling](#styling)\n    - [Custom properties](#custom-properties)\n    - [Selector hooks](#selector-hooks)\n  - [Syntax highlighting](#syntax-highlighting)\n    - [Loading](#loading)\n      - [`renderCodeFallback` — what the wait looks like](#rendercodefallback--what-the-wait-looks-like)\n      - [`preloadCodeView` — how long the wait is](#preloadcodeview--how-long-the-wait-is)\n    - [Theming](#theming)\n  - [Limits and behaviour](#limits-and-behaviour)\n  - [Not included](#not-included)\n  - [License](#license)\n\n## How it works\n\n```\nbrowser                    your server                     Cloudflare\n┌──────────────────┐      ┌──────────────────────┐      ┌──────────────┐\n│ArtifactRepoViewer│      │ routeArtifactRequest │      │ Artifacts    │\n│        ↓         │─────▶│  · validates         │─────▶│ REST API     │\n│ ArtifactsClient  │      │  · adds API token    │      │              │\n└──────────────────┘      │  · optional cache    │      └──────────────┘\n     GET /artifacts/…     └──────────────────────┘        Bearer token\n```\n\nThe router mirrors Cloudflare's namespace-relative paths 1:1 and proxies **only** these seven reads. `accountId` and `namespace` are deliberately absent from the browser-facing URL.\n\n| Mounted route                                        | Purpose                                     |\n| ---------------------------------------------------- | ------------------------------------------- |\n| `GET {apiPath}/repos/{repo}`                         | Repository metadata                         |\n| `GET {apiPath}/repos/{repo}/log?ref=&limit=&offset=` | Commit log                                  |\n| `GET {apiPath}/repos/{repo}/commit/{hash}`           | One commit                                  |\n| `GET {apiPath}/repos/{repo}/tree/{hash}`             | One directory level                         |\n| `GET {apiPath}/repos/{repo}/blob/{hash}`             | File bytes by hash                          |\n| `GET {apiPath}/repos/{repo}/file?ref=&path=`         | File bytes by path                          |\n| `GET {apiPath}/repos/{repo}/raw/{ref}/{path}`        | File bytes with a browser-safe content type |\n\nNothing else is reachable. Writes, repository creation, and token endpoints are not proxied at all.\n\n### Request flow\n\nRendering a repository takes two round trips, then goes content-addressed:\n\n```\ngetLog({ limit: 1 })       →  commits[0].treeHash    (ref omitted = default branch)\nreadTree(commit.treeHash)  →  root entries\n```\n\nAfter that every navigation is addressed by git hash — `readTree(entry.hash)` for a directory, `readBlob(entry.hash)` for a file — so responses are immutable and cacheable forever.\n\n## Install\n\n```bash\nnpm install artifacts-viewer\n# or\npnpm install artifacts-viewer\n# or\nbun install artifacts-viewer\n```\n\n`react` and `react-dom` are peer dependencies (`^18.2.0 || ^19.0.0`). `@pierre/diffs` is a direct dependency. It is split from the initial bundle and proactively prefetched when `ArtifactRepoViewer` mounts.\n\n## Quick start\n\n### 1. Mount the router\n\n> **The router allows every request by default.** Add a `beforeRequest` policy before exposing this endpoint; otherwise anyone who can reach it can read any valid repository name in the configured namespace.\n\n```ts\n// worker/index.ts\nimport { routeArtifactRequest } from \"artifacts-viewer\";\nimport { createCacheApiAdapter } from \"artifacts-viewer/server/cache\";\n\nexport default {\n  async fetch(request, env, ctx) {\n    const handled = await routeArtifactRequest(request, {\n      accountId: env.ARTIFACTS_ACCOUNT_ID,\n      namespace: env.ARTIFACTS_NAMESPACE,\n      apiToken: env.ARTIFACTS_API_TOKEN,\n      beforeRequest: async ({ request, read }) => {\n        const user = await getSessionUser(request);\n        if (user === null || !user.repositories.includes(read.repoName)) {\n          return new Response(\"Forbidden\", { status: 403 });\n        }\n      },\n      cache: createCacheApiAdapter({\n        cache: caches.default,\n        baseUrl: new URL(request.url).origin,\n      }),\n      waitUntil: (promise) => {\n        ctx.waitUntil(promise);\n      },\n    });\n\n    return handled ?? new Response(\"Not found\", { status: 404 });\n  },\n} satisfies ExportedHandler<Env>;\n```\n\n`routeArtifactRequest` returns `null` when the request is outside `apiPath`, so you can fall through to your own routing.\n\nIf you serve static assets from the same Worker, route the API to the Worker first or the asset handler will answer instead:\n\n```jsonc\n// wrangler.jsonc\n{\n  \"assets\": {\n    \"not_found_handling\": \"single-page-application\",\n    \"run_worker_first\": [\"/artifacts/*\"],\n  },\n}\n```\n\n### 2. Render the viewer\n\n```tsx\nimport { createArtifactsClient } from \"artifacts-viewer/client\";\nimport { ArtifactRepoViewer } from \"artifacts-viewer/react\";\nimport \"artifacts-viewer/styles.css\";\n\n// Module scope: the hooks treat `client` as a dependency, so a new instance\n// per render would refetch forever.\nconst client = createArtifactsClient();\n\nexport function Repository() {\n  return <ArtifactRepoViewer client={client} repoName=\"my-repository\" />;\n}\n```\n\n## `artifacts-viewer` — server router\n\nReact-free and platform-neutral. Uses only `Request`, `Response`, `Headers`, `URL`, and `fetch`, so it runs on Workers, Node, Deno, or Bun.\n\n```ts\nfunction routeArtifactRequest(\n  request: Request,\n  options: ArtifactRouterOptions,\n): Promise<Response | null>;\n```\n\n| Option          | Type                            | Notes                                                     |\n| --------------- | ------------------------------- | --------------------------------------------------------- |\n| `accountId`     | `string`                        | Required                                                  |\n| `namespace`     | `string`                        | Required                                                  |\n| `apiToken`      | `string`                        | Required. Cloudflare API token with Artifacts read access |\n| `apiPath`       | `string`                        | Default `\"/artifacts\"`. Matched on a segment boundary     |\n| `fetch`         | `typeof fetch`                  | Optional injection point for tests and tracing            |\n| `beforeRequest` | `ArtifactBeforeRequestHook`     | Authorization hook. Return a `Response` to deny           |\n| `cache`         | `ArtifactsCacheAdapter`         | Consulted for content-addressed reads only                |\n| `waitUntil`     | `(p: Promise<unknown>) => void` | Keeps cache writes alive past the response                |\n| `onCacheError`  | `(error: unknown) => void`      | Cache failures are reported here, never thrown            |\n\n### Authorization\n\n`beforeRequest` runs **before** the cache, so a cache hit can never bypass it.\n\n```ts\nbeforeRequest: async ({ request, read, operation }) => {\n  if (!(await mayRead(request, read.repoName))) {\n    return new Response(\"Forbidden\", { status: 403 });\n  }\n},\n```\n\n`read` is a fully validated discriminated union, so `read.repoName`, `read.hash`, `read.ref`, and `read.path` are safe to use in a policy decision.\n\n### Response behaviour\n\n- Outside `apiPath` → `null`.\n- Inside `apiPath` but not a known route → `404`.\n- Known route, wrong method → `405` with an `Allow` header.\n- Malformed argument → `400`. Nothing is sent upstream.\n- Errors use the Cloudflare v4 envelope, so the client has one parser.\n\nOutbound requests are built from scratch with only `Accept` and `Authorization`. A caller's `Authorization`, `Cookie`, and every other inbound header are dropped. Response headers are copied through an allowlist (`Content-Type`, `Content-Length`, `Content-Disposition`, `ETag`, `Last-Modified`); bodies stream and are never buffered.\n\n### CORS\n\nNot handled. You own the response, so add headers after the call. Note that `OPTIONS` returns `405`, so intercept preflight before delegating:\n\n```ts\nif (request.method === \"OPTIONS\") return myPreflight(request);\n```\n\n## `artifacts-viewer/server/cache` — cache adapters\n\nOnly content-addressed reads (`commit`, `tree`, `blob`) are ever cached. Their values cannot become semantically stale, but the bundled Cache API and KV adapters use one-year freshness or expiration settings and either backend may evict an entry earlier. Ref-addressed reads are never cached.\n\n```ts\ntype ArtifactsCacheAdapter = {\n  get(key: string): Promise<Response | undefined>;\n  set(key: string, response: Response): Promise<void>;\n};\n```\n\n```ts\nimport { createCacheApiAdapter, createKvCacheAdapter } from \"artifacts-viewer/server/cache\";\n\ncreateCacheApiAdapter({ cache: caches.default, baseUrl: new URL(request.url).origin });\ncreateKvCacheAdapter({ kv: env.ARTIFACTS_CACHE });\n```\n\n`createCacheApiAdapter` is the recommended backend: it streams, so large blobs never sit in memory. `baseUrl` must be an origin your Worker actually serves — the Cache API is keyed by URL and scoped to the zone.\n\nAdapters may throw. A failing cache degrades to a slower request, never a failed one.\n\n## `artifacts-viewer/client` — typed client\n\nFramework-independent and free of Worker globals.\n\n```ts\nconst client = createArtifactsClient({\n  apiPath: \"/artifacts\", // must match the router\n  fetch: myFetch, // optional\n});\n```\n\nEvery asynchronous request method returns a result union rather than throwing. The synchronous `getRawUrl` helper returns a string:\n\n```ts\ntype ArtifactsResult<T> = { ok: true; value: T } | { ok: false; error: ArtifactsClientError };\n\ntype ArtifactsClientError =\n  | { kind: \"network\"; message: string; cause: unknown }\n  | { kind: \"not-found\"; message: string }\n  | { kind: \"http\"; message: string; status: number }\n  | { kind: \"malformed\"; message: string };\n```\n\n| Method                                                 | Returns                     |\n| ------------------------------------------------------ | --------------------------- |\n| `getRepository({ repoName, signal? })`                 | `ArtifactsRepository`       |\n| `getLog({ repoName, ref?, limit?, offset?, signal? })` | `ArtifactsCommitMetadata[]` |\n| `readCommit({ repoName, hash, signal? })`              | `ArtifactsCommitMetadata`   |\n| `readTree({ repoName, hash, signal? })`                | `ArtifactsTreeEntry[]`      |\n| `readBlob({ repoName, hash, signal? })`                | `Response`                  |\n| `readFile({ repoName, ref, path, signal? })`           | `Response`                  |\n| `getRawUrl({ repoName, ref, path })`                   | `string` (synchronous)      |\n\nBinary reads hand back the `Response` untouched, so you can stream it, size-check it, or discard it. Nothing is buffered or base64-encoded.\n\nJSON payloads are narrowed field by field at the boundary — there are no type assertions — and repository metadata is normalized from the wire's snake_case to camelCase.\n\n```ts\ntype ArtifactsTreeEntry = {\n  name: string;\n  mode: string;\n  hash: string;\n  type: \"tree\" | \"blob\" | \"symlink\" | \"gitlink\" | \"exec\";\n};\n```\n\n`exec` and `symlink` behave like blobs. `gitlink` is a submodule pointer with no content in this repository, and renders as an inert row.\n\n## `artifacts-viewer/react` — components and hooks\n\n### Components\n\n| Component               | Purpose                                                  |\n| ----------------------- | -------------------------------------------------------- |\n| `ArtifactRepoViewer`    | Two-pane browser: sidebar tree plus content pane         |\n| `ArtifactFileTree`      | Lazily expanding sidebar tree                            |\n| `ArtifactDirectoryView` | Flat listing of one directory                            |\n| `ArtifactFileView`      | One file, with Raw/Download and content rendering        |\n| `CodeView`              | Syntax-highlighted text, given contents you already have |\n\nIn `ArtifactRepoViewer` the content pane shows a file when one is selected, and a listing for any directory below the root. The root itself is left blank, because the sidebar already lists it and a second copy reads as a duplicate. `ArtifactDirectoryView` has no such rule — used directly, it lists whatever tree you give it.\n\n`ArtifactRepoViewer` props:\n\n| Prop                  | Type                                    | Notes                                                             |\n| --------------------- | --------------------------------------- | ----------------------------------------------------------------- |\n| `client`              | `ArtifactsClient`                       | Required. Keep the identity stable                                |\n| `repoName`            | `string`                                | Required                                                          |\n| `gitRef`              | `string`                                | Branch, tag, or commit. Omitted resolves the default branch       |\n| `onSelect`            | `(s: ArtifactSelection) => void`        | Fires initially for root, then for each non-gitlink row selection |\n| `buildHref`           | `(s: ArtifactSelection) => string`      | When set, rows render as `<a>` instead of `<button>`              |\n| `icons`               | `Partial<ArtifactIconSlots>`            | `file`, `folder`, `folderOpen`, `submodule`                       |\n| `className` / `style` | —                                       | Applied to the root                                               |\n| `classNames`          | `Partial<Record<ArtifactSlot, string>>` | Per-slot classes                                                  |\n| `colorMode`           | `\"light\" \\| \"dark\" \\| \"system\"`         | Emitted as `data-mode`                                            |\n| `maxInlineBytes`      | `number`                                | Default `524288` (512 KiB)                                        |\n| `pierreDiffsOptions`  | `ArtifactPierreDiffsOptions`            | Code-view theming                                                 |\n| `renderCodeFallback`  | `ArtifactCodeFallbackRenderer`          | Placeholder shown while the code view is prepared                 |\n| `renderStatus`        | `ArtifactStatusRenderers`               | Replaces the loading, empty, and error markup                     |\n\nThe prop is `gitRef`, not `ref`, because React reserves `ref`.\n\n#### `renderStatus` — loading, empty, and error states\n\nBy default every pane renders a plain `<p>` with English text: `Loading repository…`,\n`This directory is empty.`, `Binary file (12.4 KiB).`, and so on. `renderStatus`\nreplaces that markup, which is also how you localise the copy.\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"website\"\n  renderStatus={{\n    loading: (context) => <Spinner label={context.scope} />,\n    empty: (context, kind) => <Notice kind={kind} scope={context.scope} />,\n    error: (context, error) => <Notice tone=\"danger\">{error.message}</Notice>,\n  }}\n/>\n```\n\nEvery renderer receives a discriminated context, so one function can speak\ndifferently per pane:\n\n```ts\ntype ArtifactStatusContext =\n  | { scope: \"repository\"; repoName: string }\n  | { scope: \"tree\"; repoName: string; path: string }\n  | { scope: \"file\"; repoName: string; path: string; name: string };\n\ntype ArtifactEmptyKind = \"empty\" | \"binary\" | \"oversized\";\n\ntype ArtifactStatusRenderers = {\n  loading?: (context: ArtifactStatusContext) => ReactNode;\n  empty?: (context: ArtifactStatusContext, kind?: ArtifactEmptyKind) => ReactNode;\n  error?: (context: ArtifactStatusContext, error: ArtifactsClientError) => ReactNode;\n};\n```\n\n`kind` is only set for files, because a repository or directory is already\nidentified by `context.scope`.\n\nThree things worth knowing:\n\n- **The map is partial.** A renderer returning `undefined` — including one you\n  simply did not name — keeps the default. Return `null` to render nothing.\n- **The slot survives.** Your output is wrapped in the matching slot element, so\n  `data-artifacts-viewer-slot`, `aria-busy`, `role=\"alert\"`, and `data-kind` are\n  still there to target and still announced. You do not repeat them.\n- **`renderStatus` is also accepted by** `ArtifactFileTree`, `ArtifactDirectoryView`,\n  and `ArtifactFileView`, so it works when you compose the pieces yourself.\n\nThe code view is deliberately not covered by `renderStatus`: it waits on the\nhighlighter rather than the network, and [`renderCodeFallback`](#rendercodefallback--what-the-wait-looks-like)\nalready owns that placeholder.\n\n### Hooks\n\nHooks return a discriminated state union — no `isLoading` booleans:\n\n```ts\ntype ArtifactQueryState<T> =\n  | { status: \"idle\" }\n  | { status: \"loading\" }\n  | { status: \"success\"; data: T }\n  | { status: \"error\"; error: ArtifactsClientError };\n```\n\n| Hook                                                                 | Returns                                     |\n| -------------------------------------------------------------------- | ------------------------------------------- |\n| `useArtifactRepository(client, repoName)`                            | `ArtifactsRepository`                       |\n| `useArtifactLog(client, { repoName, ref?, limit?, offset? })`        | `ArtifactsCommitMetadata[]`                 |\n| `useArtifactHeadCommit(client, repoName, ref?)`                      | `ArtifactsCommitMetadata \\| null`           |\n| `useArtifactTree(client, repoName, treeHash \\| null)`                | `ArtifactsTreeEntry[]`                      |\n| `useArtifactBlob(client, { repoName, name, hash, maxInlineBytes? })` | `ArtifactBlobRender`                        |\n| `useArtifactQuery(run, deps)`                                        | Anything; the primitive the others build on |\n\n**There is no cache and no request deduplication.** One request per dependency change, cancelled via `AbortSignal` when dependencies change or the component unmounts. Caching belongs to your data layer; pass a `fetch` that consults it, or wrap the client.\n\n`useArtifactHeadCommit` resolves `null` for an empty repository. Do not use `lastPushAt` for this — the Artifacts API always returns `null` there.\n\n`useArtifactBlob` produces the render classification:\n\n```ts\ntype ArtifactBlobRender =\n  | { kind: \"empty\" }\n  | { kind: \"text\"; contents: string }\n  | { kind: \"image\"; contentType: string }\n  | { kind: \"binary\"; sizeBytes: number }\n  | { kind: \"oversized\" };\n```\n\n### Building your own UI\n\nThe components are composable and customizable, and the hooks are public, so you can replace the markup entirely. `ArtifactRepoViewer` owns its current selection internally.\n\n```tsx\nfunction MyTree({ client, repoName, treeHash }) {\n  const tree = useArtifactTree(client, repoName, treeHash);\n  if (tree.status !== \"success\") return null;\n  return (\n    <ul>\n      {tree.data.map((e) => (\n        <li key={e.hash}>{e.name}</li>\n      ))}\n    </ul>\n  );\n}\n```\n\n## Styling\n\n`artifacts-viewer/styles.css` is **structural only**. It picks no theme, and its main layout values are exposed as custom properties. Its selectors are intentionally low-specificity, using `:where()` for public hooks where practical, so ordinary application rules can override them without `!important`.\n\nImport it once:\n\n```ts\nimport \"artifacts-viewer/styles.css\";\n```\n\n### Custom properties\n\n| Property                                | Default                  |\n| --------------------------------------- | ------------------------ |\n| `--artifacts-viewer-color`              | `inherit`                |\n| `--artifacts-viewer-background`         | `transparent`            |\n| `--artifacts-viewer-font`               | `inherit`                |\n| `--artifacts-viewer-font-size`          | `inherit`                |\n| `--artifacts-viewer-mono-font`          | `monospace`              |\n| `--artifacts-viewer-muted-color`        | `inherit`                |\n| `--artifacts-viewer-error-color`        | `inherit`                |\n| `--artifacts-viewer-icon-color`         | `currentColor`           |\n| `--artifacts-viewer-toolbar-background` | `inherit`                |\n| `--artifacts-viewer-gap`                | `0.5rem`                 |\n| `--artifacts-viewer-indent`             | `1rem`                   |\n| `--artifacts-viewer-row-padding`        | `0.375rem 0.75rem`       |\n| `--artifacts-viewer-sidebar-width`      | `16rem`                  |\n| `--artifacts-viewer-pane-height`        | `none`                   |\n| `--artifacts-viewer-pane-overflow`      | `visible`                |\n| `--artifacts-viewer-image-max-height`   | `80vh`                   |\n| `--artifacts-viewer-focus-outline`      | `2px solid currentColor` |\n| `--artifacts-viewer-focus-offset`       | `-2px`                   |\n\n```css\n.my-viewer {\n  --artifacts-viewer-font: ui-monospace, monospace;\n  --artifacts-viewer-sidebar-width: 18rem;\n  --artifacts-viewer-pane-height: 32rem;\n  --artifacts-viewer-pane-overflow: auto;\n  --artifacts-viewer-muted-color: #6b7280;\n}\n```\n\n### Selector hooks\n\nPublic slots and parts carry stable attributes. Slots also accept a class through `classNames`; internal wrapper elements are not part of this selector contract.\n\n`data-artifacts-viewer-slot`: `root`, `toolbar`, `sidebar`, `tree`, `treeItem`, `content`, `directory`, `directoryItem`, `file`, `loading`, `empty`, `error`\n\n`data-artifacts-viewer-part`: `header`, `name`, `icon`, `actions`, `raw`, `download`, `code`, `code-pending`, `code-fallback`, `code-highlight`, `image`\n\nState attributes:\n\n| Attribute                    | Where                                                                       |\n| ---------------------------- | --------------------------------------------------------------------------- |\n| `data-artifacts-viewer-root` | The root element                                                            |\n| `data-mode`                  | Root; mirrors `colorMode`                                                   |\n| `data-kind`                  | Rows (`tree`, `blob`, `exec`, `symlink`, `gitlink`), and error/notice kinds |\n| `data-selected`              | The selected row                                                            |\n| `data-disabled`              | Submodule rows                                                              |\n\n`data-selected` is a **bare** attribute, so match `[data-selected]` and not `[data-selected=\"true\"]`:\n\n```css\n[data-artifacts-viewer-slot=\"treeItem\"][data-selected] {\n  background: #eef2ff;\n}\n```\n\nThe layout is container-query driven: the two panes collapse to one column below `40rem` of the root's own width, not the viewport's.\n\n## Syntax highlighting\n\nText files render through [`@pierre/diffs`](https://www.npmjs.com/package/@pierre/diffs), which wraps Shiki. It is loaded with a dynamic `import()`, so neither Pierre nor Shiki lands in your initial bundle. `ArtifactRepoViewer` proactively starts fetching the chunk when it mounts.\n\n### Loading\n\nUnhighlighted text is never shown while highlighting is still on its way. Pierre paints an empty node if it is mounted before its theme and grammar are attached, so the code view waits for both, then reveals the highlighted copy only once it has real output. Placeholder and file share one grid cell, so nothing shifts.\n\nThree states, in order:\n\n| State       | Rendered                                                                                            |\n| ----------- | --------------------------------------------------------------------------------------------------- |\n| Preparing   | `renderCodeFallback`, or a loading message by default — `data-artifacts-viewer-part=\"code-pending\"` |\n| Ready       | The highlighted file — `data-artifacts-viewer-part=\"code-highlight\"`                                |\n| Unavailable | Plain `<pre>` holding the file contents — `data-artifacts-viewer-part=\"code-fallback\"`              |\n\nThe unavailable state renders readable text when preloading reports that highlighting could not be initialized. It is not a general error boundary for failures after the lazy component starts rendering.\n\n#### `renderCodeFallback` — what the wait looks like\n\nA render prop, accepted by `ArtifactRepoViewer`, `ArtifactFileView`, and `CodeView`:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  renderCodeFallback={({ name }) => <MySpinner label={`Preparing ${name}`} />}\n/>\n```\n\n```ts\ntype ArtifactCodeFallbackRenderer = (file: { name: string; contents: string }) => ReactNode;\n```\n\n`contents` is the decoded file, so a skeleton can be sized to the real line count.\n\n#### `preloadCodeView` — how long the wait is\n\n**A function, not a prop.** It warms the lazy chunk, the shared Shiki engine, the themes, and — given a file name — that file's grammar.\n\n```ts\nimport { preloadCodeView } from \"artifacts-viewer/react\";\n\nawait preloadCodeView({ theme: { light: \"github-light\", dark: \"vesper\" } });\n```\n\n`ArtifactRepoViewer` already calls it on mount, so warming overlaps with browsing the tree and the first file opened is usually highlighted straight away. Call it yourself to start earlier still — on route entry, or when a row is hovered:\n\n```tsx\n<li onPointerEnter={() => void preloadCodeView({ theme, name: entry.name })}>\n```\n\nMemoized per theme and grammar set, so repeat calls cost nothing. It resolves `boolean`; `false` means highlighting is unavailable, which is what makes the plain-text state above reachable.\n\n### Theming\n\n`@pierre/diffs` renders into an open Shadow DOM. **Your CSS cannot reach inside it.** Theming has to go through options:\n\n```tsx\n<ArtifactRepoViewer\n  client={client}\n  repoName=\"my-repository\"\n  pierreDiffsOptions={{\n    theme: { light: \"github-light\", dark: \"vesper\" },\n    themeType: \"system\",\n  }}\n/>\n```\n\n| Option      | Notes                                                                 |\n| ----------- | --------------------------------------------------------------------- |\n| `theme`     | A Shiki theme name, or a `{ light, dark }` pair                       |\n| `themeType` | `\"system\"` (default), `\"light\"`, or `\"dark\"`                          |\n| `unsafeCSS` | Raw CSS injected into the Shadow DOM. Unstable across Pierre releases |\n\nKeep the object identity stable, or memoize it, to avoid re-rendering the code view.\n\n## Limits and behaviour\n\n**Large files.** `maxInlineBytes` defaults to 512 KiB. If the response declares a larger `Content-Length` the body is never downloaded. Otherwise the stream is read and cancelled the moment the cap is crossed. Either way the state becomes `oversized` and the Raw and Download links still work.\n\n**Images** are detected from the file extension, so they never download through the client — they render straight from the raw URL via `<img src>`. SVG stays in image context and is never inlined. Recognized: `apng`, `avif`, `bmp`, `gif`, `ico`, `jfif`, `jpeg`, `jpg`, `png`, `svg`, `webp`.\n\n**Text vs binary** is decided by a strict UTF-8 decode plus a NUL-byte scan over the first 8 KiB, not by an extension list. Anything that fails is `binary` and never reaches the highlighter.\n\n**Raw and download URLs** are pinned to the commit hash rather than a branch name, so a link keeps resolving — and stays cacheable — even if someone pushes mid-session.\n\n**Tree expansion** is keyed by path, not by tree hash, so two identical subtrees at different paths expand independently.\n\n**Accessibility.** The sidebar is a `<nav>` of nested disclosure buttons rather than `role=\"tree\"`, because full WAI-ARIA tree keyboard navigation is not implemented and claiming the role without it would be worse than not claiming it. Rows are real buttons, or real anchors when `buildHref` is supplied.\n\n## Not included\n\nDeliberately out of scope for now:\n\n- **Markdown rendering.** `.md` files currently display as highlighted plain text.\n- **Diff viewing** and commit history UI.\n- **Branch switching.** The Artifacts REST API exposes no ref-listing operation; enumerating branches needs Git protocol v2 `ls-refs`. Pass a known branch to `gitRef`, or omit it for the default branch.\n- **Writes** of any kind.\n\n## License\n\nMIT\n\n<!-- File: packages/artifacts-viewer/README.md (Tokens: ~833 | Category: plugin-manifest) -->\n# artifacts-viewer\n\nMonorepo for [`artifacts-viewer`](./packages/artifacts-viewer), a read-only React\nrepository viewer for [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/).\n\nFor package documentation — installation, API reference, styling, theming — see\n[`packages/artifacts-viewer/README.md`](./packages/artifacts-viewer/README.md).\nFor the release procedure, see [`PUBLISH.md`](./PUBLISH.md).\n\n## Layout\n\n```text\napps/example                 Cloudflare Workers + React app that consumes the package\npackages/artifacts-viewer    The published npm package\n```\n\nThe package ships four JavaScript entry points, one CSS export, and package metadata, each with a deliberate boundary:\n\n| Entry point                     | Contains                                                | Must not import       |\n| ------------------------------- | ------------------------------------------------------- | --------------------- |\n| `artifacts-viewer`              | `routeArtifactRequest`, the server-side read-only proxy | React                 |\n| `artifacts-viewer/server/cache` | Workers Cache API and KV adapters                       | React                 |\n| `artifacts-viewer/client`       | `createArtifactsClient`, framework-independent          | React, Worker globals |\n| `artifacts-viewer/react`        | Hooks and components                                    | The server router     |\n| `artifacts-viewer/styles.css`   | Structural CSS only                                     | —                     |\n\nPlatform-neutral code shared by both sides (wire types, path helpers, blob\nclassification) lives in `packages/artifacts-viewer/src/shared/`. The entry\npoints are designed to preserve those boundaries: the server and client builds\ndo not import React, and the React build does not import the server router.\n\n## Toolchain\n\nThis workspace uses [Vite+](https://viteplus.dev), which wraps runtime\nmanagement, package management, and frontend tooling behind a single `vp` CLI.\nRun `vp help` for the command list. Docs are vendored at\n`node_modules/vite-plus/docs`.\n\nDependency versions are centralised in the `catalog:` block of\n`pnpm-workspace.yaml`. Add a version there and reference it as `\"catalog:\"` from\na package rather than pinning per package.\n\n## Development\n\n```bash\nvp install          # install; run after pulling\nvp run dev          # start apps/example with source-level library HMR\nvp run ready        # vp check && vp run -r build — the CI gate\nvp run -r build     # build every workspace project\n```\n\nThere is no test suite. `vp check` covers formatting, linting, and type checking\nacross all projects.\n\n### Running the example app\n\nThe example needs credentials for a real Artifacts namespace. The API token\nlives only in the Worker; the browser never sees it.\n\n```bash\ncd apps/example\ncp .dev.vars.example .dev.vars   # then fill in the three values\n```\n\n`wrangler types` reads `.dev.vars` to generate `Env`, so rerun\n`vp run cf-typegen` from `apps/example` after adding or renaming a variable.\n\n`apps/example/wrangler.jsonc` sets `assets.run_worker_first: [\"/api/*\"]`.\nWithout it, `not_found_handling: \"single-page-application\"` would serve\n`index.html` for API routes and the Worker would never run.\n\n## Releases\n\nVersions and changelogs are generated by pnpm's native release tooling; never\nhand-edit `version` or `CHANGELOG.md`.\n\n```bash\npnpm change         # record a change intent, committed with the code\npnpm version -r     # consume intents: bump, write changelogs, update the ledger\n```\n\nPublishing runs on GitHub Actions via npm OIDC trusted publishing, triggered by\na published GitHub Release whose tag matches `v<version>`. No npm token exists\nanywhere in the repository. [`PUBLISH.md`](./PUBLISH.md) has the full procedure\nand the failure modes worth knowing about.\n\n## License\n\nMIT\n</yakaai_skills>"}