diff --git a/.gitignore b/.gitignore
index 358ef59..c641b88 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@
# Generated by `npm run build:docs` — regenerated on every full build
# (prebuild) and on demand during dev. Don't commit the artifact.
/public/docs-index.json
+
+# impeccable tooling scratch (critique snapshots, live-server state)
+.impeccable/
diff --git a/CLAUDE.md b/CLAUDE.md
index 9411b5d..2eb3766 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,7 +26,7 @@ This file is a quick map, not a duplication of upstream docs.
- `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.
+- `npm run typecheck` — `react-router typegen && tsc`. **Works** (2026-07-14). Run it before every commit.
- `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).
@@ -141,6 +141,7 @@ This repo was scaffolded from `create-crema-app`, which patches marker comments.
## 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.
+- `npm run typecheck` **does not crash** — verified 2026-07-14. The old "it crashes, rely on careful reads" note was stale, and it cost us: a route shipped using `Input`/`Textarea` without importing them, which `tsc` reports instantly as TS2304 but nobody was running it. It currently reports ~39 pre-existing errors in `app/` (mostly `TS2322` prop mismatches) and more in sibling libs; treat *new* errors as blocking even while that backlog stands.
+- Every route that renders `` re-exports a shared route-level error boundary (`app/components/route-error.tsx`). Keep that export when adding routes — without it, one crashing panel replaces the entire console with an unstyled stack trace and strands the operator with no nav.
- 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/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..fca9af3
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,40 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Two audiences share one build, split by JWT capability gating:
+
+- **Platform operators** (`platform_admin`) — Sky AI staff running the whole arcadia-core fleet: provisioning tenants, watching monitoring, rotating secrets, managing SSO, announcements, status page, integrations. Expert, technical, use it daily, usually on desktop.
+- **Tenant admins** (`tenant_admin`) — a customer's administrator managing their own tenant: users, memberships, plan, entitlements, storage, activity. Semi-technical; may visit rarely (only when something needs changing), so rediscoverability matters more than muscle memory.
+
+The job to be done is administrative control of a multi-tenant agentic-cloud platform: "get in, change the thing safely, verify it took effect, get out."
+
+## Product Purpose
+
+Arcadia Admin is the operator/admin UI for arcadia-core (multi-tenant Phoenix backend). It surfaces tenant lifecycle, user/role administration, billing (apps/plan/entitlements), storage, secrets, webhooks, scheduled tasks, SSO, announcements, status page, monitoring, and audit on top of arcadia's `/api/v1` and `/admin/*` endpoints. Success = an operator can onboard a new tenant end-to-end and change any runtime setting without reaching for iex/mix tasks or SSH.
+
+## Brand Personality
+
+Calm, capable, trustworthy. "Premium AI-first glass" (Skyrise theme) but the register is product: the tool should disappear into the task. Confidence through clarity and safe defaults, not decoration.
+
+## Anti-references
+
+- Enterprise admin sprawl (endless nested settings à la old AWS console).
+- Toy dashboards — fake stat tiles, decorative charts with no drill-down.
+- Anything that makes destructive platform actions (delete tenant, rotate secret) feel casual.
+
+## Design Principles
+
+1. **Safe by construction** — destructive/irreversible actions are explicit, confirmed, and auditable; errors name the fix.
+2. **One contract, everywhere** — capability map drives nav and guards; `data-action` ids on every interactive element (AI/script drivable).
+3. **Verify what you changed** — every mutation reflects visible state (toast + updated row + audit trail), never silent success.
+4. **Recognition over recall** — a tenant admin who shows up quarterly should find everything from the sidebar without training.
+5. **Density where experts live** — tables and panels can be dense, but each screen has one clear primary action.
+
+## Accessibility & Inclusion
+
+WCAG AA floor (4.5:1 body text), full keyboard operability, visible focus, labeled icons, `prefers-reduced-motion` honored. Desktop-first but mobile shell must remain usable (operators respond to incidents from phones).
diff --git a/app/components/data-state.tsx b/app/components/data-state.tsx
new file mode 100644
index 0000000..799370a
--- /dev/null
+++ b/app/components/data-state.tsx
@@ -0,0 +1,183 @@
+import { useEffect, useState, type ReactNode } from "react"
+import { AlertTriangle, RefreshCw, ShieldOff, WifiOff } from "lucide-react"
+import { LoadingOverlay } from "@crema/feedback-ui"
+
+import { Button } from "~/components/ui/button"
+import { describeError, type LoadError } from "~/lib/errors"
+
+/**
+ * The load-state discriminator every list screen renders through.
+ *
+ * The rule it enforces: **a failed load is never an empty one.** Screens used
+ * to render their "No events match those filters — loosen the filter set…"
+ * empty state underneath a red "Too Many Requests" banner, so an operator
+ * couldn't tell a quiet audit log from a broken one. Exactly one of
+ * error / loading / empty / content renders here, ever.
+ */
+export function DataState({
+ loading,
+ error,
+ isEmpty,
+ empty,
+ onRetry,
+ loadingLabel = "Loading…",
+ children,
+}: {
+ loading: boolean
+ /** The raw thrown value; normalised for display here. */
+ error: unknown
+ isEmpty: boolean
+ /** What to show when the load succeeded and there is genuinely nothing. */
+ empty: ReactNode
+ onRetry: () => void
+ loadingLabel?: string
+ children: ReactNode
+}) {
+ if (error) return
+
+ // First load: nothing to show yet. Subsequent refreshes keep the table on
+ // screen and let the table's own `loading` prop dim it, so the page doesn't
+ // flash empty every time an operator hits Refresh.
+ if (loading && isEmpty)
+ return (
+
+ )
+}
+
+/**
+ * An error raised while a dialog is open, rendered *inside* that dialog.
+ *
+ * Page-level banners are invisible here: the modal scrim dims them and the
+ * dialog covers them. A failed submit has to speak where the operator is
+ * looking — right above the buttons they just pressed.
+ */
+export function DialogError({
+ error,
+ context = "save",
+}: {
+ error: unknown
+ context?: string
+}) {
+ const d = describeError(error, context)
+
+ return (
+
+
+
+
{d.title}
+ {d.detail ?
{d.detail}
: null}
+ {d.fields?.length ? (
+
+ {d.fields.map((f) => (
+
{f}
+ ))}
+
+ ) : null}
+
+
+ )
+}
+
+/** 429s resolve on their own — count down, retry, and say so. Nagging the
+ * operator to click Retry into a rate limiter would just extend it. */
+function AutoRetry({
+ seconds,
+ onRetry,
+}: {
+ seconds: number
+ onRetry: () => void
+}) {
+ const [left, setLeft] = useState(seconds)
+
+ useEffect(() => {
+ if (left <= 0) {
+ onRetry()
+ return
+ }
+ const t = setTimeout(() => setLeft((n) => n - 1), 1000)
+ return () => clearTimeout(t)
+ // `onRetry` is intentionally excluded: routes hand us a fresh closure each
+ // render, and depending on it would reset the countdown forever.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [left])
+
+ return (
+
+
+
+ retrying in {left}s
+
+
+ )
+}
diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx
index ed6b1e9..5ab4c95 100644
--- a/app/components/layout/app-shell.tsx
+++ b/app/components/layout/app-shell.tsx
@@ -18,7 +18,6 @@ import {
PanelLeftOpen,
User as UserIcon,
LogOut,
- HelpCircle,
Menu,
Play,
HardDrive,
@@ -38,8 +37,6 @@ import {
Database,
Plug,
MessageSquare,
- Eye,
- LayoutGrid,
CreditCard,
// CREMA:NAV-ICONS
} from "lucide-react"
@@ -76,7 +73,6 @@ import {
dismissAll,
markAllRead,
markRead,
- seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
@@ -121,7 +117,10 @@ const pinnedTop: NavItem[] = [
]
// Pinned items render flat at the bottom of the rail, below all groups.
+// Audit log is cross-cutting rather than owned by any one group, so it sits
+// here next to Settings.
const pinnedBottom: NavItem[] = [
+ { to: "/audit-log", icon: Activity, label: "Audit log" },
{ to: "/settings", icon: Settings, label: "Settings" },
]
@@ -132,9 +131,9 @@ const navGroups: NavGroup[] = [
icon: Building2,
items: [
{ to: "/tenants", icon: Building2, label: "Tenants" },
- { to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/organizations", icon: Building, label: "Organizations" },
{ to: "/users", icon: UsersIcon, label: "Users" },
+ { to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
],
},
@@ -142,11 +141,9 @@ const navGroups: NavGroup[] = [
key: "billing",
label: "Billing",
icon: CreditCard,
- items: [
- { to: "/apps", icon: LayoutGrid, label: "Apps" },
- { to: "/plan", icon: CreditCard, label: "Plan" },
- { to: "/entitlements", icon: Gauge, label: "Entitlements" },
- ],
+ // One item today (Plan/Entitlements/Apps collapsed here — none has a live
+ // endpoint yet). They split back into siblings under this group once wired.
+ items: [{ to: "/billing", icon: CreditCard, label: "Plan & usage" }],
},
{
key: "data",
@@ -156,17 +153,25 @@ const navGroups: NavGroup[] = [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
- { to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
- key: "integrations",
- label: "Integrations",
+ key: "automation",
+ label: "Automation",
icon: Plug,
items: [
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
+ { to: "/integrations", icon: Plug, label: "Integrations" },
+ ],
+ },
+ {
+ key: "infrastructure",
+ label: "Infrastructure",
+ icon: Network,
+ items: [
{ to: "/networking", icon: Network, label: "Networking" },
+ { to: "/monitoring", icon: Gauge, label: "Monitoring" },
],
},
{
@@ -178,15 +183,6 @@ const navGroups: NavGroup[] = [
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
],
},
- {
- key: "observability",
- label: "Observability",
- icon: Eye,
- items: [
- { to: "/monitoring", icon: Gauge, label: "Monitoring" },
- { to: "/activity", icon: Activity, label: "Audit log" },
- ],
- },
{
key: "ai",
label: "AI & Search",
@@ -260,8 +256,12 @@ export function AppShell({
// short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState(() => {
- if (typeof window === "undefined") return false
- return localStorage.getItem(SIDEBAR_KEY) === "1"
+ if (typeof window === "undefined") return true
+ // Default to expanded on first run — an icon-only rail of ~18 pictograms is
+ // unreadable to anyone who hasn't memorised it. Collapse stays available and
+ // is remembered once chosen.
+ const stored = localStorage.getItem(SIDEBAR_KEY)
+ return stored === null ? true : stored === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
@@ -627,9 +627,6 @@ export function AppShell({
>
Settings
-
- Help
- {
- seedIfEmpty()
- }, [])
-
return (
+
+
+
- This view requires the {capability}{" "}
- capability on your active tenant. If you think you should have it,
- switch tenants from the avatar menu or ask an admin.
+ This view needs the{" "}
+ {capability} capability,
+ which your account doesn't hold on the current tenant. Ask a platform
+ administrator to grant it.
diff --git a/app/components/tenant-detail/branding-tab.tsx b/app/components/tenant-detail/branding-tab.tsx
new file mode 100644
index 0000000..0cba271
--- /dev/null
+++ b/app/components/tenant-detail/branding-tab.tsx
@@ -0,0 +1,201 @@
+import { useState } from "react"
+
+import { useArcadiaClient } from "@crema/arcadia-core-client"
+import { useToast } from "@crema/notification-ui"
+
+import { TenantSection, Field } from "~/components/tenant-detail/section"
+import { Input } from "~/components/ui/input"
+import { Textarea } from "~/components/ui/textarea"
+import { updateBranding } from "~/lib/arcadia/tenants"
+import type { TenantTabProps } from "~/routes/tenants.$id"
+
+/**
+ * Tenant branding: logo/favicon URLs, the three brand colours, and a custom-CSS
+ * override. All fields are optional; clearing one and saving sends `null` so it
+ * clears server-side (the server accepts null and validates any colour it does
+ * get against a `#rrggbb` hex).
+ */
+export function BrandingTab({ tenant, reload }: TenantTabProps) {
+ const arcadia = useArcadiaClient()
+ const toast = useToast()
+ const b = tenant.branding
+
+ const [logoUrl, setLogoUrl] = useState(b.logo_url ?? "")
+ const [faviconUrl, setFaviconUrl] = useState(b.favicon_url ?? "")
+ const [primary, setPrimary] = useState(b.primary_color ?? "")
+ const [secondary, setSecondary] = useState(b.secondary_color ?? "")
+ const [accent, setAccent] = useState(b.accent_color ?? "")
+ const [customCss, setCustomCss] = useState(b.custom_css ?? "")
+
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+
+ const dirty =
+ logoUrl !== (b.logo_url ?? "") ||
+ faviconUrl !== (b.favicon_url ?? "") ||
+ primary !== (b.primary_color ?? "") ||
+ secondary !== (b.secondary_color ?? "") ||
+ accent !== (b.accent_color ?? "") ||
+ customCss !== (b.custom_css ?? "")
+
+ const save = async () => {
+ setSaving(true)
+ setError(null)
+ try {
+ await updateBranding(arcadia, tenant.id, {
+ logo_url: emptyToNull(logoUrl),
+ favicon_url: emptyToNull(faviconUrl),
+ primary_color: emptyToNull(primary),
+ secondary_color: emptyToNull(secondary),
+ accent_color: emptyToNull(accent),
+ custom_css: customCss.trim() === "" ? null : customCss,
+ })
+ await reload()
+ toast.success("Branding updated")
+ } catch (err) {
+ setError(err)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+ setLogoUrl(e.target.value)}
+ placeholder="https://…"
+ data-action="tenant-detail-branding-logo-url"
+ />
+
+
+
+ setFaviconUrl(e.target.value)}
+ placeholder="https://…"
+ data-action="tenant-detail-branding-favicon-url"
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * A hex-colour control: a native colour swatch and a text input kept in sync,
+ * plus a live preview chip. The text field holds the source of truth (may be
+ * empty or a partial/invalid hex mid-edit); the swatch falls back to black so
+ * the picker always has something to show.
+ */
+function ColorField({
+ label,
+ id,
+ value,
+ onChange,
+ dataAction,
+}: {
+ label: string
+ id: string
+ value: string
+ onChange: (v: string) => void
+ dataAction: string
+}) {
+ const trimmed = value.trim()
+ const valid = /^#[0-9a-fA-F]{6}$/.test(trimmed)
+
+ return (
+
+
+ {exists
+ ? "Write-only and never shown again. Leave blank to keep the stored credentials; fill in to replace them."
+ : "Write-only and never shown again."}
+