diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a7450b5..2d619ed7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,4 +23,4 @@ permissions: jobs: check: name: CI - uses: kirchDev/workflows/.github/workflows/_ci-check.yml@7fd4eaf529500fcd07c527ae70c839ab9ac7d76f # v0.7.0 + uses: kirchDev/workflows/.github/workflows/_ci-check.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9d363d7b..6022683f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,4 +28,4 @@ jobs: packages: read actions: read contents: read - uses: kirchDev/workflows/.github/workflows/_codeql.yml@7fd4eaf529500fcd07c527ae70c839ab9ac7d76f # v0.7.0 + uses: kirchDev/workflows/.github/workflows/_codeql.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 diff --git a/.github/workflows/fast-forward-queue.yml b/.github/workflows/fast-forward-queue.yml index c84db007..8467a90a 100644 --- a/.github/workflows/fast-forward-queue.yml +++ b/.github/workflows/fast-forward-queue.yml @@ -34,7 +34,7 @@ jobs: pull-requests: read checks: read statuses: read - uses: kirchDev/workflows/.github/workflows/_fast-forward-queue.yml@7fd4eaf529500fcd07c527ae70c839ab9ac7d76f # v0.7.0 + uses: kirchDev/workflows/.github/workflows/_fast-forward-queue.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 with: pr: ${{ inputs.pr }} secrets: diff --git a/.github/workflows/promotion-pr.yml b/.github/workflows/promotion-pr.yml index a7bc9f7c..ee23edba 100644 --- a/.github/workflows/promotion-pr.yml +++ b/.github/workflows/promotion-pr.yml @@ -18,4 +18,4 @@ permissions: jobs: promotion-pr: name: Promotion PR - uses: kirchDev/workflows/.github/workflows/_promotion-pr.yml@7fd4eaf529500fcd07c527ae70c839ab9ac7d76f # v0.7.0 + uses: kirchDev/workflows/.github/workflows/_promotion-pr.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 diff --git a/.github/workflows/queue-branch.yml b/.github/workflows/queue-branch.yml index aedbf6a2..17a67919 100644 --- a/.github/workflows/queue-branch.yml +++ b/.github/workflows/queue-branch.yml @@ -20,6 +20,6 @@ jobs: permissions: contents: read pull-requests: write - uses: kirchDev/workflows/.github/workflows/_queue-branch.yml@7fd4eaf529500fcd07c527ae70c839ab9ac7d76f # v0.7.0 + uses: kirchDev/workflows/.github/workflows/_queue-branch.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 secrets: BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5fb6dae4..0ba2f4b7 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -15,6 +15,6 @@ jobs: permissions: contents: write pull-requests: write - uses: kirchDev/workflows/.github/workflows/_release-please.yml@7fd4eaf529500fcd07c527ae70c839ab9ac7d76f # v0.7.0 + uses: kirchDev/workflows/.github/workflows/_release-please.yml@94cbcbd676b989ac6c6327055a240132f5cd76a5 # v0.8.0 secrets: BWS_ACCESS_TOKEN: ${{ secrets.BWS_ACCESS_TOKEN }} diff --git a/.gitignore b/.gitignore index 3af46a99..c0168f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,16 @@ # region gitignore-sync -# stacks: core, git, node, vscode, intellij, macos, windows, linux, agents +# stacks: core, agents, git, node, nuxt, vscode, intellij, macos, windows, linux # ───────────────────────────────────────── # region core@v1 .DS_Store # endregion +# region agents@v1 +.claude/settings.local.json +.claude/worktrees/ +# endregion + # region git@v1 *.orig *.rej @@ -31,6 +36,13 @@ coverage *.tgz # endregion +# region nuxt@v1 +.nuxt +.output +.nitro +.data +# endregion + # region vscode@v1 .vscode/* !.vscode/extensions.json @@ -85,9 +97,4 @@ $RECYCLE.BIN/ .nfs* # endregion -# region agents@v1 -.claude/settings.local.json -.claude/worktrees/ -# endregion - # endregion diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b61d6290..16488145 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,8 @@ "pnpm-lock.yaml", "README.md", "CLAUDE.md", - "AGENTS.md" + "AGENTS.md", + "docs/**/*.md", + "www/content/**/*.md" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..bfb6dfc4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "i18n-ally.localesPaths": ["i18n", "i18n/locales"] +} diff --git a/AGENTS.md b/AGENTS.md index 6104243d..b75ff12d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl > [!IMPORTANT] > **Nothing here is decided.** `duxt` is at the idea stage — the repo currently carries the meta layer and nothing else. What follows is the working sketch plus the questions still open. Treat every "is" below as "is currently assumed"; do not harden any of it into code without asking. +The idea started in `kirchDev/greenhouse` as `ideas/nuxt-ai-docs.md`, where it is filed under that working title; `duxt` is the name it got here. + The sketch: `duxt` as a **Nuxt documentation layer on Nuxt Content v3**, published as `@kirchdev/duxt` and consumed with one line: ```ts @@ -42,11 +44,20 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis **Decided so far:** -- **The theme is shadcn-vue, wired through `shadcn-nuxt`** — a clean base with owned components, not Docus or Nuxt UI. No UI-Pro dependency, and the components are copied into the repo rather than imported, so overriding them is editing them. -- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in the playground. Reordering does not move a URL; only renaming the name part does. -- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.nativeSqlite` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — the playground builds and renders with neither `better-sqlite3` nor `@libsql/client` present. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. +- **The theme is shadcn-vue, wired through `shadcn-nuxt`** — a clean Tailwind 4 base with owned components in `app/components/ui/`, not Docus or Nuxt UI. Add one with `pnpm dlx shadcn-vue@latest add `; `components.json` already points the CLI at the layer's own alias. The palette is the neutral shadcn set as CSS variables in `app/assets/css/duxt.css`, `dark` class toggled by `@nuxtjs/color-mode`; a consumer redefines a token in its own stylesheet rather than forking the file. +- **Markdown components are MDC, not MDX.** Content ships MDC, so `::callout{type="tip"}` works with no extra module. Components live in `app/components/content/`. +- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in `www/`. Reordering does not move a URL; only renaming the name part does. +- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.nativeSqlite` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — `www/` builds and renders with neither `better-sqlite3` nor `@libsql/client` present. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. + +**Repo shape — the root IS the layer.** `nuxt.config.ts`, `content.config.ts` and `app/` sit at the repo root, and `package.json` points at them with `main: "./nuxt.config.ts"` plus a `files` allowlist, so `extends: ['@kirchdev/duxt']` resolves. `www/` is the consuming site beside it — the only workspace package, and the development target, exactly as `www/` is in `ZTL-UwU/shadcn-docs-nuxt`. `nuxt` is a peerDependency of the layer and a real dependency only of `www/`. + +> [!IMPORTANT] +> **Nothing layer-relative resolves the way it reads.** Three places have hit this already, and a fourth will: the Content collection `cwd`, the `css` entry and `componentDir` in `nuxt.config.ts` (both go through the `layer()` helper resolving against `import.meta.url`), and the `@` alias — which belongs to whoever extends the layer, so the layer's own imports use `@duxt` instead. Assume any path written here is read from the consumer's directory until proven otherwise. + +> [!IMPORTANT] +> **A layer's collections resolve against the LAYER, not the consumer.** Content sets `collection.__rootDir = curr.cwd` per layer, so a relative `source.cwd` in this repo's `content.config.ts` points into this repo — never into the site that extends it. The layer therefore computes an absolute path at load time (`join(process.cwd(), 'docs')`), which works because c12 executes the config. This is the seam the whole `sources` shorthand sits on. -If the layer is built, `playground/` is where it is developed and wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a playground makes a bad starter. +`www/` wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a development site makes a bad starter. ## Commands @@ -56,8 +67,12 @@ If the layer is built, `playground/` is where it is developed and wants edge cas | `pnpm lint` | `oxlint . --deny-warnings` | | `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) | | `pnpm typecheck` | `tsc --noEmit` over the meta scripts | -| `pnpm check` | Runs `lint` + `format` + `typecheck` + `check:policy` — the CI gate | +| `pnpm typecheck:app`| `nuxt typecheck` over the layer, run through `www/` | +| `pnpm test` | `vitest run` over the layer's pure logic | +| `pnpm build:app` | `nuxt build` in `www/` — the gate's SSR check | +| `pnpm check` | Runs `lint` + `format` + both typechecks + `test` + `check:policy` + `build:app` + `check:a11y` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | +| `pnpm check:a11y` | axe-core over five rendered pages of the built site | | `pnpm lint:fix` | Auto-fix lint | | `pnpm format:fix` | Auto-fix format | | `pnpm check:fix` | Auto-fix lint + format | @@ -65,13 +80,13 @@ If the layer is built, `playground/` is where it is developed and wants edge cas | `pnpm taze` | Interactive dependency upgrade check | | `pnpm taze:w` | Write upgrade results | -There is no test suite yet — the repo currently carries only the meta layer. CI runs `pnpm lint`, `pnpm format`, `pnpm typecheck` and `pnpm check:policy` on PR; adding a check to the `check` script is enough, no workflow change needed. +Tests cover the layer's pure logic — the source resolver, the config merge, the icon lookup, the build validator, the redirect map, the 404's nearest-page scoring — in `tests/`, run by vitest. `tests/contrast.test.ts` is the odd one out and deliberate: it parses the palette out of `duxt.css` and measures every foreground against the background it is paired with, because that is the one accessibility rule `check:a11y` cannot answer (jsdom has no computed colour) and the one this theme actually broke. Component rendering is not covered: it needs a Nuxt environment, and the failures this repo actually had were SSR failures, which is why `check` builds the site instead. `check:a11y` then runs axe-core over that build: jsdom has no layout, so `color-contrast` and `target-size` are reported as skipped rather than passed, and the structural rules — landmarks, names, heading order, ARIA — are what it enforces. CI runs whatever `check` chains on PR; adding a check to the `check` script is enough, no workflow change needed. ## Architecture / conventions -- **Node 24, pnpm 12.** Pinned via `.nvmrc`, `engines`, and `packageManager`. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Don't loosen these without reason. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset (the rationale sits as a comment in the file). +- **Node 24, pnpm 12, TypeScript 6.** Pinned via `.nvmrc`, `engines`, and `packageManager`. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Don't loosen these without reason. **TypeScript stays on 6 deliberately**: 7 is the native port, whose `exports` map no longer exposes the compiler internals Volar builds on, so `vue-tsc` cannot run on it — and without `vue-tsc` the layer has no typecheck at all. Move to 7 when Volar does, not before. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset (the rationale sits as a comment in the file). - **oxc, not eslint/prettier.** Linting via `oxlint`, formatting via `oxfmt`. Configs live in `.oxlintrc.json` / `.oxfmtrc.json`. `oxlint` uses `unicorn` + `oxc` plugins; rules deliberately minimal. -- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate. Once `.vue` files land, `typecheck` moves to `vue-tsc`; `oxlint` + `oxfmt` cover `.vue` on their own, so no ESLint is coming. +- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate for those. The layer itself is checked separately by `pnpm typecheck:app` (`nuxt typecheck` in `www/`), because `tsc` cannot see a Nuxt config's module options — those exist only in generated types. `oxlint` + `oxfmt` cover `.vue`, so no ESLint is coming. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. `oxlint --fix --deny-warnings` then `oxfmt` on JS/TS; `oxfmt` only on JSON/YAML/MD. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. - **release-please** drives the versioning. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. `release-type: node` (this is a published package, so `package.json` gets bumped too), `include-v-in-tag: true`, starting from `0.0.0`. Publishing to npm is a job added to `release-please.yml`, gated on `needs.release-please.outputs.release-created`. diff --git a/CLAUDE.md b/CLAUDE.md index 6104243d..b75ff12d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,8 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl > [!IMPORTANT] > **Nothing here is decided.** `duxt` is at the idea stage — the repo currently carries the meta layer and nothing else. What follows is the working sketch plus the questions still open. Treat every "is" below as "is currently assumed"; do not harden any of it into code without asking. +The idea started in `kirchDev/greenhouse` as `ideas/nuxt-ai-docs.md`, where it is filed under that working title; `duxt` is the name it got here. + The sketch: `duxt` as a **Nuxt documentation layer on Nuxt Content v3**, published as `@kirchdev/duxt` and consumed with one line: ```ts @@ -42,11 +44,20 @@ What that leaves as candidate value: the **ergonomics** (a compact `sources` lis **Decided so far:** -- **The theme is shadcn-vue, wired through `shadcn-nuxt`** — a clean base with owned components, not Docus or Nuxt UI. No UI-Pro dependency, and the components are copied into the repo rather than imported, so overriding them is editing them. -- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in the playground. Reordering does not move a URL; only renaming the name part does. -- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.nativeSqlite` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — the playground builds and renders with neither `better-sqlite3` nor `@libsql/client` present. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. +- **The theme is shadcn-vue, wired through `shadcn-nuxt`** — a clean Tailwind 4 base with owned components in `app/components/ui/`, not Docus or Nuxt UI. Add one with `pnpm dlx shadcn-vue@latest add `; `components.json` already points the CLI at the layer's own alias. The palette is the neutral shadcn set as CSS variables in `app/assets/css/duxt.css`, `dark` class toggled by `@nuxtjs/color-mode`; a consumer redefines a token in its own stylesheet rather than forking the file. +- **Markdown components are MDC, not MDX.** Content ships MDC, so `::callout{type="tip"}` works with no extra module. Components live in `app/components/content/`. +- **Numbered section prefixes are a non-issue.** Content strips them itself: `1.guides/` renders at `/guides`, `99.adr/` at `/adr`. Verified in `www/`. Reordering does not move a URL; only renaming the name part does. +- **No SQLite driver is installed.** Content's default is `better-sqlite3`, a native addon needing a node-gyp toolchain. `content.experimental.nativeSqlite` uses Node 24's built-in `node:sqlite` instead, which needs no package at all — `www/` builds and renders with neither `better-sqlite3` nor `@libsql/client` present. It is flagged experimental in Content; if that changes, `@libsql/client` is the prebuilt fallback, not `better-sqlite3`. + +**Repo shape — the root IS the layer.** `nuxt.config.ts`, `content.config.ts` and `app/` sit at the repo root, and `package.json` points at them with `main: "./nuxt.config.ts"` plus a `files` allowlist, so `extends: ['@kirchdev/duxt']` resolves. `www/` is the consuming site beside it — the only workspace package, and the development target, exactly as `www/` is in `ZTL-UwU/shadcn-docs-nuxt`. `nuxt` is a peerDependency of the layer and a real dependency only of `www/`. + +> [!IMPORTANT] +> **Nothing layer-relative resolves the way it reads.** Three places have hit this already, and a fourth will: the Content collection `cwd`, the `css` entry and `componentDir` in `nuxt.config.ts` (both go through the `layer()` helper resolving against `import.meta.url`), and the `@` alias — which belongs to whoever extends the layer, so the layer's own imports use `@duxt` instead. Assume any path written here is read from the consumer's directory until proven otherwise. + +> [!IMPORTANT] +> **A layer's collections resolve against the LAYER, not the consumer.** Content sets `collection.__rootDir = curr.cwd` per layer, so a relative `source.cwd` in this repo's `content.config.ts` points into this repo — never into the site that extends it. The layer therefore computes an absolute path at load time (`join(process.cwd(), 'docs')`), which works because c12 executes the config. This is the seam the whole `sources` shorthand sits on. -If the layer is built, `playground/` is where it is developed and wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a playground makes a bad starter. +`www/` wants edge cases, ugly frontmatter, several sources and a tag to read from. [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter) would be a **different artifact** — minimal and exemplary, what a stranger clones with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. Do not conflate the two; a development site makes a bad starter. ## Commands @@ -56,8 +67,12 @@ If the layer is built, `playground/` is where it is developed and wants edge cas | `pnpm lint` | `oxlint . --deny-warnings` | | `pnpm format` | `oxfmt --check .` (note: `format` is the check, not fix) | | `pnpm typecheck` | `tsc --noEmit` over the meta scripts | -| `pnpm check` | Runs `lint` + `format` + `typecheck` + `check:policy` — the CI gate | +| `pnpm typecheck:app`| `nuxt typecheck` over the layer, run through `www/` | +| `pnpm test` | `vitest run` over the layer's pure logic | +| `pnpm build:app` | `nuxt build` in `www/` — the gate's SSR check | +| `pnpm check` | Runs `lint` + `format` + both typechecks + `test` + `check:policy` + `build:app` + `check:a11y` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | +| `pnpm check:a11y` | axe-core over five rendered pages of the built site | | `pnpm lint:fix` | Auto-fix lint | | `pnpm format:fix` | Auto-fix format | | `pnpm check:fix` | Auto-fix lint + format | @@ -65,13 +80,13 @@ If the layer is built, `playground/` is where it is developed and wants edge cas | `pnpm taze` | Interactive dependency upgrade check | | `pnpm taze:w` | Write upgrade results | -There is no test suite yet — the repo currently carries only the meta layer. CI runs `pnpm lint`, `pnpm format`, `pnpm typecheck` and `pnpm check:policy` on PR; adding a check to the `check` script is enough, no workflow change needed. +Tests cover the layer's pure logic — the source resolver, the config merge, the icon lookup, the build validator, the redirect map, the 404's nearest-page scoring — in `tests/`, run by vitest. `tests/contrast.test.ts` is the odd one out and deliberate: it parses the palette out of `duxt.css` and measures every foreground against the background it is paired with, because that is the one accessibility rule `check:a11y` cannot answer (jsdom has no computed colour) and the one this theme actually broke. Component rendering is not covered: it needs a Nuxt environment, and the failures this repo actually had were SSR failures, which is why `check` builds the site instead. `check:a11y` then runs axe-core over that build: jsdom has no layout, so `color-contrast` and `target-size` are reported as skipped rather than passed, and the structural rules — landmarks, names, heading order, ARIA — are what it enforces. CI runs whatever `check` chains on PR; adding a check to the `check` script is enough, no workflow change needed. ## Architecture / conventions -- **Node 24, pnpm 12.** Pinned via `.nvmrc`, `engines`, and `packageManager`. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Don't loosen these without reason. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset (the rationale sits as a comment in the file). +- **Node 24, pnpm 12, TypeScript 6.** Pinned via `.nvmrc`, `engines`, and `packageManager`. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Don't loosen these without reason. **TypeScript stays on 6 deliberately**: 7 is the native port, whose `exports` map no longer exposes the compiler internals Volar builds on, so `vue-tsc` cannot run on it — and without `vue-tsc` the layer has no typecheck at all. Move to 7 when Volar does, not before. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset (the rationale sits as a comment in the file). - **oxc, not eslint/prettier.** Linting via `oxlint`, formatting via `oxfmt`. Configs live in `.oxlintrc.json` / `.oxfmtrc.json`. `oxlint` uses `unicorn` + `oxc` plugins; rules deliberately minimal. -- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate. Once `.vue` files land, `typecheck` moves to `vue-tsc`; `oxlint` + `oxfmt` cover `.vue` on their own, so no ESLint is coming. +- **TypeScript, no build step.** The meta scripts and the three tool configs are `.ts` — Node 24 strips types natively, so `scripts/check-policy-parity.ts`, `commitlint.config.ts`, `lint-staged.config.ts` and `taze.config.ts` stay directly executable and each tool loads its own `.ts` config unaided. `tsconfig.json` is `noEmit` + `strict` + `erasableSyntaxOnly`, so only strippable syntax (no enums, no parameter properties) can be written; `pnpm typecheck` is the gate for those. The layer itself is checked separately by `pnpm typecheck:app` (`nuxt typecheck` in `www/`), because `tsc` cannot see a Nuxt config's module options — those exist only in generated types. `oxlint` + `oxfmt` cover `.vue`, so no ESLint is coming. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. `oxlint --fix --deny-warnings` then `oxfmt` on JS/TS; `oxfmt` only on JSON/YAML/MD. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. - **release-please** drives the versioning. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. `release-type: node` (this is a published package, so `package.json` gets bumped too), `include-v-in-tag: true`, starting from `0.0.0`. Publishing to npm is a job added to `release-please.yml`, gated on `needs.release-please.outputs.release-created`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f20339d..abb2f509 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT. Requirements: -- Node **24+** and **pnpm 12** +- Node **24+**, **pnpm 12** and **TypeScript 6** (7 is held back — see CLAUDE.md) - `git` Clone and install: @@ -30,13 +30,14 @@ pnpm install # wires husky hooks ## Running the suite -| Command | What it does | -| :--------------- | :----------------------------------------------------- | -| `pnpm lint` | oxlint across the repo. | -| `pnpm format` | oxfmt check across JS / TS / JSON / YAML / MD. | -| `pnpm typecheck` | `tsc --noEmit` over the meta scripts. | -| `pnpm check` | Runs `lint`, `format`, `typecheck` and `check:policy`. | -| `pnpm check:fix` | Auto-fix lint + format issues. | +| Command | What it does | +| :------------------- | :--------------------------------------------------------- | +| `pnpm lint` | oxlint across the repo. | +| `pnpm format` | oxfmt check across JS / TS / JSON / YAML / MD. | +| `pnpm typecheck` | `tsc --noEmit` over the meta scripts. | +| `pnpm typecheck:app` | `nuxt typecheck` over the layer, via `www/`. | +| `pnpm check` | Runs `lint`, `format`, both typechecks and `check:policy`. | +| `pnpm check:fix` | Auto-fix lint + format issues. | The same commands run in CI — keep them green before you push. diff --git a/README.md b/README.md index d7f78855..b73b0aab 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,13 @@ # 📚 duxt -**An idea: versioned, multi-repo documentation for Nuxt — one line to extend, no collection boilerplate** +**Versioned, multi-repo documentation for Nuxt — one line to extend, no collection boilerplate** + +[![npm Version](https://img.shields.io/npm/v/@kirchdev/duxt.svg?style=flat-square&color=4f46e5)](https://www.npmjs.com/package/@kirchdev/duxt) +[![Downloads](https://img.shields.io/npm/dm/@kirchdev/duxt.svg?style=flat-square&color=4f46e5)](https://www.npmjs.com/package/@kirchdev/duxt) +[![Tests](https://img.shields.io/github/actions/workflow/status/kirchDev/duxt/ci.yml?branch=main&style=flat-square&label=tests)](https://github.com/kirchDev/duxt/actions/workflows/ci.yml) +[![Node Version](https://img.shields.io/node/v/@kirchdev/duxt.svg?style=flat-square&color=8993be)](https://www.npmjs.com/package/@kirchdev/duxt) +[![License: MIT](https://img.shields.io/npm/l/@kirchdev/duxt.svg?style=flat-square&color=10b981)](LICENSE) @@ -14,45 +20,92 @@ export default defineNuxtConfig({ }) ``` -The idea is that this line is the whole setup: Nuxt Content v3 underneath, a theme on top, and — when you need them — several source repositories and several versions of the docs, declared as a list rather than as one collection per version × repo. - -> [!IMPORTANT] -> **This is a sketch, not a product.** Nothing is built, nothing is published to npm, and none of the design below is decided — including whether the layer gets built on a clean base or on top of Docus / Nuxt UI, and whether it stays public at all. Everything here reads as a proposal to argue with. - -## ✨ The idea +That's it. Put your Markdown in `docs/` and you have a themed documentation site — search, navigation, table of contents, dark mode, `llms.txt` and an MCP server — with no collection, no layout and no config written by hand. -- **📦 Extend, don't scaffold** — a Nuxt layer, so the theme, pages, components and `app.config` defaults arrive with `extends` and are overridden file by file where you disagree. -- **🗂️ Sources as a list** — one compact declaration per source instead of one Content collection per version × repo: +## 🤔 Why - ```ts - sources: [ - { path: 'docs' }, // this repo, current branch - { path: 'docs', refs: ['v1.x', 'v2.x', 'main'] }, // versioned - { repo: 'kirchDev/app', path: 'docs' }, // another repo - ] - ``` +Nuxt Content v3 already downloads and caches a git repository, at a branch or at a tag, private ones included — so nothing here rebuilds that. What it does not give you is the part that gets retyped in every documentation repo: one collection per version × repository, a URL scheme that carries them, a version switcher that knows which page exists where, and a theme on top. - The single-source, unversioned case is the default and needs no config at all. -- **🔀 Version switcher and URL scheme** — `/[repo]/[version]/[...slug]`, collapsing cleanly when there is one source and no versions, with a defined fallback for a page that a given version does not have. -- **🌿 Git-native sourcing** — branches, tags, private repositories and hash-based caching come straight from Nuxt Content v3's own `repository` support. `duxt` adds the ergonomics on top, not a second mechanism. -- **🤖 Machine-readable output** — `llms.txt` and an MCP route over the same content, generated at build time. +duxt turns all of it into one list. Sources are declared once, and the collections, the prefixes, the switcher, the redirects and the sitemap are generated from that declaration. -## 🚀 Setup (intended) +## 📦 Install & run ```bash pnpm add -D @kirchdev/duxt ``` +The `extends` line above is the whole of the single-folder case. Everything beyond it is one file — `app/app.config.ts`, which both halves of the layer read: + ```ts -// nuxt.config.ts -export default defineNuxtConfig({ - extends: ['@kirchdev/duxt'], +export default defineAppConfig({ + duxt: { + title: 'Acme', + sources: [ + { path: 'docs', slug: 'acme' }, // this repository + { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] }, + ], + sourceOptions: { defaultRef: 'v2.0.0' }, + }, }) ``` -Put your Markdown in `docs/` and start the app. Everything beyond that — more sources, versions, theme overrides — would be opt-in. +Two repositories and three refs become five collections, the URL prefixes that serve them, and a version switcher — none of which you write. + +## ✨ Features + +- **📦 Extend, don't scaffold** — a Nuxt layer: theme, pages, components and `app.config` defaults arrive with `extends` and are overridden file by file where you disagree. +- **🗂️ Sources as a list** — one compact entry per source instead of one Content collection per version × repository. +- **🧭 Version switcher and URL scheme** — `/[repo]/[version]/[...slug]`, collapsing cleanly when there is one source and no versions, with a lifecycle per version that decides the banner a reader gets. +- **🌿 Git-native sourcing** — branches, tags, private repositories and hash-based caching come straight from Content v3's own `repository` support; `'latest'` resolves to the newest semver tag at build time. +- **🤖 Machine-readable by default** — `llms.txt`, `llms-full.txt` and a real MCP server at `/mcp`, generated from the same collections the pages render from. +- **🎨 shadcn-vue theme, owned not vendored** — Tailwind 4 with the components in `app/components/ui/`, MDC components in `app/components/content/`. +- **🔍 Search, TOC, breadcrumb, prev/next** — ⌘K fuzzy search across every source, active-heading tracking, section landing pages, feedback and "Edit this page" links. +- **🧯 A build that fails loudly** — a validator walks the parse cache for URL collisions, empty collections, broken links and missing titles, because every bug this layer actually had was a silent one. + +
+Full feature list + +### Sources & versions + +- **🗂️ Sources as a list** — one compact entry per source instead of one Content collection per version × repository. The single unversioned folder is the default and needs no config at all. +- **🧭 Version switcher and URL scheme** — `/[repo]/[version]/[...slug]`, collapsing cleanly when there is one source and no versions, with a lifecycle per version (`upcoming`, `current`, `maintained`, `deprecated`, `eol`) that decides the banner a reader gets. +- **🌿 Git-native sourcing** — branches, tags, private repositories and hash-based caching come straight from Content v3's own `repository` support; `'latest'` resolves to the newest semver tag at build time. +- **🔁 Redirects from frontmatter** — `redirectFrom` on a moved page becomes route rules under every repository, version and locale prefix it is served at. + +### Theme & reading experience -Neither the package nor `kirchDev/duxt-starter` exists yet; the starter, if it happens, would be cloned with `npx nuxi@latest init -t github:kirchDev/duxt-starter`. +- **🎨 shadcn-vue theme, owned not vendored** — Tailwind 4 with the components in `app/components/ui/`, MDC components (`::callout`, `::steps`, `::code-group`, file trees, Mermaid) in `app/components/content/`. +- **🔍 Search, TOC, breadcrumb, prev/next** — ⌘K fuzzy search across every source, active-heading tracking, section landing pages, feedback and "Edit this page" links. +- **🌍 Seven locales out of the box** — `en-GB`, `en-US`, `de-DE`, `es-ES`, `fr-FR`, `pt-PT` and `pt-BR`; a site picks which of them it serves, and its own strings take a literal, a key or a per-locale record. + +### Machine-readable & SEO + +- **🤖 Machine-readable by default** — `llms.txt`, `llms-full.txt` and a real MCP server at `/mcp` (list pages, read a page, search), generated from the same collections the pages render from. +- **📈 SEO and feeds included** — sitemap, robots, generated OG images, and an optional `/rss.xml` over a section you nominate. + +### Layer & build + +- **📦 Extend, don't scaffold** — a Nuxt layer: theme, pages, components and `app.config` defaults arrive with `extends` and are overridden file by file where you disagree. +- **🧯 A build that fails loudly** — a validator walks the parse cache for URL collisions, empty collections, broken links and missing titles, because every bug this layer actually had was a silent one. +- **🔎 DevTools tab** — the resolved sources, collections, prefixes, message catalogues and download cache, in dev, where guessing used to be the only option. + +
+ +## ⚙️ Configuration + +Everything lives under the `duxt` key of `app.config.ts`. The keys most sites touch: + +| Key | What it controls | +| :-------------- | :------------------------------------------------------------------------ | +| `sources` | The documentation sources — folder, repository, refs, lifecycle, history. | +| `sourceOptions` | How those become URL prefixes (`defaultRef`, `showRepo`, `showVersion`). | +| `title` | The site name, in the navbar and every generated document. | +| `locales` | Which of the layer's locales this site serves. Read at build time. | + +Every text field takes a literal, an i18n key, or a per-locale record — a single-language site never sees the other two. + +> [!TIP] +> The full surface — `navigation`, `sections`, `landing`, `feed`, `footer` and the rest — is typed, and the types are the documentation: [`app/types/duxt.d.ts`](app/types/duxt.d.ts) carries a comment per key explaining what it costs and when it is read. ## 🧪 Development @@ -60,10 +113,10 @@ Neither the package nor `kirchDev/duxt-starter` exists yet; the starter, if it h git clone https://github.com/kirchDev/duxt.git cd duxt pnpm install # wires the husky hooks -pnpm check # lint + format + typecheck + policy parity +pnpm check # lint + format + typecheck + tests + policy parity + build + a11y ``` -`playground/` does not exist yet. It is meant to be where the layer is developed — it deliberately carries edge cases, ugly frontmatter, several sources and a tag to read from. It is not a template; the exemplary starting point lives in [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter). +The repo root **is** the layer — `nuxt.config.ts`, `content.config.ts`, `app/`, `modules/` and `server/` live there, and `package.json` points at them. `www/` beside it is the site that consumes the layer, and the development target: it deliberately carries the awkward cases — two repositories, four refs, one version of each lifecycle. It is not a template; the exemplary starting point lives in [`kirchDev/duxt-starter`](https://github.com/kirchDev/duxt-starter). ## 🤝 Contributing diff --git a/app/app.config.ts b/app/app.config.ts new file mode 100644 index 00000000..ecf4405e --- /dev/null +++ b/app/app.config.ts @@ -0,0 +1,13 @@ +// The layer ships NO app.config values. +// +// Its defaults live in app/utils/duxt-config.ts, because Nuxt merges app.config +// with defu and defu concatenates arrays — a consumer overriding `navigation` +// would get the layer's entries appended to its own. `useDuxtConfig()` merges +// them itself, replacing arrays rather than adding to them. +// +// The resolved source manifest used to be the one exception that had to sit +// here. It no longer does: the duxt module resolves it from the site's own +// `app.config.ts` and writes it back as `resolvedSources`. +export default defineAppConfig({ + duxt: {} +}); diff --git a/app/app.vue b/app/app.vue new file mode 100644 index 00000000..b23ef520 --- /dev/null +++ b/app/app.vue @@ -0,0 +1,72 @@ + + + diff --git a/app/assets/css/duxt.css b/app/assets/css/duxt.css new file mode 100644 index 00000000..6556f18b --- /dev/null +++ b/app/assets/css/duxt.css @@ -0,0 +1,345 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; +/* vue-sonner ships its own stylesheet; without it the toasts render as bare + text on the page background. */ +@import 'vue-sonner/style.css'; +@import './typeset.css'; + +/* Tailwind auto-detects sources from the build root, which is the CONSUMER's + directory — the layer sits outside it and would be scanned by nobody, so + every utility used in these components would be missing from the output. + Relative to this file, so it holds wherever the layer is installed. */ +@source '../../../app'; + +/* shadcn switches themes on a class, not a media query — @nuxtjs/color-mode + sets it with classSuffix: '' so the two agree. */ +@custom-variant dark (&:is(.dark *)); + +/* The neutral base palette, in oklch. A consumer overrides a token by + redefining it in its own stylesheet — no fork of this file needed. */ +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + /* shadcn's own value is oklch(0.556 0 0). Measured, that is 4.73:1 on the + page background — and 4.34:1 on --muted / --accent, which is where this + token actually lands: a hovered sidebar link, a badge, the sheet. So the + default fails AA on exactly the surfaces it is used on. 0.52 gives 5.51:1 + and 5.05:1, and this token carries the page descriptions, the table of + contents and the breadcrumb — too much text to leave borderline. */ + --muted-foreground: oklch(0.52 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + /* THE FOREGROUND FOLLOWS THE BACKGROUND'S LIGHTNESS, not the mode's. A pale + green with white on it reads as a badge and measures 3.12:1, which is a + badge nobody with low vision can read. Above roughly L 0.6 the pair takes + dark text, below it white — `--warning` already did, and success, info and + dark-mode destructive were simply missed. Enforced in + `tests/contrast.test.ts`; see the note on `--muted-foreground` above for + why the browser check cannot answer this. */ + --success: oklch(0.63 0.17 149); + --success-foreground: oklch(0.145 0 0); + --warning: oklch(0.75 0.16 70); + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.62 0.16 244); + --info-foreground: oklch(0.145 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + /* Same measurement in the dark: 7.63:1 on the background but 5.83:1 on + --muted. Lifted for the same reason, by less, because it starts higher. */ + --muted-foreground: oklch(0.72 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.145 0 0); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --success: oklch(0.7 0.16 149); + --success-foreground: oklch(0.145 0 0); + --warning: oklch(0.8 0.15 70); + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.7 0.14 244); + --info-foreground: oklch(0.145 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } + + /* The one line that tells the BROWSER which theme is on. Without it every + piece of native UI — scrollbars first, then form controls and the canvas + behind a short page — is drawn light on a dark site, which is why a + scrollbar inside an iframe looked nothing like the window's own. + `scrollbar-*` are inherited properties, so `:root` reaches every scroll + container in the page: the sidebar, the table of contents, a code block. */ + :root { + color-scheme: light; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + + :root:is(.dark), + .dark { + color-scheme: dark; + } +} + +/* A brand colour that follows the theme. Set --brand and --brand-dark on the + element; the class picks the one the current theme needs. */ +.duxt-brand { + color: var(--brand); +} + +:root:is(.dark) .duxt-brand, +.dark .duxt-brand { + color: var(--brand-dark); +} + +/* Shiki with `defaultColor: false` writes both themes as custom properties per + token and leaves the choice to CSS — the same arrangement Content uses for + Markdown fences, applied to the command block. */ +.duxt-shell span { + color: var(--shiki-light); +} + +.dark .duxt-shell span { + color: var(--shiki-dark); +} + +.duxt-shell pre { + margin: 0; + background: transparent !important; +} + +/* The layer's typeset preset. Three controls — size, leading, flow — and + everything else derives from them; see the typeset docs. A consumer + redefines these in its own stylesheet rather than restyling elements. */ +.typeset-docs { + --typeset-size: 15px; + --typeset-leading: 1.7; + --typeset-flow: 1.25em; +} + +/* Kept from the hand-written prose: a heading anchor in the margin, and code + blocks drawing their own surface rather than the highlighter's. + + The surface matters: typeset gives every `pre` a muted background (its line + 293), which lands on top of the card DuxtCodeBlock already draws — two + surfaces, and in dark mode the inner one reads as a highlighted row. Not + scoped to .typeset, because the package-manager block renders the same + markup outside a rendered page. */ +.typeset :where(.duxt-anchor) { + @apply absolute -left-5 font-normal text-muted-foreground no-underline opacity-0 transition-opacity; +} + +.typeset :where(h2:hover, h3:hover, h4:hover) .duxt-anchor, +.typeset :where(.duxt-anchor:focus-visible) { + @apply opacity-100; +} + +pre.shiki, +.typeset :where(pre) { + background-color: transparent; +} + +/* Content injects `html.dark .shiki span { background: var(--shiki-dark-bg) }` + inline in the document head — specificity (0,2,2), which outranks anything + sensible we could write against it. The tokens inherit that variable from + the
, where Shiki sets it to the theme's own page colour, so in dark
+   mode every token drew a #24292e rectangle over the card.
+   
+   Redefining the variable is the way out: the rule still applies, it just has
+   nothing to paint. Set on the tokens rather than the 
, because there the
+   value is inline and would win. */
+.shiki span {
+  --shiki-light-bg: transparent;
+  --shiki-dark-bg: transparent;
+}
+
+/* A code block draws its own card with a filename header, so the `
` inside
+   is that card's body rather than a block in the flow. typeset's block spacing
+   on `pre` therefore lands *inside* the border and opens a gap between the
+   header and the first line. */
+.typeset :where(.duxt-code pre) {
+  margin: 0;
+}
+
+/* A long unbroken token — a URL, a package name, a path — is the other way a
+   page grows wider than the phone holding it. Inline code may break; the
+   scrollable blocks (code fences, tables) keep their own overflow. */
+.typeset :where(:not(pre) > code) {
+  overflow-wrap: anywhere;
+}
+
+/* Honour a reader who has asked the system for less motion.
+ 
+   Blunt on purpose. The moving parts are the collapsible in the sidebar, the
+   mobile sheet and the toasts, but each of the three animates through a
+   different mechanism — tw-animate-css keyframes on a data-state, reka-ui's
+   presence, vue-sonner's own stylesheet — and naming them one by one leaves the
+   next component to be added moving again. A near-zero duration rather than
+   `none`: reka-ui waits for `animationend` before it unmounts a panel, and an
+   animation that never runs never ends. */
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+    transition-duration: 0.01ms !important;
+    scroll-behavior: auto !important;
+  }
+}
+
+/* Shiki's line annotations, which have classes and no styling of their own.
+   Four come from the transformers @nuxtjs/mdc enables by default, two more from
+   `mdc.config.ts`. Without these rules `// [!code highlight]` removes the
+   comment and changes nothing visible, which reads as the feature being off. */
+.shiki .line.highlighted {
+  background-color: color-mix(in oklab, var(--color-primary) 12%, transparent);
+  display: inline-block;
+  width: 100%;
+}
+
+.shiki .line.diff.add {
+  background-color: color-mix(in oklab, var(--color-success) 14%, transparent);
+  display: inline-block;
+  width: 100%;
+}
+
+.shiki .line.diff.remove {
+  background-color: color-mix(
+    in oklab,
+    var(--color-destructive) 14%,
+    transparent
+  );
+  display: inline-block;
+  width: 100%;
+  opacity: 0.7;
+}
+
+.shiki .line.diff.add::before {
+  content: '+';
+  color: var(--color-success);
+}
+
+.shiki .line.diff.remove::before {
+  content: '-';
+  color: var(--color-destructive);
+}
+
+.shiki .highlighted-word {
+  background-color: color-mix(in oklab, var(--color-primary) 16%, transparent);
+  border-radius: 3px;
+  padding: 1px 2px;
+}
+
+/* `focus` dims everything that is not the point. Not a filter on the code
+   itself: blur on text is unreadable for anyone who needs the contrast, so the
+   unfocused rows lose opacity and come back on hover. */
+.shiki:has(.line.focused) .line:not(.focused) {
+  opacity: 0.4;
+  transition: opacity 0.2s;
+}
+
+.shiki:hover .line:not(.focused) {
+  opacity: 1;
+}
+
+.shiki .line.error {
+  background-color: color-mix(
+    in oklab,
+    var(--color-destructive) 14%,
+    transparent
+  );
+  display: inline-block;
+  width: 100%;
+}
+
+.shiki .line.warning {
+  background-color: color-mix(in oklab, var(--color-warning) 16%, transparent);
+  display: inline-block;
+  width: 100%;
+}
+
+/* Line numbers, opt-in per fence with ```ts line-numbers.
+   A CSS counter rather than markup: the numbers are decoration, and a reader
+   copying the block must not get them in the clipboard. */
+.duxt-line-numbers code {
+  counter-reset: duxt-line;
+}
+
+.duxt-line-numbers .line::before {
+  counter-increment: duxt-line;
+  content: counter(duxt-line);
+  display: inline-block;
+  width: 2ch;
+  margin-right: 1.5ch;
+  text-align: right;
+  color: var(--color-muted-foreground);
+  user-select: none;
+}
diff --git a/app/assets/css/typeset.css b/app/assets/css/typeset.css
new file mode 100644
index 00000000..5a581033
--- /dev/null
+++ b/app/assets/css/typeset.css
@@ -0,0 +1,498 @@
+/*
+ * shadcn/typeset — https://ui.shadcn.com/docs/typeset
+ *
+ * Vendored verbatim from https://www.shadcn-vue.com/typeset.css. The registry
+ * has no `typeset` item, so the CLI cannot fetch or update it: re-download the
+ * file to upgrade, and keep local changes in duxt.css's preset instead of
+ * editing here.
+ *
+ * It lives in @layer components, below Tailwind's utilities — which is why a
+ * component can set `list-none` on itself and win, the problem prose.css
+ * needed :where() and a base layer to solve by hand.
+ */
+
+@layer components {
+  .typeset {
+    --typeset-font-body: inherit;
+    --typeset-font-heading: var(--font-heading);
+    --typeset-font-mono: var(--font-mono);
+    --typeset-size: 1em;
+    --typeset-leading: 1.75;
+    --typeset-flow: 1.25em;
+  }
+}
+
+@layer components {
+  .typeset {
+    font-family: var(--typeset-font-body);
+    font-size: calc(var(--typeset-size) * 1.125);
+    line-height: var(--typeset-leading);
+    color: var(--color-foreground, currentColor);
+    overflow-wrap: break-word;
+    margin-trim: block-start;
+
+    @media (min-width: 48rem), print {
+      font-size: var(--typeset-size);
+    }
+
+    --typeset-muted: var(
+      --color-muted-foreground,
+      color-mix(in oklab, currentColor 60%, transparent)
+    );
+    --typeset-rule: var(
+      --color-border,
+      color-mix(in oklab, currentColor 20%, transparent)
+    );
+  }
+
+  .typeset
+    *:not(
+      :where(
+        .not-typeset,
+        [data-not-typeset],
+        .not-typeset *,
+        [data-not-typeset] *
+      )
+    ) {
+    /* Paragraphs. */
+    &:where(p) {
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+    }
+
+    /* Headings. */
+    &:where(h1, h2, h3, h4, h5, h6) {
+      color: var(--color-foreground, currentColor);
+      font-family: var(--typeset-font-heading);
+      font-weight: 600;
+      margin-block-end: 0;
+    }
+    &:where(h1) {
+      font-size: 1.75em;
+      line-height: 1.3;
+      margin-block-start: var(--typeset-flow);
+    }
+    &:where(h2) {
+      font-size: 1.25em;
+      line-height: 1.4;
+      margin-block-start: calc(var(--typeset-flow) * 1.4);
+    }
+    &:where(h3) {
+      font-size: 1.125em;
+      line-height: 1.45;
+      margin-block-start: var(--typeset-flow);
+    }
+    &:where(h4) {
+      font-size: 1em;
+      line-height: 1.5;
+      margin-block-start: var(--typeset-flow);
+    }
+    &:where(h5) {
+      font-size: 0.875em;
+      line-height: 1.5;
+      font-weight: 500;
+      color: var(--typeset-muted);
+      margin-block-start: calc(var(--typeset-flow) / 0.875);
+    }
+    &:where(h6) {
+      font-size: 0.8125em;
+      line-height: 1.5;
+      font-weight: 500;
+      letter-spacing: 0.08em;
+      text-transform: uppercase;
+      color: var(--typeset-muted);
+      margin-block-start: calc(var(--typeset-flow) / 0.8125);
+    }
+    /* Anchors. */
+    &:where([id]) {
+      scroll-margin-block-start: var(--typeset-flow);
+    }
+
+    /* Headings own the space below them. */
+    &:where(h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *) {
+      margin-block-start: 1em;
+    }
+    &:where(:is(h1, h2, h3, h4) :is(code)) {
+      font-size: 0.9em;
+    }
+
+    /* Links. */
+    &:where(a) {
+      color: inherit;
+      font-weight: 500;
+      text-decoration-line: underline;
+      text-decoration-color: color-mix(in oklab, currentColor 30%, transparent);
+    }
+    &:where(a:hover) {
+      text-decoration-color: currentColor;
+    }
+    &:where(a:focus-visible) {
+      outline: 2px solid var(--color-ring, currentColor);
+      outline-offset: 2px;
+      border-radius: 0.125em;
+    }
+    &:where(:is(h1, h2, h3, h4, h5, h6) :is(a)) {
+      font-weight: inherit;
+    }
+
+    /* Inline semantics. */
+    &:where(strong, b) {
+      font-weight: 600;
+    }
+    &:where(:is(h1, h2, h3, h4) :is(strong, b)) {
+      font-weight: 600;
+    }
+    &:where(mark) {
+      background-color: color-mix(
+        in oklab,
+        oklch(0.86 0.17 95) 40%,
+        transparent
+      );
+      color: inherit;
+      padding: 0.05em 0.15em;
+      border-radius: 0.2em;
+    }
+    &:where(abbr[title]) {
+      text-decoration-line: underline;
+      text-decoration-style: dotted;
+      text-underline-offset: 0.2em;
+      cursor: help;
+    }
+    &:where(del, s) {
+      color: var(--typeset-muted);
+      text-decoration-line: line-through;
+    }
+    &:where(sup, sub) {
+      font-size: 0.75em;
+      line-height: 0;
+      position: relative;
+      vertical-align: baseline;
+    }
+    &:where(sup) {
+      top: -0.5em;
+    }
+    &:where(sub) {
+      bottom: -0.25em;
+    }
+    &:where(sup a) {
+      text-decoration-line: none;
+      font-weight: 500;
+    }
+
+    /* Lists. */
+    &:where(ul) {
+      list-style-type: disc;
+    }
+    &:where(ol) {
+      list-style-type: decimal;
+    }
+    &:where(ul ul) {
+      list-style-type: circle;
+    }
+    &:where(ul ul ul) {
+      list-style-type: square;
+    }
+    &:where(ol[type='a' i]) {
+      list-style-type: lower-alpha;
+    }
+    &:where(ol[type='i' i]) {
+      list-style-type: lower-roman;
+    }
+    &:where(ol[type='A' s]) {
+      list-style-type: upper-alpha;
+    }
+    &:where(ol[type='I' s]) {
+      list-style-type: upper-roman;
+    }
+    &:where(ul, ol) {
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+      padding-inline-start: 1.5em;
+    }
+    &:where(li) {
+      margin-block-start: 0.5em;
+      padding-inline-start: 0.4em;
+    }
+    &:where(li > p, li > ul, li > ol) {
+      margin-block-start: 0.5em;
+    }
+    &:where(ul > li)::marker {
+      color: var(--typeset-muted);
+    }
+    &:where(ol > li)::marker {
+      color: var(--typeset-muted);
+    }
+
+    /* GFM task lists. */
+    &:where(ul.contains-task-list) {
+      list-style-type: none;
+      padding-inline-start: 0.25em;
+    }
+    &:where(li.task-list-item > input[type='checkbox']) {
+      margin-inline-end: 0.5em;
+      vertical-align: -0.1em;
+      accent-color: var(--color-primary, currentColor);
+    }
+
+    /* Disclosures. */
+    &:where(details) {
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+    }
+    &:where(summary) {
+      cursor: pointer;
+      font-weight: 500;
+    }
+    &:where(summary)::marker {
+      color: var(--typeset-muted);
+    }
+
+    /* Keyboard keys. */
+    &:where(kbd) {
+      font-family: inherit;
+      font-size: 0.85em;
+      font-weight: 500;
+      border: 1px solid var(--typeset-rule);
+      border-block-end-width: 2px;
+      border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);
+      padding: 0.0625em 0.35em;
+    }
+
+    /* Definition lists. */
+    &:where(dl) {
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+    }
+    &:where(dt) {
+      font-weight: 500;
+      margin-block-start: 1em;
+    }
+    &:where(dt + dt) {
+      margin-block-start: 0.25em;
+    }
+    &:where(dd) {
+      margin-block-start: 0.25em;
+      margin-inline-start: 0;
+      padding-inline-start: 1em;
+      color: var(--typeset-muted);
+    }
+
+    /* Inline code. */
+    &:where(:not(pre) > code) {
+      background-color: var(
+        --color-muted,
+        color-mix(in oklab, currentColor 8%, transparent)
+      );
+      font-family: var(--typeset-font-mono);
+      font-size: 0.85em;
+      border-radius: min(calc(var(--radius, 0.5em) * 0.6), 0.35em);
+      padding: 0.125em 0.3em;
+    }
+
+    /* Code blocks. */
+    &:where(pre) {
+      background-color: var(
+        --color-muted,
+        color-mix(in oklab, currentColor 8%, transparent)
+      );
+      font-family: var(--typeset-font-mono);
+      font-size: 0.875em;
+      line-height: 1.5;
+      tab-size: 2;
+      direction: ltr;
+      border-radius: var(--radius, 0.5em);
+      padding: 0.75em 1em;
+      overflow-x: auto;
+      margin-block-start: calc(var(--typeset-flow) / 0.875);
+      margin-block-end: 0;
+    }
+    &:where(pre code) {
+      background-color: transparent;
+      font-family: inherit;
+      font-size: inherit;
+      padding: 0;
+      border-radius: 0;
+    }
+
+    /* Blockquote. */
+    &:where(blockquote) {
+      border-inline-start: 2px solid var(--typeset-rule);
+      padding-inline-start: 1em;
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+      margin-inline: 0;
+    }
+
+    /* Dividers. */
+    &:where(hr) {
+      border: 0;
+      border-block-start: 1px solid var(--typeset-rule);
+      margin-block-start: calc(var(--typeset-flow) * 2.4);
+      margin-block-end: 0;
+    }
+    &:where(hr + h1, hr + h2, hr + h3, hr + h4) {
+      margin-block-start: var(--typeset-flow);
+    }
+
+    /* GFM footnotes. */
+    &:where(.footnotes, [data-footnotes]) {
+      margin-block-start: calc(var(--typeset-flow) * 2);
+      border-block-start: 1px solid var(--typeset-rule);
+      padding-block-start: var(--typeset-flow);
+      font-size: 0.875em;
+      color: var(--typeset-muted);
+    }
+
+    /* Math. */
+    &:where(math[display='block']) {
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+      overflow-x: auto;
+      overflow-y: hidden;
+      padding-block: 0.25em;
+    }
+
+    /* Media. */
+    &:where(img, video) {
+      border-radius: var(--radius, 0.5em);
+      max-width: 100%;
+      height: auto;
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+    }
+    &:where(p img) {
+      margin-block-start: 0;
+    }
+    &:where(figure) {
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+      margin-inline: 0;
+    }
+    &:where(figcaption, caption) {
+      color: var(--typeset-muted);
+      font-size: 0.875em;
+      text-align: center;
+      margin-block-start: calc(0.75em / 0.875);
+    }
+    &:where(caption) {
+      caption-side: bottom;
+    }
+
+    /* Tables. Separators sit on cells, not tr:last-child: append-safe.
+       Bare tables stay real tables (keep their semantics) and wrap to fit. */
+    &:where(table) {
+      max-width: 100%;
+      font-size: 1em;
+      line-height: 1.5;
+      font-variant-numeric: tabular-nums;
+      border-collapse: separate;
+      border-spacing: 0;
+      border-block-end: 1px solid var(--typeset-rule);
+      margin-block-start: var(--typeset-flow);
+      margin-block-end: 0;
+    }
+
+    /* Horizontal scroll for any wide block. Wrap it and the wrapper owns the
+       flow margin. Tables shrink to fit, so widen the table to make it scroll
+       instead of compress. */
+    &:where(.typeset-scroll) {
+      overflow-x: auto;
+      margin-block-start: var(--typeset-flow);
+    }
+    &:where(.typeset-scroll > *) {
+      margin-block-start: 0;
+    }
+    &:where(.typeset-scroll table) {
+      width: max-content;
+      max-width: none;
+    }
+    &:where(thead th) {
+      font-weight: 500;
+      text-align: start;
+      white-space: nowrap;
+      padding: 0.65em 1em;
+    }
+    &:where(:is(tbody, tfoot) :is(td, th)) {
+      padding: 0.75em 1em;
+      text-align: start;
+      vertical-align: top;
+      border-block-start: 1px solid var(--typeset-rule);
+    }
+    &:where(tbody th, tfoot th) {
+      font-weight: 500;
+    }
+    &:where(th:first-child, td:first-child) {
+      padding-inline-start: 0;
+    }
+    &:where(th[align='center'], td[align='center']) {
+      text-align: center;
+    }
+    &:where(th[align='right'], td[align='right']) {
+      text-align: end;
+    }
+
+    /* Blocks inside list items keep the list's tighter rhythm. */
+    &:where(li > blockquote, li > table, li > figure) {
+      margin-block-start: 0.5em;
+    }
+    &:where(li > pre) {
+      margin-block-start: calc(0.5em / 0.875);
+    }
+
+    @media print {
+      &:where(pre, table, blockquote, figure) {
+        break-inside: avoid;
+      }
+      &:where(h1, h2, h3, h4) {
+        break-after: avoid;
+      }
+    }
+
+    /* Forced colors strips the code background, so give it a border. */
+    @media (forced-colors: active) {
+      &:where(:not(pre) > code) {
+        border: 1px solid;
+      }
+    }
+  }
+
+  /* First child adds no space above. Last so it wins ties. */
+  .typeset
+    > :where(:first-child):not(
+      :where(
+        .not-typeset,
+        [data-not-typeset],
+        .not-typeset *,
+        [data-not-typeset] *
+      )
+    ),
+  .typeset
+    > :where(:first-child)
+    > :where(:first-child):not(
+      :where(
+        .not-typeset,
+        [data-not-typeset],
+        .not-typeset *,
+        [data-not-typeset] *
+      )
+    ),
+  .typeset
+    :where(
+      li > :first-child,
+      blockquote > :first-child,
+      td > :first-child,
+      th > :first-child,
+      dd > :first-child,
+      figure > :first-child,
+      figure > picture > img
+    ):not(
+      :where(
+        .not-typeset,
+        [data-not-typeset],
+        .not-typeset *,
+        [data-not-typeset] *
+      )
+    ) {
+    margin-block-start: 0;
+  }
+}
diff --git a/app/components/DuxtBreadcrumb.vue b/app/components/DuxtBreadcrumb.vue
new file mode 100644
index 00000000..fb39250a
--- /dev/null
+++ b/app/components/DuxtBreadcrumb.vue
@@ -0,0 +1,37 @@
+
+
+
diff --git a/app/components/DuxtCodeBlock.vue b/app/components/DuxtCodeBlock.vue
new file mode 100644
index 00000000..f7cce237
--- /dev/null
+++ b/app/components/DuxtCodeBlock.vue
@@ -0,0 +1,96 @@
+
+
+
diff --git a/app/components/DuxtCopyPage.vue b/app/components/DuxtCopyPage.vue
new file mode 100644
index 00000000..3d30898b
--- /dev/null
+++ b/app/components/DuxtCopyPage.vue
@@ -0,0 +1,117 @@
+
+
+
diff --git a/app/components/DuxtFooter.vue b/app/components/DuxtFooter.vue
new file mode 100644
index 00000000..c50262f1
--- /dev/null
+++ b/app/components/DuxtFooter.vue
@@ -0,0 +1,87 @@
+
+
+
diff --git a/app/components/DuxtHeader.vue b/app/components/DuxtHeader.vue
new file mode 100644
index 00000000..06add0e0
--- /dev/null
+++ b/app/components/DuxtHeader.vue
@@ -0,0 +1,261 @@
+
+
+
diff --git a/app/components/DuxtLocale.vue b/app/components/DuxtLocale.vue
new file mode 100644
index 00000000..5ce36eb3
--- /dev/null
+++ b/app/components/DuxtLocale.vue
@@ -0,0 +1,86 @@
+
+
+
diff --git a/app/components/DuxtNavigation.vue b/app/components/DuxtNavigation.vue
new file mode 100644
index 00000000..a942f9f2
--- /dev/null
+++ b/app/components/DuxtNavigation.vue
@@ -0,0 +1,76 @@
+
+
+
diff --git a/app/components/DuxtNavigationLink.vue b/app/components/DuxtNavigationLink.vue
new file mode 100644
index 00000000..c8700a6a
--- /dev/null
+++ b/app/components/DuxtNavigationLink.vue
@@ -0,0 +1,38 @@
+
+
+
diff --git a/app/components/DuxtPageFeedback.vue b/app/components/DuxtPageFeedback.vue
new file mode 100644
index 00000000..990a5162
--- /dev/null
+++ b/app/components/DuxtPageFeedback.vue
@@ -0,0 +1,56 @@
+
+
+
diff --git a/app/components/DuxtPageInfo.vue b/app/components/DuxtPageInfo.vue
new file mode 100644
index 00000000..765943db
--- /dev/null
+++ b/app/components/DuxtPageInfo.vue
@@ -0,0 +1,121 @@
+
+
+
diff --git a/app/components/DuxtPageNav.vue b/app/components/DuxtPageNav.vue
new file mode 100644
index 00000000..dd28bee8
--- /dev/null
+++ b/app/components/DuxtPageNav.vue
@@ -0,0 +1,93 @@
+
+
+
diff --git a/app/components/DuxtProgress.vue b/app/components/DuxtProgress.vue
new file mode 100644
index 00000000..2e194a03
--- /dev/null
+++ b/app/components/DuxtProgress.vue
@@ -0,0 +1,45 @@
+
+
+
diff --git a/app/components/DuxtSearch.vue b/app/components/DuxtSearch.vue
new file mode 100644
index 00000000..9e0d9323
--- /dev/null
+++ b/app/components/DuxtSearch.vue
@@ -0,0 +1,268 @@
+
+
+
diff --git a/app/components/DuxtSections.vue b/app/components/DuxtSections.vue
new file mode 100644
index 00000000..7d8b7994
--- /dev/null
+++ b/app/components/DuxtSections.vue
@@ -0,0 +1,52 @@
+
+
+
diff --git a/app/components/DuxtShortcuts.vue b/app/components/DuxtShortcuts.vue
new file mode 100644
index 00000000..99bd8953
--- /dev/null
+++ b/app/components/DuxtShortcuts.vue
@@ -0,0 +1,46 @@
+
+
+
diff --git a/app/components/DuxtSkipLink.vue b/app/components/DuxtSkipLink.vue
new file mode 100644
index 00000000..cfeb9c8c
--- /dev/null
+++ b/app/components/DuxtSkipLink.vue
@@ -0,0 +1,34 @@
+
+
+
diff --git a/app/components/DuxtToc.vue b/app/components/DuxtToc.vue
new file mode 100644
index 00000000..3c040449
--- /dev/null
+++ b/app/components/DuxtToc.vue
@@ -0,0 +1,86 @@
+
+
+
diff --git a/app/components/DuxtVersion.vue b/app/components/DuxtVersion.vue
new file mode 100644
index 00000000..5c995619
--- /dev/null
+++ b/app/components/DuxtVersion.vue
@@ -0,0 +1,121 @@
+
+
+
diff --git a/app/components/DuxtVersionBanner.vue b/app/components/DuxtVersionBanner.vue
new file mode 100644
index 00000000..5ad033a4
--- /dev/null
+++ b/app/components/DuxtVersionBanner.vue
@@ -0,0 +1,109 @@
+
+
+
diff --git a/app/components/OgImage/Duxt.satori.vue b/app/components/OgImage/Duxt.satori.vue
new file mode 100644
index 00000000..c2fb7621
--- /dev/null
+++ b/app/components/OgImage/Duxt.satori.vue
@@ -0,0 +1,82 @@
+
+
+
diff --git a/app/components/content/Accordion.vue b/app/components/content/Accordion.vue
new file mode 100644
index 00000000..243edd81
--- /dev/null
+++ b/app/components/content/Accordion.vue
@@ -0,0 +1,37 @@
+
+
+
diff --git a/app/components/content/AccordionItem.vue b/app/components/content/AccordionItem.vue
new file mode 100644
index 00000000..9e2c1f3e
--- /dev/null
+++ b/app/components/content/AccordionItem.vue
@@ -0,0 +1,46 @@
+
+
+
diff --git a/app/components/content/Callout.vue b/app/components/content/Callout.vue
new file mode 100644
index 00000000..f71ff8e3
--- /dev/null
+++ b/app/components/content/Callout.vue
@@ -0,0 +1,58 @@
+
+
+
diff --git a/app/components/content/CodeGroup.vue b/app/components/content/CodeGroup.vue
new file mode 100644
index 00000000..ccd9e12b
--- /dev/null
+++ b/app/components/content/CodeGroup.vue
@@ -0,0 +1,80 @@
+
+
+
diff --git a/app/components/content/Field.vue b/app/components/content/Field.vue
new file mode 100644
index 00000000..ce8e1d68
--- /dev/null
+++ b/app/components/content/Field.vue
@@ -0,0 +1,50 @@
+
+
+
diff --git a/app/components/content/FieldGroup.vue b/app/components/content/FieldGroup.vue
new file mode 100644
index 00000000..87f10706
--- /dev/null
+++ b/app/components/content/FieldGroup.vue
@@ -0,0 +1,8 @@
+
diff --git a/app/components/content/FileTree.vue b/app/components/content/FileTree.vue
new file mode 100644
index 00000000..5b5425a4
--- /dev/null
+++ b/app/components/content/FileTree.vue
@@ -0,0 +1,92 @@
+
+
+
diff --git a/app/components/content/Mermaid.vue b/app/components/content/Mermaid.vue
new file mode 100644
index 00000000..516e6205
--- /dev/null
+++ b/app/components/content/Mermaid.vue
@@ -0,0 +1,70 @@
+
+
+
diff --git a/app/components/content/PackageManagers.vue b/app/components/content/PackageManagers.vue
new file mode 100644
index 00000000..0689dd60
--- /dev/null
+++ b/app/components/content/PackageManagers.vue
@@ -0,0 +1,152 @@
+
+
+
diff --git a/app/components/content/PageCards.vue b/app/components/content/PageCards.vue
new file mode 100644
index 00000000..d7265afc
--- /dev/null
+++ b/app/components/content/PageCards.vue
@@ -0,0 +1,48 @@
+
+
+
diff --git a/app/components/content/Partial.vue b/app/components/content/Partial.vue
new file mode 100644
index 00000000..8c21035d
--- /dev/null
+++ b/app/components/content/Partial.vue
@@ -0,0 +1,32 @@
+
+
+
diff --git a/app/components/content/Preview.vue b/app/components/content/Preview.vue
new file mode 100644
index 00000000..1caf6800
--- /dev/null
+++ b/app/components/content/Preview.vue
@@ -0,0 +1,165 @@
+
+
+
diff --git a/app/components/content/ProseA.vue b/app/components/content/ProseA.vue
new file mode 100644
index 00000000..e0f20840
--- /dev/null
+++ b/app/components/content/ProseA.vue
@@ -0,0 +1,55 @@
+
+
+
diff --git a/app/components/content/ProseH2.vue b/app/components/content/ProseH2.vue
new file mode 100644
index 00000000..ad5639e5
--- /dev/null
+++ b/app/components/content/ProseH2.vue
@@ -0,0 +1,16 @@
+
+
+
diff --git a/app/components/content/ProseH3.vue b/app/components/content/ProseH3.vue
new file mode 100644
index 00000000..7d8dde45
--- /dev/null
+++ b/app/components/content/ProseH3.vue
@@ -0,0 +1,16 @@
+
+
+
diff --git a/app/components/content/ProseH4.vue b/app/components/content/ProseH4.vue
new file mode 100644
index 00000000..ac002ba6
--- /dev/null
+++ b/app/components/content/ProseH4.vue
@@ -0,0 +1,16 @@
+
+
+
diff --git a/app/components/content/ProseImg.vue b/app/components/content/ProseImg.vue
new file mode 100644
index 00000000..fd216af9
--- /dev/null
+++ b/app/components/content/ProseImg.vue
@@ -0,0 +1,87 @@
+
+
+
diff --git a/app/components/content/ProsePre.vue b/app/components/content/ProsePre.vue
new file mode 100644
index 00000000..975ac3fa
--- /dev/null
+++ b/app/components/content/ProsePre.vue
@@ -0,0 +1,44 @@
+
+
+
diff --git a/app/components/content/ProseTable.vue b/app/components/content/ProseTable.vue
new file mode 100644
index 00000000..97f6ba9c
--- /dev/null
+++ b/app/components/content/ProseTable.vue
@@ -0,0 +1,11 @@
+
diff --git a/app/components/content/Since.vue b/app/components/content/Since.vue
new file mode 100644
index 00000000..a197a838
--- /dev/null
+++ b/app/components/content/Since.vue
@@ -0,0 +1,21 @@
+
+
+
diff --git a/app/components/content/Steps.vue b/app/components/content/Steps.vue
new file mode 100644
index 00000000..5ead52a5
--- /dev/null
+++ b/app/components/content/Steps.vue
@@ -0,0 +1,20 @@
+
diff --git a/app/components/content/Tab.vue b/app/components/content/Tab.vue
new file mode 100644
index 00000000..7b02d7f2
--- /dev/null
+++ b/app/components/content/Tab.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/content/TabGroup.vue b/app/components/content/TabGroup.vue
new file mode 100644
index 00000000..f2ae8b83
--- /dev/null
+++ b/app/components/content/TabGroup.vue
@@ -0,0 +1,58 @@
+
+
+
diff --git a/app/components/ui/alert/Alert.vue b/app/components/ui/alert/Alert.vue
new file mode 100644
index 00000000..7f068fb8
--- /dev/null
+++ b/app/components/ui/alert/Alert.vue
@@ -0,0 +1,21 @@
+
+
+
diff --git a/app/components/ui/alert/AlertDescription.vue b/app/components/ui/alert/AlertDescription.vue
new file mode 100644
index 00000000..a329ad6b
--- /dev/null
+++ b/app/components/ui/alert/AlertDescription.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/alert/AlertTitle.vue b/app/components/ui/alert/AlertTitle.vue
new file mode 100644
index 00000000..c6fa8d7c
--- /dev/null
+++ b/app/components/ui/alert/AlertTitle.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/alert/index.ts b/app/components/ui/alert/index.ts
new file mode 100644
index 00000000..907e0663
--- /dev/null
+++ b/app/components/ui/alert/index.ts
@@ -0,0 +1,24 @@
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
+
+export { default as Alert } from './Alert.vue';
+export { default as AlertDescription } from './AlertDescription.vue';
+export { default as AlertTitle } from './AlertTitle.vue';
+
+export const alertVariants = cva(
+  'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+  {
+    variants: {
+      variant: {
+        default: 'bg-card text-card-foreground',
+        destructive:
+          'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90'
+      }
+    },
+    defaultVariants: {
+      variant: 'default'
+    }
+  }
+);
+
+export type AlertVariants = VariantProps;
diff --git a/app/components/ui/badge/Badge.vue b/app/components/ui/badge/Badge.vue
new file mode 100644
index 00000000..6fe97b84
--- /dev/null
+++ b/app/components/ui/badge/Badge.vue
@@ -0,0 +1,28 @@
+
+
+
diff --git a/app/components/ui/badge/index.ts b/app/components/ui/badge/index.ts
new file mode 100644
index 00000000..e6e415ec
--- /dev/null
+++ b/app/components/ui/badge/index.ts
@@ -0,0 +1,30 @@
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
+
+export { default as Badge } from './Badge.vue';
+
+export const badgeVariants = cva(
+  'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+  {
+    variants: {
+      variant: {
+        default:
+          'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+        secondary:
+          'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+        destructive:
+          'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+        // The palette carries `--success` and its paired foreground already —
+        // callouts use them — and the contrast test measures that pair.
+        success:
+          'border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90',
+        outline:
+          'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
+      }
+    },
+    defaultVariants: {
+      variant: 'default'
+    }
+  }
+);
+export type BadgeVariants = VariantProps;
diff --git a/app/components/ui/breadcrumb/Breadcrumb.vue b/app/components/ui/breadcrumb/Breadcrumb.vue
new file mode 100644
index 00000000..5219e5cd
--- /dev/null
+++ b/app/components/ui/breadcrumb/Breadcrumb.vue
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/BreadcrumbEllipsis.vue b/app/components/ui/breadcrumb/BreadcrumbEllipsis.vue
new file mode 100644
index 00000000..35d70b81
--- /dev/null
+++ b/app/components/ui/breadcrumb/BreadcrumbEllipsis.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/BreadcrumbItem.vue b/app/components/ui/breadcrumb/BreadcrumbItem.vue
new file mode 100644
index 00000000..308f76d1
--- /dev/null
+++ b/app/components/ui/breadcrumb/BreadcrumbItem.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/BreadcrumbLink.vue b/app/components/ui/breadcrumb/BreadcrumbLink.vue
new file mode 100644
index 00000000..46de77f5
--- /dev/null
+++ b/app/components/ui/breadcrumb/BreadcrumbLink.vue
@@ -0,0 +1,24 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/BreadcrumbList.vue b/app/components/ui/breadcrumb/BreadcrumbList.vue
new file mode 100644
index 00000000..02d47e20
--- /dev/null
+++ b/app/components/ui/breadcrumb/BreadcrumbList.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/BreadcrumbPage.vue b/app/components/ui/breadcrumb/BreadcrumbPage.vue
new file mode 100644
index 00000000..d8981872
--- /dev/null
+++ b/app/components/ui/breadcrumb/BreadcrumbPage.vue
@@ -0,0 +1,20 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/BreadcrumbSeparator.vue b/app/components/ui/breadcrumb/BreadcrumbSeparator.vue
new file mode 100644
index 00000000..6d925d3f
--- /dev/null
+++ b/app/components/ui/breadcrumb/BreadcrumbSeparator.vue
@@ -0,0 +1,21 @@
+
+
+
diff --git a/app/components/ui/breadcrumb/index.ts b/app/components/ui/breadcrumb/index.ts
new file mode 100644
index 00000000..d650f7e4
--- /dev/null
+++ b/app/components/ui/breadcrumb/index.ts
@@ -0,0 +1,7 @@
+export { default as Breadcrumb } from './Breadcrumb.vue';
+export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue';
+export { default as BreadcrumbItem } from './BreadcrumbItem.vue';
+export { default as BreadcrumbLink } from './BreadcrumbLink.vue';
+export { default as BreadcrumbList } from './BreadcrumbList.vue';
+export { default as BreadcrumbPage } from './BreadcrumbPage.vue';
+export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue';
diff --git a/app/components/ui/button/Button.vue b/app/components/ui/button/Button.vue
new file mode 100644
index 00000000..7313db4e
--- /dev/null
+++ b/app/components/ui/button/Button.vue
@@ -0,0 +1,31 @@
+
+
+
diff --git a/app/components/ui/button/index.ts b/app/components/ui/button/index.ts
new file mode 100644
index 00000000..49d66bb9
--- /dev/null
+++ b/app/components/ui/button/index.ts
@@ -0,0 +1,39 @@
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
+
+export { default as Button } from './Button.vue';
+
+export const buttonVariants = cva(
+  "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+  {
+    variants: {
+      variant: {
+        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+        destructive:
+          'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+        outline:
+          'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+        secondary:
+          'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+        ghost:
+          'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+        link: 'text-primary underline-offset-4 hover:underline'
+      },
+      size: {
+        default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+        icon: 'size-9',
+        'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+        'icon-sm': 'size-8',
+        'icon-lg': 'size-10'
+      }
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default'
+    }
+  }
+);
+export type ButtonVariants = VariantProps;
diff --git a/app/components/ui/card/Card.vue b/app/components/ui/card/Card.vue
new file mode 100644
index 00000000..56ee0a82
--- /dev/null
+++ b/app/components/ui/card/Card.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/card/CardAction.vue b/app/components/ui/card/CardAction.vue
new file mode 100644
index 00000000..1e57d012
--- /dev/null
+++ b/app/components/ui/card/CardAction.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/card/CardContent.vue b/app/components/ui/card/CardContent.vue
new file mode 100644
index 00000000..7a78fad3
--- /dev/null
+++ b/app/components/ui/card/CardContent.vue
@@ -0,0 +1,14 @@
+
+
+
diff --git a/app/components/ui/card/CardDescription.vue b/app/components/ui/card/CardDescription.vue
new file mode 100644
index 00000000..65385236
--- /dev/null
+++ b/app/components/ui/card/CardDescription.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/card/CardFooter.vue b/app/components/ui/card/CardFooter.vue
new file mode 100644
index 00000000..cd3b81be
--- /dev/null
+++ b/app/components/ui/card/CardFooter.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/card/CardHeader.vue b/app/components/ui/card/CardHeader.vue
new file mode 100644
index 00000000..ef5e37c1
--- /dev/null
+++ b/app/components/ui/card/CardHeader.vue
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/components/ui/card/CardTitle.vue b/app/components/ui/card/CardTitle.vue
new file mode 100644
index 00000000..16850aff
--- /dev/null
+++ b/app/components/ui/card/CardTitle.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/card/index.ts b/app/components/ui/card/index.ts
new file mode 100644
index 00000000..a71b2f6a
--- /dev/null
+++ b/app/components/ui/card/index.ts
@@ -0,0 +1,7 @@
+export { default as Card } from './Card.vue';
+export { default as CardAction } from './CardAction.vue';
+export { default as CardContent } from './CardContent.vue';
+export { default as CardDescription } from './CardDescription.vue';
+export { default as CardFooter } from './CardFooter.vue';
+export { default as CardHeader } from './CardHeader.vue';
+export { default as CardTitle } from './CardTitle.vue';
diff --git a/app/components/ui/collapsible/Collapsible.vue b/app/components/ui/collapsible/Collapsible.vue
new file mode 100644
index 00000000..e1edac96
--- /dev/null
+++ b/app/components/ui/collapsible/Collapsible.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/collapsible/CollapsibleContent.vue b/app/components/ui/collapsible/CollapsibleContent.vue
new file mode 100644
index 00000000..6a6c8436
--- /dev/null
+++ b/app/components/ui/collapsible/CollapsibleContent.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/collapsible/CollapsibleTrigger.vue b/app/components/ui/collapsible/CollapsibleTrigger.vue
new file mode 100644
index 00000000..d10540dc
--- /dev/null
+++ b/app/components/ui/collapsible/CollapsibleTrigger.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/collapsible/index.ts b/app/components/ui/collapsible/index.ts
new file mode 100644
index 00000000..56b5a955
--- /dev/null
+++ b/app/components/ui/collapsible/index.ts
@@ -0,0 +1,3 @@
+export { default as Collapsible } from './Collapsible.vue';
+export { default as CollapsibleContent } from './CollapsibleContent.vue';
+export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue';
diff --git a/app/components/ui/command/Command.vue b/app/components/ui/command/Command.vue
new file mode 100644
index 00000000..2296af2b
--- /dev/null
+++ b/app/components/ui/command/Command.vue
@@ -0,0 +1,98 @@
+
+
+
diff --git a/app/components/ui/command/CommandDialog.vue b/app/components/ui/command/CommandDialog.vue
new file mode 100644
index 00000000..1e36090c
--- /dev/null
+++ b/app/components/ui/command/CommandDialog.vue
@@ -0,0 +1,42 @@
+
+
+
diff --git a/app/components/ui/command/CommandEmpty.vue b/app/components/ui/command/CommandEmpty.vue
new file mode 100644
index 00000000..321ebbaf
--- /dev/null
+++ b/app/components/ui/command/CommandEmpty.vue
@@ -0,0 +1,31 @@
+
+
+
diff --git a/app/components/ui/command/CommandGroup.vue b/app/components/ui/command/CommandGroup.vue
new file mode 100644
index 00000000..04738d3b
--- /dev/null
+++ b/app/components/ui/command/CommandGroup.vue
@@ -0,0 +1,52 @@
+
+
+
diff --git a/app/components/ui/command/CommandInput.vue b/app/components/ui/command/CommandInput.vue
new file mode 100644
index 00000000..9332faac
--- /dev/null
+++ b/app/components/ui/command/CommandInput.vue
@@ -0,0 +1,45 @@
+
+
+
diff --git a/app/components/ui/command/CommandItem.vue b/app/components/ui/command/CommandItem.vue
new file mode 100644
index 00000000..83773690
--- /dev/null
+++ b/app/components/ui/command/CommandItem.vue
@@ -0,0 +1,85 @@
+
+
+
diff --git a/app/components/ui/command/CommandList.vue b/app/components/ui/command/CommandList.vue
new file mode 100644
index 00000000..633a33a0
--- /dev/null
+++ b/app/components/ui/command/CommandList.vue
@@ -0,0 +1,32 @@
+
+
+
diff --git a/app/components/ui/command/CommandSeparator.vue b/app/components/ui/command/CommandSeparator.vue
new file mode 100644
index 00000000..02e374b4
--- /dev/null
+++ b/app/components/ui/command/CommandSeparator.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/command/CommandShortcut.vue b/app/components/ui/command/CommandShortcut.vue
new file mode 100644
index 00000000..99e85e3f
--- /dev/null
+++ b/app/components/ui/command/CommandShortcut.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/command/index.ts b/app/components/ui/command/index.ts
new file mode 100644
index 00000000..7455de17
--- /dev/null
+++ b/app/components/ui/command/index.ts
@@ -0,0 +1,29 @@
+import type { Ref } from 'vue';
+import { createContext } from 'reka-ui';
+
+export { default as Command } from './Command.vue';
+export { default as CommandDialog } from './CommandDialog.vue';
+export { default as CommandEmpty } from './CommandEmpty.vue';
+export { default as CommandGroup } from './CommandGroup.vue';
+export { default as CommandInput } from './CommandInput.vue';
+export { default as CommandItem } from './CommandItem.vue';
+export { default as CommandList } from './CommandList.vue';
+export { default as CommandSeparator } from './CommandSeparator.vue';
+export { default as CommandShortcut } from './CommandShortcut.vue';
+
+export const [useCommand, provideCommandContext] = createContext<{
+  allItems: Ref>;
+  allGroups: Ref>>;
+  filterState: {
+    search: string;
+    filtered: {
+      count: number;
+      items: Map;
+      groups: Set;
+    };
+  };
+}>('Command');
+
+export const [useCommandGroup, provideCommandGroupContext] = createContext<{
+  id?: string;
+}>('CommandGroup');
diff --git a/app/components/ui/dialog/Dialog.vue b/app/components/ui/dialog/Dialog.vue
new file mode 100644
index 00000000..04ff397b
--- /dev/null
+++ b/app/components/ui/dialog/Dialog.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogClose.vue b/app/components/ui/dialog/DialogClose.vue
new file mode 100644
index 00000000..1d24b5b8
--- /dev/null
+++ b/app/components/ui/dialog/DialogClose.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogContent.vue b/app/components/ui/dialog/DialogContent.vue
new file mode 100644
index 00000000..500facfd
--- /dev/null
+++ b/app/components/ui/dialog/DialogContent.vue
@@ -0,0 +1,61 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogDescription.vue b/app/components/ui/dialog/DialogDescription.vue
new file mode 100644
index 00000000..f292aa27
--- /dev/null
+++ b/app/components/ui/dialog/DialogDescription.vue
@@ -0,0 +1,25 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogFooter.vue b/app/components/ui/dialog/DialogFooter.vue
new file mode 100644
index 00000000..973ea3a0
--- /dev/null
+++ b/app/components/ui/dialog/DialogFooter.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogHeader.vue b/app/components/ui/dialog/DialogHeader.vue
new file mode 100644
index 00000000..5749010c
--- /dev/null
+++ b/app/components/ui/dialog/DialogHeader.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogOverlay.vue b/app/components/ui/dialog/DialogOverlay.vue
new file mode 100644
index 00000000..532243df
--- /dev/null
+++ b/app/components/ui/dialog/DialogOverlay.vue
@@ -0,0 +1,28 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogScrollContent.vue b/app/components/ui/dialog/DialogScrollContent.vue
new file mode 100644
index 00000000..ea1c8ac6
--- /dev/null
+++ b/app/components/ui/dialog/DialogScrollContent.vue
@@ -0,0 +1,65 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogTitle.vue b/app/components/ui/dialog/DialogTitle.vue
new file mode 100644
index 00000000..59e4e9e0
--- /dev/null
+++ b/app/components/ui/dialog/DialogTitle.vue
@@ -0,0 +1,25 @@
+
+
+
diff --git a/app/components/ui/dialog/DialogTrigger.vue b/app/components/ui/dialog/DialogTrigger.vue
new file mode 100644
index 00000000..b81a4995
--- /dev/null
+++ b/app/components/ui/dialog/DialogTrigger.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/dialog/index.ts b/app/components/ui/dialog/index.ts
new file mode 100644
index 00000000..4f9dab8a
--- /dev/null
+++ b/app/components/ui/dialog/index.ts
@@ -0,0 +1,10 @@
+export { default as Dialog } from './Dialog.vue';
+export { default as DialogClose } from './DialogClose.vue';
+export { default as DialogContent } from './DialogContent.vue';
+export { default as DialogDescription } from './DialogDescription.vue';
+export { default as DialogFooter } from './DialogFooter.vue';
+export { default as DialogHeader } from './DialogHeader.vue';
+export { default as DialogOverlay } from './DialogOverlay.vue';
+export { default as DialogScrollContent } from './DialogScrollContent.vue';
+export { default as DialogTitle } from './DialogTitle.vue';
+export { default as DialogTrigger } from './DialogTrigger.vue';
diff --git a/app/components/ui/dropdown-menu/DropdownMenu.vue b/app/components/ui/dropdown-menu/DropdownMenu.vue
new file mode 100644
index 00000000..eefe888d
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenu.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue b/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
new file mode 100644
index 00000000..84c07e75
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
@@ -0,0 +1,47 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuContent.vue b/app/components/ui/dropdown-menu/DropdownMenuContent.vue
new file mode 100644
index 00000000..2cfedb90
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuContent.vue
@@ -0,0 +1,47 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuGroup.vue b/app/components/ui/dropdown-menu/DropdownMenuGroup.vue
new file mode 100644
index 00000000..1491e2b2
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuGroup.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuItem.vue b/app/components/ui/dropdown-menu/DropdownMenuItem.vue
new file mode 100644
index 00000000..4dfebdba
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuItem.vue
@@ -0,0 +1,41 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuLabel.vue b/app/components/ui/dropdown-menu/DropdownMenuLabel.vue
new file mode 100644
index 00000000..3bbf9e18
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuLabel.vue
@@ -0,0 +1,27 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue b/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
new file mode 100644
index 00000000..6961bc6d
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
@@ -0,0 +1,21 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue b/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
new file mode 100644
index 00000000..f3276b18
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
@@ -0,0 +1,48 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue b/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
new file mode 100644
index 00000000..9eda1322
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue b/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
new file mode 100644
index 00000000..fce332c3
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuSub.vue b/app/components/ui/dropdown-menu/DropdownMenuSub.vue
new file mode 100644
index 00000000..8797d82b
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuSub.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
new file mode 100644
index 00000000..f3251414
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue
@@ -0,0 +1,34 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue b/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
new file mode 100644
index 00000000..439de66b
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
@@ -0,0 +1,34 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue b/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
new file mode 100644
index 00000000..fb0641e0
--- /dev/null
+++ b/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/dropdown-menu/index.ts b/app/components/ui/dropdown-menu/index.ts
new file mode 100644
index 00000000..bde907bf
--- /dev/null
+++ b/app/components/ui/dropdown-menu/index.ts
@@ -0,0 +1,16 @@
+export { default as DropdownMenu } from './DropdownMenu.vue';
+
+export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue';
+export { default as DropdownMenuContent } from './DropdownMenuContent.vue';
+export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue';
+export { default as DropdownMenuItem } from './DropdownMenuItem.vue';
+export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue';
+export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue';
+export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue';
+export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue';
+export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue';
+export { default as DropdownMenuSub } from './DropdownMenuSub.vue';
+export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue';
+export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue';
+export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue';
+export { DropdownMenuPortal } from 'reka-ui';
diff --git a/app/components/ui/input/Input.vue b/app/components/ui/input/Input.vue
new file mode 100644
index 00000000..700cb3af
--- /dev/null
+++ b/app/components/ui/input/Input.vue
@@ -0,0 +1,35 @@
+
+
+
diff --git a/app/components/ui/input/index.ts b/app/components/ui/input/index.ts
new file mode 100644
index 00000000..c5248c5c
--- /dev/null
+++ b/app/components/ui/input/index.ts
@@ -0,0 +1 @@
+export { default as Input } from './Input.vue';
diff --git a/app/components/ui/navigation-menu/NavigationMenu.vue b/app/components/ui/navigation-menu/NavigationMenu.vue
new file mode 100644
index 00000000..b635c51f
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenu.vue
@@ -0,0 +1,42 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuContent.vue b/app/components/ui/navigation-menu/NavigationMenuContent.vue
new file mode 100644
index 00000000..2911e1c4
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuContent.vue
@@ -0,0 +1,35 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuIndicator.vue b/app/components/ui/navigation-menu/NavigationMenuIndicator.vue
new file mode 100644
index 00000000..5db1dcdd
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuIndicator.vue
@@ -0,0 +1,32 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuItem.vue b/app/components/ui/navigation-menu/NavigationMenuItem.vue
new file mode 100644
index 00000000..a6a7d9ab
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuItem.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuLink.vue b/app/components/ui/navigation-menu/NavigationMenuLink.vue
new file mode 100644
index 00000000..221f4f7e
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuLink.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuList.vue b/app/components/ui/navigation-menu/NavigationMenuList.vue
new file mode 100644
index 00000000..96d1483e
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuList.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuTrigger.vue b/app/components/ui/navigation-menu/NavigationMenuTrigger.vue
new file mode 100644
index 00000000..e4774242
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuTrigger.vue
@@ -0,0 +1,31 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/NavigationMenuViewport.vue b/app/components/ui/navigation-menu/NavigationMenuViewport.vue
new file mode 100644
index 00000000..0506344b
--- /dev/null
+++ b/app/components/ui/navigation-menu/NavigationMenuViewport.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/navigation-menu/index.ts b/app/components/ui/navigation-menu/index.ts
new file mode 100644
index 00000000..dce83879
--- /dev/null
+++ b/app/components/ui/navigation-menu/index.ts
@@ -0,0 +1,14 @@
+import { cva } from 'class-variance-authority';
+
+export { default as NavigationMenu } from './NavigationMenu.vue';
+export { default as NavigationMenuContent } from './NavigationMenuContent.vue';
+export { default as NavigationMenuIndicator } from './NavigationMenuIndicator.vue';
+export { default as NavigationMenuItem } from './NavigationMenuItem.vue';
+export { default as NavigationMenuLink } from './NavigationMenuLink.vue';
+export { default as NavigationMenuList } from './NavigationMenuList.vue';
+export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.vue';
+export { default as NavigationMenuViewport } from './NavigationMenuViewport.vue';
+
+export const navigationMenuTriggerStyle = cva(
+  'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-3 focus-visible:outline-1'
+);
diff --git a/app/components/ui/scroll-area/ScrollArea.vue b/app/components/ui/scroll-area/ScrollArea.vue
new file mode 100644
index 00000000..4a4ed1dd
--- /dev/null
+++ b/app/components/ui/scroll-area/ScrollArea.vue
@@ -0,0 +1,31 @@
+
+
+
diff --git a/app/components/ui/scroll-area/ScrollBar.vue b/app/components/ui/scroll-area/ScrollBar.vue
new file mode 100644
index 00000000..4389106e
--- /dev/null
+++ b/app/components/ui/scroll-area/ScrollBar.vue
@@ -0,0 +1,38 @@
+
+
+
diff --git a/app/components/ui/scroll-area/index.ts b/app/components/ui/scroll-area/index.ts
new file mode 100644
index 00000000..d6541f36
--- /dev/null
+++ b/app/components/ui/scroll-area/index.ts
@@ -0,0 +1,2 @@
+export { default as ScrollArea } from './ScrollArea.vue';
+export { default as ScrollBar } from './ScrollBar.vue';
diff --git a/app/components/ui/separator/Separator.vue b/app/components/ui/separator/Separator.vue
new file mode 100644
index 00000000..e519ade4
--- /dev/null
+++ b/app/components/ui/separator/Separator.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/separator/index.ts b/app/components/ui/separator/index.ts
new file mode 100644
index 00000000..3cb07b01
--- /dev/null
+++ b/app/components/ui/separator/index.ts
@@ -0,0 +1 @@
+export { default as Separator } from './Separator.vue';
diff --git a/app/components/ui/sheet/Sheet.vue b/app/components/ui/sheet/Sheet.vue
new file mode 100644
index 00000000..4223317b
--- /dev/null
+++ b/app/components/ui/sheet/Sheet.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetClose.vue b/app/components/ui/sheet/SheetClose.vue
new file mode 100644
index 00000000..0d01bc64
--- /dev/null
+++ b/app/components/ui/sheet/SheetClose.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetContent.vue b/app/components/ui/sheet/SheetContent.vue
new file mode 100644
index 00000000..91196130
--- /dev/null
+++ b/app/components/ui/sheet/SheetContent.vue
@@ -0,0 +1,64 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetDescription.vue b/app/components/ui/sheet/SheetDescription.vue
new file mode 100644
index 00000000..ba56aeea
--- /dev/null
+++ b/app/components/ui/sheet/SheetDescription.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetFooter.vue b/app/components/ui/sheet/SheetFooter.vue
new file mode 100644
index 00000000..01f5dae9
--- /dev/null
+++ b/app/components/ui/sheet/SheetFooter.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetHeader.vue b/app/components/ui/sheet/SheetHeader.vue
new file mode 100644
index 00000000..217e4631
--- /dev/null
+++ b/app/components/ui/sheet/SheetHeader.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetOverlay.vue b/app/components/ui/sheet/SheetOverlay.vue
new file mode 100644
index 00000000..1ecaf7e6
--- /dev/null
+++ b/app/components/ui/sheet/SheetOverlay.vue
@@ -0,0 +1,28 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetTitle.vue b/app/components/ui/sheet/SheetTitle.vue
new file mode 100644
index 00000000..5de4ef2e
--- /dev/null
+++ b/app/components/ui/sheet/SheetTitle.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/sheet/SheetTrigger.vue b/app/components/ui/sheet/SheetTrigger.vue
new file mode 100644
index 00000000..f107d26f
--- /dev/null
+++ b/app/components/ui/sheet/SheetTrigger.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/sheet/index.ts b/app/components/ui/sheet/index.ts
new file mode 100644
index 00000000..37498e1d
--- /dev/null
+++ b/app/components/ui/sheet/index.ts
@@ -0,0 +1,8 @@
+export { default as Sheet } from './Sheet.vue';
+export { default as SheetClose } from './SheetClose.vue';
+export { default as SheetContent } from './SheetContent.vue';
+export { default as SheetDescription } from './SheetDescription.vue';
+export { default as SheetFooter } from './SheetFooter.vue';
+export { default as SheetHeader } from './SheetHeader.vue';
+export { default as SheetTitle } from './SheetTitle.vue';
+export { default as SheetTrigger } from './SheetTrigger.vue';
diff --git a/app/components/ui/sidebar/Sidebar.vue b/app/components/ui/sidebar/Sidebar.vue
new file mode 100644
index 00000000..19db2c69
--- /dev/null
+++ b/app/components/ui/sidebar/Sidebar.vue
@@ -0,0 +1,110 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarContent.vue b/app/components/ui/sidebar/SidebarContent.vue
new file mode 100644
index 00000000..5a29c635
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarContent.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarFooter.vue b/app/components/ui/sidebar/SidebarFooter.vue
new file mode 100644
index 00000000..46f1c036
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarFooter.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarGroup.vue b/app/components/ui/sidebar/SidebarGroup.vue
new file mode 100644
index 00000000..40c81b87
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarGroup.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarGroupAction.vue b/app/components/ui/sidebar/SidebarGroupAction.vue
new file mode 100644
index 00000000..c7c58d39
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarGroupAction.vue
@@ -0,0 +1,31 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarGroupContent.vue b/app/components/ui/sidebar/SidebarGroupContent.vue
new file mode 100644
index 00000000..389b4145
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarGroupContent.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarGroupLabel.vue b/app/components/ui/sidebar/SidebarGroupLabel.vue
new file mode 100644
index 00000000..56bbdd6a
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarGroupLabel.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarHeader.vue b/app/components/ui/sidebar/SidebarHeader.vue
new file mode 100644
index 00000000..5dea1ffc
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarHeader.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarInput.vue b/app/components/ui/sidebar/SidebarInput.vue
new file mode 100644
index 00000000..494c74c0
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarInput.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarInset.vue b/app/components/ui/sidebar/SidebarInset.vue
new file mode 100644
index 00000000..e4af7e43
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarInset.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenu.vue b/app/components/ui/sidebar/SidebarMenu.vue
new file mode 100644
index 00000000..abab73d7
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenu.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuAction.vue b/app/components/ui/sidebar/SidebarMenuAction.vue
new file mode 100644
index 00000000..1ae3b93c
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuAction.vue
@@ -0,0 +1,42 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuBadge.vue b/app/components/ui/sidebar/SidebarMenuBadge.vue
new file mode 100644
index 00000000..f21903ca
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuBadge.vue
@@ -0,0 +1,28 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuButton.vue b/app/components/ui/sidebar/SidebarMenuButton.vue
new file mode 100644
index 00000000..69bbab4b
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuButton.vue
@@ -0,0 +1,60 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuButtonChild.vue b/app/components/ui/sidebar/SidebarMenuButtonChild.vue
new file mode 100644
index 00000000..466dae24
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuButtonChild.vue
@@ -0,0 +1,36 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuItem.vue b/app/components/ui/sidebar/SidebarMenuItem.vue
new file mode 100644
index 00000000..7a21dd18
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuItem.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuSkeleton.vue b/app/components/ui/sidebar/SidebarMenuSkeleton.vue
new file mode 100644
index 00000000..20ced386
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuSkeleton.vue
@@ -0,0 +1,35 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuSub.vue b/app/components/ui/sidebar/SidebarMenuSub.vue
new file mode 100644
index 00000000..0e22a11f
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuSub.vue
@@ -0,0 +1,24 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuSubButton.vue b/app/components/ui/sidebar/SidebarMenuSubButton.vue
new file mode 100644
index 00000000..5b82410c
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuSubButton.vue
@@ -0,0 +1,43 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarMenuSubItem.vue b/app/components/ui/sidebar/SidebarMenuSubItem.vue
new file mode 100644
index 00000000..a3c46f2d
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarMenuSubItem.vue
@@ -0,0 +1,18 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarProvider.vue b/app/components/ui/sidebar/SidebarProvider.vue
new file mode 100644
index 00000000..75394373
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarProvider.vue
@@ -0,0 +1,109 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarRail.vue b/app/components/ui/sidebar/SidebarRail.vue
new file mode 100644
index 00000000..724cc23c
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarRail.vue
@@ -0,0 +1,35 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarSeparator.vue b/app/components/ui/sidebar/SidebarSeparator.vue
new file mode 100644
index 00000000..8f64e25a
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarSeparator.vue
@@ -0,0 +1,19 @@
+
+
+
diff --git a/app/components/ui/sidebar/SidebarTrigger.vue b/app/components/ui/sidebar/SidebarTrigger.vue
new file mode 100644
index 00000000..12b8f0c3
--- /dev/null
+++ b/app/components/ui/sidebar/SidebarTrigger.vue
@@ -0,0 +1,26 @@
+
+
+
diff --git a/app/components/ui/sidebar/index.ts b/app/components/ui/sidebar/index.ts
new file mode 100644
index 00000000..73706740
--- /dev/null
+++ b/app/components/ui/sidebar/index.ts
@@ -0,0 +1,62 @@
+import type { VariantProps } from 'class-variance-authority';
+import type { HTMLAttributes } from 'vue';
+import { cva } from 'class-variance-authority';
+
+export interface SidebarProps {
+  side?: 'left' | 'right';
+  variant?: 'sidebar' | 'floating' | 'inset';
+  collapsible?: 'offcanvas' | 'icon' | 'none';
+  class?: HTMLAttributes['class'];
+}
+
+export { default as Sidebar } from './Sidebar.vue';
+export { default as SidebarContent } from './SidebarContent.vue';
+export { default as SidebarFooter } from './SidebarFooter.vue';
+export { default as SidebarGroup } from './SidebarGroup.vue';
+export { default as SidebarGroupAction } from './SidebarGroupAction.vue';
+export { default as SidebarGroupContent } from './SidebarGroupContent.vue';
+export { default as SidebarGroupLabel } from './SidebarGroupLabel.vue';
+export { default as SidebarHeader } from './SidebarHeader.vue';
+export { default as SidebarInput } from './SidebarInput.vue';
+export { default as SidebarInset } from './SidebarInset.vue';
+export { default as SidebarMenu } from './SidebarMenu.vue';
+export { default as SidebarMenuAction } from './SidebarMenuAction.vue';
+export { default as SidebarMenuBadge } from './SidebarMenuBadge.vue';
+export { default as SidebarMenuButton } from './SidebarMenuButton.vue';
+export { default as SidebarMenuItem } from './SidebarMenuItem.vue';
+export { default as SidebarMenuSkeleton } from './SidebarMenuSkeleton.vue';
+export { default as SidebarMenuSub } from './SidebarMenuSub.vue';
+export { default as SidebarMenuSubButton } from './SidebarMenuSubButton.vue';
+export { default as SidebarMenuSubItem } from './SidebarMenuSubItem.vue';
+export { default as SidebarProvider } from './SidebarProvider.vue';
+export { default as SidebarRail } from './SidebarRail.vue';
+export { default as SidebarSeparator } from './SidebarSeparator.vue';
+export { default as SidebarTrigger } from './SidebarTrigger.vue';
+
+export { useSidebar } from './utils';
+
+export const sidebarMenuButtonVariants = cva(
+  'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
+  {
+    variants: {
+      variant: {
+        default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
+        outline:
+          'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
+      },
+      size: {
+        default: 'h-8 text-sm',
+        sm: 'h-7 text-xs',
+        lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!'
+      }
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default'
+    }
+  }
+);
+
+export type SidebarMenuButtonVariants = VariantProps<
+  typeof sidebarMenuButtonVariants
+>;
diff --git a/app/components/ui/sidebar/utils.ts b/app/components/ui/sidebar/utils.ts
new file mode 100644
index 00000000..4f471cbe
--- /dev/null
+++ b/app/components/ui/sidebar/utils.ts
@@ -0,0 +1,19 @@
+import type { ComputedRef, Ref } from 'vue';
+import { createContext } from 'reka-ui';
+
+export const SIDEBAR_COOKIE_NAME = 'sidebar_state';
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = '16rem';
+export const SIDEBAR_WIDTH_MOBILE = '18rem';
+export const SIDEBAR_WIDTH_ICON = '3rem';
+export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
+
+export const [useSidebar, provideSidebarContext] = createContext<{
+  state: ComputedRef<'expanded' | 'collapsed'>;
+  open: Ref;
+  setOpen: (value: boolean) => void;
+  isMobile: Ref;
+  openMobile: Ref;
+  setOpenMobile: (value: boolean) => void;
+  toggleSidebar: () => void;
+}>('Sidebar');
diff --git a/app/components/ui/skeleton/Skeleton.vue b/app/components/ui/skeleton/Skeleton.vue
new file mode 100644
index 00000000..9e7c77ba
--- /dev/null
+++ b/app/components/ui/skeleton/Skeleton.vue
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/components/ui/skeleton/index.ts b/app/components/ui/skeleton/index.ts
new file mode 100644
index 00000000..38cc2bac
--- /dev/null
+++ b/app/components/ui/skeleton/index.ts
@@ -0,0 +1 @@
+export { default as Skeleton } from './Skeleton.vue';
diff --git a/app/components/ui/sonner/Sonner.vue b/app/components/ui/sonner/Sonner.vue
new file mode 100644
index 00000000..c1ba1105
--- /dev/null
+++ b/app/components/ui/sonner/Sonner.vue
@@ -0,0 +1,67 @@
+
+
+
diff --git a/app/components/ui/sonner/index.ts b/app/components/ui/sonner/index.ts
new file mode 100644
index 00000000..93765a16
--- /dev/null
+++ b/app/components/ui/sonner/index.ts
@@ -0,0 +1 @@
+export { default as Toaster } from './Sonner.vue';
diff --git a/app/components/ui/tabs/Tabs.vue b/app/components/ui/tabs/Tabs.vue
new file mode 100644
index 00000000..9b23171a
--- /dev/null
+++ b/app/components/ui/tabs/Tabs.vue
@@ -0,0 +1,26 @@
+
+
+
diff --git a/app/components/ui/tabs/TabsContent.vue b/app/components/ui/tabs/TabsContent.vue
new file mode 100644
index 00000000..db7e9d8e
--- /dev/null
+++ b/app/components/ui/tabs/TabsContent.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/components/ui/tabs/TabsList.vue b/app/components/ui/tabs/TabsList.vue
new file mode 100644
index 00000000..77c27719
--- /dev/null
+++ b/app/components/ui/tabs/TabsList.vue
@@ -0,0 +1,28 @@
+
+
+
diff --git a/app/components/ui/tabs/TabsTrigger.vue b/app/components/ui/tabs/TabsTrigger.vue
new file mode 100644
index 00000000..1bd19b23
--- /dev/null
+++ b/app/components/ui/tabs/TabsTrigger.vue
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/components/ui/tabs/index.ts b/app/components/ui/tabs/index.ts
new file mode 100644
index 00000000..8f5f7445
--- /dev/null
+++ b/app/components/ui/tabs/index.ts
@@ -0,0 +1,4 @@
+export { default as Tabs } from './Tabs.vue';
+export { default as TabsContent } from './TabsContent.vue';
+export { default as TabsList } from './TabsList.vue';
+export { default as TabsTrigger } from './TabsTrigger.vue';
diff --git a/app/components/ui/tooltip/Tooltip.vue b/app/components/ui/tooltip/Tooltip.vue
new file mode 100644
index 00000000..46dd2443
--- /dev/null
+++ b/app/components/ui/tooltip/Tooltip.vue
@@ -0,0 +1,15 @@
+
+
+
diff --git a/app/components/ui/tooltip/TooltipContent.vue b/app/components/ui/tooltip/TooltipContent.vue
new file mode 100644
index 00000000..cf762a39
--- /dev/null
+++ b/app/components/ui/tooltip/TooltipContent.vue
@@ -0,0 +1,49 @@
+
+
+
diff --git a/app/components/ui/tooltip/TooltipProvider.vue b/app/components/ui/tooltip/TooltipProvider.vue
new file mode 100644
index 00000000..71bec268
--- /dev/null
+++ b/app/components/ui/tooltip/TooltipProvider.vue
@@ -0,0 +1,14 @@
+
+
+
diff --git a/app/components/ui/tooltip/TooltipTrigger.vue b/app/components/ui/tooltip/TooltipTrigger.vue
new file mode 100644
index 00000000..8bef43ca
--- /dev/null
+++ b/app/components/ui/tooltip/TooltipTrigger.vue
@@ -0,0 +1,12 @@
+
+
+
diff --git a/app/components/ui/tooltip/index.ts b/app/components/ui/tooltip/index.ts
new file mode 100644
index 00000000..94e681c8
--- /dev/null
+++ b/app/components/ui/tooltip/index.ts
@@ -0,0 +1,4 @@
+export { default as Tooltip } from './Tooltip.vue';
+export { default as TooltipContent } from './TooltipContent.vue';
+export { default as TooltipProvider } from './TooltipProvider.vue';
+export { default as TooltipTrigger } from './TooltipTrigger.vue';
diff --git a/app/composables/useActiveHeading.ts b/app/composables/useActiveHeading.ts
new file mode 100644
index 00000000..01203fb2
--- /dev/null
+++ b/app/composables/useActiveHeading.ts
@@ -0,0 +1,41 @@
+/**
+ * The heading currently in view, for marking the table of contents.
+ *
+ * An IntersectionObserver over the rendered headings rather than a scroll
+ * handler: it fires only when something crosses the threshold, and the
+ * rootMargin puts the trigger line near the top of the viewport so a heading
+ * counts as current once it reaches reading position, not when it first peeks
+ * in at the bottom.
+ */
+export function useActiveHeading(ids: Ref) {
+  const active = ref();
+
+  let observer: IntersectionObserver | undefined;
+
+  function observe() {
+    observer?.disconnect();
+    if (!ids.value.length) return;
+
+    observer = new IntersectionObserver(
+      (entries) => {
+        const visible = entries
+          .filter((entry) => entry.isIntersecting)
+          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
+
+        if (visible[0]) active.value = visible[0].target.id;
+      },
+      { rootMargin: '-80px 0px -70% 0px', threshold: 0 }
+    );
+
+    for (const id of ids.value) {
+      const element = document.getElementById(id);
+      if (element) observer.observe(element);
+    }
+  }
+
+  onMounted(observe);
+  watch(ids, () => nextTick(observe));
+  onBeforeUnmount(() => observer?.disconnect());
+
+  return active;
+}
diff --git a/app/composables/useDuxtBreadcrumb.ts b/app/composables/useDuxtBreadcrumb.ts
new file mode 100644
index 00000000..58e4f8fb
--- /dev/null
+++ b/app/composables/useDuxtBreadcrumb.ts
@@ -0,0 +1,60 @@
+import type { ContentNavigationItem } from '@nuxt/content';
+import type { MaybeRefOrGetter } from 'vue';
+
+/**
+ * The trail from the section down to a page.
+ *
+ * Lives in a composable rather than in `DuxtBreadcrumb` because two things want
+ * it: the component draws it, and the page emits it as `BreadcrumbList`
+ * JSON-LD. Computing it twice is how the drawn trail and the structured one
+ * would come to disagree.
+ */
+export async function useDuxtBreadcrumb(path: MaybeRefOrGetter) {
+  // EVERY composable call happens before the await, deliberately. This is a
+  // plain async function, not a `
+
+
diff --git a/app/layouts/default.vue b/app/layouts/default.vue
new file mode 100644
index 00000000..18eca1b0
--- /dev/null
+++ b/app/layouts/default.vue
@@ -0,0 +1,23 @@
+
+
+
diff --git a/app/layouts/docs.vue b/app/layouts/docs.vue
new file mode 100644
index 00000000..723e56ed
--- /dev/null
+++ b/app/layouts/docs.vue
@@ -0,0 +1,54 @@
+
+
+
diff --git a/app/lib/utils.ts b/app/lib/utils.ts
new file mode 100644
index 00000000..8e533ebb
--- /dev/null
+++ b/app/lib/utils.ts
@@ -0,0 +1,7 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+/** Merge conditional class lists, letting later Tailwind utilities win. */
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}
diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue
new file mode 100644
index 00000000..c8ab4421
--- /dev/null
+++ b/app/pages/[...slug].vue
@@ -0,0 +1,183 @@
+
+
+
diff --git a/app/pages/index.vue b/app/pages/index.vue
new file mode 100644
index 00000000..6fbffdd0
--- /dev/null
+++ b/app/pages/index.vue
@@ -0,0 +1,489 @@
+
+
+