init: arcadia-admin — admin webapp for arcadia-core, cloned from vibespace

Initial commit. Spun up via the docs/STARTER.md recipe: cp from vibespace,
reset git, rename package, set brand to "Arcadia Admin" with Shield icon
in app/lib/identity.ts.

Inherits the full Crema sibling-lib wiring including @crema/arcadia-client
(typed HTTP + Phoenix Channels realtime against arcadia-core) and
@crema/arcadia-auth-ui (login/signup/password-reset/2FA forms). The /login
route already renders <LoginForm>; <ArcadiaProvider> in app/root.tsx reads
VITE_ARCADIA_URL (default localhost:4000) and VITE_ARCADIA_TENANT (default
"default").

CLAUDE.md and README rewritten to frame this as the admin app for
arcadia-core. docs/STARTER.md removed — arcadia-admin is a leaf consumer,
not a downstream starter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-04-29 21:28:39 +10:00
commit f8cbf142b5
108 changed files with 23740 additions and 0 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.DS_Store
.env
/node_modules/
# React Router
/.react-router/
/build/
.demo.log
.demo.pid
.boot.log

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
coverage/
.pnpm-store/
pnpm-lock.yaml
package-lock.json
pnpm-lock.yaml
yarn.lock

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "app/app.css",
"tailwindFunctions": ["cn", "cva"]
}

145
CLAUDE.md Normal file
View File

@@ -0,0 +1,145 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Admin webapp for arcadia-core. Built on the Crema design system, themed with Skyrise, started from the Vibespace starter.
This file is a quick map, not a duplication of upstream docs.
## What Arcadia Admin is
- **Arcadia Admin** is the operator/admin UI for [arcadia-core](../reference/arcadia-app), a multi-tenant Phoenix backend. Surfaces tenant management, user/role admin, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
- **Cloned from** [Vibespace](../vibespace) — the starter for webapps in this style. Vibespace and Skyrise are the upstream sources of truth for the shell and the theme; don't backport arcadia-admin-specific changes into Vibespace unless they're broadly applicable.
- **Backend reference** lives at `../reference/arcadia-app/`. Treat it as read-only documentation — it's the Phoenix umbrella app that owns the OpenAPI spec, controllers, schemas, and seed data. Spec is regenerated from a running arcadia at `http://localhost:4000/api/openapi` via `node ../lib-arcadia-client/scripts/sync-spec.mjs` (run from this directory).
- **Skyrise** (`lib-theme-skyrise`) is the canonical theme — premium AI-first glass, iridescent body, vivid text, Apple-spring motion. Theme tweaks belong upstream in Vibespace + Skyrise, not here.
- The brand string lives in **one place**: `app/lib/identity.ts` (`useBrand()` / `getBrand()`). Don't hardcode "Arcadia Admin" in components, page titles, or copy.
## Arcadia integration (already wired)
- **`<ArcadiaProvider>`** in `app/root.tsx` reads `VITE_ARCADIA_URL` (default `http://localhost:4000`) and `VITE_ARCADIA_TENANT` (default `default`). `getToken` reads `arcadia_access_token` from `sessionStorage`. `onUnauthorized` clears the token.
- **`useArcadiaClient()`** for typed/generic HTTP. `arcadia.typed.GET("/api/v1/...")` infers paths from the generated `paths` type; `arcadia.GET<T>(path)` is the generic escape hatch for spec-incomplete endpoints.
- **Login** — `app/routes/login.tsx` renders `<LoginForm>` from `@crema/arcadia-auth-ui`. Successful login writes tokens via `persistFromArcadiaLogin()` in `app/lib/session.ts`, which preserves the existing `Session` shape used by `useUser` / `AppShell`.
- **Realtime** — supported by the lib but not enabled at the provider here; pass `enableRealtime` + `userId` to opt in.
## Scripts
- `npm run dev` — Vite dev server (React Router 7).
- `npm run build` — production build (`react-router build`).
- `npm run start` — serve the built app (`react-router-serve ./build/server/index.js`).
- `npm run typecheck``react-router typegen && tsc`. See gotcha below; may crash.
- `start.sh` / `stop.sh` — repo's preferred way to run/stop the dev server in the background.
- `npm run test` — Vitest run (vibespace-inherited setup; jsdom + @testing-library/react).
## App layout (`app/`)
- `routes/` + `routes.ts` — file-based React Router 7 routes (Overview, Resources, Activity, Assistant, Library, Settings).
- `components/layout/``app-shell.tsx` (rail + appbar + avatar dropdown), `appbar.tsx`, `theme-toggle.tsx`. **Template code, not a lib** — fork it freely.
- `components/assistant/message-body.tsx` — markdown rendering for assistant replies + action-block pill.
- `components/scripts-dialog.tsx` — ⌘⇧P script runner UI.
- `lib/identity.ts``useBrand()` / `useUser()` (default stubs; swap for real session).
- `lib/llm-settings.ts` — persisted LLM endpoint + context budget config.
- `lib/page-meta.ts`, `lib/utils.ts` — small helpers.
- `app.css` — Tailwind v4 entrypoint; theme `@import` first, then tailwind, then per-lib `@source` lines.
- `root.tsx``ToastProvider` + `CommandBusProvider` wrap point.
## What this is
- React Router 7 + Tailwind v4 SPA.
- Hybrid traditional + AI-first scaffold. Most surfaces are normal; the
Assistant surface is wrapped in `<AppShell theme="skyrise">` for the
AI-styled mood (warm cream + ink-blue + serif prose).
- Built on Crema libs (`@crema/*-ui`) cloned as **sibling directories**, not
npm packages.
- The `@crema/action-bus` lib is the foundation: anything (LLM, scripts,
WebSocket, tests) can drive the UI via `[data-action]`-tagged elements. See
`docs/AI_FIRST.md`.
## Crema system (authoritative)
- **Lib catalog:** see `docs/LIBS.md` for a snapshot of every `@crema/*-ui` lib
(the ones wired into this project AND the ones available to add). Before
building any UI primitive, check there first — there's a good chance a lib
already does it. Refresh the snapshot with `npm run sync-libs`.
- **Each `@crema/*-ui` lib is its own git repo** at
`https://git.sky-ai.com/CremaUIStudio/lib-<name>-ui`, cloned as a sibling
of this app. Edits to lib code commit to that lib's repo, not this one.
- **Themes** (`lib-theme-*`) are CSS-only token files at the same Gitea org.
- **Live manifest** (which `docs/LIBS.md` is generated from) is the
`crema-manifest` repo. Gitea's raw-file HTTP URL 404s; clone instead:
```bash
git clone --depth 1 https://git.sky-ai.com/CremaUIStudio/crema-manifest.git /tmp/crema-manifest
cat /tmp/crema-manifest/manifest.json
```
## Adding a lib after scaffold
Prefer the CLI:
```bash
crema add <lib-name> # e.g. crema add status-ui chart-ui
```
It clones the lib as a sibling, adds the `@source` line to `app/app.css`, and wires `tsconfig.json` paths.
Manual fallback (3 edits):
1. Clone `lib-<name>-ui` as a sibling.
2. `app/app.css` → add `@source "../../lib-<name>-ui/src";`
3. `tsconfig.json` paths → add `"@crema/<name>-ui": ["../lib-<name>-ui/src/index.tsx"]` and the `/*` variant.
Then run `npm run sync-libs` to refresh `docs/LIBS.md`.
## Theme convention
- A single theme (`lib-theme-skyrise` by default) is loaded via `:root` in
`app/app.css`, **first** so its embedded `@import url(...)` Google Fonts
declarations resolve to the top of the output.
- Themes are self-contained: tokens **and** their font `@import url(...)` ship
inside the theme's CSS file.
- Per-surface alt themes can be scoped via `[data-theme="<name>"]` and applied
with the shell's `theme` prop. The Assistant route already does this:
`<AppShell title="Assistant" theme="skyrise"></AppShell>`.
- Components reference tokens (`var(--card)`, `bg-card`, etc.), never hex
values. See `app/components/layout/THEME_CONTRACT.md` for the full token list
any theme must implement.
- Assistant prose uses `var(--font-ai-prose)`. Don't hard-code font families.
## Coding conventions
- `lib-*-ui` components use **inline styles with CSS variables** (`style={{ background: "var(--card)", color: "var(--foreground)" }}`), not Tailwind classes. App routes use Tailwind freely with theme-variable utilities (`bg-card`, `text-muted-foreground`).
- Every shadcn-style component has a `[data-slot="..."]` attribute — themes target these for surface treatment overrides. Preserve them when editing libs.
- Lib `src/index.tsx` files start with `// PURPOSE:` and `// EXPORTS` blocks, plus `"use client"` at the top. Maintain the header.
- Don't hardcode colors. Use tokens.
## Polyrepo gotchas
- `git status` at this app's root only shows app changes. To see lib edits, `cd ../lib-<name>-ui && git status`.
- Each repo commits and pushes separately.
- Don't bundle lib edits into app commits.
- Sibling libs already cloned next to this app include the full Crema set (`lib-chat-ui`, `lib-table-ui`, `lib-chart-ui`, `lib-theme-skyrise`, etc.) — check `../lib-*` before adding anything new.
## Marker contract (CLI-managed regions)
This repo was scaffolded from `create-crema-app`, which patches marker comments. Preserve them when editing:
| File | Marker |
| --- | --- |
| `tsconfig.json` | `"// CREMA:PATHS"` |
| `app/app.css` | `/* CREMA:SOURCES */` |
| `app/routes.ts` | `// CREMA:ROUTES` |
| `app/components/layout/app-shell.tsx` | `// CREMA:NAV-ICONS`, `// CREMA:NAV-ITEMS` |
| `app/root.tsx` | `// CREMA:PROVIDERS-IMPORTS`, `/* CREMA:PROVIDERS-WRAP-OPEN */`, `/* CREMA:PROVIDERS-WRAP-CLOSE */` |
## Common tasks
- **Run dev:** `npm run dev`
- **Add a UI primitive:** check the manifest first; if it exists, `crema add <lib>`; if not, build inline and consider whether it should become a lib.
- **Add a route:** create the file under `app/routes/`, register in `app/routes.ts`, add a nav entry in the sidenav (traditional) or chat shell (AI).
- **Switch theme on a route:** pass `theme="<name>"` to `<AppShell>`. Default (no prop) uses the `:root` theme.
- **Add a script:** drop a `.script` file into `public/scripts/`, register it in `KNOWN_SCRIPTS` in `app/components/scripts-dialog.tsx` if you want it in the dialog list, or invoke programmatically with `runScript("<name>")`.
- **Make a component scriptable:** add `data-action="<id>"`. The bus auto-discovers it.
## Known gotchas
- `npm run typecheck` may crash with a TypeScript internal error — pre-existing in the Crema toolchain. There's no test runner here, so rely on careful reads + dev server.
- Vite "Outdated Optimize Dep" 504s after editing `vite.config.ts` or `tsconfig.json`: stop dev, `rm -rf node_modules/.vite`, restart, hard-reload.
- After editing a sibling lib's exports, the dev server sometimes needs a manual restart to pick up the new types.

85
README.md Normal file
View File

@@ -0,0 +1,85 @@
# Arcadia Admin
Admin webapp for [arcadia-core](../reference/arcadia-app) — the multi-tenant Phoenix backend. Built on the [Crema design system](https://git.sky-ai.com/CremaUIStudio) with the **Skyrise** theme and started from the [Vibespace](../vibespace) starter.
Surfaces tenant management, user/role administration, billing, audit logs, storage configs, scheduled tasks, feature flags, and platform monitoring on top of arcadia's `/api/v1` and `/admin/*` endpoints.
## Quick start
```bash
npm install
npm run dev
```
Open [http://localhost:5173](http://localhost:5173). The app talks to arcadia at `http://localhost:4000` by default; override with `VITE_ARCADIA_URL` in `.env.local`.
To use it for real:
1. Have arcadia running locally (see `../reference/arcadia-app/DEV_SETUP.md`).
2. Visit `/login` and sign in with admin credentials. In dev seeds: `admin@example.com` / `AdminP@ssw0rd` (tenant `default`).
## Configuration
| Env var | Default | Purpose |
|---|---|---|
| `VITE_ARCADIA_URL` | `http://localhost:4000` | Base URL of arcadia-core. |
| `VITE_ARCADIA_TENANT` | `default` | Tenant id sent as `X-Tenant-ID`. Override per-deployment. |
## What's in here
### App shell
`app/components/layout/app-shell.tsx` — left rail + appbar + avatar dropdown. Brand identity in `app/lib/identity.ts` (`name: "Arcadia Admin"`, icon: `Shield`). The shell is **template code, not a lib** — fork it freely as admin features are added.
### Arcadia client + auth UI
- [`@crema/arcadia-client`](../lib-arcadia-client) — typed HTTP client (generic + openapi-fetch-backed `client.typed`), Phoenix Channels realtime, error normalization. Mounted at the root via `<ArcadiaProvider>`.
- [`@crema/arcadia-auth-ui`](../lib-arcadia-auth-ui) — login / signup / password reset / 2FA forms, themed via Skyrise tokens. The `/login` route renders `<LoginForm>`.
### Skyrise theme
[`lib-theme-skyrise`](../lib-theme-skyrise) — premium AI-first glass: iridescent body, frosted-glass surfaces, vivid text, Apple-spring motion. Default 18px root.
Surface tints (`body[data-surface="snow|stone|sage|slate"]`) and dark mode (`html.dark`) work out of the box via the existing pickers in the appbar.
### Command bus
[`@crema/action-bus`](../lib-action-bus) — every interactive element has `data-action="<id>"` so admin flows can be scripted, e2e-tested, or driven by an LLM through a single bus. See `docs/AI_FIRST.md`.
## Sibling repos
```
your-workspace/
arcadia-admin/ ← this repo
vibespace/ ← starter that this was cloned from
reference/arcadia-app/ ← Phoenix backend (read-only reference)
lib-arcadia-client/
lib-arcadia-auth-ui/
lib-action-bus/
lib-aifirst-ui/
lib-chat-ui/
lib-llm-ui/
lib-notification-ui/
lib-theme-skyrise/
```
## Dev scripts
| Command | What it does |
|---|---|
| `npm run dev` | Vite dev server |
| `npm run build` | Production build |
| `npm run start` | Serve the built app |
| `npm run typecheck` | `react-router typegen && tsc` |
| `npm run test` | Vitest run |
| `bash start.sh` / `bash stop.sh` | Run dev server in the background |
## Conventions
- **Brand strings, not literals.** Use `useBrand().name` — never hardcode "Arcadia Admin".
- **`[data-action="<id>"]`** on every interactive element. Naming: `nav-*`, `appbar-*`, `tenants-*`, `users-*`, `audit-*`, etc.
- **Tokens, not values.** `bg-card`, `text-foreground`, `var(--primary)` — never hex.
- **Lib edits commit to each lib's own repo.** `git status` here only shows app-level changes.
## Further reading
- [`docs/AI_FIRST.md`](docs/AI_FIRST.md) — command-bus / DSL system tour
- [`app/components/layout/THEME_CONTRACT.md`](app/components/layout/THEME_CONTRACT.md) — token contract every theme must satisfy
- `CLAUDE.md` — orientation for an LLM working in this repo
- `../reference/arcadia-app/` — backend (DEV_SETUP, controllers, OpenAPI source-of-truth)

136
app/app.css Normal file
View File

@@ -0,0 +1,136 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Geist+Mono:wght@100..900&display=swap");
/* Active theme — must be first so its @import url() font directives resolve
* to the top of the output. Themes are self-contained: tokens + fonts. */
@import "../../lib-theme-skyrise/theme.css"; /* CREMA:THEME */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@source "../../lib-chat-ui/src";
@source "../../lib-aifirst-ui/src";
@source "../../lib-llm-ui/src";
@source "../../lib-action-bus/src";
@source "../../lib-arcadia-client/src";
@source "../../lib-arcadia-auth-ui/src";
/* CREMA:SOURCES */
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: "Instrument Sans", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-ai-prose: "Instrument Sans", "Inter", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-syntax-keyword: var(--syntax-keyword);
--color-syntax-string: var(--syntax-string);
--color-syntax-number: var(--syntax-number);
--color-syntax-comment: var(--syntax-comment);
--color-syntax-function: var(--syntax-function);
--color-syntax-type: var(--syntax-type);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--ease-standard: var(--ease-standard);
--ease-emphasized: var(--ease-emphasized);
--ease-decelerate: var(--ease-decelerate);
--ease-accelerate: var(--ease-accelerate);
--ease-spring-gentle: var(--ease-spring-gentle);
--ease-spring-snappy: var(--ease-spring-snappy);
--ease-spring-bouncy: var(--ease-spring-bouncy);
--duration-spring: var(--duration-spring);
--duration-fast: var(--duration-fast);
--duration-base: var(--duration-base);
--duration-slow: var(--duration-slow);
--duration-slower: var(--duration-slower);
--text-display: var(--text-display-size);
--text-display--line-height: var(--text-display-lh);
--text-headline: var(--text-headline-size);
--text-headline--line-height: var(--text-headline-lh);
--text-title: var(--text-title-size);
--text-title--line-height: var(--text-title-lh);
--text-body: var(--text-body-size);
--text-body--line-height: var(--text-body-lh);
--text-label: var(--text-label-size);
--text-label--line-height: var(--text-label-lh);
--text-caption: var(--text-caption-size);
--text-caption--line-height: var(--text-caption-lh);
--shadow-e0: var(--elevation-0);
--shadow-e1: var(--elevation-1);
--shadow-e2: var(--elevation-2);
--shadow-e3: var(--elevation-3);
--shadow-e4: var(--elevation-4);
--shadow-e5: var(--elevation-5);
--border-width-thin: var(--border-width-thin);
--border-width-base: var(--border-width-base);
--border-width-thick: var(--border-width-thick);
--border-width-heavy: var(--border-width-heavy);
--radius-sm: calc(var(--radius) * 0.5);
--radius-md: calc(var(--radius) * 0.75);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.15);
--radius-2xl: calc(var(--radius) * 1.3);
--radius-3xl: calc(var(--radius) * 1.5);
--radius-4xl: calc(var(--radius) * 1.75);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html, body {
min-height: 100%;
}
body {
@apply bg-background text-foreground;
min-height: 100dvh;
overscroll-behavior-y: none;
}
html {
@apply font-sans;
overscroll-behavior-y: none;
}
/* Mount the skyrise aurora-field as a fixed backdrop layer so the
* iridescent drift always covers the viewport — independent of body
* height, scroll position, or backdrop-filter siblings unmounting
* (which can otherwise leave the body bg stuck in a stale paint). */
[data-slot="aurora-field"] {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
overflow: hidden;
}
}

