agentleFS
Atharvsinh-codezAtharvsinh-codez/ObsidianUI/public/llms-full.txt

ObsidianUI

llms.txt526 starsChanged 39 days agoMITView on GitHub

Open-source React components with complete source downloads, usage examples, and interactive previews. Platform Partners - Vercel [ Hosting Sponsor ] & Tracwell [Analytics Sponsor ] Use ObsidianUI to add buttons, navigation, galleries, scroll animations, text streams, and interface details to React interfaces. The source is yours to customize. Public reads require no account, API key, or JavaScript. For installation, GET /r/registry.json, choose an existing item name, then GET /r/{name}.json. Copy every files[].content to its resolved files[].target and install the listed…

  • Installs packages
  • Sends data out

We split this llms-full.txt into the rules it gives an agent

35 rules. About 0 are common, 25 are distinctive, and 10 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈25

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 10

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use ObsidianUI to add buttons, navigation, galleries, scroll animations, text streams, and interface details to React interfaces.

    line 7
  2. Distinctive:

    Read llms.txt and the component catalogue.

    line 79
  3. Distinctive:

    Check the resulting component with the user's real content, keyboard input, reduced motion setting, and target viewport.

    line 86
  4. Distinctive:

    Read the agent instructions to discover a component and install every source file.

    line 129
  5. Distinctive:

    Follow the template repository's installation instructions and license.

    line 194
  6. Distinctive:

    No API key, account, or cookie is required for public documentation and registry downloads.

    line 322
  7. Distinctive:

    Read the registry, Markdown documentation, llms.txt, and OpenAPI specification with ordinary HTTPS GET requests.

    line 339
  8. Distinctive:

    Use a descriptive User-Agent and fetch only the files needed for the current task.

    line 341
  9. Distinctive:

    Use MCP resources/list to discover available components.

    line 382
  10. Distinctive:

    Live until the month ends: After you cancel, your logo stays up until the period you paid for ends.

    line 407
  11. Distinctive:

    If you don’t agree with these Terms, please don’t use the Website or buy a sponsorship.

    line 415
  12. Distinctive:

    When you use the Website, you agree not to:

    line 438
  13. Distinctive:

    remove copyright or license notices that the license requires you to keep.

    line 445
  14. Distinctive:

    create a partnership, joint venture, agency, or employment relationship.

    line 497
  15. Distinctive:

    If a renewal payment fails, we may pause or remove your placement until the payment goes through.

    line 509
  16. Distinctive:

    If we decline your sponsorship before your placement goes live, we will cancel your subscription and refund that payment.

    line 523
  17. Distinctive:

    Duplicate or mistaken charges: If you were charged twice for the same period, or charged for a renewal after you asked us to cancel before …

    line 537
  18. Distinctive:

    Sponsorships we decline: If we decline your sponsorship before your placement goes live, as described in Sponsorships.

    line 538
  19. Distinctive:

    If you’d rather we handle it, message us.

    line 555
  20. Distinctive:

    When that period ends, we remove your logo from the Website and the README.

    line 567
  21. Distinctive:

    If you send us ideas, feedback, or feature requests, we may use them without owing you anything.

    line 586
  22. Distinctive:

    Messages: If you email or DM us, we keep the conversation so we can reply and manage your sponsorship.

    line 601
  23. Very specific:

    If you are a Sponsor, you agree to defend and compensate ObsidianUI and Atharv for claims, losses, and costs, including reasonable legal …

    line 621
  24. Distinctive:

    Severability: If any part of these Terms is found unenforceable, the rest stays in effect.

    line 640
  25. Distinctive:

    No waiver: If we don’t enforce part of these Terms straight away, we can still enforce it later.

    line 641
  26. Very specific:

    Create a new Next.js project with TypeScript, Tailwind CSS, ESLint, and App Router.

    line 1099
  27. Very specific:

    Run the following command to create a new Next.js project:

    line 1105
  28. Very specific:

    If you already have a Next.js project without Tailwind CSS, install it:

    line 1130
  29. Very specific:

    Create or update your postcss.config.mjs file:

    line 1138
  30. Very specific:

    Add the Tailwind CSS import to your global CSS file (src/app/globals.css):

    line 1150
  31. Very specific:

    Create a cn helper function in your utilities file (lib/utils.ts or src/lib/utils.ts):

    line 1169
  32. Very specific:

    Add the Obsidian UI registry to your project's components.json file.

    line 1203
  33. Distinctive:

    Add the MCP server to your project:

    line 1215
  34. Very specific:

    When onRevokeOthers is missing, Sign out other sessions calls onRevoke for each session instead.

    line 1306
  35. Distinctive:

    After a sign-out, focus moves to the next row's button, or to the heading when no rows remain.

    line 1331

≈ “Common” is an estimate: at least 1 in 5 of the 3,225 llms.txt files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
llms-full.txt
# ObsidianUI

> Open-source React components with complete source downloads, usage examples, and interactive previews.

Platform Partners - Vercel [ Hosting Sponsor ] & Tracwell [Analytics Sponsor ]

Use ObsidianUI to add buttons, navigation, galleries, scroll animations, text streams, and interface details to React interfaces. The source is yours to customize. Public reads require no account, API key, or JavaScript.

For installation, GET /r/registry.json, choose an existing item name, then GET /r/{name}.json. Copy every files[].content to its resolved files[].target and install the listed dependencies. Target prefixes @ui/, @components/, @lib/, and @hooks/ resolve through your project's components.json aliases; do not create literal @-named directories. Preserve CSS files, client directives, and required local helpers. Replace remote demo assets as appropriate. The documented CLI is npx shadcn@latest add "https://www.obsidianui.dev/r/{name}.json". Read the agent guide before changing project files.

Request any published page with Accept: text/markdown or read its direct Markdown URL. /llm.txt is an identical compatibility alias of this /llms.txt index. The repository has a local stdio MCP resource server (npm run mcp), not a hosted HTTP MCP endpoint. ObsidianUI is MIT licensed; preserve applicable notices and respect third-party package and asset licenses.

## When to use

- [Agent instructions](https://www.obsidianui.dev/agent-instructions.md): Choose components for React UI tasks, retrieve complete manifests, resolve file targets, install dependencies, and handle missing resources.

## Developer resources

- [Developer portal](https://www.obsidianui.dev/markdown/developers.md): ObsidianUI documentation, registry downloads, API, authentication, and MCP entry points.
- [API documentation](https://www.obsidianui.dev/markdown/api.md): Read-only registry and Markdown HTTP endpoints.
- [OpenAPI specification](https://www.obsidianui.dev/openapi.json): Machine-readable HTTP API contract.
- [Authentication](https://www.obsidianui.dev/markdown/authentication.md): Public read access requires no credentials.
- [MCP server](https://www.obsidianui.dev/markdown/mcp.md): Local stdio resources/list and resources/read at obsidian://{name}.
- [Registry catalogue](https://www.obsidianui.dev/r/registry.json): JSON manifest index for every component.
- [Sitemap](https://www.obsidianui.dev/sitemap.xml): Published page discovery.

## Getting started

- [Install Next.js](https://www.obsidianui.dev/markdown/docs/installation.md): ObsidianUI setup documentation.
- [Install Tailwind CSS](https://www.obsidianui.dev/markdown/docs/install-tailwind.md): ObsidianUI setup documentation.
- [Add utilities](https://www.obsidianui.dev/markdown/docs/add-utilities.md): ObsidianUI setup documentation.
- [CLI](https://www.obsidianui.dev/markdown/docs/cli.md): ObsidianUI setup documentation.

## Components

- [Active Sessions](https://www.obsidianui.dev/markdown/docs/active-sessions.md): [download all source files](https://www.obsidianui.dev/r/active-sessions.json) and [interactive preview](https://www.obsidianui.dev/docs/active-sessions).
- [Art Gallery](https://www.obsidianui.dev/markdown/docs/art-gallery.md): [download all source files](https://www.obsidianui.dev/r/art-gallery.json) and [interactive preview](https://www.obsidianui.dev/docs/art-gallery).
- [Discover Button](https://www.obsidianui.dev/markdown/docs/discover-button.md): [download all source files](https://www.obsidianui.dev/r/discover-button.json) and [interactive preview](https://www.obsidianui.dev/docs/discover-button).
- [Draggable Marquee](https://www.obsidianui.dev/markdown/docs/draggable-marquee.md): [download all source files](https://www.obsidianui.dev/r/draggable-marquee.json) and [interactive preview](https://www.obsidianui.dev/docs/draggable-marquee).
- [Flip Text](https://www.obsidianui.dev/markdown/docs/flip-text.md): [download all source files](https://www.obsidianui.dev/r/flip-text.json) and [interactive preview](https://www.obsidianui.dev/docs/flip-text).
- [Hover Image](https://www.obsidianui.dev/markdown/docs/hover-img.md): [download all source files](https://www.obsidianui.dev/r/hover-img.json) and [interactive preview](https://www.obsidianui.dev/docs/hover-img).
- [Split Showcase](https://www.obsidianui.dev/markdown/docs/split-showcase.md): [download all source files](https://www.obsidianui.dev/r/split-showcase.json) and [interactive preview](https://www.obsidianui.dev/docs/split-showcase).
- [Status Bars](https://www.obsidianui.dev/markdown/docs/status-bars.md): [download all source files](https://www.obsidianui.dev/r/status-bars.json) and [interactive preview](https://www.obsidianui.dev/docs/status-bars).
- [Text reel](https://www.obsidianui.dev/markdown/docs/text-stream.md): [download all source files](https://www.obsidianui.dev/r/text-stream.json) and [interactive preview](https://www.obsidianui.dev/docs/text-stream).
- [v-prism](https://www.obsidianui.dev/markdown/docs/v-prism.md): [download all source files](https://www.obsidianui.dev/r/v-prism.json) and [interactive preview](https://www.obsidianui.dev/docs/v-prism).
- [Analytics Dashboard](https://www.obsidianui.dev/markdown/docs/analytics-dashboard.md): [download all source files](https://www.obsidianui.dev/r/analytics-dashboard.json) and [interactive preview](https://www.obsidianui.dev/docs/analytics-dashboard).
- [Dashboard Shell](https://www.obsidianui.dev/markdown/docs/dashboard-shell.md): [download all source files](https://www.obsidianui.dev/r/dashboard-shell.json) and [interactive preview](https://www.obsidianui.dev/docs/dashboard-shell).

## Site pages

- [ObsidianUI home](https://www.obsidianui.dev/markdown/index.md): Product overview and installation workflow.
- [Component showcase](https://www.obsidianui.dev/markdown/components.md): Published component catalogue with Markdown documentation and downloads.
- [Templates](https://www.obsidianui.dev/markdown/templates.md): Template catalogue.
- [Project One](https://www.obsidianui.dev/markdown/project-one.md): Template preview and separate source repository.
- [Sponsors](https://www.obsidianui.dev/markdown/sponsors.md): Platform sponsors, monthly sponsorship tiers, benefits, and email inquiries.
- [Prompts](https://www.obsidianui.dev/markdown/prompts.md): Copy-ready prompts for coding agents, each with its own Markdown file.
- [Terms of Service](https://www.obsidianui.dev/markdown/terms.md): Website use, MIT-licensed code, sponsorship billing, no refunds, and cancellation.

## Optional

- [All documentation in one file](https://www.obsidianui.dev/llms-full.txt): Complete Markdown documentation and source blocks; use individual documents to keep context small.
- [Source repository](https://github.com/Atharvsinh-codez/ObsidianUI): Source, contribution history, and MIT license.


---

# ObsidianUI agent instructions

## When to use ObsidianUI

Use ObsidianUI when a user wants React interface components they can own and customize: buttons, menus, galleries, scroll animations, text streams, or interface details. Start with a published example, retrieve its complete source, and adapt it to the user's existing design system. Use the documentation for installation, props, usage, and preview behavior.

## When another tool is needed

ObsidianUI is a component library, not a cloud workspace, hosted coding agent, backend, or deployment service. Do not assume it supplies authentication, payments, storage, or a hosted MCP endpoint. Interactive previews require JavaScript; documentation and source downloads do not.

## Discover and install a component

1. Read [llms.txt](https://www.obsidianui.dev/llms.txt) and [the component catalogue](https://www.obsidianui.dev/markdown/components.md). Find a component by its documented name and use case.
2. GET [the registry](https://www.obsidianui.dev/r/registry.json). Its `items` include published components and supporting UI primitives. Each item has a unique `name`, `type`, `dependencies`, and `files`.
3. GET `https://www.obsidianui.dev/r/{name}.json`. Use an actual name from the catalogue. This is a complete shadcn-compatible JSON manifest, not a binary archive. Read every `files[].content`, `files[].target`, `dependencies`, `docs`, and `meta` field that is present.
4. For a project using shadcn, the documented installation command is `npx shadcn@latest add "https://www.obsidianui.dev/r/{name}.json"`. Run installation only within the user's authorized project workflow. A direct download does not execute code.
5. For manual installation, resolve target aliases through the destination project's `components.json`: `@ui/` uses `aliases.ui`, `@components/` uses `aliases.components`, `@lib/` uses `aliases.lib`, and `@hooks/` uses `aliases.hooks`. Resolve their TypeScript aliases to filesystem paths. For this site's defaults, `@ui/button.tsx` becomes `src/components/ui/button.tsx` and `@components/block/hover-img.tsx` becomes `src/components/block/hover-img.tsx`. A `public/` target is relative to the project root. Do not create literal directories named `@ui` or `@components`.
6. Copy all required files, including CSS, hooks, utilities, shaders, and local assets. Keep paths inside the destination project and review existing files before replacing them. Install each listed package dependency with the project's package manager. React, React DOM, and a compatible host framework are project prerequisites.
7. Preserve `"use client"` boundaries, CSS imports, and alias configuration. Start from the usage example. If `meta.remoteAssets` lists demo images or videos, replace them with the user's own assets for offline use. If `meta.requiredEndpoints` is present, implement those application endpoints yourself; they are not supplied by the component registry.
8. Check the resulting component with the user's real content, keyboard input, reduced motion setting, and target viewport. Copying code is not evidence that the interaction was tested.

## Read without JavaScript

Request a published page with `Accept: text/markdown`, or use its direct Markdown URL. The homepage is [index.md](https://www.obsidianui.dev/markdown/index.md); a documentation page is `/markdown/docs/{slug}.md`. The compatibility endpoint `/api/docs/{slug}/markdown` returns the same clean document. Source blocks include all installation files; the JSON manifest remains the canonical machine-installable download.

## API, authentication, and MCP

Public documentation and registry reads require no account, API key, cookie, or bearer token. [OpenAPI](https://www.obsidianui.dev/openapi.json) describes the published read surface. See [authentication](https://www.obsidianui.dev/authentication) for its scope.

The repository includes a local MCP resource server. In a checkout, install project dependencies, run `npm run registry:build`, then configure an MCP client to launch `npm run mcp` with the checkout as its working directory. It uses stdio and exposes `resources/list` and `resources/read` at `obsidian://{name}`. It does not expose installation tools or a hosted HTTP transport. See [MCP documentation](https://www.obsidianui.dev/mcp).

## Errors and freshness

Use the returned HTTP status. A missing page or component is not a valid empty document: recover through [the sitemap](https://www.obsidianui.dev/sitemap.xml), [llms.txt](https://www.obsidianui.dev/llms.txt), or [the registry](https://www.obsidianui.dev/r/registry.json). On a temporary server error, retry with backoff. Do not invent removed component names. Generated content is rebuilt from the current published documentation and registry with `npm run agent:build`; the production build runs registry generation first.

## License

ObsidianUI's repository is MIT licensed. Preserve the copyright and license notice when required. Third-party packages and media retain their own licenses; a component demo does not transfer rights to external assets. [Repository license](https://github.com/Atharvsinh-codez/ObsidianUI/blob/main/LICENSE).


---

# ObsidianUI

> Design less. Ship better.

Platform Partners - Vercel [ Hosting Sponsor ] & Tracwell [Analytics Sponsor ]

ObsidianUI is an open-source React component library for animated, interactive interfaces. Browse component previews, read usage examples, and copy the complete source into your own project. Components use Tailwind CSS with Motion, GSAP, canvas, or WebGL where appropriate. You control the markup, styling, and behavior after installation.

## Start building

Use the component catalogue to choose a button, menu, gallery, scroll animation, cursor effect, text reveal, or background. Documentation includes installation commands, props where available, and complete source files. A compatible React project and the dependencies listed in the component registry are required. Some examples use browser APIs or hosted demo media; preserve client boundaries and replace sample content with your own.

- [Browse components](https://www.obsidianui.dev/components)
- [Install Next.js](https://www.obsidianui.dev/docs/installation)
- [Templates](https://www.obsidianui.dev/templates)
- [Developer resources](https://www.obsidianui.dev/developers)
- [Download component registry](https://www.obsidianui.dev/r/registry.json)

## For AI agents

Read [the agent instructions](https://www.obsidianui.dev/agent-instructions.md) to discover a component and install every source file. [llms.txt](https://www.obsidianui.dev/llms.txt) lists the available resources. Public reads require no account or API key. Request Markdown using Accept: text/markdown.

## Open source

ObsidianUI is MIT licensed. Keep the license notice where required; third-party dependencies and external media have their own licenses. [Source repository](https://github.com/Atharvsinh-codez/ObsidianUI).


---

# ObsidianUI component showcase

Animated, interactive components for React. Built with Tailwind CSS and animation libraries, ready to copy, customize, and ship your next interface.

## Explore the components

Find your next detail. Preview it, read the usage example, and copy all required files from its JSON manifest. 12 components have published documentation. The [complete registry](https://www.obsidianui.dev/r/registry.json) also includes supporting UI primitives.

- [Active Sessions](https://www.obsidianui.dev/markdown/docs/active-sessions.md): [download all source files](https://www.obsidianui.dev/r/active-sessions.json) and [interactive preview](https://www.obsidianui.dev/docs/active-sessions).
- [Art Gallery](https://www.obsidianui.dev/markdown/docs/art-gallery.md): [download all source files](https://www.obsidianui.dev/r/art-gallery.json) and [interactive preview](https://www.obsidianui.dev/docs/art-gallery).
- [Discover Button](https://www.obsidianui.dev/markdown/docs/discover-button.md): [download all source files](https://www.obsidianui.dev/r/discover-button.json) and [interactive preview](https://www.obsidianui.dev/docs/discover-button).
- [Draggable Marquee](https://www.obsidianui.dev/markdown/docs/draggable-marquee.md): [download all source files](https://www.obsidianui.dev/r/draggable-marquee.json) and [interactive preview](https://www.obsidianui.dev/docs/draggable-marquee).
- [Flip Text](https://www.obsidianui.dev/markdown/docs/flip-text.md): [download all source files](https://www.obsidianui.dev/r/flip-text.json) and [interactive preview](https://www.obsidianui.dev/docs/flip-text).
- [Hover Image](https://www.obsidianui.dev/markdown/docs/hover-img.md): [download all source files](https://www.obsidianui.dev/r/hover-img.json) and [interactive preview](https://www.obsidianui.dev/docs/hover-img).
- [Split Showcase](https://www.obsidianui.dev/markdown/docs/split-showcase.md): [download all source files](https://www.obsidianui.dev/r/split-showcase.json) and [interactive preview](https://www.obsidianui.dev/docs/split-showcase).
- [Status Bars](https://www.obsidianui.dev/markdown/docs/status-bars.md): [download all source files](https://www.obsidianui.dev/r/status-bars.json) and [interactive preview](https://www.obsidianui.dev/docs/status-bars).
- [Text reel](https://www.obsidianui.dev/markdown/docs/text-stream.md): [download all source files](https://www.obsidianui.dev/r/text-stream.json) and [interactive preview](https://www.obsidianui.dev/docs/text-stream).
- [v-prism](https://www.obsidianui.dev/markdown/docs/v-prism.md): [download all source files](https://www.obsidianui.dev/r/v-prism.json) and [interactive preview](https://www.obsidianui.dev/docs/v-prism).
- [Analytics Dashboard](https://www.obsidianui.dev/markdown/docs/analytics-dashboard.md): [download all source files](https://www.obsidianui.dev/r/analytics-dashboard.json) and [interactive preview](https://www.obsidianui.dev/docs/analytics-dashboard).
- [Dashboard Shell](https://www.obsidianui.dev/markdown/docs/dashboard-shell.md): [download all source files](https://www.obsidianui.dev/r/dashboard-shell.json) and [interactive preview](https://www.obsidianui.dev/docs/dashboard-shell).

## Installation

[Agent installation guide](https://www.obsidianui.dev/agent-instructions.md) · [Next.js setup](https://www.obsidianui.dev/docs/installation) · [CLI and MCP](https://www.obsidianui.dev/docs/cli)


---

# ObsidianUI templates

Production-ready UI for your next project.

## Project One

A landing page template with a futuristic visual direction for the creator economy. Built with React, TypeScript, Tailwind CSS, Motion, and Next.js.

- [Template details](https://www.obsidianui.dev/project-one)
- [Live preview](https://project-one.obsidianui.dev/)
- [Source repository](https://github.com/Atharvsinh-codez/Project-1)

The template is a separate repository; use its own setup instructions and license. Component JSON manifests live in the [ObsidianUI component registry](https://www.obsidianui.dev/r/registry.json).


---

# ObsidianUI — Project One

A landing page concept exploring a futuristic visual direction around the creator economy. It presents creators as digital assets through a modern interface. Built with React, TypeScript, Tailwind CSS, Motion, and Next.js.

## Preview and source

- [Live preview](https://project-one.obsidianui.dev/)
- [Source repository](https://github.com/Atharvsinh-codez/Project-1)
- [Template preview video](https://cdn.obsidianui.dev/templates/project-one.mp4)
- [All templates](https://www.obsidianui.dev/templates)

Follow the template repository's installation instructions and license. This template has a separate source repository; it is not a component registry item.


---

# ObsidianUI sponsors

Support ObsidianUI and help keep its open-source React components free for developers.

## Platform sponsors

Platform Partners - Vercel [ Hosting Sponsor ] & Tracwell [Analytics Sponsor ]

- [Vercel](https://vercel.com): Hosting Sponsor through its open-source program.
- [Tracwell](https://tracwell.app): Analytics Sponsor through its open-source program.

## Monthly sponsorship tiers

### Diamond — $200/month

Largest logo on the sponsors page, home page, and README; a shoutout on X; and a direct line for feedback and requests.

### Gold — $100/month

Larger logo on the sponsors page and README, a logo on the home page, and a shoutout on X.

## How to become a sponsor

[Compare sponsorship tiers](https://www.obsidianui.dev/sponsors#tiers). Select a tier to pay through Dodo Payments hosted checkout. After payment, the tier's welcome page lists the logo, link, and handles to send to Atharv by email, X, or Telegram, and the placement is added by hand. Open sponsor slots link to the pricing section.

Sponsorships renew monthly until cancelled and payments are non-refundable. To cancel or update the card, use the billing portal link in the Dodo Payments receipt, or DM Atharv on X or Telegram, or send an email; the placement stays live until the end of the paid period. Read the [Terms of Service](https://www.obsidianui.dev/terms#sponsorships) for details.

- [Email jadavatharv2010@gmail.com](mailto:jadavatharv2010@gmail.com?subject=ObsidianUI%20sponsorship%20inquiry)
- [DM on X](https://x.com/athrix_codes)
- [Telegram](https://t.me/athrix)

## Custom sponsorship packages

Looking for tailored brand placements, co-branded interactive components, or custom sponsorships? Email jadavatharv2010@gmail.com or send a DM on X to discuss a custom partnership.

- [Sponsors page](https://www.obsidianui.dev/sponsors)
- [Explore components](https://www.obsidianui.dev/components)
- [Source repository](https://github.com/Atharvsinh-codez/ObsidianUI)


---

# ObsidianUI Developer Portal

Find component source files, installation guides, the public API, and resources for coding agents.

## Build with ObsidianUI

ObsidianUI is an open-source React component library for motion, scroll interactions, and interface details. Use the previews to choose a component, then install its source into your own project.

Each registry item includes its source files, local dependencies, installation targets, and npm dependencies. Review the complete item before writing files so your customizations stay intact.

[Browse components](https://www.obsidianui.dev/components)

[Installation guide](https://www.obsidianui.dev/docs/installation)

[Component registry](https://www.obsidianui.dev/r/registry.json) — Complete machine-readable catalog and component files.

## Resources for AI agents

[ObsidianUI llms.txt](https://www.obsidianui.dev/llms.txt) — When to use the library and how to find and download components.

[Agent instructions](https://www.obsidianui.dev/agent-instructions.md)

[ObsidianUI API documentation](https://www.obsidianui.dev/api)

[OpenAPI specification](https://www.obsidianui.dev/openapi.json)

[Authentication documentation](https://www.obsidianui.dev/authentication)

[MCP server documentation](https://www.obsidianui.dev/mcp)

## Source and support

The repository is MIT licensed. Third-party assets and dependencies retain their own licenses; use your own media when adapting examples.

[Source code](https://github.com/Atharvsinh-codez/ObsidianUI)

[MIT license](https://github.com/Atharvsinh-codez/ObsidianUI/blob/main/LICENSE)

[Report an issue](https://github.com/Atharvsinh-codez/ObsidianUI/issues)


---

# ObsidianUI API Documentation

A public, read-only HTTP interface for discovering components, downloading complete source files, and reading documentation as Markdown.

## Discover and download components

GET /r/registry.json returns the component catalog. Select an item by name, then GET /r/{name}.json to download that component with its supporting files. /r/index.json is an equivalent catalog URL.

Each files entry contains path, target, type, and content. Resolve @ui, @components, @lib, and @hooks targets against your project's components.json aliases. Install the declared npm dependencies and copy every required file; compare existing files before replacing them.

The docs and meta fields describe any required application endpoints or remote demo assets. Downloading a component does not provide a backend for these requirements.

```bash
curl -fsS https://www.obsidianui.dev/r/registry.json
curl -fsS https://www.obsidianui.dev/r/hover-img.json -o hover-img.json
```

[OpenAPI specification](https://www.obsidianui.dev/openapi.json)

[Registry catalog](https://www.obsidianui.dev/r/registry.json)

## Read any public page as Markdown

Send Accept: text/markdown to a public page URL. The response uses Content-Type: text/markdown and Vary: Accept to keep the Markdown and HTML cache variants separate. Accept quality values are respected; unsupported media types return 406.

Markdown is also available at predictable .md URLs, including /index.md and /docs/hover-img.md. GET /api/docs/{slug}/markdown remains available for documentation integrations. Missing pages return HTTP 404 with recovery links.

```bash
curl -fsS -H 'Accept: text/markdown' https://www.obsidianui.dev/docs/hover-img
curl -fsS https://www.obsidianui.dev/docs/hover-img.md
```

[Agent index](https://www.obsidianui.dev/llms.txt)

[Sitemap](https://www.obsidianui.dev/sitemap.xml)

## Access and scope

No API key, account, or cookie is required for public documentation and registry downloads. This API supplies source code and documentation; it does not host your application, run agents, or provide a remote MCP transport.

The website's visitor analytics endpoint is not part of the component integration API. Agents should not submit synthetic visits.

[Authentication documentation](https://www.obsidianui.dev/authentication)

[Local MCP setup](https://www.obsidianui.dev/mcp)


---

# ObsidianUI Authentication Documentation

Public component downloads and documentation require no authentication.

## Public access

Read the registry, Markdown documentation, llms.txt, and OpenAPI specification with ordinary HTTPS GET requests. No API key, account, bearer token, session cookie, or login flow is required.

Use a descriptive User-Agent and fetch only the files needed for the current task. Cache unchanged downloads locally. If a deployed edge service returns 403, report the URL and response headers to the maintainer rather than attempting to bypass the restriction.

[API documentation](https://www.obsidianui.dev/api)

[Crawler policy](https://www.obsidianui.dev/robots.txt)

## Authentication in your application

Copying a UI component does not add authentication or a backend to your application. If an example needs an application API, provide and secure that endpoint yourself. Never put secret keys into client component files or registry content.

[Installation guide](https://www.obsidianui.dev/docs/installation)


---

# ObsidianUI MCP Server Documentation

Connect a local coding agent to ObsidianUI component resources through the repository's stdio MCP server.

## A new direction for ObsidianUI

Now it’s time to be different. Everyone on the internet has already used ready-made components, from images to the smallest details. Atharv (@athrix\_codes on X) is taking a big step to build a next-level UI library, because he knows Claude, Gemini, Codex, and any AI can create components in a few minutes. Thank you for supporting us through every moment, guys! We’re going big. Stay tuned!

## Run the local MCP server

The repository includes an MCP server in scripts/mcp-server.ts. It uses stdio transport and exposes read-only resources. This documentation URL is not a hosted HTTP MCP endpoint.

Clone the repository, install its dependencies, and generate the component registry. Configure your MCP client to launch npm run mcp with this checkout as its working directory.

```bash
git clone https://github.com/Atharvsinh-codez/ObsidianUI.git
cd ObsidianUI
npm ci
npm run registry:build
npm run mcp
```

[MCP server source](https://github.com/Atharvsinh-codez/ObsidianUI/blob/main/scripts/mcp-server.ts)

## Read component resources

Use MCP resources/list to discover available components. Then use resources/read with a returned URI, such as obsidian://hover-img. The resource returns the same application/json registry item used by HTTP installation, including all source and supporting files.

The server exposes resources, not code-execution or file-writing tools. Your coding agent applies the downloaded files in your project with your normal approval and review settings.

[HTTP registry alternative](https://www.obsidianui.dev/r/registry.json)

[Agent installation instructions](https://www.obsidianui.dev/agent-instructions.md)


---

# ObsidianUI Terms of Service

Last updated: October 13, 2026

These Terms cover your use of obsidianui.dev and any ObsidianUI sponsorship you buy.

## The short version

This summary is for convenience. The full Terms below are what apply.

- **Free and open source.** Components are MIT licensed. Use them in personal and commercial projects.
- **No account needed.** Browse, copy, and install components without signing up.
- **Monthly sponsorships.** Diamond is $200/month and Gold is $100/month, billed through Dodo Payments until you cancel.
- **Cancel any time.** From the billing portal link in your Dodo Payments receipt, or by DM or email.
- **Live until the month ends.** After you cancel, your logo stays up until the period you paid for ends.

## 1. Agreement to these terms

These Terms of Service (the **Terms**) govern your use of obsidianui.dev, including its pages, documentation, component registry, prompts, templates, Markdown files, and API endpoints (together, the **Website**), and any sponsorship you buy from ObsidianUI.

By using the Website or paying for a sponsorship, you agree to these Terms. If you use the Website or buy a sponsorship on behalf of a company or other organisation, you confirm that you can accept these Terms for it, and “you” includes that organisation.

If you don’t agree with these Terms, please don’t use the Website or buy a sponsorship.

## 2. Who we are

ObsidianUI is an open-source React and Tailwind CSS component library, built and maintained by Atharv. In these Terms, “ObsidianUI”, “we”, “us”, and “our” mean ObsidianUI and Atharv as its maintainer.

You can reach us by email at [jadavatharv2010@gmail.com](mailto:jadavatharv2010@gmail.com), by DM to [@athrix_codes on X](https://x.com/athrix_codes), or at [@athrix on Telegram](https://t.me/athrix). All contact details are in [Contact](https://www.obsidianui.dev/terms#contact).

## 3. Key terms

A few words have a specific meaning in these Terms:

- **Website**: obsidianui.dev and its subdomains, pages, documentation, registry files, Markdown files, and public API endpoints.
- **Components**: the React components, blocks, hooks, styles, and other source code published in the ObsidianUI registry and GitHub repository.
- **Content**: documentation, prompts, templates, text, graphics, video, and other material on the Website.
- **Sponsor**: a person or organisation that pays for an ObsidianUI sponsorship.
- **Sponsor Materials**: the logo, name, links, handles, and other material a Sponsor gives us for their placement.
- **Billing period**: the one-month period covered by each sponsorship payment.

## 4. Using the website

The Website is free to use. You don’t need an account to browse components, read documentation, copy prompts, or download registry files.

When you use the Website, you agree not to:

- break the law, or help anyone else break it;
- try to gain unauthorised access to the Website, its servers, or any connected system;
- disrupt the Website, including by sending traffic at a volume or rate that slows it down for others;
- upload or send malware, or use the Website to distribute it;
- misrepresent who you are or your relationship with ObsidianUI, including claiming that ObsidianUI endorses you when it doesn’t;
- remove copyright or license notices that the license requires you to keep.

We may block or limit access for anyone who breaks these rules or puts the Website at risk.

## 5. Open-source components and the MIT License

ObsidianUI’s source code is published under the [MIT License](https://github.com/Atharvsinh-codez/ObsidianUI/blob/main/LICENSE). That license, not these Terms, sets out what you can do with the code. In short, you can use, copy, modify, merge, publish, distribute, sublicense, and sell copies of it, in personal and commercial projects, as long as you keep the copyright and license notice where the license requires it.

Where these Terms and the MIT License differ about the code itself, the MIT License applies.

### Third-party code and assets

Components can depend on third-party packages such as React, Tailwind CSS, Motion, GSAP, and Three.js. Each package has its own license, and you are responsible for following it.

Some previews use demo images, videos, fonts, or other media. Unless a file says otherwise, demo media is there to show how a component behaves and is not licensed to you for reuse. Replace it with your own assets before you ship.

### Your projects stay yours

What you build with ObsidianUI belongs to you. We claim no ownership of your projects, and you don’t need to credit ObsidianUI beyond what the MIT License requires, though a link back is always appreciated.

## 6. Documentation, prompts, and templates

Documentation, prompts, and templates are there to help you build. Prompts are written for AI coding agents, and results depend on the agent and model you use. Review and test any code an agent produces before you rely on it.

Templates such as Project One live in their own repositories and are covered by the license in each repository.

## 7. Registry, API, and AI agent access

The component registry, Markdown files, llms.txt, and the public read-only API are open to people and AI agents without an API key. You may use them to discover, read, and install components.

These endpoints come with no uptime guarantee or service level. We may change, rate-limit, or retire an endpoint, and we may block clients that send abusive traffic.

## 8. Sponsorships

Sponsors fund ObsidianUI’s development and keep it free and open source for everyone. This section applies to every sponsorship you buy.

### Tiers and pricing

These are the current monthly tiers:

- **Diamond: $200 per month.** Largest logo on the sponsors page, largest logo on the home page, largest logo in the README, shoutout on X, direct line for feedback and requests.
- **Gold: $100 per month.** Larger logo on the sponsors page, logo on the home page, larger logo in the README, shoutout on X.

Prices are in US dollars. Custom packages are agreed in writing, by email or DM, and these Terms cover them too unless that written agreement says otherwise.

### What a sponsorship is, and what it isn’t

A sponsorship buys the placements and perks listed for your tier for each billing period you pay for. It does not:

- give you ownership of, or control over, ObsidianUI, its roadmap, or its code;
- guarantee any number of visits, clicks, impressions, sign-ups, or sales;
- mean that ObsidianUI endorses your products or services;
- create a partnership, joint venture, agency, or employment relationship.

We decide the exact layout, size, and position of placements within what your tier describes, and we may redesign the Website, the sponsors page, or the README over time. If a redesign moves placements, your tier keeps its relative prominence.

### Payments through Dodo Payments

Checkout is handled by [Dodo Payments](https://dodopayments.com), which acts as the merchant of record. Dodo Payments processes your payment, charges any applicable taxes, and emails your receipt. We never see or store your full card details. Your payment is also subject to Dodo Payments’ own terms and privacy policy.

### Monthly billing and renewal

Sponsorships are subscriptions. Your first payment covers your first month, and the subscription renews automatically every month at your tier’s current price until you cancel. Each renewal is charged to the payment method on file.

If a renewal payment fails, we may pause or remove your placement until the payment goes through.

### Your sponsor materials

After checkout, your welcome page lists what we need: your logo (light and dark versions if you have them), the link your logo should open, your name as it should appear, and your X and LinkedIn handles. Send them by email, X, or Telegram. Placements are added by hand, so they go live after we receive your materials, not at the moment you pay.

You give us a non-exclusive, worldwide, royalty-free license to use, reproduce, resize, adapt for light and dark themes, and display your Sponsor Materials on the Website, in the README, and in posts about your sponsorship, while your sponsorship is active and for a reasonable time afterwards while we remove them. Posts already published on social platforms, such as your shoutout on X, may stay up.

You confirm that you own or have permission to use everything in your Sponsor Materials, that they don’t infringe anyone’s rights, and that your link doesn’t lead to unlawful or harmful content.

### Sponsors we can’t accept

We want sponsors that fit an open-source developer community. We may decline a sponsorship, or remove a placement, for a business or link that involves illegal activity, malware or phishing, scams or misleading claims, adult content, gambling, hate or harassment, or anything else that could harm ObsidianUI’s users or reputation.

If we decline your sponsorship before your placement goes live, we will cancel your subscription and refund that payment. If we remove a live placement because your business, link, or Sponsor Materials break these Terms, we will cancel your subscription and no refund is due.

### Changes to tiers and prices

We may change tiers, perks, or prices. Changes apply to existing sponsors from their next billing period, and we will tell you by email or DM before a price increase takes effect, so you can cancel first if you prefer.

## 9. No refunds

> **All sponsorship payments are final.** Sponsorship payments are non-refundable. We don’t give refunds or credits for partial months, unused time, or cancelling partway through a billing period.

Sponsorships fund ongoing work on ObsidianUI, and your placement is reserved from the moment you pay. That is why every payment, the first one and every renewal, is final once it has been charged.

The only exceptions are:

- **Duplicate or mistaken charges.** If you were charged twice for the same period, or charged for a renewal after you asked us to cancel before the renewal date, tell us and we will refund the extra payment.
- **Sponsorships we decline.** If we decline your sponsorship before your placement goes live, as described in [Sponsorships](https://www.obsidianui.dev/terms#sponsorships).
- **Rights under law.** Nothing in these Terms removes a refund right that the law where you live gives you and doesn’t allow a business to exclude.

If you think a charge is wrong, please contact us before disputing it with your bank or card issuer. Most billing problems are sorted out quickly over email. If you open a chargeback for a payment these Terms don’t make refundable, we may remove your placement and end your sponsorship.

## 10. Cancelling a sponsorship

> **Two ways to cancel.** Use the billing portal link in your Dodo Payments receipt, or message Atharv: DM [@athrix_codes on X](https://x.com/athrix_codes) or [@athrix on Telegram](https://t.me/athrix), or email [jadavatharv2010@gmail.com](mailto:jadavatharv2010@gmail.com).

You can cancel at any time, for any reason. There is no cancellation fee and no minimum term.

### From the billing portal

Your Dodo Payments receipt includes a link to the billing portal, where you can cancel your subscription or update your card yourself. Once you cancel there, you won’t be charged again.

### By message

If you’d rather we handle it, message us. So we can find the right subscription, please include:

- your company or product name;
- the email address you used at Dodo Payments checkout;
- your sponsorship tier (Diamond or Gold).

We will cancel the renewal in Dodo Payments and reply to confirm. If you message us before your renewal date and the cancellation doesn’t go through in time, we will refund that renewal.

### What happens after you cancel

- You won’t be charged again.
- Your placement and perks stay live until the end of the billing period you already paid for.
- When that period ends, we remove your logo from the Website and the README.
- The remaining days of the current period are not refunded, as explained in [No refunds](https://www.obsidianui.dev/terms#refunds).

You’re welcome back any time. To sponsor again, check out from the [sponsors page](https://www.obsidianui.dev/sponsors#tiers).

### When we cancel

We may cancel a sponsorship if renewal payments keep failing, if the sponsorship breaks these Terms, or if we stop offering sponsorships. If we stop offering sponsorships, or end yours for a reason that isn’t your fault, we will either keep your placement up until the end of the period you paid for or refund the unused part of that period.

## 11. Intellectual property

Apart from the open-source code covered by the MIT License, the Website’s design, the ObsidianUI name and logo, and our original Content belong to ObsidianUI. You may mention ObsidianUI by name to say that your project uses it, but you may not use the ObsidianUI name or logo in a way that suggests we endorse or are affiliated with you without our written permission.

Names and logos of sponsors and partners, such as Vercel and Tracwell, belong to their owners and appear with their permission.

## 12. Contributions and feedback

Issues, pull requests, and discussions on GitHub are covered by the repository’s license and [contributing guide](https://github.com/Atharvsinh-codez/ObsidianUI/blob/main/CONTRIBUTING.md). By submitting a pull request, you agree that your contribution is licensed under the MIT License.

If you send us ideas, feedback, or feature requests, we may use them without owing you anything. A Diamond sponsor’s direct line for feedback is a way to be heard quickly, not a promise to build a specific feature.

## 13. Third-party services and links

The Website relies on third-party services, including Vercel for hosting, Cloudflare for media delivery, Vercel Analytics and Tracwell for analytics, Dodo Payments for checkout, and GitHub, X, and Telegram for code and communication. Each is governed by its own terms and policies, and we aren’t responsible for their availability or actions.

Links to other websites, including sponsor links, are there for convenience. We don’t control those sites and aren’t responsible for their content or practices.

## 14. Privacy

We collect as little as we can:

- **Visitor count.** To count unique visitors, your browser stores a random visitor ID in local storage and sends it to us. If that isn’t possible, we use a one-way hash of your IP address and browser user agent instead. We don’t use either to identify you.
- **Analytics.** Vercel Analytics and Tracwell tell us which pages people use, in aggregate.
- **Sponsors.** Dodo Payments shares the details we need to manage your subscription, such as your name and email address. We also keep the Sponsor Materials you send us.
- **Messages.** If you email or DM us, we keep the conversation so we can reply and manage your sponsorship.

We don’t sell personal information. To ask about, correct, or delete information we hold about you, use the contact details below.

## 15. Disclaimers

The Website, Components, Content, and API are provided “as is” and “as available”, without warranties of any kind, express or implied, including warranties of merchantability, fitness for a particular purpose, and non-infringement, to the fullest extent the law allows.

We don’t promise that the Website will be uninterrupted, error-free, or secure, or that any Component will work in your project, browser, or framework version. Test code before you ship it.

## 16. Limitation of liability

To the fullest extent the law allows, ObsidianUI and Atharv are not liable for any indirect, incidental, special, consequential, or punitive damages, or for lost profits, revenue, data, or goodwill, arising from your use of the Website, Components, Content, or a sponsorship.

Our total liability for any claim relating to these Terms or the Website is limited to the greater of the amount you paid us for sponsorships in the three months before the claim arose, or US$100.

Some places don’t allow certain limitations, so some of these may not apply to you.

## 17. Indemnity

If you are a Sponsor, you agree to defend and compensate ObsidianUI and Atharv for claims, losses, and costs, including reasonable legal fees, that arise from your Sponsor Materials, the content your link leads to, or your breach of these Terms.

## 18. Suspension and termination

You can stop using the Website at any time. We may suspend or end your access to the Website, or end a sponsorship as described in [Cancelling a sponsorship](https://www.obsidianui.dev/terms#cancellation), if you break these Terms or if we need to protect the Website, its users, or ObsidianUI.

Sections that by their nature should continue, including No refunds, Intellectual property, Disclaimers, Limitation of liability, and Indemnity, survive the end of your use or sponsorship.

## 19. Changes to these terms

We may update these Terms as ObsidianUI and its sponsorships change. The date at the top of this page shows when they last changed. For significant changes that affect active sponsors, we will tell you by email or DM before they take effect. If you keep using the Website or keep a sponsorship active after a change, you accept the updated Terms.

## 20. Governing law and disputes

These Terms are governed by the laws of India, without regard to conflict-of-law rules. If a dispute comes up, please contact us first and we will try to resolve it informally. If we can’t resolve it within 30 days, either of us may take it to the competent courts of India, unless the law where you live gives you the right to bring it elsewhere.

## 21. General

- **Entire agreement.** These Terms, together with the MIT License for the code and any written custom sponsorship agreement, are the whole agreement between you and us about the Website and sponsorships.
- **Severability.** If any part of these Terms is found unenforceable, the rest stays in effect.
- **No waiver.** If we don’t enforce part of these Terms straight away, we can still enforce it later.
- **Assignment.** You may not transfer your sponsorship or these Terms without our consent. We may transfer them if ObsidianUI changes hands.
- **Language.** These Terms are written in English, and the English version applies.

## 22. Contact

Questions about these Terms, a payment, or your sponsorship? Get in touch:

- **Email:** [jadavatharv2010@gmail.com](mailto:jadavatharv2010@gmail.com)
- **X:** [@athrix_codes](https://x.com/athrix_codes)
- **Telegram:** [@athrix](https://t.me/athrix)


---

# ObsidianUI prompts

Copy-ready prompts for coding agents. Each prompt has a Markdown version for agents.

- [Arix Hero section](https://www.obsidianui.dev/markdown/prompts/arix-hero-section.md): A scroll-driven hero where a rotating ring of tutor tiles scatters, reveals text word by word, and flies past the camera. skydrive hero recreate prompt :)


---

# ObsidianUI prompt: Arix Hero section

A scroll-driven hero where a rotating ring of tutor tiles scatters, reveals text word by word, and flies past the camera. skydrive hero recreate prompt :)

- Category: Hero
- Published: 2026-10-09
- Author: Atharv
- Page: https://www.obsidianui.dev/prompts/arix-hero-section
- Live preview: https://herosection-v1.athrix.me

> This is the exact prompt behind the Arix hero video. Paste it into a coding agent and it builds the full page in one pass: Next.js 16, React 19, TypeScript, and plain CSS Modules, with no animation libraries. The prompt spells out the assets, the copy, the layout, the scroll math for every phase, accessibility, and Vercel deployment.

## What you get

- **A ring of 16 character tiles** that slowly rotates around the headline and call to action.
- **A scroll-driven sequence:** the tiles scatter to different depths, a paragraph reveals word by word, every tile flies past the camera, and a lesson chat preview rises into view.
- **Reversible motion** written from one `requestAnimationFrame` loop, with a reduced-motion path that only cross-fades.
- **Production details:** a responsive header with a mobile menu, a skip link, focus rings, and a verification checklist the agent runs before it finishes.

> **Recommended models:** Claude Sonnet 5 or Claude Opus 5.5. The prompt is long and precise, so a strong coding model follows the motion math most faithfully.

## The prompt

### Arix Hero section

````text
## Role and goal

You are a senior frontend engineer. Build a single-page marketing site for **Arix**, a language-tutoring product whose tutors are characterful felt-puppet personalities. The page is one long scroll-driven sequence built around a ring of character tiles. Build it in **Next.js 16 (App Router) + React 19 + TypeScript**, styled with **plain CSS Modules** (no Tailwind, no animation libraries). The site must deploy to **Vercel with zero configuration**.

Build the whole thing in one pass, then run lint and a production build, and fix anything that fails.

## The experience, in order

The page has a fixed header, then one tall "stage" section (520svh) whose inner layer is `position: sticky` and fills the viewport. Everything below happens inside that sticky layer and is driven by scroll progress `p` from 0 to 1 across the stage.

1. **Hero (p ≈ 0).** 16 rounded portrait tiles of puppet characters sit evenly spaced on a circle around a centred headline, a dark CTA button, and a "Practice on" row with three small icons. The ring slowly rotates clockwise on its own. Tiles on the lower arc are slightly larger, as if the ring tilts toward the viewer. On load, tiles fade and scale in one after another. A "Learn in" strip with language badges sits at the bottom.
2. **Scatter (p 0.05 → 0.30).** The hero text fades up and away. Each tile pulls in toward the centre (slightly below it), then spreads out to its own scattered position at its own depth. Far tiles are smaller and paler, near tiles larger. Tiles start at staggered moments. Once scattered they drift gently.
3. **Word reveal (p 0.17 → 0.56).** A centred paragraph fades in with every word light gray, then words turn black one by one as you scroll, with a soft 3-word gradient at the leading edge.
4. **Fly-through (p 0.62 → 0.87).** Every tile accelerates toward the camera: it grows, moves outward from the centre, and fades out before it would pass the viewer. Meanwhile the paragraph dims to gray, then fades out while drifting up.
5. **Chat preview (p 0.83 → 0.97).** A mock lesson chat window rises from below and fades in, ending centred and fully opaque.
6. After the stage, a small footer.

Everything is scroll-linked and reversible: scrolling up plays it backwards.

## Project setup

- Scaffold with `create-next-app` (TypeScript, App Router, ESLint, no Tailwind, no `src/` dir, import alias `@/*`).
- Delete the starter `app/page.module.css` and the starter SVGs in `public/`.
- In `package.json` add `"engines": { "node": ">=20.9.0" }`.
- Next.js 16 notes: `next/image` uses `preload` (not the deprecated `priority`). The default image quality whitelist is `[75]`, so don't pass other `quality` values. `LayoutProps<"/">` is the global type for the root layout props.
- Font: **Inter** via `next/font/google`, exposed as the CSS variable `--font-inter`.

### Files to create

```
app/
  layout.tsx              Inter font, metadata, viewport themeColor
  page.tsx                Skip link, SiteHeader, <main> with HeroSequence, footer
  globals.css             Tokens, reset, focus ring, sr-only, skip link, footer
components/
  SiteHeader.tsx          Client component: fixed header + mobile menu
  SiteHeader.module.css
  HeroSequence.tsx        Client component: the whole scroll stage
  HeroSequence.module.css
  LessonMock.tsx          Chat preview (no hooks)
  LessonMock.module.css
  icons.tsx               Inline SVG icons
public/
  avatars/tutor-01.webp … tutor-16.webp
  factory-mark-black.svg
README.md                 Deploy-with-Vercel instructions
```

## Design tokens (`app/globals.css`)

```css
:root {
  --canvas: #f6f6f6;      /* page background */
  --ink: #111111;         /* headings, revealed words */
  --ink-soft: #3d3b39;    /* nav links, secondary text */
  --ink-muted: #6b6966;   /* captions, labels */
  --cta: #272523;         /* warm near-black buttons */
  --cta-hover: #3a3734;
  --line: #e6e4e1;        /* hairline borders */
  --surface: #ffffff;     /* cards, menus */
  --focus: #1d5fd1;       /* focus ring */
  --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
```

Base styles: `box-sizing: border-box` everywhere; body uses `--canvas` and `--ink`, Inter, 16px, line-height 1.5, antialiased; links inherit colour with no underline; images are `display: block`.

Shared utilities:
- `:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }`
- `.sr-only`: the standard visually hidden pattern.
- `.skip-link`: fixed top-left dark pill ("Skip to content" → `#main`), translated off-screen until focused.
- `main:focus { outline: none; }` (main has `tabIndex={-1}` as the skip target).
- `.site-footer`: centred, 14px, muted, padding `2rem clamp(1rem, 3.6vw, 3.75rem)`. Paragraphs have no margin, with 0.25rem between them. Links are `--ink-soft`, underlined, `text-underline-offset: 3px`.

Corner radius language is **semi-rounded, never full pills** for buttons and nav: 8–12px.

## Assets

### Character tiles (16 images)

Generate original images (do not copy any existing brand's characters). Generate **four 2×2 sheets** with an image model, then crop each sheet into four portrait tiles. Use this prompt template and change only the four character descriptions:

> A 2x2 grid of four separate square portrait tiles, separated by thin pure white gutters, each tile filling exactly one quadrant. Each tile is a studio photograph of a handmade felt puppet character (fuzzy fleece skin, big round white ping-pong-ball eyes with small black pupils, a tiny stitched smile), shown head and shoulders, centered, facing camera, with generous space above the head. Every tile has the identical soft warm backdrop: a smooth vertical gradient from muted dusty rose at top to pale peach-beige at bottom, soft studio lighting, photorealistic 3D render look. Tile 1 (top-left): … Tile 2 (top-right): … Tile 3 (bottom-left): … Tile 4 (bottom-right): … No text, no logos, no watermark, no borders other than the white gutters.

Characters, in tile order 01–16:

| # | Character |
|---|---|
| 01 | violet purple skin, short black blunt bob, black turtleneck |
| 02 | tangerine orange skin, curly copper-red hair, cream knit cardigan |
| 03 | sunflower yellow skin, spiky orange hair, white zip hoodie |
| 04 | cobalt blue skin, tight navy curls, round thin glasses, off-white shirt |
| 05 | bright teal skin, long silver-white straight hair, beige linen jacket |
| 06 | hot pink skin, magenta top bun, thick round black glasses, white collared shirt |
| 07 | olive green skin, shaggy moss-green yarn hair, taupe knit sweater |
| 08 | raspberry pink skin, long straight rose-pink hair, ivory blouse |
| 09 | lime green skin, wavy golden-blonde shoulder-length hair, cream sweater |
| 10 | pumpkin orange skin, long sleek orange-red hair, white ribbed turtleneck |
| 11 | mint green skin, bubblegum-pink space buns, headphones around neck, white tee |
| 12 | aqua turquoise skin, short tousled grey hair, tan corduroy jacket |
| 13 | leaf green skin, short spiky rust-red hair, oatmeal overshirt |
| 14 | soft pink skin, platinum-blonde blunt bob with bangs, white mock-neck top |
| 15 | slate blue skin, sleek black chin-length bob, cream blazer |
| 16 | coral orange skin, light blue-white fluffy wavy hair, sand sweater |

Crop with ffmpeg. **Check the real sheet size first.** Generators often return 1254×1254, not 1024. Scale to 1024 before cropping. Each tile is 400×480 (5:6 portrait), WebP quality 82:

```bash
# offsets after scaling to 1024x1024: TL (56,14)  TR (568,14)  BL (56,528)  BR (568,528)
ffmpeg -i sheet.png -vf "scale=1024:1024:flags=lanczos,crop=400:480:56:14" -quality 82 public/avatars/tutor-01.webp
```

Inspect the 16 results as a contact sheet and confirm that every face is centred and no white gutter shows.

### Header logo

Use the Factory mark only (the icon part of the Factory lockup SVG, not the "FACTORY" wordmark). Save it as `public/factory-mark-black.svg` with `viewBox="0 0 218 218"`, containing only the mark's path (in the lockup it is the path whose coordinates sit in x 0–218).

### Icons (`components/icons.tsx`)

All icons are inline SVG with `aria-hidden` and `focusable="false"`. Never use emoji, icon fonts, or raster icons.
- `PhoneIcon`, `BrowserIcon`, `HeadphonesIcon`: 20×20 viewBox, a rounded square (`rx 4.5`) filled green `#34b26a`, blue `#4f8ef0`, and coral `#f08a5d` respectively, with a white 1.4px-stroke glyph (phone outline, browser window with a top bar, headphones).
- `MenuIcon` (two lines), `CloseIcon` (X), `SendIcon` (up arrow), `MicIcon`: `currentColor` strokes, 1.5–1.6px, round caps.

## Copy (use exactly)

- `<title>`: `Arix | Language tutors with real character`
- Meta description: `Arix pairs you with characterful language tutors who remember every conversation and keep pace as your fluency grows.`
- Header brand link text: `Made with FactoryAI`
- Nav: `Tutors`, `Languages`, `Schools`, `Pricing`, `Journal` (href `#` placeholders)
- Header actions: `Log in`, `Meet your first tutor`
- Headline (two lines): `Language tutors` / `with character`
- Hero CTA: `Meet your first tutor`
- Channels row: `Practice on` + phone, browser, headphones icons (screen-reader text: "your phone, the web, or voice calls")
- Strip: `Learn in` + badges `ES FR JA KO DE` + `+24 more` (screen-reader text: "Spanish, French, Japanese, Korean, German, and")
- Paragraph: `Arix tutors aren’t flashcards or phrasebooks. They’re characters with names, accents, and opinions who remember every conversation. They meet you at your level on day one, and keep pace as your fluency grows.`
- Footer line 1: `© 2026 Arix`
- Footer line 2: `Made by ` + link `https://x.com/athrix_codes` (link text is the full URL, opens in a new tab)

## Header (`SiteHeader.tsx`, client component)

- `position: fixed` across the top, `z-index: 50`, height `4.5rem`, `padding-inline: clamp(1rem, 3.6vw, 3.75rem)`, CSS grid `1fr auto 1fr` (brand left, nav centre, actions right). No background. The header itself has `pointer-events: none` and its direct children `auto`, so tiles show through and the empty areas don't block anything.
- **Brand link** (left): `href="https://app.factory.ai#utm_source=x.com%2Fathrix_codes&utm_campaign=Ambassador"`, `target="_blank" rel="noopener noreferrer"`, inline-flex, `gap: 0.5rem`. Contents: the Factory mark via `next/image` (`width/height 218`, rendered 20×20, `preload`, `unoptimized`, `alt=""`), then the text `Made with FactoryAI` (15px, weight 600, `line-height: 1`, `letter-spacing: -0.01em`, `--ink`, nowrap, `translate: 0 0.125rem` to optically centre it against the icon).
- **Nav** (centre, shown at ≥860px): a `<ul>` bar with `padding: 0.25rem`, `gap: 0.25rem`, `border-radius: 0.75rem`, background `rgb(255 255 255 / 0.45)`, `backdrop-filter: blur(10px)`. Links: min-height 2.25rem, `padding: 0 0.75rem`, `border-radius: 0.5rem`, 15px, `--ink-soft`. On hover (only `(hover: hover) and (pointer: fine)`): background `rgb(17 17 17 / 0.05)`, colour `--ink`, 150ms ease-out transition on background-color and color only.
- **Actions** (right): `Log in` (shown ≥860px; min-height 2.5rem, `padding: 0 0.75rem`, radius 0.625rem, 15px/500) and the CTA (shown ≥560px; min-height 2.5rem, `padding: 0 1.25rem`, radius 0.625rem, `--cta` background, white 15px/500, nowrap, hover `--cta-hover`, `:active { scale: 0.96 }` with a 150ms transition).
- **Mobile menu button** (hidden ≥860px): 44×44, radius 0.625rem, `rgb(255 255 255 / 0.7)` plus blur, `aria-expanded`, `aria-controls="mobile-menu"`, sr-only label "Open menu" or "Close menu", swapping MenuIcon and CloseIcon.
- **Mobile menu panel**: `hidden` attribute when closed; absolute at `top: 4.25rem`, inset left and right by the header padding; white, radius 1.25rem, `padding: 0.5rem`, soft two-layer shadow. Lists the 5 links plus `Log in` as 48px-tall rows (radius 0.75rem). Escape closes it and returns focus to the toggle; resizing to ≥860px closes it; clicking a link closes it.

## Hero stage (`HeroSequence.tsx`, client component)

### Markup

```
<section class="stage" aria-labelledby="hero-title">         height: 520svh (520vh fallback)
  <div class="sticky">                                        sticky; top 0; height 100svh; overflow hidden; --tile var
    <div class="tiles" aria-hidden="true">                    absolute inset 0
      16 × <div class="tile"><Image …/></div>
    <div class="hero">  h1#hero-title, CTA <a>, channels <p>   absolute inset 0, flex column centred, z-index 300
    <div class="strip"> Learn in …                             absolute bottom, z-index 300
    <p id="tutors" class="paragraph"> word spans               absolute centred, z-index 300
    <div class="mock"> <LessonMock/>                           absolute inset 0, flex centred, z-index 300
```

All layer styles (transform, opacity, visibility, z-index) are written directly to `element.style` from one `requestAnimationFrame` loop. Do not use React state for animation.

### CSS

- `.tile`: `position: absolute; top: 50%; left: 50%; width: var(--tile); aspect-ratio: 5/6;` negative margins `calc(var(--tile) * -0.6)` top and `calc(var(--tile) * -0.5)` left (centred on the origin), `overflow: hidden`, `border-radius: calc(var(--tile) * 0.13)`, fallback background `linear-gradient(#c98a86, #edc6a6)`, shadow `0 1px 2px rgb(60 30 20 / 0.08), 0 8px 18px -6px rgb(60 30 20 / 0.22)`, `opacity: 0` initially, `will-change: transform, opacity`. The image fills it with `object-fit: cover`. Use `next/image` with `width 400 height 480 sizes="200px" loading="eager" alt=""`.
- `.hero`: `padding: 4.5rem 1.5rem 0.5rem`, centred text, `pointer-events: none` with children `auto`.
- `.title`: `font-size: clamp(1.625rem, 1rem + 1.5vw + 1.6vh, 3.5rem)`, weight 500, line-height 1.06, `letter-spacing: -0.04em`. Each line is a `display: block` span (no `<br>`).
- Hero `.cta`: min-height 2.75rem, `padding: 0 1.5rem`, `border-radius: 0.75rem`, `margin-top: clamp(1.25rem, 3.4vh, 2rem)`, `--cta`, white 16px/500, the same hover and press behaviour as the header CTA.
- `.channels`: inline-flex, gap 0.75rem, `margin-top: 1rem`, 14px, `--ink-muted`. Icons are 18px with a 0.625rem gap.
- `.strip`: centred at `bottom: 1.25rem` on mobile. At ≥860px it is right-aligned at `right: clamp(1rem, 3.6vw, 3.75rem); bottom: 1.5rem`. 14px muted, gap 0.75rem. Badges are 22×22, `1.5px solid var(--ink-soft)`, radius 6px, 9px bold text.
- `.paragraph`: `top: 50%; left: 50%; translate: -50% -50%; width: min(calc(100% - 3rem), 23em)`, `font-size: clamp(1.3125rem, 0.9rem + 1.3vw, 2rem)`, weight 500, line-height 1.3, `letter-spacing: -0.02em`, centred, `text-wrap: pretty`, `opacity: 0` initially. Each `.word` starts at `opacity: 0.16`.
- `.mock`: `padding: 5.5rem clamp(1rem, 4vw, 3rem) 2rem`, `opacity: 0; visibility: hidden` initially.

### Tile data (scatter rest positions)

`x` and `y` are fractions of the stage (0–1). `z` is depth in px (negative is farther, positive is nearer).

```ts
const tutors = [
  { src: "/avatars/tutor-01.webp", x: 0.10, y: 0.17, z: 160 },
  { src: "/avatars/tutor-02.webp", x: 0.80, y: 0.15, z: 150 },
  { src: "/avatars/tutor-03.webp", x: 0.64, y: 0.22, z: -140 },
  { src: "/avatars/tutor-04.webp", x: 0.34, y: 0.23, z: -260 },
  { src: "/avatars/tutor-05.webp", x: 0.50, y: 0.27, z: -560 },
  { src: "/avatars/tutor-06.webp", x: 0.90, y: 0.43, z: 110 },
  { src: "/avatars/tutor-07.webp", x: 0.15, y: 0.46, z: -40 },
  { src: "/avatars/tutor-08.webp", x: 0.24, y: 0.66, z: -150 },
  { src: "/avatars/tutor-09.webp", x: 0.70, y: 0.64, z: -90 },
  { src: "/avatars/tutor-10.webp", x: 0.45, y: 0.66, z: -460 },
  { src: "/avatars/tutor-11.webp", x: 0.82, y: 0.70, z: 120 },
  { src: "/avatars/tutor-12.webp", x: 0.57, y: 0.74, z: -270 },
  { src: "/avatars/tutor-13.webp", x: 0.36, y: 0.75, z: -260 },
  { src: "/avatars/tutor-14.webp", x: 0.09, y: 0.81, z: 110 },
  { src: "/avatars/tutor-15.webp", x: 0.67, y: 0.87, z: 170 },
  { src: "/avatars/tutor-16.webp", x: 0.31, y: 0.64, z: -560 },
];
```

### Helpers

```ts
const PERSPECTIVE = 900;
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const lerp = (a, b, t) => a + (b - a) * t;
const range = (v, start, end) => clamp01((v - start) / (end - start));
const easeOut = (t) => 1 - Math.pow(1 - t, 3);
const easeIn = (t) => t * t * t;
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
```

Do the projection in JavaScript (no CSS `perspective`): `scale = PERSPECTIVE / (PERSPECTIVE - z)`.

### Measurement (on mount and via `ResizeObserver` on the sticky layer)

```ts
width = sticky.clientWidth; height = sticky.clientHeight;
const radius = Math.min(height * 0.34, width * 0.42);
radiusX = radius;
radiusY = radius * 1.02;
if (width / height < 0.85) radiusY = Math.min(height * 0.3, width * 0.68); // tall ellipse on narrow screens
const tile = Math.max(44, Math.min(104, radius * 0.22));
sticky.style.setProperty("--tile", `${tile}px`);
```

### Per-frame render `render(p, seconds)`

`p = clamp01(-stage.getBoundingClientRect().top / (stage.offsetHeight - viewportHeight))`. `seconds` is the time since mount. `reduced` is `matchMedia("(prefers-reduced-motion: reduce)").matches`, read every frame. `time = reduced ? 0 : seconds`. `portrait = width / height < 0.85`.

For each tile `i` (0–15):

```ts
const entrance = reduced ? 1 : easeOut(clamp01((seconds - 0.1 - i * 0.045) / 0.7));

// ring
const angle = -Math.PI / 2 + (i / 16) * Math.PI * 2 + time * 0.045;   // clockwise, ~2.6°/s
const ringX = Math.cos(angle) * radiusX;
const ringY = Math.sin(angle) * radiusY;
const ringScale = 1 + 0.07 * Math.sin(angle);                        // lower arc reads closer

// scatter rest
let restY = tutor.y;
if (portrait) { const o = restY - 0.5; restY = 0.5 + Math.sign(o) * (0.21 + Math.abs(o) * 0.62); }
const driftX = Math.sin(time * 0.7 + i * 1.3) * 7;
const driftY = Math.cos(time * 0.55 + i * 2.1) * 7;
const restX = (tutor.x - 0.5) * width + driftX;
const restPy = (restY - 0.5) * height + driftY;
const restScale = PERSPECTIVE / (PERSPECTIVE - tutor.z);
const restOpacity = tutor.z < -200 ? lerp(1, 0.45, clamp01((-tutor.z - 200) / 360)) : 1;

const stagger = (((i * 7) % 16) / 16) * 0.05;
```

Full motion (not reduced):

```ts
const e = easeInOut(range(p, 0.05 + stagger, 0.25 + stagger));
const arc = Math.sin(Math.PI * e);
const pull = 1 - 0.45 * arc;                         // converge toward centre mid-transition
x = lerp(ringX, restX, e) * pull;
y = lerp(ringY, restPy, e) * pull + arc * height * 0.06;   // dip slightly below centre
scale = lerp(ringScale, restScale, e) * (1 - 0.3 * arc);
opacity = lerp(1, restOpacity, e);

const fly = easeIn(range(p, 0.62 + stagger * 0.6, 0.84 + stagger * 0.6));
if (fly > 0) {
  const depth = PERSPECTIVE - (tutor.z + fly * 1600);
  if (depth < 80) { opacity = 0; scale = PERSPECTIVE / 80; }
  else {
    const flyScale = PERSPECTIVE / depth;
    const k = flyScale / restScale;
    x = restX * k; y = restPy * k; scale = flyScale;
    opacity = lerp(restOpacity, 1, clamp01(fly * 3)) * (1 - range(flyScale, 2.6, 5));
  }
}
```

Reduced motion (cross-fades only: no travel, no rotation, no drift):

```ts
if (p < 0.13) { x = ringX; y = ringY; scale = ringScale; opacity = 1 - range(p, 0.06, 0.12); }
else          { x = restX; y = restPy; scale = restScale; opacity = restOpacity * range(p, 0.14, 0.22); }
opacity *= 1 - range(p, 0.62, 0.72);
```

Then for every tile:

```ts
opacity *= entrance;
scale *= lerp(0.85, 1, entrance);
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
el.style.opacity = String(opacity);
el.style.visibility = opacity <= 0.001 ? "hidden" : "visible";
el.style.zIndex = String(Math.round(scale * 100));   // nearer tiles stack on top
```

Text layers. A `setLayer(el, opacity, transform)` helper also sets `visibility: hidden` at opacity 0, which keeps hidden links out of the tab order:

```ts
const heroOut = range(p, 0.03, 0.12);
setLayer(hero, 1 - heroOut, reduced ? "none" : `translateY(${-heroOut * 32}px) scale(${1 - heroOut * 0.04})`);
setLayer(strip, 1 - range(p, 0.02, 0.08), "none");

const paraIn = easeOut(range(p, 0.17, 0.27));
const paraDim = range(p, 0.68, 0.78);
const paraOut = range(p, 0.78, 0.87);
setLayer(paragraph, paraIn * (1 - paraOut),
  reduced ? "none" : `translateY(${(1 - paraIn) * 24 - paraOut * 90}px)`);

const reveal = range(p, 0.24, 0.56);
words.forEach((word, i) => {
  const lit = clamp01((reveal * (words.length + 3) - i) / 3);
  word.style.opacity = String((0.16 + 0.84 * lit) * (1 - 0.6 * paraDim));
});

const mockIn = easeOut(range(p, 0.83, 0.97));
setLayer(mock, mockIn, reduced ? "none" : `translateY(${(1 - mockIn) * 160}px)`);
```

### Loop lifecycle

- One `requestAnimationFrame` loop reads the stage rect and calls `render` every frame (the ring rotates and tiles drift even without scrolling).
- An `IntersectionObserver` on the stage pauses the loop when the stage is off-screen and resumes it when it returns.
- Clean up the rAF, `ResizeObserver`, and `IntersectionObserver` on unmount.
- Render the paragraph as `words.map` → `<span><span ref class="word">{word}</span>{" "}</span>`, collecting refs in an array via ref callbacks.

## Chat preview (`LessonMock.tsx`)

A `<figure>` (`width: min(100%, 56rem)`) containing an `aria-hidden` window and an sr-only `<figcaption>`: "Preview of an Arix lesson: the English tutor Inès asks about your weekend, you answer in English, and she suggests a more natural phrasing."

Window: white, `1px solid var(--line)`, radius 1.25rem, shadow `0 1px 2px rgb(30 20 10 / 0.04), 0 24px 60px -24px rgb(30 20 10 / 0.18)`. Grid of one column on mobile, `15rem 1fr` at ≥720px.

- **Sidebar** (≥720px only): background `#fbfbfa`, right hairline, label "Your tutors" (12px muted). Four rows (36px avatar with radius 10px, `object-position: 50% 30%`; name 14px/500; topic 12px muted, ellipsis):
  - Inès · English · Weekend plans (tutor-02, active row with `rgb(17 17 17 / 0.05)` background)
  - Kenji · Japanese · Ordering food (tutor-04)
  - Lucía · Spanish · Past tense (tutor-09)
  - Mina · Korean · Small talk (tutor-11)
- **Chat header**: avatar (tutor-02), "Inès" 15px/600, "English tutor · Lesson 14" 12px muted, bottom hairline.
- **Messages** (gap 0.625rem, padding 1.25rem; bubbles max 85% width, radius 1rem, 14px, line-height 1.45):
  - Tutor (left, `#f2f1ef`, bottom-left radius 6px): "Let’s begin! Tell me about your weekend, in English this time."
  - Learner (right, `--cta` background, white text, bottom-right radius 6px): "On Saturday, I went to the market with my sister."
  - Tutor note (left, `#eef8f5` with `1px solid #cfe9e2`): label "Nice work" (12px/600, `#1f6d5f`), then "“I went” is spot on. Add a time to sound natural: *on Saturday morning*."
- **Composer**: pill outline with "Write in English…" (muted) plus a mic icon and a dark circular send button.

## Page shell (`app/page.tsx`)

```tsx
<>
  <a href="#main" className="skip-link">Skip to content</a>
  <SiteHeader />
  <main id="main" tabIndex={-1}>
    <div id="top" />
    <HeroSequence />
  </main>
  <footer className="site-footer">
    <p>© 2026 Arix</p>
    <p>Made by <a href="https://x.com/athrix_codes" target="_blank" rel="noopener noreferrer">https://x.com/athrix_codes</a></p>
  </footer>
</>
```

`app/layout.tsx`: `<html lang="en" className={inter.variable}>`, the metadata above, and `export const viewport = { themeColor: "#f6f6f6" }`.

## Accessibility and quality rules

- Semantic landmarks: header, nav (`aria-label="Primary"` and `"Mobile"`), main, section with `aria-labelledby`, footer. One `h1`.
- Every interactive element is a real `<a>` or `<button>` with a visible focus ring and at least a 40px hit area (44px on touch).
- Decorative imagery (tiles, chat avatars, icons) uses `alt=""` or `aria-hidden`. Meaning carried by icons gets sr-only text.
- Hover effects only inside `@media (hover: hover) and (pointer: fine)`. Never use `transition: all`.
- Animate only `transform` and `opacity`, and write them directly to element styles.
- `prefers-reduced-motion`: follow the reduced path above. No rotation, drift, travel, or fly-through; everything stays readable.
- No horizontal overflow at 320px width. Tiles and text must not collide at 390×844, 1440×830, or 1920×1080.
- No emoji anywhere.

## Deployment

- No `vercel.json` and no environment variables are needed. Vercel auto-detects Next.js (build `npm run build`, output managed by Next).
- Write a `README.md` with a "Deploy with Vercel" button:
  `[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FAtharvsinh-codez%2Fhero-section-os)`
  It also needs local run steps (`npm install`, `npm run dev`), a scripts table, and the project structure.

## Verification checklist (do all before finishing)

1. `npm run lint` and `npm run build` both pass with no errors.
2. Run the production server and capture screenshots at p = 0, 0.16, 0.4, 0.6, 0.76, 0.84, 1.0 at 1440×830 and 390×844:
   - p=0: complete ring, headline fully inside it, CTA and "Practice on" visible, "Learn in" strip at the bottom.
   - p≈0.16: tiles clustered slightly below centre, hero text gone.
   - p≈0.4: scattered tiles at varied sizes; paragraph half black, half gray.
   - p≈0.6: all words black; tiles drifting.
   - p≈0.76: tiles large and moving outward; paragraph dimmed.
   - p≈0.84: paragraph nearly gone; chat rising and **not overlapping** the paragraph.
   - p=1: chat centred and fully visible.
3. On mobile, the headline must not touch any ring tile, and the menu must open, close with Escape, and return focus.
4. With reduced motion enabled, nothing travels or rotates, and all content still appears.
5. The header brand link opens the Factory URL in a new tab; the footer link opens the X profile.

A hydration warning listing attributes like `bis_skin_checked` or `bis_register` comes from a browser extension injecting them into the page, not from the app. Confirm in an incognito window instead of suppressing it in code.
````


---

# ObsidianUI — Install Next.js

[Canonical page](https://www.obsidianui.dev/docs/installation) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

Create a new Next.js project with TypeScript, Tailwind CSS, ESLint, and App Router.

***

## Create Project

Run the following command to create a new Next.js project:

```bash
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir --import-alias '@/*'
```

Navigate to your project directory:

```bash
cd my-app
```


---

# ObsidianUI — Install Tailwind CSS

[Canonical page](https://www.obsidianui.dev/docs/install-tailwind) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

Configure Tailwind CSS in your Next.js project.

***

## Install Packages

If you already have a Next.js project without Tailwind CSS, install it:

```bash
npm install tailwindcss @tailwindcss/postcss postcss
```

## Configure PostCSS

Create or update your `postcss.config.mjs` file:

```javascript
export default {
plugins: {
  "@tailwindcss/postcss": {},
},
};
```

## Add CSS Import

Add the Tailwind CSS import to your global CSS file (`src/app/globals.css`):

```css
@import "tailwindcss";
```


---

# ObsidianUI — Add utilities

[Canonical page](https://www.obsidianui.dev/docs/add-utilities) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

Set up utility functions for Obsidian UI components.

***

## Create cn Helper

Create a `cn` helper function in your utilities file (`lib/utils.ts` or `src/lib/utils.ts`):

```typescript
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```

## Install Dependencies

Install the required packages:

```bash
npm install motion clsx tailwind-merge lucide-react
```

This utility combines Tailwind CSS classes intelligently, handling conflicts and duplicates automatically.


---

# ObsidianUI — CLI

[Canonical page](https://www.obsidianui.dev/docs/cli) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

## MCP Server

The shadcn MCP Server allows AI assistants to interact with items from registries. You can browse available components, search for specific ones, and install them directly into your project using natural language.

### Configuration

Add the Obsidian UI registry to your project's `components.json` file.

```json
{
"registries": {
  "@obsidian": "https://www.obsidianui.dev/r/{name}.json"
}
}
```

### Initialization

Add the MCP server to your project:

```bash
npx shadcn@latest mcp init
```

### Client Setup

Configure for your AI assistant (e.g., Cursor):

```bash
npx shadcn@latest mcp init --client cursor
```


---

# ObsidianUI — Active Sessions

[Canonical page](https://www.obsidianui.dev/docs/active-sessions) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A settings list of the devices signed in to an account. Sign out one device and its row folds away. Sign out every other device and the rows fold shut one after another. The current device stays at the top and can't be signed out from the list.

Try it below. Each sign-out waits briefly, like a real request, and **Restore demo sessions** brings the list back.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/active-sessions)

```tsx
'use client'

import { ActiveSessions, type ActiveSession } from '@/components/block/active-sessions'

const sessions: ActiveSession[] = [
{ id: 'macbook', device: 'laptop', browser: 'Arc', os: 'macOS', location: 'Ahmedabad, India', ip: '203.0.113.24', lastActiveAt: Date.now(), current: true },
{ id: 'windows-desktop', device: 'desktop', browser: 'Chrome', os: 'Windows 11', location: 'Mumbai, India', ip: '198.51.100.73', lastActiveAt: Date.now() - 2 * 60_000 },
{ id: 'linux-laptop', device: 'laptop', browser: 'Firefox', os: 'Linux', location: 'Berlin, Germany', ip: '192.0.2.158', lastActiveAt: Date.now() - 26 * 3_600_000, flag: 'New location' },
]

export function Demo() {
return (
  <ActiveSessions
    defaultSessions={sessions}
    onRevoke={session => fetch('/api/sessions/' + session.id, { method: 'DELETE' })}
  />
)
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/active-sessions.json"
```

## Usage

```tsx
import { ActiveSessions } from '@/components/block/active-sessions'

<ActiveSessions
  defaultSessions={[
    { id: 'this-mac', device: 'laptop', browser: 'Chrome', os: 'macOS', lastActiveAt: new Date(), current: true },
    { id: 'phone', device: 'phone', browser: 'Safari', os: 'iOS 19', location: 'Berlin, Germany', lastActiveAt: '2026-10-03T18:20:00Z' },
  ]}
/>
```

Without `onRevoke` or `onRevokeOthers`, signing out only updates the list on screen. Add those handlers to connect it to your backend.

## Connect your API

`onRevoke` and `onRevokeOthers` can return a promise. The row stays in place with a spinner while the request runs, folds away when it resolves, and shows an error in place when it rejects, so nothing disappears unless the server confirms it.

```tsx
async function deleteSession(id: string) {
  const response = await fetch(`/api/sessions/${id}`, { method: 'DELETE' })
  if (!response.ok) throw new Error('Sign-out failed')
}

<ActiveSessions
  defaultSessions={sessions}
  onRevoke={session => deleteSession(session.id)}
  onRevokeOthers={async others => {
    const response = await fetch('/api/sessions/others', { method: 'DELETE' })
    if (!response.ok) throw new Error('Sign-out failed')
  }}
/>
```

When `onRevokeOthers` is missing, **Sign out other sessions** calls `onRevoke` for each session instead. Sessions that sign out leave the list, and any that fail stay behind with an error so the user can try again.

## Control the list

Pass `sessions` and `onSessionsChange` when the list lives in your own state or data cache. Show `loading` during the first request; the skeleton rows match the size of real rows, so the layout doesn't jump when data arrives.

```tsx
const [sessions, setSessions] = useState<ActiveSession[]>([])
const [loading, setLoading] = useState(true)

useEffect(() => {
  fetch('/api/sessions')
    .then(response => response.json())
    .then(setSessions)
    .finally(() => setLoading(false))
}, [])

<ActiveSessions sessions={sessions} onSessionsChange={setSessions} loading={loading} onRevoke={session => deleteSession(session.id)} />
```

## Behavior

- The current session is listed first, followed by the most recently active.
- Sessions active within `activeWithin` minutes show **Active now**. Other times read like "Last active 3 hours ago", update every 30 seconds, and render only in the browser, so server and client markup always match.
- **Sign out other sessions** asks for confirmation first. The confirm button receives focus, and Escape cancels.
- After a sign-out, focus moves to the next row's button, or to the heading when no rows remain. If focus moved elsewhere while the request ran, it stays there. A screen reader announcement confirms each sign-out.
- In narrow containers, the action moves under the heading and the status moves to its own line. This uses a container query, so it adapts to the column it sits in, not the window.
- The fold-away animation becomes a short fade when reduced motion is preferred, and spinners appear only for requests slower than 150ms.

## Customize colors

The list uses your theme's card, border, and text colors. Status colors are CSS variables you can override on the component or any parent:

```css
.obsidian-active-sessions {
  --obsidian-active-sessions-success: #15803d;
  --obsidian-active-sessions-warning: #a16207;
  --obsidian-active-sessions-danger: #b91c1c;
  --obsidian-active-sessions-danger-solid: #dc2626;
}
```

## Install manually — complete source

Download the complete manifest: [active-sessions.json](https://www.obsidianui.dev/r/active-sessions.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx lucide-react motion tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/active-sessions.css

Installation target: `@components/block/active-sessions.css`

```css
.obsidian-active-sessions {
  --obsidian-active-sessions-success: oklch(0.52 0.13 152);
  --obsidian-active-sessions-warning: oklch(0.52 0.12 65);
  --obsidian-active-sessions-danger: oklch(0.55 0.2 27);
  --obsidian-active-sessions-danger-solid: oklch(0.577 0.215 27);
}

.dark .obsidian-active-sessions {
  --obsidian-active-sessions-success: oklch(0.76 0.15 152);
  --obsidian-active-sessions-warning: oklch(0.82 0.13 80);
  --obsidian-active-sessions-danger: oklch(0.72 0.17 25);
  --obsidian-active-sessions-danger-solid: oklch(0.6 0.21 27);
}

.obsidian-active-sessions__ping {
  animation: obsidian-active-sessions-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}

.obsidian-active-sessions__pulse {
  animation: obsidian-active-sessions-pulse 1.6s ease-in-out infinite;
}

@keyframes obsidian-active-sessions-ping {
  0% {
    opacity: 0.6;
    transform: scale(1);
  }

  75%,
  100% {
    opacity: 0;
    transform: scale(2.4);
  }
}

@keyframes obsidian-active-sessions-pulse {
  0%,
  100% {
    opacity: 1;
  }

  50% {
    opacity: 0.45;
  }
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-active-sessions__ping,
  .obsidian-active-sessions__pulse {
    animation: none;
  }
}
```

### components/block/active-sessions.tsx

Installation target: `@components/block/active-sessions.tsx`

```tsx
"use client";

import { Laptop, LoaderCircle, Monitor, Smartphone, Tablet } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { ComponentProps, ReactNode, Ref } from "react";
import { cn } from "@/lib/utils";
import "./active-sessions.css";

export type ActiveSessionDevice = "desktop" | "laptop" | "phone" | "tablet";

export type ActiveSession = {
  id: string;
  device: ActiveSessionDevice;
  /** "Chrome", "Safari", "ObsidianUI for iOS". */
  browser?: string;
  /** "macOS", "Windows 11", "iOS 19". */
  os?: string;
  /** Overrides "Browser on OS" as the row title. */
  name?: string;
  /** "Berlin, Germany". */
  location?: string;
  ip?: string;
  lastActiveAt: Date | string | number;
  /** The session in use right now. Listed first and cannot be signed out from this list. */
  current?: boolean;
  /** A short warning tag, such as "New location". */
  flag?: string;
};

const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const EASE_IN = [0.55, 0, 1, 0.45] as const;
const EASE_IN_OUT = [0.65, 0, 0.35, 1] as const;
const MINUTE_MS = 60_000;
const SLOW_REQUEST_MS = 150;

const getSessionLabel = (session: ActiveSession) =>
  session.name ?? ([session.browser, session.os].filter(Boolean).join(" on ") || "Unknown device");

const getLastActiveTime = (session: ActiveSession) => new Date(session.lastActiveAt).getTime();

/** The current device first, then the most recently active. */
const sortSessions = (sessions: ActiveSession[]) =>
  [...sessions].sort(
    (a, b) => Number(!!b.current) - Number(!!a.current) || getLastActiveTime(b) - getLastActiveTime(a),
  );

const sessionNoun = (count: number) => (count === 1 ? "session" : "sessions");

// One clock for every list on the page. It ticks every 30 seconds while the tab is visible
// and catches up when the tab returns. The server has no "now", so relative times render on the client only.
let sharedNow = 0;
let sharedNowTimer: number | undefined;
const sharedNowSubscribers = new Set<() => void>();

const publishNow = () => {
  sharedNow = Date.now();
  sharedNowSubscribers.forEach((notify) => notify());
};

const refreshNowOnReturn = () => {
  if (!document.hidden) publishNow();
};

function subscribeToNow(notify: () => void) {
  sharedNowSubscribers.add(notify);
  if (sharedNowSubscribers.size === 1) {
    sharedNow = Date.now();
    sharedNowTimer = window.setInterval(refreshNowOnReturn, 30_000);
    document.addEventListener("visibilitychange", refreshNowOnReturn);
  }
  return () => {
    sharedNowSubscribers.delete(notify);
    if (sharedNowSubscribers.size === 0) {
      window.clearInterval(sharedNowTimer);
      document.removeEventListener("visibilitychange", refreshNowOnReturn);
    }
  };
}

function useSharedNow() {
  return useSyncExternalStore(subscribeToNow, () => sharedNow || (sharedNow = Date.now()), () => null);
}

function useSessionTimeFormat(locale?: string) {
  return useMemo(() => {
    const relativeTime = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
    const shortDate = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
    const shortDateWithYear = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short", year: "numeric" });
    const fullDate = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });

    return {
      formatRelative(time: number, now: number) {
        const seconds = Math.round((time - now) / 1000);
        const distance = Math.abs(seconds);
        if (distance < 3600) return relativeTime.format(Math.round(seconds / 60), "minute");
        if (distance < 86_400) return relativeTime.format(Math.round(seconds / 3600), "hour");
        if (distance < 86_400 * 7) return relativeTime.format(Math.round(seconds / 86_400), "day");
        return (time < now - 86_400_000 * 300 ? shortDateWithYear : shortDate).format(time);
      },
      formatFull: (time: number) => fullDate.format(time),
    };
  }, [locale]);
}

type SessionTimeFormat = ReturnType<typeof useSessionTimeFormat>;

function useControllableState<T>(value: T | undefined, defaultValue: T, onChange?: (next: T) => void) {
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const isControlled = value !== undefined;
  const setValue = useCallback(
    (next: T) => {
      if (!isControlled) setUncontrolledValue(next);
      onChange?.(next);
    },
    [isControlled, onChange],
  );
  return [isControlled ? value : uncontrolledValue, setValue] as const;
}

function DeviceIcon({ device }: { device: ActiveSessionDevice }) {
  const iconProps = { size: 16, strokeWidth: 1.6, "aria-hidden": true } as const;
  switch (device) {
    case "laptop":
      return <Laptop {...iconProps} />;
    case "phone":
      return <Smartphone {...iconProps} />;
    case "tablet":
      return <Tablet {...iconProps} />;
    default:
      return <Monitor {...iconProps} />;
  }
}

export type ActiveSessionsProps = Omit<ComponentProps<"section">, "title" | "defaultValue" | "onChange"> & {
  /** Controlled list of sessions. */
  sessions?: ActiveSession[];
  /** Initial sessions when uncontrolled. */
  defaultSessions?: ActiveSession[];
  onSessionsChange?: (sessions: ActiveSession[]) => void;
  /** Signs one session out. The row folds away when it resolves and shows an error if it rejects. */
  onRevoke?: (session: ActiveSession) => void | Promise<unknown>;
  /** Signs out every session except the current one. Falls back to onRevoke for each session. */
  onRevokeOthers?: (sessions: ActiveSession[]) => void | Promise<unknown>;
  /** Shows skeleton rows while the first load is in flight. */
  loading?: boolean;
  heading?: ReactNode;
  /** Heading element to match the surrounding page outline. */
  headingLevel?: 2 | 3 | 4;
  description?: ReactNode;
  /** Minutes within which a session counts as "Active now". */
  activeWithin?: number;
  /** Locale for relative and full dates. Defaults to the browser locale. */
  locale?: string;
};

export function ActiveSessions({
  sessions: sessionsProp,
  defaultSessions = [],
  onSessionsChange,
  onRevoke,
  onRevokeOthers,
  loading = false,
  heading = "Active sessions",
  headingLevel = 3,
  description = "Devices signed in to your account. Sign out any you don’t recognize.",
  activeWithin = 5,
  locale,
  className,
  ...rest
}: ActiveSessionsProps) {
  const headingId = useId();
  const reduceMotion = !!useReducedMotion();
  const now = useSharedNow();
  const timeFormat = useSessionTimeFormat(locale);
  const [sessions, setSessions] = useControllableState(sessionsProp, defaultSessions, onSessionsChange);
  const [pendingIds, setPendingIds] = useState<ReadonlySet<string>>(() => new Set());
  const [rowErrors, setRowErrors] = useState<Record<string, string>>({});
  const [isConfirmingRevokeOthers, setIsConfirmingRevokeOthers] = useState(false);
  const [isRevokingOthers, setIsRevokingOthers] = useState(false);
  const [revokeOthersError, setRevokeOthersError] = useState<string | null>(null);
  // Exit order for a sign-out-everywhere cascade. Single sign-outs have no entry and leave at once.
  const [exitOrder, setExitOrder] = useState<ReadonlyMap<string, number>>(() => new Map());
  const [announcement, setAnnouncement] = useState("");
  const headerRef = useRef<HTMLElement>(null);
  const headingRef = useRef<HTMLHeadingElement>(null);
  const revokeButtons = useRef(new Map<string, HTMLButtonElement>());
  // Where focus lands once removed rows finish leaving. No sessionId means the heading.
  const focusAfterExit = useRef<{ sessionId?: string } | null>(null);
  const restoreActionFocus = useRef(false);
  // Overlapping sign-outs each remove their row from the newest list, not the one they started with.
  const latestSessions = useRef(sessions);
  useEffect(() => {
    latestSessions.current = sessions;
  });

  const orderedSessions = useMemo(() => sortSessions(sessions), [sessions]);
  const otherSessions = useMemo(() => orderedSessions.filter((session) => !session.current), [orderedSessions]);
  const showConfirmation = isConfirmingRevokeOthers && otherSessions.length > 0;
  const HeadingTag = `h${headingLevel}` as "h2" | "h3" | "h4";

  const registerRevokeButton = useCallback((id: string, button: HTMLButtonElement | null) => {
    if (button) revokeButtons.current.set(id, button);
    else revokeButtons.current.delete(id);
  }, []);

  // Updates the ref before the next render, so sign-outs that settle together each see the other's removal.
  const commitSessions = (next: ActiveSession[]) => {
    latestSessions.current = next;
    setSessions(next);
  };

  // Focus moves only when it would disappear with the removed rows or the header action,
  // never away from wherever the user put it while the request ran.
  const isLosingFocus = (removed: ActiveSession[], actionLeaves: boolean) => {
    const focused = document.activeElement;
    if (!focused || focused === document.body) return true;
    if (actionLeaves && headerRef.current?.contains(focused)) return true;
    return removed.some((session) => revokeButtons.current.get(session.id) === focused);
  };

  const setRowError = (id: string, message: string | null) =>
    setRowErrors((previous) => {
      if (!message && !(id in previous)) return previous;
      const next = { ...previous };
      if (message) next[id] = message;
      else delete next[id];
      return next;
    });

  const revokeSession = async (session: ActiveSession) => {
    setRowError(session.id, null);
    setPendingIds((previous) => new Set(previous).add(session.id));
    try {
      await onRevoke?.(session);
      if (isLosingFocus([session], false)) {
        const others = sortSessions(latestSessions.current).filter((item) => !item.current);
        const index = others.findIndex((other) => other.id === session.id);
        focusAfterExit.current = { sessionId: (others[index + 1] ?? others[index - 1])?.id };
      }
      setExitOrder(new Map());
      commitSessions(latestSessions.current.filter((item) => item.id !== session.id));
      setAnnouncement(`Signed out ${getSessionLabel(session)}`);
    } catch {
      setRowError(session.id, "Couldn’t sign out this session. Try again.");
    } finally {
      setPendingIds((previous) => {
        const next = new Set(previous);
        next.delete(session.id);
        return next;
      });
    }
  };

  /** Resolves with the sessions that could not be signed out. */
  const signOutSessions = async (targets: ActiveSession[]) => {
    if (onRevokeOthers) {
      await onRevokeOthers(targets);
      return [];
    }
    // One request per session: the ones that succeed leave the list even if others fail.
    const results = await Promise.allSettled(targets.map((session) => onRevoke?.(session)));
    return targets.filter((_, index) => results[index].status === "rejected");
  };

  const revokeOtherSessions = async () => {
    const targets = otherSessions;
    setRevokeOthersError(null);
    setIsRevokingOthers(true);
    try {
      const failed = await signOutSessions(targets);
      const revoked = targets.filter((session) => !failed.includes(session));
      if (revoked.length > 0) {
        const revokedIds = new Set(revoked.map((session) => session.id));
        setExitOrder(new Map(revoked.map((session, index) => [session.id, index])));
        setRowErrors({});
        commitSessions(latestSessions.current.filter((item) => !revokedIds.has(item.id)));
        setAnnouncement(`Signed out ${revoked.length} other ${sessionNoun(revoked.length)}`);
      }
      if (failed.length > 0) {
        // The confirmation stays open over the sessions that are left, ready for another try.
        setRevokeOthersError(`Couldn’t sign out ${failed.length} ${sessionNoun(failed.length)}. Try again.`);
      } else {
        if (isLosingFocus(revoked, true)) focusAfterExit.current = {};
        setIsConfirmingRevokeOthers(false);
      }
    } catch {
      setRevokeOthersError("Couldn’t sign out the other sessions. Try again.");
    } finally {
      setIsRevokingOthers(false);
    }
  };

  const restoreFocusAfterExit = () => {
    const target = focusAfterExit.current;
    focusAfterExit.current = null;
    if (!target) return;
    const button = target.sessionId ? revokeButtons.current.get(target.sessionId) : undefined;
    (button ?? headingRef.current)?.focus({ preventScroll: true });
  };

  return (
    <section
      aria-labelledby={headingId}
      aria-busy={loading || undefined}
      data-slot="active-sessions"
      className={cn("obsidian-active-sessions @container flex w-full min-w-0 flex-col gap-3 text-foreground", className)}
      {...rest}
    >
      {/* The heading and the sign-out-everywhere action share a row; in a narrow column the action drops beneath. */}
      <header ref={headerRef} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-2.5 px-0.5 @max-[30rem]:grid-cols-1">
        <div className="flex min-w-0 flex-col gap-0.5">
          <HeadingTag
            ref={headingRef}
            id={headingId}
            tabIndex={-1}
            className="rounded-sm text-[14px] font-medium leading-5 tracking-[-0.015em] outline-none"
          >
            {heading}
          </HeadingTag>
          {description && <p className="text-pretty text-[12.5px] leading-[18px] text-muted-foreground">{description}</p>}
        </div>
        <AnimatePresence initial={false} mode="popLayout">
          {/* Present but disabled while loading, so the header keeps its height when the list arrives. */}
          {(loading || otherSessions.length > 0) && (
            <motion.div
              key={showConfirmation ? "confirm" : "action"}
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4, filter: "blur(2px)", transition: { duration: 0.12 } }}
              transition={{ duration: 0.2, ease: EASE_OUT }}
              className="flex min-w-0 items-center gap-2 justify-self-start @min-[30rem]:justify-self-end"
            >
              {showConfirmation ? (
                <RevokeOthersConfirmation
                  count={otherSessions.length}
                  isPending={isRevokingOthers}
                  onCancel={() => {
                    restoreActionFocus.current = true;
                    setIsConfirmingRevokeOthers(false);
                  }}
                  onConfirm={() => void revokeOtherSessions()}
                />
              ) : (
                <SessionButton
                  variant="secondary"
                  // A single sign-out still in flight would race the bulk request.
                  disabled={loading || pendingIds.size > 0}
                  onClick={() => setIsConfirmingRevokeOthers(true)}
                  // Backing out of the confirmation returns focus to where it started.
                  buttonRef={(button) => {
                    if (button && restoreActionFocus.current) {
                      restoreActionFocus.current = false;
                      button.focus({ preventScroll: true });
                    }
                  }}
                >
                  Sign out other sessions
                </SessionButton>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </header>

      {revokeOthersError && (
        <p
          role="alert"
          className="rounded-lg bg-(--obsidian-active-sessions-danger)/10 px-3 py-2 text-[12.5px] leading-[18px] text-(--obsidian-active-sessions-danger)"
        >
          {revokeOthersError}
        </p>
      )}

      <div className="overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-xs">
        {loading ? (
          <ul aria-label="Loading sessions" className="flex flex-col">
            {[0, 1, 2].map((index) => (
              <li key={index} className="flex gap-3 border-t border-border px-4 py-3 first:border-t-0">
                {/* Shaped like a real row, so nothing moves when the sessions arrive. */}
                <span className="obsidian-active-sessions__pulse mt-0.5 size-8 shrink-0 self-start rounded-lg bg-foreground/[0.06]" />
                <span className="flex flex-1 flex-col gap-0.5">
                  <span className="flex h-5 items-center">
                    <span className="obsidian-active-sessions__pulse h-3 w-36 rounded-sm bg-foreground/[0.06]" />
                  </span>
                  <span className="flex h-4 items-center">
                    <span className="obsidian-active-sessions__pulse h-2.5 w-52 max-w-full rounded-sm bg-foreground/[0.05]" />
                  </span>
                </span>
              </li>
            ))}
          </ul>
        ) : (
          <ul aria-labelledby={headingId} className="flex flex-col">
            <AnimatePresence initial={false} custom={exitOrder} onExitComplete={restoreFocusAfterExit}>
              {orderedSessions.map((session) => (
                <motion.li
                  key={session.id}
                  data-session={session.id}
                  custom={exitOrder}
                  initial={false}
                  exit="signedOut"
                  variants={{
                    // Signing out everywhere folds the rows shut from top to bottom, 45ms apart.
                    signedOut: (order: ReadonlyMap<string, number>) => {
                      const delay = Math.min(order.get(session.id) ?? 0, 8) * 0.045;
                      return reduceMotion
                        ? { opacity: 0, transition: { duration: 0.12, delay: delay / 2 } }
                        : {
                            opacity: 0,
                            height: 0,
                            x: 8,
                            transition: {
                              opacity: { duration: 0.14, delay },
                              x: { duration: 0.18, ease: EASE_IN, delay },
                              height: { duration: 0.26, ease: EASE_IN_OUT, delay: delay + 0.06 },
                            },
                          };
                    },
                  }}
                  className="overflow-hidden border-t border-border first:border-t-0"
                >
                  <SessionRow
                    session={session}
                    now={now}
                    timeFormat={timeFormat}
                    activeWithin={activeWithin}
                    isPending={pendingIds.has(session.id) || (isRevokingOthers && !session.current)}
                    error={rowErrors[session.id]}
                    onRevoke={() => void revokeSession(session)}
                    revokeButtonRef={(button) => registerRevokeButton(session.id, button)}
                  />
                </motion.li>
              ))}
            </AnimatePresence>
          </ul>
        )}
        <AnimatePresence initial={false}>
          {!loading && otherSessions.length === 0 && (
            <motion.p
              key="only-this-device"
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
              animate={{ opacity: 1, height: "auto" }}
              transition={{ height: { duration: 0.24, ease: EASE_IN_OUT, delay: 0.2 }, opacity: { duration: 0.2, delay: 0.3 } }}
              className="overflow-hidden text-[12.5px] text-muted-foreground"
            >
              <span className={cn("block px-4 py-3", orderedSessions.length > 0 && "border-t border-border")}>
                {orderedSessions.length > 0 ? "No other sessions. You’re only signed in here." : "No active sessions."}
              </span>
            </motion.p>
          )}
        </AnimatePresence>
      </div>

      <span role="status" aria-live="polite" className="sr-only">
        {announcement}
      </span>
    </section>
  );
}

function SessionRow({
  session,
  now,
  timeFormat,
  activeWithin,
  isPending,
  error,
  onRevoke,
  revokeButtonRef,
}: {
  session: ActiveSession;
  now: number | null;
  timeFormat: SessionTimeFormat;
  activeWithin: number;
  isPending: boolean;
  error?: string;
  onRevoke: () => void;
  revokeButtonRef: Ref<HTMLButtonElement>;
}) {
  const errorId = useId();
  const lastActiveTime = getLastActiveTime(session);
  const isActive = session.current || (now !== null && now - lastActiveTime < activeWithin * MINUTE_MS);
  const label = getSessionLabel(session);
  const hasPlace = Boolean(session.location || session.ip);

  return (
    <div
      data-current={session.current || undefined}
      data-active={isActive || undefined}
      className="flex min-w-0 items-start gap-3 px-4 py-3"
    >
      <span
        aria-hidden
        className="relative mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-foreground/[0.04] text-foreground/75 shadow-[inset_0_0_0_1px_var(--border)]"
      >
        <DeviceIcon device={session.device} />
      </span>

      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <p className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[13px] font-medium leading-5">
          <span className="min-w-0">{label}</span>
          {session.current && (
            <span className="rounded-full bg-foreground/[0.07] px-1.5 py-px text-[11px] font-medium leading-4 text-foreground/75">
              This device
            </span>
          )}
          {session.flag && (
            <span className="rounded-full bg-(--obsidian-active-sessions-warning)/12 px-1.5 py-px text-[11px] font-medium leading-4 text-(--obsidian-active-sessions-warning)">
              {session.flag}
            </span>
          )}
        </p>
        <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-[12px] leading-4 text-muted-foreground">
          {hasPlace && (
            // The place stays on one line; when space runs out the address trims instead of leaving a dangling dot.
            <span
              className="flex min-w-0 max-w-full items-center gap-x-1.5 overflow-hidden whitespace-nowrap"
              title={[session.location, session.ip].filter(Boolean).join(" · ")}
            >
              {session.location && <span className="shrink-0">{session.location}</span>}
              {session.location && session.ip && <MetaSeparator />}
              {session.ip && <span className="min-w-0 truncate font-mono text-[11px] tracking-[0.01em]">{session.ip}</span>}
            </span>
          )}
          {/* In a narrow column the status takes its own line instead of leaving a dot at a line end. */}
          {hasPlace && <MetaSeparator className="@max-[30rem]:hidden" />}
          {isActive ? (
            <span className="inline-flex items-center gap-1.5 text-(--obsidian-active-sessions-success) @max-[30rem]:basis-full">
              <span aria-hidden className="relative grid size-1.5 place-items-center">
                <span className="obsidian-active-sessions__ping absolute inset-0 rounded-full bg-(--obsidian-active-sessions-success)/70" />
                <span className="size-1.5 rounded-full bg-(--obsidian-active-sessions-success)" />
              </span>
              Active now
            </span>
          ) : (
            <time
              dateTime={now !== null ? new Date(lastActiveTime).toISOString() : undefined}
              title={now !== null ? timeFormat.formatFull(lastActiveTime) : undefined}
              className="tabular-nums @max-[30rem]:basis-full"
            >
              {now !== null ? `Last active ${timeFormat.formatRelative(lastActiveTime, now)}` : "\u00a0"}
            </time>
          )}
        </p>
        {error && (
          <p id={errorId} role="alert" className="pt-1 text-[12px] leading-4 text-(--obsidian-active-sessions-danger)">
            {error}
          </p>
        )}
      </div>

      {!session.current && (
        <SessionButton
          buttonRef={revokeButtonRef}
          variant="ghost"
          isPending={isPending}
          onClick={onRevoke}
          aria-label={`Sign out ${label}${session.location ? `, ${session.location}` : ""}`}
          aria-describedby={error ? errorId : undefined}
          className="-mr-1.5 mt-0.5"
        >
          Sign out
        </SessionButton>
      )}
    </div>
  );
}

const MetaSeparator = ({ className }: { className?: string }) => (
  <span aria-hidden className={cn("text-muted-foreground/60", className)}>
    ·
  </span>
);

function SessionButton({
  variant,
  isPending = false,
  buttonRef,
  className,
  children,
  onClick,
  ...props
}: Omit<ComponentProps<"button">, "type" | "ref"> & {
  variant: "secondary" | "ghost" | "danger";
  isPending?: boolean;
  buttonRef?: Ref<HTMLButtonElement>;
}) {
  // The spinner only appears once a request runs past SLOW_REQUEST_MS, so quick sign-outs never flash it.
  const [isSlow, setIsSlow] = useState(false);
  useEffect(() => {
    if (!isPending) return;
    const timer = window.setTimeout(() => setIsSlow(true), SLOW_REQUEST_MS);
    return () => {
      window.clearTimeout(timer);
      setIsSlow(false);
    };
  }, [isPending]);
  const showSpinner = isPending && isSlow;

  return (
    <button
      ref={buttonRef}
      type="button"
      aria-busy={isPending || undefined}
      data-variant={variant}
      onClick={isPending ? undefined : onClick}
      className={cn(
        "relative inline-grid h-7 shrink-0 select-none place-items-center rounded-md px-2.5 text-[12.5px] font-medium outline-none",
        "transition-[background-color,border-color,color,scale] duration-150 active:scale-[0.97] active:duration-75 aria-busy:active:scale-100",
        "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid",
        // A taller touch target on coarse pointers without changing the visual size.
        "before:absolute before:inset-x-0 before:-inset-y-2 before:content-[''] pointer-fine:before:hidden",
        variant === "secondary" &&
          "border border-border bg-background text-foreground shadow-xs enabled:hover:bg-accent enabled:hover:text-accent-foreground disabled:opacity-50",
        variant === "ghost" &&
          (isPending
            ? "text-muted-foreground"
            : "text-muted-foreground hover:bg-(--obsidian-active-sessions-danger)/10 hover:text-(--obsidian-active-sessions-danger)"),
        variant === "danger" &&
          "bg-(--obsidian-active-sessions-danger-solid) text-white shadow-xs hover:bg-(--obsidian-active-sessions-danger-solid)/90 disabled:opacity-60",
        className,
      )}
      {...props}
    >
      {/* The label keeps its place under the spinner, so the button never changes width. */}
      <span className={cn("col-start-1 row-start-1 transition-opacity duration-150", showSpinner && "opacity-0")}>{children}</span>
      {showSpinner && (
        <span className="col-start-1 row-start-1 grid place-items-center">
          <LoaderCircle size={14} className="animate-spin" aria-hidden />
        </span>
      )}
    </button>
  );
}

function RevokeOthersConfirmation({
  count,
  isPending,
  onCancel,
  onConfirm,
}: {
  count: number;
  isPending: boolean;
  onCancel: () => void;
  onConfirm: () => void;
}) {
  const confirmButtonRef = useRef<HTMLButtonElement>(null);
  // Keyboard users land on the confirming action; Escape backs out.
  useEffect(() => {
    confirmButtonRef.current?.focus({ preventScroll: true });
  }, []);

  return (
    <div
      role="group"
      aria-label="Confirm signing out other sessions"
      className="flex flex-wrap items-center gap-2"
      onKeyDown={(event) => {
        if (event.key === "Escape" && !isPending) {
          event.preventDefault();
          onCancel();
        }
      }}
    >
      <span className="text-[12.5px] text-muted-foreground">
        Sign out {count} other {sessionNoun(count)}?
      </span>
      <SessionButton variant="secondary" onClick={onCancel} disabled={isPending}>
        Cancel
      </SessionButton>
      <SessionButton buttonRef={confirmButtonRef} variant="danger" isPending={isPending} onClick={onConfirm}>
        Sign out
      </SessionButton>
    </div>
  );
}

export default ActiveSessions;
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| sessions | ActiveSession[] | - | Controlled list of sessions. |
| defaultSessions | ActiveSession[] | [] | Initial sessions when the list is uncontrolled. |
| onSessionsChange | (sessions: ActiveSession[]) => void | - | Called with the remaining sessions after each sign-out. |
| onRevoke | (session: ActiveSession) => void \| Promise<unknown> | - | Signs out one session. Reject to keep the row and show an error. |
| onRevokeOthers | (sessions: ActiveSession[]) => void \| Promise<unknown> | - | Signs out every session except the current one. Falls back to onRevoke for each. |
| loading | boolean | false | Shows skeleton rows while the first load is in flight. |
| heading | ReactNode | Active sessions | Title above the list. |
| headingLevel | 2 \| 3 \| 4 | 3 | Heading element for the title, to fit the page outline. |
| description | ReactNode | Devices signed in to your account… | Supporting text under the title. Pass null to hide it. |
| activeWithin | number | 5 | Minutes within which a session counts as Active now. |
| locale | string | Browser locale | Locale for relative and full dates. |
| className | string | - | Additional classes for the root section. |

## Session fields

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| id | string | - | Unique, stable session identifier. |
| device | 'desktop' \| 'laptop' \| 'phone' \| 'tablet' | - | Chooses the device icon. |
| browser | string | - | Browser or app name, such as Chrome. |
| os | string | - | Operating system, such as macOS. |
| name | string | Browser on OS | Overrides the row title. |
| location | string | - | Approximate location, such as Berlin, Germany. |
| ip | string | - | IP address, shown in monospace and trimmed when space runs out. |
| lastActiveAt | Date \| string \| number | - | When the session was last used. |
| current | boolean | false | Marks this device. It is listed first and cannot be signed out here. |
| flag | string | - | A short warning tag, such as New location. |


---

# ObsidianUI — Art Gallery

[Canonical page](https://www.obsidianui.dev/docs/art-gallery) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A lensed photo grid you can drag through, with barrel distortion and infinite tiled studies.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/art-gallery)

Drag inside the preview to pan the gallery.

```tsx
import { ArtGallery } from "@/components/block/art-gallery";

export default function Demo() {
  return <ArtGallery className="h-[400px]" />;
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/art-gallery.json"
```

## Usage

```jsx
import { ArtGallery } from "@/components/block/art-gallery";

export default function Demo() {
  return <ArtGallery className="h-[400px]" />;
}
```

## Install manually — complete source

Download the complete manifest: [art-gallery.json](https://www.obsidianui.dev/r/art-gallery.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx motion tailwind-merge three
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

### components/block/art-gallery.jsx

Installation target: `@components/block/art-gallery.jsx`

```jsx
"use client";

import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { WebGLSurface, useEffectReducedMotion } from "@/lib/effects/shared/webgl-surface";
import { cn } from "@/lib/utils";
import { LoaderGooeyBlobs } from "@/components/ui/loaders-gooey-blobs";

const defaultConfig = {
  cellSize: 0.75,
  zoomLevel: 1.25,
  lerpFactor: 0.075,
  borderColor: "rgba(255, 255, 255, 0.15)",
  backgroundColor: "rgba(0, 0, 0, 1)",
  textColor: "rgba(128, 128, 128, 1)",
  hoverColor: "rgba(255, 255, 255, 0)",
};

const defaultItems = [
  { title: "Motion Study", year: 2024 },
  { title: "Idle Form", year: 2023 },
  { title: "Blur Signal", year: 2024 },
  { title: "Still Drift", year: 2023 },
  { title: "Tidewalk", year: 2024 },
  { title: "Core Motion", year: 2022 },
  { title: "White Bloom", year: 2024 },
  { title: "Backrun", year: 2023 },
  { title: "Rushline", year: 2024 },
  { title: "Afterimage", year: 2023 },
  { title: "Shadowhead", year: 2022 },
  { title: "Opal Lace", year: 2024 },
  { title: "Glassprint", year: 2024 },
  { title: "Redshift", year: 2023 },
  { title: "White Noise", year: 2023 },
  { title: "Twin Field", year: 2024 },
  { title: "Petalloop", year: 2023 },
  { title: "Ghostwalk", year: 2024 },
  { title: "Heatwave", year: 2023 },
  { title: "Sky Drift", year: 2024 },
  { title: "Spindle", year: 2022 },
  { title: "Pacer", year: 2023 },
  { title: "Stride", year: 2024 },
  { title: "Cryo Pulse", year: 2022 },
  { title: "Velvet Blur", year: 2024 },
];

const defaultImages = [
  "https://cdn-new.obsidianui.dev/imagess/1.png",
  "https://cdn-new.obsidianui.dev/imagess/2.png",
  "https://cdn-new.obsidianui.dev/imagess/3.png",
  "https://cdn-new.obsidianui.dev/imagess/4.png",
  "https://cdn-new.obsidianui.dev/imagess/5.png",
  "https://cdn-new.obsidianui.dev/imagess/6.png",
  "https://cdn-new.obsidianui.dev/imagess/7.png",
  "https://cdn-new.obsidianui.dev/imagess/8.png",
  "https://cdn-new.obsidianui.dev/imagess/9.png",
  "https://cdn-new.obsidianui.dev/imagess/10.png",
  "https://cdn-new.obsidianui.dev/imagess/11.png",
  "https://cdn-new.obsidianui.dev/imagess/12.png",
  "https://cdn-new.obsidianui.dev/imagess/13.png",
  "https://cdn-new.obsidianui.dev/imagess/14.png",
  "https://cdn-new.obsidianui.dev/imagess/15.png",
  "https://cdn-new.obsidianui.dev/imagess/16.png",
  "https://cdn-new.obsidianui.dev/imagess/17.png",
  "https://cdn-new.obsidianui.dev/imagess/18.png",
  "https://cdn-new.obsidianui.dev/imagess/19.png",
  "https://cdn-new.obsidianui.dev/imagess/20.jpg",
  "https://cdn-new.obsidianui.dev/imagess/21.jpg",
  "https://cdn-new.obsidianui.dev/imagess/22.jpg",
  "https://cdn-new.obsidianui.dev/imagess/23.jpg",
  "https://cdn-new.obsidianui.dev/imagess/24.jpg",
  "https://cdn-new.obsidianui.dev/imagess/25.jpg",
];

const vertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  uniform vec2 uOffset;
  uniform vec2 uResolution;
  uniform vec4 uBorderColor;
  uniform vec4 uHoverColor;
  uniform vec4 uBackgroundColor;
  uniform vec2 uMousePos;
  uniform float uZoom;
  uniform float uCellSize;
  uniform float uTextureCount;
  uniform sampler2D uImageAtlas;
  uniform sampler2D uTextAtlas;
  varying vec2 vUv;

  void main() {
    vec2 screenUV = (vUv - 0.5) * 2.0;
    float radius = length(screenUV);
    float distortion = 1.0 - 0.08 * radius * radius;
    vec2 distortedUV = screenUV * distortion;
    vec2 aspectRatio = vec2(uResolution.x / uResolution.y, 1.0);
    vec2 worldCoord = distortedUV * aspectRatio;
    worldCoord *= uZoom;
    worldCoord += uOffset;
    vec2 cellPos = worldCoord / uCellSize;
    vec2 cellId = floor(cellPos);
    vec2 cellUV = fract(cellPos);
    vec2 mouseScreenUV = (uMousePos / uResolution) * 2.0 - 1.0;
    mouseScreenUV.y = -mouseScreenUV.y;
    float mouseRadius = length(mouseScreenUV);
    float mouseDistortion = 1.0 - 0.08 * mouseRadius * mouseRadius;
    vec2 mouseDistortedUV = mouseScreenUV * mouseDistortion;
    vec2 mouseWorldCoord = mouseDistortedUV * aspectRatio;
    mouseWorldCoord *= uZoom;
    mouseWorldCoord += uOffset;
    vec2 mouseCellPos = mouseWorldCoord / uCellSize;
    vec2 mouseCellId = floor(mouseCellPos);
    vec2 cellCenter = cellId + 0.5;
    vec2 mouseCellCenter = mouseCellId + 0.5;
    float cellDistance = length(cellCenter - mouseCellCenter);
    float hoverIntensity = 1.0 - smoothstep(0.4, 0.7, cellDistance);
    bool isHovered = hoverIntensity > 0.0 && uMousePos.x >= 0.0;
    vec3 backgroundColor = uBackgroundColor.rgb;
    if (isHovered) {
      backgroundColor = mix(uBackgroundColor.rgb, uHoverColor.rgb, hoverIntensity * uHoverColor.a);
    }
    float lineWidth = 0.005;
    float gridX = smoothstep(0.0, lineWidth, cellUV.x) * smoothstep(0.0, lineWidth, 1.0 - cellUV.x);
    float gridY = smoothstep(0.0, lineWidth, cellUV.y) * smoothstep(0.0, lineWidth, 1.0 - cellUV.y);
    float gridMask = gridX * gridY;
    float imageSize = 0.6;
    float imageBorder = (1.0 - imageSize) * 0.5;
    vec2 imageUV = (cellUV - imageBorder) / imageSize;
    float edgeSmooth = 0.01;
    vec2 imageMask = smoothstep(-edgeSmooth, edgeSmooth, imageUV) *
                    smoothstep(-edgeSmooth, edgeSmooth, 1.0 - imageUV);
    float imageAlpha = imageMask.x * imageMask.y;
    bool inImageArea = imageUV.x >= 0.0 && imageUV.x <= 1.0 && imageUV.y >= 0.0 && imageUV.y <= 1.0;
    float textHeight = 0.08;
    float textY = 0.88;
    bool inTextArea = cellUV.x >= 0.05 && cellUV.x <= 0.95 && cellUV.y >= textY && cellUV.y <= (textY + textHeight);
    float texIndex = mod(cellId.x + cellId.y * 3.0, uTextureCount);
    vec3 color = backgroundColor;
    if (inImageArea && imageAlpha > 0.0) {
      float atlasSize = ceil(sqrt(uTextureCount));
      vec2 atlasPos = vec2(mod(texIndex, atlasSize), floor(texIndex / atlasSize));
      vec2 atlasUV = (atlasPos + imageUV) / atlasSize;
      atlasUV.y = 1.0 - atlasUV.y;
      vec3 imageColor = texture2D(uImageAtlas, atlasUV).rgb;
      color = mix(color, imageColor, imageAlpha);
    }
    if (inTextArea) {
      vec2 textCoord = vec2((cellUV.x - 0.05) / 0.9, (cellUV.y - textY) / textHeight);
      textCoord.y = 1.0 - textCoord.y;
      float atlasSize = ceil(sqrt(uTextureCount));
      vec2 atlasPos = vec2(mod(texIndex, atlasSize), floor(texIndex / atlasSize));
      vec2 atlasUV = (atlasPos + textCoord) / atlasSize;
      vec4 textColor = texture2D(uTextAtlas, atlasUV);
      color = mix(backgroundColor, textColor.rgb, textColor.a);
    }
    vec3 borderRGB = uBorderColor.rgb;
    float borderAlpha = uBorderColor.a;
    color = mix(color, borderRGB, (1.0 - gridMask) * borderAlpha);
    float fade = 1.0 - smoothstep(1.2, 1.8, radius);
    gl_FragColor = vec4(color * fade, 1.0);
  }
`;

function rgbaToArray(rgba) {
  const match = rgba.match(/rgba?\(([^)]+)\)/);
  if (!match) return [1, 1, 1, 1];
  const parts = match[1].split(",");
  return [
    parseFloat(parts[0]) / 255,
    parseFloat(parts[1]) / 255,
    parseFloat(parts[2]) / 255,
    parseFloat(parts[3] ?? "1"),
  ];
}

function createTextTexture(title, year, textColor) {
  const canvas = document.createElement("canvas");
  canvas.width = 2048;
  canvas.height = 256;
  const ctx = canvas.getContext("2d");
  if (ctx) {
    ctx.clearRect(0, 0, 2048, 256);
    ctx.font = "80px monospace";
    ctx.fillStyle = textColor;
    ctx.textBaseline = "middle";
    ctx.imageSmoothingEnabled = false;
    ctx.textAlign = "left";
    ctx.fillText(String(title).toUpperCase(), 30, 128);
    ctx.textAlign = "right";
    ctx.fillText(String(year), 2048 - 30, 128);
  }
  const texture = new THREE.CanvasTexture(canvas);
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  texture.minFilter = THREE.NearestFilter;
  texture.magFilter = THREE.NearestFilter;
  texture.flipY = false;
  texture.generateMipmaps = false;
  return texture;
}

function blankTexture() {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = 512;
  const ctx = canvas.getContext("2d");
  if (ctx) {
    ctx.fillStyle = "#111";
    ctx.fillRect(0, 0, 512, 512);
  }
  return new THREE.CanvasTexture(canvas);
}

function loadImageTexture(src) {
  return new Promise((resolve) => {
    const image = new Image();
    let retries = 0;
    if (/^https?:\/\//.test(src)) image.crossOrigin = "anonymous";
    image.decoding = "async";
    image.onload = async () => {
      try { await image.decode(); } catch {}
      const texture = new THREE.Texture(image);
      texture.wrapS = THREE.ClampToEdgeWrapping;
      texture.wrapT = THREE.ClampToEdgeWrapping;
      texture.minFilter = THREE.LinearFilter;
      texture.magFilter = THREE.LinearFilter;
      texture.flipY = false;
      texture.needsUpdate = true;
      resolve(texture);
    };
    image.onerror = () => {
      if (retries === 0) {
        retries = 1;
        image.src = `${src}${src.includes("?") ? "&" : "?"}retry=1`;
        return;
      }
      resolve(null);
    };
    image.src = src;
  });
}

function createTextureAtlas(textures, isText = false) {
  const atlasSize = Math.ceil(Math.sqrt(textures.length));
  const textureSize = 512;
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = atlasSize * textureSize;
  const ctx = canvas.getContext("2d");
  if (ctx) {
    if (isText) ctx.clearRect(0, 0, canvas.width, canvas.height);
    else {
      ctx.fillStyle = "black";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
    }
    const fallback = textures.find((texture) => texture.source?.data ?? texture.image);
    const fallbackSource = fallback?.source?.data ?? fallback?.image;
    textures.forEach((texture, index) => {
      const x = (index % atlasSize) * textureSize;
      const y = Math.floor(index / atlasSize) * textureSize;
      const src = texture.source?.data ?? texture.image;
      if (!src) return;
      try { ctx.drawImage(src, x, y, textureSize, textureSize); }
      catch {
        if (fallbackSource) ctx.drawImage(fallbackSource, x, y, textureSize, textureSize);
      }
    });
  }
  const atlasTexture = new THREE.CanvasTexture(canvas);
  atlasTexture.wrapS = THREE.ClampToEdgeWrapping;
  atlasTexture.wrapT = THREE.ClampToEdgeWrapping;
  atlasTexture.minFilter = THREE.LinearFilter;
  atlasTexture.magFilter = THREE.LinearFilter;
  atlasTexture.flipY = false;
  return atlasTexture;
}

function ArtGalleryScene({ images, items, cellSize, zoomLevel, showHint, reducedMotion }) {
  const containerRef = useRef(null);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    setReady(false);

    let cancelled = false;
    let animFrameId = 0;
    let renderer;
    let plane;
    let geometry;
    let material;
    let imageAtlas;
    let textAtlas;
    const loadedTextures = [];

    const state = {
      isDragging: false,
      previousPointer: { x: 0, y: 0 },
      offset: { x: 0, y: 0 },
      targetOffset: { x: 0, y: 0 },
      mousePosition: { x: -1, y: -1 },
      zoom: 1,
      targetZoom: 1,
    };

    const scene = new THREE.Scene();
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
    camera.position.z = 1;

    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
    renderer.setSize(container.clientWidth, container.clientHeight);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    const bgColor = rgbaToArray(defaultConfig.backgroundColor);
    renderer.setClearColor(new THREE.Color(bgColor[0], bgColor[1], bgColor[2]), bgColor[3]);
    renderer.domElement.style.display = "block";
    renderer.domElement.style.width = "100%";
    renderer.domElement.style.height = "100%";
    renderer.domElement.style.touchAction = "none";
    container.appendChild(renderer.domElement);

    const lerpFactor = reducedMotion ? 1 : defaultConfig.lerpFactor;
    const dragZoom = reducedMotion ? 1 : zoomLevel;

    const animate = () => {
      animFrameId = requestAnimationFrame(animate);
      state.offset.x += (state.targetOffset.x - state.offset.x) * lerpFactor;
      state.offset.y += (state.targetOffset.y - state.offset.y) * lerpFactor;
      state.zoom += (state.targetZoom - state.zoom) * lerpFactor;
      if (plane?.material.uniforms) {
        plane.material.uniforms.uOffset.value.set(state.offset.x, state.offset.y);
        plane.material.uniforms.uZoom.value = state.zoom;
      }
      renderer.render(scene, camera);
    };

    const updateMousePosition = (event) => {
      const rect = renderer.domElement.getBoundingClientRect();
      state.mousePosition.x = event.clientX - rect.left;
      state.mousePosition.y = event.clientY - rect.top;
      plane?.material.uniforms.uMousePos.value.set(state.mousePosition.x, state.mousePosition.y);
    };

    const startDrag = (x, y) => {
      state.isDragging = true;
      state.previousPointer.x = x;
      state.previousPointer.y = y;
    };

    const handleMove = (x, y) => {
      if (!state.isDragging) return;
      const deltaX = x - state.previousPointer.x;
      const deltaY = y - state.previousPointer.y;
      if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
        if (state.targetZoom === 1) state.targetZoom = dragZoom;
      }
      state.targetOffset.x -= deltaX * 0.003;
      state.targetOffset.y += deltaY * 0.003;
      state.previousPointer.x = x;
      state.previousPointer.y = y;
    };

    const endDrag = () => {
      state.isDragging = false;
      state.targetZoom = 1;
    };

    const onPointerDown = (event) => {
      event.preventDefault();
      container.setPointerCapture?.(event.pointerId);
      startDrag(event.clientX, event.clientY);
    };
    const onPointerMove = (event) => {
      updateMousePosition(event);
      handleMove(event.clientX, event.clientY);
    };
    const onPointerUp = (event) => {
      if (container.hasPointerCapture?.(event.pointerId)) container.releasePointerCapture(event.pointerId);
      endDrag();
    };
    const onPointerLeave = () => {
      state.mousePosition.x = state.mousePosition.y = -1;
      plane?.material.uniforms.uMousePos.value.set(-1, -1);
      endDrag();
    };
    const onResize = () => {
      const width = container.clientWidth;
      const height = container.clientHeight;
      if (!width || !height) return;
      camera.updateProjectionMatrix();
      renderer.setSize(width, height);
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
      plane?.material.uniforms.uResolution.value.set(width, height);
    };

    const init = async () => {
      const loadedImages = await Promise.all(images.map((src) => loadImageTexture(src)));
      if (cancelled) {
        loadedImages.forEach((texture) => texture?.dispose());
        return;
      }
      const replacement = loadedImages.find(Boolean) ?? blankTexture();
      const imageTiles = loadedImages.map((texture) => texture ?? replacement);
      loadedTextures.push(...new Set(imageTiles));
      const textTextures = items.map((item) => createTextTexture(item.title, item.year, defaultConfig.textColor));
      loadedTextures.push(...textTextures);
      imageAtlas = createTextureAtlas(imageTiles, false);
      textAtlas = createTextureAtlas(textTextures, true);
      if (cancelled) return;

      const uniforms = {
        uOffset: { value: new THREE.Vector2(0, 0) },
        uResolution: { value: new THREE.Vector2(container.clientWidth, container.clientHeight) },
        uBorderColor: { value: new THREE.Vector4(...rgbaToArray(defaultConfig.borderColor)) },
        uHoverColor: { value: new THREE.Vector4(...rgbaToArray(defaultConfig.hoverColor)) },
        uBackgroundColor: { value: new THREE.Vector4(...rgbaToArray(defaultConfig.backgroundColor)) },
        uMousePos: { value: new THREE.Vector2(-1, -1) },
        uZoom: { value: 1 },
        uCellSize: { value: cellSize },
        uTextureCount: { value: images.length },
        uImageAtlas: { value: imageAtlas },
        uTextAtlas: { value: textAtlas },
      };

      geometry = new THREE.PlaneGeometry(2, 2);
      material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms });
      plane = new THREE.Mesh(geometry, material);
      scene.add(plane);

      container.addEventListener("pointerdown", onPointerDown);
      container.addEventListener("pointermove", onPointerMove);
      container.addEventListener("pointerup", onPointerUp);
      container.addEventListener("pointercancel", onPointerUp);
      container.addEventListener("pointerleave", onPointerLeave);
      window.addEventListener("resize", onResize);
      animate();
      setReady(true);
    };

    init();

    return () => {
      cancelled = true;
      cancelAnimationFrame(animFrameId);
      container.removeEventListener("pointerdown", onPointerDown);
      container.removeEventListener("pointermove", onPointerMove);
      container.removeEventListener("pointerup", onPointerUp);
      container.removeEventListener("pointercancel", onPointerUp);
      container.removeEventListener("pointerleave", onPointerLeave);
      window.removeEventListener("resize", onResize);
      loadedTextures.forEach((texture) => texture.dispose());
      imageAtlas?.dispose();
      textAtlas?.dispose();
      geometry?.dispose();
      material?.dispose();
      renderer?.dispose();
      if (renderer?.domElement?.parentNode === container) container.removeChild(renderer.domElement);
    };
  }, [images, items, cellSize, zoomLevel, reducedMotion]);

  return (
    <div className="absolute inset-0 cursor-grab active:cursor-grabbing" style={{ touchAction: "none" }}>
      <div ref={containerRef} className="absolute inset-0" style={{ opacity: ready ? 1 : 0 }} />
      {!ready ? (
        <div className="absolute inset-0 z-20 flex items-center justify-center bg-black text-white" role="status" aria-live="polite">
          <LoaderGooeyBlobs />
        </div>
      ) : null}
      {ready && showHint ? (
        <div className="pointer-events-none absolute bottom-6 left-1/2 z-10 -translate-x-1/2 font-mono text-[0.6rem] uppercase tracking-[0.2em] text-white/20">
          drag to explore
        </div>
      ) : null}
    </div>
  );
}

/** @param {{ images?: string[], items?: { title: string, year: string | number }[], cellSize?: number, zoomLevel?: number, showHint?: boolean, className?: string, style?: import("react").CSSProperties }} props */
export function ArtGallery({
  images = defaultImages,
  items = defaultItems,
  cellSize = defaultConfig.cellSize,
  zoomLevel = defaultConfig.zoomLevel,
  showHint = true,
  className,
  style,
} = {}) {
  const reducedMotion = useEffectReducedMotion();
  const tiles = images.length ? images : defaultImages;
  const captions = tiles.map((_, index) => items[index % items.length] ?? { title: `Study ${index + 1}`, year: "2024" });

  return (
    <WebGLSurface className={cn("bg-black", className)} style={style} label="ObsidianUI Art Gallery">
      <ArtGalleryScene
        images={tiles}
        items={captions}
        cellSize={cellSize}
        zoomLevel={zoomLevel}
        showHint={showHint}
        reducedMotion={reducedMotion}
      />
    </WebGLSurface>
  );
}
```

### components/ui/loaders-gooey-blobs.tsx

Installation target: `@ui/loaders-gooey-blobs.tsx`

```tsx
"use client";

import { useId } from "react";
import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import { cn } from "@/lib/utils";

export interface LoaderGooeyBlobsProps extends Omit<HTMLMotionProps<"div">, "children"> {
  size?: number;
  color?: string;
  duration?: number;
}

export function LoaderGooeyBlobs({
  className,
  size = 20,
  color = "currentColor",
  duration = 1.5,
  style,
  ...props
}: LoaderGooeyBlobsProps) {
  const filterId = `gooey-${useId().replace(/:/g, "")}`;
  const reducedMotion = useReducedMotion();

  return (
    <motion.div
      role="status"
      aria-label="Loading"
      className={cn("flex items-center justify-center", className)}
      style={style}
      {...props}
    >
      <svg aria-hidden="true" focusable="false" className="absolute h-0 w-0">
        <defs>
          <filter id={filterId}>
            <feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7"
              result="gooey"
            />
            <feBlend in="SourceGraphic" in2="gooey" />
          </filter>
        </defs>
      </svg>
      <span aria-hidden="true" className="flex gap-1" style={{ filter: `url(#${filterId})` }}>
        {[0, 1, 2].map((index) => (
          <motion.span
            key={index}
            className="rounded-full"
            style={{ width: size, height: size, backgroundColor: color }}
            animate={reducedMotion ? undefined : { x: [0, 15, 0, -15, 0], scale: [1, 1.2, 1, 1.2, 1] }}
            transition={{ duration, ease: "easeInOut", repeat: Infinity, delay: index * 0.2 }}
          />
        ))}
      </span>
    </motion.div>
  );
}

export default LoaderGooeyBlobs;
```

### lib/effects/shared/webgl-surface.jsx

Installation target: `@lib/effects/shared/webgl-surface.jsx`

```jsx
"use client";

import { Component, useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";

const subscribeMotion = (notify) => {
  const query = window.matchMedia("(prefers-reduced-motion: reduce)");
  query.addEventListener("change", notify);
  return () => query.removeEventListener("change", notify);
};

export function useEffectReducedMotion() {
  return useSyncExternalStore(subscribeMotion, () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, () => true);
}

let webglAvailable;
function supportsWebGL() {
  if (webglAvailable !== undefined) return webglAvailable;
  try {
    const canvas = document.createElement("canvas");
    const context = canvas.getContext("webgl2");
    webglAvailable = Boolean(context);
    context?.getExtension("WEBGL_lose_context")?.loseContext();
  } catch {
    webglAvailable = false;
  }
  return webglAvailable;
}
const subscribeAvailability = () => () => {};

class SurfaceBoundary extends Component {
  state = { failed: false };
  static getDerivedStateFromError() { return { failed: true }; }
  render() { return this.state.failed ? this.props.fallback : this.props.children; }
}

/** @param {{ children?: import("react").ReactNode, className?: string, style?: import("react").CSSProperties, imageSrc?: string, label?: string }} props */
export function WebGLSurface({ children, className, style, imageSrc, label = "ObsidianUI visual effect" }) {
  const supported = useSyncExternalStore(subscribeAvailability, supportsWebGL, () => false);
  const fallback = <div role="img" aria-label={label} className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: imageSrc ? `url(${JSON.stringify(imageSrc)})` : undefined }} />;
  return (
    <div className={cn("relative isolate h-[28rem] w-full overflow-hidden bg-black", className)} style={{ containerType: "size", ...style }}>
      {fallback}
      {supported && <SurfaceBoundary fallback={fallback}>{children}</SurfaceBoundary>}
    </div>
  );
}
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

## Preview behavior

Drag inside the preview to pan the gallery. The effect stays inside its container and respects reduced motion preferences.


---

# ObsidianUI — Discover Button

[Canonical page](https://www.obsidianui.dev/docs/discover-button) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A rounded call-to-action with an arrow circle that expands across the label on hover. The default label is **Discover Components**. It works as a link when you provide `href`, or as a button when you provide `onClick`.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/discover-button)

```tsx
import { DiscoverButton } from '@/components/block/discover-button'

export function Demo() {
return <DiscoverButton href="/components" />
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/discover-button.json"
```

## Usage

```tsx
import { DiscoverButton } from '@/components/block/discover-button'

<DiscoverButton href="/components" />
```

Provide `onClick` instead of `href` to use it as a button. Change the label with the `label` prop. The hover and focus effects respect reduced-motion preferences.

## Customize colors

The button uses neutral ObsidianUI colors by default. Override its CSS variables with `className` when you need a different surface or fill:

```tsx
<DiscoverButton href="/components" className="custom-discover-button" />
```

```css
.custom-discover-button {
  --obsidian-discover-surface: #e5e7eb;
  --obsidian-discover-text: #171717;
  --obsidian-discover-fill: #171717;
  --obsidian-discover-active-text: #ffffff;
}
```

## Install manually — complete source

Download the complete manifest: [discover-button.json](https://www.obsidianui.dev/r/discover-button.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx lucide-react tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/discover-button.css

Installation target: `@components/block/discover-button.css`

```css
.obsidian-discover-button {
  position: relative;
  display: inline-flex;
  min-height: 66px;
  max-width: 100%;
  align-items: center;
  overflow: hidden;
  border: 1px solid rgb(255 255 255 / 30%);
  border-radius: 999px;
  background: var(--obsidian-discover-surface, rgb(237 237 237 / 92%));
  backdrop-filter: blur(10px);
  color: var(--obsidian-discover-text, #171717);
  cursor: pointer;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 600;
  line-height: 1.1;
  text-decoration: none;
  white-space: nowrap;
  animation: obsidian-discover-enter 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

.obsidian-discover-button__fill {
  position: absolute;
  inset: 2px auto 2px 2px;
  width: 60px;
  border-radius: inherit;
  background: var(--obsidian-discover-fill, #171717);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 12%);
  transition: width 480ms cubic-bezier(0.65, 0, 0.076, 1);
}

.obsidian-discover-button__icon {
  position: relative;
  z-index: 1;
  display: grid;
  width: 64px;
  height: 64px;
  flex: none;
  place-items: center;
  color: var(--obsidian-discover-active-text, #fff);
  transition: transform 480ms cubic-bezier(0.65, 0, 0.076, 1);
}

.obsidian-discover-button__text {
  position: relative;
  z-index: 1;
  padding: 0 27px 0 15px;
  text-align: center;
  transition: color 320ms ease;
}

.obsidian-discover-button:hover .obsidian-discover-button__fill,
.obsidian-discover-button:focus-visible .obsidian-discover-button__fill {
  width: calc(100% - 4px);
}

.obsidian-discover-button:hover .obsidian-discover-button__icon,
.obsidian-discover-button:focus-visible .obsidian-discover-button__icon {
  transform: translateX(7px);
}

.obsidian-discover-button:hover .obsidian-discover-button__text,
.obsidian-discover-button:focus-visible .obsidian-discover-button__text {
  color: var(--obsidian-discover-active-text, #fff);
}

.obsidian-discover-button:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 4px;
}

@keyframes obsidian-discover-enter {
  from { opacity: 0; transform: scale(0.9); }
  to { opacity: 1; transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-discover-button { animation: none; }
  .obsidian-discover-button__fill,
  .obsidian-discover-button__icon,
  .obsidian-discover-button__text { transition: none; }
}
```

### components/block/discover-button.tsx

Installation target: `@components/block/discover-button.tsx`

```tsx
"use client";

import { ArrowRight } from "lucide-react";
import type { MouseEventHandler } from "react";
import { cn } from "@/lib/utils";
import "./discover-button.css";

export interface DiscoverButtonProps {
  label?: string;
  href?: string;
  onClick?: MouseEventHandler<HTMLButtonElement>;
  className?: string;
}

export function DiscoverButton({
  label = "Discover Components",
  href,
  onClick,
  className,
}: DiscoverButtonProps) {
  const buttonFace = (
    <>
      <span className="obsidian-discover-button__fill" aria-hidden="true" />
      <span className="obsidian-discover-button__icon" aria-hidden="true">
        <ArrowRight size={25} strokeWidth={2.25} />
      </span>
      <span className="obsidian-discover-button__text">{label}</span>
    </>
  );

  if (href) {
    return (
      <a className={cn("obsidian-discover-button", className)} href={href}>
        {buttonFace}
      </a>
    );
  }

  return (
    <button className={cn("obsidian-discover-button", className)} type="button" onClick={onClick}>
      {buttonFace}
    </button>
  );
}

export default DiscoverButton;
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| label | string | Discover Components | Text shown beside the arrow. |
| href | string | - | Destination when rendered as a link. |
| onClick | MouseEventHandler<HTMLButtonElement> | - | Click handler when rendered as a button. |
| className | string | - | Additional classes for the outer pill. |


---

# ObsidianUI — Draggable Marquee

[Canonical page](https://www.obsidianui.dev/docs/draggable-marquee) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A continuous image marquee with drag momentum and a seamless looping track.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/draggable-marquee)

Drag the images, or focus the marquee and use the left and right arrow keys.

```tsx
"use client";
import { DraggableMarquee } from "@/components/block/draggable-marquee";

const items = [
  {
    "id": 1,
    "src": "https://cdn-new.obsidianui.dev/imagess/1.png",
    "alt": "Landscape photograph 1",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  },
  {
    "id": 2,
    "src": "https://cdn-new.obsidianui.dev/imagess/2.png",
    "alt": "Landscape photograph 2",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  },
  {
    "id": 3,
    "src": "https://cdn-new.obsidianui.dev/imagess/3.png",
    "alt": "Landscape photograph 3",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  },
  {
    "id": 4,
    "src": "https://cdn-new.obsidianui.dev/imagess/4.png",
    "alt": "Landscape photograph 4",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  }
];

export default function Demo() {
  return <DraggableMarquee items={items} speed={1} className="py-8" />;
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/draggable-marquee.json"
```

## Usage

```jsx
"use client";
import { DraggableMarquee } from "@/components/block/draggable-marquee";

const items = [
  {
    "id": 1,
    "src": "https://cdn-new.obsidianui.dev/imagess/1.png",
    "alt": "Landscape photograph 1",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  },
  {
    "id": 2,
    "src": "https://cdn-new.obsidianui.dev/imagess/2.png",
    "alt": "Landscape photograph 2",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  },
  {
    "id": 3,
    "src": "https://cdn-new.obsidianui.dev/imagess/3.png",
    "alt": "Landscape photograph 3",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  },
  {
    "id": 4,
    "src": "https://cdn-new.obsidianui.dev/imagess/4.png",
    "alt": "Landscape photograph 4",
    "width": 420,
    "height": 520,
    "imageClassName": "h-[260px] w-[200px] rounded-2xl object-cover"
  }
];

export default function Demo() {
  return <DraggableMarquee items={items} speed={1} className="py-8" />;
}
```

## Install manually — complete source

Download the complete manifest: [draggable-marquee.json](https://www.obsidianui.dev/r/draggable-marquee.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install gsap
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/draggable-marquee.jsx

Installation target: `@components/block/draggable-marquee.jsx`

```jsx
"use client";

import { useEffect, useMemo, useRef } from "react";
import Image from "next/image";
import gsap from "gsap";
import Draggable from "gsap/Draggable";
import "@/lib/effects/draggable-marquee/styles.css";

if (typeof window !== "undefined") gsap.registerPlugin(Draggable);

/**
 * @param {{items?: Array<{id?: string | number, src: string, alt?: string, width?: number, height?: number, imageClassName?: string}>,
 * speed?: number, repeatCount?: number, gapClassName?: string, className?: string, trackClassName?: string,
 * itemClassName?: string, pauseOnHover?: boolean, renderItem?: (item: {src: string, [key: string]: unknown}, index: number) => import('react').ReactNode,
 * throwMultiplier?: number, throwFriction?: number, maxThrowVelocity?: number, initialOffset?: number,
 * loopStart?: number, loopEndMultiplier?: number, label?: string}} props
 */
const DraggableMarquee = ({
    items = [],
    speed = 1,
    repeatCount = 3,
    gapClassName = "gap-6",
    className = "",
    trackClassName = "",
    itemClassName = "rounded-2xl",
    pauseOnHover = false,
    renderItem,

    // motion controls
    throwMultiplier = 2.8,
    throwFriction = 0.975,
    maxThrowVelocity = 60,

    // loop controls
    initialOffset = 0,
    loopStart = 0,
    loopEndMultiplier = -1.02,
    label = "Image marquee. Drag or use the left and right arrow keys.",
}) => {
    const rootRef = useRef(null);
    const trackRef = useRef(null);
    const dragRef = useRef(null);

    const duplicatedItems = useMemo(() => {
        return Array.from({ length: repeatCount }).flatMap(() => items);
    }, [items, repeatCount]);

    useEffect(() => {
        if (!rootRef.current || !trackRef.current || !items.length) return;

        const media = gsap.matchMedia();
        media.add("(prefers-reduced-motion: no-preference)", () => {

        const root = rootRef.current;
        const track = trackRef.current;

        let singleSetWidth = 0;
        let x = initialOffset;
        let throwVelocity = 0;
        let isPointerOver = false;
        let isDragging = false;
        let lastDragX = 0;
        let lastDragTime = 0;

        let wrapValue = (value) => value;
        let resizeRaf = null;

        const observers = [];
        const setX = gsap.quickSetter(track, "x", "px");

        const getGap = () => {
            const styles = window.getComputedStyle(track);
            return parseFloat(styles.columnGap || styles.gap || 0);
        };

        const buildWrap = () => {
            const min = singleSetWidth * loopEndMultiplier;
            const max = loopStart;
            wrapValue = gsap.utils.wrap(min, max);
        };

        const getProgressInLoop = () => {
            if (!singleSetWidth) return 0;

            const min = singleSetWidth * loopEndMultiplier;
            const max = loopStart;
            const range = max - min;

            if (!range) return 0;

            let wrapped = x;
            while (wrapped < min) wrapped += range;
            while (wrapped > max) wrapped -= range;

            return (wrapped - min) / range;
        };

        const setProgressInLoop = (progress) => {
            if (!singleSetWidth) return;

            const min = singleSetWidth * loopEndMultiplier;
            const max = loopStart;
            const range = max - min;

            x = min + range * progress;
            x = wrapValue(x);
            setX(x);

            if (dragRef.current) {
                dragRef.current.x = x;
            }
        };

        const measure = () => {
            const children = Array.from(track.children);
            const setSize = Math.floor(children.length / repeatCount);
            const firstSetChildren = children.slice(0, setSize);
            const gap = getGap();

            if (!firstSetChildren.length) return;

            const prevProgress = getProgressInLoop();

            const widths = firstSetChildren.reduce(
                (sum, child) => sum + child.getBoundingClientRect().width,
                0
            );

            singleSetWidth = widths + gap * Math.max(0, firstSetChildren.length - 1);

            buildWrap();

            if (!Number.isFinite(prevProgress)) {
                x = wrapValue(initialOffset);
                setX(x);
            } else {
                setProgressInLoop(prevProgress);
            }
        };

        const scheduleMeasure = () => {
            if (resizeRaf) cancelAnimationFrame(resizeRaf);
            resizeRaf = requestAnimationFrame(() => {
                measure();
            });
        };

        const update = () => {
            if (!isDragging) {
                if (!(pauseOnHover && isPointerOver)) {
                    x -= speed;
                }

                x += throwVelocity;
                throwVelocity *= throwFriction;

                if (Math.abs(throwVelocity) < 0.01) {
                    throwVelocity = 0;
                }
            }

            x = wrapValue(x);
            setX(x);
        };

        measure();

        dragRef.current = Draggable.create(track, {
            type: "x",
            allowContextMenu: true,
            dragClickables: true,

            onPress() {
                isDragging = true;
                throwVelocity = 0;
                this.x = x;
                lastDragX = this.x;
                lastDragTime = performance.now();
            },

            onDrag() {
                const now = performance.now();
                const dx = this.x - lastDragX;
                const dt = now - lastDragTime;

                x = wrapValue(this.x);
                setX(x);
                this.x = x;

                if (dt > 0) {
                    const sampledVelocity = (dx / dt) * 36.67;

                    throwVelocity = gsap.utils.clamp(
                        -maxThrowVelocity,
                        maxThrowVelocity,
                        sampledVelocity * throwMultiplier
                    );
                }

                lastDragX = this.x;
                lastDragTime = now;
            },

            onRelease() {
                isDragging = false;
            },
        })[0];

        const handleMouseEnter = () => {
            isPointerOver = true;
        };

        const handleMouseLeave = () => {
            isPointerOver = false;
        };

        if (pauseOnHover) {
            root.addEventListener("mouseenter", handleMouseEnter);
            root.addEventListener("mouseleave", handleMouseLeave);
        }

        const handleResize = () => {
            scheduleMeasure();
        };
        const handleKeyDown = (event) => {
            if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
            event.preventDefault();
            x = wrapValue(x + (event.key === "ArrowLeft" ? 1 : -1) * root.clientWidth * 0.35);
            throwVelocity = 0;
            setX(x);
        };
        root.addEventListener("keydown", handleKeyDown);

        window.addEventListener("resize", handleResize);

        const children = Array.from(track.children);
        const setSize = Math.floor(children.length / repeatCount);
        const firstSetChildren = children.slice(0, setSize);

        const trackObserver = new ResizeObserver(() => {
            scheduleMeasure();
        });

        trackObserver.observe(track);
        observers.push(trackObserver);

        firstSetChildren.forEach((child) => {
            const ro = new ResizeObserver(() => {
                scheduleMeasure();
            });

            ro.observe(child);
            observers.push(ro);

            const imgs = child.querySelectorAll("img");

            imgs.forEach((img) => {
                if (!img.complete) {
                    img.addEventListener("load", scheduleMeasure, { once: false });
                }
            });
        });

        gsap.ticker.add(update);

        return () => {
            root.removeEventListener("keydown", handleKeyDown);
            window.removeEventListener("resize", handleResize);

            if (pauseOnHover) {
                root.removeEventListener("mouseenter", handleMouseEnter);
                root.removeEventListener("mouseleave", handleMouseLeave);
            }

            gsap.ticker.remove(update);

            if (resizeRaf) cancelAnimationFrame(resizeRaf);

            observers.forEach((observer) => observer.disconnect());

            const cleanupChildren = Array.from(track.children);

            cleanupChildren.forEach((child) => {
                const imgs = child.querySelectorAll("img");

                imgs.forEach((img) => {
                    img.removeEventListener("load", scheduleMeasure);
                });
            });

            if (dragRef.current) {
                dragRef.current.kill();
                dragRef.current = null;
            }
        };
        }, rootRef);
        return () => media.revert();
    }, [
        items,
        speed,
        repeatCount,
        pauseOnHover,
        throwMultiplier,
        throwFriction,
        maxThrowVelocity,
        initialOffset,
        loopStart,
        loopEndMultiplier,
    ]);

    if (!items.length) return null;

    return (
        <div
            ref={rootRef}
            tabIndex={0}
            role="region"
            aria-label={label}
            className={`obsidian-draggable-marquee relative w-full overflow-hidden cursor-grab outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring active:cursor-grabbing ${className}`}
        >
            <div
                ref={trackRef}
                className={`flex w-max items-center ${gapClassName} ${trackClassName}`}
            >
                {duplicatedItems.map((item, index) => (
                    <div
                        key={`${item?.id || item?.src || "item"}-${index}`}
                        className={`shrink-0 ${itemClassName}`}
                        aria-hidden={index >= items.length}
                        data-marquee-copy={index >= items.length ? "duplicate" : "original"}
                    >
                        {renderItem ? (
                            renderItem(item, index % items.length)
                        ) : (
                            <Image
                                src={item.src}
                                alt={item.alt || "marquee-item"}
                                width={item.width || 400}
                                height={item.height || 500}
                                className={item.imageClassName || "h-auto w-auto object-cover max-sm:w-[320px] max-sm:h-105"}
                            />
                        )}
                    </div>
                ))}
            </div>
        </div>
    );
};

export { DraggableMarquee };
```

### lib/effects/draggable-marquee/styles.css

Installation target: `@lib/effects/draggable-marquee/styles.css`

```css
@media (prefers-reduced-motion: reduce) {
  .obsidian-draggable-marquee { overflow-x: auto; }
  .obsidian-draggable-marquee > div { transform: none !important; }
  .obsidian-draggable-marquee [data-marquee-copy="duplicate"] { display: none; }
}
```

## Preview behavior

Drag the images, or focus the marquee and use the left and right arrow keys. The effect stays inside its container and respects reduced motion preferences.


---

# ObsidianUI — Flip Text

[Canonical page](https://www.obsidianui.dev/docs/flip-text) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

An animated text component where each character flips and rotates on hover. Creates a playful, interactive typography effect.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/flip-text)

```tsx
import FlipText from '@/components/block/flip-text'

export function Demo() {
return (
  <FlipText className="text-4xl font-bold">
    Hover Me
  </FlipText>
)
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/flip-text.json"
```

## Install manually — complete source

Download the complete manifest: [flip-text.json](https://www.obsidianui.dev/r/flip-text.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/flip-text.tsx

Installation target: `@components/block/flip-text.tsx`

```tsx
"use client";

import React, { useMemo } from "react";
import { cn } from "@/lib/utils";

interface FlipTextProps {
    /**
     * Additional CSS classes for the wrapper
     */
    className?: string;

    /**
     * The text content to animate (will be split by spaces)
     */
    children: string;

    /**
     * Duration of the flip animation in seconds
     * @default 2.2
     */
    duration?: number;

    /**
     * Initial delay before animation starts in seconds
     * @default 0
     */
    delay?: number;

    /**
     * Whether the animation should loop infinitely
     * @default true
     */
    loop?: boolean;

    /**
     * Custom separator for splitting text (default is space)
     * @default " "
     */
    separator?: string;

    /**
     * Whether all characters should animate together (no stagger)
     * @default false
     */
    together?: boolean;
}

export function FlipText({
    className,
    children,
    duration = 2.2,
    delay = 0,
    loop = true,
    separator = " ",
    together = false,
}: FlipTextProps) {
    const words = useMemo(() => children.split(separator), [children, separator]);
    const totalChars = children.length;

    // Calculate character index for each position
    const getCharIndex = (wordIndex: number, charIndex: number) => {
        let index = 0;
        for (let i = 0; i < wordIndex; i++) {
            index += words[i].length + (separator === " " ? 1 : separator.length);
        }
        return index + charIndex;
    };

    return (
        <div
            className={cn(
                "flip-text-wrapper inline-block leading-none",
                className
            )}
            style={{ perspective: "1000px" }}
        >
            {words.map((word, wordIndex) => {
                const chars = word.split("");

                return (
                    <span
                        key={wordIndex}
                        className="word inline-block whitespace-nowrap"
                        style={{ transformStyle: "preserve-3d" }}
                    >
                        {chars.map((char, charIndex) => {
                            const currentGlobalIndex = getCharIndex(wordIndex, charIndex);

                            // Calculate delay - if together, use same delay for all
                            let calculatedDelay = delay;
                            if (!together) {
                                const normalizedIndex = currentGlobalIndex / totalChars;
                                const sineValue = Math.sin(normalizedIndex * (Math.PI / 2));
                                calculatedDelay = sineValue * (duration * 0.25) + delay;
                            }

                            return (
                                <span
                                    key={charIndex}
                                    className="flip-char inline-block relative"
                                    data-char={char}
                                    style={
                                        {
                                            "--flip-duration": `${duration}s`,
                                            "--flip-delay": `${calculatedDelay}s`,
                                            "--flip-iteration": loop ? "infinite" : "1",
                                            transformStyle: "preserve-3d",
                                        } as React.CSSProperties
                                    }
                                >
                                    {char}
                                </span>
                            );
                        })}
                        {separator === " " && wordIndex < words.length - 1 && (
                            <span className="whitespace inline-block">&nbsp;</span>
                        )}
                        {separator !== " " && wordIndex < words.length - 1 && (
                            <span className="separator inline-block">{separator}</span>
                        )}
                    </span>
                );
            })}
        </div>
    );
}

export default FlipText;
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children | string | - | Text content to display |
| className | string | - | Additional CSS classes |


---

# ObsidianUI — Hover Image

[Canonical page](https://www.obsidianui.dev/docs/hover-img) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A stunning hover-based image preview component. When users hover over project titles, a smooth mouse-following thumbnail appears showcasing the corresponding image. Perfect for portfolios, project showcases, and creative agency websites.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/hover-img)

```tsx
import { HoverImg } from '@/components/block/hover-img'

export function Demo() {
return (
  <HoverImg
    projects={[
      { title: "Shree Krishna", label: "The Supreme Personality of Godhead", imageSrc: "/cdn/hover-img/hover-img-img01-alt.jpg?v=3" },
      { title: "Radha Krishna", label: "The Divine Couple", imageSrc: "/cdn/hover-img/hover-img-img02.jpg?v=3" },
      { title: "Divine Love", label: "Eternal Bond", imageSrc: "/cdn/hover-img/hover-img-img03.jpg?v=3" },
    ]}
  />
)
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/hover-img.json"
```

## Install manually — complete source

Download the complete manifest: [hover-img.json](https://www.obsidianui.dev/r/hover-img.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install gsap
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/hover-img.css

Installation target: `@components/block/hover-img.css`

```css
.hover-img-container {
  --hi-bg: #f2f2f2;
  --hi-text: #000000;
  --hi-text-muted: #666666;
  --hi-border: rgba(0, 0, 0, 0.15);

  font-family: "Raleway", "Inter", system-ui, sans-serif;
  min-height: 100vh;
  width: 100%;
  background: var(--hi-bg);
  color: var(--hi-text);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}

/* Dark mode support */
.dark .hover-img-container,
:root[class~="dark"] .hover-img-container {
  --hi-bg: #0a0a0a;
  --hi-text: #ffffff;
  --hi-text-muted: #999999;
  --hi-border: rgba(255, 255, 255, 0.2);
}

.hover-img-projects {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 1200px;
}

.hover-img-project {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 2rem 4rem;
  border-top: 1px solid var(--hi-border);
  cursor: pointer;
  transition: opacity 0.5s ease;
}

.hover-img-project:last-child {
  border-bottom: 1px solid var(--hi-border);
}

.hover-img-project h2 {
  font-size: 2.5rem;
  font-weight: 500;
  letter-spacing: -0.02em;
  transition: transform 0.5s ease;
  margin: 0;
}

.hover-img-project p {
  font-size: 1rem;
  font-weight: 400;
  color: var(--hi-text-muted);
  transition: transform 0.5s ease;
  margin: 0;
}

.hover-img-project:hover {
  opacity: 0.5;
}

.hover-img-project:hover h2 {
  transform: translateX(-15px);
}

.hover-img-project:hover p {
  transform: translateX(15px);
}

.hover-img-thumbnail-wrapper {
  position: fixed;
  width: 400px;
  height: 250px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  pointer-events: none;
  top: 0;
  left: 0;
  transform-origin: center center;
  z-index: 100;
  border-radius: 12px;
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
}

.hover-img-thumbnail {
  width: 100%;
  height: 100%;
  flex-shrink: 0;
}

.hover-img-thumbnail img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Responsive */
@media (max-width: 1024px) {
  .hover-img-project {
    padding: 2rem 4rem;
  }

  .hover-img-project h2 {
    font-size: 2.5rem;
  }
}

@media (max-width: 768px) {
  .hover-img-project {
    padding: 1.5rem 2rem;
  }

  .hover-img-project h2 {
    font-size: 1.75rem;
  }

  .hover-img-project p {
    font-size: 0.875rem;
  }

  .hover-img-thumbnail-wrapper {
    display: none;
  }
}
.hover-img-compact {
  min-height: auto;
  padding: 0;
}

.hover-img-compact .hover-img-project {
  padding: 0.75rem 1rem;
}

.hover-img-compact .hover-img-project h2 {
  font-size: 1.125rem;
}

.hover-img-compact .hover-img-project p {
  font-size: 0.7rem;
}

.hover-img-compact .hover-img-thumbnail-wrapper {
  width: 160px;
  height: 100px;
  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2);
}
```

### components/block/hover-img.tsx

Installation target: `@components/block/hover-img.tsx`

```tsx
"use client";

import React, { useRef, useEffect } from "react";
import gsap from "gsap";
import "@/components/block/hover-img.css";

interface ProjectItem {
    title: string;
    label: string;
    imageSrc: string;
}

const defaultProjects: ProjectItem[] = [
    {
        title: "Shree Krishna",
        label: "The Supreme Personality of Godhead",
        imageSrc: "/cdn/hover-img/hover-img-img01-alt.jpg?v=3",
    },
    {
        title: "Radha Krishna",
        label: "The Divine Couple",
        imageSrc: "/cdn/hover-img/hover-img-img02.jpg?v=3",
    },
    {
        title: "Divine Love",
        label: "Eternal Bond",
        imageSrc: "/cdn/hover-img/hover-img-img03.jpg?v=3",
    },
];

interface HoverImgProps {
    projects?: ProjectItem[];
    className?: string;
    isContained?: boolean; // New prop for grid previews
    compact?: boolean; // New prop for compact layout
}

export function HoverImg({ projects = defaultProjects, className, isContained = false, compact = false }: HoverImgProps) {
    const containerRef = useRef<HTMLDivElement>(null);
    const thumbnailRef = useRef<HTMLDivElement>(null);
    const xToRef = useRef<gsap.QuickToFunc | null>(null);
    const yToRef = useRef<gsap.QuickToFunc | null>(null);

    useEffect(() => {
        const projectThumbnail = thumbnailRef.current;
        const projectsContainer = containerRef.current?.querySelector(
            ".hover-img-projects"
        ) as HTMLElement | null;

        if (!projectThumbnail || !projectsContainer) return;

        const projectElements = gsap.utils.toArray(
            ".hover-img-project",
            projectsContainer
        ) as HTMLElement[];
        const thumbnails = gsap.utils.toArray(
            ".hover-img-thumbnail",
            projectThumbnail
        ) as HTMLElement[];

        gsap.set(projectThumbnail, { scale: 0, xPercent: -50, yPercent: -50 });

        xToRef.current = gsap.quickTo(projectThumbnail, "x", {
            duration: 0.4,
            ease: "power3.out",
        });
        yToRef.current = gsap.quickTo(projectThumbnail, "y", {
            duration: 0.4,
            ease: "power3.out",
        });

        const handleMouseMove = (e: MouseEvent) => {
            let x = e.clientX;
            let y = e.clientY;

            if (isContained && containerRef.current) {
                const rect = containerRef.current.getBoundingClientRect();
                const halfWidth = projectThumbnail.offsetWidth / 2;
                const halfHeight = projectThumbnail.offsetHeight / 2;
                const inset = 16;
                x = Math.max(halfWidth + inset, Math.min(rect.width - halfWidth - inset, e.clientX - rect.left));
                y = Math.max(halfHeight + inset, Math.min(rect.height - halfHeight - inset, e.clientY - rect.top));
            }

            xToRef.current?.(x);
            yToRef.current?.(y);
        };

        const handleMouseLeave = () => {
            gsap.to(projectThumbnail, {
                scale: 0,
                duration: 0.3,
                ease: "power2.out",
                overwrite: "auto",
            });
        };

        projectsContainer.addEventListener("mousemove", handleMouseMove);
        projectsContainer.addEventListener("mouseleave", handleMouseLeave);

        const projectListeners: Array<() => void> = [];

        projectElements.forEach((project, index) => {
            const handleMouseEnter = () => {
                gsap.to(projectThumbnail, {
                    scale: 1,
                    duration: 0.4,
                    ease: "power2.out",
                    overwrite: "auto",
                });

                gsap.to(thumbnails, {
                    yPercent: -100 * index,
                    duration: 0.4,
                    ease: "power2.out",
                    overwrite: "auto",
                });
            };

            project.addEventListener("mouseenter", handleMouseEnter);
            projectListeners.push(() =>
                project.removeEventListener("mouseenter", handleMouseEnter)
            );
        });

        return () => {
            projectsContainer.removeEventListener("mousemove", handleMouseMove);
            projectsContainer.removeEventListener("mouseleave", handleMouseLeave);
            projectListeners.forEach((cleanup) => cleanup());
        };
    }, [projects, isContained]);

    return (
        <div className={`hover-img-container ${compact ? "hover-img-compact" : ""} ${className || ""}`} ref={containerRef}>
            <div className="hover-img-projects">
                {projects.map((project, index) => (
                    <div className="hover-img-project" key={index}>
                        <h2>{project.title}</h2>
                        <p>{project.label}</p>
                    </div>
                ))}
            </div>

            <div
                className="hover-img-thumbnail-wrapper"
                ref={thumbnailRef}
                style={isContained ? { position: "absolute" } : undefined}
            >
                {projects.map((project, index) => (
                    <div className="hover-img-thumbnail" key={index}>
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img src={project.imageSrc} alt={project.title} />
                    </div>
                ))}
            </div>
        </div>
    );
}

export default HoverImg;
```

Default preview images are hosted by ObsidianUI. Replace the image URLs with your own assets for offline use.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| projects | ProjectItem[] | Default projects | Array of project items with title, label, and imageSrc |
| className | string | - | Additional CSS classes for the container |
| isContained | boolean | false | Position the image preview inside the component container |
| compact | boolean | false | Use compact spacing, typography, and preview dimensions |

## ProjectItem Type

```typescript
interface ProjectItem {
    title: string;    // Project title displayed on hover
    label: string;    // Subtitle/category label
    imageSrc: string; // URL to the project image
}
```


---

# ObsidianUI — Split Showcase

[Canonical page](https://www.obsidianui.dev/docs/split-showcase) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A split showcase component with two interactive partner cards separated by a dotted divider. Features outward spring shift on hover, expanding rounded corners, and smooth Apple-style transitions.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/split-showcase)

```tsx
import { SplitShowcase } from '@/components/block/split-showcase'

export function Demo() {
return (
  <SplitShowcase />
)
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/split-showcase.json"
```

## Install manually — complete source

Download the complete manifest: [split-showcase.json](https://www.obsidianui.dev/r/split-showcase.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx motion tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/split-showcase.tsx

Installation target: `@components/block/split-showcase.tsx`

```tsx
"use client";

import React, { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface SplitShowcaseItem {
  id?: string;
  title?: React.ReactNode;
  tag?: React.ReactNode;
  description?: React.ReactNode;
  icon?: React.ReactNode;
  href?: string;
  target?: string;
  rel?: string;
  className?: string;
  onClick?: () => void;
  ariaLabel?: string;
}

export interface SplitShowcaseProps {
  items?: [SplitShowcaseItem, SplitShowcaseItem] | SplitShowcaseItem[];
  className?: string;
  children?: React.ReactNode;
  compact?: boolean;
}

export function VercelLogo({ className }: { className?: string }) {
  return (
    <svg
      className={cn("h-6 sm:h-7 w-auto max-w-[140px] sm:max-w-[200px] text-foreground", className)}
      viewBox="0 0 261 52"
      aria-hidden="true"
    >
      <path
        fill="currentColor"
        d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07"
      />
    </svg>
  );
}

export function TracwellLogo({ className }: { className?: string }) {
  return (
    <span
      className={cn(
        "inline-flex items-center gap-1.5 sm:gap-2 text-lg sm:text-2xl font-bold tracking-tight text-foreground",
        className
      )}
      aria-hidden="true"
    >
      <svg viewBox="0 0 96 96" className="size-6 sm:size-8 shrink-0">
        <title>Tracwell</title>
        <g transform="translate(0 7)">
          <path d="M12 75c-5 0-8-5-5-10l19-35c3-6 10-8 16-5s8 10 5 16L29 72c-1 2-4 3-7 3H12Z" fill="#8CB4FF" />
          <path d="M43 75c-5 0-8-5-5-10L67 12c3-6 10-8 16-5s8 10 5 16L60 72c-1 2-4 3-7 3H43Z" fill="#357DFF" />
          <circle cx="82" cy="67" r="9" fill="#174EA6" />
        </g>
      </svg>
      <span>Tracwell</span>
    </span>
  );
}

const defaultItems: [SplitShowcaseItem, SplitShowcaseItem] = [
  {
    id: "vercel",
    title: <VercelLogo />,
    tag: "Hosting Sponsor",
    href: "http://vercel.com?utm_source=obsidianui.dev&utm_medium=web&utm_campaign=obsidianui-sponsor",
    ariaLabel: "Vercel — Hosting Sponsor",
  },
  {
    id: "tracwell",
    title: <TracwellLogo />,
    tag: "Analytics Sponsor",
    href: "https://tracwell.app?utm_source=obsidianui.dev&utm_medium=web&utm_campaign=obsidianui-sponsor",
    ariaLabel: "Tracwell — Analytics Sponsor",
  },
];

export function SplitShowcase({
  items = defaultItems,
  className,
  children,
  compact = false,
}: SplitShowcaseProps) {
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
  const reduceMotion = useReducedMotion();

  const activeIndex = hoveredIndex ?? focusedIndex;
  const isAnyActive = activeIndex !== null;

  if (children) {
    return (
      <div className={cn("relative w-full max-w-5xl mx-auto", className)}>
        {children}
      </div>
    );
  }

  const [leftItem, rightItem] = items;

  const getSpringProps = (index: number) => {
    const isActive = activeIndex === index;
    const shift = index === 0 ? -12 : 12;

    if (reduceMotion) {
      return {};
    }

    return {
      animate: {
        x: isActive ? shift : 0,
        scale: isActive ? 0.98 : 1,
      },
      transition: {
        type: "spring" as const,
        stiffness: 350,
        damping: 24,
      },
    };
  };

  const renderCardContent = (item: SplitShowcaseItem) => (
    <>
      <div
        className={cn(
          "flex flex-col items-center justify-center text-center",
          compact ? "gap-2" : "gap-3"
        )}
      >
        {item.icon && <div className="shrink-0">{item.icon}</div>}
        {item.title}
        {item.description && (
          <p
            className={cn(
              "text-muted-foreground",
              compact ? "max-w-xs text-xs" : "max-w-xs text-sm"
            )}
          >
            {item.description}
          </p>
        )}
      </div>
      {item.tag && (
        <span
          className={cn(
            "relative font-semibold uppercase text-muted-foreground",
            compact
              ? "top-0 text-[10px] tracking-wider"
              : "top-2 text-xs tracking-widest"
          )}
        >
          {item.tag}
        </span>
      )}
    </>
  );

  return (
    <div
      className={cn(
        "relative grid w-full grid-cols-1 sm:grid-cols-2 gap-0 overflow-visible",
        className
      )}
    >
      {/* Card 1 (Left / Top) */}
      <div className="relative min-w-0">
        <motion.div
          {...getSpringProps(0)}
          className={cn(
            "flex h-full flex-col items-center justify-center text-foreground no-underline select-none",
            compact
              ? "min-h-[7.5rem] gap-3 p-4 sm:p-5"
              : "min-h-[11rem] gap-6 p-6 sm:p-8",
            "bg-zinc-200 dark:bg-zinc-800",
            "transition-[border-radius,box-shadow,border-color] duration-300",
            activeIndex === 0
              ? "rounded-[32px] sm:rounded-[32px] shadow-2xl shadow-black/10 dark:shadow-black/40 border border-border z-10"
              : "border border-border/40 sm:border-r-0 border-b-0 sm:border-b rounded-t-[32px] rounded-b-none sm:rounded-l-[32px] sm:rounded-r-none",
            leftItem?.className
          )}
          onMouseEnter={() => setHoveredIndex(0)}
          onMouseLeave={() => setHoveredIndex(null)}
          onFocus={() => setFocusedIndex(0)}
          onBlur={() => setFocusedIndex(null)}
        >
          {leftItem?.href ? (
            <a
              href={leftItem.href}
              target={leftItem.target ?? "_blank"}
              rel={leftItem.rel ?? "noreferrer"}
              aria-label={leftItem.ariaLabel}
              className="flex h-full w-full flex-col items-center justify-center gap-4 focus-visible:outline-none"
              onClick={leftItem.onClick}
            >
              {renderCardContent(leftItem)}
            </a>
          ) : (
            renderCardContent(leftItem)
          )}
        </motion.div>
      </div>

      {/* Dotted Divider - Desktop */}
      <div
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute left-1/2 z-10 hidden w-0 -translate-x-1/2 border-l-2 border-dotted border-zinc-400 dark:border-zinc-400 sm:block transition-opacity duration-250 ease-out",
          compact ? "inset-y-4" : "inset-y-8",
          isAnyActive ? "opacity-0" : "opacity-100"
        )}
      />

      {/* Dotted Divider - Mobile */}
      <div
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute top-1/2 z-10 block h-0 -translate-y-1/2 border-t-2 border-dotted border-zinc-400 dark:border-zinc-400 sm:hidden transition-opacity duration-250 ease-out",
          compact ? "inset-x-4" : "inset-x-8",
          isAnyActive ? "opacity-0" : "opacity-100"
        )}
      />

      {/* Card 2 (Right / Bottom) */}
      <div className="relative min-w-0">
        <motion.div
          {...getSpringProps(1)}
          className={cn(
            "flex h-full flex-col items-center justify-center text-foreground no-underline select-none",
            compact
              ? "min-h-[7.5rem] gap-3 p-4 sm:p-5"
              : "min-h-[11rem] gap-6 p-6 sm:p-8",
            "bg-zinc-200 dark:bg-zinc-800",
            "transition-[border-radius,box-shadow,border-color] duration-300",
            activeIndex === 1
              ? "rounded-[32px] sm:rounded-[32px] shadow-2xl shadow-black/10 dark:shadow-black/40 border border-border z-10"
              : "border border-border/40 sm:border-l-0 border-t-0 sm:border-t rounded-b-[32px] rounded-t-none sm:rounded-r-[32px] sm:rounded-l-none",
            rightItem?.className
          )}
          onMouseEnter={() => setHoveredIndex(1)}
          onMouseLeave={() => setHoveredIndex(null)}
          onFocus={() => setFocusedIndex(1)}
          onBlur={() => setFocusedIndex(null)}
        >
          {rightItem?.href ? (
            <a
              href={rightItem.href}
              target={rightItem.target ?? "_blank"}
              rel={rightItem.rel ?? "noreferrer"}
              aria-label={rightItem.ariaLabel}
              className="flex h-full w-full flex-col items-center justify-center gap-4 focus-visible:outline-none"
              onClick={rightItem.onClick}
            >
              {renderCardContent(rightItem)}
            </a>
          ) : (
            renderCardContent(rightItem)
          )}
        </motion.div>
      </div>
    </div>
  );
}

export default SplitShowcase;
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| items | [SplitShowcaseItem, SplitShowcaseItem] | Vercel & Tracwell | Array of two items to display in the split showcase cards |
| className | string | - | Optional custom CSS class names for the showcase grid |
| children | ReactNode | - | Optional custom children to override the default split layout |


---

# ObsidianUI — Status Bars

[Canonical page](https://www.obsidianui.dev/docs/status-bars) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A daily status strip for a service. Each bar is one day, colored by whether the service was operational, degraded, or down. Hover, tap, or use the arrow keys to see that day's uptime and incidents. The uptime total animates as the range changes.

The preview starts on August 1 and adds a new bar each day at UTC midnight.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/status-bars)

```tsx
import { fillStatusDays, StatusBars, useStatusToday } from '@/components/block/status-bars'

const incidents = [
{ date: '2026-08-19', downtime: 23, incidents: [{ title: 'Registry deploy rolled back', minutes: 23 }] },
{ date: '2026-09-21', degraded: 6, incidents: [{ title: 'Elevated error rate', minutes: 6 }] },
]

export function Demo() {
const today = useStatusToday()
if (!today) return <StatusBars label="Component registry" days={[]} loading />

const days = fillStatusDays('2026-08-01', today, incidents)
return <StatusBars label="Component registry" days={days} maxDays={days.length} />
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/status-bars.json"
```

## Usage

```tsx
import { StatusBars } from '@/components/block/status-bars'

<StatusBars
  label="API"
  days={[
    { date: '2026-09-20' },
    { date: '2026-09-21', degraded: 6, incidents: [{ title: 'Elevated error rate', minutes: 6 }] },
  ]}
/>
```

Pass one entry per day, oldest first; the last entry is today. Downtime minutes count against uptime, and degraded minutes are shown without reducing it.

## Keep the range current

`useStatusToday()` returns today's UTC date and updates at midnight, so the strip gains a new day while the page stays open. It returns `null` during server rendering; show the `loading` state until it has a value.

`fillStatusDays(from, to, entries)` builds every day in the range and marks days without an entry as operational:

```tsx
const today = useStatusToday()
const days = today ? fillStatusDays('2026-08-01', today, incidents) : []
```

## Customize colors

Override the status colors on the component or any parent:

```css
.obsidian-status-bars {
  --obsidian-status-up: #22c55e;
  --obsidian-status-degraded: #f59e0b;
  --obsidian-status-down: #ef4444;
}
```

## Install manually — complete source

Download the complete manifest: [status-bars.json](https://www.obsidianui.dev/r/status-bars.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install @number-flow/react clsx motion tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/status-bars.css

Installation target: `@components/block/status-bars.css`

```css
.obsidian-status-bars {
  --obsidian-status-up: oklch(0.7 0.16 152);
  --obsidian-status-degraded: oklch(0.8 0.15 78);
  --obsidian-status-down: oklch(0.63 0.21 25);
  --obsidian-status-empty: color-mix(in oklab, currentColor 9%, transparent);
}

.dark .obsidian-status-bars {
  --obsidian-status-up: oklch(0.72 0.15 152);
  --obsidian-status-degraded: oklch(0.82 0.14 80);
  --obsidian-status-down: oklch(0.66 0.2 25);
  --obsidian-status-empty: color-mix(in oklab, currentColor 11%, transparent);
}

.obsidian-status-bars__ping {
  animation: obsidian-status-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;
}

.obsidian-status-bars__pulse {
  animation: obsidian-status-pulse 1.6s ease-in-out infinite;
}

@keyframes obsidian-status-ping {
  0% {
    opacity: 0.55;
    transform: scale(1);
  }

  75%,
  100% {
    opacity: 0;
    transform: scale(2.2);
  }
}

@keyframes obsidian-status-pulse {
  0%,
  100% {
    opacity: 1;
  }

  50% {
    opacity: 0.45;
  }
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-status-bars__ping,
  .obsidian-status-bars__pulse {
    animation: none;
  }
}
```

### components/block/status-bars.tsx

Installation target: `@components/block/status-bars.tsx`

```tsx
"use client";

import NumberFlow from "@number-flow/react";
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { ComponentProps, PointerEvent as ReactPointerEvent } from "react";
import { cn } from "@/lib/utils";
import "./status-bars.css";

export type StatusLevel = "up" | "degraded" | "down" | "none";

export type StatusIncident = {
  title: string;
  /** Minutes it lasted. */
  minutes?: number;
  status?: "degraded" | "down";
};

export type StatusDay = {
  /** ISO date, for example "2026-09-21". */
  date: string;
  /** Overrides the status derived from the minutes below. "none" means no data for the day. */
  status?: StatusLevel;
  /** Minutes of full outage. Counts against uptime. */
  downtime?: number;
  /** Minutes of degraded service. Shown, but does not count against uptime. */
  degraded?: number;
  /** Overrides the day's uptime percentage (0–100). */
  uptime?: number;
  incidents?: StatusIncident[];
};

const DAY_MS = 86_400_000;
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const TOOLTIP_SPRING = { type: "spring", stiffness: 520, damping: 42, mass: 0.6 } as const;

const parseIsoDate = (iso: string) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
export const toIsoDate = (time: number) => new Date(time).toISOString().slice(0, 10);

/** Builds one entry per UTC day from `from` to `to`, inclusive. Days without an entry count as operational. */
export function fillStatusDays(from: string, to: string, entries: StatusDay[] = []): StatusDay[] {
  const byDate = new Map(entries.map((day) => [day.date, day]));
  const days: StatusDay[] = [];
  for (let time = parseIsoDate(from), end = parseIsoDate(to); time <= end; time += DAY_MS) {
    const date = toIsoDate(time);
    days.push(byDate.get(date) ?? { date });
  }
  return days;
}

function subscribeToDayChange(onChange: () => void) {
  let timer: ReturnType<typeof setTimeout>;
  const schedule = () => {
    timer = setTimeout(() => {
      onChange();
      schedule();
    }, DAY_MS - (Date.now() % DAY_MS) + 1_000);
  };
  // Background tabs throttle timers, so a returning visitor re-checks the date.
  const onVisible = () => {
    if (document.visibilityState === "visible") onChange();
  };
  schedule();
  document.addEventListener("visibilitychange", onVisible);
  return () => {
    clearTimeout(timer);
    document.removeEventListener("visibilitychange", onVisible);
  };
}

/** Today's UTC date as an ISO string. It is `null` during server rendering and rolls over at UTC midnight. */
export function useStatusToday(): string | null {
  return useSyncExternalStore(subscribeToDayChange, () => toIsoDate(Date.now()), () => null);
}

const subscribeToNothing = () => () => {};
function useLocale(locale?: string) {
  const detected = useSyncExternalStore(subscribeToNothing, () => Intl.DateTimeFormat().resolvedOptions().locale, () => "en-US");
  return locale ?? detected;
}

export function getDayStatus(day: StatusDay): StatusLevel {
  if (day.status) return day.status;
  if ((day.downtime ?? 0) > 0) return "down";
  if ((day.degraded ?? 0) > 0) return "degraded";
  return "up";
}

export const getDayUptime = (day: StatusDay) => day.uptime ?? Math.max(0, 100 - ((day.downtime ?? 0) / 1440) * 100);

const BAR: Record<StatusLevel, string> = {
  up: "bg-(--obsidian-status-up)/75",
  degraded: "bg-(--obsidian-status-degraded)",
  down: "bg-(--obsidian-status-down)",
  none: "bg-(--obsidian-status-empty)",
};
const DOT: Record<StatusLevel, string> = {
  up: "bg-(--obsidian-status-up)",
  degraded: "bg-(--obsidian-status-degraded)",
  down: "bg-(--obsidian-status-down)",
  none: "bg-muted-foreground/50",
};
const WORD: Record<StatusLevel, string> = { up: "Operational", degraded: "Degraded", down: "Outage", none: "No data" };
const CURRENT: Record<StatusLevel, string> = { up: "Operational", degraded: "Degraded performance", down: "Outage", none: "No data" };

const formatDuration = (minutes: number) =>
  minutes >= 60 ? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${minutes % 60}m` : ""}` : `${Math.max(1, Math.round(minutes))}m`;

export type StatusBarsProps = Omit<ComponentProps<"div">, "children"> & {
  /** One entry per day, oldest first. The last entry is today. */
  days: StatusDay[];
  /** The service or component being tracked. */
  label: string;
  /** Status shown in the header. Defaults to today's. */
  current?: StatusLevel;
  /** Most days to show. Narrow containers step down to 60, then 30. */
  maxDays?: number;
  /** Narrowest a bar may draw, in pixels, before fewer days are shown. */
  minBarWidth?: number;
  /** Hides the name, status and axis, for a denser list. */
  compact?: boolean;
  loading?: boolean;
  locale?: string;
};

export function StatusBars({
  days,
  label,
  current,
  maxDays = 90,
  minBarWidth = 3,
  compact = false,
  loading = false,
  locale: localeProp,
  className,
  ...rest
}: StatusBarsProps) {
  const locale = useLocale(localeProp);
  const reduce = useReducedMotion();
  const uid = useId();
  const wrap = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);
  const [active, setActive] = useState<number | null>(null);
  const [pinned, setPinned] = useState(false);
  const [keyboard, setKeyboard] = useState(false);

  useEffect(() => {
    const element = wrap.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  const gap = 2;
  const fits = width ? Math.floor((width + gap) / (minBarWidth + gap)) : maxDays;
  const count = [maxDays, 60, 30].filter((n) => n <= maxDays).find((n) => n <= fits) ?? Math.max(1, fits);
  const shown = useMemo(() => {
    const tail = days.slice(-count);
    // Pad the front with empty days so a new service still reads as a full range.
    const pad = Array.from({ length: Math.max(0, count - tail.length) }, (): StatusDay => ({ date: "", status: "none" }));
    return [...pad, ...tail];
  }, [days, count]);

  const measured = shown.filter((day) => day.date && getDayStatus(day) !== "none");
  const uptime = measured.length ? measured.reduce((sum, day) => sum + getDayUptime(day), 0) / measured.length : null;
  const today = days.length ? getDayStatus(days[days.length - 1]) : "none";
  const now = current ?? today;

  const dateFormat = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }), [locale]);
  const longFormat = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" }), [locale]);
  const axisFormat = useMemo(() => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", timeZone: "UTC" }), [locale]);
  const percentFormat = useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: 0 }), [locale]);
  const formatDate = (iso: string, format: Intl.DateTimeFormat) => (iso ? format.format(parseIsoDate(iso)) : "");
  const percent = (value: number) => `${percentFormat.format(Math.floor(value * 100) / 100)}%`;
  const firstDate = shown[0]?.date;

  const describe = (day: StatusDay) => {
    if (!day.date) return "No data";
    const status = getDayStatus(day);
    const parts = [formatDate(day.date, longFormat), WORD[status]];
    if (status !== "none") parts.push(`${percent(getDayUptime(day))} uptime`);
    for (const incident of day.incidents ?? []) {
      parts.push(incident.minutes ? `${incident.title} for ${formatDuration(incident.minutes)}` : incident.title);
    }
    return parts.join(", ");
  };

  // One tooltip glides between bars and stays inside the strip at both ends.
  const tooltipX = useMotionValue(0);
  const tooltipShift = useTransform(tooltipX, (x) => `${-Math.min(1, Math.max(0, x / (width || 1))) * 100}%`);
  const visible = useRef(false);
  const point = (index: number, instant: boolean) => {
    const x = ((index + 0.5) * (width + gap)) / shown.length - gap / 2;
    if (instant || !visible.current || reduce) tooltipX.jump(x);
    else animate(tooltipX, x, TOOLTIP_SPRING);
    visible.current = true;
    setActive(index);
  };
  const hide = () => {
    visible.current = false;
    setActive(null);
    setPinned(false);
    setKeyboard(false);
  };
  const indexAt = (event: ReactPointerEvent) => {
    const rect = event.currentTarget.getBoundingClientRect();
    return Math.min(shown.length - 1, Math.max(0, Math.floor(((event.clientX - rect.left) / rect.width) * shown.length)));
  };

  // A tap pins the tooltip on touch screens; the next tap elsewhere releases it.
  useEffect(() => {
    if (!pinned) return;
    const away = (event: PointerEvent) => {
      if (!wrap.current?.contains(event.target as Node)) {
        visible.current = false;
        setActive(null);
        setPinned(false);
      }
    };
    document.addEventListener("pointerdown", away);
    return () => document.removeEventListener("pointerdown", away);
  }, [pinned]);

  const tipDay = active !== null ? shown[active] : null;
  const tipStatus = tipDay?.date ? getDayStatus(tipDay) : "none";
  const cellId = (index: number) => `${uid}-day-${index}`;

  return (
    <div
      data-slot="status-bars"
      data-state={loading ? "loading" : now}
      aria-busy={loading || undefined}
      className={cn("obsidian-status-bars flex w-full min-w-0 flex-col text-foreground", className)}
      {...rest}
    >
      {!compact && (
        <div className="mb-2.5 flex items-center justify-between gap-3">
          <span className="min-w-0 truncate text-[13px] font-medium tracking-[-0.01em]">{label}</span>
          <span className="flex shrink-0 items-center gap-1.5 text-[12px] text-muted-foreground">
            <span aria-hidden className="relative grid size-2 place-items-center">
              {!loading && (now === "down" || now === "degraded") && (
                <span className={cn("obsidian-status-bars__ping absolute inset-0 rounded-full", DOT[now])} />
              )}
              <span className={cn("size-1.5 rounded-full", loading ? "bg-muted-foreground/50" : DOT[now])} />
            </span>
            {loading ? "Checking…" : CURRENT[now]}
          </span>
        </div>
      )}

      <div
        ref={wrap}
        className="relative"
        // Listening here rather than on the strip keeps the tooltip open while the cursor moves onto it.
        onPointerLeave={(event) => {
          if (event.pointerType === "mouse" && !pinned && !keyboard) hide();
        }}
      >
        <div
          role="grid"
          aria-label={`${label}, last ${count} days${uptime !== null ? `, ${percent(uptime)} uptime` : ""}`}
          aria-readonly
          aria-activedescendant={keyboard && active !== null ? cellId(active) : undefined}
          tabIndex={loading ? -1 : 0}
          data-hovering={active !== null || undefined}
          onPointerMove={(event) => {
            if (loading || event.pointerType !== "mouse" || pinned) return;
            const index = indexAt(event);
            if (index !== active) point(index, false);
          }}
          onPointerDown={(event) => {
            if (loading || event.pointerType === "mouse") return;
            const index = indexAt(event);
            if (pinned && index === active) return hide();
            point(index, true);
            setPinned(true);
          }}
          onFocus={(event) => {
            if (!event.currentTarget.matches(":focus-visible") || loading) return;
            setKeyboard(true);
            point(active ?? shown.length - 1, true);
          }}
          onBlur={hide}
          onKeyDown={(event) => {
            if (loading) return;
            if (event.key === "Escape") return hide();
            const last = shown.length - 1;
            const from = active ?? last;
            const to =
              event.key === "ArrowLeft" ? Math.max(0, from - 1)
              : event.key === "ArrowRight" ? Math.min(last, from + 1)
              : event.key === "Home" ? 0
              : event.key === "End" ? last
              : event.key === "PageUp" ? Math.max(0, from - 7)
              : event.key === "PageDown" ? Math.min(last, from + 7)
              : null;
            if (to === null) return;
            event.preventDefault();
            setKeyboard(true);
            point(to, true);
          }}
          className={cn(
            "group/strip flex h-8 rounded-[3px] outline-none",
            "focus-visible:outline-1 focus-visible:outline-offset-[3px] focus-visible:outline-ring focus-visible:outline-solid",
          )}
          style={{ gap }}
        >
          <div role="row" className="contents">
            {shown.map((day, index) => {
              const status = day.date ? getDayStatus(day) : "none";
              return (
                <div
                  key={day.date || `pad-${index}`}
                  id={cellId(index)}
                  role="gridcell"
                  aria-label={describe(day)}
                  data-status={status}
                  data-active={active === index || undefined}
                  className={cn(
                    "min-w-0 flex-1 origin-bottom rounded-[2px] transition-[opacity,scale] duration-150 ease-out",
                    loading ? "obsidian-status-bars__pulse bg-(--obsidian-status-empty)" : BAR[status],
                    // Pointing at one day quiets the rest so its colour reads against them.
                    "group-data-[hovering]/strip:opacity-35 data-[active]:opacity-100 data-[active]:scale-y-[1.08] motion-reduce:data-[active]:scale-y-100",
                  )}
                />
              );
            })}
          </div>
        </div>

        <AnimatePresence>
          {tipDay && !loading && (
            <motion.div
              key="tip"
              aria-hidden
              className="pointer-events-none absolute left-0 top-0 z-20"
              style={{ x: tooltipX }}
              initial={{ opacity: 0, y: reduce ? 0 : 3 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, transition: { duration: 0.08 } }}
              transition={{ duration: 0.14, ease: EASE_OUT }}
            >
              <motion.div className="pointer-events-auto -translate-y-full pb-2.5" style={{ x: tooltipShift }}>
                <div className="w-max max-w-64 rounded-lg border border-border bg-popover px-2.5 py-2 text-[12px] leading-4 text-popover-foreground shadow-lg">
                  <div className="flex items-baseline justify-between gap-4">
                    <span className="font-medium">{tipDay.date ? formatDate(tipDay.date, dateFormat) : "Before tracking"}</span>
                    {tipStatus !== "none" && (
                      <span className="font-mono text-[11px] tabular-nums text-muted-foreground">{percent(getDayUptime(tipDay))}</span>
                    )}
                  </div>
                  <div className="mt-1 flex items-center gap-1.5 text-muted-foreground">
                    <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", DOT[tipStatus])} />
                    {WORD[tipStatus]}
                    {tipStatus === "down" && tipDay.downtime && !tipDay.incidents?.length ? (
                      <span className="text-muted-foreground/80">· {formatDuration(tipDay.downtime)} down</span>
                    ) : null}
                    {tipStatus === "degraded" && tipDay.degraded && !tipDay.incidents?.length ? (
                      <span className="text-muted-foreground/80">· {formatDuration(tipDay.degraded)}</span>
                    ) : null}
                  </div>
                  {tipStatus === "up" && !tipDay.incidents?.length && <div className="mt-1 text-muted-foreground/80">No incidents</div>}
                  {tipDay.incidents?.map((incident, index) => (
                    <div key={index} className="mt-1.5 border-t border-border pt-1.5 text-muted-foreground first-of-type:mt-2">
                      {incident.title}
                      {incident.minutes ? <span className="tabular-nums text-muted-foreground/80"> · {formatDuration(incident.minutes)}</span> : null}
                    </div>
                  ))}
                </div>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      {!compact && (
        <div className="mt-2 flex items-center gap-3 text-[11px] text-muted-foreground/80">
          <span className="shrink-0 tabular-nums">{firstDate ? formatDate(firstDate, axisFormat) : `${count} days ago`}</span>
          <span aria-hidden className="h-px flex-1 bg-border" />
          <span className="shrink-0 tabular-nums text-muted-foreground">
            {loading ? (
              "Loading…"
            ) : uptime === null ? (
              "No data yet"
            ) : (
              <>
                <NumberFlow
                  value={Math.floor(uptime * 100) / 10000}
                  locales={locale}
                  format={{ style: "percent", maximumFractionDigits: 2 }}
                  animated={!reduce}
                  willChange
                />{" "}
                uptime
              </>
            )}
          </span>
          <span aria-hidden className="h-px flex-1 bg-border" />
          <span className="shrink-0">Today</span>
        </div>
      )}
    </div>
  );
}

export default StatusBars;
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| days | StatusDay[] | - | One entry per day, oldest first. The last entry is today. |
| label | string | - | Name of the service being tracked. |
| current | 'up' \| 'degraded' \| 'down' \| 'none' | Today's status | Status shown in the header. |
| maxDays | number | 90 | Most days to show. Narrow containers step down to 60, then 30. |
| minBarWidth | number | 3 | Narrowest bar, in pixels, before fewer days are shown. |
| compact | boolean | false | Hides the name, status, and axis for dense lists. |
| loading | boolean | false | Shows a pulsing placeholder while data loads. |
| locale | string | Browser locale | Locale for dates and percentages. |
| className | string | - | Additional classes for the root element. |


---

# ObsidianUI — Text reel

[Canonical page](https://www.obsidianui.dev/docs/text-stream) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A continuous vertical text stream changes speed and direction with your scroll.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/text-stream)

Scroll the page to change the stream's momentum.

```tsx
"use client";
import { TextStream } from "@/components/block/text-stream";

export default function Demo() {
  return <TextStream items={["Create", "Explore", "Build", "Ship"]} prefix="Let’s" height="400px" fontSize="2.25rem" />;
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/text-stream.json"
```

## Usage

```jsx
"use client";
import { TextStream } from "@/components/block/text-stream";

export default function Demo() {
  return <TextStream items={["Create", "Explore", "Build", "Ship"]} prefix="Let’s" height="400px" fontSize="2.25rem" />;
}
```

## Install manually — complete source

Download the complete manifest: [text-stream.json](https://www.obsidianui.dev/r/text-stream.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx gsap tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/text-stream.css

Installation target: `@components/block/text-stream.css`

```css
.obsidian-text-stream { min-width: 0; }

@media (prefers-reduced-motion: reduce) {
  .obsidian-text-stream .obsidian-text-stream__viewport {
    display: flex;
    align-items: center;
    mask-image: none !important;
    -webkit-mask-image: none !important;
  }

  .obsidian-text-stream .obsidian-text-stream__track {
    position: relative !important;
    transform: none !important;
  }

  .obsidian-text-stream__copy + .obsidian-text-stream__copy { display: none !important; }
}
```

### components/block/text-stream.jsx

Installation target: `@components/block/text-stream.jsx`

```jsx
"use client";

import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
import "./text-stream.css";

/**
 * @param {{
 *   items?: string[], prefix?: string, fontSize?: string, fontWeight?: number,
 *   height?: string | number, paused?: boolean, className?: string,
 *   style?: import('react').CSSProperties,
 *   scroller?: HTMLElement | Window | import('react').RefObject<HTMLElement | null>
 * }} props
 */
export function TextStream({
  items = [],
  prefix = "ObsidianUI",
  fontSize = "clamp(2rem, 6vw, 4rem)",
  fontWeight = 700,
  height = "100vh",
  scroller,
  paused = false,
  className,
  style,
}) {
  const trackRef = useRef(null);
  const contentRef = useRef(null);
  const containerRef = useRef(null);
  const [copyCount, setCopyCount] = useState(2);
  const metricsRef = useRef({
    currentY: 0,
    distance: 0,
    currentVelocity: 0.6,
    targetVelocity: 0.6,
    lastScrollDirection: 1,
  });
  const scrollTimeoutRef = useRef(null);

  useEffect(() => {
    const track = trackRef.current;
    const content = contentRef.current;
    const container = containerRef.current;
    if (!track || !content || !container || paused || !items.length) return;

    const media = gsap.matchMedia();
    media.add("(prefers-reduced-motion: no-preference)", () => {
      const baseSpeed = 0.6;
      const maxBoost = 12;
      const scrollTarget = (scroller?.current !== undefined ? scroller.current : scroller) ?? window;
      const readScroll = () => scrollTarget === window ? window.scrollY : scrollTarget.scrollTop;
      let lastScrollY = readScroll();

      const startAnimation = () => {
        const distance = content.offsetHeight;
        const containerHeight = container.offsetHeight;
        if (!distance || !containerHeight) return;

        const nextCopyCount = Math.max(2, Math.ceil(containerHeight / distance) + 2);
        setCopyCount((c) => (c === nextCopyCount ? c : nextCopyCount));
        metricsRef.current.distance = distance;

        metricsRef.current.currentY = gsap.utils.wrap(-distance, 0, metricsRef.current.currentY);

        gsap.set(track, { y: metricsRef.current.currentY });
      };

      const tick = (_, deltaTime) => {
        const { distance } = metricsRef.current;
        if (!distance) return;

        const frameFactor = deltaTime / (1000 / 60);
        metricsRef.current.currentVelocity = gsap.utils.interpolate(
          metricsRef.current.currentVelocity,
          metricsRef.current.targetVelocity,
          0.14
        );
        metricsRef.current.currentY += metricsRef.current.currentVelocity * frameFactor;

        metricsRef.current.currentY = gsap.utils.wrap(-distance, 0, metricsRef.current.currentY);

        gsap.set(track, { y: metricsRef.current.currentY });
      };

      const applyScrollMotion = (delta) => {
        if (!delta) return;
        const direction = delta > 0 ? -1 : 1;
        const boost = Math.min(maxBoost, baseSpeed + Math.pow(Math.abs(delta), 1.2) * 0.08);
        metricsRef.current.lastScrollDirection = direction;
        metricsRef.current.targetVelocity = direction * boost;
        window.clearTimeout(scrollTimeoutRef.current);
        scrollTimeoutRef.current = window.setTimeout(() => {
          metricsRef.current.targetVelocity = metricsRef.current.lastScrollDirection * baseSpeed;
        }, 120);
      };

      const handleWheel = (e) => applyScrollMotion(e.deltaY);
      const handleScroll = () => {
        const next = readScroll();
        applyScrollMotion(next - lastScrollY);
        lastScrollY = next;
      };

      startAnimation();
      gsap.ticker.add(tick);

      const ro = new ResizeObserver(startAnimation);
      ro.observe(content);
      ro.observe(container);
      window.addEventListener("resize", startAnimation);
      scrollTarget.addEventListener("wheel", handleWheel, { passive: true });
      scrollTarget.addEventListener("scroll", handleScroll, { passive: true });

      return () => {
        ro.disconnect();
        window.removeEventListener("resize", startAnimation);
        scrollTarget.removeEventListener("wheel", handleWheel);
        scrollTarget.removeEventListener("scroll", handleScroll);
        window.clearTimeout(scrollTimeoutRef.current);
        gsap.ticker.remove(tick);
      };
    }, containerRef);

    return () => media.revert();
  }, [items, paused, scroller]);

  if (!items.length) return null;

  return (
    <div className={cn("obsidian-text-stream font-heading text-foreground", className)} style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height, gap: "0.5rem", ...style }}>
      <p style={{ fontSize: "clamp(0.65rem, 1.5vw, 0.85rem)", fontWeight: 400, letterSpacing: "0.25em", textTransform: "uppercase", opacity: 0.4, margin: 0 }}>
        {prefix}
      </p>

      <div
        ref={containerRef}
        className="obsidian-text-stream__viewport"
        style={{
          position: "relative", width: "100%", flexShrink: 0, height: `calc(${typeof fontSize === "string" ? fontSize : fontSize + "px"} * 2.2)`, overflow: "hidden",
          maskImage: "linear-gradient(to bottom, transparent 0%, black 30%, black 70%, transparent 100%)",
          WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 30%, black 70%, transparent 100%)",
          textAlign: "center",
        }}
      >
        <div
          ref={trackRef}
          className="obsidian-text-stream__track"
          style={{ position: "absolute", left: 0, right: 0, top: 0, display: "flex", flexDirection: "column", fontSize, fontWeight, lineHeight: 1.1 }}
        >
          {Array.from({ length: copyCount }, (_, copyIndex) => (
            <div
              key={copyIndex}
              ref={copyIndex === 0 ? contentRef : null}
              className="obsidian-text-stream__copy"
              style={{ display: "flex", flexDirection: "column" }}
              aria-hidden={copyIndex > 0}
            >
              {items.map((text, i) => (
                <div key={`${copyIndex}-${i}`} style={{ padding: "6px 0", whiteSpace: "nowrap" }}>
                  {text}
                </div>
              ))}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Preview behavior

Scroll the page to change the stream's momentum. The effect stays inside its container and respects reduced motion preferences.


---

# ObsidianUI — v-prism

[Canonical page](https://www.obsidianui.dev/docs/v-prism) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

An interactive glass prism that bends a beam into a spectrum of color. The source setup includes vGPU tooling; this component renders with Three.js. Recreated from Vercel's v-prism.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/v-prism)

Click and drag inside the preview to aim the beam at the prism.

```tsx
import { VPrism } from "@/components/block/v-prism";

export default function Demo() {
  return <div className="h-[500px] overflow-hidden rounded-3xl"><VPrism /></div>;
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/v-prism.json"
```

## Usage

```jsx
import { VPrism } from "@/components/block/v-prism";

export default function Demo() {
  return <div className="h-[500px] overflow-hidden rounded-3xl"><VPrism /></div>;
}
```

## Install manually — complete source

Download the complete manifest: [v-prism.json](https://www.obsidianui.dev/r/v-prism.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install next-themes postprocessing three
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

### components/block/v-prism-settings.ts

Installation target: `@components/block/v-prism-settings.ts`

```ts
export interface SceneSettings {
  ambientLight: number;
  pointLights: number;
  spotIntensity: number;
  rainbowGlow: number;
  bloom: number;
  roughness: number;
  ior: number;
  thickness: number;
  background: string;
  prismTint: string;
}

export const DARK_PRESET: SceneSettings = {
  ambientLight: 0.015,
  pointLights: 0.05,
  spotIntensity: 1.0,
  rainbowGlow: 2.5,
  bloom: 0.5,
  roughness: 0,
  ior: 1.5,
  thickness: 0.9,
  background: "#000000",
  prismTint: "#ffffff",
};

export const LIGHT_PRESET: SceneSettings = {
  ...DARK_PRESET,
  bloom: 0.5,
  background: "#ffffff",
};
```

### components/block/v-prism.tsx

Installation target: `@components/block/v-prism.tsx`

```tsx
'use client';

import React, { useEffect, useRef } from 'react';
import { useTheme } from 'next-themes';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {
  EffectComposer,
  RenderPass,
  BloomEffect,
  EffectPass,
  FXAAEffect,
} from 'postprocessing';
import { DARK_PRESET, LIGHT_PRESET, type SceneSettings } from './v-prism-settings';

interface VPrismProps {
  settings?: SceneSettings;
  className?: string;
}

// ---------------------------------------------------------------------
// Alan Zucconi's Spectral Rainbow + JuliaPoo's Iridescence (Vercel Prism)
// ---------------------------------------------------------------------
const vertexShader = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    gl_Position = projectionMatrix * viewMatrix * modelPosition;
  }
`;

const fragmentShader = `
  varying vec2 vUv;
  uniform float fade;
  uniform float speed;
  uniform float startRadius;
  uniform float endRadius;
  uniform float emissiveIntensity;
  uniform float time;
  uniform float ratio;
  uniform vec3 bgColor;

  vec3 physhue2rgb(float hue, float ratio) {
    return smoothstep(
      vec3(0.0), vec3(1.0),
      abs(mod(hue + vec3(0.0, 1.0, 2.0) * ratio, 1.0) * 2.0 - 1.0)
    );
  }

  vec3 iridescence(float angle, float thickness) {
    float NxV = cos(angle);
    float lum = 0.05064;
    float luma = 0.01070;
    vec3 tint = vec3(0.49639, 0.78252, 0.8723);
    float interf0 = 2.4;
    float phase0 = 1.0 / 2.8;
    float interf1 = interf0 * 4.0 / 3.0;
    float phase1 = phase0;
    float f = (1.0 - NxV) * (1.0 - NxV);
    float interf = mix(interf0, interf1, f);
    float phase = mix(phase0, phase1, f);
    float dp = (NxV - 1.0) * 0.5;

    vec3 hue = mix(
      physhue2rgb(thickness * interf0 + dp, thickness * phase0),
      physhue2rgb(thickness * interf1 + 0.1 + dp, thickness * phase1),
      f
    );
    vec3 film = hue * lum + vec3(0.9639, 0.78252, 0.18723) * luma;
    return vec3((film * 3.0 + pow(f, 12.0))) * tint;
  }

  float _saturate(float x) {
    return clamp(x, 0.0, 1.0);
  }

  vec3 _saturate(vec3 x) {
    return clamp(x, vec3(0.0), vec3(1.0));
  }

  vec3 bump3y(vec3 x, vec3 yoffset) {
    vec3 y = vec3(1.0) - x * x;
    y = _saturate(y - yoffset);
    return y;
  }

  vec3 spectral_zucconi6(float w, float t) {
    float x = _saturate((w - 400.0) / 300.0);
    const vec3 c1 = vec3(3.54585104, 2.93225262, 2.41593945);
    const vec3 x1 = vec3(0.69549072, 0.49228336, 0.27699880);
    const vec3 y1 = vec3(0.02312639, 0.15225084, 0.52607955);
    const vec3 c2 = vec3(3.90307140, 3.21182957, 3.96587128);
    const vec3 x2 = vec3(0.11748627, 0.86755042, 0.66077860);
    const vec3 y2 = vec3(0.84897130, 0.88445281, 0.73949448);
    return bump3y(c1 * (x - x1), y1) + bump3y(c2 * (x - x2), y2);
  }

  void main() {
    const vec2 vstart = vec2(0.5, 0.5);
    const vec2 vend = vec2(1.0, 0.5);
    vec2 dir = vstart - vend;
    float len = length(dir);
    float cosR = dir.y / len;
    float sinR = dir.x / len;
    vec2 uv = (
      mat2(cosR, -sinR, sinR, cosR) *
      (vUv * vec2(ratio, 1.0) - vec2(0.0, 1.0) - vstart * vec2(1.0, -1.0))
      / len
    );
    float a = atan(uv.x, uv.y) * 10.0;
    float s = uv.y * (endRadius - startRadius) + startRadius;
    float w = (uv.x / max(abs(s), 0.0001) + 0.5) * 300.0 + 400.0 + a;
    vec3 c = spectral_zucconi6(w, time);
    float l = 1.0 - smoothstep(fade, 1.0, uv.y);
    float area = uv.y < 0.0 ? 0.0 : 1.0;
    float brightness = smoothstep(0.0, 0.5, c.x + c.y + c.z);
    vec3 co = c / max(iridescence(uv.x * 0.5 * 3.14159265, 1.0 - uv.y + time / 10.0), vec3(0.0001)) / 20.0;
    vec3 col = area * co * l * brightness * emissiveIntensity;
    float intensity = col.r + col.g + col.b;

    float edge = smoothstep(0.05, 1.0, intensity);
    gl_FragColor = vec4(mix(bgColor, col, edge), 1.0);
    if (intensity < 0.05) discard;

    #include <colorspace_fragment>
  }
`;

function isLightColor(colorHex: string): boolean {
  const hex = colorHex.replace('#', '');
  if (hex.length < 6) return false;
  const r = parseInt(hex.slice(0, 2), 16) / 255;
  const g = parseInt(hex.slice(2, 4), 16) / 255;
  const b = parseInt(hex.slice(4, 6), 16) / 255;
  return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5;
}

const MOBILE_BREAKPOINT = 600;

function getZoomScale(w: number): number {
  return w <= MOBILE_BREAKPOINT ? 50 : w <= 960 ? 70 : 100;
}

function snellDeflection(angle: number, ior = 2.5, nAir = 1.000293): number {
  return Math.asin((nAir * Math.sin(angle)) / ior) || 0;
}

export function VPrism({ settings, className = '' }: VPrismProps) {
  const { resolvedTheme } = useTheme();
  const activeSettings = settings ?? (resolvedTheme === 'dark' ? DARK_PRESET : LIGHT_PRESET);
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const settingsRef = useRef<SceneSettings>(activeSettings);

  useEffect(() => {
    settingsRef.current = activeSettings;
  }, [activeSettings]);

  useEffect(() => {
    const container = containerRef.current;
    const canvas = canvasRef.current;
    if (!container || !canvas) return;

    let isDisposed = false;
    let animId: number;

    let width = Math.max(1, container.clientWidth || window.innerWidth);
    let height = Math.max(1, container.clientHeight || window.innerHeight);

    // --- RENDERER ---
    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({
        canvas,
        antialias: false,
        powerPreference: 'high-performance',
        stencil: false,
        alpha: false,
        depth: true,
      });
    } catch {
      return;
    }
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.0;
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.setSize(width, height, false);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

    // --- SCENE & BACKGROUND ---
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(settingsRef.current.background);

    // --- ORTHOGRAPHIC CAMERA ---
    const camera = new THREE.OrthographicCamera(
      width / -2,
      width / 2,
      height / 2,
      height / -2,
      0.1,
      1000
    );
    camera.position.set(0, 0, 100);
    camera.zoom = getZoomScale(width);
    camera.updateProjectionMatrix();

    // --- POSTPROCESSING (BLOOM + FXAA via postprocessing) ---
    const composer = new EffectComposer(renderer, {
      frameBufferType: THREE.HalfFloatType,
      multisampling: 0,
      stencilBuffer: false,
    });
    composer.addPass(new RenderPass(scene, camera));

    const bloomEffect = new BloomEffect({
      intensity: settingsRef.current.bloom,
      levels: 9,
      luminanceSmoothing: 1,
      luminanceThreshold: 1,
      mipmapBlur: true,
    });

    const fxaaPass = new EffectPass(camera, new FXAAEffect());
    const bloomPass = new EffectPass(camera, bloomEffect);
    composer.addPass(fxaaPass);
    composer.addPass(bloomPass);

    // --- LIGHTS ---
    const ambientLight = new THREE.AmbientLight(0xffffff, settingsRef.current.ambientLight);
    scene.add(ambientLight);

    const pointLights = [
      new THREE.PointLight(0xffffff, settingsRef.current.pointLights),
      new THREE.PointLight(0xffffff, settingsRef.current.pointLights),
      new THREE.PointLight(0xffffff, settingsRef.current.pointLights),
    ];
    pointLights[0].position.set(10, -10, 0);
    pointLights[1].position.set(0, 10, 0);
    pointLights[2].position.set(-10, 0, 0);
    scene.add(...pointLights);

    const spotLight = new THREE.SpotLight(0xffffff, 1, 7, 1, 1);
    spotLight.position.set(0, 0, 1);
    scene.add(spotLight, spotLight.target);

    // Shared plane geometry
    const planeGeo = new THREE.PlaneGeometry(1, 1);

    // Prism & Ray mesh/material declarations
    let prismMesh: THREE.Mesh | null = null;
    let prismPhysicalMat: THREE.MeshPhysicalMaterial | null = null;
    let rayHitProxy: THREE.Mesh | null = null;
    let rayLineMesh: THREE.InstancedMesh | null = null;
    let rayGlowMesh: THREE.InstancedMesh | null = null;
    let rayDarkMat: THREE.MeshBasicMaterial | null = null;
    let rayLightMat: THREE.MeshBasicMaterial | null = null;

    // --- ENVIRONMENT MAP CUBE FOR CRYSTAL SPECULARITY ---
    let cubeRT: THREE.WebGLCubeRenderTarget | null = null;
    function updateEnvironment(isLight: boolean) {
      if (isLight) {
        if (!cubeRT) {
          cubeRT = new THREE.WebGLCubeRenderTarget(256);
          const envScene = new THREE.Scene();
          envScene.background = new THREE.Color('#000000');

          const addSoftbox = (intensity: number, pos: [number, number, number], scale: [number, number, number]) => {
            const mesh = new THREE.Mesh(
              planeGeo,
              new THREE.MeshBasicMaterial({
                color: new THREE.Color(intensity, intensity, intensity),
                toneMapped: false,
                side: THREE.DoubleSide,
              })
            );
            mesh.position.set(...pos);
            mesh.scale.set(...scale);
            mesh.lookAt(0, 0, 0);
            envScene.add(mesh);
          };

          addSoftbox(2.5, [0, 4, 5], [6, 3, 1]);
          addSoftbox(2.0, [-4, -1, 2], [6, 2, 1]);
          addSoftbox(2.0, [4, -1, 2], [6, 2, 1]);

          const cubeCam = new THREE.CubeCamera(0.1, 100, cubeRT);
          cubeCam.update(renderer, envScene);
        }
        scene.environment = cubeRT.texture;
        if (prismPhysicalMat) prismPhysicalMat.needsUpdate = true;
      } else {
        scene.environment = null;
        if (prismPhysicalMat) prismPhysicalMat.needsUpdate = true;
      }
    }
    updateEnvironment(isLightColor(settingsRef.current.background));

    // --- RAINBOW MESH ---
    const rainbowMaterial = new THREE.ShaderMaterial({
      vertexShader,
      fragmentShader,
      toneMapped: false,
      uniforms: {
        time: { value: 0 },
        speed: { value: 1 },
        fade: { value: 0 },
        startRadius: { value: 0 },
        endRadius: { value: 0.5 },
        emissiveIntensity: { value: 0 },
        ratio: { value: 1 },
        bgColor: { value: new THREE.Color(settingsRef.current.background) },
      },
    });

    const rainbowMesh = new THREE.Mesh(planeGeo, rainbowMaterial);
    rainbowMesh.position.set(0, width <= MOBILE_BREAKPOINT ? -0.5 : 0, 0);
    scene.add(rainbowMesh);

    function updateRainbowScale() {
      const z = getZoomScale(width);
      const hyp = Math.hypot(width / z, height / z) + 1.5;
      rainbowMesh.scale.set(hyp, hyp, 1);
    }
    updateRainbowScale();

    // --- TEXTURES & FLARES ---
    const textureLoader = new THREE.TextureLoader();
    let streakTex: THREE.Texture | null = null;
    let glowTex: THREE.Texture | null = null;
    let flareDotTex: THREE.Texture | null = null;
    let flareGlowTex: THREE.Texture | null = null;

    // Flare Group (y)
    const flareGroup = new THREE.Group();
    flareGroup.scale.setScalar(1.25);
    flareGroup.visible = false;
    flareGroup.renderOrder = 10;
    scene.add(flareGroup);

    const flareScales = [0.5, 1.25, 0.75, 1.5, 2.0];
    let flareInstanced: THREE.InstancedMesh | null = null;

    // Ray Group (x)
    const rayGroup = new THREE.Group();
    scene.add(rayGroup);

    // Prism Root Group
    const prismGroup = new THREE.Group();
    prismGroup.position.set(0, width <= MOBILE_BREAKPOINT ? -0.5 : 0, 0);
    scene.add(prismGroup);

    // Ray positions buffer
    const rayStart = new THREE.Vector3(-10, -0.05, 0);
    const rayEnd = new THREE.Vector3(0, 0, 0);
    const rayPositions = new Float32Array(33);
    let rayCount = 0;
    let rayIntersecting = false;

    // Load assets
    Promise.all([
      textureLoader.loadAsync('https://cdn-new.obsidianui.dev/effects/v-prism/lensflare2.png'),
      textureLoader.loadAsync('https://cdn-new.obsidianui.dev/effects/v-prism/lensflare0_bw.jpg'),
      textureLoader.loadAsync('https://cdn-new.obsidianui.dev/effects/v-prism/lensflare3.png'),
      textureLoader.loadAsync('https://cdn-new.obsidianui.dev/effects/v-prism/lensflare0_bw.png'),
      new GLTFLoader().loadAsync('https://cdn-new.obsidianui.dev/effects/v-prism/prism.glb'),
    ]).then(([sTex, gTex, dotTex, fGlowTex, gltf]) => {
      if (isDisposed) return;

      streakTex = sTex;
      glowTex = gTex;
      flareDotTex = dotTex;
      flareGlowTex = fGlowTex;

      // --- BUILD FLARE GROUP (Qx) ---
      const blendProps = {
        transparent: true,
        opacity: 1,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
        toneMapped: false,
      };

      flareInstanced = new THREE.InstancedMesh(
        planeGeo,
        new THREE.MeshBasicMaterial({ map: flareDotTex, ...blendProps, opacity: 0.12 }),
        flareScales.length
      );
      flareInstanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      flareInstanced.count = flareScales.length;
      flareGroup.add(flareInstanced);

      const flareCore = new THREE.Mesh(
        planeGeo,
        new THREE.MeshBasicMaterial({ map: flareGlowTex, ...blendProps, opacity: 0.75 })
      );
      flareCore.scale.setScalar(0.12);
      flareGroup.add(flareCore);

      const flareHalo = new THREE.Mesh(
        planeGeo,
        new THREE.MeshBasicMaterial({ map: flareGlowTex, ...blendProps, opacity: 0.1 })
      );
      flareGroup.add(flareHalo);

      const flareStreak = new THREE.Mesh(
        planeGeo,
        new THREE.MeshBasicMaterial({ map: streakTex, ...blendProps, opacity: 0.08 })
      );
      flareStreak.rotation.z = Math.PI / 2;
      flareStreak.scale.set(12.5, 20, 1);
      flareGroup.add(flareStreak);

      // --- BUILD INCIDENT BEAM (Uv) ---
      rayDarkMat = new THREE.MeshBasicMaterial({
        map: streakTex,
        color: '#686868',
        opacity: 1.5,
        transparent: false,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
        toneMapped: false,
      });

      rayLightMat = new THREE.MeshBasicMaterial({
        color: '#666666',
        depthWrite: false,
        opacity: 0.7,
        toneMapped: false,
        transparent: true,
      });

      rayLineMesh = new THREE.InstancedMesh(planeGeo, rayDarkMat, 100);
      rayLineMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);

      rayGlowMesh = new THREE.InstancedMesh(
        planeGeo,
        new THREE.MeshBasicMaterial({ map: glowTex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }),
        100
      );
      rayGlowMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      rayGroup.add(rayLineMesh, rayGlowMesh);

      // --- BUILD PRISM (Fx) ---
      const coneObj = gltf.scene.getObjectByName('Cone') as THREE.Mesh;

      // 1. Ray hit proxy: 3-sided cylinder matching Vercel's Prism geometry
      rayHitProxy = new THREE.Mesh(
        new THREE.CylinderGeometry(1, 1, 1, 3, 1),
        new THREE.MeshBasicMaterial()
      );
      rayHitProxy.rotation.set(Math.PI / 2, Math.PI, 0);
      rayHitProxy.scale.setScalar(1.9);
      rayHitProxy.visible = false;
      prismGroup.add(rayHitProxy);

      // 2. Visible crystal prism mesh
      prismPhysicalMat = new THREE.MeshPhysicalMaterial({
        clearcoat: 1,
        clearcoatRoughness: 0,
        roughness: settingsRef.current.roughness,
        toneMapped: false,
        transmission: 1,
        ior: settingsRef.current.ior,
        thickness: settingsRef.current.thickness,
        color: new THREE.Color(settingsRef.current.prismTint),
      });

      prismMesh = new THREE.Mesh(coneObj.geometry, prismPhysicalMat);
      prismMesh.position.set(0, 0, 0.6);
      prismMesh.renderOrder = 10;
      prismMesh.scale.setScalar(2);
      prismGroup.add(prismMesh);

      // Initial ray update
      updateRayPhysics();
    }).catch(err => {
      console.error('Failed to load prism assets:', err);
    });

    // --- RAY PHYSICS & REFLECTION (Lv + Uv + Fx) ---
    const raycaster = new THREE.Raycaster();
    const scratchMat4 = new THREE.Matrix4();
    const scratchPos = new THREE.Vector3();
    const scratchScale = new THREE.Vector3();
    const scratchDir = new THREE.Vector3();
    const scratchExt = new THREE.Vector3();
    const vecA = new THREE.Vector3();
    const vecB = new THREE.Vector3();
    const vecDir = new THREE.Vector3();

    function setRay(start: [number, number, number], end: [number, number, number]) {
      rayStart.set(...start);
      rayEnd.set(...end);
      updateRayPhysics();
    }

    function updateRayPhysics() {
      if (!rayHitProxy) return;

      vecA.copy(rayStart);
      vecB.copy(rayEnd);
      vecDir.subVectors(vecB, vecA).normalize();

      rayCount = 0;
      vecA.toArray(rayPositions, rayCount++ * 3);

      raycaster.set(vecA, vecDir);
      const hits = raycaster.intersectObject(rayHitProxy, false);
      const hit = hits[0];

      if (hit && hit.face) {
        const hitPoint = hit.point;
        const hitNormal = hit.face.normal;

        hitPoint.toArray(rayPositions, rayCount++ * 3);
        rayIntersecting = true;

        // Flare positioning (y)
        const isLight = isLightColor(settingsRef.current.background);
        flareGroup.visible = !isLight && rayIntersecting;
        flareGroup.position.set(hitPoint.x, hitPoint.y, -0.5);
        flareGroup.rotation.set(0, 0, -Math.atan2(vecDir.x, vecDir.y));

        // Snell deflection angle (ve)
        let j = Math.atan2(-hitPoint.y, -hitPoint.x);
        const Z = Math.atan2(hitNormal.y, hitNormal.x);
        const re = j - Z;
        const ve = snellDeflection(re, settingsRef.current.ior) * 6;
        j += ve;

        rainbowMesh.rotation.z = j;
        const W = Math.cos(j);
        const Le = Math.sin(j);
        spotLight.target.position.set(W, Le, 0);
        spotLight.target.updateMatrixWorld();
      } else {
        rayIntersecting = false;
        flareGroup.visible = false;
        scratchExt.copy(vecDir).multiplyScalar(20);
        vecB.addVectors(vecA, scratchExt).toArray(rayPositions, rayCount++ * 3);
      }

      // Update incident beam geometry (Uv) with zero heap allocations
      if (rayLineMesh && rayGlowMesh) {
        const isLight = isLightColor(settingsRef.current.background);
        rayLineMesh.material = isLight && rayLightMat ? rayLightMat : (rayDarkMat || rayLineMesh.material);
        rayGlowMesh.visible = !isLight;

        const stride = isLight ? 1 : 4;
        const beamWidth = isLight ? 0.06 : 8;

        const segs = rayCount - 1;
        for (let i = 0; i < segs; i++) {
          vecA.fromArray(rayPositions, i * 3);
          vecB.fromArray(rayPositions, i * 3 + 3);
          scratchDir.subVectors(vecB, vecA).normalize();

          scratchPos.addVectors(vecA, vecB).multiplyScalar(0.5);
          const rotZ = Math.atan2(scratchDir.y, scratchDir.x);
          const scX = vecB.distanceTo(vecA) * stride;

          scratchMat4.makeRotationZ(rotZ);
          scratchMat4.setPosition(scratchPos);
          scratchScale.set(scX, beamWidth, 1);
          scratchMat4.scale(scratchScale);

          rayLineMesh.setMatrixAt(i, scratchMat4);
        }
        rayLineMesh.count = segs;
        rayLineMesh.instanceMatrix.needsUpdate = true;

        // Joints glow (u)
        scratchMat4.identity();
        scratchMat4.makeScale(0, 0, 0);
        rayGlowMesh.setMatrixAt(0, scratchMat4);

        scratchScale.set(0.75, 0.75, 1);
        for (let i = 1; i < segs; i++) {
          scratchPos.fromArray(rayPositions, i * 3);
          scratchMat4.identity();
          scratchMat4.setPosition(scratchPos);
          scratchMat4.scale(scratchScale);
          rayGlowMesh.setMatrixAt(i, scratchMat4);
        }
        rayGlowMesh.count = segs;
        rayGlowMesh.instanceMatrix.needsUpdate = true;
      }
    }

    // Aim beam at prism from pointer coords (Wh + D)
    function aimRayAt(clientX: number, clientY: number) {
      const rect = container?.getBoundingClientRect();
      if (!rect) return;

      const Q = getZoomScale(width);
      const N = 0;
      const K = width <= MOBILE_BREAKPOINT ? -0.5 : 0;

      const relX = clientX - rect.left;
      const relY = clientY - rect.top;
      const re = (relX - width / 2) / Q;
      const ve = (height / 2 - relY) / Q;

      let W = N - re;
      let Le = K - ve;
      const hyp = Math.hypot(W, Le);
      if (hyp < 0.01) return;
      W /= hyp;
      Le /= hyp;

      const start: [number, number, number] = [N - W * 10, K - Le * 10, 0];
      const end: [number, number, number] = [N, K, 0];
      setRay(start, end);
    }

    // --- POINTER INTERACTIONS (RAF-throttled for zero mouse jank) ---
    let isPointerDown = false;
    let pendingPointerX: number | null = null;
    let pendingPointerY: number | null = null;

    const handlePointerDown = (e: PointerEvent) => {
      if (e.button !== 0 || !e.isPrimary) return;
      isPointerDown = true;
      container.setPointerCapture(e.pointerId);
      aimRayAt(e.clientX, e.clientY);
    };

    const handlePointerMove = (e: PointerEvent) => {
      if (isPointerDown) {
        pendingPointerX = e.clientX;
        pendingPointerY = e.clientY;
      }
    };

    const handlePointerUp = () => {
      isPointerDown = false;
      pendingPointerX = null;
      pendingPointerY = null;
    };

    container.addEventListener('pointerdown', handlePointerDown);
    container.addEventListener('pointermove', handlePointerMove);
    container.addEventListener('pointerup', handlePointerUp);
    container.addEventListener('pointercancel', handlePointerUp);

    // Initial default beam angle
    setTimeout(() => {
      if (!isDisposed) {
        setRay([-10, -0.05, 0], [0, 0, 0]);
      }
    }, 200);

    // --- RESIZE HANDLER ---
    const handleResize = () => {
      if (!container) return;
      width = Math.max(1, container.clientWidth || window.innerWidth);
      height = Math.max(1, container.clientHeight || window.innerHeight);

      renderer.setSize(width, height, false);
      composer.setSize(width, height);

      camera.left = width / -2;
      camera.right = width / 2;
      camera.top = height / 2;
      camera.bottom = height / -2;
      camera.zoom = getZoomScale(width);
      camera.updateProjectionMatrix();

      const centerY = width <= MOBILE_BREAKPOINT ? -0.5 : 0;
      prismGroup.position.set(0, centerY, 0);
      rainbowMesh.position.set(0, centerY, 0);
      updateRainbowScale();
      updateRayPhysics();
    };

    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(container);
    window.addEventListener('resize', handleResize);

    // --- ANIMATION LOOP ---
    let prevTime = performance.now();
    let elapsedTime = 0;
    let lastBgHex = settingsRef.current.background;
    const dummyObj = new THREE.Object3D();

    const animate = (currentTime: number) => {
      animId = requestAnimationFrame(animate);

      // Process throttled pointer updates
      if (pendingPointerX !== null && pendingPointerY !== null) {
        aimRayAt(pendingPointerX, pendingPointerY);
        pendingPointerX = null;
        pendingPointerY = null;
      }

      const delta = Math.min((currentTime - prevTime) / 1000, 0.1);
      prevTime = currentTime;
      elapsedTime += delta;

      // Update rainbow speed and emissive intensity
      const curSettings = settingsRef.current;
      const targetEmissive = rayIntersecting ? curSettings.rainbowGlow : 0;
      rainbowMaterial.uniforms.emissiveIntensity.value = THREE.MathUtils.lerp(
        rainbowMaterial.uniforms.emissiveIntensity.value,
        targetEmissive,
        0.1
      );
      rainbowMaterial.uniforms.time.value += delta * rainbowMaterial.uniforms.speed.value;

      spotLight.intensity = rainbowMaterial.uniforms.emissiveIntensity.value * curSettings.spotIntensity;
      ambientLight.intensity = THREE.MathUtils.lerp(ambientLight.intensity, curSettings.ambientLight, 0.05);

      // Animate floating bokeh flare discs (Qx)
      if (flareGroup.visible && flareInstanced) {
        flareScales.forEach((sc, f) => {
          dummyObj.scale.setScalar(sc);
          dummyObj.position.x = (Math[sc > 1 ? 'sin' : 'cos']((elapsedTime * sc) / 2) * sc) / 8;
          dummyObj.position.y = (Math[sc > 1 ? 'cos' : 'atan'](elapsedTime * sc) * sc) / 5;
          dummyObj.position.z = f === flareScales.length - 1 ? -0.7 : 0;
          dummyObj.updateMatrix();
          flareInstanced?.setMatrixAt(f, dummyObj.matrix);
        });
        flareInstanced.instanceMatrix.needsUpdate = true;
      }

      // Sync settings to prism material
      if (prismPhysicalMat) {
        prismPhysicalMat.roughness = curSettings.roughness;
        prismPhysicalMat.ior = curSettings.ior;
        prismPhysicalMat.thickness = curSettings.thickness;
        prismPhysicalMat.color.set(curSettings.prismTint);
      }

      // Sync background color & react to light mode switch
      const currentBgHex = curSettings.background;
      const isLight = isLightColor(currentBgHex);
      if (lastBgHex !== currentBgHex) {
        lastBgHex = currentBgHex;
        scene.background = new THREE.Color(currentBgHex);
        rainbowMaterial.uniforms.bgColor.value.set(currentBgHex);
        updateEnvironment(isLight);
        updateRayPhysics();
      }

      // Sync point lights
      pointLights.forEach(pl => {
        pl.intensity = curSettings.pointLights;
      });

      // Render: in light mode, bloom is disabled to prevent blowout;
      // in dark mode, postprocessing with BloomEffect renders smoothly
      if (bloomEffect) {
        bloomEffect.intensity = isLight ? 0 : curSettings.bloom;
      }
      composer.render();
    };

    animId = requestAnimationFrame(animate);

    // --- CLEANUP ---
    return () => {
      isDisposed = true;
      cancelAnimationFrame(animId);
      resizeObserver.disconnect();
      window.removeEventListener('resize', handleResize);
      container.removeEventListener('pointerdown', handlePointerDown);
      container.removeEventListener('pointermove', handlePointerMove);
      container.removeEventListener('pointerup', handlePointerUp);
      container.removeEventListener('pointercancel', handlePointerUp);

      composer.dispose();
      cubeRT?.dispose();
      planeGeo.dispose();
      rainbowMaterial.dispose();
      streakTex?.dispose();
      glowTex?.dispose();
      flareDotTex?.dispose();
      flareGlowTex?.dispose();
      rayDarkMat?.dispose();
      rayLightMat?.dispose();
      prismPhysicalMat?.dispose();
      renderer.dispose();
    };
  }, []);

  return (
    <div
      ref={containerRef}
      className={`relative h-full w-full overflow-hidden select-none touch-none ${className}`}
    >
      <canvas ref={canvasRef} aria-label="Interactive glass prism with a movable light beam" className="h-full w-full block" />
    </div>
  );
}

export default VPrism;
```

## Scene settings

The reference project exposes these values in its scene tuning panel. Adjust them to change the lighting, spectrum, glass, and background.

```json
{
  "ambientLight": 0.015,
  "pointLights": 0.05,
  "spotIntensity": 1,
  "rainbowGlow": 2.5,
  "bloom": 0.5,
  "reflections": 2.5,
  "roughness": 0,
  "ior": 1.5,
  "thickness": 0.9,
  "background": "#000000",
  "prismTint": "#ffffff"
}
```

| Setting | What it controls | Reference range |
| --- | --- | --- |
| ambientLight | Low-level light across the scene. | 0–0.2 |
| pointLights | Strength of the three point lights around the prism. | 0–0.5 |
| spotIntensity | Spotlight strength applied to the spectrum beam. | 0–3 |
| rainbowGlow | Emission intensity of the rainbow when the beam hits the prism. | 0–10 |
| bloom | Glow added around bright parts of the beam. | 0–5 |
| reflections | Screen-space reflection strength in the reference project. | 0–10 |
| roughness | Surface roughness of the glass; lower values look smoother. | 0–1 |
| ior | Glass index of refraction, which changes how strongly the beam bends. | 1–2.33 |
| thickness | Transmission thickness used by the glass material. | 0–3 |
| background | Scene background color as a hex value. | Any CSS hex color |
| prismTint | Base tint color of the prism glass as a hex value. | Any CSS hex color |

The settings above match the reference project's tuning panel. The published ObsidianUI VPrism currently accepts the other values through its settings prop; reflections is only supported by the full reference implementation and is ignored by the published component.

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

## Preview behavior

Click and drag inside the preview to aim the beam at the prism. The effect stays inside its container and respects reduced motion preferences.


---

# ObsidianUI — Analytics Dashboard

[Canonical page](https://www.obsidianui.dev/docs/analytics-dashboard) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

A complete engineering analytics app in one component. It has an overview with metric cards, a commit chart, and top contributors, a searchable repository grid, a code review queue with status filters, and a settings page. Selecting a pull request opens a details panel with an assistant you can ask about it.

Open the preview full screen to see the docked details panel. In containers narrower than 1180px the panel slides over the content, and below 760px the sidebar floats over the page.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/analytics-dashboard)

```tsx
'use client'

import { AnalyticsDashboard } from '@/components/block/analytics-dashboard'

export function Demo() {
return (
  <div className="h-dvh">
    <AnalyticsDashboard />
  </div>
)
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/analytics-dashboard.json"
```

## Usage

```tsx
import { AnalyticsDashboard } from '@/components/block/analytics-dashboard'

<div className="h-dvh">
  <AnalyticsDashboard />
</div>
```

The dashboard fills its parent, so give the parent a height, such as `h-dvh` for a full page. Without a `data` prop it shows demo data, so you can see every page before connecting your own.

## Use it as your /dashboard route

By default the dashboard sits in a rounded, floating window, which suits previews and landing pages. In an app, set `variant="fill"` to drop the frame so it fills the route edge to edge:

```tsx
// app/dashboard/page.tsx
import { AnalyticsDashboard } from '@/components/block/analytics-dashboard'

export default function DashboardPage() {
  return (
    <div className="h-dvh">
      <AnalyticsDashboard variant="fill" />
    </div>
  )
}
```

To keep the open page in the URL, so a refresh or a shared link opens the same page, control it with `page` and `onPageChange`. `useSearchParams` needs a `Suspense` boundary, so the dashboard lives in its own client file:

```tsx
// app/dashboard/dashboard.tsx
'use client'

import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { AnalyticsDashboard, type AnalyticsDashboardPage } from '@/components/block/analytics-dashboard'

const pages: AnalyticsDashboardPage[] = ['overview', 'repositories', 'reviews', 'settings']

export function Dashboard() {
  const router = useRouter()
  const pathname = usePathname()
  const requested = useSearchParams().get('page')
  const page = pages.find(name => name === requested) ?? 'overview'

  return (
    <div className="h-dvh">
      <AnalyticsDashboard
        variant="fill"
        page={page}
        onPageChange={next => router.replace(`${pathname}?page=${next}`, { scroll: false })}
      />
    </div>
  )
}
```

```tsx
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Dashboard } from './dashboard'

export default function DashboardPage() {
  return (
    <Suspense>
      <Dashboard />
    </Suspense>
  )
}
```

Then connect your own numbers through `data`, as shown next.

## Your data

Pass your team, metrics, repositories, and pull requests through `data`. Pull requests and activity refer to people by `handle`, and each person brings an avatar and a chart color:

```tsx
import { AnalyticsDashboard, type AnalyticsDashboardData } from '@/components/block/analytics-dashboard'

const data: AnalyticsDashboardData = {
  people: [
    { handle: 'maya', avatar: '/avatars/maya.webp', color: '#f08a3c', role: 'Frontend' },
    { handle: 'leo', avatar: '/avatars/leo.webp', color: '#9d8df5', role: 'Platform' },
  ],
  ranges: [
    { id: 'week', label: 'Week', caption: 'Apr 13 – Apr 17, 2026' },
    { id: 'month', label: 'Month', caption: 'March 20 – April 17, 2026' },
  ],
  kpis: {
    week: [
      { id: 'commits', label: 'Commits', icon: 'commit', value: '91', delta: 12, spark: [8, 11, 9, 14, 12, 16] },
      { id: 'review', label: 'Avg. Review Time', icon: 'clock', value: '3.4h', delta: -18, lowerIsBetter: true, spark: [5.2, 4.9, 4.4, 3.8, 3.4] },
    ],
    month: [],
  },
  activity: {
    week: {
      labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
      series: { maya: [7, 11, 9, 13, 10], leo: [5, 8, 4, 9, 5] },
      lines: { maya: { added: 3400, removed: 2300 }, leo: { added: 2100, removed: 1400 } },
    },
    month: { labels: [], series: {}, lines: {} },
  },
  repositories: [],
  pullRequests: [],
}

<AnalyticsDashboard
  data={data}
  brand={{ name: 'Acme Engineering' }}
  user={{ name: 'maya', email: 'maya@acme.dev', avatar: '/avatars/maya.webp', role: 'Frontend · Admin' }}
/>
```

`kpis` and `activity` are keyed by range id, so the range switcher on the overview swaps both. A metric's `value` is shown as written, so it can carry a unit like `3.4h`, and its digits roll up like a slot machine. Set `lowerIsBetter` on metrics such as review time, so a drop shows in green.

Start from `analyticsDashboardDemoData`, exported from the same file, when you only need to change part of the data.

## Assistant answers

The composer in the details panel sends questions to `onAsk` with the selected pull request. Return the answer as a string or a promise. While it waits, the panel shows a "thinking" shimmer:

```tsx
<AnalyticsDashboard
  assistantName="Claude"
  onAsk={async (question, pullRequest) => {
    const response = await fetch('/api/ask', {
      method: 'POST',
      body: JSON.stringify({ question, pullRequest: pullRequest.number }),
    })
    return response.text()
  }}
/>
```

Without `onAsk`, the panel replies with a short summary of the pull request.

## Pages

The sidebar switches between Overview, Repositories, Code review, and Settings without leaving the page. Track the page with `page` and `onPageChange`, for example to keep it in the URL, or set the first page with `defaultPage`.

## Light and dark mode

The dashboard has a light and a dark palette. It is light by default and switches to dark when a parent element has the `dark` class, the convention next-themes and shadcn/ui use. Add `dark` to the dashboard's own `className` to keep it dark on a light page.

```tsx
<AnalyticsDashboard className="dark" />
```

## Background and fonts

The floating window sits on a plain color: `#f6f6f7` in light mode and `#0b0b0c` in dark mode. With `variant="fill"` there is no frame, so this color doesn't show. Change it with `background`:

```tsx
<AnalyticsDashboard background="#1c1c1f" />
```

The dashboard uses Inter for text and JetBrains Mono for code, through the `--font-inter` and `--font-jetbrains-mono` variables. In Next.js, load both with `next/font` and add their variables to a parent element:

```tsx
import { Inter, JetBrains_Mono } from 'next/font/google'

const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-jetbrains-mono' })

<html lang="en" className={`${inter.variable} ${mono.variable}`}>
```

Without them, it falls back to the system fonts.

## Behavior

- The layout responds to the width of the dashboard, not the window, so it also works inside a panel or a preview. The details panel docks from 1180px, and the sidebar floats over the page below 760px.
- Escape closes the details panel or the floating sidebar, then returns focus to the button that opened it.
- Settings → Appearance changes the accent color of buttons, selections, and charts right away. Set the first accent with `defaultAccent`.
- With reduced motion, entrances fade in place, and the numbers, charts, and panels skip their movement.

## Install manually — complete source

Download the complete manifest: [analytics-dashboard.json](https://www.obsidianui.dev/r/analytics-dashboard.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install clsx motion tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

### components/analytics-dashboard/context.ts

Installation target: `@components/analytics-dashboard/context.ts`

```ts
"use client";

import { createContext, useContext } from "react";
import type { AnalyticsDashboardData, AnalyticsPerson, AnalyticsPullRequest } from "./data";

export type AnalyticsDashboardPage = "overview" | "repositories" | "reviews" | "settings";

export type AnalyticsDashboardAccent = "blue" | "violet" | "teal" | "amber";

export const ACCENTS: Record<AnalyticsDashboardAccent, { label: string; ui: string }> = {
  blue: { label: "Blue", ui: "#4c9bff" },
  violet: { label: "Violet", ui: "#9d8df5" },
  teal: { label: "Teal", ui: "#2fd0b5" },
  amber: { label: "Amber", ui: "#f2b54a" },
};

export type AnalyticsDashboardUser = {
  name: string;
  email: string;
  avatar: string;
  role?: string;
};

export type AnswerHandler = (question: string, pullRequest: AnalyticsPullRequest) => string | Promise<string>;

type DashboardState = {
  data: AnalyticsDashboardData;
  person: (handle: string) => AnalyticsPerson | undefined;
  user: AnalyticsDashboardUser;
  assistantName: string;
  answer?: AnswerHandler;
  /** Prefix for element ids, unique per dashboard instance. */
  uid: string;
  selected: number;
  openPr: (prNumber: number) => void;
  navigate: (page: AnalyticsDashboardPage) => void;
  accent: AnalyticsDashboardAccent;
  setAccent: (accent: AnalyticsDashboardAccent) => void;
};

export const DashboardContext = createContext<DashboardState | null>(null);

export function useDashboard() {
  const ctx = useContext(DashboardContext);
  if (!ctx) throw new Error("useDashboard must be used inside <AnalyticsDashboard>");
  return ctx;
}
```

### components/analytics-dashboard/data.ts

Installation target: `@components/analytics-dashboard/data.ts`

```ts
export type AnalyticsPerson = {
  /** Unique handle. Pull requests and activity series refer to people by it. */
  handle: string;
  /** Avatar image URL. */
  avatar: string;
  /** Any CSS color. Used for the chart segments, legend and share bars. */
  color: string;
  role: string;
};

export type AnalyticsRange = { id: string; label: string; caption: string };

export type AnalyticsKpiIcon = "commit" | "pull" | "merge" | "clock";

export type AnalyticsKpi = {
  id: string;
  label: string;
  icon: AnalyticsKpiIcon;
  /** Displayed as is, so it can carry a unit such as "3.4h". */
  value: string;
  /** Percent change against the previous period. */
  delta: number;
  /** For metrics like review time, going down is the good direction. */
  lowerIsBetter?: boolean;
  spark: number[];
};

export type AnalyticsActivity = {
  labels: string[];
  /** Commits per label, keyed by person handle. */
  series: Record<string, number[]>;
  lines: Record<string, { added: number; removed: number }>;
};

export type AnalyticsRepository = {
  name: string;
  description: string;
  language: string;
  languageColor: string;
  visibility: "Private" | "Public";
  openPrs: number;
  stars: number;
  updated: string;
  /** Minutes since the last push, used for sorting. */
  updatedMinutes: number;
  /** Commits per day, oldest first. */
  activity: number[];
};

export type AnalyticsPrStatus = { kind: "merged" | "review" | "open"; label: string };

export type AnalyticsPullRequest = {
  number: number;
  title: string;
  /** Handle of a person in `people`. */
  author: string;
  status: AnalyticsPrStatus;
  repository: string;
  opened: string;
  /** Short relative time used in lists. */
  age: string;
  /** Listed under "Today PRs" on the overview. */
  today: boolean;
  additions: number;
  deletions: number;
  comments: number;
  /** Paragraphs; text wrapped in backticks renders as inline code. */
  problem: string[];
  approach: string[];
};

export type AnalyticsDashboardData = {
  people: AnalyticsPerson[];
  ranges: AnalyticsRange[];
  /** Metric cards per range id. */
  kpis: Record<string, AnalyticsKpi[]>;
  /** Commit activity per range id. */
  activity: Record<string, AnalyticsActivity>;
  repositories: AnalyticsRepository[];
  pullRequests: AnalyticsPullRequest[];
};

export const analyticsDashboardDemoData: AnalyticsDashboardData = {
  people: [
    { handle: "atharv", avatar: "https://www.obsidianui.dev/analytics-dashboard/avatars/atharv.webp", color: "#f08a3c", role: "CLI & tooling" },
    { handle: "ananya", avatar: "https://www.obsidianui.dev/analytics-dashboard/avatars/ananya.webp", color: "#9d8df5", role: "Web platform" },
    { handle: "rahul", avatar: "https://www.obsidianui.dev/analytics-dashboard/avatars/rahul.webp", color: "#2fd06f", role: "Payments" },
  ],
  ranges: [
    { id: "today", label: "Today", caption: "Fri, Apr 17 · since 9:00 AM" },
    { id: "week", label: "Week", caption: "Apr 13 – Apr 17, 2026" },
    { id: "month", label: "Month", caption: "March 20 – April 17, 2026" },
  ],
  kpis: {
    today: [
      { id: "commits", label: "Commits", icon: "commit", value: "18", delta: 6, spark: [2, 3, 2, 4, 3, 5, 4, 6, 5, 7] },
      { id: "prs", label: "Pull Requests", icon: "pull", value: "12", delta: 9, spark: [1, 1, 2, 2, 3, 2, 3, 4, 3, 4] },
      { id: "merged", label: "Merged PRs", icon: "merge", value: "9", delta: 13, spark: [0, 1, 1, 2, 1, 2, 3, 2, 3, 4] },
      { id: "review", label: "Avg. Review Time", icon: "clock", value: "2.1h", delta: -9, lowerIsBetter: true, spark: [4, 3.6, 3.8, 3.1, 3.3, 2.8, 2.6, 2.7, 2.3, 2.1] },
    ],
    week: [
      { id: "commits", label: "Commits", icon: "commit", value: "91", delta: 12, spark: [8, 11, 9, 14, 12, 16, 13, 18, 15, 19] },
      { id: "prs", label: "Pull Requests", icon: "pull", value: "201", delta: 8, spark: [22, 25, 21, 27, 24, 30, 26, 29, 31, 33] },
      { id: "merged", label: "Merged PRs", icon: "merge", value: "184", delta: 15, spark: [18, 20, 19, 24, 22, 26, 25, 28, 27, 31] },
      { id: "review", label: "Avg. Review Time", icon: "clock", value: "3.4h", delta: -18, lowerIsBetter: true, spark: [5.2, 4.9, 5.1, 4.6, 4.4, 4.5, 4, 3.8, 3.6, 3.4] },
    ],
    month: [
      { id: "commits", label: "Commits", icon: "commit", value: "412", delta: 21, spark: [70, 82, 76, 90, 88, 97, 94, 105, 101, 112] },
      { id: "prs", label: "Pull Requests", icon: "pull", value: "836", delta: 11, spark: [160, 172, 168, 181, 176, 190, 194, 201, 198, 212] },
      { id: "merged", label: "Merged PRs", icon: "merge", value: "790", delta: 9, spark: [150, 158, 161, 166, 170, 176, 181, 184, 190, 196] },
      { id: "review", label: "Avg. Review Time", icon: "clock", value: "3.9h", delta: -6, lowerIsBetter: true, spark: [4.6, 4.4, 4.5, 4.3, 4.2, 4.1, 4.2, 4, 3.9, 3.9] },
    ],
  },
  activity: {
    today: {
      labels: ["9 AM", "11 AM", "1 PM", "3 PM", "5 PM"],
      series: { atharv: [2, 3, 1, 3, 2], ananya: [1, 1, 2, 1, 0], rahul: [0, 1, 1, 0, 1] },
      lines: { atharv: { added: 640, removed: 210 }, ananya: { added: 380, removed: 150 }, rahul: { added: 120, removed: 40 } },
    },
    week: {
      labels: ["Mon", "Tue", "Wed", "Thu", "Fri"],
      series: { atharv: [7, 11, 9, 13, 10], ananya: [5, 8, 4, 9, 5], rahul: [4, 3, 6, 4, 4] },
      lines: { atharv: { added: 3400, removed: 2300 }, ananya: { added: 2100, removed: 1400 }, rahul: { added: 1200, removed: 600 } },
    },
    month: {
      labels: ["Mar 20", "Mar 27", "Apr 3", "Apr 10", "Apr 17"],
      series: { atharv: [34, 41, 38, 46, 50], ananya: [22, 25, 31, 27, 31], rahul: [14, 18, 12, 19, 21] },
      lines: { atharv: { added: 14800, removed: 9100 }, ananya: { added: 9600, removed: 5200 }, rahul: { added: 5300, removed: 2700 } },
    },
  },
  repositories: [
    {
      name: "branch/cli",
      description: "Interactive CLI for switching, creating and validating project branches.",
      language: "TypeScript",
      languageColor: "#3178c6",
      visibility: "Public",
      openPrs: 2,
      stars: 1240,
      updated: "5m ago",
      updatedMinutes: 5,
      activity: [3, 5, 4, 7, 6, 9, 8, 11, 9, 12, 10, 13, 12, 14],
    },
    {
      name: "web/app",
      description: "Customer dashboard, profile settings and the shared UI kit.",
      language: "TypeScript",
      languageColor: "#3178c6",
      visibility: "Private",
      openPrs: 3,
      stars: 318,
      updated: "1h ago",
      updatedMinutes: 60,
      activity: [6, 5, 7, 6, 8, 7, 6, 9, 8, 7, 9, 8, 10, 9],
    },
    {
      name: "payments/core",
      description: "Charges, refunds, partial captures and nightly reconciliation.",
      language: "Go",
      languageColor: "#00add8",
      visibility: "Private",
      openPrs: 1,
      stars: 96,
      updated: "3h ago",
      updatedMinutes: 180,
      activity: [2, 2, 3, 2, 4, 3, 3, 2, 4, 5, 4, 6, 5, 6],
    },
    {
      name: "infra/build",
      description: "Build cache, CI pipelines and release automation.",
      language: "Rust",
      languageColor: "#dea584",
      visibility: "Private",
      openPrs: 0,
      stars: 54,
      updated: "1d ago",
      updatedMinutes: 1440,
      activity: [4, 3, 2, 3, 1, 2, 2, 1, 3, 2, 1, 2, 1, 1],
    },
    {
      name: "docs/site",
      description: "Product documentation, guides and the TUI component reference.",
      language: "MDX",
      languageColor: "#f9ac00",
      visibility: "Public",
      openPrs: 1,
      stars: 187,
      updated: "6h ago",
      updatedMinutes: 360,
      activity: [1, 2, 1, 1, 2, 3, 2, 2, 3, 2, 4, 3, 3, 4],
    },
  ],
  pullRequests: [
    {
      number: 101,
      title: "feat(cli): add interactive `branch switch` + shared TUI foundation",
      author: "atharv",
      status: { kind: "merged", label: "Merged 5m Ago" },
      repository: "branch/branch-switch",
      opened: "4:54 PM Apr 16, 2026",
      age: "5m",
      today: true,
      additions: 842,
      deletions: 117,
      comments: 14,
      problem: [
        "Switching the active project was previously handled through slash commands that directly updated `.active`. This meant there was no proper CLI entry point, no validation, and no interactive selection. It also made it harder to scale, since future commands like `branch new` didn\u2019t have a shared TUI layer to rely on.",
      ],
      approach: [
        "A new branch switch command is introduced, with an interactive picker that lets you navigate using arrow keys in the terminal. For non-interactive usage (like scripts), --to <name> is also supported.",
        "Behind the scenes, listProjects() and setActiveProject() helpers are added to a shared TUI module, so future commands can reuse the same picker, validation and rendering primitives.",
      ],
    },
    {
      number: 102,
      title: "feat(ui): enhance user profile settings with new privacy options",
      author: "ananya",
      status: { kind: "review", label: "In Review 1h Ago" },
      repository: "web/profile-settings",
      opened: "11:20 AM Apr 17, 2026",
      age: "1h",
      today: true,
      additions: 512,
      deletions: 89,
      comments: 6,
      problem: [
        "Profile visibility was a single public/private switch stored on `user.visibility`. Users could not hide their email or activity separately, and support kept receiving requests to remove contribution graphs from public profiles.",
      ],
      approach: [
        "The settings page now groups privacy controls into Profile, Activity and Contact sections, each backed by its own field on `privacy_preferences`.",
        "Existing accounts are migrated with their current visibility applied to every section, so nothing becomes public without the user opting in.",
      ],
    },
    {
      number: 103,
      title: "chore(tests): add unit tests for the payment processing module",
      author: "rahul",
      status: { kind: "open", label: "Opened 3h Ago" },
      repository: "payments/core",
      opened: "9:02 AM Apr 17, 2026",
      age: "3h",
      today: true,
      additions: 1290,
      deletions: 12,
      comments: 2,
      problem: [
        "The payment processor had no unit coverage outside of `chargeCard()`. Refunds, partial captures and currency rounding were only exercised by slow end-to-end runs, so regressions surfaced late.",
      ],
      approach: [
        "Adds focused suites for refunds, captures and rounding using a fake gateway built on `PaymentGateway`, keeping every test under 50ms.",
        "Edge cases for zero-decimal currencies and duplicate webhook delivery are covered with table-driven cases.",
      ],
    },
    {
      number: 104,
      title: "feat(api): add cursor pagination to the /projects endpoint",
      author: "atharv",
      status: { kind: "review", label: "In Review 2h Ago" },
      repository: "branch/api",
      opened: "10:41 AM Apr 17, 2026",
      age: "2h",
      today: false,
      additions: 336,
      deletions: 74,
      comments: 9,
      problem: [
        "`GET /projects` returned every project in one response. Large workspaces took several seconds to load the picker, and offset pagination skipped rows whenever projects were created mid-scroll.",
      ],
      approach: [
        "Responses now return a `next_cursor` built from the creation time and id, and accept `?after=` to resume. The page size defaults to 50 and is capped at 200.",
        "The CLI picker streams pages as you scroll, so the first results appear immediately.",
      ],
    },
    {
      number: 105,
      title: "fix(auth): stop rotating refresh tokens on every request",
      author: "ananya",
      status: { kind: "merged", label: "Merged 4h Ago" },
      repository: "web/auth",
      opened: "8:15 AM Apr 17, 2026",
      age: "4h",
      today: false,
      additions: 58,
      deletions: 31,
      comments: 4,
      problem: [
        "The session middleware called `rotateRefreshToken()` on every authenticated request. Two tabs refreshing at once invalidated each other\u2019s tokens and signed people out at random.",
      ],
      approach: [
        "Rotation now only happens when the access token is within five minutes of expiring, and a short grace window accepts the previous refresh token once.",
      ],
    },
    {
      number: 106,
      title: "docs: document the new TUI picker and validation primitives",
      author: "rahul",
      status: { kind: "open", label: "Opened 6h Ago" },
      repository: "docs/site",
      opened: "6:30 AM Apr 17, 2026",
      age: "6h",
      today: false,
      additions: 410,
      deletions: 0,
      comments: 1,
      problem: [
        "The shared TUI module from #101 had no documentation, so other teams kept rebuilding their own pickers instead of reusing `createPicker()`.",
      ],
      approach: [
        "Adds a reference page for every primitive with live terminal recordings, plus a migration guide for commands that still use raw prompts.",
      ],
    },
    {
      number: 107,
      title: "perf(build): cache TypeScript project references in CI",
      author: "atharv",
      status: { kind: "merged", label: "Merged 1d Ago" },
      repository: "infra/build",
      opened: "3:12 PM Apr 16, 2026",
      age: "1d",
      today: false,
      additions: 96,
      deletions: 40,
      comments: 5,
      problem: [
        "Every CI run rebuilt all TypeScript project references from scratch, adding about four minutes to each pipeline even when only one package changed.",
      ],
      approach: [
        "The `.tsbuildinfo` files are now cached per package and keyed by the lockfile hash, so unchanged packages are skipped. Median pipeline time dropped from 9m to 5m.",
      ],
    },
    {
      number: 108,
      title: "refactor(ui): extract Toolbar into the shared component package",
      author: "ananya",
      status: { kind: "open", label: "Opened 1d Ago" },
      repository: "web/app",
      opened: "1:48 PM Apr 16, 2026",
      age: "1d",
      today: false,
      additions: 274,
      deletions: 351,
      comments: 3,
      problem: [
        "Three apps carried slightly different copies of `Toolbar`, and keyboard focus handling had drifted between them.",
      ],
      approach: [
        "The toolbar moves into `@acme/ui` with a single roving-focus implementation. Each app now imports it and passes its own actions.",
      ],
    },
  ],
};
```

### components/analytics-dashboard/details.tsx

Installation target: `@components/analytics-dashboard/details.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties, type FormEvent, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import { useDashboard } from "./context";
import type { AnalyticsPullRequest } from "./data";
import { ArrowUpIcon, ClaudeIcon, DraftIcon, ImageIcon, PanelIcon, PullRequestIcon, TriangleIcon } from "./icons";
import { PersonAvatar } from "./ui";

export type Phase = "in" | "out" | "pre";

type Message = { id: number; role: "user" | "assistant"; text: string; pending?: boolean };

type Props = {
  pr: AnalyticsPullRequest;
  phase: Phase;
  /** Slide-over state below the desktop breakpoint. */
  open: boolean;
  /** Whether the panel is on screen in the current layout. */
  visible: boolean;
  panelRef: RefObject<HTMLElement | null>;
  hideRef: RefObject<HTMLButtonElement | null>;
  onHide: () => void;
};

const STATUS_ICON = { merged: PullRequestIcon, review: DraftIcon, open: PullRequestIcon } as const;

export function PrDetails({ pr, phase, open, visible, panelRef, hideRef, onHide }: Props) {
  const { uid, assistantName, answer } = useDashboard();
  const scrollRef = useRef<HTMLDivElement>(null);
  const [threads, setThreads] = useState<Record<number, Message[]>>({});
  const nextId = useRef(1);
  const timers = useRef<number[]>([]);
  const mounted = useRef(true);
  const messages = threads[pr.number] ?? [];

  useEffect(() => {
    mounted.current = true;
    const pending = timers.current;
    return () => {
      mounted.current = false;
      pending.forEach((t) => window.clearTimeout(t));
    };
  }, []);

  function scrollToEnd() {
    requestAnimationFrame(() => {
      const el = scrollRef.current;
      if (!el) return;
      if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
      else el.scrollTop = el.scrollHeight;
    });
  }

  function resolve(prNumber: number, botId: number, text: string) {
    if (!mounted.current) return;
    setThreads((t) => ({
      ...t,
      [prNumber]: (t[prNumber] ?? []).map((m) => (m.id === botId ? { ...m, pending: false, text } : m)),
    }));
    scrollToEnd();
  }

  function ask(question: string) {
    const target = pr;
    const userId = nextId.current++;
    const botId = nextId.current++;
    setThreads((t) => ({
      ...t,
      [target.number]: [
        ...(t[target.number] ?? []),
        { id: userId, role: "user", text: question },
        { id: botId, role: "assistant", text: "", pending: true },
      ],
    }));
    scrollToEnd();
    if (answer) {
      Promise.resolve()
        .then(() => answer(question, target))
        .then(
          (text) => resolve(target.number, botId, text),
          () => resolve(target.number, botId, "Something went wrong while answering. Try again."),
        );
      return;
    }
    timers.current.push(window.setTimeout(() => resolve(target.number, botId, answerFor(target)), 1400));
  }

  function onScroll() {
    const el = scrollRef.current;
    if (el) el.dataset.scrolled = String(el.scrollTop > 4);
  }

  const StatusIcon = STATUS_ICON[pr.status.kind];
  const panelId = `${uid}-details`;
  const titleId = `${uid}-details-title`;

  return (
    <aside ref={panelRef} id={panelId} className="details" data-open={open} inert={!visible} aria-labelledby={titleId}>
      <header className="details-bar enter" style={{ "--i": 1 } as CSSProperties}>
        <button
          ref={hideRef}
          type="button"
          className="icon-btn press has-tip tip-below details-hide"
          data-tip="Hide PR details"
          aria-label="Hide PR details"
          aria-expanded={visible}
          aria-controls={panelId}
          onClick={onHide}
        >
          <PanelIcon side="right" open size={15} />
        </button>
        <span id={titleId} className="details-bar-title">
          <span key={pr.number} className="swap-in">
            PR #{pr.number} Details
          </span>
        </span>
      </header>

      <div className="details-scroll" ref={scrollRef} onScroll={onScroll}>
        <div className="details-body enter" style={{ "--i": 2 } as CSSProperties}>
          <div className="details-swap" data-phase={phase}>
            <p className="status" data-kind={pr.status.kind}>
              <StatusIcon size={15} />
              {pr.status.label}
            </p>
            <h2 className="details-title">{pr.title}</h2>

            <dl className="kv">
              <div>
                <dt>Author</dt>
                <dd>
                  <PersonAvatar handle={pr.author} size={22} />
                  {pr.author}
                </dd>
              </div>
              <div>
                <dt>Repository</dt>
                <dd>{pr.repository}</dd>
              </div>
              <div>
                <dt>Opened</dt>
                <dd>{pr.opened}</dd>
              </div>
            </dl>

            <Disclosure key={`p-${pr.number}`} title="Problem">
              {pr.problem.map((p, i) => (
                <p key={i}>
                  <RichText text={p} />
                </p>
              ))}
            </Disclosure>
            <Disclosure key={`a-${pr.number}`} title="Approach">
              {pr.approach.map((p, i) => (
                <p key={i}>
                  <RichText text={p} />
                </p>
              ))}
            </Disclosure>

            {messages.length > 0 && (
              <ol className="thread" aria-label="Conversation" aria-live="polite">
                {messages.map((m) => (
                  <li key={m.id} className={`msg msg-${m.role}`}>
                    {m.pending ? <span className="shimmer">{assistantName} is thinking…</span> : m.text}
                  </li>
                ))}
              </ol>
            )}
          </div>
        </div>
      </div>

      <div className="enter composer-slot" style={{ "--i": 3 } as CSSProperties}>
        <Composer onSubmit={ask} />
      </div>
    </aside>
  );
}

function Composer({ onSubmit }: { onSubmit: (text: string) => void }) {
  const { uid, assistantName } = useDashboard();
  const [value, setValue] = useState("");
  const [files, setFiles] = useState(0);
  const fileRef = useRef<HTMLInputElement>(null);
  const hasValue = value.trim().length > 0;
  const inputId = `${uid}-composer`;

  function submit(e?: FormEvent) {
    e?.preventDefault();
    if (!hasValue) return;
    onSubmit(value.trim());
    setValue("");
    setFiles(0);
  }

  function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      submit();
    }
  }

  const fileLabel = files === 0 ? "Image/Files" : `${files} ${files === 1 ? "file" : "files"}`;

  return (
    <form className="composer" onSubmit={submit} data-has-value={hasValue}>
      <label htmlFor={inputId} className="sr-only">
        Ask anything about PR
      </label>
      <textarea
        id={inputId}
        className="composer-input"
        rows={1}
        placeholder="Ask anything about PR"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={onKeyDown}
      />
      <div className="composer-row">
        <button type="button" className="chip press" onClick={() => fileRef.current?.click()}>
          <ImageIcon size={15} />
          <span key={fileLabel} className="swap-in">
            {fileLabel}
          </span>
        </button>
        <input
          ref={fileRef}
          type="file"
          multiple
          hidden
          accept="image/*,.txt,.md,.diff,.patch,.log"
          onChange={(e) => setFiles(e.target.files?.length ?? 0)}
        />
        <button type="button" className="chip chip-claude press" aria-label={`Model: ${assistantName}`}>
          <ClaudeIcon size={15} className="claude-icon" />
          {assistantName}
        </button>
        <button type="submit" className="send press" aria-label="Send" disabled={!hasValue}>
          <ArrowUpIcon size={15} />
        </button>
      </div>
    </form>
  );
}

function Disclosure({ title, children }: { title: string; children: ReactNode }) {
  const [open, setOpen] = useState(true);
  const id = useId();
  return (
    <div className="acc" data-open={open}>
      <button type="button" className="acc-head" aria-expanded={open} aria-controls={id} onClick={() => setOpen((o) => !o)}>
        <TriangleIcon size={10} className="acc-tri" />
        {title}
      </button>
      <div className="acc-panel" id={id}>
        <div className="acc-inner">{children}</div>
      </div>
    </div>
  );
}

function RichText({ text }: { text: string }) {
  return (
    <>
      {text.split("`").map((part, i) =>
        i % 2 === 1 ? (
          <code key={i} className="code-chip">
            {part}
          </code>
        ) : (
          part
        ),
      )}
    </>
  );
}

function answerFor(pr: AnalyticsPullRequest) {
  const firstSentence = (pr.approach[0] ?? pr.title).split(". ")[0].replace(/`/g, "");
  return `${firstSentence}. It touches ${pr.repository} and was opened by ${pr.author} at ${pr.opened}.`;
}
```

### components/analytics-dashboard/icons.tsx

Installation target: `@components/analytics-dashboard/icons.tsx`

```tsx
import type { SVGProps } from "react";

type IconProps = SVGProps<SVGSVGElement> & { size?: number };

function Base({ size = 16, children, ...rest }: IconProps) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
      {...rest}
    >
      {children}
    </svg>
  );
}

export const PanelIcon = ({
  side = "left",
  open = false,
  ...p
}: IconProps & { side?: "left" | "right"; open?: boolean }) => (
  <Base {...p}>
    <rect width="18" height="18" x="3" y="3" rx="3" />
    <path d={side === "left" ? "M9 3v18" : "M15 3v18"} />
    <rect
      className="panel-icon-fill"
      data-open={open}
      x={side === "left" ? 4 : 16}
      y="4"
      width="4"
      height="16"
      rx="1.5"
      fill="currentColor"
      stroke="none"
    />
  </Base>
);

export const HomeIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M4 10.2a2 2 0 0 1 .72-1.54l6-5.1a2 2 0 0 1 2.56 0l6 5.1A2 2 0 0 1 20 10.2V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
  </Base>
);

export const LayersIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
    <path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
    <path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
  </Base>
);

export const CodeIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M7.5 3.5H7a2 2 0 0 0-2 2v4a2.5 2.5 0 0 1-2 2.5 2.5 2.5 0 0 1 2 2.5v4a2 2 0 0 0 2 2h.5" />
    <path d="M16.5 20.5h.5a2 2 0 0 0 2-2v-4a2.5 2.5 0 0 1 2-2.5 2.5 2.5 0 0 1-2-2.5v-4a2 2 0 0 0-2-2h-.5" />
    <path d="m13.6 8.5-3.2 7" />
  </Base>
);

export const SettingsIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
    <circle cx="12" cy="12" r="3" />
  </Base>
);

export const CommitIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="12" cy="12" r="3" />
    <path d="M3 12h6M15 12h6" />
  </Base>
);

export const PullRequestIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="18" cy="18" r="3" />
    <circle cx="6" cy="6" r="3" />
    <path d="M13 6h3a2 2 0 0 1 2 2v7" />
    <path d="M6 9v12" />
  </Base>
);

export const MergeIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="18" cy="18" r="3" />
    <circle cx="6" cy="6" r="3" />
    <path d="M6 21V9a9 9 0 0 0 9 9" />
  </Base>
);

export const DraftIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="18" cy="18" r="3" />
    <circle cx="6" cy="6" r="3" />
    <path d="M18 6V5M18 11v-1M6 9v12" />
  </Base>
);

export const BookIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M12 7v14" />
    <path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
  </Base>
);

export const ImageIcon = (p: IconProps) => (
  <Base {...p}>
    <rect width="18" height="18" x="3" y="3" rx="3" />
    <circle cx="9" cy="9" r="2" />
    <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
  </Base>
);

export const ArrowUpIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="m5 12 7-7 7 7" />
    <path d="M12 19V5" />
  </Base>
);

export const CloseIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M18 6 6 18" />
    <path d="m6 6 12 12" />
  </Base>
);

export const ClockIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="12" cy="12" r="9" />
    <path d="M12 7v5l3 2" />
  </Base>
);

export const SearchIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="11" cy="11" r="7" />
    <path d="m20 20-3.5-3.5" />
  </Base>
);

export const StarIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M11.48 3.5a.6.6 0 0 1 1.04 0l2.4 4.86 5.36.78a.6.6 0 0 1 .33 1.02l-3.88 3.78.92 5.34a.6.6 0 0 1-.87.63L12 17.39l-4.79 2.52a.6.6 0 0 1-.87-.63l.92-5.34L3.38 10.16a.6.6 0 0 1 .33-1.02l5.36-.78z" />
  </Base>
);

export const CheckIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M20 6 9 17l-5-5" />
  </Base>
);

export const ArrowRightIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M5 12h14" />
    <path d="m13 6 6 6-6 6" />
  </Base>
);

export const MessageIcon = (p: IconProps) => (
  <Base {...p}>
    <path d="M21 12a8 8 0 0 1-11.6 7.14L4 20.5l1.36-4.6A8 8 0 1 1 21 12z" />
  </Base>
);

export const LockIcon = (p: IconProps) => (
  <Base {...p}>
    <rect width="16" height="11" x="4" y="10.5" rx="2.5" />
    <path d="M8 10.5V7a4 4 0 0 1 8 0v3.5" />
  </Base>
);

export const GlobeIcon = (p: IconProps) => (
  <Base {...p}>
    <circle cx="12" cy="12" r="9" />
    <path d="M3 12h18" />
    <path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z" />
  </Base>
);

export const TrendIcon = ({ down, ...p }: IconProps & { down?: boolean }) => (
  <Base {...p}>
    {down ? <path d="m4 7 6 6 4-4 6 6M20 10v5h-5" /> : <path d="m4 17 6-6 4 4 6-6M20 14V9h-5" />}
  </Base>
);

export const TriangleIcon = ({ size = 10, ...rest }: IconProps) => (
  <svg width={size} height={size} viewBox="0 0 10 10" aria-hidden="true" focusable="false" {...rest}>
    <path d="M2.5 1.2v7.6L8.6 5z" fill="currentColor" />
  </svg>
);

const CLAUDE_RAYS = [
  [0, 10], [28, 7.5], [55, 9.5], [83, 8], [112, 10], [140, 7], [168, 9],
  [196, 8], [224, 10], [252, 7.5], [280, 9.5], [306, 8], [334, 9],
] as const;

export const ClaudeIcon = ({ size = 16, ...rest }: IconProps) => (
  <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" focusable="false" {...rest}>
    <g stroke="#E0784A" strokeWidth={2.3} strokeLinecap="round">
      {CLAUDE_RAYS.map(([deg, len]) => {
        const r = (deg * Math.PI) / 180;
        const x = 12 + Math.cos(r) * len;
        const y = 12 + Math.sin(r) * len;
        return <line key={deg} x1={12} y1={12} x2={x.toFixed(2)} y2={y.toFixed(2)} />;
      })}
    </g>
  </svg>
);
```

### components/analytics-dashboard/number-animation.module.css

Installation target: `@components/analytics-dashboard/number-animation.module.css`

```css
.root { position: relative; display: inline-block; font-variant-numeric: tabular-nums; line-height: 1.15; white-space: nowrap; vertical-align: bottom; }
.reels { display: inline-flex; height: 1.15em; align-items: flex-start; }

/* Each slot holds one character. Its width follows the character on a spring, so the word never resizes in one frame. */
.slot { position: relative; display: inline-block; height: 1.15em; flex: none; overflow: visible; }
.sizer { display: inline-block; visibility: hidden; white-space: pre; pointer-events: none; }

/* The window reaches a little past the line so glyphs fade at the edges instead of being cut. */
.window {
  position: absolute;
  inset: -.22em -.08em;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 .24em, #000 calc(100% - .24em), transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 .24em, #000 calc(100% - .24em), transparent);
}
.strip { position: absolute; top: calc(.22em - .225em); right: .08em; left: .08em; display: flex; flex-direction: column; align-items: center; will-change: transform; }
.cell { display: block; height: 1.6em; padding-top: .225em; box-sizing: border-box; line-height: 1.15; white-space: pre; }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
```

### components/analytics-dashboard/number-animation.tsx

Installation target: `@components/analytics-dashboard/number-animation.tsx`

```tsx
"use client";

import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties } from "react";
import { AnimatePresence, animate, motion, useMotionValue, usePresence, useReducedMotion, useTransform, useVelocity } from "motion/react";
import styles from "./number-animation.module.css";

/**
 * Text and numbers that spin into their new value like slot machine reels. Every character is a reel; digits count through
 * the wheel in the direction the number moved, letters shuffle through the alphabet, and reels stop one after another from
 * left to right with a soft, overshoot-free landing. Use it for prices, stats, and launch moments where a change deserves a beat.
 */
export interface NumberAnimationProps {
  /** The value to show. Numbers pass through format; strings render as they are. */
  value: string | number;
  /** Formats a number value. Defaults to en-US grouping, so 12480 reads 12,480. */
  format?: (value: number) => string;
  /** Seconds the first reel spins. Later reels add stagger. */
  duration?: number;
  /** Seconds between reels stopping, left to right. */
  stagger?: number;
  /** Extra full turns a digit makes before it lands. 0 rolls straight to the new digit. */
  spins?: number;
  /** Which end reels are matched from when the length changes. Numbers default to end, so 999 to 1,000 grows on the left. */
  align?: "start" | "end";
  /** Announce new values politely to screen readers. */
  announce?: boolean;
  className?: string;
  style?: CSSProperties;
}

const DIGITS = "0123456789";
const LOWER = "abcdefghijklmnopqrstuvwxyz";
const UPPER = LOWER.toUpperCase();
/** Fast out, long soft landing, never past the target. */
const LANDING = [.12, .8, .16, 1] as const;
const subscribe = () => () => {};
/** Cell pitch in em. Keep in sync with .cell in the stylesheet. */
const CELL = 1.6;

function useReducedFlag() {
  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
  return !!useReducedMotion() && hydrated;
}

const classOf = (char: string) => DIGITS.includes(char) ? DIGITS : LOWER.includes(char) ? LOWER : UPPER.includes(char) ? UPPER : null;

/** Deterministic shuffle so the same change always spins the same letters. */
function seeded(seed: number) {
  let state = seed >>> 0 || 1;
  return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 4294967296; };
}

/** The reel's cells, from what is visible now to the target. */
function buildStrip(from: string, to: string, spins: number, rising: boolean, seed: number) {
  if (from === to) return [to];
  const set = classOf(to);
  if (!set) return [from, to];
  if (set === DIGITS && classOf(from) === DIGITS) {
    const a = Number(from), b = Number(to);
    const steps = spins * 10 + (rising ? (b - a + 10) % 10 : (a - b + 10) % 10);
    return Array.from({ length: steps + 1 }, (_, i) => String((a + (rising ? i : -i) + 100) % 10));
  }
  const random = seeded(seed);
  const fillers = Array.from({ length: Math.max(2, spins * 5 + 2) }, () => set[Math.floor(random() * set.length)]);
  return [from, ...fillers, to];
}

interface ReelProps {
  char: string;
  order: number;
  entering: boolean;
  rising: boolean;
  duration: number;
  stagger: number;
  spins: number;
  reduced: boolean;
}

function Reel({ char, order, entering, rising, duration, stagger, spins, reduced }: ReelProps) {
  const pos = useMotionValue(0);
  const width = useMotionValue<number | "auto">("auto");
  const sizer = useRef<HTMLSpanElement>(null);
  const [isPresent, safeToRemove] = usePresence();
  const [reel, setReel] = useState(() => ({
    char,
    strip: entering && !reduced ? buildStrip("", char, spins, rising, char.charCodeAt(0) + order) : [char],
    from: 0,
    grow: entering,
    time: duration + order * stagger,
  }));

  // A new target restarts the reel from the cell that is on screen right now, keeping its sub-cell offset, so nothing jumps.
  if (reel.char !== char) {
    const current = pos.get();
    const index = Math.max(0, Math.min(reel.strip.length - 1, Math.round(current)));
    const visible = reel.strip[index]!;
    setReel({
      char,
      strip: reduced ? [char] : buildStrip(visible, char, spins, rising, char.charCodeAt(0) * 31 + visible.charCodeAt(0) * 7 + order + reel.strip.length),
      from: reduced ? 0 : current - index,
      grow: false,
      time: duration + order * stagger,
    });
  }

  const blur = useTransform(useVelocity(pos), velocity => {
    const amount = Math.min(Math.abs(velocity) * .05, 2.4);
    return amount < .15 ? "none" : `blur(${amount.toFixed(2)}px)`;
  });
  // Cells are taller than the line, so the neighbours of a landed glyph sit fully outside the window.
  const y = useTransform(pos, value => `translate3d(0, ${(-value * CELL).toFixed(4)}em, 0)`);

  useLayoutEffect(() => {
    const measured = sizer.current?.getBoundingClientRect().width ?? 0;
    const last = reel.strip.length - 1;
    if (reduced) {
      pos.jump(last);
      width.jump(measured);
      return;
    }
    pos.jump(reel.from);
    const spin = animate(pos, last, { duration: last === 0 ? .2 : reel.time, ease: [...LANDING] });
    if (reel.grow) width.jump(0);
    if (width.get() === "auto") { width.jump(measured); return () => spin.stop(); }
    const size = animate(width, measured, { type: "spring", visualDuration: Math.min(reel.time, .6), bounce: 0 });
    return () => { spin.stop(); size.stop(); };
  }, [reel, pos, width, reduced]);

  useLayoutEffect(() => {
    if (isPresent) return;
    if (reduced) { safeToRemove(); return; }
    const exit = animate(width, 0, { type: "spring", visualDuration: .35, bounce: 0, onComplete: safeToRemove });
    return () => exit.stop();
  }, [isPresent, safeToRemove, width, reduced]);

  return <motion.span className={styles.slot} style={{ width }} data-exiting={isPresent ? undefined : ""}>
    <span ref={sizer} className={styles.sizer}>{char === " " ? "\u00a0" : char}</span>
    <span className={styles.window}>
      <motion.span className={styles.strip} style={{ transform: y, filter: blur }}>
        {reel.strip.map((cell, i) => <span key={i} className={styles.cell}>{cell === " " ? "\u00a0" : cell}</span>)}
      </motion.span>
    </span>
  </motion.span>;
}

export function NumberAnimation({ value, format, duration = .9, stagger = .07, spins = 1, align, announce = false, className, style }: NumberAnimationProps) {
  const reduced = useReducedFlag();
  const text = typeof value === "number" ? (format ? format(value) : value.toLocaleString("en-US")) : value;
  const fromEnd = (align ?? (typeof value === "number" ? "end" : "start")) === "end";
  const chars = Array.from(text);
  // A leading run of symbols (a currency sign, a plus) is keyed from the start and never spins into a digit; the rest is
  // keyed from the aligned end, so separators and suffixes stay on their own reels when the number grows.
  const lead = fromEnd ? chars.findIndex(char => /[\p{L}\p{N}]/u.test(char)) : 0;
  const prefix = lead < 0 ? chars.length : lead;
  const keyOf = (i: number) => i < prefix ? `p${i}` : fromEnd ? `e${chars.length - 1 - i}` : `s${i}`;
  const [initialKeys] = useState(() => new Set(chars.map((_, i) => keyOf(i))));
  const [trend, setTrend] = useState({ value, rising: true });
  if (trend.value !== value) {
    setTrend({ value, rising: typeof value === "number" && typeof trend.value === "number" ? value >= trend.value : true });
  }

  return <span className={[styles.root, className].filter(Boolean).join(" ")} style={style}>
    <span className={styles.srOnly} aria-live={announce ? "polite" : undefined}>{text}</span>
    <span className={styles.reels} aria-hidden="true">
      <AnimatePresence initial={false}>
        {chars.map((char, i) => <Reel
          key={keyOf(i)}
          char={char}
          order={i}
          entering={!initialKeys.has(keyOf(i))}
          rising={trend.rising}
          duration={duration}
          stagger={stagger}
          spins={spins}
          reduced={reduced}
        />)}
      </AnimatePresence>
    </span>
  </span>;
}

export default NumberAnimation;
```

### components/analytics-dashboard/pages.tsx

Installation target: `@components/analytics-dashboard/pages.tsx`

```tsx
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { ACCENTS, useDashboard, type AnalyticsDashboardAccent } from "./context";
import type { AnalyticsActivity, AnalyticsKpi, AnalyticsKpiIcon, AnalyticsPerson, AnalyticsPrStatus, AnalyticsPullRequest, AnalyticsRepository } from "./data";
import {
  ArrowRightIcon,
  CheckIcon,
  ClockIcon,
  CommitIcon,
  GlobeIcon,
  LockIcon,
  MergeIcon,
  MessageIcon,
  PullRequestIcon,
  SearchIcon,
  StarIcon,
  TrendIcon,
} from "./icons";
import { Avatar, Digits, formatCount, PersonAvatar, PrRow, Segmented, Sparkline, StatusIcon, StatusPill, Switch } from "./ui";

/* ───────────── Overview ───────────── */

const KPI_ICONS: Record<AnalyticsKpiIcon, typeof CommitIcon> = {
  commit: CommitIcon,
  pull: PullRequestIcon,
  merge: MergeIcon,
  clock: ClockIcon,
};

const EMPTY_ACTIVITY: AnalyticsActivity = { labels: [], series: {}, lines: {} };

export function OverviewPage() {
  const { data, selected, openPr, navigate } = useDashboard();
  const id = useId();
  const [range, setRange] = useState(() => data.ranges.find((r) => r.id === "week")?.id ?? data.ranges[0]?.id ?? "");
  const caption = data.ranges.find((r) => r.id === range)?.caption;
  const today = data.pullRequests.filter((pr) => pr.today);
  const activity = data.activity[range] ?? EMPTY_ACTIVITY;

  return (
    <div className="page-content">
      <header className="page-head enter" style={{ "--i": 1 } as CSSProperties}>
        <div className="page-head-text">
          <h1 className="page-title">Overview</h1>
          <p className="page-sub">
            <span key={range} className="swap-in">
              {caption}
            </span>
          </p>
        </div>
        {data.ranges.length > 1 && <Segmented label="Date range" options={data.ranges} value={range} onChange={setRange} />}
      </header>

      <ul className="stats" aria-label="Key metrics">
        {(data.kpis[range] ?? []).map((k, i) => (
          <KpiCard key={k.id} kpi={k} range={range} index={i} />
        ))}
      </ul>

      <div className="overview-grid">
        <section className="card enter" style={{ "--i": 3 } as CSSProperties} aria-labelledby={`${id}-activity`}>
          <header className="card-head">
            <h2 id={`${id}-activity`} className="card-title">
              Commit activity
            </h2>
            <ul className="legend" aria-label="Contributors">
              {data.people.map((p) => (
                <li key={p.handle}>
                  <i style={{ "--c": p.color } as CSSProperties} aria-hidden="true" />
                  {p.handle}
                </li>
              ))}
            </ul>
          </header>
          <ActivityChart key={range} data={activity} people={data.people} />
        </section>

        <section className="card enter" style={{ "--i": 3.5 } as CSSProperties} aria-labelledby={`${id}-contrib`}>
          <header className="card-head">
            <h2 id={`${id}-contrib`} className="card-title">
              Top contributors
            </h2>
          </header>
          <Leaderboard data={activity} people={data.people} />
        </section>
      </div>

      <section className="block enter" style={{ "--i": 4.5 } as CSSProperties} aria-labelledby={`${id}-prs`}>
        <div className="block-head">
          <h2 id={`${id}-prs`} className="block-title">
            Today PRs
          </h2>
          <button type="button" className="link-more" onClick={() => navigate("reviews")}>
            View all
            <ArrowRightIcon size={14} />
          </button>
        </div>
        <ul className="pr-list">
          {today.map((pr) => (
            <PrRow key={pr.number} pr={pr} selected={selected === pr.number} onOpen={openPr} />
          ))}
        </ul>
      </section>
    </div>
  );
}

function KpiCard({ kpi, range, index }: { kpi: AnalyticsKpi; range: string; index: number }) {
  const Icon = KPI_ICONS[kpi.icon];
  const good = kpi.lowerIsBetter ? kpi.delta < 0 : kpi.delta > 0;
  return (
    <li className="stat enter" style={{ "--i": 2 + index * 0.4 } as CSSProperties}>
      <span className="stat-label">
        <Icon size={16} strokeWidth={1.7} className="stat-icon" />
        {kpi.label}
      </span>
      <div className="stat-row">
        <Digits value={kpi.value} delay={420 + index * 70} className="stat-value" />
        <span className="delta" data-good={good}>
          <TrendIcon size={13} strokeWidth={2} down={kpi.delta < 0} />
          <Digits value={`${kpi.delta < 0 ? "\u2212" : "+"}${Math.abs(kpi.delta)}%`} delay={500 + index * 70} />
        </span>
      </div>
      <Sparkline key={range} data={kpi.spark} tone={good ? "good" : "bad"} />
    </li>
  );
}

function niceCeil(n: number) {
  const step = n <= 10 ? 2 : n <= 40 ? 10 : n <= 120 ? 20 : 50;
  return Math.max(step, Math.ceil(n / step) * step);
}

function ActivityChart({ data, people }: { data: AnalyticsActivity; people: AnalyticsPerson[] }) {
  const totals = data.labels.map((_, i) => people.reduce((sum, p) => sum + (data.series[p.handle]?.[i] ?? 0), 0));
  const ceil = niceCeil(Math.max(0, ...totals));
  const summary = data.labels.map((label, i) => `${label} ${totals[i]}`).join(", ");

  return (
    <figure className="chart" role="img" aria-label={`Commits per period: ${summary}`}>
      <div className="chart-grid" aria-hidden="true">
        {[1, 0.5, 0].map((f) => (
          <span key={f} data-value={Math.round(ceil * f)} />
        ))}
      </div>
      <ol className="chart-cols" aria-hidden="true" style={{ "--cols": Math.max(1, data.labels.length) } as CSSProperties}>
        {data.labels.map((label, i) => (
          <li key={label} className="chart-col" style={{ "--col": i } as CSSProperties}>
            <div className="bar-wrap has-tip tip-top" data-tip={`${label} · ${totals[i]} commits`} style={{ height: `${(totals[i] / ceil) * 100}%` }}>
              <div className="bar">
                {people.map((p) => {
                  const v = data.series[p.handle]?.[i] ?? 0;
                  return v ? <span key={p.handle} className="bar-seg" style={{ flexGrow: v, "--c": p.color } as CSSProperties} /> : null;
                })}
              </div>
            </div>
            <span className="chart-label">{label}</span>
          </li>
        ))}
      </ol>
    </figure>
  );
}

function Leaderboard({ data, people }: { data: AnalyticsActivity; people: AnalyticsPerson[] }) {
  const rows = people
    .map((p) => ({
      ...p,
      commits: (data.series[p.handle] ?? []).reduce((a, b) => a + b, 0),
      lines: data.lines[p.handle] ?? { added: 0, removed: 0 },
    }))
    .sort((a, b) => b.commits - a.commits);
  const total = rows.reduce((sum, r) => sum + r.commits, 0) || 1;
  const top = rows[0]?.commits || 1;

  return (
    <ol className="leaders">
      {rows.map((r, i) => (
        <li key={r.handle} className="leader" style={{ "--row": i, "--c": r.color } as CSSProperties}>
          <Avatar src={r.avatar} size={32} />
          <div className="leader-main">
            <div className="leader-line">
              <span className="leader-name">{r.handle}</span>
              <span className="leader-count">
                <Digits value={r.commits} delay={560 + i * 80} /> commits
              </span>
            </div>
            <div className="share" aria-hidden="true">
              <span className="share-fill" style={{ "--share": r.commits / top } as CSSProperties} />
            </div>
            <div className="leader-meta">
              <span className="badge badge-add">
                <Digits value={`+${formatCount(r.lines.added)}`} delay={620 + i * 80} />
              </span>
              <span className="badge badge-del">
                <Digits value={`\u2212${formatCount(r.lines.removed)}`} delay={660 + i * 80} />
              </span>
              <span className="leader-pct">
                <Digits value={Math.round((r.commits / total) * 100)} delay={700 + i * 80} />% of commits
              </span>
            </div>
          </div>
        </li>
      ))}
    </ol>
  );
}

/* ───────────── Repositories ───────────── */

type SortId = "recent" | "name" | "stars";

const SORTS = [
  { id: "recent", label: "Recent" },
  { id: "name", label: "Name" },
  { id: "stars", label: "Stars" },
] as const satisfies readonly { id: SortId; label: string }[];

const compare: Record<SortId, (a: AnalyticsRepository, b: AnalyticsRepository) => number> = {
  recent: (a, b) => a.updatedMinutes - b.updatedMinutes,
  name: (a, b) => a.name.localeCompare(b.name),
  stars: (a, b) => b.stars - a.stars,
};

export function RepositoriesPage() {
  const { data } = useDashboard();
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<SortId>("recent");
  const [starred, setStarred] = useState<Set<string>>(() => new Set(data.repositories[0] ? [data.repositories[0].name] : []));

  const q = query.trim().toLowerCase();
  const list = data.repositories
    .filter((r) => !q || r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q))
    .sort(compare[sort]);
  const openPrs = data.repositories.reduce((sum, r) => sum + r.openPrs, 0);

  function toggleStar(name: string) {
    setStarred((prev) => {
      const next = new Set(prev);
      if (next.has(name)) next.delete(name);
      else next.add(name);
      return next;
    });
  }

  return (
    <div className="page-content">
      <header className="page-head enter" style={{ "--i": 1 } as CSSProperties}>
        <div className="page-head-text">
          <h1 className="page-title">Repositories</h1>
          <p className="page-sub">
            {data.repositories.length} repositories · {openPrs} open pull requests · {data.pullRequests.length} this week
          </p>
        </div>
      </header>

      <div className="toolbar enter" style={{ "--i": 2 } as CSSProperties}>
        <label className="search">
          <SearchIcon size={15} className="search-icon" />
          <span className="sr-only">Find a repository</span>
          <input
            type="search"
            value={query}
            placeholder="Find a repository…"
            onChange={(e) => setQuery(e.target.value)}
            spellCheck={false}
            autoComplete="off"
          />
        </label>
        <Segmented label="Sort repositories" options={SORTS} value={sort} onChange={setSort} />
      </div>

      {list.length === 0 ? (
        <div className="empty enter" style={{ "--i": 3 } as CSSProperties}>
          <p className="empty-title">No repositories match “{query.trim()}”</p>
          <p className="empty-sub">Try a different name or clear the search.</p>
          <button type="button" className="btn press" onClick={() => setQuery("")}>
            Clear search
          </button>
        </div>
      ) : (
        <ul className="repo-grid" aria-label="Repositories">
          {list.map((repo, i) => (
            <RepoCard key={repo.name} repo={repo} index={i} starred={starred.has(repo.name)} onStar={() => toggleStar(repo.name)} />
          ))}
        </ul>
      )}
    </div>
  );
}

function RepoCard({ repo, index, starred, onStar }: { repo: AnalyticsRepository; index: number; starred: boolean; onStar: () => void }) {
  const slash = repo.name.indexOf("/");
  const org = slash >= 0 ? repo.name.slice(0, slash + 1) : "";
  const name = slash >= 0 ? repo.name.slice(slash + 1) : repo.name;
  const peak = Math.max(1, ...repo.activity);
  const VisIcon = repo.visibility === "Private" ? LockIcon : GlobeIcon;
  const stars = repo.stars + (starred ? 1 : 0);

  return (
    <li className="card repo enter" style={{ "--i": 3 + index * 0.35 } as CSSProperties}>
      <div className="repo-head">
        <VisIcon size={15} strokeWidth={1.8} className="repo-vis-icon" />
        <h2 className="repo-name">
          {org && <span className="repo-org">{org}</span>}
          {name}
        </h2>
        <span className="tag">{repo.visibility}</span>
        <button
          type="button"
          className="star-btn press"
          aria-pressed={starred}
          aria-label={`${starred ? "Unstar" : "Star"} ${repo.name}`}
          onClick={onStar}
        >
          <StarIcon size={16} strokeWidth={1.8} />
        </button>
      </div>
      <p className="repo-desc">{repo.description}</p>

      <div className="mini-bars" role="img" aria-label={`Commits over the last ${repo.activity.length} days, peak ${peak} per day`}>
        {repo.activity.map((v, i) => (
          <span key={i} style={{ "--h": v / peak, "--bar": i } as CSSProperties} />
        ))}
      </div>

      <div className="repo-foot">
        <span className="repo-lang">
          <i style={{ background: repo.languageColor }} aria-hidden="true" />
          {repo.language}
        </span>
        <span className="repo-stat">
          <StarIcon size={13} strokeWidth={1.9} />
          <Digits value={stars} format={formatCount} delay={420 + index * 80} />
        </span>
        <span className="repo-stat">
          <PullRequestIcon size={13} strokeWidth={1.9} />
          {repo.openPrs} open
        </span>
        <span className="repo-updated">Updated {repo.updated}</span>
      </div>
    </li>
  );
}

/* ───────────── Code review ───────────── */

type FilterId = "all" | AnalyticsPrStatus["kind"];

const FILTER_LABELS: Record<FilterId, string> = { all: "All", open: "Open", review: "In review", merged: "Merged" };
const ORDER: FilterId[] = ["all", "open", "review", "merged"];

export function ReviewsPage() {
  const { data, selected, openPr } = useDashboard();
  const [filter, setFilter] = useState<FilterId>("all");
  const prs = data.pullRequests;

  const count = (id: FilterId) => (id === "all" ? prs.length : prs.filter((p) => p.status.kind === id).length);
  const options = ORDER.map((id) => ({
    id,
    label: (
      <>
        {FILTER_LABELS[id]}
        <span className="seg-count">{count(id)}</span>
      </>
    ),
  }));
  const list = filter === "all" ? prs : prs.filter((p) => p.status.kind === filter);
  const waiting = count("review");

  return (
    <div className="page-content">
      <header className="page-head enter" style={{ "--i": 1 } as CSSProperties}>
        <div className="page-head-text">
          <h1 className="page-title">Code review</h1>
          <p className="page-sub">
            {waiting} {waiting === 1 ? "pull request is" : "pull requests are"} waiting for review
          </p>
        </div>
      </header>

      <div className="toolbar enter" style={{ "--i": 2 } as CSSProperties}>
        <Segmented label="Filter by status" options={options} value={filter} onChange={setFilter} className="seg-wide" />
      </div>

      <ul key={filter} className="review-list" aria-label={`${FILTER_LABELS[filter]} pull requests`}>
        {list.map((pr, i) => (
          <ReviewRow key={pr.number} pr={pr} index={i} selected={selected === pr.number} onOpen={openPr} />
        ))}
      </ul>
    </div>
  );
}

function ReviewRow({ pr, index, selected, onOpen }: { pr: AnalyticsPullRequest; index: number; selected: boolean; onOpen: (n: number) => void }) {
  const total = pr.additions + pr.deletions || 1;
  const greenBlocks = Math.round((pr.additions / total) * 5);

  return (
    <li className="review-row enter" data-selected={selected} style={{ "--i": 2.6 + index * 0.25 } as CSSProperties}>
      <StatusIcon status={pr.status} />
      <div className="review-main">
        <p className="pr-title">
          #{pr.number} {pr.title}
        </p>
        <p className="review-meta">
          <span className="review-repo">{pr.repository}</span>
          <span aria-hidden="true">·</span>
          <span className="review-author">
            <PersonAvatar handle={pr.author} size={22} />
            {pr.author}
          </span>
          <span aria-hidden="true">·</span>
          <span>{pr.age} ago</span>
          <span aria-hidden="true">·</span>
          <span className="review-comments">
            <MessageIcon size={13} strokeWidth={1.9} />
            {pr.comments}
            <span className="sr-only"> comments</span>
          </span>
        </p>
      </div>
      <div className="diffstat">
        <span className="diff-add">+{pr.additions}</span>
        <span className="diff-del">−{pr.deletions}</span>
        <span className="diff-blocks" aria-hidden="true">
          {Array.from({ length: 5 }, (_, i) => (
            <i key={i} data-kind={i < greenBlocks ? "add" : "del"} />
          ))}
        </span>
      </div>
      <StatusPill status={pr.status} />
      <button
        type="button"
        className="btn-details press"
        aria-pressed={selected}
        aria-label={`Details for PR #${pr.number}`}
        onClick={() => onOpen(pr.number)}
      >
        Details
      </button>
    </li>
  );
}

/* ───────────── Settings ───────────── */

type Style = "concise" | "balanced" | "detailed";
type NotifyId = "requests" | "mentions" | "merged" | "digest";

type Form = {
  name: string;
  email: string;
  notify: Record<NotifyId, boolean>;
  style: Style;
  includeDiff: boolean;
};

const NOTIFICATIONS: { id: NotifyId; title: string; detail: string }[] = [
  { id: "requests", title: "Review requests", detail: "When someone asks you to review a pull request." },
  { id: "mentions", title: "Mentions", detail: "When you are @mentioned in a comment or description." },
  { id: "merged", title: "Merged pull requests", detail: "When a pull request you authored is merged." },
  { id: "digest", title: "Weekly digest", detail: "A Friday summary of commits, merges and review time." },
];

const STYLES = [
  { id: "concise", label: "Concise" },
  { id: "balanced", label: "Balanced" },
  { id: "detailed", label: "Detailed" },
] as const satisfies readonly { id: Style; label: string }[];

type SaveState = "idle" | "saving" | "saved";

export function SettingsPage() {
  const { accent, setAccent, user, assistantName } = useDashboard();
  const id = useId();
  const [initial] = useState<Form>(() => ({
    name: user.name,
    email: user.email,
    notify: { requests: true, mentions: true, merged: false, digest: true },
    style: "balanced",
    includeDiff: true,
  }));
  const [saved, setSaved] = useState<Form>(initial);
  const [form, setForm] = useState<Form>(initial);
  const [save, setSave] = useState<SaveState>("idle");
  const timers = useRef<number[]>([]);
  const dirty = JSON.stringify(form) !== JSON.stringify(saved);
  const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email);

  useEffect(() => {
    const pending = timers.current;
    return () => pending.forEach((t) => window.clearTimeout(t));
  }, []);

  function update<K extends keyof Form>(key: K, value: Form[K]) {
    setForm((f) => ({ ...f, [key]: value }));
    if (save === "saved") setSave("idle");
  }

  function onSave() {
    if (!dirty || !emailValid || save === "saving") return;
    setSave("saving");
    timers.current.push(
      window.setTimeout(() => {
        setSaved(form);
        setSave("saved");
        timers.current.push(window.setTimeout(() => setSave("idle"), 1800));
      }, 700),
    );
  }

  return (
    <div className="page-content settings">
      <header className="page-head enter" style={{ "--i": 1 } as CSSProperties}>
        <div className="page-head-text">
          <h1 className="page-title">Settings</h1>
          <p className="page-sub">Manage your profile, notifications and how {assistantName} answers PR questions.</p>
        </div>
      </header>

      <form
        className="settings-form"
        onSubmit={(e) => {
          e.preventDefault();
          onSave();
        }}
      >
        <section className="card settings-card enter" style={{ "--i": 2 } as CSSProperties} aria-labelledby={`${id}-profile`}>
          <h2 id={`${id}-profile`} className="card-title">
            Profile
          </h2>
          <div className="profile-row">
            <Avatar src={user.avatar} size={40} />
            <div>
              <p className="profile-name">{form.name || "Unnamed"}</p>
              {user.role && <p className="profile-role">{user.role}</p>}
            </div>
          </div>
          <div className="fields">
            <label className="field">
              <span className="field-label">Display name</span>
              <input value={form.name} onChange={(e) => update("name", e.target.value)} autoComplete="nickname" />
            </label>
            <label className="field">
              <span className="field-label">Email</span>
              <input
                type="email"
                value={form.email}
                onChange={(e) => update("email", e.target.value)}
                autoComplete="email"
                aria-invalid={!emailValid}
                aria-describedby={`${id}-email-hint`}
              />
              <span id={`${id}-email-hint`} className="field-hint" data-error={!emailValid}>
                {emailValid ? "Used for review requests and the weekly digest." : "Enter a valid email address."}
              </span>
            </label>
          </div>
        </section>

        <section className="card settings-card enter" style={{ "--i": 2.5 } as CSSProperties} aria-labelledby={`${id}-notify`}>
          <h2 id={`${id}-notify`} className="card-title">
            Notifications
          </h2>
          <ul className="setting-list">
            {NOTIFICATIONS.map((n) => (
              <li key={n.id} className="setting-row">
                <div>
                  <p id={`${id}-n-${n.id}`} className="setting-title">
                    {n.title}
                  </p>
                  <p id={`${id}-n-${n.id}-d`} className="setting-detail">
                    {n.detail}
                  </p>
                </div>
                <Switch
                  checked={form.notify[n.id]}
                  onChange={(v) => update("notify", { ...form.notify, [n.id]: v })}
                  labelledBy={`${id}-n-${n.id}`}
                  describedBy={`${id}-n-${n.id}-d`}
                />
              </li>
            ))}
          </ul>
        </section>

        <section className="card settings-card enter" style={{ "--i": 3 } as CSSProperties} aria-labelledby={`${id}-assistant`}>
          <h2 id={`${id}-assistant`} className="card-title">
            {assistantName} assistant
          </h2>
          <ul className="setting-list">
            <li className="setting-row">
              <div>
                <p className="setting-title">Answer style</p>
                <p className="setting-detail">How much detail replies in the PR panel include.</p>
              </div>
              <Segmented label="Answer style" options={STYLES} value={form.style} onChange={(v) => update("style", v)} />
            </li>
            <li className="setting-row">
              <div>
                <p id={`${id}-diff`} className="setting-title">
                  Include the diff
                </p>
                <p id={`${id}-diff-d`} className="setting-detail">
                  Send changed lines along with the description for sharper answers.
                </p>
              </div>
              <Switch checked={form.includeDiff} onChange={(v) => update("includeDiff", v)} labelledBy={`${id}-diff`} describedBy={`${id}-diff-d`} />
            </li>
          </ul>
        </section>

        <section className="card settings-card enter" style={{ "--i": 3.5 } as CSSProperties} aria-labelledby={`${id}-look`}>
          <h2 id={`${id}-look`} className="card-title">
            Appearance
          </h2>
          <div className="setting-row">
            <div>
              <p id={`${id}-accent`} className="setting-title">
                Accent color
              </p>
              <p className="setting-detail">Tints buttons, selections and highlights. Applies right away.</p>
            </div>
            <div className="swatches" role="radiogroup" aria-labelledby={`${id}-accent`}>
              {(Object.keys(ACCENTS) as AnalyticsDashboardAccent[]).map((accentId) => (
                <button
                  key={accentId}
                  type="button"
                  role="radio"
                  aria-checked={accent === accentId}
                  aria-label={ACCENTS[accentId].label}
                  className="swatch press has-tip tip-top"
                  data-tip={ACCENTS[accentId].label}
                  style={{ "--swatch": ACCENTS[accentId].ui } as CSSProperties}
                  onClick={() => setAccent(accentId)}
                >
                  <CheckIcon size={13} strokeWidth={2.6} />
                </button>
              ))}
            </div>
          </div>
        </section>

        <div className="save-bar enter" style={{ "--i": 4 } as CSSProperties}>
          <p className="save-note" aria-live="polite">
            <span key={`${dirty}-${save}`} className="swap-in">
              {save === "saved" ? "All changes saved" : dirty ? "You have unsaved changes" : "Everything is up to date"}
            </span>
          </p>
          <button
            type="button"
            className="btn press"
            disabled={!dirty || save === "saving"}
            onClick={() => {
              setForm(saved);
              setSave("idle");
            }}
          >
            Reset
          </button>
          <button type="submit" className="btn btn-primary press" disabled={(!dirty && save !== "saved") || !emailValid} data-state={save}>
            <span key={save} className="swap-in btn-label">
              {save === "saved" && <CheckIcon size={14} strokeWidth={2.4} />}
              {save === "saving" ? "Saving…" : save === "saved" ? "Saved" : "Save changes"}
            </span>
          </button>
        </div>
      </form>
    </div>
  );
}
```

### components/analytics-dashboard/ui.tsx

Installation target: `@components/analytics-dashboard/ui.tsx`

```tsx
"use client";

import { useEffect, useId, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useDashboard } from "./context";
import type { AnalyticsPrStatus, AnalyticsPullRequest } from "./data";
import { DraftIcon, MergeIcon, PullRequestIcon } from "./icons";
import { NumberAnimation } from "./number-animation";

/** "3.4h" → "0.0h", "+12%" → "+0%": the resting shape the reels roll up from. */
const zeroed = (text: string) => text.replace(/\d+/g, "0");

/**
 * Slot-machine number. It first renders zeros, then rolls up to the real value
 * after `delay` ms so the roll lands once its card has faded in. Later changes
 * spin straight from whatever is on screen.
 */
export function Digits({
  value,
  delay = 0,
  className,
  format,
}: {
  value: string | number;
  delay?: number;
  className?: string;
  format?: (value: number) => string;
}) {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    const wait = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : delay;
    const t = window.setTimeout(() => setReady(true), wait);
    return () => window.clearTimeout(t);
  }, [delay]);
  const shown = ready ? value : typeof value === "number" ? 0 : zeroed(value);
  return <NumberAnimation value={shown} format={format} align="end" className={cn("slot-num", className)} />;
}

type SegOption<T extends string> = { id: T; label: ReactNode };

export function Segmented<T extends string>({
  label,
  options,
  value,
  onChange,
  className,
}: {
  label: string;
  options: readonly SegOption<T>[];
  value: T;
  onChange: (next: T) => void;
  className?: string;
}) {
  const index = Math.max(
    0,
    options.findIndex((o) => o.id === value),
  );

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    const next = (index + step + options.length) % options.length;
    onChange(options[next].id);
    const buttons = e.currentTarget.querySelectorAll<HTMLButtonElement>("[role=radio]");
    buttons[next]?.focus();
  }

  return (
    <div
      className={cn("seg", className)}
      role="radiogroup"
      aria-label={label}
      onKeyDown={onKeyDown}
      style={{ "--n": options.length, "--idx": index } as CSSProperties}
    >
      <span className="seg-thumb" aria-hidden="true" />
      {options.map((o) => {
        const checked = o.id === value;
        return (
          <button
            key={o.id}
            type="button"
            role="radio"
            aria-checked={checked}
            tabIndex={checked ? 0 : -1}
            className="seg-btn"
            onClick={() => onChange(o.id)}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

export function Sparkline({
  data,
  tone = "good",
  width = 132,
  height = 34,
}: {
  data: number[];
  tone?: "good" | "bad";
  width?: number;
  height?: number;
}) {
  const id = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  if (data.length < 2) return null;
  const min = Math.min(...data);
  const max = Math.max(...data);
  const pts = data.map((v, i) => [
    (i / (data.length - 1)) * width,
    height - 3 - ((v - min) / (max - min || 1)) * (height - 8),
  ]);
  // Quadratic smoothing through segment midpoints keeps the line soft without overshoot.
  let line = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
  for (let i = 1; i < pts.length - 1; i++) {
    const mx = (pts[i][0] + pts[i + 1][0]) / 2;
    const my = (pts[i][1] + pts[i + 1][1]) / 2;
    line += ` Q ${pts[i][0].toFixed(1)} ${pts[i][1].toFixed(1)} ${mx.toFixed(1)} ${my.toFixed(1)}`;
  }
  const last = pts[pts.length - 1];
  line += ` T ${last[0].toFixed(1)} ${last[1].toFixed(1)}`;
  const area = `${line} L ${width} ${height} L 0 ${height} Z`;

  return (
    <svg className="spark" data-tone={tone} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-hidden="true">
      <defs>
        <linearGradient id={`g${id}`} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="currentColor" stopOpacity="0.28" />
          <stop offset="1" stopColor="currentColor" stopOpacity="0" />
        </linearGradient>
      </defs>
      <path className="spark-area" d={area} fill={`url(#g${id})`} />
      <path className="spark-line" d={line} pathLength={1} fill="none" stroke="currentColor" strokeWidth={1.6} vectorEffect="non-scaling-stroke" />
      <circle className="spark-dot" cx={last[0]} cy={last[1]} r={2.6} fill="currentColor" />
    </svg>
  );
}

export function Switch({
  checked,
  onChange,
  labelledBy,
  describedBy,
}: {
  checked: boolean;
  onChange: (next: boolean) => void;
  labelledBy: string;
  describedBy?: string;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-labelledby={labelledBy}
      aria-describedby={describedBy}
      className="switch"
      onClick={() => onChange(!checked)}
    >
      <span className="switch-thumb" />
    </button>
  );
}

const STATUS_TEXT: Record<AnalyticsPrStatus["kind"], string> = { merged: "Merged", review: "In review", open: "Open" };
const STATUS_ICON = { merged: MergeIcon, review: DraftIcon, open: PullRequestIcon } as const;

export function StatusPill({ status }: { status: AnalyticsPrStatus }) {
  const Icon = STATUS_ICON[status.kind];
  return (
    <span className="pill" data-kind={status.kind}>
      <Icon size={13} strokeWidth={2} />
      {STATUS_TEXT[status.kind]}
    </span>
  );
}

export function StatusIcon({ status, size = 15 }: { status: AnalyticsPrStatus; size?: number }) {
  const Icon = STATUS_ICON[status.kind];
  return (
    <span className="status-dot" data-kind={status.kind}>
      <Icon size={size} strokeWidth={1.9} />
    </span>
  );
}

export function Avatar({ src, size, className }: { src?: string; size: 22 | 32 | 40; className?: string }) {
  if (!src) return <span className={cn("avatar avatar-empty", `avatar-${size}`, className)} aria-hidden="true" />;
  // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the block framework-agnostic
  return <img src={src} alt="" width={size} height={size} loading="lazy" decoding="async" className={cn("avatar", `avatar-${size}`, className)} />;
}

export function PersonAvatar({ handle, size }: { handle: string; size: 22 | 32 | 40 }) {
  const { person } = useDashboard();
  return <Avatar src={person(handle)?.avatar} size={size} />;
}

export function formatCount(n: number) {
  if (n < 1000) return String(n);
  const k = n / 1000;
  return `${k >= 10 ? Math.round(k) : k.toFixed(1).replace(/\.0$/, "")}k`;
}

export function PrRow({
  pr,
  selected,
  onOpen,
  style,
}: {
  pr: AnalyticsPullRequest;
  selected: boolean;
  onOpen: (n: number) => void;
  style?: CSSProperties;
}) {
  return (
    <li className="pr-row" style={style}>
      <PersonAvatar handle={pr.author} size={40} />
      <div className="pr-text">
        <p className="pr-title">{pr.title}</p>
        <p className="pr-sub">PR opened by {pr.author}</p>
      </div>
      <StatusPill status={pr.status} />
      <button
        type="button"
        className="btn-details press"
        aria-pressed={selected}
        aria-label={`Details for PR #${pr.number}`}
        onClick={() => onOpen(pr.number)}
      >
        Details
      </button>
    </li>
  );
}
```

### components/block/analytics-dashboard.css

Installation target: `@components/block/analytics-dashboard.css`

```css
/*
 * Analytics Dashboard. Every rule is scoped to .obsidian-analytics-dashboard, and
 * layout breakpoints are container queries on the dashboard's own width, so it
 * works full page, inside a panel, or in a preview.
 */
.obsidian-analytics-dashboard {
  /* Plain color behind the window. The `background` prop overrides it. */
  --obsidian-analytics-dashboard-background: var(--ad-stage);
  --obsidian-analytics-dashboard-window: var(--ad-window);
  --obsidian-analytics-dashboard-inset: 24px;
  --obsidian-analytics-dashboard-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --obsidian-analytics-dashboard-font-mono: var(--font-jetbrains-mono, "JetBrains Mono"), ui-monospace, "SF Mono", Menlo, monospace;
  container: analytics-dashboard / inline-size;
  position: relative;
  display: grid;
  width: 100%;
  height: 100%;
  min-height: 0;
  padding: var(--obsidian-analytics-dashboard-inset);
  overflow: hidden;
  isolation: isolate;
  color-scheme: light;
  background: var(--obsidian-analytics-dashboard-background);
  font-family: var(--obsidian-analytics-dashboard-font-sans);
  font-size: 14.5px;
  line-height: 1.4;
  text-align: left;
  color: var(--text);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-feature-settings: "cv11";
}

/* Too narrow for a floating window, or placed in an app route: it fills the stage edge to edge. */
.obsidian-analytics-dashboard:is([data-compact="true"], [data-variant="fill"]) {
  padding: 0;
}

.obsidian-analytics-dashboard[data-variant="fill"] .window {
  border: 0;
  border-radius: 0;
  box-shadow: none;
  animation: none;
}

.obsidian-analytics-dashboard :is(h1, h2, h3, p, dl, dt, dd, ol, ul, li, figure) {
  margin: 0;
  padding: 0;
}

.obsidian-analytics-dashboard .rail-logo-custom {
  display: grid;
  place-items: center;
  justify-self: center;
  width: 21px;
  height: 21px;
}

.obsidian-analytics-dashboard .rail-logo-custom > :is(svg, img) {
  width: 100%;
  height: 100%;
}

.obsidian-analytics-dashboard .avatar-empty {
  background: rgb(var(--ad-ink) / 0.1);
}

/* Design tokens: motion, surfaces, data colors and layout sizes. */
.obsidian-analytics-dashboard {
  /* Motion (transitions-dev scale + emil-design-eng curves) */
  --duration-quick: 150ms;
  --duration-fast: 250ms;
  --duration-slow: 400ms;
  --ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-ui: cubic-bezier(0.2, 0, 0, 1);
  --digit-ease: cubic-bezier(0.34, 1.45, 0.64, 1);

  /* Light palette. Text, borders and fills are --ad-ink at different strengths. */
  --ad-ink: 24 24 27;
  --ad-shadow: 0.28;
  --ad-stage: #f6f6f7;
  --ad-window: #eeeef0;
  --ad-main: #ffffff;
  --ad-surface: #ffffff;
  --ad-rail-float: rgb(238 238 240 / 0.97);
  --ad-composer: rgb(24 24 27 / 0.045);
  --ad-composer-focus: rgb(24 24 27 / 0.065);
  --ad-overlay: rgb(255 255 255 / 0.94);
  --ad-save-bar: rgb(255 255 255 / 0.9);

  --text: rgb(var(--ad-ink) / 0.92);
  --text-2: rgb(var(--ad-ink) / 0.62);
  --text-3: rgb(var(--ad-ink) / 0.42);
  --fill-1: rgb(var(--ad-ink) / 0.055);
  --fill-2: rgb(var(--ad-ink) / 0.08);
  --ring: 0 0 0 1px rgb(var(--ad-ink) / 0.075);
  --ring-strong: 0 0 0 1px rgb(var(--ad-ink) / 0.12);

  /* Overridden at runtime from Settings → Appearance */
  --accent: #4c9bff;

  /* Data colors, darkened enough to read on white */
  --orange: #ea6a1c;
  --purple: #7c5cf0;
  --green: #16a34a;
  --add-fg: #16a34a;
  --add-bg: rgba(22, 163, 74, 0.1);
  --del-fg: #dc2626;
  --del-bg: rgba(220, 38, 38, 0.1);
  --merged: #16a34a;
  --review: #d97706;
  --open: #2563eb;
  --bad: #dc2626;
  --star: #d99a06;

  /* Layout */
  --rail-step: 40px;
  --rail-w-compact: 54px;
  --rail-w-open: 180px;
  --details-w: 360px;

  /* Page entrance stagger; shortened once the window has landed */
  --enter-base: 180ms;
  --enter-step: 90ms;
}

/* Dark palette: follows a `dark` class on any ancestor, like the rest of the site. */
.dark .obsidian-analytics-dashboard,
.obsidian-analytics-dashboard.dark {
  color-scheme: dark;
  --ad-ink: 255 255 255;
  --ad-shadow: 1;
  --ad-stage: #0b0b0c;
  --ad-window: #121213;
  --ad-main: linear-gradient(100deg, #1b1a17 0%, #181816 48%, #16181b 100%);
  --ad-surface: #1a1a18;
  --ad-rail-float: rgba(22, 22, 24, 0.97);
  --ad-composer: rgba(52, 52, 52, 0.62);
  --ad-composer-focus: rgba(58, 58, 58, 0.7);
  --ad-overlay: rgba(14, 20, 32, 0.9);
  --ad-save-bar: rgba(32, 32, 30, 0.86);

  --orange: #f08a3c;
  --purple: #9d8df5;
  --green: #2fd06f;
  --add-fg: #3ddc7a;
  --add-bg: rgba(46, 204, 113, 0.12);
  --del-fg: #f04b4b;
  --del-bg: rgba(240, 75, 75, 0.12);
  --merged: #34d27b;
  --review: #f2b54a;
  --open: #5aa9ff;
  --bad: #f07a6a;
  --star: #f5c451;
}

@property --obsidian-analytics-dashboard-fade-top {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

/* Reset, typography defaults, accessibility helpers and shared motion. */

.obsidian-analytics-dashboard,
.obsidian-analytics-dashboard *,
.obsidian-analytics-dashboard *::before,
.obsidian-analytics-dashboard *::after {
  box-sizing: border-box;
}

.obsidian-analytics-dashboard h1,
.obsidian-analytics-dashboard h2,
.obsidian-analytics-dashboard p,
.obsidian-analytics-dashboard dl,
.obsidian-analytics-dashboard dd,
.obsidian-analytics-dashboard ol,
.obsidian-analytics-dashboard ul,
.obsidian-analytics-dashboard figure {
  margin: 0;
  padding: 0;
}

.obsidian-analytics-dashboard ul,
.obsidian-analytics-dashboard ol {
  list-style: none;
}

.obsidian-analytics-dashboard a {
  color: inherit;
  text-decoration: none;
}

.obsidian-analytics-dashboard button,
.obsidian-analytics-dashboard input,
.obsidian-analytics-dashboard textarea {
  font: inherit;
  color: inherit;
}

.obsidian-analytics-dashboard button {
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.obsidian-analytics-dashboard button:disabled {
  cursor: default;
}

.obsidian-analytics-dashboard :focus-visible {
  outline: 2px solid color-mix(in oklab, var(--accent) 70%, rgb(var(--ad-ink)));
  outline-offset: 2px;
}

.obsidian-analytics-dashboard .sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Slot numbers sit on the text baseline so "50 commits" reads as one line.
   The extra type selector outranks the module's own vertical-align. */

.obsidian-analytics-dashboard span.slot-num {
  vertical-align: baseline;
}

.obsidian-analytics-dashboard .press:not(:disabled):active {
  transform: scale(0.96);
}

/* ───────── Staggered entrance: one semantic chunk per --i step ───────── */

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .enter {
    animation: obsidian-ad-enter 300ms var(--ease-out-strong) both;
    animation-delay: calc(var(--enter-base) + var(--i, 0) * var(--enter-step));
  }
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-analytics-dashboard .enter {
    animation: obsidian-ad-fade-in 300ms ease both;
    animation-delay: calc(var(--i, 0) * 40ms);
  }
}

@keyframes obsidian-ad-enter {
  from {
    opacity: 0;
    transform: translateY(12px);
    filter: blur(4px);
  }
}

@keyframes obsidian-ad-fade-in {
  from {
    opacity: 0;
  }
}

/* ───────── Text swap for labels that change in place ───────── */

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .swap-in {
    display: inline-block;
    animation: obsidian-ad-swap-in 250ms ease-in-out both;
  }
}

@keyframes obsidian-ad-swap-in {
  from {
    opacity: 0;
    transform: translateY(4px);
    filter: blur(2px);
  }
}

/* ───────── Shimmer text (transitions-dev #15) ───────── */

.obsidian-analytics-dashboard .shimmer {
  background: linear-gradient(
      90deg,
      rgb(var(--ad-ink) / 0.38) 0%,
      rgb(var(--ad-ink) / 0.38) 40%,
      rgb(var(--ad-ink)) 50%,
      rgb(var(--ad-ink) / 0.38) 60%,
      rgb(var(--ad-ink) / 0.38) 100%
    )
    0 0 / 400% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .shimmer {
    animation: obsidian-ad-shimmer 2000ms linear infinite;
  }
}

@keyframes obsidian-ad-shimmer {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0% 0;
  }
}

/* App frame shared by every route: window, rail, tooltips, main panel, page header. */

/* ───────── Window ───────── */

.obsidian-analytics-dashboard .window {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 0;
  --rail-w: var(--rail-w-compact);
  display: grid;
  grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--details-w);
  border: 1px solid transparent;
  border-radius: 22px;
  /* clip, not hidden: a hidden box is still scrollable, so focusing something in
     the off-screen PR panel would scroll the whole window sideways. */
  overflow: clip;
  background: var(--obsidian-analytics-dashboard-window);
  box-shadow:
    inset 0 1px 0 rgb(var(--ad-ink) / 0.1),
    inset 0 0 0 1px rgb(var(--ad-ink) / 0.07),
    0 0 0 1px rgb(0 0 0 / calc(0.28 * var(--ad-shadow))),
    0 40px 90px -24px rgb(0 0 0 / calc(0.6 * var(--ad-shadow))),
    0 14px 28px -14px rgb(0 0 0 / calc(0.45 * var(--ad-shadow)));
  transition: grid-template-columns 300ms var(--ease-smooth-out);
}

.obsidian-analytics-dashboard .window[data-collapsed="true"] {
  grid-template-columns: var(--rail-w) minmax(0, 1fr) 0px;
}

.obsidian-analytics-dashboard .window[data-nav-open="true"] {
  --rail-w: var(--rail-w-open);
}

.obsidian-analytics-dashboard .window[data-booted="true"] {
  --enter-base: 20ms;
  --enter-step: 55ms;
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .window {
    animation: obsidian-ad-window-in 700ms var(--ease-out-strong) both;
  }
}

@keyframes obsidian-ad-window-in {
  from {
    opacity: 0;
    transform: translateY(10px) scale(0.985);
    filter: blur(6px);
  }
}

/* ───────── Rail ───────── */

/* Every rail row is a 38px icon column plus a label column. The label column
   is 0px wide while compact, so icons never move when the rail expands. */

.obsidian-analytics-dashboard .rail {
  grid-column: 1;
  display: flex;
  flex-direction: column;
  padding: 17px 8px 14px;
  min-width: 0;
  min-height: 0;
}

.obsidian-analytics-dashboard .rail-brand,
.obsidian-analytics-dashboard .rail-btn,
.obsidian-analytics-dashboard .rail-account {
  display: grid;
  grid-template-columns: 38px minmax(0, 1fr);
  align-items: center;
  width: 100%;
}

.obsidian-analytics-dashboard .rail-icon {
  justify-self: center;
}

/* Buttons center their text by default, which pushes labels away from their icons. */
.obsidian-analytics-dashboard .rail-label {
  min-width: 0;
  padding-left: 2px;
  overflow: hidden;
  white-space: nowrap;
  text-align: left;
  text-overflow: ellipsis;
  font-size: 14px;
  line-height: 20px;
  opacity: 0;
  transform: translateX(-4px);
  transition:
    opacity 120ms ease,
    transform 120ms ease;
}

.obsidian-analytics-dashboard .rail[data-expanded="true"] .rail-label {
  opacity: 1;
  transform: none;
  transition:
    opacity 220ms ease 70ms,
    transform 300ms var(--ease-smooth-out) 40ms;
}

/* Labels replace tooltips once they're visible */

.obsidian-analytics-dashboard .rail[data-expanded="true"] .has-tip::after {
  display: none;
}

.obsidian-analytics-dashboard .rail-brand {
  height: 21px;
  border-radius: 6px;
}

.obsidian-analytics-dashboard .rail-brand-name {
  font-weight: 600;
  letter-spacing: -0.005em;
  color: rgb(var(--ad-ink));
}

.obsidian-analytics-dashboard .rail-logo {
  position: relative;
  justify-self: center;
  display: block;
  width: 21px;
  height: 21px;
  border-radius: 5px;
  overflow: hidden;
  background:
    linear-gradient(128deg, transparent 38%, rgba(255, 255, 255, 0.95) 46%, rgba(220, 238, 255, 0.9) 54%, transparent 64%),
    radial-gradient(70% 70% at 85% 90%, #d9ecff 0%, transparent 70%),
    linear-gradient(150deg, #7fb6ee 0%, #2a64c9 34%, #1b3f96 52%, #5c9de6 78%, #cfe6ff 100%);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.22),
    0 2px 6px rgba(10, 40, 110, 0.35);
  transition: transform var(--duration-quick) ease-out;
}

.obsidian-analytics-dashboard .rail-logo-shine {
  position: absolute;
  inset: -40%;
  background: linear-gradient(115deg, transparent 40%, rgba(255, 255, 255, 0.85) 50%, transparent 60%);
  transform: translateX(-70%);
}

@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .rail-logo-shine {
    transition: transform 700ms var(--ease-smooth-out);
  }
  .obsidian-analytics-dashboard .rail-brand:hover .rail-logo-shine {
    transform: translateX(70%);
  }
}

.obsidian-analytics-dashboard .rail-nav {
  position: relative;
  margin-top: 15px;
}

.obsidian-analytics-dashboard .rail-list {
  display: flex;
  flex-direction: column;
}

.obsidian-analytics-dashboard .rail-indicator {
  position: absolute;
  top: 2px;
  left: 2px;
  right: 2px;
  height: 36px;
  border-radius: 10px;
  background: rgb(var(--ad-ink) / 0.075);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.05);
  transform: translateY(calc(var(--active, 0) * var(--rail-step)));
  transition:
    transform var(--duration-fast) var(--ease-smooth-out),
    opacity var(--duration-quick) ease;
  pointer-events: none;
}

.obsidian-analytics-dashboard .rail-indicator[data-hidden="true"] {
  opacity: 0;
}

.obsidian-analytics-dashboard .rail-btn {
  position: relative;
  height: var(--rail-step);
  border-radius: 10px;
  color: rgb(var(--ad-ink) / 0.5);
  transition:
    color var(--duration-quick) ease,
    background-color var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

.obsidian-analytics-dashboard .rail-btn[data-active="true"] {
  color: rgb(var(--ad-ink));
}

.obsidian-analytics-dashboard .rail-account {
  position: relative;
  height: 40px;
  margin-top: auto;
  border-radius: 10px;
  transition:
    background-color var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

.obsidian-analytics-dashboard .rail-account-text {
  display: flex;
  flex-direction: column;
  line-height: 16px;
}

.obsidian-analytics-dashboard .rail-account-name {
  font-size: 13.5px;
  font-weight: 500;
  color: var(--text);
}

.obsidian-analytics-dashboard .rail-account-mail {
  font-size: 11.5px;
  color: var(--text-3);
  text-overflow: ellipsis;
  overflow: hidden;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .rail-btn:hover {
    color: rgb(var(--ad-ink) / 0.9);
  }
  .obsidian-analytics-dashboard .rail[data-expanded="true"] .rail-btn:not([data-active="true"]):hover,
  .obsidian-analytics-dashboard .rail[data-expanded="true"] .rail-account:hover {
    background-color: rgb(var(--ad-ink) / 0.04);
  }
}

.obsidian-analytics-dashboard .avatar-img {
  display: block;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  object-fit: cover;
  outline: 1px solid rgb(var(--ad-ink) / 0.1);
  outline-offset: -1px;
}

/* ───────── Tooltips ─────────
   Drawn from data-tip with a pseudo-element, so tooltip text never shows up
   in copied page text and screen readers rely on the element's own label. */

.obsidian-analytics-dashboard .has-tip {
  position: relative;
}

.obsidian-analytics-dashboard .has-tip::after {
  content: attr(data-tip);
  position: absolute;
  z-index: 30;
  padding: 6px 9px;
  border-radius: 8px;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.2;
  white-space: nowrap;
  color: rgba(255, 255, 255, 0.92);
  background: rgba(30, 32, 38, 0.97);
  box-shadow:
    0 0 0 1px rgba(255, 255, 255, 0.08),
    0 4px 14px rgba(0, 0, 0, 0.35);
  opacity: 0;
  pointer-events: none;
  transition:
    opacity 50ms ease-out,
    transform 50ms ease-out;
}

.obsidian-analytics-dashboard .tip-right::after {
  left: calc(100% + 10px);
  top: 50%;
  transform: translate(0, -50%) scale(0.97);
  transform-origin: 0 50%;
}

.obsidian-analytics-dashboard .tip-top::after {
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translate(-50%, 0) scale(0.97);
  transform-origin: 50% 100%;
}

/* Below the trigger, aligned to its start edge (or end edge with .tip-end) so
   tips near the panel edges never get clipped. */

.obsidian-analytics-dashboard .tip-below::after {
  top: calc(100% + 8px);
  left: 0;
  transform: translateY(-2px) scale(0.97);
  transform-origin: 14px 0;
}

.obsidian-analytics-dashboard .tip-below.tip-end::after {
  left: auto;
  right: 0;
  transform-origin: calc(100% - 14px) 0;
}

.obsidian-analytics-dashboard .has-tip:focus-visible::after {
  opacity: 1;
  transition-duration: 150ms;
}

.obsidian-analytics-dashboard .tip-right:focus-visible::after {
  transform: translate(0, -50%) scale(1);
}

.obsidian-analytics-dashboard .tip-top:focus-visible::after {
  transform: translate(-50%, 0) scale(1);
}

.obsidian-analytics-dashboard .tip-below:focus-visible::after {
  transform: none;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .has-tip:hover::after {
    opacity: 1;
    transition-duration: 150ms;
    transition-delay: 80ms;
  }
  .obsidian-analytics-dashboard .tip-right:hover::after {
    transform: translate(0, -50%) scale(1);
  }
  .obsidian-analytics-dashboard .tip-top:hover::after {
    transform: translate(-50%, 0) scale(1);
  }
  .obsidian-analytics-dashboard .tip-below:hover::after {
    transform: none;
  }
}

/* ───────── Main panel ───────── */

.obsidian-analytics-dashboard .main {
  grid-column: 2;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  margin: 7px 0;
  border: 1px solid transparent;
  border-radius: 15px;
  overflow: hidden;
  background: var(--ad-main);
  box-shadow:
    0 0 0 1px rgb(var(--ad-ink) / 0.055),
    0 10px 30px -12px rgb(0 0 0 / calc(0.55 * var(--ad-shadow)));
  transition: margin 300ms var(--ease-smooth-out);
}

.obsidian-analytics-dashboard .window[data-collapsed="true"] .main {
  margin-right: 7px;
}

/* z-index keeps the bar's tooltips above page content that animates in later */

.obsidian-analytics-dashboard .main-bar {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 3px;
  height: 44px;
  padding: 0 5px;
  flex-shrink: 0;
}

.obsidian-analytics-dashboard .panel-icon-fill {
  opacity: 0;
  transition: opacity 200ms ease;
}

.obsidian-analytics-dashboard .panel-icon-fill[data-open="true"] {
  opacity: 0.4;
}

/* Shown only while the PR panel is hidden; fades in after the panel has moved away */

.obsidian-analytics-dashboard .icon-btn.details-reopen {
  margin-left: auto;
  opacity: 0;
  transform: scale(0.85);
  filter: blur(3px);
  pointer-events: none;
  transition:
    opacity 150ms var(--ease-ui),
    transform 150ms var(--ease-ui),
    filter 150ms var(--ease-ui),
    background-color var(--duration-quick) ease,
    color var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .details-reopen[data-visible="true"] {
  opacity: 1;
  transform: none;
  filter: none;
  pointer-events: auto;
  transition-duration: 200ms, 200ms, 200ms, var(--duration-quick), var(--duration-quick);
  transition-delay: 120ms, 120ms, 120ms, 0ms, 0ms;
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-analytics-dashboard .icon-btn.details-reopen,
  .obsidian-analytics-dashboard .details-reopen[data-visible="true"] {
    transform: none;
    filter: none;
  }
}

.obsidian-analytics-dashboard .main-bar-title {
  font-size: 14px;
  color: rgb(var(--ad-ink) / 0.82);
}

.obsidian-analytics-dashboard .icon-btn {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 8px;
  color: rgb(var(--ad-ink) / 0.6);
  transition:
    background-color var(--duration-quick) ease,
    color var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .icon-btn:hover {
    background-color: rgb(var(--ad-ink) / 0.06);
    color: rgb(var(--ad-ink));
  }
}

.obsidian-analytics-dashboard .main-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: rgb(var(--ad-ink) / 0.12) transparent;
}

/* ───────── Page frame (every route renders inside .page) ───────── */

.obsidian-analytics-dashboard .page-content {
  container: page / inline-size;
  padding: 34px 28px 32px 50px;
}

.obsidian-analytics-dashboard .page-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
}

.obsidian-analytics-dashboard .page-head-text {
  min-width: 0;
}

.obsidian-analytics-dashboard .page-title {
  font-size: 33px;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.022em;
  color: rgb(var(--ad-ink));
}

.obsidian-analytics-dashboard .page-sub {
  margin-top: 4px;
  font-size: 14px;
  line-height: 20px;
  color: var(--text-3);
  text-wrap: pretty;
}

.obsidian-analytics-dashboard .block {
  margin-top: 28px;
}

.obsidian-analytics-dashboard .block-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 6px;
}

.obsidian-analytics-dashboard .block-title {
  font-size: 14.5px;
  font-weight: 600;
  line-height: 20px;
  color: rgb(var(--ad-ink) / 0.5);
}

.obsidian-analytics-dashboard .toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.obsidian-analytics-dashboard .scrim {
  display: none;
}

/* ───────── Responsive frame ───────── */

@container analytics-dashboard (max-width: 1180px) {
  .obsidian-analytics-dashboard .window,
  .obsidian-analytics-dashboard .window[data-collapsed="true"] {
    grid-template-columns: var(--rail-w) minmax(0, 1fr);
  }

  .obsidian-analytics-dashboard .main,
  .obsidian-analytics-dashboard .window[data-collapsed="true"] .main {
    margin-right: 7px;
  }

  .obsidian-analytics-dashboard .scrim {
    display: block;
    position: absolute;
    inset: 0;
    z-index: 5;
    background: rgb(0 0 0 / calc(0.3 * var(--ad-shadow)));
    opacity: 0;
    pointer-events: none;
    cursor: default;
    transition: opacity 300ms ease;
  }

  .obsidian-analytics-dashboard .window[data-scrim="true"] .scrim {
    opacity: 1;
    pointer-events: auto;
  }
}

@container analytics-dashboard (max-width: 960px) {
  .obsidian-analytics-dashboard .page-content {
    padding: 28px 20px 28px 28px;
  }
}

@container analytics-dashboard (max-width: 760px) {
  .obsidian-analytics-dashboard .window {
    height: 100%;
    min-height: 0;
    border-radius: 0;
  }

  /* Too narrow to push content aside: the expanded rail floats over it instead */
  .obsidian-analytics-dashboard .window[data-nav-open="true"] {
    --rail-w: var(--rail-w-compact);
  }

  .obsidian-analytics-dashboard .rail {
    position: relative;
    z-index: 6;
    width: var(--rail-w-compact);
    border-radius: 0 16px 16px 0;
    transition:
      width 300ms var(--ease-smooth-out),
      background-color 300ms ease,
      box-shadow 300ms ease;
  }

  .obsidian-analytics-dashboard .rail[data-expanded="true"] {
    width: var(--rail-w-open);
    background-color: var(--ad-rail-float);
    box-shadow:
      1px 0 0 rgb(var(--ad-ink) / 0.07),
      24px 0 48px -16px rgb(0 0 0 / calc(0.6 * var(--ad-shadow)));
  }

  .obsidian-analytics-dashboard .page-content {
    padding: 22px 16px 28px 18px;
  }

  .obsidian-analytics-dashboard .page-head {
    flex-direction: column;
    align-items: flex-start;
  }
}

@container analytics-dashboard (max-width: 520px) {
  .obsidian-analytics-dashboard .page-content {
    padding: 18px 12px 24px 14px;
  }

  .obsidian-analytics-dashboard .page-title {
    font-size: 26px;
  }

  .obsidian-analytics-dashboard .btn-details {
    width: auto;
    height: 32px;
    padding: 0 12px;
    font-size: 13.5px;
  }
}

/* Reusable primitives from components/ui.tsx and shared building blocks. */

/* ───────── Card ───────── */

.obsidian-analytics-dashboard .card {
  border: 1px solid transparent;
  border-radius: 14px;
  background-color: var(--fill-1);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.025);
}

.obsidian-analytics-dashboard .card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
}

.obsidian-analytics-dashboard .card-title {
  font-size: 14.5px;
  font-weight: 600;
  line-height: 20px;
  color: rgb(var(--ad-ink) / 0.78);
}

/* ───────── Segmented control (sliding thumb) ───────── */

.obsidian-analytics-dashboard .seg {
  position: relative;
  flex: none;
  display: inline-grid;
  grid-template-columns: repeat(var(--n), minmax(0, 1fr));
  padding: 3px;
  border-radius: 10px;
  background: rgb(var(--ad-ink) / 0.045);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.06);
}

.obsidian-analytics-dashboard .seg-thumb {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc((100% - 6px) / var(--n));
  border-radius: 7px;
  background: rgb(var(--ad-ink) / 0.1);
  box-shadow:
    inset 0 0 0 1px rgb(var(--ad-ink) / 0.06),
    0 1px 3px rgb(0 0 0 / calc(0.3 * var(--ad-shadow)));
  transform: translateX(calc(var(--idx) * 100%));
  transition: transform var(--duration-fast) var(--ease-smooth-out);
  pointer-events: none;
}

.obsidian-analytics-dashboard .seg-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 28px;
  padding: 0 14px;
  border-radius: 7px;
  font-size: 13.5px;
  font-weight: 500;
  white-space: nowrap;
  color: rgb(var(--ad-ink) / 0.5);
  transition: color var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .seg-btn[aria-checked="true"] {
  color: rgb(var(--ad-ink));
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .seg-btn[aria-checked="false"]:hover {
    color: rgb(var(--ad-ink) / 0.8);
  }
}

.obsidian-analytics-dashboard .seg-count {
  min-width: 18px;
  padding: 0 5px;
  border-radius: 999px;
  font-size: 11.5px;
  line-height: 17px;
  font-variant-numeric: tabular-nums;
  color: rgb(var(--ad-ink) / 0.6);
  background: rgb(var(--ad-ink) / 0.07);
}

.obsidian-analytics-dashboard .seg-btn[aria-checked="true"] .seg-count {
  color: rgb(var(--ad-ink));
  background: color-mix(in oklab, var(--accent) 32%, transparent);
}

/* ───────── Switch ───────── */

.obsidian-analytics-dashboard .switch {
  position: relative;
  flex: none;
  width: 36px;
  height: 21px;
  border-radius: 999px;
  background-color: rgb(var(--ad-ink) / 0.13);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.05);
  transition: background-color 200ms var(--ease-ui);
}

.obsidian-analytics-dashboard .switch[aria-checked="true"] {
  background-color: var(--accent);
}

.obsidian-analytics-dashboard .switch-thumb {
  position: absolute;
  top: 2.5px;
  left: 2.5px;
  width: 16px;
  height: 16px;
  border-radius: 999px;
  background: #fff;
  box-shadow: 0 1px 3px rgb(0 0 0 / calc(0.35 * var(--ad-shadow)));
  transition:
    transform 250ms var(--ease-smooth-out),
    width 150ms var(--ease-ui);
}

.obsidian-analytics-dashboard .switch[aria-checked="true"] .switch-thumb {
  transform: translateX(15px);
}

/* The thumb stretches while pressed, like a physical toggle. */

.obsidian-analytics-dashboard .switch:active .switch-thumb {
  width: 19px;
}

.obsidian-analytics-dashboard .switch[aria-checked="true"]:active .switch-thumb {
  transform: translateX(12px);
}

/* ───────── Status ───────── */

.obsidian-analytics-dashboard .pill {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 24px;
  padding: 0 9px 0 7px;
  border-radius: 999px;
  font-size: 12.5px;
  font-weight: 500;
  white-space: nowrap;
}

.obsidian-analytics-dashboard .pill[data-kind="merged"] {
  color: var(--merged);
  background: rgba(52, 210, 123, 0.1);
}

.obsidian-analytics-dashboard .pill[data-kind="review"] {
  color: var(--review);
  background: rgba(242, 181, 74, 0.1);
}

.obsidian-analytics-dashboard .pill[data-kind="open"] {
  color: var(--open);
  background: rgba(90, 169, 255, 0.1);
}

.obsidian-analytics-dashboard .status-dot {
  flex: none;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 10px;
}

.obsidian-analytics-dashboard .status-dot[data-kind="merged"] {
  color: var(--merged);
  background: rgba(52, 210, 123, 0.1);
}

.obsidian-analytics-dashboard .status-dot[data-kind="review"] {
  color: var(--review);
  background: rgba(242, 181, 74, 0.1);
}

.obsidian-analytics-dashboard .status-dot[data-kind="open"] {
  color: var(--open);
  background: rgba(90, 169, 255, 0.1);
}

/* ───────── Badges and tags ───────── */

.obsidian-analytics-dashboard .badge {
  display: inline-grid;
  place-items: center;
  min-width: 44px;
  height: 22px;
  padding: 0 7px;
  border-radius: 6px;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.obsidian-analytics-dashboard .badge-add {
  color: var(--add-fg);
  background: var(--add-bg);
}

.obsidian-analytics-dashboard .badge-del {
  color: var(--del-fg);
  background: var(--del-bg);
}

.obsidian-analytics-dashboard .tag {
  flex: none;
  height: 20px;
  padding: 0 7px;
  border-radius: 6px;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 20px;
  color: rgb(var(--ad-ink) / 0.55);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.1);
}

/* ───────── Avatars ───────── */

.obsidian-analytics-dashboard .avatar {
  flex: none;
  display: block;
  border-radius: 50%;
  object-fit: cover;
  outline: 1px solid rgb(var(--ad-ink) / 0.1);
  outline-offset: -1px;
}

.obsidian-analytics-dashboard .avatar-40 {
  width: 40px;
  height: 40px;
}

.obsidian-analytics-dashboard .avatar-32 {
  width: 32px;
  height: 32px;
}

.obsidian-analytics-dashboard .avatar-22 {
  width: 22px;
  height: 22px;
}

/* ───────── Buttons ───────── */

.obsidian-analytics-dashboard .btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 34px;
  padding: 0 14px;
  border: 1px solid transparent;
  border-radius: 9px;
  font-size: 14px;
  font-weight: 500;
  color: var(--text);
  background-color: rgb(var(--ad-ink) / 0.04);
  box-shadow: var(--ring);
  transition:
    background-color var(--duration-quick) ease,
    box-shadow var(--duration-quick) ease,
    opacity var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

.obsidian-analytics-dashboard .btn:disabled {
  opacity: 0.45;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .btn:not(:disabled):hover {
    background-color: rgb(var(--ad-ink) / 0.08);
    box-shadow: var(--ring-strong);
  }
}

.obsidian-analytics-dashboard .btn-primary {
  min-width: 128px;
  color: #fff;
  background-color: color-mix(in oklab, var(--accent) 82%, black);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .btn-primary:not(:disabled):hover {
    background-color: var(--accent);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
  }
}

.obsidian-analytics-dashboard .btn-primary[data-state="saved"] {
  background-color: color-mix(in oklab, var(--merged) 70%, black);
}

.obsidian-analytics-dashboard .btn-label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.obsidian-analytics-dashboard .btn-details {
  flex: none;
  width: 78px;
  height: 35px;
  border: 1px solid transparent;
  border-radius: 8px;
  font-size: 14.5px;
  font-weight: 450;
  color: var(--text);
  background-color: rgb(var(--ad-ink) / 0.025);
  box-shadow: var(--ring);
  transition:
    background-color var(--duration-quick) ease,
    box-shadow var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

.obsidian-analytics-dashboard .btn-details[aria-pressed="true"] {
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 45%, transparent);
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .btn-details:hover {
    background-color: rgb(var(--ad-ink) / 0.07);
    box-shadow: var(--ring-strong);
  }
}

.obsidian-analytics-dashboard .link-more {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 26px;
  padding: 0 8px;
  margin-right: -8px;
  border-radius: 7px;
  font-size: 13.5px;
  font-weight: 500;
  color: rgb(var(--ad-ink) / 0.55);
  transition:
    color var(--duration-quick) ease,
    background-color var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .link-more svg {
  transition: transform var(--duration-fast) var(--ease-smooth-out);
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .link-more:hover {
    color: rgb(var(--ad-ink));
    background-color: rgb(var(--ad-ink) / 0.05);
  }
  .obsidian-analytics-dashboard .link-more:hover svg {
    transform: translateX(2px);
  }
}

/* ───────── Search ───────── */

.obsidian-analytics-dashboard .search {
  position: relative;
  flex: 1;
  max-width: 340px;
  display: flex;
  align-items: center;
}

.obsidian-analytics-dashboard .search-icon {
  position: absolute;
  left: 11px;
  color: rgb(var(--ad-ink) / 0.4);
  pointer-events: none;
}

.obsidian-analytics-dashboard .search input {
  width: 100%;
  height: 34px;
  padding: 0 12px 0 33px;
  border: 0;
  border-radius: 10px;
  outline: none;
  font-size: 14px;
  background: rgb(var(--ad-ink) / 0.045);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.06);
  transition:
    box-shadow var(--duration-quick) ease,
    background-color var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .search input::placeholder {
  color: rgb(var(--ad-ink) / 0.38);
}

.obsidian-analytics-dashboard .search input:focus-visible {
  background: rgb(var(--ad-ink) / 0.06);
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 65%, transparent);
}

/* ───────── Empty state ───────── */

.obsidian-analytics-dashboard .empty {
  display: grid;
  justify-items: center;
  gap: 6px;
  padding: 48px 20px;
  border-radius: 14px;
  text-align: center;
  background: rgb(var(--ad-ink) / 0.03);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.05);
}

.obsidian-analytics-dashboard .empty-title {
  font-size: 15px;
  font-weight: 500;
}

.obsidian-analytics-dashboard .empty-sub {
  margin-bottom: 8px;
  font-size: 14px;
  color: var(--text-3);
}

/* ───────── Sparkline ───────── */

.obsidian-analytics-dashboard .spark {
  display: block;
  width: 100%;
  height: 34px;
  overflow: visible;
  color: var(--accent);
}

.obsidian-analytics-dashboard .spark[data-tone="bad"] {
  color: var(--bad);
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .spark {
    animation: obsidian-ad-spark-reveal 900ms var(--ease-smooth-out) both;
    animation-delay: calc(var(--enter-base) + 260ms);
  }
  .obsidian-analytics-dashboard .spark-dot {
    animation: obsidian-ad-fade-in 300ms ease both;
    animation-delay: calc(var(--enter-base) + 900ms);
  }
}

@keyframes obsidian-ad-spark-reveal {
  from {
    clip-path: inset(0 100% 0 0);
  }
  to {
    clip-path: inset(0 0 0 0);
  }
}

/* ───────── PR row (Overview "Today PRs") ───────── */

.obsidian-analytics-dashboard .pr-list {
  display: flex;
  flex-direction: column;
  margin: 4px -10px 0;
}

.obsidian-analytics-dashboard .pr-row {
  display: flex;
  align-items: center;
  gap: 16px;
  min-height: 59px;
  padding: 0 10px;
  border-radius: 12px;
  transition: background-color var(--duration-quick) ease;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .pr-row:hover {
    background-color: rgb(var(--ad-ink) / 0.03);
  }
}

.obsidian-analytics-dashboard .pr-text {
  flex: 1;
  min-width: 0;
}

.obsidian-analytics-dashboard .pr-title {
  font-size: 15px;
  line-height: 21px;
  color: rgb(var(--ad-ink) / 0.88);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.obsidian-analytics-dashboard .pr-sub {
  margin-top: 2px;
  font-size: 13.5px;
  line-height: 19px;
  color: var(--text-3);
}

@container analytics-dashboard (max-width: 760px) {
  .obsidian-analytics-dashboard .pr-title {
    white-space: normal;
  }
}

@container analytics-dashboard (max-width: 520px) {
  .obsidian-analytics-dashboard .pr-row .pill {
    display: none;
  }
}

/* PR details side panel: content swap, accordions, conversation and composer. */

.obsidian-analytics-dashboard .details {
  grid-column: 3;
  position: relative;
  display: flex;
  flex-direction: column;
  width: var(--details-w);
  min-height: 0;
  transition: opacity 250ms var(--ease-smooth-out);
}

.obsidian-analytics-dashboard .window[data-collapsed="true"] .details {
  opacity: 0;
  pointer-events: none;
}

/* The 28px hide button starts at 11.5px so its 15px glyph sits on the 18px content edge. */

.obsidian-analytics-dashboard .details-bar {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 3px;
  height: 44px;
  margin-top: 7px;
  padding: 0 20px 0 11.5px;
  flex-shrink: 0;
}

.obsidian-analytics-dashboard .details-hide {
  flex: none;
}

.obsidian-analytics-dashboard .details-bar-title {
  flex: 1;
  min-width: 0;
  font-size: 15px;
  font-weight: 500;
  color: rgb(var(--ad-ink) / 0.55);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.obsidian-analytics-dashboard .details-scroll {
  --obsidian-analytics-dashboard-fade-top: 0px;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
  padding: 0 20px 164px 18px;
  /* Text fades out as it slides under the floating composer */
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 var(--obsidian-analytics-dashboard-fade-top),
    #000 calc(100% - 158px),
    rgb(0 0 0 / 0.36) calc(100% - 124px),
    rgb(0 0 0 / 0.14) calc(100% - 104px),
    transparent calc(100% - 96px)
  );
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 var(--obsidian-analytics-dashboard-fade-top),
    #000 calc(100% - 158px),
    rgb(0 0 0 / 0.36) calc(100% - 124px),
    rgb(0 0 0 / 0.14) calc(100% - 104px),
    transparent calc(100% - 96px)
  );
  transition: --obsidian-analytics-dashboard-fade-top 200ms ease;
}

.obsidian-analytics-dashboard .details-scroll::-webkit-scrollbar {
  display: none;
}

.obsidian-analytics-dashboard .details-scroll[data-scrolled="true"] {
  --obsidian-analytics-dashboard-fade-top: 28px;
}

.obsidian-analytics-dashboard .details-body {
  padding-top: 14px;
}

/* ───────── Content swap between PRs ───────── */

.obsidian-analytics-dashboard .details-swap {
  transition:
    opacity 300ms var(--ease-out-strong),
    transform 300ms var(--ease-out-strong),
    filter 300ms var(--ease-out-strong);
}

.obsidian-analytics-dashboard .details-swap[data-phase="out"] {
  opacity: 0;
  transform: translateY(-6px);
  filter: blur(4px);
  transition-duration: 150ms;
  transition-timing-function: ease-out;
}

.obsidian-analytics-dashboard .details-swap[data-phase="pre"] {
  opacity: 0;
  transform: translateY(12px);
  filter: blur(4px);
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-analytics-dashboard .details-swap,
  .obsidian-analytics-dashboard .details-swap[data-phase] {
    transform: none;
    filter: none;
  }
}

.obsidian-analytics-dashboard .status {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 14.5px;
  font-weight: 500;
  line-height: 20px;
}

.obsidian-analytics-dashboard .status[data-kind="merged"] {
  color: var(--merged);
}

.obsidian-analytics-dashboard .status[data-kind="review"] {
  color: var(--review);
}

.obsidian-analytics-dashboard .status[data-kind="open"] {
  color: var(--open);
}

.obsidian-analytics-dashboard .details-title {
  margin-top: 7px;
  font-size: 19.5px;
  font-weight: 500;
  line-height: 24px;
  letter-spacing: -0.01em;
  color: rgb(var(--ad-ink));
  text-wrap: pretty;
}

.obsidian-analytics-dashboard .kv {
  margin-top: 13px;
  display: flex;
  flex-direction: column;
}

.obsidian-analytics-dashboard .kv > div {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 28.5px;
}

.obsidian-analytics-dashboard .kv dt {
  font-size: 14.5px;
  color: var(--text-3);
}

.obsidian-analytics-dashboard .kv dd {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14.5px;
  color: rgb(var(--ad-ink) / 0.9);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ───────── Accordion (transitions-dev #21) ───────── */

.obsidian-analytics-dashboard .kv + .acc {
  margin-top: 24px;
}

.obsidian-analytics-dashboard .acc + .acc {
  margin-top: 14px;
}

.obsidian-analytics-dashboard .acc-head {
  display: flex;
  align-items: center;
  gap: 9px;
  height: 24px;
  font-size: 14.5px;
  font-weight: 500;
  color: rgb(var(--ad-ink) / 0.5);
  border-radius: 6px;
  transition: color var(--duration-quick) ease;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .acc-head:hover {
    color: rgb(var(--ad-ink) / 0.78);
  }
}

.obsidian-analytics-dashboard .acc-tri {
  color: rgb(var(--ad-ink) / 0.45);
  transform: rotate(0deg);
  transition: transform 250ms var(--ease-smooth-out);
}

.obsidian-analytics-dashboard .acc[data-open="true"] .acc-tri {
  transform: rotate(90deg);
}

.obsidian-analytics-dashboard .acc-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 250ms var(--ease-smooth-out);
}

.obsidian-analytics-dashboard .acc[data-open="true"] .acc-panel {
  grid-template-rows: 1fr;
}

.obsidian-analytics-dashboard .acc-inner {
  overflow: hidden;
  opacity: 0;
  filter: blur(2px);
  transition:
    opacity 250ms var(--ease-smooth-out),
    filter 250ms var(--ease-smooth-out);
}

.obsidian-analytics-dashboard .acc[data-open="true"] .acc-inner {
  opacity: 1;
  filter: blur(0);
}

.obsidian-analytics-dashboard .acc-inner p {
  padding-right: 8px;
  font-size: 14.5px;
  line-height: 20.5px;
  color: rgb(var(--ad-ink) / 0.88);
  text-wrap: pretty;
}

.obsidian-analytics-dashboard .acc-inner p:first-child {
  padding-top: 8px;
}

.obsidian-analytics-dashboard .acc-inner p + p {
  margin-top: 9px;
}

.obsidian-analytics-dashboard .code-chip {
  font-family: var(--obsidian-analytics-dashboard-font-mono), ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12.5px;
  padding: 2px 6px;
  border-radius: 6px;
  color: rgb(var(--ad-ink) / 0.72);
  background: rgb(var(--ad-ink) / 0.08);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-analytics-dashboard .acc-panel,
  .obsidian-analytics-dashboard .acc-inner,
  .obsidian-analytics-dashboard .acc-tri {
    transition: none !important;
  }
}

/* ───────── Conversation ───────── */

.obsidian-analytics-dashboard .thread {
  margin-top: 22px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.obsidian-analytics-dashboard .msg {
  font-size: 14.5px;
  line-height: 20.5px;
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .msg {
    animation: obsidian-ad-enter 300ms var(--ease-out-strong) both;
  }
}

.obsidian-analytics-dashboard .msg-user {
  align-self: flex-end;
  max-width: 85%;
  padding: 8px 12px;
  border-radius: 14px 14px 4px 14px;
  background: rgb(var(--ad-ink) / 0.09);
  color: rgb(var(--ad-ink));
}

.obsidian-analytics-dashboard .msg-assistant {
  color: rgb(var(--ad-ink) / 0.86);
}

/* ───────── Composer ───────── */

.obsidian-analytics-dashboard .composer-slot {
  position: absolute;
  left: 18px;
  right: 14px;
  bottom: 15px;
}

.obsidian-analytics-dashboard .composer {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 10px 10px;
  border: 1px solid transparent;
  border-radius: 16px;
  background-color: var(--ad-composer);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.05);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  transition:
    background-color var(--duration-quick) ease,
    box-shadow var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .composer:focus-within {
  background-color: var(--ad-composer-focus);
  box-shadow:
    inset 0 0 0 1px rgb(var(--ad-ink) / 0.14),
    0 8px 24px -8px rgb(0 0 0 / calc(0.45 * var(--ad-shadow)));
}

.obsidian-analytics-dashboard .composer-input {
  display: block;
  width: 100%;
  min-height: 22px;
  max-height: 120px;
  padding: 0 4px;
  border: 0;
  background: transparent;
  resize: none;
  outline: none;
  font-size: 14.5px;
  line-height: 22px;
  color: rgb(var(--ad-ink));
  field-sizing: content;
  scrollbar-width: none;
}

.obsidian-analytics-dashboard .composer-input::placeholder {
  color: rgb(var(--ad-ink) / 0.52);
}

.obsidian-analytics-dashboard .composer-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.obsidian-analytics-dashboard .chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 29px;
  padding: 0 12px 0 10px;
  border-radius: 999px;
  font-size: 14.5px;
  color: rgb(var(--ad-ink) / 0.75);
  background-color: rgb(var(--ad-ink) / 0.07);
  transition:
    background-color var(--duration-quick) ease,
    color var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .chip:hover {
    background-color: rgb(var(--ad-ink) / 0.11);
    color: rgb(var(--ad-ink));
  }
}

.obsidian-analytics-dashboard .claude-icon {
  transition: transform 400ms var(--ease-smooth-out);
}

@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .chip-claude:hover .claude-icon {
    transform: rotate(40deg) scale(1.08);
  }
}

.obsidian-analytics-dashboard .send {
  margin-left: auto;
  display: grid;
  place-items: center;
  width: 29px;
  height: 29px;
  border-radius: 50%;
  color: var(--ad-window);
  background-color: rgb(var(--ad-ink));
  transition:
    opacity 200ms var(--ease-ui),
    transform 200ms var(--ease-ui),
    filter 200ms var(--ease-ui);
}

.obsidian-analytics-dashboard .send:disabled {
  opacity: 0;
  transform: scale(0.8);
  filter: blur(4px);
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .obsidian-analytics-dashboard .send,
  .obsidian-analytics-dashboard .send:disabled {
    transform: none;
    filter: none;
  }
}

/* ───────── Slide-over below the desktop breakpoint ───────── */

@container analytics-dashboard (max-width: 1180px) {
  /* grid-column: auto makes the whole window the containing block; column 3
     no longer exists here, which would collapse the panel to zero width. */
  .obsidian-analytics-dashboard .details,
  .obsidian-analytics-dashboard .window[data-collapsed="true"] .details {
    grid-column: auto;
    position: absolute;
    z-index: 6;
    top: 7px;
    right: 7px;
    bottom: 7px;
    width: min(380px, calc(100% - 68px));
    border: 1px solid transparent;
    border-radius: 15px;
    background: var(--ad-overlay);
    backdrop-filter: blur(30px) saturate(160%);
    -webkit-backdrop-filter: blur(30px) saturate(160%);
    box-shadow:
      0 0 0 1px rgb(var(--ad-ink) / 0.08),
      0 24px 60px -12px rgb(0 0 0 / calc(0.6 * var(--ad-shadow)));
    opacity: 0;
    transform: translateX(calc(100% + 16px));
    visibility: hidden;
    pointer-events: none;
    transition:
      transform 350ms var(--ease-smooth-out),
      opacity 350ms var(--ease-smooth-out),
      visibility 0s linear 350ms;
  }

  .obsidian-analytics-dashboard .details[data-open="true"],
  .obsidian-analytics-dashboard .window[data-collapsed="true"] .details[data-open="true"] {
    opacity: 1;
    transform: translateX(0);
    visibility: visible;
    pointer-events: auto;
    transition:
      transform 400ms var(--ease-smooth-out),
      opacity 400ms var(--ease-smooth-out),
      visibility 0s;
  }

  .obsidian-analytics-dashboard .details-bar {
    margin-top: 0;
  }

  @media (prefers-reduced-motion: reduce) {
    .obsidian-analytics-dashboard .details,
    .obsidian-analytics-dashboard .details[data-open="true"] {
      transform: none;
    }
  }
}

/* Overview route: KPI cards, commit activity chart, contributor leaderboard. */

/* ───────── KPI cards ───────── */

.obsidian-analytics-dashboard .stats {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 7px;
}

.obsidian-analytics-dashboard .stat {
  display: flex;
  flex-direction: column;
  padding: 13px 15px 12px;
  border: 1px solid transparent;
  border-radius: 12px;
  background-color: var(--fill-1);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.025);
  transition:
    background-color var(--duration-quick) ease,
    box-shadow var(--duration-quick) ease;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .stat:hover {
    background-color: rgb(var(--ad-ink) / 0.075);
    box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.05);
  }
  .obsidian-analytics-dashboard .stat:hover .stat-icon {
    color: rgb(var(--ad-ink) / 0.85);
  }
}

.obsidian-analytics-dashboard .stat-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin: 10px 0 8px;
}

.obsidian-analytics-dashboard .stat-label {
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  font-size: 14px;
  line-height: 20px;
  color: rgb(var(--ad-ink) / 0.55);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.obsidian-analytics-dashboard .stat-icon {
  flex: none;
  color: rgb(var(--ad-ink) / 0.5);
  transition: color var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .delta {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 3px;
  height: 21px;
  padding: 0 7px 0 5px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

.obsidian-analytics-dashboard .delta[data-good="true"] {
  color: var(--add-fg);
  background: var(--add-bg);
}

.obsidian-analytics-dashboard .delta[data-good="false"] {
  color: var(--bad);
  background: rgba(240, 122, 106, 0.12);
}

.obsidian-analytics-dashboard .stat-value {
  min-width: 0;
  font-size: 25px;
  font-weight: 500;
  line-height: 30px;
  letter-spacing: -0.015em;
  color: rgb(var(--ad-ink));
  font-variant-numeric: tabular-nums;
}

/* ───────── Activity + leaderboard grid ───────── */

.obsidian-analytics-dashboard .overview-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  gap: 7px;
  margin-top: 7px;
}

.obsidian-analytics-dashboard .overview-grid > .card {
  padding: 15px 18px 14px;
}

.obsidian-analytics-dashboard .legend {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 4px 12px;
  font-size: 12.5px;
  color: rgb(var(--ad-ink) / 0.5);
}

.obsidian-analytics-dashboard .legend li {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.obsidian-analytics-dashboard .legend i {
  width: 8px;
  height: 8px;
  border-radius: 2.5px;
  background: var(--c);
}

/* ───────── Stacked bar chart ───────── */

.obsidian-analytics-dashboard .chart {
  position: relative;
  height: 196px;
}

.obsidian-analytics-dashboard .chart-grid {
  position: absolute;
  inset: 0 0 24px 26px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.obsidian-analytics-dashboard .chart-grid span {
  position: relative;
  height: 0;
  border-top: 1px dashed rgb(var(--ad-ink) / 0.07);
}

.obsidian-analytics-dashboard .chart-grid span::before {
  content: attr(data-value);
  position: absolute;
  left: -26px;
  top: 0;
  transform: translateY(-50%);
  font-size: 11px;
  color: rgb(var(--ad-ink) / 0.32);
  font-variant-numeric: tabular-nums;
}

.obsidian-analytics-dashboard .chart-cols {
  position: absolute;
  inset: 0 0 0 26px;
  display: grid;
  grid-template-columns: repeat(var(--cols, 5), minmax(0, 1fr));
  gap: 12px;
}

.obsidian-analytics-dashboard .chart-col {
  display: grid;
  grid-template-rows: minmax(0, 1fr) 24px;
  min-width: 0;
}

.obsidian-analytics-dashboard .bar-wrap {
  grid-row: 1;
  align-self: end;
  justify-self: center;
  width: min(46px, 100%);
}

.obsidian-analytics-dashboard .bar {
  height: 100%;
  display: flex;
  flex-direction: column-reverse;
  gap: 2px;
  border-radius: 7px 7px 3px 3px;
  overflow: hidden;
  transform-origin: 50% 100%;
  transition: opacity var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .bar-seg {
  flex-basis: 0;
  min-height: 3px;
  background: var(--c);
}

.obsidian-analytics-dashboard .chart-label {
  grid-row: 2;
  align-self: end;
  font-size: 11.5px;
  line-height: 18px;
  text-align: center;
  color: rgb(var(--ad-ink) / 0.4);
  white-space: nowrap;
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .bar {
    animation: obsidian-ad-bar-grow 650ms var(--ease-out-strong) both;
    animation-delay: calc(var(--enter-base) + 220ms + var(--col) * 60ms);
  }
}

@keyframes obsidian-ad-bar-grow {
  from {
    transform: scaleY(0);
  }
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .chart-cols:hover .chart-col:not(:hover) .bar {
    opacity: 0.4;
  }
  .obsidian-analytics-dashboard .chart-col:hover .bar-wrap::after {
    opacity: 1;
    transform: translate(-50%, 0) scale(1);
    transition-duration: 150ms;
  }
}

/* ───────── Leaderboard ───────── */

.obsidian-analytics-dashboard .leaders {
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.obsidian-analytics-dashboard .leader {
  display: flex;
  align-items: center;
  gap: 12px;
}

.obsidian-analytics-dashboard .leader-main {
  flex: 1;
  min-width: 0;
}

.obsidian-analytics-dashboard .leader-line {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
}

.obsidian-analytics-dashboard .leader-name {
  font-size: 14.5px;
  color: var(--text);
}

.obsidian-analytics-dashboard .leader-count {
  font-size: 13px;
  color: rgb(var(--ad-ink) / 0.55);
  font-variant-numeric: tabular-nums;
}

.obsidian-analytics-dashboard .share {
  height: 5px;
  margin: 7px 0 8px;
  border-radius: 999px;
  background: rgb(var(--ad-ink) / 0.06);
  overflow: hidden;
}

.obsidian-analytics-dashboard .share-fill {
  display: block;
  height: 100%;
  width: calc(var(--share) * 100%);
  border-radius: inherit;
  background: var(--c);
  transform-origin: 0 50%;
  transition: width 700ms var(--ease-out-strong);
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .share-fill {
    animation: obsidian-ad-share-grow 700ms var(--ease-out-strong) both;
    animation-delay: calc(var(--enter-base) + 300ms + var(--row) * 80ms);
  }
}

@keyframes obsidian-ad-share-grow {
  from {
    transform: scaleX(0);
  }
}

.obsidian-analytics-dashboard .leader-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 6px;
}

.obsidian-analytics-dashboard .leader-pct {
  margin-left: auto;
  font-size: 12.5px;
  color: rgb(var(--ad-ink) / 0.4);
  white-space: nowrap;
}

/* ───────── Responsive ─────────
   Sized off the page container, not the viewport: the sidebar and PR panel
   both change how much room the page gets at the same screen width. */

@container page (max-width: 740px) {
  .obsidian-analytics-dashboard .stats {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .obsidian-analytics-dashboard .overview-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

@container page (max-width: 420px) {
  .obsidian-analytics-dashboard .stats {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Repositories route: searchable, sortable grid of repository cards. */

.obsidian-analytics-dashboard .repo-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
}

.obsidian-analytics-dashboard .repo {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 15px 16px 14px 18px;
  transition:
    background-color var(--duration-quick) ease,
    box-shadow var(--duration-quick) ease;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .repo:hover {
    background-color: rgb(var(--ad-ink) / 0.07);
    box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.06);
  }
}

.obsidian-analytics-dashboard .repo-head {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.obsidian-analytics-dashboard .repo-vis-icon {
  flex: none;
  color: rgb(var(--ad-ink) / 0.42);
}

.obsidian-analytics-dashboard .repo-name {
  min-width: 0;
  font-size: 15.5px;
  font-weight: 600;
  line-height: 22px;
  color: rgb(var(--ad-ink));
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.obsidian-analytics-dashboard .repo-org {
  font-weight: 450;
  color: rgb(var(--ad-ink) / 0.5);
}

.obsidian-analytics-dashboard .star-btn {
  flex: none;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  margin: -4px -6px -4px auto;
  border-radius: 8px;
  color: rgb(var(--ad-ink) / 0.42);
  transition:
    color var(--duration-quick) ease,
    background-color var(--duration-quick) ease,
    transform var(--duration-quick) ease-out;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .star-btn:hover {
    color: rgb(var(--ad-ink) / 0.85);
    background-color: rgb(var(--ad-ink) / 0.06);
  }
}

.obsidian-analytics-dashboard .star-btn[aria-pressed="true"] {
  color: var(--star);
}

.obsidian-analytics-dashboard .star-btn[aria-pressed="true"] svg {
  fill: currentColor;
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .star-btn[aria-pressed="true"] svg {
    animation: obsidian-ad-star-pop 380ms var(--digit-ease);
  }
}

@keyframes obsidian-ad-star-pop {
  0% {
    transform: scale(0.6);
  }
  60% {
    transform: scale(1.18);
  }
}

.obsidian-analytics-dashboard .repo-desc {
  min-height: 40px;
  font-size: 14px;
  line-height: 20px;
  color: rgb(var(--ad-ink) / 0.55);
  text-wrap: pretty;
}

/* 14-day commit strip */

.obsidian-analytics-dashboard .mini-bars {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 34px;
}

.obsidian-analytics-dashboard .mini-bars span {
  flex: 1;
  height: calc(var(--h) * 100%);
  min-height: 3px;
  border-radius: 2px;
  background: color-mix(in oklab, var(--accent) 50%, transparent);
  transform-origin: 50% 100%;
}

.obsidian-analytics-dashboard .mini-bars span:last-child {
  background: var(--accent);
}

@media (prefers-reduced-motion: no-preference) {
  .obsidian-analytics-dashboard .mini-bars span {
    animation: obsidian-ad-bar-grow 500ms var(--ease-out-strong) both;
    animation-delay: calc(var(--enter-base) + 200ms + var(--bar) * 22ms);
  }
}

.obsidian-analytics-dashboard .repo-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 14px;
  font-size: 12.5px;
  color: rgb(var(--ad-ink) / 0.5);
}

.obsidian-analytics-dashboard .repo-lang,
.obsidian-analytics-dashboard .repo-stat {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-variant-numeric: tabular-nums;
}

.obsidian-analytics-dashboard .repo-lang i {
  width: 9px;
  height: 9px;
  border-radius: 50%;
}

.obsidian-analytics-dashboard .repo-updated {
  margin-left: auto;
}

@container analytics-dashboard (max-width: 880px) {
  .obsidian-analytics-dashboard .repo-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

@container analytics-dashboard (max-width: 520px) {
  .obsidian-analytics-dashboard .toolbar {
    flex-wrap: wrap;
  }
  .obsidian-analytics-dashboard .search {
    max-width: none;
    flex-basis: 100%;
  }
}

/* Code review route: status-filtered pull request queue. */

.obsidian-analytics-dashboard .review-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.obsidian-analytics-dashboard .review-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 14px;
  border: 1px solid transparent;
  border-radius: 12px;
  background-color: rgb(var(--ad-ink) / 0.035);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.03);
  transition:
    background-color var(--duration-quick) ease,
    box-shadow var(--duration-quick) ease;
}

@media (hover: hover) {
  .obsidian-analytics-dashboard .review-row:hover {
    background-color: rgb(var(--ad-ink) / 0.055);
  }
}

.obsidian-analytics-dashboard .review-row[data-selected="true"] {
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 38%, transparent);
}

.obsidian-analytics-dashboard .review-main {
  flex: 1;
  min-width: 0;
}

.obsidian-analytics-dashboard .review-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 7px;
  margin-top: 4px;
  font-size: 13px;
  line-height: 20px;
  color: rgb(var(--ad-ink) / 0.45);
}

.obsidian-analytics-dashboard .review-repo {
  font-family: var(--obsidian-analytics-dashboard-font-mono), ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  color: rgb(var(--ad-ink) / 0.6);
}

.obsidian-analytics-dashboard .review-author,
.obsidian-analytics-dashboard .review-comments {
  display: inline-flex;
  align-items: center;
  gap: 5px;
}

.obsidian-analytics-dashboard .review-author .avatar-22 {
  width: 18px;
  height: 18px;
}

.obsidian-analytics-dashboard .diffstat {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--obsidian-analytics-dashboard-font-mono), ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

.obsidian-analytics-dashboard .diff-add {
  color: var(--add-fg);
}

.obsidian-analytics-dashboard .diff-del {
  color: var(--del-fg);
}

.obsidian-analytics-dashboard .diff-blocks {
  display: inline-flex;
  gap: 2px;
  margin-left: 2px;
}

.obsidian-analytics-dashboard .diff-blocks i {
  width: 7px;
  height: 7px;
  border-radius: 1.5px;
}

.obsidian-analytics-dashboard .diff-blocks i[data-kind="add"] {
  background: var(--add-fg);
}

.obsidian-analytics-dashboard .diff-blocks i[data-kind="del"] {
  background: var(--del-fg);
}

@container analytics-dashboard (max-width: 1000px) {
  .obsidian-analytics-dashboard .review-row .diffstat {
    display: none;
  }
}

@container analytics-dashboard (max-width: 760px) {
  .obsidian-analytics-dashboard .review-row .pill {
    display: none;
  }
}

@container analytics-dashboard (max-width: 520px) {
  /* The filter keeps equal tabs and scrolls sideways instead of squeezing labels into each other. */
  .obsidian-analytics-dashboard .toolbar:has(.seg-wide) {
    margin-inline: -14px -12px;
    padding: 2px 12px 2px 14px;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
  }
  .obsidian-analytics-dashboard .toolbar:has(.seg-wide)::-webkit-scrollbar {
    display: none;
  }
  .obsidian-analytics-dashboard .seg-wide {
    min-width: 100%;
    grid-template-columns: repeat(var(--n), 1fr);
  }
  .obsidian-analytics-dashboard .seg-wide .seg-btn {
    gap: 5px;
    padding: 0 10px;
  }
  .obsidian-analytics-dashboard .review-row {
    flex-wrap: wrap;
    align-items: flex-start;
    gap: 10px 12px;
    padding: 12px;
  }
  .obsidian-analytics-dashboard .review-row .status-dot {
    display: none;
  }
  .obsidian-analytics-dashboard .review-main {
    flex-basis: 0;
  }
  .obsidian-analytics-dashboard .review-row .btn-details {
    flex-basis: 100%;
    width: 100%;
  }
  .obsidian-analytics-dashboard .review-repo {
    flex-basis: 100%;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .obsidian-analytics-dashboard .review-repo + [aria-hidden="true"] {
    display: none;
  }
}

/* Settings route: profile fields, notification switches, assistant and appearance. */

.obsidian-analytics-dashboard .settings-form {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: 760px;
}

.obsidian-analytics-dashboard .settings-card {
  padding: 16px 20px 8px;
}

.obsidian-analytics-dashboard .settings-card > .card-title {
  margin-bottom: 2px;
}

.obsidian-analytics-dashboard .profile-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 12px 0 16px;
}

.obsidian-analytics-dashboard .profile-name {
  font-size: 15px;
  font-weight: 500;
  color: rgb(var(--ad-ink));
}

.obsidian-analytics-dashboard .profile-role {
  font-size: 13px;
  color: var(--text-3);
}

.obsidian-analytics-dashboard .fields {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
  padding-bottom: 12px;
}

.obsidian-analytics-dashboard .field {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.obsidian-analytics-dashboard .field-label {
  font-size: 13px;
  font-weight: 500;
  color: rgb(var(--ad-ink) / 0.55);
}

.obsidian-analytics-dashboard .field input {
  height: 36px;
  padding: 0 12px;
  border: 0;
  border-radius: 9px;
  outline: none;
  font-size: 14.5px;
  background: rgb(var(--ad-ink) / 0.045);
  box-shadow: inset 0 0 0 1px rgb(var(--ad-ink) / 0.07);
  transition:
    box-shadow var(--duration-quick) ease,
    background-color var(--duration-quick) ease;
}

.obsidian-analytics-dashboard .field input:focus-visible {
  background: rgb(var(--ad-ink) / 0.06);
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 65%, transparent);
}

.obsidian-analytics-dashboard .field input[aria-invalid="true"] {
  box-shadow: inset 0 0 0 1px rgba(240, 75, 75, 0.6);
}

.obsidian-analytics-dashboard .field-hint {
  font-size: 12.5px;
  color: rgb(var(--ad-ink) / 0.4);
}

.obsidian-analytics-dashboard .field-hint[data-error="true"] {
  color: var(--del-fg);
}

.obsidian-analytics-dashboard .setting-list {
  display: flex;
  flex-direction: column;
}

.obsidian-analytics-dashboard .setting-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 0;
}

.obsidian-analytics-dashboard .setting-list > li + li {
  border-top: 1px solid rgb(var(--ad-ink) / 0.05);
}

.obsidian-analytics-dashboard .setting-title {
  font-size: 14.5px;
  color: var(--text);
}

.obsidian-analytics-dashboard .setting-detail {
  margin-top: 2px;
  font-size: 13px;
  line-height: 18px;
  color: var(--text-3);
  text-wrap: pretty;
}

/* Accent swatches */

.obsidian-analytics-dashboard .swatches {
  flex: none;
  display: flex;
  gap: 10px;
}

.obsidian-analytics-dashboard .swatch {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  color: #fff;
  background: var(--swatch);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
  transition:
    box-shadow 200ms var(--ease-ui),
    transform var(--duration-quick) ease-out;
}

.obsidian-analytics-dashboard .swatch svg {
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 150ms ease,
    transform 250ms var(--digit-ease);
}

.obsidian-analytics-dashboard .swatch[aria-checked="true"] {
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.2),
    0 0 0 2px var(--ad-surface),
    0 0 0 4px var(--swatch);
}

.obsidian-analytics-dashboard .swatch[aria-checked="true"] svg {
  opacity: 1;
  transform: scale(1);
}

/* Save bar sticks to the bottom of the scroll area */

.obsidian-analytics-dashboard .save-bar {
  position: sticky;
  bottom: 12px;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
  padding: 8px 8px 8px 16px;
  border: 1px solid transparent;
  border-radius: 14px;
  background: var(--ad-save-bar);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  box-shadow:
    inset 0 0 0 1px rgb(var(--ad-ink) / 0.07),
    0 12px 30px -10px rgb(0 0 0 / calc(0.6 * var(--ad-shadow)));
}

.obsidian-analytics-dashboard .save-note {
  flex: 1;
  min-width: 0;
  font-size: 13.5px;
  color: rgb(var(--ad-ink) / 0.55);
}

@container analytics-dashboard (max-width: 760px) {
  .obsidian-analytics-dashboard .fields {
    grid-template-columns: minmax(0, 1fr);
  }
  .obsidian-analytics-dashboard .setting-row:has(.seg) {
    flex-wrap: wrap;
  }
}
```

### components/block/analytics-dashboard.tsx

Installation target: `@components/block/analytics-dashboard.tsx`

```tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import {
  ACCENTS,
  DashboardContext,
  type AnalyticsDashboardAccent,
  type AnalyticsDashboardPage,
  type AnalyticsDashboardUser,
  type AnswerHandler,
} from "@/components/analytics-dashboard/context";
import { analyticsDashboardDemoData, type AnalyticsDashboardData } from "@/components/analytics-dashboard/data";
import { PrDetails, type Phase } from "@/components/analytics-dashboard/details";
import { CodeIcon, HomeIcon, LayersIcon, PanelIcon, SettingsIcon } from "@/components/analytics-dashboard/icons";
import { OverviewPage, RepositoriesPage, ReviewsPage, SettingsPage } from "@/components/analytics-dashboard/pages";
import { Avatar } from "@/components/analytics-dashboard/ui";
import "./analytics-dashboard.css";

export { analyticsDashboardDemoData } from "@/components/analytics-dashboard/data";
export type {
  AnalyticsActivity,
  AnalyticsDashboardData,
  AnalyticsKpi,
  AnalyticsKpiIcon,
  AnalyticsPerson,
  AnalyticsPrStatus,
  AnalyticsPullRequest,
  AnalyticsRange,
  AnalyticsRepository,
} from "@/components/analytics-dashboard/data";
export type { AnalyticsDashboardAccent, AnalyticsDashboardPage, AnalyticsDashboardUser } from "@/components/analytics-dashboard/context";

/** Keep in sync with the `analytics-dashboard` container queries in analytics-dashboard.css. */
const OVERLAY_WIDTH = 1180;
const COMPACT_WIDTH = 760;
const EXIT_MS = 150;

const NAV = [
  { page: "overview", label: "Overview", Icon: HomeIcon },
  { page: "repositories", label: "Repositories", Icon: LayersIcon },
  { page: "reviews", label: "Code review", Icon: CodeIcon },
  { page: "settings", label: "Settings", Icon: SettingsIcon },
] as const satisfies readonly { page: AnalyticsDashboardPage; label: string; Icon: typeof HomeIcon }[];

const PAGES: Record<AnalyticsDashboardPage, () => ReactNode> = {
  overview: OverviewPage,
  repositories: RepositoriesPage,
  reviews: ReviewsPage,
  settings: SettingsPage,
};

const DEFAULT_USER: AnalyticsDashboardUser = {
  name: "atharv",
  email: "atharv@obsidianui.dev",
  avatar: "https://www.obsidianui.dev/analytics-dashboard/avatars/atharv.webp",
  role: "CLI & tooling · Admin",
};

export interface AnalyticsDashboardProps {
  /** People, metrics, activity, repositories, and pull requests. Defaults to demo data. */
  data?: AnalyticsDashboardData;
  /** Product name and optional logo at the top of the sidebar. */
  brand?: { name: string; logo?: ReactNode };
  /** Signed-in account shown in the sidebar and on the Settings page. */
  user?: AnalyticsDashboardUser;
  /** Controlled page. */
  page?: AnalyticsDashboardPage;
  /** Initial page when uncontrolled. */
  defaultPage?: AnalyticsDashboardPage;
  onPageChange?: (page: AnalyticsDashboardPage) => void;
  /** Initial accent color. Settings → Appearance changes it. */
  defaultAccent?: AnalyticsDashboardAccent;
  onAccentChange?: (accent: AnalyticsDashboardAccent) => void;
  /** Assistant name in the PR panel composer. */
  assistantName?: string;
  /** Answers questions asked in the PR panel. Without it, the panel replies with a summary of the pull request. */
  onAsk?: AnswerHandler;
  /** Plain color behind the window. */
  background?: string;
  /**
   * `floating` shows the dashboard as a rounded window on a plain background, for previews and marketing pages.
   * `fill` drops the frame so the dashboard fills its parent edge to edge, for an app route such as `/dashboard`.
   */
  variant?: "floating" | "fill";
  className?: string;
  style?: CSSProperties;
}

function useContainerWidth() {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState<number | null>(null);
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    setWidth(el.clientWidth);
    const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    observer.observe(el);
    return () => observer.disconnect();
  }, []);
  return [ref, width] as const;
}

export function AnalyticsDashboard({
  data = analyticsDashboardDemoData,
  brand = { name: "PR Dashboard" },
  user = DEFAULT_USER,
  page: pageProp,
  defaultPage = "overview",
  onPageChange,
  defaultAccent = "blue",
  onAccentChange,
  assistantName = "Claude",
  onAsk,
  background,
  variant = "floating",
  className,
  style,
}: AnalyticsDashboardProps) {
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [rootRef, width] = useContainerWidth();
  const [pageState, setPageState] = useState<AnalyticsDashboardPage>(defaultPage);
  const page = pageProp ?? pageState;
  const firstPr = data.pullRequests[0]?.number ?? 0;
  const [selected, setSelected] = useState(firstPr);
  const [shown, setShown] = useState(firstPr);
  const [phase, setPhase] = useState<Phase>("in");
  const [panelOpen, setPanelOpen] = useState(false);
  const [collapsed, setCollapsed] = useState(false);
  const [navOpen, setNavOpen] = useState(false);
  const [accent, setAccentState] = useState<AnalyticsDashboardAccent>(defaultAccent);
  const [booted, setBooted] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  const scrollRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLElement>(null);
  const reopenRef = useRef<HTMLButtonElement>(null);
  const hideRef = useRef<HTMLButtonElement>(null);

  // Layout follows the dashboard's own width, so it also works inside a panel or a preview.
  const overlay = width !== null && width <= OVERLAY_WIDTH;
  const compact = width !== null && width <= COMPACT_WIDTH;
  const layout = useRef({ overlay, compact });
  useEffect(() => {
    layout.current = { overlay, compact };
  }, [overlay, compact]);

  // Desktop keeps the panel docked unless collapsed; narrower layouts slide it over on demand.
  const detailsVisible = overlay ? panelOpen : !collapsed;
  const navFloating = compact && navOpen;

  // First-load entrances wait for the window to land; later page swaps start right away.
  useEffect(() => {
    const t = window.setTimeout(() => setBooted(true), 900);
    return () => window.clearTimeout(t);
  }, []);

  useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = 0;
  }, [page]);

  useEffect(() => () => window.clearTimeout(timer.current), []);

  const navigate = useCallback(
    (next: AnalyticsDashboardPage) => {
      if (pageProp === undefined) setPageState(next);
      onPageChange?.(next);
      if (layout.current.compact) setNavOpen(false);
    },
    [pageProp, onPageChange],
  );

  const setAccent = useCallback(
    (next: AnalyticsDashboardAccent) => {
      setAccentState(next);
      onAccentChange?.(next);
    },
    [onAccentChange],
  );

  const hideDetails = useCallback(() => {
    // Focus would otherwise fall to <body> once the panel turns inert.
    const hadFocus = panelRef.current?.contains(document.activeElement) ?? false;
    if (layout.current.overlay) setPanelOpen(false);
    else setCollapsed(true);
    if (hadFocus) requestAnimationFrame(() => reopenRef.current?.focus({ preventScroll: true }));
  }, []);

  function showDetails() {
    if (layout.current.overlay) setPanelOpen(true);
    else setCollapsed(false);
    requestAnimationFrame(() => hideRef.current?.focus({ preventScroll: true }));
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (event.key !== "Escape" || event.defaultPrevented) return;
    if (overlay && panelOpen) hideDetails();
    else if (navFloating) setNavOpen(false);
    else return;
    // Marks Escape as handled, so a surrounding dialog or full-screen preview stays open.
    event.preventDefault();
  }

  const people = useMemo(() => new Map(data.people.map((p) => [p.handle, p])), [data.people]);

  const ctx = useMemo(() => {
    function openPr(n: number) {
      setSelected(n);
      if (layout.current.overlay) setPanelOpen(true);
      else setCollapsed(false);
      window.clearTimeout(timer.current);
      if (n === shown) {
        setPhase("in");
        return;
      }
      setPhase("out");
      timer.current = window.setTimeout(() => {
        setShown(n);
        setPhase("pre");
        // Two frames so the "pre" offset paints before transitioning to "in".
        requestAnimationFrame(() => requestAnimationFrame(() => setPhase("in")));
      }, EXIT_MS);
    }
    return {
      data,
      person: (handle: string) => people.get(handle),
      user,
      assistantName,
      answer: onAsk,
      uid,
      selected,
      openPr,
      navigate,
      accent,
      setAccent,
    };
  }, [data, people, user, assistantName, onAsk, uid, selected, shown, navigate, accent, setAccent]);

  const activeIndex = NAV.findIndex((item) => item.page === page);
  const title = NAV[activeIndex]?.label ?? "";
  const Page = PAGES[page] ?? OverviewPage;
  const shownPr = data.pullRequests.find((pr) => pr.number === shown) ?? data.pullRequests[0];
  const navId = `${uid}-nav`;
  const detailsId = `${uid}-details`;

  return (
    <DashboardContext value={ctx}>
      <div
        ref={rootRef}
        className={cn("obsidian-analytics-dashboard", className)}
        data-compact={compact}
        data-variant={variant}
        style={{ "--accent": ACCENTS[accent].ui, ...(background ? { "--obsidian-analytics-dashboard-background": background } : null), ...style } as CSSProperties}
        onKeyDown={onKeyDown}
      >
        <div
          className="window"
          data-panel-open={panelOpen}
          data-collapsed={collapsed || !shownPr}
          data-nav-open={navOpen}
          data-scrim={(overlay && panelOpen) || navFloating}
          data-booted={booted}
        >
          <nav id={navId} className="rail" aria-label="Primary" data-expanded={navOpen}>
            <button type="button" className="rail-brand press" aria-label={`${brand.name} overview`} onClick={() => navigate("overview")}>
              {brand.logo ? (
                <span className="rail-logo-custom">{brand.logo}</span>
              ) : (
                <span className="rail-logo">
                  <span className="rail-logo-shine" />
                </span>
              )}
              <span className="rail-label rail-brand-name" aria-hidden="true">
                {brand.name}
              </span>
            </button>

            <div className="rail-nav" style={{ "--active": Math.max(0, activeIndex) } as CSSProperties}>
              <span className="rail-indicator" aria-hidden="true" data-hidden={activeIndex < 0} />
              <ul className="rail-list">
                {NAV.map(({ page: target, label, Icon }, i) => {
                  const active = i === activeIndex;
                  return (
                    <li key={target}>
                      <button
                        type="button"
                        className="rail-btn press has-tip tip-right"
                        data-tip={label}
                        aria-label={label}
                        aria-current={active ? "page" : undefined}
                        data-active={active}
                        onClick={() => navigate(target)}
                      >
                        <Icon size={19} strokeWidth={1.7} className="rail-icon" />
                        <span className="rail-label" aria-hidden="true">
                          {label}
                        </span>
                      </button>
                    </li>
                  );
                })}
              </ul>
            </div>

            <button
              type="button"
              className="rail-account press has-tip tip-right"
              data-tip={`${user.name} · Settings`}
              aria-label={`${user.name}, account settings`}
              onClick={() => navigate("settings")}
            >
              <Avatar src={user.avatar} size={22} className="rail-icon" />
              <span className="rail-label rail-account-text" aria-hidden="true">
                <span className="rail-account-name">{user.name}</span>
                <span className="rail-account-mail">{user.email}</span>
              </span>
            </button>
          </nav>

          <main className="main">
            <header className="main-bar enter" style={{ "--i": 0 } as CSSProperties}>
              <button
                type="button"
                className="icon-btn press has-tip tip-below"
                data-tip={navOpen ? "Collapse sidebar" : "Expand sidebar"}
                aria-label="Sidebar"
                aria-expanded={navOpen}
                aria-controls={navId}
                onClick={() => setNavOpen((o) => !o)}
              >
                <PanelIcon side="left" open={navOpen} size={15} />
              </button>
              <span className="main-bar-title">
                <span key={title} className="swap-in">
                  {title}
                </span>
              </span>
              {shownPr && (
                <button
                  ref={reopenRef}
                  type="button"
                  className="icon-btn press has-tip tip-below tip-end details-reopen"
                  data-tip="Show PR details"
                  data-visible={!detailsVisible}
                  inert={detailsVisible}
                  aria-label="Show PR details"
                  aria-expanded={detailsVisible}
                  aria-controls={detailsId}
                  onClick={showDetails}
                >
                  <PanelIcon side="right" size={15} />
                </button>
              )}
            </header>
            <div className="main-scroll" ref={scrollRef}>
              {/* Keyed by page so every page swap replays its entrance stagger. */}
              <div key={page} className="page">
                <Page />
              </div>
            </div>
          </main>

          {shownPr && (
            <PrDetails pr={shownPr} phase={phase} open={panelOpen} visible={detailsVisible} panelRef={panelRef} hideRef={hideRef} onHide={hideDetails} />
          )}
          <button
            type="button"
            className="scrim"
            aria-label="Close panel"
            tabIndex={-1}
            // Keep focus where it was instead of parking it on the scrim.
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => {
              if (panelOpen) hideDetails();
              if (layout.current.compact) setNavOpen(false);
            }}
          />
        </div>
      </div>
    </DashboardContext>
  );
}

export default AnalyticsDashboard;
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| data | AnalyticsDashboardData | Demo data | People, metrics, activity, repositories, and pull requests. |
| brand | { name: string; logo?: ReactNode } | { name: 'PR Dashboard' } | Product name and logo at the top of the sidebar. |
| user | { name: string; email: string; avatar: string; role?: string } | Demo user | Signed-in account in the sidebar and on the Settings page. |
| page | 'overview' \| 'repositories' \| 'reviews' \| 'settings' | - | Controlled page. |
| defaultPage | AnalyticsDashboardPage | 'overview' | First page when uncontrolled. |
| onPageChange | (page: AnalyticsDashboardPage) => void | - | Called when the sidebar changes the page. |
| defaultAccent | 'blue' \| 'violet' \| 'teal' \| 'amber' | 'blue' | First accent color. |
| onAccentChange | (accent: AnalyticsDashboardAccent) => void | - | Called when Settings changes the accent. |
| assistantName | string | 'Claude' | Assistant name in the details panel. |
| onAsk | (question: string, pullRequest: AnalyticsPullRequest) => string \| Promise<string> | - | Answers questions about the selected pull request. |
| background | string | '#f6f6f7' light, '#0b0b0c' dark | Plain color behind the window, in both modes. |
| variant | 'floating' \| 'fill' | 'floating' | floating shows a rounded window on a plain background. fill drops the frame for an app route such as /dashboard. |
| className | string | - | Additional classes for the root element. |

## Pull request fields

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| number | number | - | Unique pull request number. |
| title | string | - | Title. Text in backticks shows as code in the details panel. |
| author | string | - | Handle of a person in people. |
| status | { kind: 'merged' \| 'review' \| 'open'; label: string } | - | Status pill, icon, and the label in the details panel. |
| repository | string | - | Repository name. |
| opened | string | - | Opening time in the details panel. |
| age | string | - | Short relative time in the review queue, such as 2h. |
| today | boolean | - | Lists the pull request under Today PRs on the overview. |
| additions | number | - | Added lines. |
| deletions | number | - | Removed lines. |
| comments | number | - | Comment count. |
| problem | string[] | - | Paragraphs for the Problem section. |
| approach | string[] | - | Paragraphs for the Approach section. |


---

# ObsidianUI — Dashboard Shell

[Canonical page](https://www.obsidianui.dev/docs/dashboard-shell) · [Agent guide](https://www.obsidianui.dev/agent-instructions.md)

An app layout for dashboards and admin pages. It has a resizable sidebar with grouped navigation, a header with a title, status, and action buttons, tabs with a sliding pill, and a toolbar for filters and primary actions. Your page content goes in the scrolling area below.

Drag the sidebar edge to resize it, or double-click it to reset. In containers narrower than 672px, the sidebar moves into a drawer that opens from the menu button.

The preview is the ready-made `/dashboard` page described below. Select rows to open the bulk action bar, sort by the column headers, star components, open a row's menu, or pick a team in the sidebar to filter the table.

## Preview

[Open the interactive component preview](https://www.obsidianui.dev/docs/dashboard-shell)

```tsx
// app/dashboard/page.tsx
import { DashboardShellPage } from '@/components/block/dashboard-shell-page'

export default function DashboardPage() {
return (
  <div className="h-dvh">
    <DashboardShellPage />
  </div>
)
}
```

## Install using CLI

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/dashboard-shell.json"
```

## Usage

```tsx
import { AppDots } from '@duo-icons/react'
import { DashboardShell } from '@/components/block/dashboard-shell'

<div className="h-dvh">
  <DashboardShell
    brand={{ name: 'ObsidianUI' }}
    navigation={[{ id: 'main', items: [{ id: 'components', label: 'Components', icon: AppDots }] }]}
    title="Components"
  >
    <YourContent />
  </DashboardShell>
</div>
```

The shell fills its parent, so give the parent a height, such as `h-dvh` for a full page. The content area scrolls on its own, and the header, tabs, and toolbar stay in place.

Leave out `tabs`, `filters`, or `toolbarActions` and those rows are not rendered.

## Ready-made /dashboard page

`dashboard-shell-page` is the complete page from the preview, built on the shell. Install it to get a working dashboard route in one step. It brings `dashboard-shell` with it:

```bash
npx shadcn@latest add "https://www.obsidianui.dev/r/dashboard-shell-page.json"
```

```tsx
// app/dashboard/page.tsx
import { DashboardShellPage } from '@/components/block/dashboard-shell-page'

export default function DashboardPage() {
  return (
    <div className="h-dvh">
      <DashboardShellPage />
    </div>
  )
}
```

The page includes:

- A table with a star, name, category, install trend, last update, owner, and status for each row. Columns hide one by one as the content area narrows, so it fits a phone without scrolling sideways.
- Row selection. Tick the boxes or click a row, and a floating bar offers Star, Mark stable, Export, and Delete for the selection.
- Sortable Component, Installs, and Updated headers. Click again to reverse the order. The Sort by menu stays in sync.
- A menu on each row to copy its install command, open its docs, star it, change its status, or delete it.
- Undo after a delete, and a short confirmation after every action, announced to screen readers.
- Sidebar items that filter the table. Blocks shows blocks only, the Teams show what each team owns, and the other items show an empty state.
- Stats under the table and a weekly installs card in the sidebar footer.

Pass your own rows with `items`. Each row has an `id`, `name`, `category`, `status`, `team`, `installs`, a seven-day `trend`, and `updatedDaysAgo`. Change the copied command with `installCommand`, and the docs link with `docsHref`:

```tsx
<DashboardShellPage
  items={myComponents}
  installCommand={item => `npx shadcn@latest add @acme/${item.id}`}
  docsHref={item => `/docs/${item.id}`}
/>
```

It is a regular file in your project after install, so rename the labels, swap the columns, or wire the actions to your API.

## Route-based navigation

To move between real pages, put the shell in a layout, give each item an `href`, and pass Next.js `Link` as `linkComponent` so the sidebar navigates without a full reload. Use the current path as `activeItemId`:

```tsx
// app/dashboard/layout.tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { AppDots, Box, Target } from '@duo-icons/react'
import { DashboardShell } from '@/components/block/dashboard-shell'

const items = [
  { id: '/dashboard', label: 'Overview', icon: AppDots, href: '/dashboard' },
  { id: '/dashboard/projects', label: 'Projects', icon: Box, href: '/dashboard/projects' },
  { id: '/dashboard/analytics', label: 'Analytics', icon: Target, href: '/dashboard/analytics' },
]

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname()
  const active = items.find(item => item.href === pathname)

  return (
    <div className="h-dvh">
      <DashboardShell
        brand={{ name: 'Acme' }}
        navigation={[{ id: 'main', items }]}
        activeItemId={pathname}
        linkComponent={Link}
        title={active?.label ?? 'Dashboard'}
      >
        {children}
      </DashboardShell>
    </div>
  )
}
```

Each page under `app/dashboard` then renders only its content. The layout stays mounted between pages, so the sidebar keeps its width and the active pill slides to the new page.

## Navigation

Each section has an optional `title` and a list of items. An item shows an `icon`, or a colored dot when you pass `color` instead, and an optional `count` badge. The demo uses duotone icons from [`@duo-icons/react`](https://github.com/fernandcf/duo-icons): their second layer draws at 30% opacity, so one icon color gives a dark shape with a grey fill. Lucide icons work too.

A single hover highlight glides between items as the pointer moves down the list, and the active item's pill slides to whichever item you choose.

Items with `href` render as links, through `linkComponent` when you pass one. Items without it render as buttons that only change the active item. Track the active item with `activeItemId` and `onActiveItemChange`, or let the shell keep it with `defaultActiveItemId`. The active item gets `aria-current="page"`.

`secondaryNavigation` holds quieter items pinned under the main list, such as Invite or Help. `sidebarFooter` sits at the very bottom, for a plan or account card:

```tsx
<DashboardShell
  secondaryNavigation={[{ id: 'help', label: 'Help', icon: Info, href: '/help' }]}
  sidebarFooter={<DashboardButton variant="muted" size="md">Add billing</DashboardButton>}
  {...props}
/>
```

## Buttons and filters

`DashboardButton` matches the shell's look and works anywhere inside it.

- `variant`: `primary` for the main action, `secondary` for everyday buttons, `muted` for a filled grey button, `ghost` for low-emphasis actions, and `danger` for deleting.
- `size`: `sm` for toolbars, `md` for larger buttons, and `icon` for square icon buttons. Give icon buttons an `aria-label`.

`DashboardFilterMenu` is a dropdown that shows its label and current value side by side, like **Sort by | Last updated**:

```tsx
const [team, setTeam] = useState('all')

<DashboardFilterMenu
  label="Team"
  value={team}
  options={[
    { value: 'all', label: 'All teams' },
    { value: 'design', label: 'Design' },
  ]}
  onValueChange={setTeam}
/>
```

## Menus and popovers

The filter menu is built from menu pieces you can use for your own menus, such as an account menu. `DashboardPopover` holds free-form content, like a search field or a notifications list. Both match the buttons: frosted glass with a backdrop blur, the same corners and text size, and a highlight that glides between items as you hover or use the arrow keys. They open with a short spring from the trigger, coming into focus as the blur clears.

```tsx
import {
  DashboardButton,
  DashboardMenu,
  DashboardMenuContent,
  DashboardMenuItem,
  DashboardMenuSeparator,
  DashboardMenuTrigger,
} from '@/components/block/dashboard-shell'

<DashboardMenu>
  <DashboardMenuTrigger asChild>
    <DashboardButton variant="secondary" size="sm">Account</DashboardButton>
  </DashboardMenuTrigger>
  <DashboardMenuContent align="end">
    <DashboardMenuItem>Profile</DashboardMenuItem>
    <DashboardMenuItem>Settings</DashboardMenuItem>
    <DashboardMenuSeparator />
    <DashboardMenuItem>Sign out</DashboardMenuItem>
  </DashboardMenuContent>
</DashboardMenu>
```

`DashboardMenuLabel`, `DashboardMenuRadioGroup`, and `DashboardMenuRadioItem` are also available for headings and single-choice options. `DashboardPopover`, `DashboardPopoverTrigger`, and `DashboardPopoverContent` follow the same pattern. Both are built on Radix, so they keep its keyboard support and focus handling.

## Confirm dialog

`DashboardConfirmDialog` asks before an action you can't easily take back. It follows the shell's palette and theme, focuses **Cancel** first so Enter never deletes by accident, and closes with Escape. Set `tone="danger"` to paint the confirm button red.

```tsx
const [open, setOpen] = useState(false)

<DashboardButton variant="danger" size="sm" onClick={() => setOpen(true)}>Delete</DashboardButton>
<DashboardConfirmDialog
  open={open}
  onOpenChange={setOpen}
  tone="danger"
  title="Delete Status Bars?"
  description="It's removed from the library. You can undo right after."
  confirmLabel="Delete"
  onConfirm={() => deleteComponent('status-bars')}
/>
```

## Sidebar width

The sidebar starts at 200px and resizes between 180px and 400px. Change the limits with `minSidebarWidth` and `maxSidebarWidth`. To remember the width, save it in `onSidebarWidthChange`, which runs once when a drag ends rather than on every frame:

```tsx
const [width, setWidth] = useState(200)

useEffect(() => {
  const saved = Number(localStorage.getItem('sidebar-width'))
  if (saved) setWidth(saved)
}, [])

<DashboardShell
  sidebarWidth={width}
  onSidebarWidthChange={next => {
    setWidth(next)
    localStorage.setItem('sidebar-width', String(next))
  }}
  {...props}
/>
```

## Behavior

- The resize handle works with the keyboard. Focus it, then press the left and right arrow keys to change the width by 10px, or Home and End to jump to the limits.
- The layout responds to the width of its container, not the window, so it also works inside a panel or a preview.
- The sidebar, content area, and menus use a thin 4px scrollbar with no track or arrows. It only appears while the pointer or keyboard focus is inside that area.
- The drawer moves focus to its close button and keeps Tab inside until it closes. Escape, the backdrop, or choosing an item closes it and returns focus to the menu button.
- Tabs follow the WAI-ARIA tabs pattern, so the arrow keys move between them. The active pill slides to the tab you choose, the hover highlight glides across the tabs, and the page content fades in. The content stays mounted when the tab changes, so its state and scroll position are kept.
- With reduced motion, the drawer, hover highlights, and active pills move into place without animating, the content doesn't fade, and menus and popovers appear without the spring or blur. Touch input skips the hover highlight.

## Customize colors

The shell has a light and a dark palette, and switches when a parent has the `dark` class. Menus and popovers render in a portal at the end of the page, so they carry an `obsidian-dashboard-shell-layer` class with the same palette. Include both classes when you override colors:

```css
.obsidian-dashboard-shell,
.obsidian-dashboard-shell-layer {
  --obsidian-dashboard-shell-background: #ffffff;
  /* The sidebar and its header and footer use the page background, so the whole shell reads as one surface. */
  --obsidian-dashboard-shell-sidebar: var(--obsidian-dashboard-shell-background);
  --obsidian-dashboard-shell-panel: var(--obsidian-dashboard-shell-background);
  --obsidian-dashboard-shell-border: #e7e7e7;
  --obsidian-dashboard-shell-foreground: #161616;
  --obsidian-dashboard-shell-text-muted: #5c5c5c;
  --obsidian-dashboard-shell-icon: #161616;
  --obsidian-dashboard-shell-hover: rgb(0 0 0 / 0.045);
  --obsidian-dashboard-shell-accent: #2563eb;
  --obsidian-dashboard-shell-accent-hover: #3b82f6;
  --obsidian-dashboard-shell-menu: rgb(255 255 255 / 0.82);
  --obsidian-dashboard-shell-menu-highlight: rgb(0 0 0 / 0.06);
}
```

Keep `--obsidian-dashboard-shell-menu` slightly transparent, or the backdrop blur has nothing to show through.

The full list, with both palettes, is in `dashboard-shell.css`.

## Install manually — complete source

Download the complete manifest: [dashboard-shell.json](https://www.obsidianui.dev/r/dashboard-shell.json). It includes every required local file and package dependency.

Install the listed package dependencies in your React project:

```bash
npm install @radix-ui/react-alert-dialog @radix-ui/react-dropdown-menu @radix-ui/react-popover @radix-ui/react-tabs class-variance-authority clsx lucide-react motion tailwind-merge
```

Resolve @components/, @ui/, @lib/, and @hooks/ targets through your components.json aliases. For example, @components/block/example.tsx maps to src/components/block/example.tsx when components is @/components and @/\* resolves to src/\*. Do not create a literal @components directory. Preserve existing files deliberately and keep the use client directive where present.

### components/block/dashboard-shell.css

Installation target: `@components/block/dashboard-shell.css`

```css
/* Menus and popovers render in a portal, so the layer class repeats the palette for them. */
.obsidian-dashboard-shell,
.obsidian-dashboard-shell-layer {
  --obsidian-dashboard-shell-background: #ffffff;
  --obsidian-dashboard-shell-sidebar: var(--obsidian-dashboard-shell-background);
  --obsidian-dashboard-shell-panel: var(--obsidian-dashboard-shell-background);
  --obsidian-dashboard-shell-control: #ffffff;
  --obsidian-dashboard-shell-muted: #efefef;
  --obsidian-dashboard-shell-muted-hover: #e6e6e6;
  --obsidian-dashboard-shell-border: #e7e7e7;
  --obsidian-dashboard-shell-border-strong: #d6d6d6;
  --obsidian-dashboard-shell-foreground: #161616;
  --obsidian-dashboard-shell-text-muted: #5c5c5c;
  --obsidian-dashboard-shell-text-subtle: #737373;
  /* Duotone icons draw their second layer at 30% of this color, so one dark value gives black and grey. */
  --obsidian-dashboard-shell-icon: #161616;
  --obsidian-dashboard-shell-hover: rgb(0 0 0 / 0.045);
  --obsidian-dashboard-shell-accent: #2563eb;
  --obsidian-dashboard-shell-accent-hover: #3b82f6;
  --obsidian-dashboard-shell-accent-foreground: #f9fbff;
  --obsidian-dashboard-shell-success: #16a34a;
  --obsidian-dashboard-shell-warning: #d97706;
  --obsidian-dashboard-shell-danger: #e5484d;
  /* Filled red buttons keep white text readable in both themes. */
  --obsidian-dashboard-shell-danger-solid: #dc2626;
  --obsidian-dashboard-shell-danger-solid-hover: #c81e1e;
  --obsidian-dashboard-shell-ring: #8b8b8b;
  --obsidian-dashboard-shell-control-shadow: 0 0 0 1px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.06);
  --obsidian-dashboard-shell-accent-shadow: 0 2px 4px rgb(37 99 235 / 0.24), 0 0 0 1px #1d4ed8, inset 0 4px 6px rgb(255 255 255 / 0.2),
    inset 0 0 0 1px rgb(255 255 255 / 0.15), inset 0 -8px 14px rgb(0 0 0 / 0.15);
  /* Translucent so the backdrop blur shows through. */
  --obsidian-dashboard-shell-menu: rgb(255 255 255 / 0.82);
  --obsidian-dashboard-shell-menu-highlight: rgb(0 0 0 / 0.06);
  --obsidian-dashboard-shell-menu-shadow: 0 0 0 1px rgb(0 0 0 / 0.08), 0 2px 4px rgb(0 0 0 / 0.04), 0 12px 32px -6px rgb(0 0 0 / 0.16);
}

.dark .obsidian-dashboard-shell,
.dark .obsidian-dashboard-shell-layer,
.obsidian-dashboard-shell.dark,
.obsidian-dashboard-shell-layer.dark {
  --obsidian-dashboard-shell-background: #161616;
  --obsidian-dashboard-shell-sidebar: var(--obsidian-dashboard-shell-background);
  --obsidian-dashboard-shell-panel: var(--obsidian-dashboard-shell-background);
  --obsidian-dashboard-shell-control: #1e1e1e;
  --obsidian-dashboard-shell-muted: #2a2a2a;
  --obsidian-dashboard-shell-muted-hover: #333333;
  --obsidian-dashboard-shell-border: #232323;
  --obsidian-dashboard-shell-border-strong: #393939;
  --obsidian-dashboard-shell-foreground: #f9fbff;
  --obsidian-dashboard-shell-text-muted: #9a9a9a;
  --obsidian-dashboard-shell-text-subtle: #808080;
  --obsidian-dashboard-shell-icon: #f9fbff;
  --obsidian-dashboard-shell-hover: rgb(255 255 255 / 0.045);
  --obsidian-dashboard-shell-success: #22c55e;
  --obsidian-dashboard-shell-warning: #fbbf24;
  --obsidian-dashboard-shell-danger: #f97373;
  --obsidian-dashboard-shell-ring: #676767;
  --obsidian-dashboard-shell-control-shadow: 0 0 0 1px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.06);
  --obsidian-dashboard-shell-accent-shadow: 0 4px 4px rgb(42 42 42 / 0.32), 0 0 0 1px #0e0e0e, inset 0 4px 6px rgb(255 255 255 / 0.2),
    inset 0 0 0 1px rgb(255 255 255 / 0.15), inset 0 -8px 14px rgb(0 0 0 / 0.15);
  --obsidian-dashboard-shell-menu: rgb(30 30 30 / 0.78);
  --obsidian-dashboard-shell-menu-highlight: rgb(255 255 255 / 0.07);
  --obsidian-dashboard-shell-menu-shadow: 0 0 0 1px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 0 0 1px rgb(255 255 255 / 0.06),
    0 16px 36px -8px rgb(0 0 0 / 0.6);
}

/* A thin thumb that only appears while the pointer or focus is in the scroll area, with no track or arrow buttons. */
@supports not selector(::-webkit-scrollbar) {
  .obsidian-dashboard-shell-scroll {
    scrollbar-width: thin;
    scrollbar-color: transparent transparent;
  }

  .obsidian-dashboard-shell-scroll:is(:hover, :focus-within) {
    scrollbar-color: var(--obsidian-dashboard-shell-border-strong) transparent;
  }
}

/* Chromium ignores the scrollbar pseudo-elements once scrollbar-width or scrollbar-color is set, so they stay unset here. */
@supports selector(::-webkit-scrollbar) {
  .obsidian-dashboard-shell-scroll::-webkit-scrollbar {
    width: 4px;
    height: 4px;
  }

  .obsidian-dashboard-shell-scroll::-webkit-scrollbar-track,
  .obsidian-dashboard-shell-scroll::-webkit-scrollbar-corner {
    background: transparent;
  }

  .obsidian-dashboard-shell-scroll::-webkit-scrollbar-thumb {
    border-radius: 999px;
    background: transparent;
  }

  .obsidian-dashboard-shell-scroll:is(:hover, :focus-within)::-webkit-scrollbar-thumb {
    background: var(--obsidian-dashboard-shell-border-strong);
  }

  .obsidian-dashboard-shell-scroll::-webkit-scrollbar-button {
    display: none;
    width: 0;
    height: 0;
  }
}

.obsidian-dashboard-shell[data-resizing] {
  cursor: col-resize;
  user-select: none;
}
```

### components/block/dashboard-shell.tsx

Installation target: `@components/block/dashboard-shell.tsx`

```tsx
"use client";

import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cva, type VariantProps } from "class-variance-authority";
import { Check, ChevronDown, Menu, X } from "lucide-react";
import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion } from "motion/react";
import { createContext, createElement, memo, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, ElementType, FocusEvent, KeyboardEvent, PointerEvent, ReactNode, Ref, RefObject } from "react";
import { cn } from "@/lib/utils";
import "./dashboard-shell.css";

const EASE_OUT = [0.25, 1, 0.5, 1] as const;
/** The hover highlight glides after the pointer. */
const FOLLOW_SPRING = { type: "spring", stiffness: 650, damping: 45, mass: 0.5 } as const;
/** The active pill slides to the chosen item. */
const PILL_SPRING = { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const;
/** Menus and popovers settle from a slight squash with a small overshoot. */
const LAYER_SPRING = { type: "spring", stiffness: 560, damping: 26, mass: 0.7 } as const;
const RESIZE_STEP = 10;
/** The sidebar docks beside the content from this container width, in rem. Keep in sync with `@2xl/dashboard-shell`. */
const DOCKED_SIDEBAR_REM = 42;

export const dashboardButtonVariants = cva(
  "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium leading-none outline-none select-none transition-[background-color,color,box-shadow] duration-150 ease-[cubic-bezier(0.25,1,0.5,1)] focus-visible:ring-2 focus-visible:ring-(--obsidian-dashboard-shell-ring) disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        primary:
          "bg-(--obsidian-dashboard-shell-accent) text-(--obsidian-dashboard-shell-accent-foreground) shadow-(--obsidian-dashboard-shell-accent-shadow) hover:bg-(--obsidian-dashboard-shell-accent-hover)",
        secondary:
          "bg-(--obsidian-dashboard-shell-control) text-(--obsidian-dashboard-shell-foreground) shadow-(--obsidian-dashboard-shell-control-shadow) hover:bg-(--obsidian-dashboard-shell-muted) data-[state=open]:bg-(--obsidian-dashboard-shell-muted)",
        muted:
          "bg-(--obsidian-dashboard-shell-muted) text-(--obsidian-dashboard-shell-foreground) shadow-(--obsidian-dashboard-shell-control-shadow) hover:bg-(--obsidian-dashboard-shell-muted-hover)",
        ghost:
          "text-(--obsidian-dashboard-shell-text-subtle) hover:bg-(--obsidian-dashboard-shell-muted) hover:text-(--obsidian-dashboard-shell-foreground)",
        danger:
          "bg-(--obsidian-dashboard-shell-danger-solid) text-white shadow-[0_1px_2px_rgb(0_0_0/0.16),inset_0_1px_0_rgb(255_255_255/0.16),0_0_0_1px_rgb(0_0_0/0.08)] hover:bg-(--obsidian-dashboard-shell-danger-solid-hover)",
      },
      size: {
        sm: "h-[30px] px-[9px] text-xs [&_svg]:size-3",
        md: "h-8 px-2.5 text-sm [&_svg]:size-4",
        icon: "size-[30px] [&_svg]:size-4",
      },
    },
    defaultVariants: { variant: "secondary", size: "sm" },
  },
);

export type DashboardButtonProps = ComponentProps<"button"> & VariantProps<typeof dashboardButtonVariants>;

export function DashboardButton({ variant, size, className, type = "button", ...props }: DashboardButtonProps) {
  return <button type={type} className={cn(dashboardButtonVariants({ variant, size }), className)} {...props} />;
}

// Menus and popovers portal to the body, outside the shell, so they carry the layer class that holds the palette.
const layerClassName =
  "obsidian-dashboard-shell-layer z-50 rounded-[10px] bg-(--obsidian-dashboard-shell-menu) p-1 text-(--obsidian-dashboard-shell-foreground) shadow-(--obsidian-dashboard-shell-menu-shadow) outline-none backdrop-blur-xl backdrop-saturate-150";

function layerMotion(reduceMotion: boolean) {
  if (reduceMotion) {
    return { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } };
  }
  return {
    initial: { opacity: 0, scaleX: 0.92, scaleY: 0.86, filter: "blur(8px)" },
    animate: {
      opacity: 1,
      scaleX: 1,
      scaleY: 1,
      filter: "blur(0px)",
      transition: { ...LAYER_SPRING, opacity: { duration: 0.16, ease: EASE_OUT }, filter: { duration: 0.24, ease: EASE_OUT } },
    },
    exit: { opacity: 0, scaleX: 0.96, scaleY: 0.94, filter: "blur(4px)", transition: { duration: 0.14, ease: EASE_OUT } },
  };
}

// Exit animations need the open state, since the content stays mounted until it has animated out.
const LayerOpenContext = createContext(false);

// Portaled layers leave a scoped `.dark` wrapper behind, so the shell passes its theme down to them.
const ShellDarkContext = createContext(false);

function useScopedDark(ref: RefObject<HTMLElement | null>, className?: string) {
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    const update = () => setIsDark(element.closest(".dark") !== null);
    update();
    if (typeof MutationObserver === "undefined") return;
    const observer = new MutationObserver(update);
    for (let node: HTMLElement | null = element; node; node = node.parentElement) {
      observer.observe(node, { attributes: true, attributeFilter: ["class"] });
    }
    return () => observer.disconnect();
  }, [ref, className]);

  return isDark;
}

export type DashboardMenuProps = ComponentProps<typeof DropdownMenuPrimitive.Root>;

export function DashboardMenu({ open, defaultOpen = false, onOpenChange, ...props }: DashboardMenuProps) {
  const [isOpen, setIsOpen] = useControllableState(open, defaultOpen, onOpenChange);
  return (
    <LayerOpenContext.Provider value={isOpen}>
      <DropdownMenuPrimitive.Root open={isOpen} onOpenChange={setIsOpen} {...props} />
    </LayerOpenContext.Provider>
  );
}

export const DashboardMenuTrigger = DropdownMenuPrimitive.Trigger;

export type DashboardMenuContentProps = Omit<ComponentProps<typeof DropdownMenuPrimitive.Content>, "asChild" | "forceMount">;

export function DashboardMenuContent({ className, children, sideOffset = 6, ...props }: DashboardMenuContentProps) {
  const isOpen = useContext(LayerOpenContext);
  const isDark = useContext(ShellDarkContext);
  const reduceMotion = !!useReducedMotion();

  return (
    <AnimatePresence>
      {isOpen && (
        <DropdownMenuPrimitive.Portal forceMount>
          <DropdownMenuPrimitive.Content forceMount asChild sideOffset={sideOffset} {...props}>
            <motion.div
              {...layerMotion(reduceMotion)}
              className={cn(
                layerClassName,
                isDark && "dark",
                "obsidian-dashboard-shell-scroll max-h-(--radix-dropdown-menu-content-available-height) min-w-40 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overscroll-contain",
                className,
              )}
            >
              <HoverHighlightList highlightClassName="rounded-md bg-(--obsidian-dashboard-shell-menu-highlight)">{children}</HoverHighlightList>
            </motion.div>
          </DropdownMenuPrimitive.Content>
        </DropdownMenuPrimitive.Portal>
      )}
    </AnimatePresence>
  );
}

const menuItemClassName =
  "relative flex h-8 cursor-pointer select-none items-center gap-2 rounded-md px-2.5 text-xs leading-none text-(--obsidian-dashboard-shell-text-muted) outline-none transition-[color,scale] duration-150 ease-[cubic-bezier(0.25,1,0.5,1)] active:scale-[0.97] active:duration-75 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:text-(--obsidian-dashboard-shell-foreground) [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0";

// Radix focuses items on hover and with the arrow keys, so following focus moves the highlight for both.
function useHighlightOnFocus(onFocus?: (event: FocusEvent<HTMLDivElement>) => void, onBlur?: (event: FocusEvent<HTMLDivElement>) => void) {
  const highlight = useContext(HoverHighlightContext);
  return {
    onFocus: (event: FocusEvent<HTMLDivElement>) => {
      onFocus?.(event);
      highlight?.show(event.currentTarget);
    },
    onBlur: (event: FocusEvent<HTMLDivElement>) => {
      onBlur?.(event);
      highlight?.hide();
    },
  };
}

export function DashboardMenuItem({ className, onFocus, onBlur, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Item>) {
  const focusHandlers = useHighlightOnFocus(onFocus, onBlur);
  return <DropdownMenuPrimitive.Item className={cn(menuItemClassName, className)} {...focusHandlers} {...props} />;
}

export const DashboardMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;

export function DashboardMenuRadioItem({ className, children, onFocus, onBlur, ...props }: ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
  const focusHandlers = useHighlightOnFocus(onFocus, onBlur);
  return (
    <DropdownMenuPrimitive.RadioItem
      className={cn(menuItemClassName, "pr-8 data-[state=checked]:font-medium data-[state=checked]:text-(--obsidian-dashboard-shell-foreground)", className)}
      {...focusHandlers}
      {...props}
    >
      {children}
      <DropdownMenuPrimitive.ItemIndicator className="absolute right-2.5 flex">
        <Check aria-hidden className="size-3.5!" />
      </DropdownMenuPrimitive.ItemIndicator>
    </DropdownMenuPrimitive.RadioItem>
  );
}

export function DashboardMenuLabel({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Label>) {
  return (
    <DropdownMenuPrimitive.Label
      className={cn("px-2.5 pb-2 pt-2.5 text-[11px] font-medium uppercase leading-none tracking-[1px] text-(--obsidian-dashboard-shell-text-subtle)", className)}
      {...props}
    />
  );
}

export function DashboardMenuSeparator({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
  return <DropdownMenuPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-(--obsidian-dashboard-shell-border)", className)} {...props} />;
}

export type DashboardPopoverProps = ComponentProps<typeof PopoverPrimitive.Root>;

export function DashboardPopover({ open, defaultOpen = false, onOpenChange, ...props }: DashboardPopoverProps) {
  const [isOpen, setIsOpen] = useControllableState(open, defaultOpen, onOpenChange);
  return (
    <LayerOpenContext.Provider value={isOpen}>
      <PopoverPrimitive.Root open={isOpen} onOpenChange={setIsOpen} {...props} />
    </LayerOpenContext.Provider>
  );
}

export const DashboardPopoverTrigger = PopoverPrimitive.Trigger;

export type DashboardPopoverContentProps = Omit<ComponentProps<typeof PopoverPrimitive.Content>, "asChild" | "forceMount">;

export function DashboardPopoverContent({ className, children, align = "center", sideOffset = 6, ...props }: DashboardPopoverContentProps) {
  const isOpen = useContext(LayerOpenContext);
  const isDark = useContext(ShellDarkContext);
  const reduceMotion = !!useReducedMotion();

  return (
    <AnimatePresence>
      {isOpen && (
        <PopoverPrimitive.Portal forceMount>
          <PopoverPrimitive.Content forceMount asChild align={align} sideOffset={sideOffset} {...props}>
            <motion.div
              {...layerMotion(reduceMotion)}
              className={cn(layerClassName, isDark && "dark", "w-72 origin-(--radix-popover-content-transform-origin)", className)}
            >
              {children}
            </motion.div>
          </PopoverPrimitive.Content>
        </PopoverPrimitive.Portal>
      )}
    </AnimatePresence>
  );
}

export type DashboardConfirmDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: ReactNode;
  description?: ReactNode;
  confirmLabel?: ReactNode;
  cancelLabel?: ReactNode;
  /** "danger" paints the confirm button red, for deleting and other actions that remove things. */
  tone?: "default" | "danger";
  onConfirm: () => void;
};

export function DashboardConfirmDialog({
  open,
  onOpenChange,
  title,
  description,
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
  tone = "default",
  onConfirm,
}: DashboardConfirmDialogProps) {
  const isDark = useContext(ShellDarkContext);
  const reduceMotion = !!useReducedMotion();

  return (
    <AlertDialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
      <AnimatePresence>
        {open && (
          <AlertDialogPrimitive.Portal forceMount>
            <AlertDialogPrimitive.Overlay forceMount asChild>
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reduceMotion ? 0 : 0.18, ease: EASE_OUT }}
                className={cn("fixed inset-0 z-50 backdrop-blur-[2px]", isDark ? "bg-black/50" : "bg-black/25")}
              />
            </AlertDialogPrimitive.Overlay>
            <div className="pointer-events-none fixed inset-0 z-50 grid place-items-center p-4">
              <AlertDialogPrimitive.Content forceMount asChild>
                <motion.div
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: 6, filter: "blur(6px)" }}
                  animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transition: reduceMotion ? { duration: 0 } : { ...LAYER_SPRING, opacity: { duration: 0.16, ease: EASE_OUT }, filter: { duration: 0.2, ease: EASE_OUT } } }}
                  exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.97, y: 4, filter: "blur(4px)", transition: { duration: 0.14, ease: EASE_OUT } }}
                  className={cn(
                    "obsidian-dashboard-shell-layer pointer-events-auto flex w-full max-w-sm flex-col gap-4 rounded-xl bg-(--obsidian-dashboard-shell-background) p-4 text-(--obsidian-dashboard-shell-foreground) shadow-(--obsidian-dashboard-shell-menu-shadow) outline-none",
                    isDark && "dark",
                  )}
                >
                  <div className="flex flex-col gap-1.5">
                    <AlertDialogPrimitive.Title className="text-sm font-medium leading-snug">{title}</AlertDialogPrimitive.Title>
                    {description ? (
                      <AlertDialogPrimitive.Description className="text-xs leading-relaxed text-(--obsidian-dashboard-shell-text-muted)">{description}</AlertDialogPrimitive.Description>
                    ) : (
                      <AlertDialogPrimitive.Description className="sr-only">{title}</AlertDialogPrimitive.Description>
                    )}
                  </div>
                  <div className="flex justify-end gap-2">
                    <AlertDialogPrimitive.Cancel asChild>
                      <DashboardButton variant="secondary" size="sm">
                        {cancelLabel}
                      </DashboardButton>
                    </AlertDialogPrimitive.Cancel>
                    <AlertDialogPrimitive.Action asChild>
                      <DashboardButton variant={tone === "danger" ? "danger" : "primary"} size="sm" onClick={onConfirm}>
                        {confirmLabel}
                      </DashboardButton>
                    </AlertDialogPrimitive.Action>
                  </div>
                </motion.div>
              </AlertDialogPrimitive.Content>
            </div>
          </AlertDialogPrimitive.Portal>
        )}
      </AnimatePresence>
    </AlertDialogPrimitive.Root>
  );
}

export type DashboardFilterOption = { value: string; label: string };

export type DashboardFilterMenuProps = {
  /** Shown before the current value and as the menu heading, such as "Sort by". */
  label?: string;
  value: string;
  options: DashboardFilterOption[];
  onValueChange: (value: string) => void;
  align?: "start" | "end";
  className?: string;
};

export function DashboardFilterMenu({ label, value, options, onValueChange, align = "start", className }: DashboardFilterMenuProps) {
  const currentLabel = options.find((option) => option.value === value)?.label ?? value;

  return (
    <DashboardMenu>
      <DashboardMenuTrigger asChild>
        <DashboardButton
          variant="secondary"
          size="sm"
          aria-label={label ? `${label}: ${currentLabel}` : undefined}
          className={cn("group gap-0 overflow-hidden px-0", className)}
        >
          {label && (
            <>
              <span className="px-[9px] font-normal text-(--obsidian-dashboard-shell-text-subtle)">{label}</span>
              <span aria-hidden className="h-full w-px bg-(--obsidian-dashboard-shell-border)" />
            </>
          )}
          <span className="flex items-center gap-1.5 px-[9px]">
            {currentLabel}
            <ChevronDown
              aria-hidden
              className="text-(--obsidian-dashboard-shell-text-subtle) transition-transform duration-200 group-data-[state=open]:rotate-180"
            />
          </span>
        </DashboardButton>
      </DashboardMenuTrigger>
      <DashboardMenuContent align={align} className="min-w-44">
        {label && <DashboardMenuLabel>{label}</DashboardMenuLabel>}
        <DashboardMenuRadioGroup value={value} onValueChange={onValueChange}>
          {options.map((option) => (
            <DashboardMenuRadioItem key={option.value} value={option.value}>
              {option.label}
            </DashboardMenuRadioItem>
          ))}
        </DashboardMenuRadioGroup>
      </DashboardMenuContent>
    </DashboardMenu>
  );
}

// Duo icons ship their own bundled SVG prop types, so accept any element type rather than a narrow props shape.
type NavIcon = ElementType;

export type DashboardShellNavItem = {
  id: string;
  label: string;
  /** A duo-icons or lucide icon, or any component that accepts `className`. */
  icon?: NavIcon;
  /** Shows a colored dot instead of an icon, for projects or pipelines. */
  color?: string;
  count?: number;
  /** Renders a link. Without it the item is a button that only changes the active item. */
  href?: string;
};

export type DashboardShellNavSection = {
  id: string;
  title?: string;
  items: DashboardShellNavItem[];
};

export type DashboardShellTab = { value: string; label: string };

export type DashboardShellStatusTone = "success" | "warning" | "neutral";

export type DashboardShellProps = Omit<ComponentProps<"div">, "title" | "children" | "ref"> & {
  brand: { name: string; description?: string; logo?: ReactNode };
  navigation: DashboardShellNavSection[];
  /** Low-emphasis items pinned under the navigation, such as Invite or Help. */
  secondaryNavigation?: DashboardShellNavItem[];
  /** Pinned to the bottom of the sidebar, for a plan or trial card. */
  sidebarFooter?: ReactNode;
  activeItemId?: string;
  defaultActiveItemId?: string;
  onActiveItemChange?: (id: string) => void;
  title: ReactNode;
  headingLevel?: 1 | 2 | 3;
  /** A short label in a pill beside the title, such as "Active". */
  status?: string;
  statusTone?: DashboardShellStatusTone;
  /** Buttons on the right of the header: search, notifications, profile. */
  headerActions?: ReactNode;
  tabs?: DashboardShellTab[];
  tab?: string;
  defaultTab?: string;
  onTabChange?: (value: string) => void;
  /** Left side of the toolbar, usually `DashboardFilterMenu`s. */
  filters?: ReactNode;
  /** Right side of the toolbar, such as Export and New. */
  toolbarActions?: ReactNode;
  children?: ReactNode;
  sidebarWidth?: number;
  defaultSidebarWidth?: number;
  /** Called with the new width when a drag ends or the width changes from the keyboard. */
  onSidebarWidthChange?: (width: number) => void;
  minSidebarWidth?: number;
  maxSidebarWidth?: number;
  navigationLabel?: string;
  /** Renders navigation items that have an `href`, such as Next.js `Link` for client-side routing. Defaults to `<a>`. */
  linkComponent?: ElementType;
};

const ShellLinkContext = createContext<ElementType>("a");

function useControllableState<T>(value: T | undefined, defaultValue: T, onChange?: (next: T) => void) {
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const isControlled = value !== undefined;
  const setValue = useCallback(
    (next: T) => {
      if (!isControlled) setUncontrolledValue(next);
      onChange?.(next);
    },
    [isControlled, onChange],
  );
  return [isControlled ? value : uncontrolledValue, setValue] as const;
}

/** Wraps Tab and Shift+Tab inside the drawer, since the page around the shell is not inert. */
function keepFocusInside(event: KeyboardEvent<HTMLElement>) {
  const focusable = [...event.currentTarget.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')];
  const first = focusable[0];
  const last = focusable.at(-1);
  if (!first || !last) return;
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
}

const statusToneClasses: Record<DashboardShellStatusTone, string> = {
  success: "text-(--obsidian-dashboard-shell-success)",
  warning: "text-(--obsidian-dashboard-shell-warning)",
  neutral: "text-(--obsidian-dashboard-shell-text-subtle)",
};

export function DashboardShell({
  brand,
  navigation,
  secondaryNavigation,
  sidebarFooter,
  activeItemId,
  defaultActiveItemId,
  onActiveItemChange,
  title,
  headingLevel = 1,
  status,
  statusTone = "success",
  headerActions,
  tabs,
  tab,
  defaultTab,
  onTabChange,
  filters,
  toolbarActions,
  children,
  sidebarWidth,
  defaultSidebarWidth = 200,
  onSidebarWidthChange,
  minSidebarWidth = 180,
  maxSidebarWidth = 400,
  navigationLabel = "Navigation",
  linkComponent = "a",
  className,
  style,
  ...props
}: DashboardShellProps) {
  const reduceMotion = useReducedMotion();
  const uid = useId();
  const titleId = `${uid}-title`;
  const sidebarId = `${uid}-sidebar`;
  const drawerId = `${uid}-drawer`;
  const rootRef = useRef<HTMLDivElement>(null);
  const menuButtonRef = useRef<HTMLButtonElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);
  const restoreFocusOnClose = useRef(false);
  const drag = useRef<{ startX: number; startWidth: number; width: number } | null>(null);
  const tabPillId = `${uid}-tab-pill`;
  const contentRef = useRef<HTMLDivElement>(null);

  const [activeItem, setActiveItem] = useControllableState(activeItemId, defaultActiveItemId ?? navigation[0]?.items[0]?.id, onActiveItemChange);
  const [activeTab, setActiveTab] = useControllableState(tab, defaultTab ?? tabs?.[0]?.value ?? "", onTabChange);
  const [committedWidth, setCommittedWidth] = useControllableState(sidebarWidth, defaultSidebarWidth, onSidebarWidthChange);
  const [dragWidth, setDragWidth] = useState<number | null>(null);
  const [isDrawerOpen, setIsDrawerOpen] = useState(false);
  const shownTab = useRef(activeTab);
  const isDark = useScopedDark(rootRef, className);

  // The content stays mounted across tabs, so it animates in place rather than remounting and losing its state.
  useEffect(() => {
    if (shownTab.current === activeTab) return;
    shownTab.current = activeTab;
    const content = contentRef.current;
    if (!content || reduceMotion) return;
    const controls = animate(
      content,
      { opacity: [0, 1], y: [6, 0], filter: ["blur(4px)", "blur(0px)"] },
      { duration: 0.32, ease: EASE_OUT },
    );
    // A leftover transform or filter would make the scroll area the containing block for fixed-position content.
    controls.then(() => {
      content.style.removeProperty("transform");
      content.style.removeProperty("filter");
    });
    return () => controls.stop();
  }, [activeTab, reduceMotion]);

  const clampWidth = (width: number) => Math.round(Math.min(maxSidebarWidth, Math.max(minSidebarWidth, width)));
  const width = clampWidth(dragWidth ?? committedWidth);

  const commitWidth = (next: number) => {
    const clamped = clampWidth(next);
    if (clamped !== committedWidth) setCommittedWidth(clamped);
  };

  const closeDrawer = useCallback(() => {
    restoreFocusOnClose.current = true;
    setIsDrawerOpen(false);
  }, []);

  // The content is inert while the drawer is open, so focus can only move back once it re-renders.
  useEffect(() => {
    if (isDrawerOpen) {
      closeButtonRef.current?.focus();
    } else if (restoreFocusOnClose.current) {
      restoreFocusOnClose.current = false;
      menuButtonRef.current?.focus();
    }
  }, [isDrawerOpen]);

  // A container that widens past the docking width hides the drawer with CSS; close it too so the content is not left inert.
  useEffect(() => {
    const root = rootRef.current;
    if (!isDrawerOpen || !root || typeof ResizeObserver === "undefined") return;
    const dockedWidth = DOCKED_SIDEBAR_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
    const observer = new ResizeObserver(([entry]) => {
      if (entry.contentRect.width >= dockedWidth) setIsDrawerOpen(false);
    });
    observer.observe(root);
    return () => observer.disconnect();
  }, [isDrawerOpen]);

  // Stable handlers let the memoized sidebar skip the re-render on every resize frame,
  // which would otherwise make the active pill spring after the width.
  const selectItem = setActiveItem;
  const selectItemFromDrawer = useCallback(
    (id: string) => {
      setActiveItem(id);
      closeDrawer();
    },
    [closeDrawer, setActiveItem],
  );

  const handleResizeStart = (event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    drag.current = { startX: event.clientX, startWidth: width, width };
    setDragWidth(width);
  };

  const handleResizeMove = (event: PointerEvent<HTMLDivElement>) => {
    if (!drag.current) return;
    drag.current.width = clampWidth(drag.current.startWidth + event.clientX - drag.current.startX);
    setDragWidth(drag.current.width);
  };

  const handleResizeEnd = (event: PointerEvent<HTMLDivElement>) => {
    if (!drag.current) return;
    const finalWidth = drag.current.width;
    drag.current = null;
    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
    setDragWidth(null);
    commitWidth(finalWidth);
  };

  const handleResizeKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const targets: Record<string, number> = {
      ArrowLeft: width - RESIZE_STEP,
      ArrowRight: width + RESIZE_STEP,
      Home: minSidebarWidth,
      End: maxSidebarWidth,
    };
    if (!(event.key in targets)) return;
    event.preventDefault();
    commitWidth(targets[event.key]);
  };

  const Heading = `h${headingLevel}` as "h1" | "h2" | "h3";

  const sidebarProps = { brand, navigation, secondaryNavigation, footer: sidebarFooter, activeItemId: activeItem, navigationLabel };

  const renderHeader = (tabList?: ReactNode) => (
    <header className="shrink-0">
      <div className="flex items-center justify-between gap-2 px-4 py-3.5">
        <div className="flex min-w-0 items-center gap-2">
          <DashboardButton
            ref={menuButtonRef}
            variant="secondary"
            size="icon"
            className="@2xl/dashboard-shell:hidden"
            aria-label="Open navigation"
            aria-expanded={isDrawerOpen}
            aria-controls={isDrawerOpen ? drawerId : undefined}
            onClick={() => setIsDrawerOpen(true)}
          >
            <Menu aria-hidden />
          </DashboardButton>
          <Heading id={titleId} className="truncate text-base font-medium leading-none tracking-normal">
            {title}
          </Heading>
          {status && (
            <span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-(--obsidian-dashboard-shell-border-strong) bg-(--obsidian-dashboard-shell-muted) py-[3px] pl-[3px] pr-[6px] text-xs leading-none">
              <span aria-hidden className={cn("relative flex size-3 items-center justify-center", statusToneClasses[statusTone])}>
                <span className="absolute inset-0 rounded-full bg-current opacity-25" />
                <span className="size-1.5 rounded-full bg-current" />
              </span>
              {status}
            </span>
          )}
        </div>
        {headerActions && <div className="flex shrink-0 items-center gap-2">{headerActions}</div>}
      </div>
      {tabList}
    </header>
  );

  const body = (
    <>
      {(filters || toolbarActions) && (
        <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 p-4">
          {filters && <div className="flex min-w-0 flex-wrap items-center gap-2">{filters}</div>}
          {toolbarActions && <div className="ml-auto flex shrink-0 items-center gap-1">{toolbarActions}</div>}
        </div>
      )}
      <div ref={contentRef} className="obsidian-dashboard-shell-scroll min-h-0 flex-1 overflow-auto">
        {children}
      </div>
    </>
  );

  return (
    <ShellDarkContext.Provider value={isDark}>
      <ShellLinkContext.Provider value={linkComponent}>
        <div
          ref={rootRef}
          {...props}
          data-resizing={dragWidth !== null ? "" : undefined}
          className={cn(
            "obsidian-dashboard-shell @container/dashboard-shell relative flex h-full min-h-0 w-full overflow-hidden bg-(--obsidian-dashboard-shell-background) text-(--obsidian-dashboard-shell-foreground)",
            className,
          )}
          style={{ "--obsidian-dashboard-shell-sidebar-width": `${width}px`, ...style } as CSSProperties}
        >
          <aside
            id={sidebarId}
            className="relative hidden w-(--obsidian-dashboard-shell-sidebar-width) shrink-0 flex-col border-r border-(--obsidian-dashboard-shell-border) bg-(--obsidian-dashboard-shell-sidebar) @2xl/dashboard-shell:flex"
          >
            <SidebarContent {...sidebarProps} onSelect={selectItem} />
            <div
              role="separator"
              aria-orientation="vertical"
              aria-label="Resize sidebar"
              aria-controls={sidebarId}
              aria-valuemin={minSidebarWidth}
              aria-valuemax={maxSidebarWidth}
              aria-valuenow={width}
              tabIndex={0}
              onPointerDown={handleResizeStart}
              onPointerMove={handleResizeMove}
              onPointerUp={handleResizeEnd}
              onPointerCancel={handleResizeEnd}
              onDoubleClick={() => commitWidth(defaultSidebarWidth)}
              onKeyDown={handleResizeKey}
              className="group absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize touch-none outline-none select-none"
            >
              <span
                aria-hidden
                className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors duration-150 group-hover:bg-(--obsidian-dashboard-shell-border-strong) group-focus-visible:w-0.5 group-focus-visible:bg-(--obsidian-dashboard-shell-ring) group-active:bg-(--obsidian-dashboard-shell-ring)"
              />
            </div>
          </aside>

          <section aria-labelledby={titleId} inert={isDrawerOpen} className="flex min-h-0 min-w-0 flex-1 flex-col">
            {tabs?.length ? (
              <TabsPrimitive.Root value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
                {renderHeader(
                  <TabsPrimitive.List aria-labelledby={titleId} className="border-b border-(--obsidian-dashboard-shell-border) px-3 pb-2.5">
                    <HoverHighlightList className="flex flex-wrap items-center gap-1" highlightClassName="rounded-md bg-(--obsidian-dashboard-shell-hover)">
                      {tabs.map((item) => (
                        <ShellTab key={item.value} item={item} isActive={item.value === activeTab} pillId={tabPillId} />
                      ))}
                    </HoverHighlightList>
                  </TabsPrimitive.List>,
                )}
                <TabsPrimitive.Content value={activeTab} className="flex min-h-0 flex-1 flex-col outline-none">
                  {body}
                </TabsPrimitive.Content>
              </TabsPrimitive.Root>
            ) : (
              <>
                {renderHeader()}
                {body}
              </>
            )}
          </section>

          <AnimatePresence>
            {isDrawerOpen && (
              <motion.div
                key="drawer"
                className="absolute inset-0 z-30 @2xl/dashboard-shell:hidden"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: reduceMotion ? 0 : 0.2, ease: EASE_OUT }}
              >
                <div aria-hidden className="absolute inset-0 bg-black/40" onClick={closeDrawer} />
                <motion.div
                  id={drawerId}
                  role="dialog"
                  aria-modal="true"
                  aria-label={navigationLabel}
                  onKeyDown={(event) => {
                    if (event.key === "Escape") {
                      event.stopPropagation();
                      closeDrawer();
                    } else if (event.key === "Tab") {
                      keepFocusInside(event);
                    }
                  }}
                  className="absolute inset-y-0 left-0 flex w-[254px] max-w-[85%] flex-col border-r border-(--obsidian-dashboard-shell-border) bg-(--obsidian-dashboard-shell-sidebar) shadow-[0_16px_40px_rgb(0_0_0/0.35)]"
                  initial={{ x: reduceMotion ? 0 : "-100%" }}
                  animate={{ x: 0 }}
                  exit={{ x: reduceMotion ? 0 : "-100%" }}
                  transition={{ duration: reduceMotion ? 0 : 0.28, ease: EASE_OUT }}
                >
                  <SidebarContent {...sidebarProps} onSelect={selectItemFromDrawer} onClose={closeDrawer} closeButtonRef={closeButtonRef} />
                </motion.div>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </ShellLinkContext.Provider>
    </ShellDarkContext.Provider>
  );
}


type HoverHighlight = { show: (element: HTMLElement) => void; hide: () => void };

const HoverHighlightContext = createContext<HoverHighlight | null>(null);

/**
 * One hover highlight for a whole list. It glides from item to item after the pointer,
 * and appears in place when the pointer arrives from outside the list.
 */
function HoverHighlightList({
  className,
  highlightClassName = "rounded-lg bg-(--obsidian-dashboard-shell-hover)",
  children,
}: {
  className?: string;
  highlightClassName?: string;
  children: ReactNode;
}) {
  const reduceMotion = !!useReducedMotion();
  const listRef = useRef<HTMLDivElement>(null);
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const width = useMotionValue(0);
  const height = useMotionValue(0);
  const [isVisible, setIsVisible] = useState(false);
  const isShown = useRef(false);
  const hiddenAt = useRef(0);

  const show = useCallback(
    (element: HTMLElement) => {
      const list = listRef.current;
      if (!list) return;
      // Layout offsets rather than client rects, so a menu that is still scaling in doesn't skew the position.
      let top = 0;
      let left = 0;
      for (let node: HTMLElement | null = element; node && node !== list; node = node.offsetParent as HTMLElement | null) {
        top += node.offsetTop;
        left += node.offsetLeft;
      }
      // Leaving and re-entering quickly still glides, so a pointer that grazes a border doesn't make it blink.
      const glide = !reduceMotion && (isShown.current || performance.now() - hiddenAt.current < 150);
      const moveTo = (value: typeof x, target: number) => (glide ? animate(value, target, FOLLOW_SPRING) : value.jump(target));
      moveTo(x, left);
      moveTo(y, top);
      moveTo(width, element.offsetWidth);
      moveTo(height, element.offsetHeight);
      isShown.current = true;
      setIsVisible(true);
    },
    [height, reduceMotion, width, x, y],
  );

  const hide = useCallback(() => {
    if (!isShown.current) return;
    isShown.current = false;
    hiddenAt.current = performance.now();
    setIsVisible(false);
  }, []);

  const highlight = useMemo(() => ({ show, hide }), [show, hide]);

  return (
    <HoverHighlightContext.Provider value={highlight}>
      <div ref={listRef} className={cn("relative", className)} onPointerLeave={hide}>
        <motion.span
          aria-hidden
          className={cn("pointer-events-none absolute left-0 top-0", highlightClassName)}
          style={{ x, y, width, height }}
          initial={false}
          animate={{ opacity: isVisible ? 1 : 0 }}
          transition={{ duration: isVisible ? 0.12 : 0.15, ease: "easeOut" }}
        />
        {children}
      </div>
    </HoverHighlightContext.Provider>
  );
}

type SidebarContentProps = {
  brand: DashboardShellProps["brand"];
  navigation: DashboardShellNavSection[];
  secondaryNavigation?: DashboardShellNavItem[];
  footer?: ReactNode;
  activeItemId?: string;
  navigationLabel: string;
  onSelect: (id: string) => void;
  onClose?: () => void;
  closeButtonRef?: Ref<HTMLButtonElement>;
};

const SidebarContent = memo(function SidebarContent({
  brand,
  navigation,
  secondaryNavigation,
  footer,
  activeItemId,
  navigationLabel,
  onSelect,
  onClose,
  closeButtonRef,
}: SidebarContentProps) {
  // Each sidebar instance (docked and drawer) needs its own pill, or the pill would jump between them.
  const pillId = useId();

  return (
    <div className="flex h-full min-h-0 flex-col">
      <div className="flex shrink-0 items-center gap-2 border-b border-(--obsidian-dashboard-shell-border) bg-(--obsidian-dashboard-shell-panel) p-3">
        {brand.logo && <span className="flex size-8 shrink-0 items-center justify-center overflow-hidden">{brand.logo}</span>}
        <div className="flex min-w-0 flex-1 flex-col gap-1">
          <span className="block truncate text-sm font-medium leading-none tracking-[-0.01em]">{brand.name}</span>
          {brand.description && (
            <span className="block truncate text-xs leading-none text-(--obsidian-dashboard-shell-text-subtle)">{brand.description}</span>
          )}
        </div>
        {onClose && (
          <DashboardButton ref={closeButtonRef} variant="ghost" size="icon" aria-label="Close navigation" onClick={onClose}>
            <X aria-hidden />
          </DashboardButton>
        )}
      </div>

      <motion.nav
        layoutScroll
        aria-label={navigationLabel}
        className="obsidian-dashboard-shell-scroll min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
      >
        <HoverHighlightList>
          {navigation.map((section, index) => (
            <NavSection
              key={section.id}
              title={section.title}
              items={section.items}
              activeItemId={activeItemId}
              pillId={pillId}
              onSelect={onSelect}
              className={index < navigation.length - 1 ? "border-b border-(--obsidian-dashboard-shell-border)" : undefined}
            />
          ))}
        </HoverHighlightList>
      </motion.nav>

      {secondaryNavigation && secondaryNavigation.length > 0 && (
        <HoverHighlightList className="shrink-0 border-t border-(--obsidian-dashboard-shell-border)">
          <NavSection items={secondaryNavigation} activeItemId={activeItemId} pillId={pillId} onSelect={onSelect} isQuiet />
        </HoverHighlightList>
      )}

      {footer && (
        <div className="shrink-0 border-t border-(--obsidian-dashboard-shell-border) bg-(--obsidian-dashboard-shell-panel) p-4">{footer}</div>
      )}
    </div>
  );
});

type NavSectionProps = {
  title?: string;
  items: DashboardShellNavItem[];
  activeItemId?: string;
  pillId: string;
  onSelect: (id: string) => void;
  isQuiet?: boolean;
  className?: string;
};

function NavSection({ title, items, activeItemId, pillId, onSelect, isQuiet = false, className }: NavSectionProps) {
  const titleId = useId();

  return (
    <div className={cn("flex flex-col gap-1 p-3", className)}>
      {title && (
        <span id={titleId} className="block py-1 text-xs font-medium uppercase leading-none tracking-[1px] text-(--obsidian-dashboard-shell-text-subtle)">
          {title}
        </span>
      )}
      <ul aria-labelledby={title ? titleId : undefined} className="flex flex-col gap-px">
        {items.map((item) => (
          <NavItem key={item.id} item={item} isActive={item.id === activeItemId} isQuiet={isQuiet} pillId={pillId} onSelect={onSelect} />
        ))}
      </ul>
    </div>
  );
}

type NavItemProps = {
  item: DashboardShellNavItem;
  isActive: boolean;
  isQuiet: boolean;
  pillId: string;
  onSelect: (id: string) => void;
};

function NavItem({ item, isActive, isQuiet, pillId, onSelect }: NavItemProps) {
  const highlight = useContext(HoverHighlightContext);
  const linkComponent = useContext(ShellLinkContext);
  const reduceMotion = useReducedMotion();
  const Icon = item.icon;

  const shared = {
    "data-active": isActive,
    "aria-current": isActive ? ("page" as const) : undefined,
    onClick: () => onSelect(item.id),
    onPointerEnter: (event: PointerEvent<HTMLElement>) => {
      if (event.pointerType !== "touch") highlight?.show(event.currentTarget);
    },
    className: cn(
      "group relative flex h-8 w-full cursor-pointer select-none items-center gap-2 rounded-lg px-2 text-sm font-medium leading-none outline-none",
      "transition-[color,scale] duration-150 hover:text-(--obsidian-dashboard-shell-foreground) active:scale-[0.98] active:duration-75 focus-visible:ring-2 focus-visible:ring-(--obsidian-dashboard-shell-ring) data-[active=true]:text-(--obsidian-dashboard-shell-foreground)",
      isQuiet ? "text-(--obsidian-dashboard-shell-text-subtle)" : "text-(--obsidian-dashboard-shell-text-muted)",
    ),
  };

  const content = (
    <>
      {isActive && (
        <motion.span
          layoutId={pillId}
          aria-hidden
          className="absolute inset-0 rounded-lg bg-(--obsidian-dashboard-shell-muted) shadow-(--obsidian-dashboard-shell-control-shadow)"
          transition={reduceMotion ? { duration: 0 } : PILL_SPRING}
        />
      )}
      {Icon ? (
        <Icon aria-hidden className="relative size-4 shrink-0 text-(--obsidian-dashboard-shell-icon)" />
      ) : item.color ? (
        <span aria-hidden className="relative flex size-4 shrink-0 items-center justify-center">
          <span className="size-2 rounded-full" style={{ backgroundColor: item.color }} />
        </span>
      ) : null}
      <span className="relative min-w-0 flex-1 truncate text-left">{item.label}</span>
      {item.count !== undefined && (
        <span className="relative inline-flex h-4 min-w-6 shrink-0 items-center justify-center rounded-[5px] border-[0.5px] border-(--obsidian-dashboard-shell-border-strong) bg-(--obsidian-dashboard-shell-muted) px-1 text-[11px] font-normal leading-none tabular-nums text-(--obsidian-dashboard-shell-text-muted)">
          {item.count}
        </span>
      )}
    </>
  );

  return (
    <li>
      {item.href ? (
        // The link component comes from the app, usually next/link, and keeps its identity between renders.
        createElement(linkComponent, { href: item.href, ...shared }, content)
      ) : (
        <button type="button" {...shared}>
          {content}
        </button>
      )}
    </li>
  );
}

type ShellTabProps = { item: DashboardShellTab; isActive: boolean; pillId: string };

function ShellTab({ item, isActive, pillId }: ShellTabProps) {
  const highlight = useContext(HoverHighlightContext);
  const reduceMotion = useReducedMotion();

  return (
    <TabsPrimitive.Trigger
      value={item.value}
      onPointerEnter={(event) => {
        if (event.pointerType !== "touch") highlight?.show(event.currentTarget);
      }}
      className="group relative grid h-7 cursor-pointer select-none place-items-center rounded-md px-2.5 text-xs leading-none text-(--obsidian-dashboard-shell-text-subtle) outline-none transition-[color,scale] duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:text-(--obsidian-dashboard-shell-foreground) focus-visible:ring-2 focus-visible:ring-(--obsidian-dashboard-shell-ring) active:scale-[0.97] active:duration-75 data-[state=active]:text-(--obsidian-dashboard-shell-foreground)"
    >
      {isActive && (
        <motion.span
          layoutId={pillId}
          aria-hidden
          className="absolute inset-0 rounded-md bg-(--obsidian-dashboard-shell-muted) shadow-(--obsidian-dashboard-shell-control-shadow)"
          transition={reduceMotion ? { duration: 0 } : PILL_SPRING}
        />
      )}
      {/* The hidden bold copy reserves the active width so the row never shifts. */}
      <span aria-hidden className="invisible relative col-start-1 row-start-1 font-medium">
        {item.label}
      </span>
      <span className="relative col-start-1 row-start-1 group-data-[state=active]:font-medium">{item.label}</span>
    </TabsPrimitive.Trigger>
  );
}
```

### lib/utils.ts

Installation target: `@lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| brand | { name: string; description?: string; logo?: ReactNode } | - | Product name, tagline, and logo at the top of the sidebar. |
| navigation | DashboardShellNavSection[] | - | Sidebar sections, each with an optional title and its items. |
| secondaryNavigation | DashboardShellNavItem[] | - | Quieter items pinned under the main navigation. |
| sidebarFooter | ReactNode | - | Content pinned to the bottom of the sidebar. |
| activeItemId | string | - | Controlled active navigation item. |
| defaultActiveItemId | string | First item | Initial active item when uncontrolled. |
| onActiveItemChange | (id: string) => void | - | Called when an item is chosen. |
| title | ReactNode | - | Page title in the header. |
| headingLevel | 1 \| 2 \| 3 | 1 | Heading element for the title, to fit the page outline. |
| status | string | - | Short label in a pill beside the title. |
| statusTone | 'success' \| 'warning' \| 'neutral' | 'success' | Color of the status dot. |
| headerActions | ReactNode | - | Buttons on the right of the header. |
| tabs | { value: string; label: string }[] | - | Tabs under the header. |
| tab | string | - | Controlled active tab. |
| defaultTab | string | First tab | Initial tab when uncontrolled. |
| onTabChange | (value: string) => void | - | Called when the tab changes. |
| filters | ReactNode | - | Left side of the toolbar. |
| toolbarActions | ReactNode | - | Right side of the toolbar. |
| children | ReactNode | - | Page content in the scrolling area. |
| sidebarWidth | number | - | Controlled sidebar width in pixels. |
| defaultSidebarWidth | number | 200 | Initial width, and the width a double-click resets to. |
| onSidebarWidthChange | (width: number) => void | - | Called when a drag ends or the width changes from the keyboard. |
| minSidebarWidth | number | 180 | Narrowest sidebar width in pixels. |
| maxSidebarWidth | number | 400 | Widest sidebar width in pixels. |
| navigationLabel | string | Navigation | Accessible name for the navigation and the drawer. |
| linkComponent | ElementType | 'a' | Renders items that have an href, such as Next.js Link for client-side navigation. |
| className | string | - | Additional classes for the root element. |

## Navigation item fields

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| id | string | - | Unique, stable identifier. |
| label | string | - | Visible text. |
| icon | ElementType | - | A duo-icons or lucide icon, or any component that accepts className. |
| color | string | - | Shows a colored dot when there is no icon. |
| count | number | - | Number shown in a badge on the right. |
| href | string | - | Renders the item as a link. |


---

# ObsidianUI documentation

Installation guides and complete component source.

- [Install Next.js](https://www.obsidianui.dev/markdown/docs/installation.md)
- [Install Tailwind CSS](https://www.obsidianui.dev/markdown/docs/install-tailwind.md)
- [Add utilities](https://www.obsidianui.dev/markdown/docs/add-utilities.md)
- [CLI](https://www.obsidianui.dev/markdown/docs/cli.md)
- [Active Sessions](https://www.obsidianui.dev/markdown/docs/active-sessions.md)
- [Art Gallery](https://www.obsidianui.dev/markdown/docs/art-gallery.md)
- [Discover Button](https://www.obsidianui.dev/markdown/docs/discover-button.md)
- [Draggable Marquee](https://www.obsidianui.dev/markdown/docs/draggable-marquee.md)
- [Flip Text](https://www.obsidianui.dev/markdown/docs/flip-text.md)
- [Hover Image](https://www.obsidianui.dev/markdown/docs/hover-img.md)
- [Split Showcase](https://www.obsidianui.dev/markdown/docs/split-showcase.md)
- [Status Bars](https://www.obsidianui.dev/markdown/docs/status-bars.md)
- [Text reel](https://www.obsidianui.dev/markdown/docs/text-stream.md)
- [v-prism](https://www.obsidianui.dev/markdown/docs/v-prism.md)
- [Analytics Dashboard](https://www.obsidianui.dev/markdown/docs/analytics-dashboard.md)
- [Dashboard Shell](https://www.obsidianui.dev/markdown/docs/dashboard-shell.md)

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.