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 87d12bc..2eb3766 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,9 +7,9 @@ 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.
+- **Arcadia Admin** is the operator/admin UI for [arcadia-core](../reference/arcadia-core), 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).
+- **Backend reference** lives at `../reference/arcadia-core/`. 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-core-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.
@@ -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/README.md b/README.md
index e8986a3..c9272c9 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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.
+Admin webapp for [arcadia-core](../reference/arcadia-core) — 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.
@@ -15,7 +15,7 @@ Open [http://localhost:5173](http://localhost:5173). The app talks to arcadia at
To use it for real:
-1. Have arcadia running locally (see `../reference/arcadia-app/DEV_SETUP.md`).
+1. Have arcadia running locally (see `../reference/arcadia-core/DEV_SETUP.md`).
2. Visit `/login` and sign in with admin credentials. In dev seeds: `admin@example.com` / `AdminP@ssw0rd` (tenant `default`).
## Configuration
@@ -33,7 +33,7 @@ To use it for real:
`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-core-client`](../lib-arcadia-core-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
@@ -50,8 +50,8 @@ Surface tints (`body[data-surface="snow|stone|sage|slate"]`) and dark mode (`htm
your-workspace/
arcadia-admin/ ← this repo
vibespace/ ← starter that this was cloned from
- reference/arcadia-app/ ← Phoenix backend (read-only reference)
- lib-arcadia-client/
+ reference/arcadia-core/ ← Phoenix backend (read-only reference)
+ lib-arcadia-core-client/
lib-arcadia-auth-ui/
lib-action-bus/
lib-aifirst-ui/
@@ -84,4 +84,4 @@ your-workspace/
- [`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)
+- `../reference/arcadia-core/` — backend (DEV_SETUP, controllers, OpenAPI source-of-truth)
diff --git a/app/app.css b/app/app.css
index 6dfa9da..91732a1 100644
--- a/app/app.css
+++ b/app/app.css
@@ -15,7 +15,7 @@
@source "../../lib-aifirst-ui/src";
@source "../../lib-llm-ui/src";
@source "../../lib-action-bus/src";
-@source "../../lib-arcadia-client/src";
+@source "../../lib-arcadia-core-client/src";
@source "../../lib-arcadia-auth-ui/src";
@source "../../lib-table-ui/src";
@source "../../lib-search-ui/src";
@@ -156,3 +156,21 @@
}
}
+
+/* Accessibility: honour a reduced-motion request. Skyrise leans on ambient
+ * drift (the aurora field), spring transitions, and looping keyframes; for
+ * anyone who asks for less motion, near-instant everything and freeze the
+ * decorative loops. This is the global guard the app previously lacked. */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ [data-slot="aurora-field"] {
+ animation: none !important;
+ }
+}
diff --git a/app/components/copy-id.tsx b/app/components/copy-id.tsx
new file mode 100644
index 0000000..fde3c7d
--- /dev/null
+++ b/app/components/copy-id.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react"
+import { Check, Copy } from "lucide-react"
+
+/**
+ * A monospace id (UUID, slug, token name) that copies to the clipboard on
+ * click, with a brief checkmark. Raw ids are common in an admin console and
+ * useless if you can't get them into a support ticket or a CLI.
+ */
+export function CopyId({
+ value,
+ label,
+ className = "",
+ dataAction,
+}: {
+ value: string
+ /** What's being copied, for the aria-label. Defaults to "id". */
+ label?: string
+ className?: string
+ dataAction?: string
+}) {
+ const [copied, setCopied] = useState(false)
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1200)
+ } catch {
+ // Clipboard blocked (insecure context / permissions) — no-op; the value
+ // is still selectable by hand.
+ }
+ }
+
+ return (
+
+ )
+}
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..09129cd 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,9 +37,9 @@ import {
Database,
Plug,
MessageSquare,
- Eye,
- LayoutGrid,
CreditCard,
+ Flag,
+ Eye,
// CREMA:NAV-ICONS
} from "lucide-react"
@@ -68,7 +67,7 @@ import {
PopoverTrigger,
} from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile"
-import { signOut, useSession } from "~/lib/session"
+import { endImpersonation, signOut, useSession } from "~/lib/session"
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
import {
addNotification,
@@ -76,7 +75,6 @@ import {
dismissAll,
markAllRead,
markRead,
- seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
@@ -121,7 +119,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 +133,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 +143,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 +155,26 @@ const navGroups: NavGroup[] = [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
+ ],
+ },
+ {
+ key: "automation",
+ label: "Automation",
+ icon: Plug,
+ items: [
+ { to: "/feature-flags", icon: Flag, label: "Feature flags" },
+ { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
+ { to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
- key: "integrations",
- label: "Integrations",
- icon: Plug,
+ key: "infrastructure",
+ label: "Infrastructure",
+ icon: Network,
items: [
- { to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
- { to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/networking", icon: Network, label: "Networking" },
+ { to: "/monitoring", icon: Gauge, label: "Monitoring" },
],
},
{
@@ -178,15 +186,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 +259,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")
@@ -357,7 +360,7 @@ export function AppShell({
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",
+ "sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard motion-reduce:transition-none md:flex",
expanded ? "w-60" : "w-16",
].join(" ")}
>
@@ -468,6 +471,7 @@ export function AppShell({
+
{/* Mobile-only menu trigger, floating top-left of main */}
Settings
-
- Help
-
+ {/* pt-16 on mobile reserves a row for the floating hamburger (left)
+ and actions pill (right) so they never overlap the page H1; the
+ desktop rail/pill sit outside this column, so pt drops to p-6. */}
+
{children}
@@ -692,6 +696,9 @@ function NavRow({
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
+ // When collapsed there's no visible label text, only an icon — give
+ // screen readers the name explicitly (title alone isn't reliably read).
+ aria-label={expanded ? undefined : item.label}
onClick={onNavigate}
data-action={`${prefix}${item.label.toLowerCase()}`}
className={({ isActive }) =>
@@ -790,15 +797,42 @@ function NotificationDispatcher() {
)
}
+function ImpersonationBanner() {
+ const session = useSession()
+ const navigate = useNavigate()
+ if (!session?.impersonatedBy) return null
+
+ return (
+
+
+
+ Viewing as {session.email} — actions you take
+ happen as this user.
+
+
+
- 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/settings/llm-configurations-panel.tsx b/app/components/settings/llm-configurations-panel.tsx
index 23e6554..cb1f72a 100644
--- a/app/components/settings/llm-configurations-panel.tsx
+++ b/app/components/settings/llm-configurations-panel.tsx
@@ -9,7 +9,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
-import { useArcadiaClient } from "@crema/arcadia-client"
+import { useArcadiaClient } from "@crema/arcadia-core-client"
import {
loadSettings as loadActiveSettings,
saveSettings as saveActiveSettings,
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."}
+