31
app/components/README.md Normal file
View File

@@ -0,0 +1,31 @@
# components/
Component layers in this project.
```
ui/ shadcn primitives — token-driven, reskinnable per design system
forms/ composed form widgets
data/ data display (tables, filters, empty states)
layout/ app shell, page chrome, navigation wrappers
marketing/ landing and marketing blocks
[system]/ design-system-specific components (e.g. m3/, apple/)
```
## The one rule
**Custom components import *from* `ui/`, never the reverse.**
`ui/` is the primitive layer. It must stay reskinnable by swapping tokens in
`app/themes/*.css` alone. If a component can't be expressed that way (M3
ripple, Apple segmented control, etc.), it belongs in a system-specific
folder — not `ui/` and not the shared folders above.
## Tokens, not values
Every custom component should reference semantic tokens:
- Colors: `bg-primary`, `text-muted-foreground`, `border-border`
- Radius: `rounded-md`, `rounded-lg`
- Fonts: `font-sans`, `font-heading`
Hardcoded hex, oklch, or px values are a bug — they break theming.

View File

@@ -0,0 +1,68 @@
// Renders an assistant message: markdown for prose, and a small "Ran N
// actions" pill in place of any ```action``` fenced blocks (which are the
// machine-readable instructions the bus has already executed).
import { useMemo } from "react"
import ReactMarkdown from "react-markdown"
import { Sparkles } from "lucide-react"
import { extractActionBlocks } from "@crema/action-bus"
const ACTION_BLOCK_RE = /```action\s*\n[\s\S]*?```/g
export function MessageBody({ content }: { content: string }) {
const { prose, actionCount } = useMemo(() => {
const blocks = extractActionBlocks(content)
return {
prose: content.replace(ACTION_BLOCK_RE, "").trim(),
actionCount: blocks.length,
}
}, [content])
return (
<div className="prose prose-sm max-w-none dark:prose-invert">
{prose && (
<ReactMarkdown
components={{
// Tight overrides — keep paragraphs compact in chat bubbles
p: ({ children }) => <p className="my-1.5 leading-relaxed">{children}</p>,
code: ({ children, className }) => {
const isBlock = className?.startsWith("language-")
if (isBlock) {
return (
<pre className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">
<code className="font-mono">{children}</code>
</pre>
)
}
return (
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
)
},
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
ol: ({ children }) => <ol className="my-1.5 list-decimal pl-5">{children}</ol>,
li: ({ children }) => <li className="my-0.5">{children}</li>,
a: ({ children, href }) => (
<a href={href} className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer">
{children}
</a>
),
}}
>
{prose}
</ReactMarkdown>
)}
{actionCount > 0 && (
<span
className="mt-1 inline-flex items-center gap-1.5 rounded-full border border-primary/40 bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary dark:border-sky-400/60 dark:bg-sky-400/15 dark:text-sky-200"
title="Action block executed by the command bus"
>
<Sparkles className="size-3" />
Ran {actionCount} action{actionCount > 1 ? "s" : ""}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,10 @@
# layout/
App structure and page chrome.
Examples: `AppShell`, `PageHeader`, `SidebarNav`, `Breadcrumbs`, `Footer`.
**Rules:**
- Import from `~/components/ui/*` — never the reverse.
- Reference tokens only. No hardcoded colors or spacing.
- These compose `ui/sidebar`, `ui/navigation-menu`, etc. into opinionated shells.

View File

@@ -0,0 +1,132 @@
# Theme contract
Every Crema theme (`lib-theme-*`) must declare these CSS variables so the
shell, AI surfaces, and shadcn primitives render correctly. Missing a variable
won't crash anything — it'll just look wrong (transparent backgrounds, missing
borders, broken focus rings, etc.).
A theme is a single CSS file that declares variables under `:root` for light
mode and under `.dark` for dark mode. Themes may also `@import url(...)` font
packages at the top of the file — they ship self-contained.
The shell expects the theme to be the **first** import in the consuming app's
entry CSS, so any embedded font URLs land at the top of the resolved output
(CSS spec requires `@import` before any other rules).
## Required tokens
### Surfaces
| Token | Used for |
|---|---|
| `--background` | Page background |
| `--foreground` | Default text color |
| `--card` | Card surfaces, inputs, dropdowns |
| `--card-foreground` | Text on cards |
| `--popover` | Popover/menu surfaces |
| `--popover-foreground` | Text on popovers |
| `--muted` | Subtle backgrounds (hover states, toolbars, code blocks) |
| `--muted-foreground` | Subtle text (timestamps, hints, labels) |
| `--accent` | Hover/focus highlight on items |
| `--accent-foreground` | Text on accent backgrounds |
### Brand & status
| Token | Used for |
|---|---|
| `--primary` | Brand color, primary buttons, active nav, ripple |
| `--primary-foreground` | Text on primary surfaces |
| `--secondary` | Secondary buttons |
| `--secondary-foreground` | Text on secondary |
| `--destructive` | Errors, sign-out, destructive menu items |
| `--success` | Confirmations |
| `--success-foreground` | Text on success surfaces |
### Borders, inputs, focus
| Token | Used for |
|---|---|
| `--border` | Dividers, default borders |
| `--input` | Input field borders |
| `--ring` | Focus rings |
### Sidebar (rail)
| Token | Used for |
|---|---|
| `--sidebar` | Rail background |
| `--sidebar-foreground` | Rail text |
| `--sidebar-primary` | Active nav background |
| `--sidebar-primary-foreground` | Active nav text |
| `--sidebar-accent` | Rail hover |
| `--sidebar-accent-foreground` | Text on rail hover |
| `--sidebar-border` | Rail borders |
| `--sidebar-ring` | Rail focus ring |
### Charts & syntax (optional but recommended)
| Token | Used for |
|---|---|
| `--chart-1``--chart-5` | Chart series colors; `--chart-3` doubles as warning amber |
| `--syntax-keyword`, `--syntax-string`, `--syntax-number`, `--syntax-comment`, `--syntax-function`, `--syntax-type` | Code-block highlighting |
### Typography
| Token | Used for |
|---|---|
| `--font-heading` | Heading font stack |
| `--font-sans` | Default UI font stack |
| `--font-ai-prose` | Assistant prose (the AI surface uses this for replies) |
| `--font-mono` | Code, tabular numbers |
### Type scale
Each pair declares a size and matching line height:
- `--text-display-size` / `--text-display-lh`
- `--text-headline-size` / `--text-headline-lh`
- `--text-title-size` / `--text-title-lh`
- `--text-body-size` / `--text-body-lh`
- `--text-label-size` / `--text-label-lh`
- `--text-caption-size` / `--text-caption-lh`
### Motion
| Token | Used for |
|---|---|
| `--duration-fast`, `--duration-base`, `--duration-slow`, `--duration-slower`, `--duration-spring` | Animation lengths |
| `--ease-standard`, `--ease-emphasized`, `--ease-decelerate`, `--ease-accelerate` | Standard easing curves |
| `--ease-spring-gentle`, `--ease-spring-snappy`, `--ease-spring-bouncy` | Spring curves |
### Radii & elevation
| Token | Used for |
|---|---|
| `--radius` | Base radius (sm/md/lg/xl/2xl computed from this in `app.css`) |
| `--elevation-0``--elevation-5` | Shadow scale |
| `--border-width-thin`, `--border-width-base`, `--border-width-thick`, `--border-width-heavy` | Stroke widths |
## Scoping conventions
- `:root { … }` — theme defaults (light mode).
- `.dark { … }` — dark-mode overrides.
- `[data-theme="<name>"] { … }` — alternate-scoped theme. Only needed if a
theme should coexist with another on the same page (e.g. an AI surface uses
a different theme than the rest of the app). Single-theme apps don't need
this scope.
## Adding a new theme
1. Clone any existing theme as a starting point — `lib-theme-mightypix` is a
good reference because it's complete.
2. Edit tokens (and font `@import url` if changing fonts).
3. Update the consuming app's `app.css`:
```css
@import "../../lib-theme-<your-name>/theme.css"; /* CREMA:THEME */
@import "tailwindcss";
...
```
4. Done. Components automatically pick up the new look — no changes needed in
any TSX file.
## Multi-theme apps (e.g. mightypix on AI surfaces only)
If a theme defines `[data-theme="<name>"]` instead of (or in addition to)
`:root`, you can switch surfaces per route via the shell's `theme` prop:
```tsx
<AppShell title="Assistant" theme="mightypix">…</AppShell>
```
The shell wraps itself in `data-theme={theme}` so the alt theme's tokens
cascade through that subtree only.

View File

@@ -0,0 +1,568 @@
import { useEffect, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, useNavigate } from "react-router"
import {
Bell,
LayoutDashboard,
Boxes,
Activity,
Sparkles,
Bot,
BookOpen,
Settings,
Search,
PanelLeftClose,
PanelLeftOpen,
User as UserIcon,
LogOut,
HelpCircle,
Menu,
Play,
// CREMA:NAV-ICONS
} from "lucide-react"
import {
useBrand,
useUser,
type Brand,
type User,
} from "~/lib/identity"
import {
Appbar,
AppbarActions,
AppbarSpacer,
AppbarTitle,
} from "~/components/layout/appbar"
import { ThemeToggle } from "~/components/layout/theme-toggle"
import { BackgroundPicker } from "~/components/layout/background-picker"
import { FontSizePicker } from "~/components/layout/font-size-picker"
import { SurfacePicker } from "~/components/layout/surface-picker"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile"
import { signOut, useSession } from "~/lib/session"
import {
addNotification,
dismiss,
dismissAll,
markAllRead,
markRead,
seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet"
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
type NavItem = {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
end?: boolean
}
const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/resources", icon: Boxes, label: "Resources" },
{ to: "/activity", icon: Activity, label: "Activity" },
{ to: "/assistant", icon: Sparkles, label: "Assistant" },
{ to: "/ai", icon: Bot, label: "AI" },
{ to: "/library", icon: BookOpen, label: "Library" },
{ to: "/settings", icon: Settings, label: "Settings" },
// CREMA:NAV-ITEMS
]
type AppShellProps = {
title: string
children: React.ReactNode
brand?: Brand
user?: User
/**
* Optional theme name. When set, the shell wraps itself in
* `data-theme={theme}` so a route can opt into an alternate theme without
* the caller having to add an extra wrapping div.
*/
theme?: string
}
export function AppShell({
title,
children,
brand: brandOverride,
user: userOverride,
theme,
}: AppShellProps) {
const defaultBrand = useBrand()
const defaultUser = useUser()
const profile = useProfile()
const session = useSession()
const navigate = useNavigate()
const brand = brandOverride ?? defaultBrand
// Prefer the live session for identity, fall back to the editable profile,
// fall back to the stub user.
const user = userOverride ?? {
name: session?.name || profile.name || defaultUser.name,
email: session?.email || profile.email || defaultUser.email,
initials: profileInitials(
session?.name || profile.name || defaultUser.name,
),
}
// Protected shell: bounce to /login when there's no session.
useEffect(() => {
if (typeof window === "undefined") return
if (!session) {
const next = encodeURIComponent(
window.location.pathname + window.location.search,
)
navigate(`/login?next=${next}`, { replace: true })
}
}, [session, navigate])
if (!session) return null
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
const BrandIcon = brand.icon
useScriptsHotkey(() => setScriptsOpen(true))
return (
<div
data-theme={theme}
className="relative isolate flex min-h-svh"
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-primary focus:px-3 focus:py-2 focus:text-primary-foreground focus:shadow-lg"
>
Skip to main content
</a>
<aside
data-slot="sidebar"
data-expanded={expanded ? "true" : "false"}
className={[
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex",
expanded ? "w-60" : "w-16",
].join(" ")}
>
<div
className={[
"flex h-14 items-center gap-2 border-b px-3",
expanded ? "justify-between" : "justify-center",
].join(" ")}
>
{expanded ? (
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
<span className="font-heading font-semibold tracking-tight">
{brand.name}
</span>
</div>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
)}
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
data-action={`nav-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
expanded ? "justify-start" : "justify-center",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
{expanded && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="shrink-0 border-t p-2">
<Button
data-action="sidebar-toggle"
variant="ghost"
size={expanded ? "sm" : "icon-sm"}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
className={expanded ? "w-full justify-start" : "w-full"}
>
{expanded ? (
<>
<PanelLeftClose className="size-4" />
<span>Collapse</span>
</>
) : (
<PanelLeftOpen className="size-4" />
)}
</Button>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
{/* Mobile-only menu trigger, floating top-left of main */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
data-action="mobile-nav-toggle"
aria-label="Open navigation"
className="fixed left-3 top-3 z-30 inline-flex size-9 items-center justify-center rounded-full border bg-card/70 text-muted-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground md:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
{brand.name}
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
data-action={`nav-mobile-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
)
})}
</nav>
</SheetContent>
</Sheet>
{/* Floating glass pill, top-right, replacing the appbar action group */}
<div
data-slot="floating-actions"
className="fixed right-3 top-3 z-30 flex items-center gap-0.5 rounded-full border bg-card/70 px-1.5 py-1 shadow-sm backdrop-blur-md"
>
<Button
data-action="appbar-scripts"
variant="ghost"
size="icon-sm"
aria-label="Run script (Cmd+Shift+P)"
title="Run script (Cmd+Shift+P)"
onClick={() => setScriptsOpen(true)}
>
<Play />
</Button>
<FontSizePicker />
<SurfacePicker />
<BackgroundPicker />
<ThemeToggle />
<NotificationsBell />
<DropdownMenu>
<DropdownMenuTrigger
data-action="appbar-avatar"
aria-label="Account menu"
className="ml-1 rounded-full outline-none ring-offset-2 ring-offset-background transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-7 cursor-pointer">
{profile.avatarUrl ? (
<AvatarImage src={profile.avatarUrl} alt={user.name} />
) : null}
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5">
<span className="font-medium">{user.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{user.email}
</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-profile"
onClick={() => navigate("/profile")}
>
<UserIcon /> Profile
</DropdownMenuItem>
<DropdownMenuItem
data-action="avatar-settings"
onClick={() => navigate("/settings")}
>
<Settings /> Settings
</DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-signout"
variant="destructive"
onClick={() => {
signOut()
navigate("/login", { replace: true })
}}
>
<LogOut /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
id="main-content"
tabIndex={-1}
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
>
{children}
</div>
</main>
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
<NotificationDispatcher />
</div>
)
}
function NotificationDispatcher() {
// Hidden bridge so the action bus can create real notifications:
// fill notify-title "Hello"
// fill notify-body "Body text"
// fill notify-kind "info" # info|success|warning|error
// fill notify-href "/library" # optional
// click notify-create
const titleRef = useRef<HTMLInputElement>(null)
const bodyRef = useRef<HTMLInputElement>(null)
const kindRef = useRef<HTMLInputElement>(null)
const hrefRef = useRef<HTMLInputElement>(null)
const submit = () => {
const title = titleRef.current?.value.trim() ?? ""
if (!title) return
const body = bodyRef.current?.value.trim() || undefined
const rawKind = (kindRef.current?.value || "info").trim().toLowerCase()
const kind = (
["info", "success", "warning", "error"].includes(rawKind)
? rawKind
: "info"
) as "info" | "success" | "warning" | "error"
const href = hrefRef.current?.value.trim() || undefined
addNotification({ title, body, kind, href })
if (titleRef.current) titleRef.current.value = ""
if (bodyRef.current) bodyRef.current.value = ""
if (kindRef.current) kindRef.current.value = ""
if (hrefRef.current) hrefRef.current.value = ""
}
return (
<div
aria-hidden
className="pointer-events-none fixed left-0 top-0 size-px overflow-hidden opacity-0"
>
<input
ref={titleRef}
data-action="notif-title"
placeholder="title"
aria-label="Notification title"
/>
<input
ref={bodyRef}
data-action="notif-body"
placeholder="body"
aria-label="Notification body"
/>
<input
ref={kindRef}
data-action="notif-kind"
placeholder="info|success|warning|error"
aria-label="Notification kind"
/>
<input
ref={hrefRef}
data-action="notif-href"
placeholder="/path (optional)"
aria-label="Notification href"
/>
<button
type="button"
data-action="notif-create"
aria-label="Create notification"
onClick={submit}
className="pointer-events-auto"
>
create
</button>
</div>
)
}
function NotificationsBell() {
const items = useNotifications()
const unread = unreadCount(items)
const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return (
<Popover>
<PopoverTrigger
render={
<Button
data-action="appbar-notifications"
variant="ghost"
size="icon-sm"
aria-label="Notifications"
>
<span className="relative inline-flex">
<Bell />
{unread > 0 && (
<span className="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold text-primary-foreground">
{unread > 9 ? "9+" : unread}
</span>
)}
</span>
</Button>
}
/>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex items-center gap-1">
<Button
data-action="notif-mark-all-read"
variant="ghost"
size="sm"
onClick={() => markAllRead()}
disabled={unread === 0}
>
Mark all read
</Button>
<Button
data-action="notif-clear"
variant="ghost"
size="sm"
onClick={() => dismissAll()}
disabled={items.length === 0}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-80 overflow-y-auto">
{items.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications.
</li>
) : (
items.map((n) => (
<li
key={n.id}
className={
"group flex items-start gap-2 border-b px-3 py-2 text-sm transition-colors hover:bg-accent/40 " +
(!n.readAt ? "bg-primary/5" : "")
}
>
<span
className={
"mt-1 size-2 shrink-0 rounded-full " +
(n.kind === "success"
? "bg-emerald-500"
: n.kind === "warning"
? "bg-amber-500"
: n.kind === "error"
? "bg-rose-500"
: "bg-primary")
}
aria-hidden
/>
<button
type="button"
data-action={`notif-open-${n.id}`}
onClick={() => {
markRead(n.id)
if (n.href) navigate(n.href)
}}
className="flex flex-1 flex-col items-start text-left"
>
<span className="font-medium">{n.title}</span>
{n.body && (
<span className="text-xs text-muted-foreground">
{n.body}
</span>
)}
<span className="text-[10px] text-muted-foreground/70">
{new Date(n.createdAt).toLocaleString()}
</span>
</button>
<button
type="button"
data-action={`notif-dismiss-${n.id}`}
onClick={() => dismiss(n.id)}
aria-label="Dismiss"
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
>
<span aria-hidden>×</span>
</button>
</li>
))
)}
</ul>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,48 @@
import type { ComponentProps } from "react"
import { cn } from "~/lib/utils"
function Appbar({ className, ...props }: ComponentProps<"header">) {
return (
<header
data-slot="appbar"
className={cn(
"flex h-14 w-full items-center gap-3 border-b bg-background px-4",
className
)}
{...props}
/>
)
}
function AppbarTitle({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="appbar-title"
className={cn("font-heading text-sm font-semibold", className)}
{...props}
/>
)
}
function AppbarSpacer({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="appbar-spacer"
className={cn("flex-1", className)}
{...props}
/>
)
}
function AppbarActions({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="appbar-actions"
className={cn("flex items-center gap-1", className)}
{...props}
/>
)
}
export { Appbar, AppbarTitle, AppbarSpacer, AppbarActions }

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react"
import { Check, Palette } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "~/components/ui/popover"
import { cn } from "~/lib/utils"
export const backgrounds = [
{ id: "none", label: "None" },
{ id: "pearl", label: "Pearl" },
{ id: "linen", label: "Linen" },
{ id: "mist", label: "Mist" },
{ id: "dawn", label: "Dawn" },
{ id: "seafoam", label: "Seafoam" },
{ id: "aurora", label: "Aurora" },
{ id: "sunset", label: "Sunset" },
{ id: "meadow", label: "Meadow" },
{ id: "midnight", label: "Midnight" },
{ id: "blush", label: "Blush" },
{ id: "noir", label: "Noir" },
] as const
export type BackgroundId = (typeof backgrounds)[number]["id"]
const STORAGE_KEY = "crema-bg"
const DEFAULT_BG: BackgroundId = "none"
function isBackgroundId(value: string | null): value is BackgroundId {
return !!value && backgrounds.some((b) => b.id === value)
}
function applyBg(id: BackgroundId) {
if (id === "none") {
delete document.body.dataset.bg
} else {
document.body.dataset.bg = id
}
}
export function BackgroundPicker() {
const [current, setCurrent] = useState<BackgroundId>(DEFAULT_BG)
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
const next = isBackgroundId(stored) ? stored : DEFAULT_BG
setCurrent(next)
applyBg(next)
}, [])
const select = (id: BackgroundId) => {
setCurrent(id)
applyBg(id)
localStorage.setItem(STORAGE_KEY, id)
}
return (
<Popover>
<PopoverTrigger
render={
<Button
data-action="appbar-background"
variant="ghost"
size="icon-sm"
aria-label="Change background"
>
<Palette />
</Button>
}
/>
<PopoverContent align="end" className="w-64">
<PopoverHeader>
<PopoverTitle>Background</PopoverTitle>
<PopoverDescription>Pick an atmosphere.</PopoverDescription>
</PopoverHeader>
<div className="grid grid-cols-3 gap-2">
{backgrounds.map((bg) => {
const active = current === bg.id
return (
<button
key={bg.id}
type="button"
onClick={() => select(bg.id)}
aria-pressed={active}
className={cn(
"group relative flex aspect-square flex-col justify-end overflow-hidden rounded-lg ring-1 ring-border transition-all duration-fast ease-standard hover:ring-foreground/30 focus-visible:ring-2 focus-visible:ring-ring",
bg.id === "none" ? "bg-background" : `bg-variant-${bg.id}`,
active && "ring-2 ring-primary"
)}
>
{active && (
<span className="absolute top-1 right-1 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-e1">
<Check className="size-3" />
</span>
)}
<span className="rounded-b-lg bg-background/70 px-1.5 py-1 text-center text-[10px] font-medium tracking-tight backdrop-blur-sm">
{bg.label}
</span>
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react"
import { Check, Type } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "~/components/ui/popover"
import { cn } from "~/lib/utils"
export const fontSizes = [
{ id: "sm", label: "Small", sample: "Aa" },
{ id: "md", label: "Medium", sample: "Aa" },
{ id: "lg", label: "Large", sample: "Aa" },
{ id: "xl", label: "Extra large", sample: "Aa" },
] as const
export type FontSizeId = (typeof fontSizes)[number]["id"]
const STORAGE_KEY = "crema-font-scale"
const DEFAULT_SIZE: FontSizeId = "md"
function isFontSizeId(value: string | null): value is FontSizeId {
return !!value && fontSizes.some((f) => f.id === value)
}
const SAMPLE_PX: Record<FontSizeId, number> = {
sm: 14,
md: 16,
lg: 18,
xl: 20,
}
export function FontSizePicker() {
const [current, setCurrent] = useState<FontSizeId>(DEFAULT_SIZE)
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
const next = isFontSizeId(stored) ? stored : DEFAULT_SIZE
setCurrent(next)
document.documentElement.dataset.fontScale = next
}, [])
const select = (id: FontSizeId) => {
setCurrent(id)
document.documentElement.dataset.fontScale = id
localStorage.setItem(STORAGE_KEY, id)
}
return (
<Popover>
<PopoverTrigger
render={
<Button
data-action="appbar-font-size"
variant="ghost"
size="icon-sm"
aria-label="Change font size"
>
<Type />
</Button>
}
/>
<PopoverContent align="end" className="w-56">
<PopoverHeader>
<PopoverTitle>Font size</PopoverTitle>
<PopoverDescription>Scales the entire UI.</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-1">
{fontSizes.map((f) => {
const active = current === f.id
return (
<button
key={f.id}
type="button"
onClick={() => select(f.id)}
aria-pressed={active}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none",
active && "bg-accent text-accent-foreground"
)}
>
<span className="flex items-baseline gap-2.5">
<span
className="font-heading leading-none"
style={{ fontSize: `${SAMPLE_PX[f.id]}px` }}
>
{f.sample}
</span>
<span className="text-sm">{f.label}</span>
</span>
{active && <Check className="size-4 text-primary" />}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from "react"
import { Check, Layers } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "~/components/ui/popover"
import { cn } from "~/lib/utils"
export const surfaces = [
{ id: "default", label: "Default" },
{ id: "snow", label: "Snow" },
{ id: "stone", label: "Stone" },
{ id: "sage", label: "Sage" },
{ id: "slate", label: "Slate" },
] as const
export type SurfaceId = (typeof surfaces)[number]["id"]
const STORAGE_KEY = "crema-surface"
const DEFAULT_SURFACE: SurfaceId = "default"
function isSurfaceId(value: string | null): value is SurfaceId {
return !!value && surfaces.some((s) => s.id === value)
}
function applySurface(id: SurfaceId) {
if (id === "default") {
delete document.body.dataset.surface
} else {
document.body.dataset.surface = id
}
}
export function SurfacePicker() {
const [current, setCurrent] = useState<SurfaceId>(DEFAULT_SURFACE)
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
const next = isSurfaceId(stored) ? stored : DEFAULT_SURFACE
setCurrent(next)
applySurface(next)
}, [])
const select = (id: SurfaceId) => {
setCurrent(id)
applySurface(id)
localStorage.setItem(STORAGE_KEY, id)
}
return (
<Popover>
<PopoverTrigger
render={
<Button
data-action="appbar-surface"
variant="ghost"
size="icon-sm"
aria-label="Change surface tint"
>
<Layers />
</Button>
}
/>
<PopoverContent align="end" className="w-56">
<PopoverHeader>
<PopoverTitle>Surface</PopoverTitle>
<PopoverDescription>Tint of cards and the sidebar.</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-1">
{surfaces.map((s) => {
const active = current === s.id
return (
<button
key={s.id}
type="button"
onClick={() => select(s.id)}
aria-pressed={active}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-fast ease-standard hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:outline-none",
active && "bg-accent text-accent-foreground"
)}
>
<span className="flex items-center gap-2.5">
<span
aria-hidden
className={cn(
"size-5 rounded-md ring-1 ring-border",
`surface-swatch-${s.id}`
)}
/>
<span className="text-sm">{s.label}</span>
</span>
{active && <Check className="size-4 text-primary" />}
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,30 @@
import { Moon, Sun } from "lucide-react"
import { Button } from "~/components/ui/button"
const STORAGE_KEY = "crema-theme"
function applyTheme(next: "light" | "dark") {
document.documentElement.classList.toggle("dark", next === "dark")
localStorage.setItem(STORAGE_KEY, next)
}
export function ThemeToggle() {
const toggle = () => {
const isDark = document.documentElement.classList.contains("dark")
applyTheme(isDark ? "light" : "dark")
}
return (
<Button
data-action="theme-toggle"
variant="ghost"
size="icon-sm"
aria-label="Toggle theme"
onClick={toggle}
>
<Sun className="hidden dark:block" />
<Moon className="block dark:hidden" />
</Button>
)
}

View File

@@ -0,0 +1,155 @@
// Run a saved script from public/scripts/ or paste DSL ad-hoc.
// Triggered by the Play icon in the appbar or Cmd/Ctrl+Shift+P.
import { useEffect, useState } from "react"
import { Play } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { runScript, runScriptText } from "@crema/action-bus"
const KNOWN_SCRIPTS = [
{ name: "demo-tour", description: "Tour the rail" },
{ name: "demo-search", description: "Focus and fill search" },
]
export function useScriptsHotkey(open: () => void) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (mod && e.shiftKey && (e.key === "p" || e.key === "P")) {
e.preventDefault()
open()
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open])
}
export function ScriptsDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (v: boolean) => void
}) {
const [text, setText] = useState("")
const [status, setStatus] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const runByName = async (name: string) => {
setBusy(true)
setStatus(`Running ${name}`)
try {
onOpenChange(false)
await runScript(name)
setStatus(`Ran ${name}.`)
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy(false)
}
}
const runText = async () => {
if (!text.trim()) return
setBusy(true)
setStatus("Running…")
try {
onOpenChange(false)
await runScriptText(text)
setStatus("Done.")
} catch (e) {
setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Run a script</DialogTitle>
<DialogDescription>
Pick a saved script or paste DSL. Cmd/Ctrl + Shift + P toggles this.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">
Saved
</div>
<div className="flex flex-col gap-1">
{KNOWN_SCRIPTS.map((s) => (
<button
key={s.name}
type="button"
data-action={`run-script-${s.name}`}
onClick={() => runByName(s.name)}
disabled={busy}
className="flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors hover:bg-accent disabled:opacity-50"
>
<span>
<span className="font-mono text-xs">{s.name}</span>
<span className="ml-2 text-muted-foreground">
{s.description}
</span>
</span>
<Play className="size-4 text-muted-foreground" />
</button>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">
Paste DSL
</div>
<textarea
data-action="scripts-dsl"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={"navigate /resources\nclick nav-resources"}
spellCheck={false}
rows={6}
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
/>
</div>
{status && (
<div className="rounded-md border bg-muted px-3 py-2 text-xs text-muted-foreground">
{status}
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button
data-action="scripts-cancel"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Close
</Button>
<Button
data-action="scripts-run"
onClick={runText}
disabled={busy || !text.trim()}
>
<Play className="size-4" /> Run
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,72 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "~/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-fast supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-fast outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@@ -0,0 +1,22 @@
import { cn } from "~/lib/utils"
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<"div"> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
"--ratio": ratio,
} as React.CSSProperties
}
className={cn("relative aspect-(--ratio)", className)}
{...props}
/>
)
}
export { AspectRatio }

View File

@@ -0,0 +1,107 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "~/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,87 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { Separator } from "~/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
app/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "~/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,48 @@
import { cva, type VariantProps } from "class-variance-authority"
import type { ComponentProps } from "react"
import { cn } from "~/lib/utils"
const chipVariants = cva(
"inline-flex shrink-0 items-center gap-1 rounded-full border text-xs font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:border-transparent [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
default:
"border-border bg-background text-foreground hover:bg-muted aria-pressed:bg-primary aria-pressed:text-primary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-pressed:bg-primary aria-pressed:text-primary-foreground",
},
size: {
default: "h-7 px-3",
sm: "h-6 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Chip({
className,
variant = "default",
size = "default",
active,
type = "button",
...props
}: ComponentProps<"button"> &
VariantProps<typeof chipVariants> & { active?: boolean }) {
return (
<button
data-slot="chip"
type={type}
aria-pressed={active}
className={cn(chipVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Chip, chipVariants }

View File

@@ -0,0 +1,165 @@
import { Check, Copy } from "lucide-react"
import { useState, type ComponentProps } from "react"
import { cn } from "~/lib/utils"
function CodeBlock({
className,
children,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="code-block"
className={cn(
"overflow-hidden rounded-lg border bg-muted/30 font-mono text-sm",
className
)}
{...props}
>
{children}
</div>
)
}
function CodeBlockHeader({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="code-block-header"
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-3 py-1.5",
className
)}
{...props}
/>
)
}
function CodeBlockLang({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-lang"
className={cn(
"text-xs font-medium tracking-wide text-muted-foreground uppercase",
className
)}
{...props}
/>
)
}
function CodeBlockCopyButton({
className,
value,
...props
}: Omit<ComponentProps<"button">, "onClick" | "children"> & { value: string }) {
const [copied, setCopied] = useState(false)
function handleCopy() {
navigator.clipboard.writeText(value).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<button
data-slot="code-block-copy"
type="button"
onClick={handleCopy}
className={cn(
"inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
"[&_svg]:size-3",
className
)}
{...props}
>
{copied ? <Check /> : <Copy />}
{copied ? "Copied" : "Copy"}
</button>
)
}
function CodeBlockContent({ className, ...props }: ComponentProps<"pre">) {
return (
<pre
data-slot="code-block-content"
className={cn("overflow-x-auto p-4 text-sm leading-relaxed", className)}
{...props}
/>
)
}
function CodeBlockKeyword({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-keyword"
className={cn("text-syntax-keyword", className)}
{...props}
/>
)
}
function CodeBlockString({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-string"
className={cn("text-syntax-string", className)}
{...props}
/>
)
}
function CodeBlockNumber({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-number"
className={cn("text-syntax-number", className)}
{...props}
/>
)
}
function CodeBlockComment({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-comment"
className={cn("text-syntax-comment italic", className)}
{...props}
/>
)
}
function CodeBlockFunction({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-function"
className={cn("text-syntax-function", className)}
{...props}
/>
)
}
function CodeBlockType({ className, ...props }: ComponentProps<"span">) {
return (
<span
data-slot="code-block-type"
className={cn("text-syntax-type", className)}
{...props}
/>
)
}
export {
CodeBlock,
CodeBlockHeader,
CodeBlockLang,
CodeBlockCopyButton,
CodeBlockContent,
CodeBlockKeyword,
CodeBlockString,
CodeBlockNumber,
CodeBlockComment,
CodeBlockFunction,
CodeBlockType,
}

View File

@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,295 @@
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "~/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-fast data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -0,0 +1,271 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-fast outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-fast supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-fast outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,4 @@
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider"

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-fast outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-fast data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

104
app/components/ui/empty.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

236
app/components/ui/field.tsx Normal file
View File

@@ -0,0 +1,236 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { Label } from "~/components/ui/label"
import { Separator } from "~/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -0,0 +1,51 @@
"use client"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "~/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-fast data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "~/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

201
app/components/ui/item.tsx Normal file
View File

@@ -0,0 +1,201 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { Separator } from "~/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-fast outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"font-heading line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

26
app/components/ui/kbd.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { cn } from "~/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "~/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "~/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-fast data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn("min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-fast data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -0,0 +1,61 @@
import * as React from "react"
import { cn } from "~/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }

View File

@@ -0,0 +1,168 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-slow group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-slow data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-fast data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
)
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Icon>
)
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
}

View File

@@ -0,0 +1,130 @@
import * as React from "react"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "~/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-fast data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-heading font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "~/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,36 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "~/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "~/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-fast data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "~/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

138
app/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-fast data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-base ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,164 @@
import {
useCallback,
useEffect,
useRef,
type ComponentProps,
} from "react"
import { Button } from "~/components/ui/button"
import { cn } from "~/lib/utils"
type SignaturePadProps = Omit<ComponentProps<"div">, "onChange"> & {
width?: number
height?: number
strokeColor?: string
strokeWidth?: number
disabled?: boolean
onChange?: (dataUrl: string | null) => void
onClear?: () => void
}
function SignaturePad({
width = 400,
height = 160,
strokeColor,
strokeWidth = 2,
disabled = false,
onChange,
onClear,
className,
...props
}: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const drawing = useRef(false)
const empty = useRef(true)
const getCtx = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return null
const ctx = canvas.getContext("2d")
if (!ctx) return null
ctx.strokeStyle = strokeColor ?? "currentColor"
ctx.lineWidth = strokeWidth
ctx.lineCap = "round"
ctx.lineJoin = "round"
return ctx
}, [strokeColor, strokeWidth])
const getPos = (e: MouseEvent | TouchEvent, canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const source = "touches" in e ? e.touches[0] : e
return {
x: (source.clientX - rect.left) * scaleX,
y: (source.clientY - rect.top) * scaleY,
}
}
const startDraw = useCallback(
(e: MouseEvent | TouchEvent) => {
if (disabled) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = getCtx()
if (!ctx) return
drawing.current = true
const { x, y } = getPos(e, canvas)
ctx.beginPath()
ctx.moveTo(x, y)
e.preventDefault()
},
[disabled, getCtx]
)
const draw = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!drawing.current || disabled) return
const canvas = canvasRef.current
if (!canvas) return
const ctx = getCtx()
if (!ctx) return
const { x, y } = getPos(e, canvas)
ctx.lineTo(x, y)
ctx.stroke()
empty.current = false
e.preventDefault()
},
[disabled, getCtx]
)
const endDraw = useCallback(() => {
if (!drawing.current) return
drawing.current = false
const canvas = canvasRef.current
if (!canvas) return
onChange?.(empty.current ? null : canvas.toDataURL())
}, [onChange])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.addEventListener("mousedown", startDraw)
canvas.addEventListener("mousemove", draw)
canvas.addEventListener("mouseup", endDraw)
canvas.addEventListener("mouseleave", endDraw)
canvas.addEventListener("touchstart", startDraw, { passive: false })
canvas.addEventListener("touchmove", draw, { passive: false })
canvas.addEventListener("touchend", endDraw)
return () => {
canvas.removeEventListener("mousedown", startDraw)
canvas.removeEventListener("mousemove", draw)
canvas.removeEventListener("mouseup", endDraw)
canvas.removeEventListener("mouseleave", endDraw)
canvas.removeEventListener("touchstart", startDraw)
canvas.removeEventListener("touchmove", draw)
canvas.removeEventListener("touchend", endDraw)
}
}, [startDraw, draw, endDraw])
const clear = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
ctx?.clearRect(0, 0, canvas.width, canvas.height)
empty.current = true
onChange?.(null)
onClear?.()
}
return (
<div
data-slot="signature-pad"
data-disabled={disabled || undefined}
className={cn(
"inline-flex flex-col overflow-hidden rounded-lg border bg-background data-[disabled]:opacity-50",
className
)}
{...props}
>
<canvas
ref={canvasRef}
width={width}
height={height}
className="block touch-none bg-card"
style={{ width, height }}
/>
<div className="flex items-center justify-between border-t bg-muted/30 px-3 py-1.5">
<span className="text-xs text-muted-foreground">Sign above</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={clear}
disabled={disabled}
>
Clear
</Button>
</div>
</div>
)
}
export { SignaturePad }
export type { SignaturePadProps }

