From f8cbf142b58b7f3a509a7502c9fcbf37aeb43138 Mon Sep 17 00:00:00 2001 From: jules Date: Wed, 29 Apr 2026 21:28:39 +1000 Subject: [PATCH] =?UTF-8?q?init:=20arcadia-admin=20=E2=80=94=20admin=20web?= =?UTF-8?q?app=20for=20arcadia-core,=20cloned=20from=20vibespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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) --- .dockerignore | 4 + .gitignore | 11 + .prettierignore | 7 + .prettierrc | 11 + CLAUDE.md | 145 + README.md | 85 + app/app.css | 136 + app/components/README.md | 31 + app/components/assistant/message-body.tsx | 68 + app/components/layout/README.md | 10 + app/components/layout/THEME_CONTRACT.md | 132 + app/components/layout/app-shell.tsx | 568 ++ app/components/layout/appbar.tsx | 48 + app/components/layout/background-picker.tsx | 112 + app/components/layout/font-size-picker.tsx | 104 + app/components/layout/surface-picker.tsx | 107 + app/components/layout/theme-toggle.tsx | 30 + app/components/scripts-dialog.tsx | 155 + app/components/ui/accordion.tsx | 72 + app/components/ui/alert-dialog.tsx | 187 + app/components/ui/alert.tsx | 76 + app/components/ui/aspect-ratio.tsx | 22 + app/components/ui/avatar.tsx | 107 + app/components/ui/badge.tsx | 52 + app/components/ui/breadcrumb.tsx | 125 + app/components/ui/button-group.tsx | 87 + app/components/ui/button.tsx | 58 + app/components/ui/card.tsx | 103 + app/components/ui/checkbox.tsx | 29 + app/components/ui/chip.tsx | 48 + app/components/ui/code-block.tsx | 165 + app/components/ui/collapsible.tsx | 19 + app/components/ui/combobox.tsx | 295 + app/components/ui/context-menu.tsx | 271 + app/components/ui/dialog.tsx | 158 + app/components/ui/direction.tsx | 4 + app/components/ui/dropdown-menu.tsx | 266 + app/components/ui/empty.tsx | 104 + app/components/ui/field.tsx | 236 + app/components/ui/hover-card.tsx | 51 + app/components/ui/input-group.tsx | 158 + app/components/ui/input.tsx | 20 + app/components/ui/item.tsx | 201 + app/components/ui/kbd.tsx | 26 + app/components/ui/label.tsx | 20 + app/components/ui/menubar.tsx | 280 + app/components/ui/native-select.tsx | 61 + app/components/ui/navigation-menu.tsx | 168 + app/components/ui/pagination.tsx | 130 + app/components/ui/popover.tsx | 88 + app/components/ui/progress.tsx | 83 + app/components/ui/radio-group.tsx | 36 + app/components/ui/scroll-area.tsx | 53 + app/components/ui/select.tsx | 201 + app/components/ui/separator.tsx | 23 + app/components/ui/sheet.tsx | 138 + app/components/ui/signature-pad.tsx | 164 + app/components/ui/skeleton.tsx | 13 + app/components/ui/slider.tsx | 52 + app/components/ui/spinner.tsx | 10 + app/components/ui/switch.tsx | 30 + app/components/ui/table.tsx | 116 + app/components/ui/tabs.tsx | 80 + app/components/ui/textarea.tsx | 18 + app/components/ui/toggle-group.tsx | 87 + app/components/ui/toggle.tsx | 45 + app/components/ui/tooltip.tsx | 64 + app/hooks/use-mobile.ts | 15 + app/lib/agents.ts | 153 + app/lib/api.ts | 83 + app/lib/identity.ts | 40 + app/lib/library.ts | 125 + app/lib/llm-settings.ts | 99 + app/lib/notifications.ts | 155 + app/lib/page-meta.ts | 6 + app/lib/profile.ts | 115 + app/lib/resources.test.ts | 32 + app/lib/resources.ts | 157 + app/lib/session.test.ts | 31 + app/lib/session.ts | 160 + app/lib/threads.ts | 222 + app/lib/utils.ts | 6 + app/root.tsx | 99 + app/routes.ts | 14 + app/routes/activity.tsx | 43 + app/routes/ai.tsx | 1155 +++ app/routes/assistant.tsx | 2091 ++++ app/routes/home.tsx | 96 + app/routes/library.tsx | 205 + app/routes/login.tsx | 57 + app/routes/profile.tsx | 288 + app/routes/resources.tsx | 183 + app/routes/settings.tsx | 562 ++ docs/AI_FIRST.md | 366 + docs/LIBS.md | 99 + package-lock.json | 9882 +++++++++++++++++++ package.json | 57 + public/favicon.ico | Bin 0 -> 15086 bytes public/scripts/demo-search.script | 7 + public/scripts/demo-tour.script | 28 + react-router.config.ts | 5 + scripts/sync-libs.mjs | 212 + start.sh | 23 + stop.sh | 23 + tsconfig.json | 53 + vite.config.ts | 108 + vitest.config.ts | 13 + vitest.setup.ts | 8 + 108 files changed, 23740 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 app/app.css create mode 100644 app/components/README.md create mode 100644 app/components/assistant/message-body.tsx create mode 100644 app/components/layout/README.md create mode 100644 app/components/layout/THEME_CONTRACT.md create mode 100644 app/components/layout/app-shell.tsx create mode 100644 app/components/layout/appbar.tsx create mode 100644 app/components/layout/background-picker.tsx create mode 100644 app/components/layout/font-size-picker.tsx create mode 100644 app/components/layout/surface-picker.tsx create mode 100644 app/components/layout/theme-toggle.tsx create mode 100644 app/components/scripts-dialog.tsx create mode 100644 app/components/ui/accordion.tsx create mode 100644 app/components/ui/alert-dialog.tsx create mode 100644 app/components/ui/alert.tsx create mode 100644 app/components/ui/aspect-ratio.tsx create mode 100644 app/components/ui/avatar.tsx create mode 100644 app/components/ui/badge.tsx create mode 100644 app/components/ui/breadcrumb.tsx create mode 100644 app/components/ui/button-group.tsx create mode 100644 app/components/ui/button.tsx create mode 100644 app/components/ui/card.tsx create mode 100644 app/components/ui/checkbox.tsx create mode 100644 app/components/ui/chip.tsx create mode 100644 app/components/ui/code-block.tsx create mode 100644 app/components/ui/collapsible.tsx create mode 100644 app/components/ui/combobox.tsx create mode 100644 app/components/ui/context-menu.tsx create mode 100644 app/components/ui/dialog.tsx create mode 100644 app/components/ui/direction.tsx create mode 100644 app/components/ui/dropdown-menu.tsx create mode 100644 app/components/ui/empty.tsx create mode 100644 app/components/ui/field.tsx create mode 100644 app/components/ui/hover-card.tsx create mode 100644 app/components/ui/input-group.tsx create mode 100644 app/components/ui/input.tsx create mode 100644 app/components/ui/item.tsx create mode 100644 app/components/ui/kbd.tsx create mode 100644 app/components/ui/label.tsx create mode 100644 app/components/ui/menubar.tsx create mode 100644 app/components/ui/native-select.tsx create mode 100644 app/components/ui/navigation-menu.tsx create mode 100644 app/components/ui/pagination.tsx create mode 100644 app/components/ui/popover.tsx create mode 100644 app/components/ui/progress.tsx create mode 100644 app/components/ui/radio-group.tsx create mode 100644 app/components/ui/scroll-area.tsx create mode 100644 app/components/ui/select.tsx create mode 100644 app/components/ui/separator.tsx create mode 100644 app/components/ui/sheet.tsx create mode 100644 app/components/ui/signature-pad.tsx create mode 100644 app/components/ui/skeleton.tsx create mode 100644 app/components/ui/slider.tsx create mode 100644 app/components/ui/spinner.tsx create mode 100644 app/components/ui/switch.tsx create mode 100644 app/components/ui/table.tsx create mode 100644 app/components/ui/tabs.tsx create mode 100644 app/components/ui/textarea.tsx create mode 100644 app/components/ui/toggle-group.tsx create mode 100644 app/components/ui/toggle.tsx create mode 100644 app/components/ui/tooltip.tsx create mode 100644 app/hooks/use-mobile.ts create mode 100644 app/lib/agents.ts create mode 100644 app/lib/api.ts create mode 100644 app/lib/identity.ts create mode 100644 app/lib/library.ts create mode 100644 app/lib/llm-settings.ts create mode 100644 app/lib/notifications.ts create mode 100644 app/lib/page-meta.ts create mode 100644 app/lib/profile.ts create mode 100644 app/lib/resources.test.ts create mode 100644 app/lib/resources.ts create mode 100644 app/lib/session.test.ts create mode 100644 app/lib/session.ts create mode 100644 app/lib/threads.ts create mode 100644 app/lib/utils.ts create mode 100644 app/root.tsx create mode 100644 app/routes.ts create mode 100644 app/routes/activity.tsx create mode 100644 app/routes/ai.tsx create mode 100644 app/routes/assistant.tsx create mode 100644 app/routes/home.tsx create mode 100644 app/routes/library.tsx create mode 100644 app/routes/login.tsx create mode 100644 app/routes/profile.tsx create mode 100644 app/routes/resources.tsx create mode 100644 app/routes/settings.tsx create mode 100644 docs/AI_FIRST.md create mode 100644 docs/LIBS.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 public/scripts/demo-search.script create mode 100644 public/scripts/demo-tour.script create mode 100644 react-router.config.ts create mode 100644 scripts/sync-libs.mjs create mode 100755 start.sh create mode 100755 stop.sh create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b450be4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ + +.demo.log +.demo.pid +.boot.log diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0b4a1db --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules/ +coverage/ +.pnpm-store/ +pnpm-lock.yaml +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e76c16d --- /dev/null +++ b/.prettierrc @@ -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"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..077989c --- /dev/null +++ b/CLAUDE.md @@ -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) + +- **``** 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(path)` is the generic escape hatch for spec-incomplete endpoints. +- **Login** — `app/routes/login.tsx` renders `` 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 `` 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--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 # 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--ui` as a sibling. +2. `app/app.css` → add `@source "../../lib--ui/src";` +3. `tsconfig.json` paths → add `"@crema/-ui": ["../lib--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=""]` and applied + with the shell's `theme` prop. The Assistant route already does this: + ``. +- 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--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 `; 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=""` to ``. 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("")`. +- **Make a component scriptable:** add `data-action=""`. 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2884406 --- /dev/null +++ b/README.md @@ -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 ``. +- [`@crema/arcadia-auth-ui`](../lib-arcadia-auth-ui) — login / signup / password reset / 2FA forms, themed via Skyrise tokens. The `/login` route renders ``. + +### 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=""` 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=""]`** 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) diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..611481e --- /dev/null +++ b/app/app.css @@ -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; + } +} + diff --git a/app/components/README.md b/app/components/README.md new file mode 100644 index 0000000..7a3b50a --- /dev/null +++ b/app/components/README.md @@ -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. diff --git a/app/components/assistant/message-body.tsx b/app/components/assistant/message-body.tsx new file mode 100644 index 0000000..8e9540c --- /dev/null +++ b/app/components/assistant/message-body.tsx @@ -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 ( +
+ {prose && ( +

{children}

, + code: ({ children, className }) => { + const isBlock = className?.startsWith("language-") + if (isBlock) { + return ( +
+                    {children}
+                  
+ ) + } + return ( + + {children} + + ) + }, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + a: ({ children, href }) => ( + + {children} + + ), + }} + > + {prose} +
    + )} + {actionCount > 0 && ( + + + Ran {actionCount} action{actionCount > 1 ? "s" : ""} + + )} +
    + ) +} diff --git a/app/components/layout/README.md b/app/components/layout/README.md new file mode 100644 index 0000000..c34d995 --- /dev/null +++ b/app/components/layout/README.md @@ -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. diff --git a/app/components/layout/THEME_CONTRACT.md b/app/components/layout/THEME_CONTRACT.md new file mode 100644 index 0000000..e0a9b25 --- /dev/null +++ b/app/components/layout/THEME_CONTRACT.md @@ -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=""] { … }` — 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-/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=""]` instead of (or in addition to) +`:root`, you can switch surfaces per route via the shell's `theme` prop: + +```tsx + +``` + +The shell wraps itself in `data-theme={theme}` so the alt theme's tokens +cascade through that subtree only. diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx new file mode 100644 index 0000000..c3a2d7a --- /dev/null +++ b/app/components/layout/app-shell.tsx @@ -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(() => { + 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 ( +
    + + Skip to main content + + + +
    + {/* Mobile-only menu trigger, floating top-left of main */} + + + + + + + +
    + +
    + {brand.name} +
    +
    + +
    + + + {/* Floating glass pill, top-right, replacing the appbar action group */} +
    + + + + + + + + + + {profile.avatarUrl ? ( + + ) : null} + {user.initials} + + + + + + {user.name} + + {user.email} + + + + + navigate("/profile")} + > + Profile + + navigate("/settings")} + > + Settings + + + Help + + + { + signOut() + navigate("/login", { replace: true }) + }} + > + Sign out + + + +
    + +
    + {children} +
    +
    + + + +
    + ) +} + +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(null) + const bodyRef = useRef(null) + const kindRef = useRef(null) + const hrefRef = useRef(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 ( +
    + + + + + +
    + ) +} + +function NotificationsBell() { + const items = useNotifications() + const unread = unreadCount(items) + const navigate = useNavigate() + + useEffect(() => { + seedIfEmpty() + }, []) + + return ( + + + + + {unread > 0 && ( + + {unread > 9 ? "9+" : unread} + + )} + + + } + /> + +
    + Notifications +
    + + +
    +
    +
      + {items.length === 0 ? ( +
    • + No notifications. +
    • + ) : ( + items.map((n) => ( +
    • + + + +
    • + )) + )} +
    +
    +
    + ) +} diff --git a/app/components/layout/appbar.tsx b/app/components/layout/appbar.tsx new file mode 100644 index 0000000..96df632 --- /dev/null +++ b/app/components/layout/appbar.tsx @@ -0,0 +1,48 @@ +import type { ComponentProps } from "react" + +import { cn } from "~/lib/utils" + +function Appbar({ className, ...props }: ComponentProps<"header">) { + return ( +
    + ) +} + +function AppbarTitle({ className, ...props }: ComponentProps<"span">) { + return ( + + ) +} + +function AppbarSpacer({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ) +} + +function AppbarActions({ className, ...props }: ComponentProps<"div">) { + return ( +
    + ) +} + +export { Appbar, AppbarTitle, AppbarSpacer, AppbarActions } diff --git a/app/components/layout/background-picker.tsx b/app/components/layout/background-picker.tsx new file mode 100644 index 0000000..914fc3e --- /dev/null +++ b/app/components/layout/background-picker.tsx @@ -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(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 ( + + + + + } + /> + + + Background + Pick an atmosphere. + +
    + {backgrounds.map((bg) => { + const active = current === bg.id + return ( + + ) + })} +
    +
    +
    + ) +} diff --git a/app/components/layout/font-size-picker.tsx b/app/components/layout/font-size-picker.tsx new file mode 100644 index 0000000..4f8277f --- /dev/null +++ b/app/components/layout/font-size-picker.tsx @@ -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 = { + sm: 14, + md: 16, + lg: 18, + xl: 20, +} + +export function FontSizePicker() { + const [current, setCurrent] = useState(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 ( + + + + + } + /> + + + Font size + Scales the entire UI. + +
    + {fontSizes.map((f) => { + const active = current === f.id + return ( + + ) + })} +
    +
    +
    + ) +} diff --git a/app/components/layout/surface-picker.tsx b/app/components/layout/surface-picker.tsx new file mode 100644 index 0000000..d51bfd1 --- /dev/null +++ b/app/components/layout/surface-picker.tsx @@ -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(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 ( + + + + + } + /> + + + Surface + Tint of cards and the sidebar. + +
    + {surfaces.map((s) => { + const active = current === s.id + return ( + + ) + })} +
    +
    +
    + ) +} diff --git a/app/components/layout/theme-toggle.tsx b/app/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..dd669ef --- /dev/null +++ b/app/components/layout/theme-toggle.tsx @@ -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 ( + + ) +} diff --git a/app/components/scripts-dialog.tsx b/app/components/scripts-dialog.tsx new file mode 100644 index 0000000..be92f1e --- /dev/null +++ b/app/components/scripts-dialog.tsx @@ -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(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 ( + + + + Run a script + + Pick a saved script or paste DSL. Cmd/Ctrl + Shift + P toggles this. + + + +
    +
    +
    + Saved +
    +
    + {KNOWN_SCRIPTS.map((s) => ( + + ))} +
    +
    + +
    +
    + Paste DSL +
    +