View File

@@ -0,0 +1,13 @@
import { cn } from "~/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,52 @@
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import { cn } from "~/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: SliderPrimitive.Root.Props) {
const _values = Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max]
return (
<SliderPrimitive.Root
className={cn("data-horizontal:w-full data-vertical:h-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted select-none data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -0,0 +1,10 @@
import { cn } from "~/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View File

@@ -0,0 +1,30 @@
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "~/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
app/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "~/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,87 @@
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { toggleVariants } from "~/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "~/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

15
app/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,15 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

153
app/lib/agents.ts Normal file
View File

@@ -0,0 +1,153 @@
// Agent personas — named, role-scoped sub-system prompts.
// Each persona stacks on top of the main systemPrompt to specialize the
// assistant for a task. Persisted in localStorage; reactive across tabs.
import { useEffect, useSyncExternalStore } from "react"
export type Agent = {
id: string
name: string
role: string
prompt: string
}
export const DEFAULT_AGENTS: Agent[] = [
{
id: "generalist",
name: "Atlas",
role: "Generalist",
prompt:
"You handle anything: chat, planning, summaries, casual questions. Match the user's tone. Keep replies as long as the task deserves — terse for quick questions, detailed when explaining.",
},
{
id: "coder",
name: "Forge",
role: "Software engineer",
prompt:
"You are a senior software engineer. Write idiomatic, well-typed code. Prefer concrete examples over abstract advice. When asked to fix a bug, identify root cause before patching. Use markdown code blocks with language tags. Mention edge cases briefly when relevant.",
},
{
id: "writer",
name: "Inkwell",
role: "Writer",
prompt:
"You are a prose writer. Produce vivid, well-paced text — short stories, copy, emails, essays. Vary sentence length. Show, don't tell. When the user asks for a draft, deliver the draft, not a description of it.",
},
{
id: "researcher",
name: "Pilot",
role: "Researcher",
prompt:
"You are a careful researcher. Structure answers as: claim → evidence → caveat. Distinguish what is well-established from what is uncertain. Refuse to fabricate citations — if you don't know, say so.",
},
{
id: "ui-driver",
name: "Cursor",
role: "UI Operator",
prompt:
"You specialize in driving this app's UI on the user's behalf. Prefer doing over explaining. When the user asks for an action, emit an action block immediately. When they ask a question about the app, answer concisely and offer to do it.",
},
]
const STORAGE_KEY = "crema.agents"
const ACTIVE_KEY = "crema.assistant.activeAgent"
const CHANGE_EVENT = "crema:agents-change"
function isAgent(v: unknown): v is Agent {
return (
!!v &&
typeof v === "object" &&
typeof (v as Agent).id === "string" &&
typeof (v as Agent).name === "string" &&
typeof (v as Agent).role === "string" &&
typeof (v as Agent).prompt === "string"
)
}
function readFromStorage(): Agent[] {
if (typeof window === "undefined") return DEFAULT_AGENTS
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_AGENTS
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return DEFAULT_AGENTS
const cleaned = parsed.filter(isAgent)
return cleaned.length > 0 ? cleaned : DEFAULT_AGENTS
} catch {
return DEFAULT_AGENTS
}
}
export function loadAgents(): Agent[] {
return readFromStorage()
}
export function saveAgents(next: Agent[]) {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function resetAgents() {
saveAgents(DEFAULT_AGENTS)
}
let cached: Agent[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Agent[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Agent[] {
return DEFAULT_AGENTS
}
export function useAgents(): Agent[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function loadActiveAgentId(): string {
if (typeof window === "undefined") return DEFAULT_AGENTS[0].id
try {
return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id
} catch {
return DEFAULT_AGENTS[0].id
}
}
export function saveActiveAgentId(id: string) {
if (typeof window === "undefined") return
localStorage.setItem(ACTIVE_KEY, id)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function composeSystemPrompt(
base: string,
agent: Agent | undefined,
): string {
if (!agent) return base
return `${base}\n\nActive persona: ${agent.name}${agent.role}\n${agent.prompt}`
}
export function newAgentId(): string {
return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}

83
app/lib/api.ts Normal file
View File

@@ -0,0 +1,83 @@
// API — typed fetch wrapper. Auto-injects the session token, throws on
// non-2xx with a parsed error, and supports AbortSignal for cancellation.
//
// Replace `apiBaseURL` with your backend root. The Resources route shows the
// typical usage pattern.
import { loadSession, signOut } from "~/lib/session"
export const apiBaseURL = "/api"
export class ApiError extends Error {
status: number
body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = "ApiError"
this.status = status
this.body = body
}
}
export type ApiInit = Omit<RequestInit, "body" | "method"> & {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
body?: unknown
}
export async function apiFetch<T = unknown>(
path: string,
init: ApiInit = {},
): Promise<T> {
const session = loadSession()
const headers = new Headers(init.headers)
if (session?.token) headers.set("Authorization", `Bearer ${session.token}`)
if (init.body !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const url = path.startsWith("http") ? path : `${apiBaseURL}${path}`
const res = await fetch(url, {
...init,
method: init.method ?? "GET",
headers,
body:
init.body === undefined
? undefined
: typeof init.body === "string"
? init.body
: JSON.stringify(init.body),
})
if (res.status === 401) {
// Token rejected — clear session so the shell bounces to /login.
signOut()
}
const ct = res.headers.get("Content-Type") ?? ""
const parsed = ct.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null)
if (!res.ok) {
const message =
(parsed && typeof parsed === "object" && "message" in parsed
? String((parsed as { message: unknown }).message)
: null) ?? `${res.status} ${res.statusText}`
throw new ApiError(message, res.status, parsed)
}
return parsed as T
}
/** Convenience helpers. */
export const api = {
get: <T = unknown>(path: string, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "GET" }),
post: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "POST", body }),
put: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "PUT", body }),
patch: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "PATCH", body }),
del: <T = unknown>(path: string, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "DELETE" }),
}

40
app/lib/identity.ts Normal file
View File

@@ -0,0 +1,40 @@
// Project identity — brand and user. Hooks return module-singleton defaults
// so routes don't have to thread props. Swap the constants below for your
// project's brand; swap useUser() for a real session hook when you wire auth.
import { Shield, type LucideIcon } from "lucide-react"
export type Brand = {
name: string
icon: LucideIcon
}
export type User = {
name: string
email: string
initials: string
}
const brand: Brand = {
name: "Arcadia Admin",
icon: Shield,
}
const currentUser: User = {
name: "Signed-in user",
email: "user@example.com",
initials: "U",
}
export function useBrand(): Brand {
return brand
}
export function useUser(): User {
return currentUser
}
/** Convenience for non-React modules (page meta, scripts, etc). */
export function getBrand(): Brand {
return brand
}

125
app/lib/library.ts Normal file
View File

@@ -0,0 +1,125 @@
// Library — saved artifacts. Today: conversation snapshots.
// Tomorrow: snippets, prompts, generated documents.
import { useEffect, useSyncExternalStore } from "react"
export type LibraryItem = {
id: string
kind: "conversation" | "snippet"
title: string
// Free-form body. For "conversation": markdown transcript. For "snippet": text.
content: string
tags: string[]
// Optional metadata.
agentName?: string
agentRole?: string
threadId?: string
messageCount?: number
createdAt: number
}
const STORAGE_KEY = "crema.library"
const CHANGE_EVENT = "crema:library-change"
const MAX_BYTES = 1_500_000
export function newLibraryId(): string {
return `lib-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function isLibraryItem(v: unknown): v is LibraryItem {
if (!v || typeof v !== "object") return false
const x = v as LibraryItem
return (
typeof x.id === "string" &&
(x.kind === "conversation" || x.kind === "snippet") &&
typeof x.title === "string" &&
typeof x.content === "string" &&
Array.isArray(x.tags) &&
typeof x.createdAt === "number"
)
}
function readFromStorage(): LibraryItem[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isLibraryItem)
} catch {
return []
}
}
function writeToStorage(items: LibraryItem[]) {
if (typeof window === "undefined") return
let trimmed = items
let serialized = JSON.stringify(trimmed)
while (serialized.length > MAX_BYTES && trimmed.length > 1) {
trimmed = trimmed.slice(0, -1)
serialized = JSON.stringify(trimmed)
}
try {
localStorage.setItem(STORAGE_KEY, serialized)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota — bail */
}
}
export function loadLibrary(): LibraryItem[] {
return readFromStorage()
}
export function addLibraryItem(item: Omit<LibraryItem, "id" | "createdAt">): LibraryItem {
const next: LibraryItem = {
...item,
id: newLibraryId(),
createdAt: Date.now(),
}
const items = readFromStorage()
writeToStorage([next, ...items])
return next
}
export function deleteLibraryItem(id: string) {
const items = readFromStorage().filter((x) => x.id !== id)
writeToStorage(items)
}
export function updateLibraryItem(id: string, patch: Partial<LibraryItem>) {
const items = readFromStorage().map((x) =>
x.id === id ? { ...x, ...patch } : x,
)
writeToStorage(items)
}
let cached: LibraryItem[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): LibraryItem[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): LibraryItem[] {
return []
}
export function useLibrary(): LibraryItem[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}

99
app/lib/llm-settings.ts Normal file
View File

@@ -0,0 +1,99 @@
// Persisted LLM settings — base URL, context budget, response cap.
// Reactive across tabs (storage event) and within the same tab (custom event).
import { useEffect, useSyncExternalStore } from "react"
export type LLMSettings = {
baseURL: string
contextTokens: number
responseBudget: number
systemPrompt: string
}
export const DEFAULT_SYSTEM_PROMPT =
"You are a helpful general-purpose assistant embedded in an app. Handle any request the user makes — writing, brainstorming, code, analysis, casual chat — at the length the task deserves. Use markdown when it helps. You can also drive the UI when the user toggles UI Control on."
export const DEFAULT_SETTINGS: LLMSettings = {
baseURL: "http://localhost:1234/v1",
contextTokens: 9000,
responseBudget: 512,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
}
const STORAGE_KEY = "crema.llm.settings"
const CHANGE_EVENT = "comfy:llm-settings-change"
function readFromStorage(): LLMSettings {
if (typeof window === "undefined") return DEFAULT_SETTINGS
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_SETTINGS
const parsed = JSON.parse(raw) as Partial<LLMSettings>
return {
baseURL: typeof parsed.baseURL === "string" ? parsed.baseURL : DEFAULT_SETTINGS.baseURL,
contextTokens:
Number.isFinite(parsed.contextTokens) && (parsed.contextTokens as number) > 0
? (parsed.contextTokens as number)
: DEFAULT_SETTINGS.contextTokens,
responseBudget:
Number.isFinite(parsed.responseBudget) && (parsed.responseBudget as number) > 0
? (parsed.responseBudget as number)
: DEFAULT_SETTINGS.responseBudget,
systemPrompt:
typeof parsed.systemPrompt === "string" && parsed.systemPrompt.trim().length > 0
? parsed.systemPrompt
: DEFAULT_SETTINGS.systemPrompt,
}
} catch {
return DEFAULT_SETTINGS
}
}
export function loadLLMSettings(): LLMSettings {
return readFromStorage()
}
export function saveLLMSettings(next: LLMSettings) {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function resetLLMSettings() {
saveLLMSettings(DEFAULT_SETTINGS)
}
let cached: LLMSettings | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): LLMSettings {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): LLMSettings {
return DEFAULT_SETTINGS
}
export function useLLMSettings(): LLMSettings {
// useSyncExternalStore avoids hydration flicker and stays reactive.
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
// Re-read after mount to pick up localStorage on first client render.
useEffect(() => {
cached = null
}, [])
return value
}

155
app/lib/notifications.ts Normal file
View File

@@ -0,0 +1,155 @@
// Notifications — small reactive store for in-app toasts/inbox items.
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
// this store is for the appbar bell's persistent inbox.
import { useEffect, useSyncExternalStore } from "react"
export type NotificationKind = "info" | "success" | "warning" | "error"
export type AppNotification = {
id: string
kind: NotificationKind
title: string
body?: string
// Optional href to open when the row is clicked.
href?: string
createdAt: number
readAt?: number
}
const STORAGE_KEY = "crema.notifications"
const CHANGE_EVENT = "crema:notifications-change"
const MAX_ITEMS = 200
function newId(): string {
return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function readFromStorage(): AppNotification[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(n): n is AppNotification =>
n &&
typeof n.id === "string" &&
typeof n.title === "string" &&
typeof n.createdAt === "number" &&
["info", "success", "warning", "error"].includes(n.kind),
)
} catch {
return []
}
}
function writeToStorage(items: AppNotification[]) {
if (typeof window === "undefined") return
const trimmed = items.slice(0, MAX_ITEMS)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota — drop silently */
}
}
export function loadNotifications(): AppNotification[] {
return readFromStorage()
}
export function addNotification(
n: Omit<AppNotification, "id" | "createdAt">,
): AppNotification {
const next: AppNotification = {
...n,
id: newId(),
createdAt: Date.now(),
}
writeToStorage([next, ...readFromStorage()])
return next
}
export function markRead(id: string) {
const items = readFromStorage().map((n) =>
n.id === id ? { ...n, readAt: Date.now() } : n,
)
writeToStorage(items)
}
export function markAllRead() {
const now = Date.now()
const items = readFromStorage().map((n) =>
n.readAt ? n : { ...n, readAt: now },
)
writeToStorage(items)
}
export function dismiss(id: string) {
writeToStorage(readFromStorage().filter((n) => n.id !== id))
}
export function dismissAll() {
writeToStorage([])
}
let cached: AppNotification[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): AppNotification[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): AppNotification[] {
return []
}
export function useNotifications(): AppNotification[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length
}
/** Seed a few demo notifications on first load so the bell isn't empty. */
export function seedIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: AppNotification[] = [
{
id: newId(),
kind: "info",
title: "Welcome",
body: "Tag elements with data-action and the assistant can drive them.",
href: "/assistant",
createdAt: now - 60_000,
},
{
id: newId(),
kind: "success",
title: "Profile saved",
body: "Your display name and avatar are live across the app.",
href: "/profile",
createdAt: now - 5 * 60_000,
},
]
writeToStorage(seed)
}

6
app/lib/page-meta.ts Normal file
View File

@@ -0,0 +1,6 @@
import { getBrand } from "./identity"
/** Build a route's <title> as `${brand.name} · ${suffix}`. */
export function pageTitle(suffix: string): { title: string }[] {
return [{ title: `${getBrand().name} · ${suffix}` }]
}

115
app/lib/profile.ts Normal file
View File

@@ -0,0 +1,115 @@
// User profile — name, email, title, bio, signature, default agent.
// Persisted in localStorage; reactive across tabs.
import { useEffect, useSyncExternalStore } from "react"
export type Profile = {
name: string
email: string
title: string
bio: string
signature: string
avatarUrl: string
defaultAgentId: string
}
export const DEFAULT_PROFILE: Profile = {
name: "Signed-in user",
email: "user@example.com",
title: "",
bio: "",
signature: "",
avatarUrl: "",
defaultAgentId: "",
}
const STORAGE_KEY = "crema.profile"
const CHANGE_EVENT = "crema:profile-change"
function readFromStorage(): Profile {
if (typeof window === "undefined") return DEFAULT_PROFILE
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_PROFILE
const parsed = JSON.parse(raw) as Partial<Profile>
return {
name:
typeof parsed.name === "string" && parsed.name.trim().length > 0
? parsed.name
: DEFAULT_PROFILE.name,
email:
typeof parsed.email === "string" ? parsed.email : DEFAULT_PROFILE.email,
title:
typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title,
bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio,
signature:
typeof parsed.signature === "string"
? parsed.signature
: DEFAULT_PROFILE.signature,
avatarUrl:
typeof parsed.avatarUrl === "string"
? parsed.avatarUrl
: DEFAULT_PROFILE.avatarUrl,
defaultAgentId:
typeof parsed.defaultAgentId === "string"
? parsed.defaultAgentId
: DEFAULT_PROFILE.defaultAgentId,
}
} catch {
return DEFAULT_PROFILE
}
}
export function loadProfile(): Profile {
return readFromStorage()
}
export function saveProfile(next: Profile) {
if (typeof window === "undefined") return
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
export function resetProfile() {
saveProfile(DEFAULT_PROFILE)
}
export function profileInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return "?"
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}
let cached: Profile | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Profile {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Profile {
return DEFAULT_PROFILE
}
export function useProfile(): Profile {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}

32
app/lib/resources.test.ts Normal file
View File

@@ -0,0 +1,32 @@
import { describe, expect, it, beforeEach } from "vitest"
import {
createResource,
deleteResource,
listResources,
updateResource,
} from "./resources"
describe("resources", () => {
beforeEach(() => {
localStorage.clear()
})
it("creates, updates, and deletes", () => {
expect(listResources()).toEqual([])
const r = createResource({ name: "Test", owner: "Atlas" })
expect(r.status).toBe("active")
expect(listResources()).toHaveLength(1)
const updated = updateResource(r.id, { status: "paused" })
expect(updated?.status).toBe("paused")
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
deleteResource(r.id)
expect(listResources()).toEqual([])
})
it("ignores updates for unknown ids", () => {
expect(updateResource("missing", { name: "x" })).toBeNull()
})
})

157
app/lib/resources.ts Normal file
View File

@@ -0,0 +1,157 @@
// Resource store — example domain entity.
// Backed by localStorage today, but written so each call is a single function
// you can swap with `api.get/post/put/del` once you have a real backend.
import { useEffect, useSyncExternalStore } from "react"
export type Resource = {
id: string
name: string
status: "active" | "paused" | "archived"
owner: string
createdAt: number
updatedAt: number
}
const STORAGE_KEY = "crema.resources"
const CHANGE_EVENT = "crema:resources-change"
function newId() {
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function readFromStorage(): Resource[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(r): r is Resource =>
r &&
typeof r.id === "string" &&
typeof r.name === "string" &&
["active", "paused", "archived"].includes(r.status) &&
typeof r.owner === "string" &&
typeof r.createdAt === "number" &&
typeof r.updatedAt === "number",
)
} catch {
return []
}
}
function write(items: Resource[]) {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota */
}
}
// CRUD — these mirror what `api.get/post/put/del` would look like.
export function listResources(): Resource[] {
return readFromStorage()
}
export function createResource(input: {
name: string
owner: string
status?: Resource["status"]
}): Resource {
const now = Date.now()
const r: Resource = {
id: newId(),
name: input.name,
owner: input.owner,
status: input.status ?? "active",
createdAt: now,
updatedAt: now,
}
write([r, ...readFromStorage()])
return r
}
export function updateResource(
id: string,
patch: Partial<Omit<Resource, "id" | "createdAt">>,
): Resource | null {
const items = readFromStorage()
let updated: Resource | null = null
const next = items.map((r) => {
if (r.id !== id) return r
updated = { ...r, ...patch, updatedAt: Date.now() }
return updated
})
if (updated) write(next)
return updated
}
export function deleteResource(id: string) {
write(readFromStorage().filter((r) => r.id !== id))
}
let cached: Resource[] | null = null
function subscribe(cb: () => void) {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Resource[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Resource[] {
return []
}
export function useResources(): Resource[] {
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return v
}
/** Seed a few rows on first load so the table isn't empty. */
export function seedResourcesIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: Resource[] = [
{
id: newId(),
name: "Acme dashboard",
status: "active",
owner: "Atlas",
createdAt: now - 86_400_000 * 3,
updatedAt: now - 3600_000,
},
{
id: newId(),
name: "Onboarding pipeline",
status: "paused",
owner: "Forge",
createdAt: now - 86_400_000 * 7,
updatedAt: now - 86_400_000,
},
{
id: newId(),
name: "Q1 report draft",
status: "archived",
owner: "Inkwell",
createdAt: now - 86_400_000 * 30,
updatedAt: now - 86_400_000 * 14,
},
]
write(seed)
}

31
app/lib/session.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, expect, it, beforeEach } from "vitest"
import { hasSession, loadSession, signIn, signOut } from "./session"
describe("session", () => {
beforeEach(() => {
localStorage.clear()
})
it("starts unauthenticated", () => {
expect(loadSession()).toBeNull()
expect(hasSession()).toBe(false)
})
it("rejects empty credentials", async () => {
await expect(signIn("", "")).rejects.toThrow(/required/i)
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
expect(hasSession()).toBe(false)
})
it("creates a session on sign-in and clears on sign-out", async () => {
const session = await signIn("alice@example.com", "hunter2")
expect(session.email).toBe("alice@example.com")
expect(session.token).toMatch(/^dev-/)
expect(hasSession()).toBe(true)
signOut()
expect(loadSession()).toBeNull()
expect(hasSession()).toBe(false)
})
})

160
app/lib/session.ts Normal file
View File

@@ -0,0 +1,160 @@
// Session — minimal auth scaffold backed by localStorage.
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
// wire a backend. The shape here matches what AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
export type Session = {
userId: string
name: string
email: string
token: string
// Issued at, ms since epoch.
issuedAt: number
}
const STORAGE_KEY = "crema.session"
const CHANGE_EVENT = "crema:session-change"
function readFromStorage(): Session | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<Session>
if (
typeof parsed.userId !== "string" ||
typeof parsed.email !== "string" ||
typeof parsed.token !== "string"
)
return null
return {
userId: parsed.userId,
name:
typeof parsed.name === "string" && parsed.name.trim()
? parsed.name
: parsed.email,
email: parsed.email,
token: parsed.token,
issuedAt:
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
}
} catch {
return null
}
}
export function loadSession(): Session | null {
return readFromStorage()
}
/**
* Mock sign-in. Validates only that email + password are non-empty; returns
* a fake session. Replace with a real fetch to your auth endpoint.
*/
export async function signIn(
email: string,
password: string,
): Promise<Session> {
await new Promise((r) => setTimeout(r, 250))
if (!email.trim() || !password.trim()) {
throw new Error("Email and password are required.")
}
if (!email.includes("@")) {
throw new Error("Enter a valid email address.")
}
const session: Session = {
userId: `u-${Date.now().toString(36)}`,
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
email,
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
issuedAt: Date.now(),
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
return session
}
export function signOut() {
if (typeof window === "undefined") return
localStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem("arcadia_access_token")
sessionStorage.removeItem("arcadia_refresh_token")
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
/** Bridge: persist a Session record from a successful arcadia login.
* Stores the JWT in sessionStorage (where ArcadiaProvider's getToken reads
* it) and writes the user-shaped Session into localStorage so the existing
* AppShell / useUser machinery keeps working unchanged. */
export function persistFromArcadiaLogin(
tokens: { access_token: string; refresh_token?: string },
user?: { id: string; email: string; full_name?: string; first_name?: string; last_name?: string } | null,
): Session {
const name =
user?.full_name ||
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
user?.email ||
"Signed-in user"
const session: Session = {
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
name,
email: user?.email ?? "",
token: tokens.access_token,
issuedAt: Date.now(),
}
if (typeof window !== "undefined") {
sessionStorage.setItem("arcadia_access_token", tokens.access_token)
if (tokens.refresh_token) sessionStorage.setItem("arcadia_refresh_token", tokens.refresh_token)
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
return session
}
/** True if a non-expired session is in storage. */
export function hasSession(): boolean {
return !!readFromStorage()
}
let cached: Session | null = null
let cacheValid = false
function subscribe(cb: () => void): () => void {
const onChange = () => {
cacheValid = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Session | null {
if (!cacheValid) {
cached = readFromStorage()
cacheValid = true
}
return cached
}
function getServerSnapshot(): Session | null {
return null
}
export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
}
export function sessionInitials(session: Session | null): string {
if (!session) return "?"
return profileInitials(session.name || session.email)
}

222
app/lib/threads.ts Normal file
View File

@@ -0,0 +1,222 @@
// Conversation threads — multiple named chats, each with its own history,
// active agent, and pinned message indices. Persisted in localStorage.
import { useEffect, useSyncExternalStore } from "react"
export type ThreadMessage = {
role: "user" | "assistant"
content: string
/** Persona that authored this assistant message (omitted for user msgs). */
agentId?: string
}
export type Thread = {
id: string
title: string
agentId: string
messages: ThreadMessage[]
pinned: number[] // indices into messages[]
createdAt: number
updatedAt: number
}
const THREADS_KEY = "crema.assistant.threads"
const ACTIVE_KEY = "crema.assistant.activeThreadId"
const SNAPSHOT_KEY_PREFIX = "crema.assistant.thread.snapshot."
const CHANGE_EVENT = "crema:threads-change"
const MAX_BYTES = 800_000
export function newThreadId(): string {
return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function isThread(v: unknown): v is Thread {
if (!v || typeof v !== "object") return false
const t = v as Thread
return (
typeof t.id === "string" &&
typeof t.title === "string" &&
typeof t.agentId === "string" &&
Array.isArray(t.messages) &&
Array.isArray(t.pinned) &&
typeof t.createdAt === "number" &&
typeof t.updatedAt === "number"
)
}
function readFromStorage(): Thread[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(THREADS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isThread)
} catch {
return []
}
}
function writeToStorage(threads: Thread[]) {
if (typeof window === "undefined") return
let serialized = JSON.stringify(threads)
// Trim oldest threads if quota gets tight.
let trimmed = threads
while (serialized.length > MAX_BYTES && trimmed.length > 1) {
trimmed = trimmed.slice(0, -1)
serialized = JSON.stringify(trimmed)
}
try {
localStorage.setItem(THREADS_KEY, serialized)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota — bail */
}
}
export function loadThreads(): Thread[] {
return readFromStorage()
}
export function saveThreads(threads: Thread[]) {
writeToStorage(threads)
}
export function loadActiveThreadId(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(ACTIVE_KEY)
}
export function saveActiveThreadId(id: string | null) {
if (typeof window === "undefined") return
if (id) localStorage.setItem(ACTIVE_KEY, id)
else localStorage.removeItem(ACTIVE_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
let cached: Thread[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === THREADS_KEY || e.key === ACTIVE_KEY) onChange()
})
return () => {
window.removeEventListener(CHANGE_EVENT, onChange)
}
}
function getSnapshot(): Thread[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Thread[] {
return []
}
export function useThreads(): Thread[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function ensureThread(
threads: Thread[],
fallbackAgentId: string,
): { threads: Thread[]; activeId: string } {
const stored = loadActiveThreadId()
if (stored && threads.some((t) => t.id === stored))
return { threads, activeId: stored }
if (threads.length > 0) {
saveActiveThreadId(threads[0].id)
return { threads, activeId: threads[0].id }
}
const id = newThreadId()
const now = Date.now()
const fresh: Thread = {
id,
title: "New conversation",
agentId: fallbackAgentId,
messages: [],
pinned: [],
createdAt: now,
updatedAt: now,
}
saveActiveThreadId(id)
saveThreads([fresh, ...threads])
return { threads: [fresh, ...threads], activeId: id }
}
export function updateThread(id: string, patch: Partial<Thread>) {
const threads = readFromStorage()
const next = threads.map((t) =>
t.id === id ? { ...t, ...patch, updatedAt: Date.now() } : t,
)
writeToStorage(next)
}
export function createThread(agentId: string, title = "New conversation"): Thread {
const threads = readFromStorage()
const id = newThreadId()
const now = Date.now()
const fresh: Thread = {
id,
title,
agentId,
messages: [],
pinned: [],
createdAt: now,
updatedAt: now,
}
writeToStorage([fresh, ...threads])
saveActiveThreadId(id)
return fresh
}
export function deleteThread(id: string) {
const threads = readFromStorage()
const next = threads.filter((t) => t.id !== id)
writeToStorage(next)
if (loadActiveThreadId() === id) {
saveActiveThreadId(next[0]?.id ?? null)
}
}
export function snapshotThread(id: string) {
const threads = readFromStorage()
const t = threads.find((x) => x.id === id)
if (!t) return
try {
localStorage.setItem(SNAPSHOT_KEY_PREFIX + id, JSON.stringify(t))
} catch {
/* quota */
}
}
export function loadThreadSnapshot(id: string): Thread | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(SNAPSHOT_KEY_PREFIX + id)
if (!raw) return null
const parsed = JSON.parse(raw)
return isThread(parsed) ? parsed : null
} catch {
return null
}
}
export function clearThreadSnapshot(id: string) {
if (typeof window === "undefined") return
localStorage.removeItem(SNAPSHOT_KEY_PREFIX + id)
}
export function deriveTitleFromFirstMessage(text: string): string {
const trimmed = text.trim().split(/\s+/).slice(0, 8).join(" ")
return trimmed.length > 60 ? trimmed.slice(0, 57) + "…" : trimmed || "New conversation"
}

6
app/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

99
app/root.tsx Normal file
View File

@@ -0,0 +1,99 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router"
import type { Route } from "./+types/root"
import "./app.css"
import { ToastProvider } from "@crema/notification-ui"
import { CommandBusProvider } from "@crema/action-bus"
import { ArcadiaProvider } from "@crema/arcadia-client"
// CREMA:PROVIDERS-IMPORTS
const ARCADIA_URL = import.meta.env.VITE_ARCADIA_URL ?? "http://localhost:4000"
const ARCADIA_TENANT = import.meta.env.VITE_ARCADIA_TENANT ?? "default"
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('crema-theme');if(!t)t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';if(t==='dark')document.documentElement.classList.add('dark');var f=localStorage.getItem('crema-font-scale');if(f&&/^(sm|md|lg|xl)$/.test(f))document.documentElement.dataset.fontScale=f;var b=localStorage.getItem('crema-bg');if(b&&/^(drift|static)$/.test(b)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.bg=b;});}var s=localStorage.getItem('crema-surface');if(s&&/^(snow|stone|sage|slate)$/.test(s)){document.addEventListener('DOMContentLoaded',function(){document.body.dataset.surface=s;});}}catch(e){}})();`,
}}
/>
</head>
<body suppressHydrationWarning>
<div data-slot="aurora-field" aria-hidden="true">
<div className="aurora-blob aurora-blob-1" />
<div className="aurora-blob aurora-blob-2" />
</div>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return (
/* CREMA:PROVIDERS-WRAP-OPEN */
<ToastProvider>
<ArcadiaProvider
baseUrl={ARCADIA_URL}
initialTenantId={ARCADIA_TENANT}
getToken={() => (typeof window === "undefined" ? null : sessionStorage.getItem("arcadia_access_token"))}
onUnauthorized={() => {
if (typeof window !== "undefined") {
sessionStorage.removeItem("arcadia_access_token")
sessionStorage.removeItem("arcadia_refresh_token")
}
}}
>
<CommandBusProvider>
<Outlet />
</CommandBusProvider>
</ArcadiaProvider>
</ToastProvider>
/* CREMA:PROVIDERS-WRAP-CLOSE */
)
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!"
let details = "An unexpected error occurred."
let stack: string | undefined
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message
stack = error.stack
}
return (
<main className="container mx-auto p-4 pt-16">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full overflow-x-auto p-4">
<code>{stack}</code>
</pre>
)}
</main>
)
}

14
app/routes.ts Normal file
View File

@@ -0,0 +1,14 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes"
export default [
index("routes/home.tsx"),
route("resources", "routes/resources.tsx"),
route("activity", "routes/activity.tsx"),
route("assistant", "routes/assistant.tsx"),
route("ai", "routes/ai.tsx"),
route("library", "routes/library.tsx"),
route("settings", "routes/settings.tsx"),
route("profile", "routes/profile.tsx"),
route("login", "routes/login.tsx"),
// CREMA:ROUTES
] satisfies RouteConfig

43
app/routes/activity.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { Activity } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Activity")
export default function ActivityRoute() {
return (
<AppShell title="Activity">
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Event stream, audit log, recent changes.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<Activity className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">No activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Once your app is doing things, this is where audit events,
webhook deliveries, and recent changes show up pair with{" "}
<code className="font-mono text-xs">@crema/log-ui</code>.
</p>
</div>
</div>
</CardContent>
</Card>
</AppShell>
)
}

1155
app/routes/ai.tsx Normal file

File diff suppressed because it is too large Load Diff

2091
app/routes/assistant.tsx Normal file

File diff suppressed because it is too large Load Diff

96
app/routes/home.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
import { Link } from "react-router"
import { AppShell } from "~/components/layout/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Overview")
const tiles = [
{
to: "/assistant",
icon: Sparkles,
title: "Assistant",
body: "AI-first surface — chat, suggestions, and full UI control.",
accent: true,
},
{
to: "/resources",
icon: Boxes,
title: "Resources",
body: "Traditional list + detail surface for managed entities.",
},
{
to: "/activity",
icon: Activity,
title: "Activity",
body: "Event stream and audit log.",
},
{
to: "/library",
icon: BookOpen,
title: "Library",
body: "Saved items, templates, reusable artifacts.",
},
]
export default function HomeRoute() {
return (
<AppShell title="Overview">
<Card>
<CardHeader>
<CardTitle>Welcome</CardTitle>
<CardDescription>
A hybrid traditional + AI-first scaffold. Use the rail to navigate;
the Assistant can drive the UI on your behalf try{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
P
</kbd>{" "}
for the script runner.
</CardDescription>
</CardHeader>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{tiles.map((t) => {
const Icon = t.icon
return (
<Link
key={t.to}
to={t.to}
data-action={`home-tile-${t.title.toLowerCase()}`}
className="group block"
>
<Card
className={[
"h-full transition-colors",
t.accent
? "border-primary/30 bg-primary/5 hover:border-primary/50"
: "hover:border-foreground/20",
].join(" ")}
>
<CardHeader>
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<CardTitle className="flex items-center gap-2">
{t.title}
<ArrowRight className="size-4 opacity-0 transition-opacity group-hover:opacity-100" />
</CardTitle>
<CardDescription>{t.body}</CardDescription>
</CardHeader>
</Card>
</Link>
)
})}
</div>
</AppShell>
)
}

205
app/routes/library.tsx Normal file
View File

@@ -0,0 +1,205 @@
import { useState } from "react"
import { BookOpen, Copy, Download, Trash2, MessagesSquare } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { pageTitle } from "~/lib/page-meta"
import {
deleteLibraryItem,
useLibrary,
type LibraryItem,
} from "~/lib/library"
export const meta = () => pageTitle("Library")
export default function LibraryRoute() {
const items = useLibrary()
const [query, setQuery] = useState("")
const [openId, setOpenId] = useState<string | null>(null)
const filtered = items.filter((it) => {
if (!query.trim()) return true
const q = query.toLowerCase()
return (
it.title.toLowerCase().includes(q) ||
it.content.toLowerCase().includes(q) ||
it.tags.some((t) => t.toLowerCase().includes(q))
)
})
const open = items.find((x) => x.id === openId) ?? null
return (
<AppShell title="Library">
<Card>
<CardHeader>
<CardTitle>Library</CardTitle>
<CardDescription>
Saved items and templates. Save a chat from the Assistant via the
menu "Save to Library".
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Input
data-action="library-search"
placeholder="Search saved items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{items.length === 0 ? (
<EmptyState />
) : (
<div className="grid gap-3 md:grid-cols-[18rem_1fr]">
<ul className="flex max-h-[60vh] flex-col gap-1 overflow-y-auto rounded-lg border bg-card/40 p-2">
{filtered.length === 0 && (
<li className="px-2 py-3 text-sm text-muted-foreground">
No matches.
</li>
)}
{filtered.map((it) => (
<li key={it.id}>
<button
type="button"
data-action={`library-open-${it.id}`}
onClick={() => setOpenId(it.id)}
className={
"flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors " +
(openId === it.id
? "bg-accent text-accent-foreground"
: "hover:bg-accent hover:text-accent-foreground")
}
>
<span className="mt-0.5 shrink-0">
{it.kind === "conversation" ? (
<MessagesSquare className="size-4 text-muted-foreground" />
) : (
<BookOpen className="size-4 text-muted-foreground" />
)}
</span>
<span className="flex min-w-0 flex-col">
<span className="line-clamp-1 text-sm font-medium">
{it.title}
</span>
<span className="line-clamp-1 text-[11px] text-muted-foreground">
{it.agentName ? `${it.agentName} · ` : ""}
{it.messageCount
? `${it.messageCount} msg · `
: ""}
{new Date(it.createdAt).toLocaleDateString()}
</span>
</span>
</button>
</li>
))}
</ul>
<div className="min-w-0">
{open ? <Detail item={open} /> : <PickAnItem />}
</div>
</div>
)}
</CardContent>
</Card>
</AppShell>
)
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<BookOpen className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">Library is empty</p>
<p className="mt-1 text-sm text-muted-foreground">
Save a conversation from the Assistant via the menu {" "}
<span className="font-medium">Save to Library</span>.
</p>
</div>
</div>
)
}
function PickAnItem() {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-muted-foreground/20 p-12 text-center text-sm text-muted-foreground">
Pick an item to view.
</div>
)
}
function Detail({ item }: { item: LibraryItem }) {
const copy = async () => {
try {
await navigator.clipboard.writeText(item.content)
} catch {
/* ignore */
}
}
const download = () => {
const blob = new Blob([item.content], {
type: "text/markdown;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
const slug = item.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60) || "item"
a.download = `${slug}.md`
a.click()
URL.revokeObjectURL(url)
}
const remove = () => {
if (window.confirm(`Delete "${item.title}"?`)) deleteLibraryItem(item.id)
}
return (
<div className="flex max-h-[60vh] flex-col rounded-lg border bg-card/40">
<div className="flex items-start gap-2 border-b px-3 py-2">
<div className="flex flex-1 flex-col">
<span className="font-medium">{item.title}</span>
<span className="text-xs text-muted-foreground">
{item.agentName ? `${item.agentName} · ` : ""}
{item.messageCount ? `${item.messageCount} msg · ` : ""}
{new Date(item.createdAt).toLocaleString()}
</span>
</div>
<Button
data-action={`library-copy-${item.id}`}
variant="ghost"
size="sm"
onClick={copy}
>
<Copy className="size-3.5" /> Copy
</Button>
<Button
data-action={`library-download-${item.id}`}
variant="ghost"
size="sm"
onClick={download}
>
<Download className="size-3.5" /> Download
</Button>
<Button
data-action={`library-delete-${item.id}`}
variant="ghost"
size="sm"
onClick={remove}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
<pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
{item.content}
</pre>
</div>
)
}

57
app/routes/login.tsx Normal file
View File

@@ -0,0 +1,57 @@
import { useEffect } from "react"
import { useNavigate, useSearchParams } from "react-router"
import { LoginForm } from "@crema/arcadia-auth-ui"
import { useBrand } from "~/lib/identity"
import { pageTitle } from "~/lib/page-meta"
import { useSession, persistFromArcadiaLogin } from "~/lib/session"
export const meta = () => pageTitle("Sign in")
export default function LoginRoute() {
const navigate = useNavigate()
const [params] = useSearchParams()
const session = useSession()
const brand = useBrand()
const BrandIcon = brand.icon
const next = params.get("next") || "/"
// Already signed in? Bounce.
useEffect(() => {
if (session) navigate(next, { replace: true })
}, [session, next, navigate])
return (
<div
className="relative isolate flex min-h-svh items-center justify-center p-4"
style={{ background: "var(--background)" }}
>
<LoginForm
brand={
<div className="flex items-center gap-2">
<span
className="flex size-8 items-center justify-center rounded-lg"
style={{ background: "var(--primary)", color: "var(--primary-foreground)" }}
>
<BrandIcon className="size-4" />
</span>
<span className="text-sm font-semibold">{brand.name}</span>
</div>
}
heading={`Sign in to ${brand.name}`}
subhead="Use your arcadia credentials. In dev seeds: admin@example.com / AdminP@ssw0rd."
onSuccess={async ({ tokens, user, twoFactorRequired, twoFactorChallenge }) => {
if (twoFactorRequired && twoFactorChallenge) {
navigate(`/login/2fa?challenge=${encodeURIComponent(twoFactorChallenge)}&next=${encodeURIComponent(next)}`)
return
}
persistFromArcadiaLogin(tokens, user)
navigate(next, { replace: true })
}}
onForgotPassword={() => navigate("/login/forgot")}
onSignup={() => navigate("/signup")}
/>
</div>
)
}

288
app/routes/profile.tsx Normal file
View File

@@ -0,0 +1,288 @@
import { useEffect, useState } from "react"
import { Check, Trash2 } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import { useAgents } from "~/lib/agents"
import { pageTitle } from "~/lib/page-meta"
import {
DEFAULT_PROFILE,
profileInitials,
resetProfile,
saveProfile,
useProfile,
type Profile,
} from "~/lib/profile"
export const meta = () => pageTitle("Profile")
export default function ProfileRoute() {
const profile = useProfile()
const agents = useAgents()
const [draft, setDraft] = useState<Profile>(profile)
const [savedAt, setSavedAt] = useState<number | null>(null)
useEffect(() => {
setDraft(profile)
}, [profile])
const dirty = JSON.stringify(draft) !== JSON.stringify(profile)
const initials = profileInitials(draft.name || DEFAULT_PROFILE.name)
const onPickAvatar = (file: File | null) => {
if (!file) {
setDraft((d) => ({ ...d, avatarUrl: "" }))
return
}
const reader = new FileReader()
reader.onload = () => {
const result = reader.result
if (typeof result === "string")
setDraft((d) => ({ ...d, avatarUrl: result }))
}
reader.readAsDataURL(file)
}
const save = () => {
saveProfile(draft)
setSavedAt(Date.now())
}
const defaultAgent =
agents.find((a) => a.id === draft.defaultAgentId) ?? null
return (
<AppShell title="Profile">
<Card>
<CardHeader>
<CardTitle>You</CardTitle>
<CardDescription>
Personal info shown across the app appbar avatar, signatures, and
anywhere the assistant references you.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-wrap items-center gap-4">
<Avatar className="size-20 ring-2 ring-primary/30">
{draft.avatarUrl ? (
<AvatarImage src={draft.avatarUrl} alt={draft.name} />
) : null}
<AvatarFallback className="bg-primary text-lg font-semibold text-primary-foreground">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<label className="inline-flex w-fit cursor-pointer items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground">
<input
data-action="profile-avatar-upload"
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => onPickAvatar(e.target.files?.[0] ?? null)}
/>
Upload avatar
</label>
{draft.avatarUrl && (
<Button
data-action="profile-avatar-remove"
variant="ghost"
size="sm"
onClick={() => onPickAvatar(null)}
className="w-fit text-muted-foreground"
>
<Trash2 className="size-3.5" /> Remove
</Button>
)}
<span className="text-xs text-muted-foreground">
PNG, JPG, or SVG. Stored locally as a data URL.
</span>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Name">
<Input
data-action="profile-name"
value={draft.name}
onChange={(e) =>
setDraft((d) => ({ ...d, name: e.target.value }))
}
autoComplete="name"
/>
</Field>
<Field label="Email">
<Input
data-action="profile-email"
type="email"
value={draft.email}
onChange={(e) =>
setDraft((d) => ({ ...d, email: e.target.value }))
}
autoComplete="email"
/>
</Field>
<Field label="Title" hint="Your role at work.">
<Input
data-action="profile-title"
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
placeholder="e.g. Product designer"
/>
</Field>
<Field
label="Default agent"
hint="Used as the active persona on first load."
>
<DropdownMenu>
<DropdownMenuTrigger
data-action="profile-default-agent"
className="inline-flex h-9 items-center justify-between gap-2 rounded-md border bg-background px-3 text-sm hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="truncate">
{defaultAgent ? (
<>
<span className="font-medium">{defaultAgent.name}</span>
<span className="text-muted-foreground">
{" "}
{defaultAgent.role}
</span>
</>
) : (
"Use first available"
)}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
onClick={() =>
setDraft((d) => ({ ...d, defaultAgentId: "" }))
}
data-state={!draft.defaultAgentId ? "checked" : undefined}
>
First available
</DropdownMenuItem>
{agents.map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() =>
setDraft((d) => ({ ...d, defaultAgentId: a.id }))
}
data-state={
draft.defaultAgentId === a.id ? "checked" : undefined
}
className="flex flex-col items-start"
>
<span className="font-medium">{a.name}</span>
<span className="text-xs text-muted-foreground">
{a.role}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</Field>
</div>
<Field
label="Bio"
hint="A short blurb the assistant can reference (e.g. 'I work mostly in TypeScript')."
>
<Textarea
data-action="profile-bio"
value={draft.bio}
onChange={(e) =>
setDraft((d) => ({ ...d, bio: e.target.value }))
}
rows={3}
placeholder="Tell the assistant about you."
/>
</Field>
<Field
label="Signature"
hint="Appended automatically when you ask the assistant to draft an email or note."
>
<Textarea
data-action="profile-signature"
value={draft.signature}
onChange={(e) =>
setDraft((d) => ({ ...d, signature: e.target.value }))
}
rows={3}
placeholder={`Cheers,\n${draft.name || "Your name"}`}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="profile-save"
onClick={save}
disabled={!dirty}
>
Save
</Button>
<Button
data-action="profile-revert"
variant="ghost"
onClick={() => setDraft(profile)}
disabled={!dirty}
>
Revert
</Button>
<Button
data-action="profile-reset"
variant="ghost"
onClick={() => {
resetProfile()
setSavedAt(Date.now())
}}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
<span className="inline-flex items-center gap-1 text-sm text-emerald-700 dark:text-emerald-400">
<Check className="size-4" /> Saved.
</span>
)}
</div>
</CardContent>
</Card>
</AppShell>
)
}
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{children}
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</label>
)
}

183
app/routes/resources.tsx Normal file
View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from "react"
import { Plus, Search, Trash2 } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import {
createResource,
deleteResource,
seedResourcesIfEmpty,
updateResource,
useResources,
type Resource,
} from "~/lib/resources"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Resources")
const statuses: Resource["status"][] = ["active", "paused", "archived"]
export default function ResourcesRoute() {
const items = useResources()
const [query, setQuery] = useState("")
const [draftName, setDraftName] = useState("")
useEffect(() => {
seedResourcesIfEmpty()
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return q
? items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.owner.toLowerCase().includes(q) ||
r.status.includes(q),
)
: items
}, [items, query])
const create = () => {
const name = draftName.trim()
if (!name) return
createResource({ name, owner: "You" })
setDraftName("")
}
return (
<AppShell title="Resources">
<Card>
<CardHeader>
<CardTitle>Resources</CardTitle>
<CardDescription>
Example domain entity. CRUD goes through{" "}
<code className="font-mono text-xs">~/lib/resources.ts</code>
swap that file's calls for{" "}
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
from <code className="font-mono text-xs">~/lib/api.ts</code> when
you have a backend.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-48">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-action="resources-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name, owner, status…"
className="pl-8"
/>
</div>
<Input
data-action="resources-new-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create()
}}
placeholder="New resource name…"
className="max-w-64"
/>
<Button
data-action="resources-create"
onClick={create}
disabled={!draftName.trim()}
>
<Plus className="size-4" /> Add
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-card/40">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-3 py-2 text-left font-medium">Owner</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Updated</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-3 py-8 text-center text-muted-foreground"
>
{items.length === 0
? "No resources yet — add one above."
: "No matches."}
</td>
</tr>
) : (
filtered.map((r) => (
<tr
key={r.id}
className="border-t transition-colors hover:bg-accent/30"
>
<td className="px-3 py-2 font-medium">{r.name}</td>
<td className="px-3 py-2 text-muted-foreground">
{r.owner}
</td>
<td className="px-3 py-2">
<select
data-action={`resources-status-${r.id}`}
value={r.status}
onChange={(e) =>
updateResource(r.id, {
status: e.target.value as Resource["status"],
})
}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
>
{statuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{new Date(r.updatedAt).toLocaleDateString()}
</td>
<td className="px-2 py-2 text-right">
<Button
data-action={`resources-delete-${r.id}`}
variant="ghost"
size="icon-sm"
aria-label="Delete"
onClick={() => {
if (window.confirm(`Delete "${r.name}"?`))
deleteResource(r.id)
}}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
{items.length} total · {filtered.length} shown
</p>
</CardContent>
</Card>
</AppShell>
)
}

562
app/routes/settings.tsx Normal file
View File

@@ -0,0 +1,562 @@
import { useEffect, useState } from "react"
import {
Check,
X,
Loader2,
Cpu,
Palette,
User as UserIcon,
Info,
Users,
Plus,
Trash2,
} from "lucide-react"
import { listModels } from "@crema/llm-ui"
import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { Textarea } from "~/components/ui/textarea"
import {
DEFAULT_SETTINGS,
DEFAULT_SYSTEM_PROMPT,
saveLLMSettings,
useLLMSettings,
type LLMSettings,
} from "~/lib/llm-settings"
import {
loadActiveAgentId,
newAgentId,
resetAgents,
saveActiveAgentId,
saveAgents,
useAgents,
type Agent,
} from "~/lib/agents"
import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Settings")
const SECTION_KEY = "crema.settings.section"
type SectionId = "llm" | "agents" | "appearance" | "account" | "about"
const sections: {
id: SectionId
label: string
icon: React.ComponentType<{ className?: string }>
description: string
}[] = [
{ id: "llm", label: "LLM", icon: Cpu, description: "Model endpoint & budgets" },
{
id: "agents",
label: "Agents",
icon: Users,
description: "Personas, roles, sub-prompts",
},
{
id: "appearance",
label: "Appearance",
icon: Palette,
description: "Theme, font size, surface, background",
},
{ id: "account", label: "Account", icon: UserIcon, description: "Profile & preferences" },
{ id: "about", label: "About", icon: Info, description: "Version & credits" },
]
type TestState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "ok"; count: number }
| { kind: "fail"; reason: string }
export default function SettingsRoute() {
const settings = useLLMSettings()
const [draft, setDraft] = useState<LLMSettings>(settings)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [test, setTest] = useState<TestState>({ kind: "idle" })
useEffect(() => {
setDraft(settings)
}, [settings])
const runTest = async () => {
setTest({ kind: "running" })
const ac = new AbortController()
const timeout = setTimeout(() => ac.abort(), 4000)
try {
const rows = await listModels({ baseURL: draft.baseURL, signal: ac.signal })
setTest({ kind: "ok", count: rows.length })
} catch (e) {
setTest({
kind: "fail",
reason: e instanceof Error ? e.message : String(e),
})
} finally {
clearTimeout(timeout)
}
}
const dirty =
draft.baseURL !== settings.baseURL ||
draft.contextTokens !== settings.contextTokens ||
draft.responseBudget !== settings.responseBudget
const save = () => {
saveLLMSettings(draft)
setSavedAt(Date.now())
}
const reset = () => {
setDraft(DEFAULT_SETTINGS)
}
const [section, setSection] = useState<SectionId>(() => {
if (typeof window === "undefined") return "llm"
const stored = localStorage.getItem(SECTION_KEY)
return sections.some((s) => s.id === stored)
? (stored as SectionId)
: "llm"
})
useEffect(() => {
if (typeof window !== "undefined")
localStorage.setItem(SECTION_KEY, section)
}, [section])
return (
<AppShell title="Settings">
<div className="grid gap-6 md:grid-cols-[14rem_1fr]">
<nav
aria-label="Settings sections"
className="flex flex-row gap-1 overflow-x-auto md:flex-col md:gap-0.5"
>
{sections.map((s) => {
const Icon = s.icon
const active = section === s.id
return (
<button
key={s.id}
type="button"
data-action={`settings-section-${s.id}`}
onClick={() => setSection(s.id)}
aria-current={active ? "page" : undefined}
className={[
"group flex shrink-0 items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")}
>
<Icon className="size-4 shrink-0" />
<span className="flex flex-col">
<span className="font-medium leading-tight">{s.label}</span>
<span
className={[
"hidden text-xs leading-tight md:inline",
active ? "text-primary/80" : "text-muted-foreground/80",
].join(" ")}
>
{s.description}
</span>
</span>
</button>
)
})}
</nav>
<div className="min-w-0">
{section === "llm" && (
<Card>
<CardHeader>
<CardTitle>LLM</CardTitle>
<CardDescription>
Configure the local model endpoint and context budgets used
by the Assistant.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<Field
label="Base URL"
hint="OpenAI-compatible endpoint. LM Studio defaults to http://localhost:1234/v1."
>
<Input
data-action="settings-base-url"
value={draft.baseURL}
onChange={(e) =>
setDraft((d) => ({ ...d, baseURL: e.target.value }))
}
placeholder="http://localhost:1234/v1"
spellCheck={false}
autoComplete="off"
/>
</Field>
<Field
label="Context window (tokens)"
hint="Match this to the context length you've loaded in LM Studio."
>
<Input
data-action="settings-context-tokens"
type="number"
min={1024}
step={512}
value={draft.contextTokens}
onChange={(e) =>
setDraft((d) => ({
...d,
contextTokens:
Number(e.target.value) || d.contextTokens,
}))
}
/>
</Field>
<Field
label="System prompt"
hint="Sent at the start of every conversation. Shapes the assistant's persona and scope. UI Control adds an action-driving preface on top of this when enabled."
>
<Textarea
data-action="settings-system-prompt"
value={draft.systemPrompt}
onChange={(e) =>
setDraft((d) => ({ ...d, systemPrompt: e.target.value }))
}
rows={5}
spellCheck={false}
className="min-h-24 font-mono text-xs"
/>
<button
type="button"
data-action="settings-system-prompt-reset"
onClick={() =>
setDraft((d) => ({
...d,
systemPrompt: DEFAULT_SYSTEM_PROMPT,
}))
}
className="self-start text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Reset to default prompt
</button>
</Field>
<Field
label="Response cap (max tokens)"
hint="Upper bound on each model reply. Smaller = faster, less rambling."
>
<Input
data-action="settings-response-budget"
type="number"
min={64}
step={64}
value={draft.responseBudget}
onChange={(e) =>
setDraft((d) => ({
...d,
responseBudget:
Number(e.target.value) || d.responseBudget,
}))
}
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="settings-save"
onClick={save}
disabled={!dirty}
>
Save
</Button>
<Button
data-action="settings-test"
variant="outline"
onClick={runTest}
disabled={test.kind === "running"}
>
{test.kind === "running" ? (
<Loader2 className="size-4 animate-spin" />
) : test.kind === "ok" ? (
<Check className="size-4 text-emerald-600" />
) : test.kind === "fail" ? (
<X className="size-4 text-destructive" />
) : null}
Test connection
</Button>
<Button
data-action="settings-reset"
variant="outline"
onClick={reset}
>
Reset to defaults
</Button>
{savedAt && !dirty && (
<span className="text-sm text-muted-foreground">
Saved.
</span>
)}
{test.kind === "ok" && (
<span className="text-sm text-emerald-700 dark:text-emerald-400">
{test.count} model{test.count === 1 ? "" : "s"} available.
</span>
)}
{test.kind === "fail" && (
<span
className="text-sm text-destructive"
title={test.reason}
>
Failed: {test.reason.slice(0, 60)}
</span>
)}
</div>
</CardContent>
</Card>
)}
{section === "agents" && <AgentsPanel />}
{section === "appearance" && (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Theme, font size, surface tint, and background atmosphere are
in the appbar the toggles up top write to localStorage and
persist across sessions.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Use the icons in the appbar (top right) to change theme, font
size, surface tint, and background.
</CardContent>
</Card>
)}
{section === "account" && (
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Identity and profile preferences.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Wire <code className="font-mono">~/lib/identity.ts</code> to a
real session to populate this panel.
</CardContent>
</Card>
)}
{section === "about" && (
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
<CardDescription>App version and credits.</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
<p>Built on the Crema design system.</p>
<p>
Hybrid traditional + AI-first scaffold with a virtual cursor
and command bus for assistant-driven UI control.
</p>
</CardContent>
</Card>
)}
</div>
</div>
</AppShell>
)
}
function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{children}
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</label>
)
}
function AgentsPanel() {
const agents = useAgents()
const [activeId, setActiveId] = useState<string>(() => loadActiveAgentId())
const [editingId, setEditingId] = useState<string | null>(null)
useEffect(() => {
saveActiveAgentId(activeId)
}, [activeId])
const editing = agents.find((a) => a.id === editingId) ?? null
const update = (next: Agent) => {
saveAgents(agents.map((a) => (a.id === next.id ? next : a)))
}
const remove = (id: string) => {
if (agents.length <= 1) return
const next = agents.filter((a) => a.id !== id)
saveAgents(next)
if (activeId === id) setActiveId(next[0].id)
if (editingId === id) setEditingId(null)
}
const create = () => {
const id = newAgentId()
const draft: Agent = {
id,
name: "New persona",
role: "Specialist",
prompt: "Describe what this persona is good at and how it should respond.",
}
saveAgents([...agents, draft])
setEditingId(id)
}
return (
<Card>
<CardHeader>
<CardTitle>Agents</CardTitle>
<CardDescription>
Personas with their own sub-system prompts. Switch the active one in
the chat status bar the assistant inherits its skills, tone, and
scope. Lets you keep contexts focused: a coder agent doesn't carry
writing-task context; a writer doesn't carry codebase context.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Button
data-action="settings-agent-new"
onClick={create}
size="sm"
variant="outline"
>
<Plus className="size-4" /> New persona
</Button>
<Button
data-action="settings-agent-reset"
onClick={() => {
resetAgents()
setEditingId(null)
}}
size="sm"
variant="ghost"
>
Reset to defaults
</Button>
</div>
<ul className="flex flex-col gap-1.5">
{agents.map((a) => {
const isActive = activeId === a.id
const isEditing = editingId === a.id
return (
<li
key={a.id}
className={[
"rounded-lg border transition-colors",
isEditing
? "border-primary/40 bg-primary/5"
: "border-border bg-card/40",
].join(" ")}
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
data-action={`settings-agent-activate-${a.id}`}
onClick={() => setActiveId(a.id)}
className={[
"size-2.5 shrink-0 rounded-full ring-2 transition-colors",
isActive
? "bg-primary ring-primary/30"
: "bg-muted ring-transparent hover:ring-foreground/20",
].join(" ")}
aria-label={
isActive ? `${a.name} (active)` : `Activate ${a.name}`
}
title={isActive ? "Active" : "Set active"}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{a.name}</span>
<span className="truncate text-xs text-muted-foreground">
{a.role}
</span>
</div>
<Button
data-action={`settings-agent-edit-${a.id}`}
onClick={() => setEditingId(isEditing ? null : a.id)}
size="sm"
variant="ghost"
>
{isEditing ? "Done" : "Edit"}
</Button>
<Button
data-action={`settings-agent-delete-${a.id}`}
onClick={() => remove(a.id)}
size="icon-sm"
variant="ghost"
disabled={agents.length <= 1}
aria-label="Delete persona"
title="Delete persona"
>
<Trash2 className="size-4" />
</Button>
</div>
{isEditing && editing && (
<div className="flex flex-col gap-3 border-t bg-background/60 px-3 py-3">
<Field label="Name">
<Input
data-action={`settings-agent-name-${a.id}`}
value={editing.name}
onChange={(e) =>
update({ ...editing, name: e.target.value })
}
/>
</Field>
<Field label="Role">
<Input
data-action={`settings-agent-role-${a.id}`}
value={editing.role}
onChange={(e) =>
update({ ...editing, role: e.target.value })
}
/>
</Field>
<Field
label="Sub-system prompt"
hint="Stacked on top of the main system prompt only when this persona is active."
>
<Textarea
data-action={`settings-agent-prompt-${a.id}`}
value={editing.prompt}
onChange={(e) =>
update({ ...editing, prompt: e.target.value })
}
rows={6}
className="min-h-32 font-mono text-xs"
/>
</Field>
</div>
)}
</li>
)
})}
</ul>
</CardContent>
</Card>
)
}

366
docs/AI_FIRST.md Normal file
View File

@@ -0,0 +1,366 @@
# AI-first system tour
The "anything can drive the UI" architecture — the contract every interactive
element opts into, the command bus that dispatches actions, the DSL that
makes scripts and LLM output ergonomic, and the LLM integration that wires
the whole thing into a chat surface.
It's one system, designed end-to-end. Reading top-to-bottom is the fastest
way to understand it.
## Overview
```
┌─ producers ─────────────────────┐ ┌─ executor ──────────────────┐
│ ⌨ console (window.commandBus) │ │ command-bus.ts │
│ 📜 scripts (.script DSL) │ ─▶ │ • dispatch(cmd) │
│ 🤖 LLM (```action blocks) │ │ • handlers map │
│ 🔌 WebSocket (optional) │ │ • vars + history │
└─────────────────────────────────┘ │ • listActions / readState │
└─────────┬───────────────────┘
┌───────────────────┴───────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ DOM actions │ │ replay layer │
│ (click/fill/ │ │ • virtual cursor │
│ navigate/…) │ │ • ripple on click│
└──────────────┘ └──────────────────┘
```
Every interactive element opts in by adding `data-action="<id>"`. That's the
entire contract. From there, the bus can find it, scripts can target it, and
the LLM can drive it.
## The `[data-action]` convention
Every interactive UI element gets a stable id:
```tsx
<button data-action="sidebar-toggle"></button>
<input data-action="appbar-search" />
<NavLink data-action="nav-resources" to="/resources">Resources</NavLink>
```
**Naming:** lowercase, kebab-case, prefixed by the surface it lives in:
| Prefix | Surface |
|---|---|
| `nav-*` | Sidebar nav links (`nav-overview`, `nav-resources`, …) |
| `nav-mobile-*` | Mobile sheet versions of nav |
| `appbar-*` | Top bar controls (`appbar-search`, `appbar-notifications`) |
| `avatar-*` | Avatar dropdown items |
| `<route>-*` | Route-specific (`assistant-clear`, `settings-save`) |
| `home-tile-*`, `run-script-*`, etc. | Domain-grouped |
**Why this works:** the bus introspects the DOM at dispatch time —
`commandBus.listActions()` returns every visible `[data-action]` in the page
right now. New components are automatically scriptable as long as they tag
their interactive slots. No central registry to maintain.
**One gotcha:** elements rendered in portals (closed dropdowns, sheets,
dialogs) appear in the DOM but aren't visible. The `listActions()` filter
uses `Element.checkVisibility()` + `offsetParent` + bbox checks to exclude
those — so the LLM doesn't see actions it can't actually click.
## The command bus
`app/lib/command-bus.ts`. Single dispatch point. Built-in handlers:
| Command | Args | Purpose |
|---|---|---|
| `navigate` | `path` | React Router navigation |
| `click` | `target` | Find `[data-action=target]`, scroll into view, click |
| `fill` | `target`, `value` | Set input value, fire input + change events |
| `submit` | `target` | Submit the form containing target |
| `select` | `target`, `value` | Set `<select>` value |
| `wait` | `ms` | Sleep |
| `wait_for` | `target`, `timeout?` | Poll for element existence (default 5s timeout) |
| `scroll` | `target?` | scrollIntoView, or page bottom if no target |
| `read` | `target?` | Return innerText (truncated to 4000 chars) |
| `expect` | `target`, `op`, `value?` | Assert: `to_contain`, `to_be_visible`, `to_have_value` |
| `set` | `name`, `value` | Set a variable for later interpolation |
### Variables
Every command can declare `as: "name"` — its return value gets stored under
that name. Other commands reference it via `$name` interpolation.
```js
commandBus.dispatch({ type: "read", target: "row-1", as: "id" })
commandBus.dispatch({ type: "click", target: "$id" }) // resolves at dispatch
```
In DSL form:
```
$id = read row-1
click $id
```
### Custom handlers
Register your own anywhere:
```ts
import { commandBus } from "~/lib/command-bus"
commandBus.register("ring", async (cmd) => {
const el = document.querySelector(`[data-action="${cmd.target}"]`)
el?.classList.add("animate-ring")
setTimeout(() => el?.classList.remove("animate-ring"), 1200)
})
// dispatch as: { type: "ring", target: "save-button" }
```
Returns an `unregister` fn for cleanup.
### Console API
The provider exposes `window.commandBus`, `window.runScript`, and
`window.runScriptText`. Open devtools and try:
```js
commandBus.listActions().filter(a => a.id.startsWith("nav-"))
commandBus.readState() // visible page text
commandBus.history // every dispatch + result/error
runScript("demo-tour") // load + run /scripts/demo-tour.script
runScriptText('click sidebar-toggle\nwait 500\nclick nav-assistant')
```
## The DSL
Plain text, one command per line. Parses to canonical JSON. Both layers
share the same vocabulary; the DSL is just sugar.
```
# Comfy Cloud — short tour through the rail
# speed: 0.9
click sidebar-toggle
wait 500
click nav-resources
wait_for nav-resources
wait 500
click nav-assistant
wait 700
# Variables
$id = read row-1
click $id
# Assertions
fill appbar-search "acme"
expect resources-table to_contain "Acme"
```
**Syntax:**
- One command per line.
- Whitespace-tolerant. Quote values with spaces (`"hello world"`). Backslash-escape inside quotes.
- `#` starts a comment. The `# speed: <n>` directive at the top sets cursor animation speed (default 1).
- `$name = <command>` assigns the command's return value to a variable.
- `$name` in args is interpolated at dispatch.
- `run other-script` includes another script (resolved as `/scripts/other-script.script`).
**API:**
```ts
import { parseScript, parseLine, stringifyScript } from "~/lib/command-parser"
import { runScript, runScriptText } from "~/lib/command-script"
const { options, commands } = parseScript(text) // → JSON commands
const dsl = stringifyScript(commands, options) // → text (round-trips)
await runScriptText(dsl)
await runScript("demo-tour")
```
## Scripts
Live in `public/scripts/*.script`. Two ways to invoke at runtime:
1. **Dialog** — appbar Play icon, or **⌘⇧P** keyboard shortcut. Lists known
scripts + a paste-DSL textarea. See `app/components/scripts-dialog.tsx`.
2. **Console**`runScript("demo-tour")` or `runScriptText("…")`.
Sub-scripts compose:
```
# in a parent script
run setup-fixtures
run actual-test
```
## Virtual cursor
`app/lib/virtual-cursor.ts`. A floating SVG cursor + ripple element appended
to `document.body`. The bus's `beforeCommand` hook moves the cursor to the
target element before each command and rips a ripple on `click`. Speed is
controlled by the script's `# speed:` header (default 1; lower = slower
animation).
`aria-hidden`/`role="presentation"` so screen readers ignore it. To run
silently (e.g. tests), pass `{ silent: true }` to `dispatch()` or `run()`.
## LLM integration
`app/lib/llm-tools.ts`. Two responsibilities:
### 1. Build the system prompt
`buildSystemPrompt({ path, includeActions })` returns a string with:
- A short preface ("You are the assistant in Comfy Cloud…")
- A compact DSL reference (~120 tokens — kept tight for small context windows)
- The current route
- A live snapshot of every visible `[data-action]` on screen, formatted as
`- <id>: <label>` (skipping invisible/portal items)
The Assistant route rebuilds this on every send when **UI Control** is on,
so the model always sees the current page's actions.
### 2. Extract action blocks from streamed replies
The system prompt teaches the model to emit a fenced ` ```action ` block
when the user asks it to do something. After each assistant turn ends, the
Assistant route runs `runActionBlocks(message.content)` which:
1. Regex-extracts every ` ```action ... ``` ` block.
2. Feeds each through `runScriptText` → DSL parser → command bus.
3. Returns `{ ran, errors }` for the route to surface as a status pill
("Ran 2 actions").
The action-block fence renders as a small pill in the chat bubble (not raw
text) — see `app/components/assistant/message-body.tsx`.
### Format the model emits
```
I'll take you to resources.
```action
navigate /resources
wait_for nav-resources
```
Done — anything else?
```
The "rules" the system prompt teaches the model:
- Only emit a block when asked to *do* something. Questions and chitchat
reply normally.
- Use only ids from the "Available actions" list.
- A short sentence + the block. Optional follow-up after.
- Never invent target ids.
Smaller models (≤7B) sometimes drift — the system prompt is explicit about
each rule, but you'll see occasional invented ids or extra prose. Bigger
models or Claude follow it near-perfectly.
### Token budget
`useChat` with these `reqExtras`:
- `system` — fresh per send via `buildSystemPrompt(...)`
- `messages` — pre-trimmed via `trimMessages(...)` to fit `contextTokens sysTokens responseBudget`
- `maxTokens: responseBudget` — caps the reply length
`contextTokens`, `responseBudget`, and `baseURL` come from `/settings`.
Default 9000 / 512. The header bar shows a live `<used> / <total>` badge
that turns amber when getting close.
## Producer 4: WebSocket (optional, unwired)
`app/lib/command-ws.ts`. A reconnecting `WebSocket` listener that accepts
three message shapes:
```json
{ "id": "abc", "command": { "type": "click", "target": "save-button" } }
{ "id": "abc", "script": [ {"type":"navigate","path":"/x"}, ] }
{ "id": "abc", "dsl": "navigate /x\nclick save-button" }
```
Replies with `{ id, ok: true }` or `{ id, ok: false, error: "…" }`.
Not wired up by default. To enable for a session:
```ts
import { connectCommandSocket } from "~/lib/command-ws"
const sock = connectCommandSocket("ws://localhost:9229/ui")
// ... later ...
sock.close()
```
The intended use is screen-share / observer / CI scenarios where an external
process drives the UI. **Origin checks and an opt-in toggle in /settings are
sketched but not built** — don't auto-connect in production.
## Safety (sketch, not built)
When the UI gains destructive surfaces, mark them with
`data-action-danger`:
```tsx
<Button data-action="delete-account" data-action-danger>Delete</Button>
```
The bus's `beforeCommand` hook can refuse danger-marked targets unless the
caller passes a confirmation token. This is **not implemented yet** — flag
and design when you have a real destructive action to gate.
## Files at a glance
| File | Role |
|---|---|
| `app/lib/command-bus.ts` | JSON layer, dispatch, handlers, vars, history, `listActions`, `readState` |
| `app/lib/command-parser.ts` | DSL ↔ JSON |
| `app/lib/command-script.ts` | Script runner — load `/scripts/*.script`, run with cursor speed |
| `app/lib/virtual-cursor.ts` | Visible cursor + ripple |
| `app/lib/command-provider.tsx` | React glue: registers `navigate`, mounts cursor, exposes `window.*` |
| `app/lib/command-ws.ts` | Optional WebSocket producer |
| `app/lib/llm-tools.ts` | `buildSystemPrompt`, `extractActionBlocks`, `runActionBlocks`, token utils |
| `app/lib/llm-settings.ts` | Persisted base URL / context / response cap |
| `app/components/scripts-dialog.tsx` | ⌘⇧P script runner UI |
| `app/components/assistant/message-body.tsx` | Markdown bubble + action-block pill |
| `public/scripts/demo-*.script` | Examples |
## Quick recipes
**Make a new component scriptable** — add `data-action="<id>"`. Done.
**Test a flow** — write a `.script` file with `expect` assertions:
```
navigate /settings
fill settings-base-url "http://localhost:1234/v1"
click settings-test
wait 1500
expect settings-test to_contain "models available"
```
Run it via the dialog or `runScript("…")`.
**Drive a custom widget** — register a handler:
```ts
commandBus.register("highlight", async (cmd) => {
/* ...your logic... */
})
```
Then dispatch `{ type: "highlight", target: "…" }` from anywhere.
**Send the LLM a different system prompt**`buildSystemPrompt` accepts a
`preface` override:
```ts
buildSystemPrompt({
preface: "You are the support agent for Comfy Cloud's billing team.",
path: window.location.pathname,
})
```

99
docs/LIBS.md Normal file
View File

@@ -0,0 +1,99 @@
# Available Crema libs
> Generated by `scripts/sync-libs.mjs` from crema-manifest@3.1.0.
> Run `npm run sync-libs` to refresh.
Every `@crema/*-ui` lib is its own git repo at
`https://git.sky-ai.com/CremaUIStudio/lib-<name>-ui`. To add one, clone it
as a sibling of this project, then add the tsconfig path entry and the
`@source` line to `app/app.css` (the marker comments make this easy).
## Wired in this project (5)
These are importable from your code right now (`import { … } from "@crema/<name>"`):
| Lib | Alias | Purpose |
|---|---|---|
| `action-bus` | `@crema/action-bus` | Anything-can-drive-the-UI command bus. Single dispatch point for LLM tool calls, scripts, and (optional) WebSocket remote control. JSON c… |
| `aifirst-ui` | `@crema/aifirst-ui` | AI-first card primitives: AICard with left-border accent + canonical anatomy (title, metadata, AI rationale, verb-prefixed action buttons… |
| `chat-ui` | `@crema/chat-ui` | Chat threads, message bubbles, composer, channel list |
| `llm-ui` | `@crema/llm-ui` | LLM client + React bindings. Two adapters out of the box: OpenAICompatibleAdapter (LM Studio, Ollama, DeepSeek, OpenAI, Together, Groq, O… |
| `notification-ui` | `@crema/notification-ui` | Toasts, badges, bell, banners, inbox, notification center |
## Active theme
| Theme | Dark mode | Purpose |
|---|---|---|
| `mightypix` | supported | Warm AI-first companion to pristine. Cream paper surfaces, deep ink-blue accent, soft flat elevation, asymmetric typography (Inter for UI chrome, Source Seri… |
## Available to add (50)
From the manifest. To wire one in: `crema add <name>` (CLI), or
manually clone the repo + edit `tsconfig.json` paths + `app/app.css`
`@source`.
| Lib | Alias | Purpose |
|---|---|---|
| `a11y-ui` | `@crema/a11y-ui` | Skip links, focus trap/ring, shortcut overlay, contrast checker |
| `agent-ui` | `@crema/agent-ui` | Agentic UI atoms: action proposals + queue, diff proposals, tool-call audit cards, multi-step run panel with step trail and milestone rai… |
| `artifact-ui` | `@crema/artifact-ui` | AI-generated artifacts as cards: code, file, email, sql, draft, chart, image, dataset. Type-specific previews, draft/reviewing/applied li… |
| `auth-ui` | `@crema/auth-ui` | Sign-in, sign-up, MFA, password reset, OAuth buttons |
| `billing-ui` | `@crema/billing-ui` | Pricing tables, plan comparison, usage meters, invoices, payment methods |
| `calendar-ui` | `@crema/calendar-ui` | Calendar grid, week/day views, event cards, scheduling |
| `callcentre-ui` | `@crema/callcentre-ui` | Agent desktop: softphone, active call card, queue, history, customer lookup, scripts, knowledge search, transcript, notes, disposition, w… |
| `card-ui` | `@crema/card-ui` | Theme-agnostic card primitives: three-zone template (media / body / actions) with portrait, landscape, compact, wide-banner orientations;… |
| `chart-ui` | `@crema/chart-ui` | Themed charting primitives: sparkline, line, bar, donut, heatmap. Pure SVG, currentColor-driven. |
| `code-ui` | `@crema/code-ui` | Code blocks, diff viewer, syntax highlighting, file tree |
| `codereview-ui` | `@crema/codereview-ui` | Code review primitives in two registers, sharing PullRequest/DiffHunk/ReviewComment/Reviewer types. Plain: PullRequestCard (queue row), D… |
| `color-ui` | `@crema/color-ui` | Color picker, swatches, palettes, contrast tooling |
| `command-ui` | `@crema/command-ui` | Command palette (⌘K) with self-registering commands, fuzzy match, keyboard navigation. |
| `comments-ui` | `@crema/comments-ui` | Threaded comments, mentions, reactions, presence |
| `commerce-ui` | `@crema/commerce-ui` | Product cards, cart, checkout, order summary |
| `content-editor-ui` | `@crema/content-editor-ui` | Rich-text editor (Tiptap), blog composer, slash menu, image embeds |
| `content-media-ui` | `@crema/content-media-ui` | Media library, image gallery, video/audio players, lightbox |
| `content-ui` | `@crema/content-ui` | Article display, prose styles, table of contents, callouts |
| `crm-ui` | `@crema/crm-ui` | CRM-domain primitives in two registers sharing the same Deal/Contact/Company/Activity types (Deal includes probability for forecasting; C… |
| `dashboard-ui` | `@crema/dashboard-ui` | Stat cards, KPI tiles, gauges, sparklines, heatmap calendar |
| `data-ui` | `@crema/data-ui` | Lists, key-value, definition rows, descriptive blocks |
| `diagram-ui` | `@crema/diagram-ui` | Node graphs, flow diagrams, swimlanes, sequence diagrams |
| `ehr-ui` | `@crema/ehr-ui` | EHR primitives in two registers, sharing Patient/Vital/Medication/Allergy/LabResult/Order/ProblemListItem/Encounter types. Plain: Patient… |
| `eval-ui` | `@crema/eval-ui` | Eval result UI: score cards, A/B comparison rows, run grids, score distributions. |
| `feedback-ui` | `@crema/feedback-ui` | Alerts, banners, empty states, error boundaries |
| `file-ui` | `@crema/file-ui` | Dropzone, file grid/list, previewers, upload progress, browser |
| `fleetops-ui` | `@crema/fleetops-ui` | Air traffic / fleet ops: radar map, flight strips, status badges, schedule gantt, weather, runway load, aircraft health, crew |
| `flow-ui` | `@crema/flow-ui` | Workflow / state-machine canvas: drag nodes, draw edges, execute with branching decisions. |
| `form-ui` | `@crema/form-ui` | Forms, fields, validation, multi-step wizards |
| `futurecafe-ui` | `@crema/futurecafe-ui` | Near-future café barista dashboard: AI↔AI handshake summary, customer card with mood/vibe, pedestrian approach map, arrival countdown, or… |
| `inflight-aurora-ui` | `@crema/inflight-aurora-ui` | Near-future supersonic cabin UI (second theme): glassmorphism aurora aesthetic — flight arc globe, trip timeline, ETA, altitude strip, ov… |
| `inflight-ui` | `@crema/inflight-ui` | Near-future luxury inflight passenger UI: flight map arc, trip timeline, ETA, altitude strip, gourmet menu, order tray, cabin mood, windo… |
| `kanban-ui` | `@crema/kanban-ui` | Kanban board, draggable cards, columns, swimlanes |
| `layout-ui` | `@crema/layout-ui` | Grids, stacks, dividers, sidebars, scroll areas |
| `log-ui` | `@crema/log-ui` | Streamed log viewer: level filter, structured-field expansion, follow-mode auto-scroll, pause/resume/clear. |
| `map-ui` | `@crema/map-ui` | SVG maps (world, US states), choropleth, spatial primitives, game grid |
| `morph-ui` | `@crema/morph-ui` | Shared-element container primitive: tiles with icon/card/workspace states, FLIP transitions, body-portaled workspace with grid-centered l… |
| `motorsport-ui` | `@crema/motorsport-ui` | F1 / motorsport: animated track map, telemetry cluster, tyre + sector badges, lap timing tower, race control, stint chart, pit stop seque… |
| `onboarding-ui` | `@crema/onboarding-ui` | Welcome cards, checklists, wizards, coachmarks, product tours |
| `presence-ui` | `@crema/presence-ui` | Human + agent presence layer: avatars, ambient strips, rails, workspace panel, proposals with voting, interrupt dock, inline threads, cro… |
| `print-ui` | `@crema/print-ui` | Print provider, page sizes, invoice/receipt/label/badge templates |
| `prompt-ui` | `@crema/prompt-ui` | Prompt + template authoring: variable highlighting, token estimation, version diff. |
| `property-man-ui` | `@crema/property-man-ui` | Real estate listings, property cards, gallery, filter facets, mortgage calculator, agent cards |
| `rag-ui` | `@crema/rag-ui` | Retrieval result UI: ranked chunks with highlighted spans, source attribution, retriever comparison. |
| `search-ui` | `@crema/search-ui` | Search input, command palette, facets, query tokens |
| `settings-ui` | `@crema/settings-ui` | Settings shell, preferences, profile, API keys, danger zone |
| `status-ui` | `@crema/status-ui` | System status board: components with uptime grids, incident timeline, maintenance windows. Renders subgrid-aligned bars across rows. |
| `table-ui` | `@crema/table-ui` | Sortable tables, row selection, pagination, sticky headers |
| `tool-ui` | `@crema/tool-ui` | Agentic tool catalog, schema editor, and mock-execution preview. Pairs with agent-ui (single-call display) as the management surface for … |
| `typography-ui` | `@crema/typography-ui` | Type scale, font specimens, prose blocks |
## Other themes (5)
Swap by changing the `@import "../../lib-theme-<name>/theme.css"` line at the top of `app/app.css`.
| Theme | Dark mode | Purpose |
|---|---|---|
| `arcade` | dark-only | Gaming-shell theme: dark-first cobalt canvas, electric-cyan CTA, neon burst accents, sharper radii, snappier motion. For app surfaces around a game (lobby, s… |
| `caffe-florian` | supported | Editorial theme for content-first apps — cream parchment surfaces, Venetian red accent, Libre Baskerville display + DM Sans body. Generous line-heights, soft… |
| `otium` | supported | Pristine UI's Apple-inspired glass aesthetic with generous, spacious typography. Sharp clean translucent surfaces, hairline borders, vibrancy stack. More rel… |
| `pristine` | supported | Apple-inspired glass design system with translucent surfaces, spring motion, and SF/Inter typography. |
| `swish` | supported | Touchscreen Apple-feel theme for shared surfaces (inflight seatback, automotive passenger, kiosks). Saturated-and-serene sky canvas with opaque matte chiclet… |

9882
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "arcadia-admin",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc",
"test": "vitest run",
"test:watch": "vitest",
"sync-libs": "node scripts/sync-libs.mjs"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
"@fontsource-variable/inter": "^5.2.8",
"@react-router/node": "7.13.1",
"@react-router/serve": "7.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-geo": "^3.1.1",
"isbot": "^5.1.36",
"lucide-react": "^1.8.0",
"motion": "^12.38.0",
"openapi-fetch": "^0.17.0",
"phoenix": "^1.8.5",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router": "7.13.1",
"shadcn": "^4.2.0",
"tailwind-merge": "^3.5.0",
"topojson-client": "^3.1.0",
"tw-animate-css": "^1.4.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@react-router/dev": "7.13.1",
"@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-geo": "^3.1.0",
"@types/node": "^22",
"@types/phoenix": "^1.6.7",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5",
"jsdom": "^29.1.0",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.5"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,7 @@
# Demo: focus and fill the appbar search
# speed: 1
click appbar-search
fill appbar-search "hello from a script"
wait 1200
fill appbar-search ""

View File

@@ -0,0 +1,28 @@
# Comfy Cloud — short tour through the rail
# speed: 0.9
# Open the rail so labels are visible
click sidebar-toggle
wait 500
# Visit each section
click nav-resources
wait_for nav-resources
wait 500
click nav-activity
wait 500
click nav-assistant
wait 700
click nav-library
wait 500
click nav-settings
wait 500
# Back home, collapse rail
click nav-overview
wait 400
click sidebar-toggle

Some files were not shown because too many files have changed in this diff Show More