initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

78
web/src/App.tsx Normal file
View file

@ -0,0 +1,78 @@
/**
* The route table.
*
* Every page requires an authenticated session. That is enforced by
* `RequireAuth` on the layout route rather than per page, so a new page added later
* is protected by default instead of by remembering to add a guard.
*/
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppProviders } from './components/providers'
import { AppShell, RequireAuth } from './components/shell'
/**
* The router.
*
* `lazy` defers each route's module until it is first visited, so the initial
* payload is the shell rather than every page in the application. Each route is a
* default export, so the loader unwraps `.default` to satisfy React Router's
* `ComponentType` contract.
*/
const router = createBrowserRouter([
{
path: '/',
element: (
<AppProviders>
<RequireAuth>
<AppShell />
</RequireAuth>
</AppProviders>
),
children: [
{ index: true, lazy: { Component: () => import('./routes/dashboard').then((m) => m.default) } },
{ path: 'themes', lazy: { Component: () => import('./routes/theme-list').then((m) => m.default) } },
{ path: 'themes/:slug', lazy: { Component: () => import('./routes/theme-detail').then((m) => m.default) } },
{ path: 'themes/:slug/versions', lazy: { Component: () => import('./routes/versions').then((m) => m.default) } },
{ path: 'audit', lazy: { Component: () => import('./routes/audit').then((m) => m.default) } },
{ path: 'integrate', lazy: { Component: () => import('./routes/integrate').then((m) => m.default) } },
{ path: 'settings', lazy: { Component: () => import('./routes/settings').then((m) => m.default) } },
],
},
{
// The login route is outside the authenticated shell: wrapping it in
// RequireAuth would be a circular requirement.
path: '/login',
lazy: { Component: () => import('./routes/login').then((m) => m.default) },
},
{
path: '*',
element: <NotFound />,
},
])
/** The 404 page. Rendered outside the shell so it works even when unauthenticated. */
function NotFound() {
return (
<div className="grid min-h-screen place-items-center p-6">
<div className="text-center">
<p className="text-ink-subtle text-xs font-medium uppercase tracking-widest">404</p>
<h1 className="text-ink mt-2 text-lg font-semibold">That page does not exist</h1>
<p className="text-ink-muted mt-2 text-xs">
The link may be out of date, or the theme may have been deleted.
</p>
<a
href="/"
className="border-line bg-raised text-ink hover:bg-overlay mt-5 inline-flex h-9 items-center rounded-control border px-3.5 text-sm font-medium"
>
Back to the dashboard
</a>
</div>
</div>
)
}
/** Mounts the application. */
export function App() {
return <RouterProvider router={router} />
}

View file

@ -0,0 +1,34 @@
import { createContext } from 'react'
import type { AdminThemeMode } from './admin-theme'
/**
* The theme context, in its own module.
*
* It is separated from the provider so that `admin-theme.tsx` exports only a
* component. A module that exports both a component and a hook disables React Fast
* Refresh for that module, which turns every save into a full reload during
* development.
*/
export type AdminThemeValue = {
mode: AdminThemeMode
/** The slug being previewed, or null when the admin chrome is active. */
previewSlug: string | null
setMode: (mode: AdminThemeMode) => void
setPreviewSlug: (slug: string | null) => void
/** The slugs available to preview, from the runtime manifest. */
available: string[]
/** The runtime base URL, for building preview URLs. */
runtimeBase: string
/**
* Whether the shipped controls have been defined.
*
* The switcher waits on this before rendering the controls, because a custom
* element that has not been defined yet renders as an empty inline box and then
* upgrades, which reads as a layout glitch rather than a load.
*/
controlsReady: boolean
}
/** The context instance. Typed null so a missing provider is detectable. */
export const AdminThemeContextInstance = createContext<AdminThemeValue | null>(null)

View file

@ -0,0 +1,188 @@
/**
* Tests for the admin theme provider.
*
* The behaviour that matters is the account-backed preference: the switcher must
* start on the theme the person chose, on a browser that has never seen it, and a
* change must reach the API so the next browser does too.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AdminThemeProvider } from './admin-theme'
import { useAdminTheme } from './use-admin-theme'
vi.mock('../lib/api', () => ({
getManifest: vi.fn(() => Promise.resolve({ baseUrl: '/rt/v1', themes: [{ slug: 'midnight' }] })),
getSession: vi.fn(() => Promise.resolve({ authenticated: true, mode: 'oidc' })),
updatePreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
}))
const api = await import('../lib/api')
const THEME_ATTRIBUTE = 'data-bananas-theme'
const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
/** A probe that renders the current choice and exposes the setters. */
function Probe() {
const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme()
return (
<div>
<span data-testid="mode">{mode}</span>
<span data-testid="slug">{previewSlug ?? ''}</span>
<span data-testid="available">{available.join(',')}</span>
<button type="button" onClick={() => setMode('theme-dark')}>
dark
</button>
<button type="button" onClick={() => setMode('admin')}>
admin
</button>
<button type="button" onClick={() => setPreviewSlug('midnight')}>
midnight
</button>
</div>
)
}
function renderProvider() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
return render(
<QueryClientProvider client={client}>
<AdminThemeProvider>
<Probe />
</AdminThemeProvider>
</QueryClientProvider>,
)
}
beforeEach(() => {
window.localStorage.clear()
document.documentElement.removeAttribute(THEME_ATTRIBUTE)
document.documentElement.removeAttribute(APPEARANCE_ATTRIBUTE)
vi.mocked(api.getSession).mockResolvedValue({
authenticated: true,
mode: 'oidc',
} as unknown as Awaited<ReturnType<typeof api.getSession>>)
vi.mocked(api.updatePreferences).mockResolvedValue({
selectedTheme: 'midnight',
selectedAppearance: 'dark',
})
})
afterEach(() => {
vi.clearAllMocks()
})
// TestAccountsWithNoStoredChoiceKeepTheirBrowserDefault: an account that has never
// chosen a theme must not have its local value overwritten by the server's empty
// preference, which would reset a browser that had made a choice before the feature
// existed.
describe('with no stored preference', () => {
it('keeps the browser default', async () => {
renderProvider()
await waitFor(() => expect(screen.getByTestId('available')).toHaveTextContent('midnight'))
expect(screen.getByTestId('mode')).toHaveTextContent('admin')
expect(api.updatePreferences).not.toHaveBeenCalled()
})
})
describe('with a stored preference', () => {
beforeEach(() => {
vi.mocked(api.getSession).mockResolvedValue({
authenticated: true,
mode: 'oidc',
preferences: { selectedTheme: 'midnight', selectedAppearance: 'dark' },
} as unknown as Awaited<ReturnType<typeof api.getSession>>)
})
// This is the cross-browser case: localStorage is empty, so nothing but the
// account can supply the theme.
it('adopts the stored theme in a browser that has never seen it', async () => {
renderProvider()
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
expect(screen.getByTestId('slug')).toHaveTextContent('midnight')
// The previewed palette is fetched as a stylesheet. Injecting it stays the
// provider's job: the shipped control selects a palette, but only this service
// knows that its own chrome is styled by the full variant of a theme.
await waitFor(() =>
expect(
document.querySelector('link[data-bananas-admin="bananas-admin-preview"]')?.getAttribute('href'),
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
)
})
// The document attributes belong to the shipped control, so that there is one writer
// for them rather than two that can disagree. This test pins that division of
// responsibility: if the provider starts writing them again, it will fail.
it('leaves the document attributes to the shipped controls', async () => {
renderProvider()
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
await waitFor(() => expect(screen.getByTestId('slug')).toHaveTextContent('midnight'))
expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false)
expect(document.documentElement.hasAttribute(APPEARANCE_ATTRIBUTE)).toBe(false)
})
it('writes the choice back to the API when the mode changes', async () => {
renderProvider()
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
await userEvent.click(screen.getByRole('button', { name: 'dark' }))
// theme-dark is already the stored mode, so the visible choice is unchanged and
// only the write matters here.
await waitFor(() =>
expect(api.updatePreferences).toHaveBeenCalledWith({
selectedTheme: 'midnight',
selectedAppearance: 'dark',
}),
)
})
// Returning to the admin chrome is the absence of a theme, so it must forget the
// preference. Persisting the slug that is still selected in the dropdown would make
// the next browser open on a theme this person had just switched away from.
it('forgets the preference when the admin chrome is chosen again', async () => {
renderProvider()
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
await userEvent.click(screen.getByRole('button', { name: 'admin' }))
await waitFor(() => expect(api.resetPreferences).toHaveBeenCalled())
expect(api.updatePreferences).not.toHaveBeenCalled()
expect(screen.getByTestId('mode')).toHaveTextContent('admin')
})
it('records the slug when a different theme is chosen', async () => {
vi.mocked(api.updatePreferences).mockResolvedValue({
selectedTheme: 'midnight',
selectedAppearance: 'light',
})
renderProvider()
await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark'))
await userEvent.click(screen.getByRole('button', { name: 'midnight' }))
await waitFor(() =>
expect(api.updatePreferences).toHaveBeenCalledWith({
selectedTheme: 'midnight',
selectedAppearance: 'dark',
}),
)
})
})
// TestAFailedWriteDoesNotBreakTheSwitcher: persisting a preference is best effort.
// The interface has already changed locally, so a network failure must not undo it
// or leave the switcher unusable.
describe('when persisting fails', () => {
it('keeps the local choice and does not throw', async () => {
vi.mocked(api.updatePreferences).mockRejectedValue(new Error('offline'))
renderProvider()
await waitFor(() => expect(screen.getByTestId('available')).toHaveTextContent('midnight'))
await userEvent.click(screen.getByRole('button', { name: 'dark' }))
await waitFor(() => expect(api.updatePreferences).toHaveBeenCalled())
expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')
})
})

View file

@ -0,0 +1,264 @@
/**
* The admin UI's own theme.
*
* The admin interface is a consumer of Go Bananas!!, which is a deliberate
* constraint: it means the token engine is exercised by the tool that maintains
* it, and a change that breaks theme delivery breaks the editor immediately rather
* than being discovered by a customer.
*
* Three themes are offered — the admin chrome (dark), the previewed theme's light
* scheme, and its dark scheme — and switching between them writes the same
* attributes the runtime writes, so the CSS in index.css maps straight onto them.
*
* The choice belongs to the account rather than to the browser. localStorage is
* still read on the first render, because a server round trip cannot happen
* before paint and waiting for one would flash the wrong theme on every load, but
* the stored preference is authoritative once the session arrives: that is what
* carries a selection to another browser. Changing it writes both, so the next
* paint is correct even with the network unavailable.
*/
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AdminThemeContextInstance, type AdminThemeValue } from './admin-theme-context'
import { getManifest, getSession, resetPreferences, updatePreferences } from '../lib/api'
import { loadRuntimeControls } from '../lib/runtime-controls'
/** Which visual mode the admin chrome is in. */
export type AdminThemeMode = 'admin' | 'theme-light' | 'theme-dark'
/** The value used when a previewed theme has not been chosen yet. */
const DEFAULT_PREVIEW = 'midnight'
/**
* The context is created in its own module and imported here, so that this file
* exports only a component. A file that exports both a component and a hook
* defeats Fast Refresh, which costs every HMR in development for the sake of one
* import.
*/
const AdminThemeContext = AdminThemeContextInstance
const STORAGE_KEY = 'go-bananas:admin-theme-mode'
const PREVIEW_KEY = 'go-bananas:admin-preview'
/**
* What the switcher currently shows, as one value.
*
* Mode and slug are held together because they are always written together: the
* slug means nothing without a scheme to render it in. Reading one place rather
* than two also keeps the document attributes below consistent by construction.
*/
type ThemeChoice = { mode: AdminThemeMode; slug: string | null }
export function AdminThemeProvider({ children }: { children: ReactNode }) {
// Restored lazily through a state initialiser rather than in an effect: an effect
// would setState after the first render, producing a visible flash of the wrong
// theme on every page load.
const [choice, setChoice] = useState<ThemeChoice>(readStoredChoice)
const [adopted, setAdopted] = useState<string | null>(null)
const [controlsReady, setControlsReady] = useState(false)
// The manifest tells the UI which themes exist. A failure is not worth
// surfacing: the preview simply falls back to the default slug.
const { data: manifest } = useQuery({
queryKey: ['manifest'],
queryFn: ({ signal }) => getManifest(signal),
staleTime: 60_000,
})
// The session carries the account's stored preference, so the switcher starts on
// the right theme in a browser that has never seen it before. A failure is not
// worth surfacing: the locally stored choice remains in force and the UI works.
const { data: session } = useQuery({
queryKey: ['session'],
queryFn: ({ signal }) => getSession(signal),
staleTime: 60_000,
})
const stored = session?.preferences
// Adopt the stored preference as it arrives, by adjusting state during render
// rather than in an effect. This is React's documented pattern for deriving state
// from a value that is not available on the first render, and it matters here for
// correctness as well as style: an effect would paint the old theme for a frame
// after the switcher had already been given the right value.
//
// The adopted marker makes it a one-time adoption per value, so a later change
// made in this browser is not undone by a refetch. An empty stored theme means no
// choice was made, which leaves whatever this browser had alone rather than
// resetting it to a default the person did not ask for.
if (stored && adopted !== stored.selectedTheme) {
setAdopted(stored.selectedTheme)
if (stored.selectedTheme !== '') {
setChoice({
slug: stored.selectedTheme,
mode: stored.selectedAppearance === 'dark' ? 'theme-dark' : 'theme-light',
})
}
}
const { mode, slug: previewSlug } = choice
const available = useMemo(() => manifest?.themes.map((theme) => theme.slug) ?? [], [manifest])
const runtimeBase = manifest?.baseUrl ?? '/rt/v1'
// Fetch the shipped controls from this service. A failure is not worth surfacing:
// the switcher keeps its previous control, and nothing else depends on this.
useEffect(() => {
let live = true
void loadRuntimeControls(runtimeBase)
.then(() => {
if (live) setControlsReady(true)
})
.catch(() => undefined)
return () => {
live = false
}
}, [runtimeBase])
/**
* Records a change on the account as well as in this browser.
*
* The request is fire-and-forget: the interface has already changed by the time
* it is sent, and failing to persist a preference is not something an operator
* editing a theme can act on. The local write is what makes the next paint
* correct without the network.
*/
const persist = useCallback((theme: string, appearance: 'light' | 'dark') => {
void updatePreferences({ selectedTheme: theme, selectedAppearance: appearance }).catch(
() => undefined,
)
}, [])
/** Applies a new choice locally and records it against the account. */
const choose = useCallback(
(next: ThemeChoice) => {
setChoice(next)
writeStored(STORAGE_KEY, next.mode)
if (next.slug) {
writeStored(PREVIEW_KEY, next.slug)
persist(next.slug, next.mode === 'theme-dark' ? 'dark' : 'light')
} else {
window.localStorage?.removeItem(PREVIEW_KEY)
}
},
[persist],
)
const setMode = useCallback(
(next: AdminThemeMode) => {
// The admin chrome is the absence of a theme, so returning to it forgets the
// preference rather than recording the slug that is still selected in the
// dropdown. Persisting the slug here would make the next browser open on a
// theme this person had just switched away from.
if (next === 'admin') {
setChoice({ mode: 'admin', slug: previewSlug })
writeStored(STORAGE_KEY, 'admin')
void resetPreferences().catch(() => undefined)
return
}
choose({ mode: next, slug: previewSlug })
},
[choose, previewSlug],
)
const setPreviewSlug = useCallback(
(slug: string | null) => {
choose({ mode, slug })
},
[choose, mode],
)
// The document attributes are no longer written from here. The shipped controls
// write them, including the prefixed names this stylesheet reads, so there is one
// writer and no chance of the two disagreeing. The admin state drives the elements
// instead; see the shell for the binding.
//
// The `go-bananas-admin` sentinel is gone with it. It was read by nothing at all:
// no rule in the stylesheet matched it and no component inspected it. "Admin
// chrome" is the state with no previewed palette applied, which is exactly what the
// appearance toggle's admin state produces.
// The injected stylesheet must follow the mode too, because a theme's light and
// dark schemes are two rules under the same scoped selector and the attribute
// decides which wins.
useEffect(() => {
const root = document.documentElement
if (mode === 'admin') {
root.removeAttribute('data-bananas-theme')
root.removeAttribute('data-bananas-appearance')
return
}
const slug = previewSlug ?? DEFAULT_PREVIEW
root.setAttribute('data-bananas-theme', slug)
root.setAttribute('data-bananas-appearance', mode === 'theme-dark' ? 'dark' : 'light')
const id = 'bananas-admin-preview'
let link = document.querySelector<HTMLLinkElement>(`link[data-bananas-admin="${id}"]`)
if (!link) {
link = document.createElement('link')
link.rel = 'stylesheet'
link.dataset.bananasAdmin = id
document.head.appendChild(link)
}
link.href = `${runtimeBase}/theme.css?slug=${encodeURIComponent(slug)}&variant=full`
}, [mode, previewSlug, runtimeBase])
// Remove the preview stylesheet when the chrome is restored, so that a
// published theme never lingers in the document after the editor stops using
// it.
useEffect(() => {
if (mode !== 'admin') return
document.querySelector('link[data-bananas-admin]')?.remove()
}, [mode])
const value = useMemo<AdminThemeValue>(
() => ({ mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady }),
[
mode,
previewSlug,
setMode,
setPreviewSlug,
available,
runtimeBase,
controlsReady,
],
)
return <AdminThemeContext.Provider value={value}>{children}</AdminThemeContext.Provider>
}
/**
* Reads the persisted choice, tolerating storage being unavailable.
*
* The slug falls back to the default preview so the stylesheet request has
* something to ask for even before the manifest arrives.
*/
function readStoredChoice(): ThemeChoice {
const stored = readStored(STORAGE_KEY)
const mode: AdminThemeMode =
stored === 'admin' || stored === 'theme-light' || stored === 'theme-dark' ? stored : 'admin'
return { mode, slug: readStored(PREVIEW_KEY) ?? DEFAULT_PREVIEW }
}
/** Reads a persisted value, tolerating storage being unavailable. */
function readStored(key: string): string | null {
try {
return window.localStorage.getItem(key)
} catch {
return null
}
}
/** Persists a value, tolerating storage being unavailable. */
function writeStored(key: string, value: string): void {
try {
window.localStorage.setItem(key, value)
} catch {
/* private browsing or a disabled storage partition */
}
}

View file

@ -0,0 +1,39 @@
/**
* Application-level providers.
*
* Kept in one file because the composition order is the interesting part and it is
* easier to reason about when the whole tree is visible at once: Query, then
* Toaster, then the router outlet.
*/
import { useState, type ReactNode } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
import { AdminThemeProvider } from './admin-theme'
import { createQueryClient } from './query-client'
/** Props for the provider tree. */
export function AppProviders({ children }: { children: ReactNode }) {
// The client is created once per mount. Creating it in the component body
// without state would discard the entire cache on every render.
const [client] = useState(createQueryClient)
return (
<QueryClientProvider client={client}>
<AdminThemeProvider>
{children}
<Toaster
position="bottom-right"
closeButton
richColors
toastOptions={{
classNames: {
toast: 'font-sans',
},
}}
/>
</AdminThemeProvider>
</QueryClientProvider>
)
}

View file

@ -0,0 +1,39 @@
import { QueryClient } from '@tanstack/react-query'
import { ApiError } from '../lib/api'
/**
* Builds the query client.
*
* Two defaults are deliberate:
*
* - `retry` skips 4xx responses. A 404 will not become a 404 on the third
* attempt, and retrying it only delays the error the user needs to see.
* - `staleTime` is non-zero because theme data changes on human timescales. A
* zero stale time would refetch on every component mount and every window
* focus, which in an editor with a form open is both wasteful and a source of
* lost edits.
*
* It lives in its own module so that `providers.tsx` exports only a component: a
* module exporting both a component and a function disables React Fast Refresh.
*/
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
return false
}
return failureCount < 2
},
refetchOnWindowFocus: false,
},
mutations: {
retry: false,
},
},
})
}

View file

@ -0,0 +1,526 @@
/**
* The application shell: sidebar navigation, top bar and the routed outlet.
*/
import React, { Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import {
Activity,
ChevronRight,
ExternalLink,
Layers,
LogOut,
Menu,
Palette,
ScrollText,
X,
} from 'lucide-react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getSession, logout, rebuildCache } from '../lib/api'
import type { AppearanceMode, BananasElement } from '../vite-env'
import type { AdminThemeMode } from './admin-theme'
import { cn } from '../lib/utils'
import { Button, ErrorState, Skeleton } from './ui'
import { useAdminTheme } from './use-admin-theme'
/** Navigation entries, in the order they appear. */
const navigation = [
{ to: '/', label: 'Dashboard', icon: Activity, end: true },
{ to: '/themes', label: 'Themes', icon: Palette, end: false },
{ to: '/audit', label: 'Activity', icon: ScrollText, end: false, adminOnly: true },
{ to: '/integrate', label: 'Integrate', icon: Layers, end: false },
{ to: '/settings', label: 'Settings', icon: Layers, end: false, adminOnly: true },
]
/** The application shell. */
export function AppShell() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [navOpen, setNavOpen] = useState(false)
const { data: session, isLoading, error } = useQuery({
queryKey: ['session'],
queryFn: ({ signal }) => getSession(signal),
staleTime: 60_000,
})
if (error) {
return (
<div className="p-8">
<ErrorState
title="Could not reach the API"
message="The administration interface cannot load its session. Check that the server is running and reload."
onRetry={() => window.location.reload()}
/>
</div>
)
}
const permissions = session?.permissions ?? {}
const items = navigation.filter((item) => !item.adminOnly || permissions.manageUsers)
async function handleLogout() {
const redirect = await logout()
// A full navigation rather than a client-side one, because the session cookie
// must be cleared before the next request is made.
window.location.assign(redirect)
}
async function handleRebuildCache() {
try {
const result = await rebuildCache()
toast.success(`Cache rebuilt`, {
description: `${result.artefacts} artefacts warmed across ${result.themes} themes`,
})
void queryClient.invalidateQueries({ queryKey: ['stats'] })
} catch (caught) {
toast.error('Could not rebuild the cache', {
description: caught instanceof Error ? caught.message : String(caught),
})
}
}
return (
<div className="bg-canvas text-ink flex min-h-screen">
{/* The sidebar is a landmark with a label, so a screen-reader user can jump
past it rather than tabbing through every link on every page. */}
<nav
aria-label="Primary"
className={cn(
'border-line bg-surface fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r',
'transition-transform lg:static lg:translate-x-0',
navOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="border-line flex h-14 items-center gap-2.5 border-b px-5">
<BananasMark />
<span className="text-ink text-sm font-semibold tracking-tight">go-bananas</span>
<button
type="button"
aria-label="Close navigation"
className="text-ink-muted hover:text-ink ml-auto lg:hidden"
onClick={() => setNavOpen(false)}
>
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3">
<ul className="space-y-0.5">
{items.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-control px-2.5 py-2 text-sm transition-colors',
isActive
? 'bg-brand-subtle text-brand font-medium'
: 'text-ink-muted hover:bg-raised hover:text-ink',
)
}
>
<item.icon className="size-4 shrink-0" aria-hidden />
{item.label}
</NavLink>
</li>
))}
</ul>
</div>
<div className="border-line space-y-1 border-t p-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
icon={<ExternalLink className="size-3.5" aria-hidden />}
onClick={() => window.open('/rt/v1/manifest.json', '_blank', 'noopener')}
>
Runtime manifest
</Button>
{permissions.manageSettings ? (
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => void handleRebuildCache()}
>
Rebuild cache
</Button>
) : null}
</div>
</nav>
{navOpen ? (
<button
type="button"
aria-label="Close navigation"
className="bg-black/60 fixed inset-0 z-30 lg:hidden"
onClick={() => setNavOpen(false)}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
<header className="border-line bg-surface/80 sticky top-0 z-20 flex h-14 items-center gap-3 border-b px-4 backdrop-blur lg:px-6">
<button
type="button"
aria-label="Open navigation"
className="text-ink-muted hover:text-ink lg:hidden"
onClick={() => setNavOpen(true)}
>
<Menu className="size-5" />
</button>
<Breadcrumbs />
<div className="ml-auto flex items-center gap-3">
<ThemeSwitcher />
{isLoading ? (
<Skeleton className="h-8 w-32" />
) : session?.authenticated ? (
<div className="flex items-center gap-2">
<div className="hidden text-right sm:block">
<p className="text-ink max-w-[12rem] truncate text-xs font-medium">
{session.identity?.name ?? session.identity?.email ?? session.identity?.subject}
</p>
<p className="text-ink-subtle text-[11px]">
{(session.identity?.roles ?? []).join(', ')}
</p>
</div>
<Button
variant="ghost"
size="icon"
aria-label="Sign out"
title="Sign out"
onClick={handleLogout}
>
<LogOut className="size-4" />
</Button>
</div>
) : (
<Button variant="primary" size="sm" onClick={() => navigate('/login')}>
Sign in
</Button>
)}
</div>
</header>
<main className="min-w-0 flex-1 p-4 lg:p-6">
<Suspense fallback={<RouteFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
)
}
/** The banana mark. Drawn inline so the shell needs no image asset. */
function BananasMark() {
return (
<svg
viewBox="0 0 24 24"
className="text-brand size-5 shrink-0"
fill="currentColor"
aria-hidden
>
<path d="M4 8c0-1.1.9-2 2-2h3c.6 0 1.1.2 1.5.6l7.2 6.4c.2.2.3.5.3.8V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8Z" opacity=".9" />
<path d="M20 8c0-1.1-.9-2-2-2h-1.2c-.6 0-1.1.2-1.5.6L8.1 13c-.2.2-.3.5-.3.8V20a2 2 0 0 0 2 2H18a2 2 0 0 0 2-2V8Z" opacity=".55" />
<path d="M8 5c0-.6.4-1 1-1h1.5c.3 0 .6.1.8.4l.5.5c.2.2.2.5 0 .7-.2.2-.5.2-.7 0l-.4-.4H9.5a.5.5 0 0 1-.5-.5V5Z" opacity=".7" />
</svg>
)
}
/** Breadcrumb trail derived from the current route. */
function Breadcrumbs() {
const segments = usePathSegments()
if (segments.length === 0) {
return <span className="text-ink-muted text-xs">Dashboard</span>
}
return (
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1 text-xs">
{segments.map((segment, index) => {
const last = index === segments.length - 1
return (
<span key={`${segment.label}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? (
<ChevronRight className="text-ink-subtle size-3 shrink-0" aria-hidden />
) : null}
{last ? (
<span className="text-ink truncate font-medium">{segment.label}</span>
) : (
<NavLink to={segment.to} className="text-ink-muted hover:text-ink truncate">
{segment.label}
</NavLink>
)}
</span>
)
})}
</nav>
)
}
/** Path-to-label mapping for the breadcrumb trail. */
const routeLabels: Record<string, string> = {
themes: 'Themes',
versions: 'Versions',
audit: 'Activity',
integrate: 'Integrate',
settings: 'Settings',
}
/** Builds breadcrumb segments from the location pathname. */
function usePathSegments(): { label: string; to: string }[] {
// useLocation is imported lazily through the module scope; declaring the hook
// here keeps Breadcrumbs readable.
return useLocationSegments()
}
/**
* The controls that let an operator preview a published theme against the chrome.
*
* These are the elements this service ships to consumers, rendered here unchanged.
* The admin UI is not given its own version: a control that only runs in the hands of
* its author is a control whose bugs are found by that person.
*
* The elements are driven from the admin state rather than the other way round, so
* the account preference stays authoritative. Two reasons for that direction. The
* admin stores the choice on the account, not only in this browser, so an operator
* moving between machines expects to see the same preview. And the element persists
* locally under its own key; letting it win would silently outrank the account.
*/
function ThemeSwitcher() {
const { mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady } =
useAdminThemeSafe()
const selector = useRef<BananasElement>(null)
const toggle = useRef<BananasElement>(null)
// The other direction: an operator using the shipped control updates the admin
// state, which is what persists the choice on the account. Listening on the
// element is enough because the events are composed and cross the shadow boundary.
useEffect(() => {
const el = selector.current
if (!el) return undefined
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ slug: string | null; mode: AppearanceMode }>).detail
const slug = detail?.slug
// An empty slug is the control's "no theme" entry, which in the admin means
// the chrome, since that is what an unset preview already maps to.
setPreviewSlug(slug || null)
// Also apply the theme's default appearance mode so selecting a theme
// immediately shows it rather than leaving the chrome in 'admin' mode.
if (slug && detail?.mode) {
setMode(detail.mode === 'dark' ? 'theme-dark' : 'theme-light')
}
}
el.addEventListener('bananas:theme', handler)
return () => el.removeEventListener('bananas:theme', handler)
}, [setPreviewSlug, setMode, controlsReady])
useEffect(() => {
const el = toggle.current
if (!el) return undefined
const handler = (event: Event) => {
const next = (event as CustomEvent<{ mode: AppearanceMode }>).detail?.mode ?? 'admin'
setMode(next === 'admin' ? 'admin' : next === 'dark' ? 'theme-dark' : 'theme-light')
}
el.addEventListener('bananas:appearance', handler)
return () => el.removeEventListener('bananas:appearance', handler)
}, [setMode, controlsReady])
// The elements expose `value` as a property rather than a React-controlled
// attribute, so a change made elsewhere — the session preference arriving, or the
// reset button — is pushed in through the ref.
useEffect(() => {
const el = selector.current
if (!el || el.value === (previewSlug ?? '')) return
el.value = previewSlug ?? ''
}, [previewSlug, controlsReady])
useEffect(() => {
const el = toggle.current
if (!el) return
const next = mode === 'admin' ? 'admin' : mode === 'theme-dark' ? 'dark' : 'light'
if (el.value !== next) el.value = next
}, [mode, controlsReady])
// Until the module has been evaluated the elements render as empty inline boxes,
// so the previous control is shown instead. That is a visible switch rather than a
// layout jump.
if (!controlsReady) {
return (
<LegacyThemeSwitcher
mode={mode}
previewSlug={previewSlug}
setMode={setMode}
setPreviewSlug={setPreviewSlug}
available={available}
/>
)
}
return (
<div className="flex items-center gap-1.5">
{React.createElement('bananas-theme-selector', {
ref: selector,
base: runtimeBase,
label: 'Preview theme',
})}
{React.createElement('bananas-appearance-toggle', {
ref: toggle,
label: 'Interface theme',
})}
</div>
)
}
/**
* The previous switcher, kept only until the shipped controls load.
*
* This is a fallback and not a second implementation to maintain: once the module
* resolves, ThemeSwitcher stops rendering it. It exists so a failed or slow module
* load leaves the operator with a usable control rather than nothing.
*/
function LegacyThemeSwitcher({
mode,
previewSlug,
setMode,
setPreviewSlug,
available,
}: {
mode: AdminThemeMode
previewSlug: string | null
setMode: (mode: AdminThemeMode) => void
setPreviewSlug: (slug: string | null) => void
available: string[]
}) {
return (
<div className="flex items-center gap-1.5">
{mode !== 'admin' ? (
<select
aria-label="Preview theme"
value={previewSlug ?? ''}
onChange={(event) => setPreviewSlug(event.target.value)}
className="border-line bg-canvas text-ink-muted h-8 max-w-[10rem] rounded-control border px-2 text-xs"
>
{available.length === 0 ? <option value="">no published themes</option> : null}
{available.map((slug) => (
<option key={slug} value={slug}>
{slug}
</option>
))}
</select>
) : null}
<div
className="border-line bg-canvas flex rounded-control border p-0.5"
role="group"
aria-label="Interface theme"
>
{(
[
{ id: 'admin', label: 'Admin', title: 'The go-bananas interface' },
{ id: 'theme-light', label: 'Light', title: 'Preview the theme, light scheme' },
{ id: 'theme-dark', label: 'Dark', title: 'Preview the theme, dark scheme' },
] as const
).map((option) => (
<button
key={option.id}
type="button"
title={option.title}
aria-pressed={mode === option.id}
onClick={() => setMode(option.id)}
className={cn(
'rounded-[0.3rem] px-2 py-1 text-[11px] font-medium transition-colors',
mode === option.id ? 'bg-brand text-on-brand' : 'text-ink-muted hover:text-ink',
)}
>
{option.label}
</button>
))}
</div>
</div>
)
}
/** Fallback shown while a lazily loaded route is fetched. */
export function RouteFallback() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-24" />
))}
</div>
<Skeleton className="h-72" />
</div>
)
}
/** Marks a route as requiring an authenticated session. */
export function RequireAuth({ children }: { children: ReactNode }) {
const { data: session, isLoading } = useQuery({
queryKey: ['session'],
queryFn: ({ signal }) => getSession(signal),
})
if (isLoading) return <RouteFallback />
if (!session?.authenticated) {
return (
<div className="mx-auto max-w-md py-16 text-center">
<h1 className="text-ink text-base font-semibold">Sign in required</h1>
<p className="text-ink-muted mt-2 text-xs leading-relaxed">
This page is part of the administration interface. Sign in with your identity provider to
continue.
</p>
<a href="/auth/login" className="mt-5 inline-block">
<Button variant="primary">Sign in</Button>
</a>
</div>
)
}
return <>{children}</>
}
/** Wraps useAdminTheme so a missing provider cannot crash the shell. */
function useAdminThemeSafe() {
try {
return useAdminTheme()
} catch {
return {
mode: 'admin' as const,
setMode: () => {},
previewSlug: null,
setPreviewSlug: () => {},
available: [] as string[],
runtimeBase: '/rt/v1',
// Without a provider the module is never asked for, so the fallback control
// stays in place rather than being replaced by controls that cannot load.
controlsReady: false,
}
}
}
/** Reads the pathname and maps it onto breadcrumb segments. */
function useLocationSegments(): { label: string; to: string }[] {
const { pathname } = useLocation()
const parts = pathname.split('/').filter(Boolean)
const out: { label: string; to: string }[] = []
let accumulated = ''
for (const part of parts) {
accumulated += `/${part}`
out.push({ label: routeLabels[part] ?? part, to: accumulated })
}
return out
}

View file

@ -0,0 +1,401 @@
/**
* The live preview.
*
* Renders a realistic interface using only design tokens, inside an isolated
* stylesheet scope. That isolation is what makes it trustworthy: the preview must
* not inherit the admin UI's own colours, or a change to one would look like a
* change to the other.
*
* The approach is to write the edited token document into a `<style>` element
* scoped to a wrapper, rather than to fetch the theme from the runtime. The
* document being edited is unsaved by definition, so there is nothing on the server
* to fetch.
*/
import { useEffect, useId, useMemo, useRef } from 'react'
import type { TokenDoc } from '../../lib/types'
/** Props for the preview. */
export type ThemePreviewProps = {
tokens: TokenDoc
appearance: 'light' | 'dark'
className?: string
}
/** The sample interfaces the preview can render. */
export type PreviewScreen = 'dashboard' | 'form' | 'table' | 'content'
/** Converts a token document into scoped CSS. */
function buildCss(tokens: TokenDoc, appearance: 'light' | 'dark', scope: string): string {
const declarations: string[] = []
// The document is walked in a fixed order so the generated stylesheet is stable
// between renders. An unstable stylesheet would defeat the browser's own
// parse cache and make every keystroke in the editor feel slower.
const walk = (node: unknown, prefix: string[]) => {
if (typeof node !== 'object' || node === null) return
if ('$value' in node) {
const leaf = node as { $type?: string; $value: unknown }
declarations.push(`--bn-${prefix.join('-')}: ${renderValue(leaf.$type, leaf.$value)};`)
return
}
for (const key of Object.keys(node).sort()) {
if (key.startsWith('$')) continue
walk((node as Record<string, unknown>)[key], [...prefix, key])
}
}
// Base tokens first, then the appearance's overrides on top, which is the same
// merge order the engine uses when it renders a theme.
walk(stripAppearances(tokens), [])
const overrides = (tokens.$appearances as Record<string, unknown> | undefined)?.[appearance]
if (overrides) walk(overrides, [])
// Derived tokens, so the preview shows the foreground pairs and interaction
// states that the runtime would add.
const derived = derivePreviewTokens(declarations)
return `${scope} { ${declarations.join(' ')} ${derived} }`
}
/** Renders a token value the way the engine would. */
function renderValue(type: string | undefined, value: unknown): string {
if (typeof value === 'number') {
if (type === 'dimension' || type === 'fontSize' || type === 'letterSpacing') {
return `${value}px`
}
if (type === 'duration') return `${value}ms`
return String(value)
}
if (Array.isArray(value)) return `cubic-bezier(${value.join(', ')})`
return String(value)
}
/** Removes the $appearances subtree so it is not walked as tokens. */
function stripAppearances(tokens: TokenDoc): Record<string, unknown> {
const { $appearances: _ignored, ...rest } = tokens
return rest
}
/**
* Computes the small set of derived tokens the preview needs.
*
* Only contrast-derived foreground pairs are derived here. The full derivation set
* is the engine's job, and duplicating it would mean the preview could disagree
* with what consumers actually receive.
*/
function derivePreviewTokens(declarations: string[]): string {
const values = new Map<string, string>()
for (const declaration of declarations) {
const separator = declaration.indexOf(':')
if (separator < 0) continue
values.set(declaration.slice(0, separator).trim(), declaration.slice(separator + 1).trim())
}
const out: string[] = []
for (const [name, value] of values) {
if (!name.startsWith('--bn-color-')) continue
if (name.includes('--bn-color-on-')) continue
// A hue-ish token is a candidate for a derived foreground pair. Translucent and
// non-hex values are skipped because contrast cannot be computed from them
// without the engine.
const rgb = /^#([0-9a-f]{6})$/i.exec(value)
if (!rgb) continue
out.push(`--bn-color-on-${name.slice('--bn-color-'.length)}: ${bestForeground(rgb[1]!)};`)
}
return out.join(' ')
}
/** Returns '#ffffff' or '#000000', whichever contrasts better with a hex colour. */
function bestForeground(hex: string): string {
const luminance = relativeLuminance(hex)
// The crossover point where black and white contrast equally is luminance
// 0.1791.
return luminance > 0.1791 ? '#000000' : '#ffffff'
}
/** WCAG relative luminance for a six-digit hex colour. */
function relativeLuminance(hex: string): number {
const channels = [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255)
const linear = channels.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
)
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!
}
/** The preview surface. */
export function ThemePreview({
tokens,
appearance,
className,
}: ThemePreviewProps) {
const styleRef = useRef<HTMLStyleElement>(null)
// useId rather than a random string: it is stable across renders and remounts,
// which matters because a changed scope id would orphan the previous stylesheet
// and leak a rule that nothing can ever match again.
// useId is stable for the life of the component and is a plain value, so it can
// be read during render. A ref would be the wrong tool: reading `.current`
// during render is what makes concurrent rendering unsafe.
const scope = useId().replace(/:/g, '')
const css = useMemo(() => buildCss(tokens, appearance, scope), [tokens, appearance, scope])
useEffect(() => {
if (styleRef.current) {
styleRef.current.textContent = css
}
}, [css])
return (
<div data-bananas-scope={scope} className={className}>
<style ref={styleRef} />
<div className={appearance === 'dark' ? 'dark' : undefined}>
<PreviewSurface appearance={appearance} />
</div>
</div>
)
}
/** The themed surface the sample components sit on. */
function PreviewSurface({ appearance }: { appearance: 'light' | 'dark' }) {
return (
<div
className="min-h-full p-5"
style={{
background: 'var(--bn-color-semantic-background, Canvas)',
color: 'var(--bn-color-semantic-text, CanvasText)',
fontFamily: 'var(--bn-font-family-sans, system-ui, sans-serif)',
}}
data-appearance={appearance}
>
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<p
className="text-sm font-semibold"
style={{ fontWeight: 'var(--bn-font-weight-semibold, 600)' }}
>
Acme Console
</p>
<p
className="mt-0.5 text-xs"
style={{ color: 'var(--bn-color-semantic-text-muted, inherit)' }}
>
Overview of the current period
</p>
</div>
<PreviewButton variant="primary">Create</PreviewButton>
</div>
<div className="mb-5 grid grid-cols-3 gap-3">
{[
{ label: 'Revenue', value: '£48.2k', delta: '+12%' },
{ label: 'Active users', value: '3,104', delta: '+4%' },
{ label: 'Error rate', value: '0.02%', delta: '−0.01%' },
].map((stat) => (
<div
key={stat.label}
className="p-3"
style={{
background: 'var(--bn-color-semantic-surface-raised, Canvas)',
border: '1px solid var(--bn-color-semantic-border, transparent)',
borderRadius: 'var(--bn-radius-lg, 0.5rem)',
boxShadow: 'var(--bn-shadow-sm, none)',
}}
>
<p
className="text-[11px]"
style={{ color: 'var(--bn-color-semantic-text-muted, inherit)' }}
>
{stat.label}
</p>
<p className="tabular mt-1 text-lg font-semibold">{stat.value}</p>
<p className="mt-0.5 text-[11px]" style={{ color: 'var(--bn-color-semantic-success, green)' }}>
{stat.delta}
</p>
</div>
))}
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
<PreviewButton variant="primary">Save changes</PreviewButton>
<PreviewButton variant="secondary">Cancel</PreviewButton>
<PreviewButton variant="ghost">Discard</PreviewButton>
<PreviewButton variant="danger">Delete</PreviewButton>
</div>
<div className="mb-5 grid gap-3 sm:grid-cols-2">
<div
className="p-3"
style={{
background: 'var(--bn-color-semantic-surface-raised, Canvas)',
border: '1px solid var(--bn-color-semantic-border, transparent)',
borderRadius: 'var(--bn-radius-lg, 0.5rem)',
}}
>
<p className="mb-2 text-[11px] font-medium">Form controls</p>
<PreviewInput placeholder="Email address" label="Email" />
<div className="mt-2">
<PreviewInput placeholder="Select a plan" label="Plan" />
</div>
</div>
<div className="flex flex-col gap-2">
<span
className="inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
style={{
background: 'var(--bn-color-semantic-success-tint, transparent)',
color: 'var(--bn-color-semantic-success, inherit)',
}}
>
Operational
</span>
<span
className="inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
style={{
background: 'var(--bn-color-semantic-warning-tint, transparent)',
color: 'var(--bn-color-semantic-warning, inherit)',
}}
>
Degraded
</span>
<span
className="inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
style={{
background: 'var(--bn-color-semantic-danger-tint, transparent)',
color: 'var(--bn-color-semantic-danger, inherit)',
}}
>
Failing
</span>
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr>
{['Service', 'Status', 'Latency'].map((heading) => (
<th
key={heading}
className="px-2 py-1.5 text-left"
style={{
color: 'var(--bn-color-semantic-text-muted, inherit)',
fontWeight: 'var(--bn-font-weight-semibold, 600)',
borderBottom: '1px solid var(--bn-color-semantic-border, transparent)',
}}
>
{heading}
</th>
))}
</tr>
</thead>
<tbody>
{[
['gateway', 'healthy', '42 ms'],
['billing', 'healthy', '118 ms'],
['search', 'degraded', '903 ms'],
].map(([service, status, latency]) => (
<tr key={service}>
<td className="px-2 py-1.5" style={{ borderBottom: '1px solid var(--bn-color-semantic-border, transparent)' }}>
{service}
</td>
<td className="px-2 py-1.5" style={{ borderBottom: '1px solid var(--bn-color-semantic-border, transparent)' }}>
<span
style={{
color:
status === 'healthy'
? 'var(--bn-color-semantic-success, inherit)'
: 'var(--bn-color-semantic-warning, inherit)',
}}
>
{status}
</span>
</td>
<td
className="tabular px-2 py-1.5"
style={{ borderBottom: '1px solid var(--bn-color-semantic-border, transparent)' }}
>
{latency}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/** A themed button for the preview. */
function PreviewButton({
children,
variant = 'primary',
}: {
children: React.ReactNode
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
}) {
const styles: Record<string, React.CSSProperties> = {
primary: {
background: 'var(--bn-color-semantic-primary, currentColor)',
color: 'var(--bn-color-on-primary, #fff)',
border: '1px solid transparent',
},
secondary: {
background: 'var(--bn-color-semantic-secondary, transparent)',
color: 'var(--bn-color-semantic-text, inherit)',
border: '1px solid var(--bn-color-semantic-border, currentColor)',
},
ghost: {
background: 'transparent',
color: 'var(--bn-color-semantic-primary, inherit)',
border: '1px solid transparent',
},
danger: {
background: 'var(--bn-color-semantic-danger, currentColor)',
color: 'var(--bn-color-on-danger, #fff)',
border: '1px solid transparent',
},
}
return (
<button
type="button"
className="inline-flex items-center rounded-[var(--bn-radius-md,0.375rem)] px-3 py-1.5 text-xs"
style={{
...styles[variant],
fontWeight: 'var(--bn-font-weight-medium, 500)',
fontFamily: 'inherit',
cursor: 'pointer',
}}
>
{children}
</button>
)
}
/** A themed labelled input for the preview. */
function PreviewInput({ label, placeholder }: { label: string; placeholder: string }) {
return (
<label className="block">
<span
className="mb-1 block text-[11px]"
style={{ color: 'var(--bn-color-semantic-text-muted, inherit)' }}
>
{label}
</span>
<input
type="text"
placeholder={placeholder}
readOnly
className="w-full px-2 py-1.5 text-xs"
style={{
background: 'var(--bn-color-semantic-background, Canvas)',
color: 'var(--bn-color-semantic-text, CanvasText)',
border: '1px solid var(--bn-color-semantic-border, currentColor)',
borderRadius: 'var(--bn-radius-md, 0.375rem)',
fontFamily: 'inherit',
}}
/>
</label>
)
}

View file

@ -0,0 +1,193 @@
/**
* Component tests for the token editor controls.
*
* The focus is on behaviour a user depends on: does the control show the right
* value, does it report the change, is a text token's caret preserved while typing.
*/
import { describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TokenControl } from './token-control'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { TokenValue } from '../../lib/types'
import type { ReactNode } from 'react'
/**
* Renders a control inside a query client.
*
* The contrast readout issues a request, so a provider is required. A fresh client
* per test keeps one test's cache out of another's assertions.
*/
function renderControl(node: ReactNode) {
// A real client, not a hand-rolled stand-in: the provider calls several internal
// methods on it, and a fake that satisfies only today's call site silently stops
// working the moment the library changes.
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
}
/**
* A controlled harness.
*
* The controls are fully controlled components, so a test that renders one with a
* fixed value and then types into it is testing an input that cannot change. This
* wrapper holds the value in state, exactly as the editor does, so the assertions
* reflect real behaviour.
*/
function ControlledControl(props: Omit<Parameters<typeof TokenControl>[0], 'onChange'>) {
const [value, setValue] = useState<TokenValue>(props.value)
return <TokenControl {...props} value={value} onChange={setValue} />
}
describe('TokenControl', () => {
it('renders a colour control for a hex value', () => {
renderControl(
<TokenControl path="color.semantic.primary" value="#4f46e5" type="color" onChange={() => {}} />,
)
const picker = screen.getByLabelText('Colour for color.semantic.primary')
expect(picker).toHaveValue('#4f46e5')
expect(screen.getByLabelText('color.semantic.primary value')).toHaveValue('#4f46e5')
})
it('reports a colour change from the text field', async () => {
const user = userEvent.setup()
renderControl(
<ControlledControl path="color.semantic.primary" value="#4f46e5" type="color" />,
)
const field = screen.getByLabelText('color.semantic.primary value')
await user.clear(field)
await user.type(field, '#ff0000')
expect(field).toHaveValue('#ff0000')
})
it('reports a colour change from the native picker', () => {
renderControl(
<ControlledControl path="color.semantic.primary" value="#4f46e5" type="color" />,
)
const picker = screen.getByLabelText('Colour for color.semantic.primary')
// jsdom does not open the browser's native colour dialog, so the change event
// is dispatched directly. That is exactly what the browser emits when the user
// picks a colour, so the component's behaviour is still being exercised.
fireEvent.change(picker, { target: { value: '#00ff00' } })
expect(picker).toHaveValue('#00ff00')
expect(screen.getByLabelText('color.semantic.primary value')).toHaveValue('#00ff00')
})
it('renders a numeric control with the unit shown but not typed', () => {
renderControl(
<TokenControl path="space.4" value={1} type="dimension" onChange={() => {}} />,
)
const input = screen.getByLabelText('space.4 value')
expect(input).toHaveValue(1)
// The unit is a label, not part of the value: writing it into the field would
// make the number unparseable on the next keystroke.
expect(input).toHaveAttribute('type', 'number')
expect(screen.getByText('px')).toBeInTheDocument()
})
it('reports a numeric change', async () => {
const user = userEvent.setup()
renderControl(<ControlledControl path="space.4" value={1} type="dimension" />)
const input = screen.getByLabelText('space.4 value')
await user.clear(input)
await user.type(input, '2')
expect(input).toHaveValue(2)
})
it('renders four inputs and a curve for a cubic bezier', () => {
renderControl(
<TokenControl
path="ease.standard"
value={[0.2, 0, 0, 1]}
type="cubicBezier"
onChange={() => {}}
/>,
)
for (let index = 1; index <= 4; index += 1) {
expect(screen.getByLabelText(`ease.standard component ${index}`)).toBeInTheDocument()
}
})
it('ignores a non-finite numeric input rather than writing NaN', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
renderControl(<TokenControl path="space.4" value={1} type="dimension" onChange={onChange} />)
const input = screen.getByLabelText('space.4 value')
await user.clear(input)
// Clearing leaves the field empty, which parses as NaN. That must not reach the
// document: a NaN token would fail validation and could not be typed back.
expect(onChange).not.toHaveBeenCalledWith(Number.NaN)
expect(input).toHaveValue(1)
})
it('shows the token path and a description when present', () => {
renderControl(
<TokenControl
path="color.semantic.primary"
value="#4f46e5"
type="color"
description="The primary action colour"
onChange={() => {}}
/>,
)
expect(screen.getByText('color.semantic.primary')).toBeInTheDocument()
expect(screen.getByText('The primary action colour')).toBeInTheDocument()
})
it('offers no copy control when no handler is supplied', () => {
renderControl(
<TokenControl path="space.4" value={1} type="dimension" onChange={() => {}} />,
)
expect(
screen.queryByLabelText('Copy the path of space.4'),
).not.toBeInTheDocument()
})
it('copies the path when a handler is supplied', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
// navigator.clipboard is a getter-only property in jsdom, so it has to be
// redefined rather than assigned.
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const user = userEvent.setup()
renderControl(
<TokenControl
path="space.4"
value={1}
type="dimension"
onChange={() => {}}
onCopyPath={() => void writeText('space.4')}
/>,
)
await user.click(screen.getByLabelText('Copy the path of space.4'))
expect(writeText).toHaveBeenCalledWith('space.4')
})
})

View file

@ -0,0 +1,343 @@
/**
* The token editor's controls.
*
* Each control knows how to edit one kind of token: a colour picker with a
* contrast readout, a numeric field with its unit appended, a shadow editor, and so
* on. Keeping that knowledge here rather than in the editor page means the page
* reads as a list of tokens rather than as a switch over value types.
*/
import { useMemo, useState, type ChangeEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, Check, Copy, Pipette } from 'lucide-react'
import { checkContrast } from '../../lib/api'
import { looksLikeColour, toPickerValue } from '../../lib/tokens'
import type { TokenValue } from '../../lib/types'
import { Badge, Input } from '../ui'
/** Props shared by every control. */
export type TokenControlProps = {
/** The dotted token path. */
path: string
value: TokenValue
/** The `$type` the engine resolved, when the document declared one. */
type?: string
description?: string
onChange: (value: TokenValue) => void
/** Called when the user copies the token path. */
onCopyPath?: () => void
}
/**
* Picks the right control for a value and renders it.
*
* The dispatch is on the resolved `$type` first and the value shape second, so a
* document that omits `$type` still gets the correct control.
*/
export function TokenControl(props: TokenControlProps) {
const { value, type } = props
if (type === 'color' || (typeof value === 'string' && looksLikeColour(value))) {
return <ColourControl {...props} value={String(value)} />
}
if (typeof value === 'number') {
return <NumberControl {...props} />
}
if (Array.isArray(value)) {
return <BezierControl {...props} />
}
if (type === 'shadow' || (typeof value === 'string' && value.includes('px'))) {
return <TextControl {...props} />
}
return <TextControl {...props} />
}
/**
* A colour control with a native picker, a hex field and a live contrast readout.
*
* The contrast readout is the part that matters: it is the difference between an
* editor that lets you create an unreadable theme and one that tells you before
* you save.
*/
function ColourControl({
path,
value,
description,
onChange,
onCopyPath,
}: TokenControlProps & { value: string }) {
const picker = toPickerValue(value)
// Contrast is measured against the theme's own background, which is the surface
// a text colour will realistically sit on. A theme without a resolved
// background falls back to white, which is the conservative assumption.
const isTextual = /(^|\.)(text|on-|foreground|link)/.test(path)
const background = useBackgroundColour()
const shouldMeasure = isTextual && looksLikeColour(background)
const contrast = useQuery({
queryKey: ['contrast', picker, background],
queryFn: ({ signal }) => checkContrast(picker, background, signal),
enabled: shouldMeasure,
staleTime: 60_000,
})
const copied = useCopyFeedback(onCopyPath)
return (
<div className="space-y-1.5">
<Row path={path} description={description} onCopy={copied} />
<div className="flex items-center gap-2">
<label className="relative shrink-0">
<span className="sr-only">Colour for {path}</span>
<input
type="color"
value={picker}
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
className="border-line bg-canvas size-9 cursor-pointer rounded-control border p-0.5"
/>
</label>
<Input
value={value}
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange(event.target.value)}
className="font-mono text-xs"
spellCheck={false}
aria-label={`${path} value`}
/>
{shouldMeasure && contrast.data ? <ContrastBadge ratio={contrast.data.rounded} level={contrast.data.level} /> : null}
</div>
</div>
)
}
/** A numeric control with the unit the token type implies. */
function NumberControl({ path, value, type, description, onChange, onCopyPath }: TokenControlProps) {
const unit =
type === 'dimension' || type === 'fontSize' || type === 'letterSpacing'
? 'px'
: type === 'duration'
? 'ms'
: ''
const copied = useCopyFeedback(onCopyPath)
return (
<div className="space-y-1.5">
<Row path={path} description={description} onCopy={copied} />
<div className="relative">
<Input
type="number"
step="any"
value={Number(value)}
onChange={(event) => {
const next = Number(event.target.value)
if (Number.isFinite(next)) onChange(next)
}}
className="tabular pr-10 text-xs"
aria-label={`${path} value`}
/>
{unit ? (
<span className="text-ink-subtle pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[11px]">
{unit}
</span>
) : null}
</div>
</div>
)
}
/** A text control, used for shadows and font stacks. */
function TextControl({ path, value, description, onChange, onCopyPath }: TokenControlProps) {
const copied = useCopyFeedback(onCopyPath)
// The draft is local state so that typing a partial value such as "0." does not
// round-trip through the document and reset the caret on every keystroke. It is
// seeded from `value` and re-seeded only when `value` changes underneath it,
// which is the one case where discarding the in-progress edit is correct.
const [draft, setDraft] = useState(String(value))
const [seeded, setSeeded] = useState(String(value))
if (seeded !== String(value)) {
setSeeded(String(value))
setDraft(String(value))
}
return (
<div className="space-y-1.5">
<Row path={path} description={description} onCopy={copied} />
<Input
value={draft}
onChange={(event) => {
setDraft(event.target.value)
onChange(event.target.value)
}}
onBlur={() => setDraft(String(value))}
className="font-mono text-xs"
spellCheck={false}
aria-label={`${path} value`}
/>
</div>
)
}
/** A cubic-bezier control with four numeric inputs. */
function BezierControl({ path, value, description, onChange, onCopyPath }: TokenControlProps) {
// TokenControl only routes an array value here, but the prop type is the union,
// so the narrowing is asserted rather than assumed.
const components = (Array.isArray(value) ? value : [0, 0, 1, 1]).map(Number)
const copied = useCopyFeedback(onCopyPath)
// A linear preview of the curve. Drawing it is far more useful than four numbers
// because the shape of the curve is the thing being chosen.
const pathDescription = useMemo(() => {
const [x1 = 0, y1 = 0, x2 = 1, y2 = 1] = components
return `M 0 16 C ${x1 * 16} ${16 - y1 * 16}, ${x2 * 16} ${16 - y2 * 16}, 16 0`
}, [components])
function update(index: number, next: number) {
const updated = [...components]
updated[index] = next
onChange(updated)
}
return (
<div className="space-y-1.5">
<Row path={path} description={description} onCopy={copied} />
<div className="flex items-center gap-3">
<svg viewBox="0 0 16 16" className="border-line bg-canvas size-10 shrink-0 rounded border" aria-hidden>
<path d={pathDescription} fill="none" stroke="var(--app-brand)" strokeWidth="1.25" />
</svg>
<div className="grid flex-1 grid-cols-4 gap-1.5">
{components.map((component, index) => (
<Input
key={index}
type="number"
step="0.01"
value={component}
onChange={(event: ChangeEvent<HTMLInputElement>) => update(index, Number(event.target.value))}
className="tabular h-8 px-1.5 text-center text-[11px]"
aria-label={`${path} component ${index + 1}`}
/>
))}
</div>
</div>
</div>
)
}
/** The path label, description and copy affordance shared by every control. */
function Row({
path,
description,
onCopy,
}: {
path: string
description?: string
onCopy?: () => void
}) {
return (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-ink-muted truncate font-mono text-[11px]" title={path}>
{path}
</p>
{description ? (
<p className="text-ink-subtle mt-0.5 text-[10px] leading-relaxed">{description}</p>
) : null}
</div>
{onCopy ? (
<button
type="button"
onClick={onCopy}
title="Copy the token path"
aria-label={`Copy the path of ${path}`}
className="text-ink-subtle hover:text-ink shrink-0"
>
{onCopy.name === 'copied' ? (
<Check className="text-good size-3" aria-hidden />
) : (
<Copy className="size-3" aria-hidden />
)}
</button>
) : null}
</div>
)
}
/** The WCAG verdict for a measured pair. */
function ContrastBadge({ ratio, level }: { ratio: number; level: string }) {
const tone =
level === 'aaa' ? 'good' : level === 'aa' ? 'good' : level === 'aa-large' ? 'warn' : 'bad'
return (
<Badge
tone={tone}
className="shrink-0"
title={`Contrast ratio ${ratio.toFixed(2)}:1 against the theme background`}
>
{tone === 'bad' ? <AlertTriangle className="size-3" aria-hidden /> : null}
{ratio.toFixed(1)}:1
</Badge>
)
}
/**
* Reads the current theme's background colour so contrast can be measured against
* it.
*
* Reading a computed custom property is what makes this correct: the effective
* background is whatever the resolved theme says it is, including the appearance
* overrides and the derived tokens.
*/
function useBackgroundColour(): string {
return useMemo(() => {
if (typeof window === 'undefined') return '#ffffff'
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--bn-color-semantic-background')
.trim()
return value || '#ffffff'
}, [])
}
/**
* Wires a copy-to-clipboard callback to a brief confirmation.
*
* The trick is that the callback itself is decorated with a `name` property, which
* the Row component reads to render its own confirmation icon. That keeps the
* confirmation local to the row that triggered it.
*/
function useCopyFeedback(onCopyPath?: () => void) {
const [copied, setCopied] = useState(false)
return useMemo(() => {
if (!onCopyPath) return undefined
const handler = () => {
onCopyPath()
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
Object.defineProperty(handler, 'name', {
value: copied ? 'copied' : 'copy',
configurable: true,
})
return handler
}, [onCopyPath, copied])
}
/** An eyedropper button, offered where the browser supports the EyeDropper API. */
export function ColourPickerHint() {
const supported = typeof window !== 'undefined' && 'EyeDropper' in window
if (!supported) return null
return (
<span
className="text-ink-subtle inline-flex items-center gap-1 text-[10px]"
title="Your browser supports picking a colour from anywhere on screen"
>
<Pipette className="size-3" aria-hidden /> eyedropper available
</span>
)
}

View file

@ -0,0 +1,598 @@
/**
* The admin UI's component primitives.
*
* These are deliberately small and unopinionated. They exist so that a page
* component reads as layout rather than as a pile of class names, and so that
* focus handling, keyboard behaviour and disabled semantics are written once
* instead of once per button.
*
* Every interactive primitive is a real <button>, <input> or <a> under the hood.
* A <div> with a click handler is not a control: it cannot be focused, does not
* appear in the accessibility tree and does not respond to the keyboard.
*/
import {
forwardRef,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
} from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '../../lib/utils'
/* -------------------------------------------------------------------------- */
/* Button */
/* -------------------------------------------------------------------------- */
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'subtle'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
/** Style variants. Sizes are handled separately so they can combine freely. */
const buttonVariants: Record<ButtonVariant, string> = {
primary:
'bg-brand text-on-brand hover:bg-brand-hover active:bg-brand-active shadow-sm disabled:hover:bg-brand',
secondary:
'bg-raised text-ink border border-line-strong hover:border-ink-subtle hover:bg-overlay',
ghost: 'text-ink-muted hover:text-ink hover:bg-raised',
danger: 'bg-bad text-on-bad hover:brightness-110',
subtle: 'bg-brand-subtle text-brand hover:brightness-110 border border-brand/30',
}
const buttonSizes: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-xs gap-1.5',
md: 'h-9 px-3.5 text-sm gap-2',
lg: 'h-11 px-5 text-sm gap-2',
icon: 'h-9 w-9 justify-center',
}
/** Button props. `loading` and `icon` are additions to the native attributes. */
export type ButtonProps = {
variant?: ButtonVariant
size?: ButtonSize
/** Shows a spinner and disables the button while true. */
loading?: boolean
/** A leading icon. Hidden while loading so the button does not resize. */
icon?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'secondary', size = 'md', loading, icon, className, children, disabled, ...props },
ref,
) {
return (
<button
ref={ref}
type={props.type ?? 'button'}
disabled={disabled || loading}
// aria-busy tells assistive technology the control is working, which is more
// useful than a silent disabled state.
aria-busy={loading || undefined}
className={cn(
'inline-flex items-center rounded-control font-medium whitespace-nowrap',
'transition-[background-color,border-color,color,filter] duration-150',
'disabled:opacity-50 disabled:cursor-not-allowed',
buttonVariants[variant],
buttonSizes[size],
className,
)}
{...props}
>
{loading ? <Loader2 className="size-4 shrink-0 animate-spin" aria-hidden /> : icon}
{children}
</button>
)
})
/* -------------------------------------------------------------------------- */
/* Card */
/* -------------------------------------------------------------------------- */
export type CardProps = {
/** Adds hover elevation. Use only on elements that are themselves clickable. */
interactive?: boolean
} & HTMLAttributes<HTMLDivElement>
export function Card({ className, interactive, ...props }: CardProps) {
return (
<div
className={cn(
'bg-surface border-line rounded-card border shadow-card',
interactive && 'hover:border-line-strong transition-colors',
className,
)}
{...props}
/>
)
}
// `title` is omitted from the inherited attributes because the native title
// attribute is a tooltip; here it is the card's visible heading, and extending it
// with a different type would be a lie to the compiler and to a screen reader.
export type CardHeaderProps = {
title: ReactNode
description?: ReactNode
actions?: ReactNode
} & Omit<HTMLAttributes<HTMLDivElement>, 'title'>
export function CardHeader({ title, description, actions, className, ...props }: CardHeaderProps) {
return (
<div
className={cn(
'border-line flex items-start justify-between gap-4 border-b px-5 py-4',
className,
)}
{...props}
>
<div className="min-w-0">
<h2 className="text-ink text-sm font-semibold">{title}</h2>
{description ? <p className="text-ink-muted mt-1 text-xs">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</div>
)
}
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-5', className)} {...props} />
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('border-line bg-canvas/40 flex items-center gap-3 border-t px-5 py-3', className)}
{...props}
/>
)
}
/* -------------------------------------------------------------------------- */
/* Form controls */
/* -------------------------------------------------------------------------- */
const fieldBase =
'w-full rounded-control border border-line bg-canvas text-ink placeholder:text-ink-subtle ' +
'transition-colors hover:border-line-strong focus:border-brand focus:outline-none ' +
'disabled:opacity-50 disabled:cursor-not-allowed'
export type FieldProps = {
label?: ReactNode
hint?: ReactNode
error?: ReactNode
/** Links the label, hint and error to the control for screen readers. */
htmlFor?: string
className?: string
children: ReactNode
}
/**
* Wraps a control with its label, hint and error.
*
* The hint and error are linked through aria-describedby by the caller, because
* only the caller knows the generated ids. What this component does guarantee is
* that an error is announced with role="alert", so a validation failure on save is
* not silent for a screen-reader user.
*/
export function Field({ label, hint, error, htmlFor, className, children }: FieldProps) {
return (
<div className={cn('space-y-1.5', className)}>
{label ? (
<label htmlFor={htmlFor} className="text-ink-muted block text-xs font-medium">
{label}
</label>
) : null}
{children}
{error ? (
<p role="alert" className="text-bad text-xs">
{error}
</p>
) : hint ? (
<p className="text-ink-subtle text-xs">{hint}</p>
) : null}
</div>
)
}
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return <input ref={ref} className={cn(fieldBase, 'h-9 px-3 text-sm', className)} {...props} />
},
)
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
function Textarea({ className, ...props }, ref) {
return (
<textarea
ref={ref}
className={cn(fieldBase, 'resize-y px-3 py-2 font-mono text-xs leading-relaxed', className)}
{...props}
/>
)
},
)
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, children, ...props }, ref) {
return (
<select
ref={ref}
className={cn(fieldBase, 'h-9 cursor-pointer px-2.5 pr-8 text-sm', className)}
{...props}
>
{children}
</select>
)
},
)
/* -------------------------------------------------------------------------- */
/* Badge */
/* -------------------------------------------------------------------------- */
export type BadgeTone = 'neutral' | 'brand' | 'good' | 'warn' | 'bad' | 'info'
const badgeTones: Record<BadgeTone, string> = {
neutral: 'bg-raised text-ink-muted border-line',
brand: 'bg-brand-subtle text-brand border-brand/30',
good: 'bg-good/12 text-good border-good/30',
warn: 'bg-warn/12 text-warn border-warn/30',
bad: 'bg-bad/12 text-bad border-bad/30',
info: 'bg-info/12 text-info border-info/30',
}
export type BadgeProps = {
tone?: BadgeTone
} & HTMLAttributes<HTMLSpanElement>
export function Badge({ tone = 'neutral', className, ...props }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap',
badgeTones[tone],
className,
)}
{...props}
/>
)
}
/* -------------------------------------------------------------------------- */
/* States */
/* -------------------------------------------------------------------------- */
/** A loading placeholder with an accessible busy announcement. */
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
aria-hidden
className={cn('bg-raised animate-pulse rounded-md', className)}
{...props}
/>
)
}
/** A full-panel empty state. */
export function EmptyState({
icon,
title,
description,
action,
className,
}: {
icon?: ReactNode
title: string
description?: ReactNode
action?: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col items-center justify-center px-6 py-16 text-center', className)}>
{icon ? <div className="text-ink-subtle mb-4">{icon}</div> : null}
<p className="text-ink text-sm font-medium">{title}</p>
{description ? (
<p className="text-ink-muted mt-1.5 max-w-sm text-xs leading-relaxed">{description}</p>
) : null}
{action ? <div className="mt-5">{action}</div> : null}
</div>
)
}
/** An inline error with a retry affordance. */
export function ErrorState({
title = 'Something went wrong',
message,
onRetry,
className,
}: {
title?: string
message?: string
onRetry?: () => void
className?: string
}) {
return (
<div
role="alert"
className={cn(
'border-bad/40 bg-bad/8 text-ink flex items-start gap-3 rounded-card border p-4',
className,
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{title}</p>
{message ? <p className="text-ink-muted mt-1 text-xs break-words">{message}</p> : null}
</div>
{onRetry ? (
<Button size="sm" variant="secondary" onClick={onRetry}>
Retry
</Button>
) : null}
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Layout helpers */
/* -------------------------------------------------------------------------- */
/** A page title block. */
export function PageHeader({
title,
description,
actions,
className,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-wrap items-start justify-between gap-4', className)}>
<div className="min-w-0">
<h1 className="text-ink text-lg font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-ink-muted mt-1 max-w-2xl text-xs leading-relaxed">{description}</p>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
)
}
/** A labelled figure for the dashboard. */
export function Stat({
label,
value,
hint,
tone,
icon,
}: {
label: string
value: ReactNode
hint?: ReactNode
tone?: BadgeTone
icon?: ReactNode
}) {
return (
<Card className="p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-ink-muted text-xs font-medium">{label}</p>
{icon ? <span className="text-ink-subtle">{icon}</span> : null}
</div>
<p
className={cn(
'tabular mt-2 text-2xl font-semibold tracking-tight',
tone === 'good' && 'text-good',
tone === 'warn' && 'text-warn',
tone === 'bad' && 'text-bad',
tone === 'info' && 'text-info',
)}
>
{value}
</p>
{hint ? <p className="text-ink-subtle mt-1 text-xs">{hint}</p> : null}
</Card>
)
}
/** A horizontal meter, used for cache hit rate and validation completeness. */
export function Meter({
value,
max = 100,
tone = 'brand',
label,
}: {
value: number
max?: number
tone?: BadgeTone
label?: string
}) {
const percentage = Math.max(0, Math.min(100, (value / (max || 1)) * 100))
const tones: Record<BadgeTone, string> = {
neutral: 'bg-ink-subtle',
brand: 'bg-brand',
good: 'bg-good',
warn: 'bg-warn',
bad: 'bg-bad',
info: 'bg-info',
}
return (
<div
className="bg-raised h-1.5 w-full overflow-hidden rounded-full"
role="meter"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label}
>
<div
className={cn('h-full rounded-full transition-[width] duration-300', tones[tone])}
style={{ width: `${percentage}%` }}
/>
</div>
)
}
/** A modal dialog. Escape closes it and focus is trapped by the browser natively. */
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
size = 'md',
}: {
open: boolean
onClose: () => void
title: string
description?: ReactNode
children?: ReactNode
footer?: ReactNode
size?: 'sm' | 'md' | 'lg'
}) {
if (!open) return null
const widths = { sm: 'max-w-md', md: 'max-w-xl', lg: 'max-w-3xl' }
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[10vh] backdrop-blur-sm"
// A click on the backdrop closes the dialog, but a click inside must not.
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
'bg-surface border-line shadow-pop w-full rounded-card border',
widths[size],
)}
onKeyDown={(event) => {
if (event.key === 'Escape') onClose()
}}
>
<div className="border-line border-b px-5 py-4">
<h2 className="text-ink text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-ink-muted mt-1 text-xs leading-relaxed">{description}</p>
) : null}
</div>
{children ? <div className="px-5 py-4">{children}</div> : null}
{footer ? (
<div className="border-line bg-canvas/40 flex items-center justify-end gap-2 border-t px-5 py-3">
{footer}
</div>
) : null}
</div>
</div>
)
}
/** A tab bar. */
export function Tabs({
tabs,
value,
onChange,
className,
}: {
tabs: { id: string; label: ReactNode; badge?: ReactNode }[]
value: string
onChange: (id: string) => void
className?: string
}) {
return (
<div className={cn('border-line flex gap-1 border-b', className)} role="tablist">
{tabs.map((tab) => {
const selected = tab.id === value
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => onChange(tab.id)}
className={cn(
'-mb-px flex items-center gap-2 border-b-2 px-3 py-2 text-xs font-medium transition-colors',
selected
? 'border-brand text-ink'
: 'text-ink-muted hover:text-ink border-transparent',
)}
>
{tab.label}
{tab.badge}
</button>
)
})}
</div>
)
}
/** A copy-to-clipboard button that reports its own outcome. */
export function CopyButton({
value,
label = 'Copy',
className,
size = 'sm',
}: {
value: string
label?: string
className?: string
size?: ButtonSize
}) {
const [copied, setCopied] = useState(false)
return (
<Button
size={size}
variant="secondary"
className={className}
onClick={async () => {
if (await copyText(value)) {
setCopied(true)
setTimeout(() => setCopied(false), 1600)
}
}}
>
{copied ? 'Copied' : label}
</Button>
)
}
/**
* Copies text to the clipboard.
*
* navigator.clipboard is unavailable on an insecure origin, which a local
* development run over plain HTTP is, so the textarea fallback keeps the button
* usable rather than silently dead.
*/
async function copyText(value: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(value)
return true
} catch {
try {
const area = document.createElement('textarea')
area.value = value
area.setAttribute('readonly', '')
area.style.position = 'fixed'
area.style.opacity = '0'
document.body.appendChild(area)
area.select()
const copied = document.execCommand('copy')
document.body.removeChild(area)
return copied
} catch {
return false
}
}
}

View file

@ -0,0 +1,19 @@
import { useContext } from 'react'
import { AdminThemeContextInstance, type AdminThemeValue } from './admin-theme-context'
/**
* Reads the admin theme context.
*
* Throws when used outside a provider rather than returning a default. A silent
* default would let a component render with the wrong theme and never report the
* mistake, which is exactly the class of bug this codebase's tooling exists to
* surface.
*/
export function useAdminTheme(): AdminThemeValue {
const value = useContext(AdminThemeContextInstance)
if (!value) {
throw new Error('useAdminTheme must be used inside an AdminThemeProvider')
}
return value
}

21
web/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
/**
* Global type augmentations for bananas runtime controls.
* This file must not have any imports/exports to be treated as a global script.
*/
declare global {
namespace JSX {
interface IntrinsicElements {
'bananas-theme-selector': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
base?: string
label?: string
compact?: boolean
'no-preview'?: boolean
}
'bananas-appearance-toggle': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
label?: string
'show-labels'?: boolean
}
}
}
}

211
web/src/index.css Normal file
View file

@ -0,0 +1,211 @@
/*
* go-bananas administration UI.
*
* The admin UI is itself a consumer of go-bananas, which is deliberate: it keeps
* the service honest, because a change to the token engine that breaks the admin
* UI is immediately visible rather than discovered by a customer.
*
* That works by loading the *runtime* theme from /rt/v1 and mapping its variables
* onto Tailwind's theme namespaces. A theme published in the service therefore
* restyles this UI too, with no rebuild and no coordination.
*
* The values below are the fallback palette, used before a runtime theme has
* loaded and if the runtime is unreachable. They are intentionally close to the
* built-in "midnight" theme so the transition is invisible.
*/
@import 'tailwindcss';
/* The theme the admin UI falls back to when no runtime theme is loaded. The
selector mirrors what the runtime emits so a later swap is a pure repaint. */
@theme {
--font-sans:
'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
--color-canvas: #0a0c11;
--color-surface: #11141c;
--color-raised: #171b26;
--color-overlay: #1c2130;
--color-line: #232838;
--color-line-strong: #2f364a;
--color-ink: #e8eaf0;
--color-ink-muted: #98a1b5;
--color-ink-subtle: #6a7387;
--color-ink-inverted: #0a0c11;
--color-brand: #8b7cf6;
--color-brand-hover: #9d90f8;
--color-brand-active: #7a69e8;
--color-on-brand: #0f0d1f;
--color-brand-subtle: #8b7cf61f;
--color-good: #3ddc97;
--color-on-good: #04231a;
--color-warn: #ffb454;
--color-on-warn: #2a1a02;
--color-bad: #ff6b6b;
--color-on-bad: #2b0505;
--color-info: #63b3ff;
--color-on-info: #04182e;
--radius-card: 0.875rem;
--radius-control: 0.5rem;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.4), 0 8px 24px -12px rgb(0 0 0 / 0.6);
--shadow-pop: 0 12px 40px -12px rgb(0 0 0 / 0.75);
}
/*
* Runtime theme mapping.
*
* Each rule reads a --bn-* variable and assigns the Tailwind colour it feeds.
* Because Tailwind 4 resolves these at use time through var(), a runtime theme
* change repaints the UI without a rebuild. `light-dark()` is not used because
* the admin UI follows the previewed theme rather than the operating system: when
* an editor switches to the dark scheme, the whole chrome should follow.
*/
:root {
--app-canvas: var(--bn-color-semantic-background, var(--color-canvas));
--app-surface: var(--bn-color-semantic-surface, var(--color-surface));
--app-raised: var(--bn-color-semantic-surface-raised, var(--color-raised));
--app-line: var(--bn-color-semantic-border, var(--color-line));
--app-line-strong: var(--bn-color-semantic-border-strong, var(--color-line-strong));
--app-ink: var(--bn-color-semantic-text, var(--color-ink));
--app-ink-muted: var(--bn-color-semantic-text-muted, var(--color-ink-muted));
--app-ink-subtle: var(--bn-color-semantic-text-subtle, var(--color-ink-subtle));
--app-brand: var(--bn-color-semantic-primary, var(--color-brand));
--app-brand-ink: var(--bn-color-on-primary, var(--color-on-brand));
--app-good: var(--bn-color-semantic-success, var(--color-good));
--app-warn: var(--bn-color-semantic-warning, var(--color-warn));
--app-bad: var(--bn-color-semantic-danger, var(--color-bad));
--app-info: var(--bn-color-semantic-info, var(--color-info));
--app-radius: var(--bn-radius-lg, var(--radius-card));
--app-radius-control: var(--bn-radius-md, var(--radius-control));
--app-font: var(--bn-font-family-sans, var(--font-sans));
--app-mono: var(--bn-font-family-mono, var(--font-mono));
}
/* Re-point the Tailwind colour names at the runtime variables. */
@theme inline {
--color-canvas: var(--app-canvas);
--color-surface: var(--app-surface);
--color-raised: var(--app-raised);
--color-overlay: var(--color-overlay);
--color-line: var(--app-line);
--color-line-strong: var(--app-line-strong);
--color-ink: var(--app-ink);
--color-ink-muted: var(--app-ink-muted);
--color-ink-subtle: var(--app-ink-subtle);
--color-ink-inverted: var(--color-ink-inverted);
--color-brand: var(--app-brand);
--color-brand-hover: var(--bn-color-semantic-primary-hover, var(--color-brand-hover));
--color-brand-active: var(--bn-color-semantic-primary-active, var(--color-brand-active));
--color-on-brand: var(--app-brand-ink);
--color-good: var(--app-good);
--color-warn: var(--app-warn);
--color-bad: var(--app-bad);
--color-info: var(--app-info);
--radius-card: var(--app-radius);
--radius-control: var(--app-radius-control);
}
@layer base {
* {
border-color: var(--app-line);
}
html {
color-scheme: dark;
}
body {
margin: 0;
background: var(--app-canvas);
color: var(--app-ink);
font-family: var(--app-font);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* A visible focus ring on every interactive element is not optional: the admin
UI is used by keyboard, and a focus style that is only a subtle shade change
fails WCAG 2.4.7. */
:focus-visible {
outline: 2px solid var(--app-brand);
outline-offset: 2px;
border-radius: 0.25rem;
}
::selection {
background: var(--app-brand);
color: var(--app-brand-ink);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--app-line-strong);
border: 2px solid transparent;
background-clip: content-box;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--app-ink-subtle);
background-clip: content-box;
}
}
@layer components {
/* A hairline grid used behind empty states and the preview canvas. */
.checkerboard {
background-image:
linear-gradient(45deg, rgb(255 255 255 / 0.02) 25%, transparent 25%),
linear-gradient(-45deg, rgb(255 255 255 / 0.02) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgb(255 255 255 / 0.02) 75%),
linear-gradient(-45deg, transparent 75%, rgb(255 255 255 / 0.02) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
/* The theme swatch grid on the dashboard. */
.swatch-strip {
display: flex;
height: 100%;
border-radius: inherit;
overflow: hidden;
}
.swatch-strip > * {
flex: 1 1 0;
}
}
@layer utilities {
.tabular {
font-variant-numeric: tabular-nums;
}
/* Respect the operating system's reduced-motion preference. Animations here are
decorative only, but vestibular discomfort is a real accessibility barrier. */
@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;
}
}
}

153
web/src/lib/api.test.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* Tests for the API client's error handling.
*
* This is the layer that turns every server failure into something a component can
* branch on, so its behaviour on the awkward cases — a non-JSON body from a proxy,
* an aborted request, a 204 — is what matters.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, ErrorCode, getSession, listThemes, request, updateTheme } from '../lib/api'
/** Installs a fetch stub and returns the mock. */
function stubFetch(responder: (url: string, init?: RequestInit) => Response | Promise<Response>) {
const mock = vi.fn(responder)
vi.stubGlobal('fetch', mock)
return mock
}
/** Builds a JSON response. */
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('request', () => {
it('sends cookies so the session cookie is included', async () => {
const mock = stubFetch(() => json({ ok: true }))
await request('/meta')
const init = mock.mock.calls[0]![1]!
expect(init.credentials).toBe('include')
})
it('sets a JSON content type only when there is a body', async () => {
const mock = stubFetch(() => json({}))
await request('/meta')
expect((mock.mock.calls[0]![1]!).headers).not.toHaveProperty('content-type')
mock.mockClear()
await request('/themes', { method: 'POST', body: { name: 'x' } })
const init = mock.mock.calls[0]![1]!
expect(init.headers).toMatchObject({ 'content-type': 'application/json' })
expect(init.body).toBe(JSON.stringify({ name: 'x' }))
})
it('omits empty query parameters', async () => {
const mock = stubFetch(() => json({}))
await request('/themes', { query: { q: 'acme', status: '', limit: undefined } })
const url = mock.mock.calls[0]![0]
expect(url).toContain('q=acme')
expect(url).not.toContain('status=')
expect(url).not.toContain('limit=')
})
it('returns undefined for a 204', async () => {
stubFetch(() => new Response(null, { status: 204 }))
await expect(request('/themes/x', { method: 'DELETE' })).resolves.toBeUndefined()
})
it('raises a structured ApiError on a failure', async () => {
stubFetch(() =>
json({ code: 'validation_failed', message: 'tokens are invalid', details: [{ field: 'color.semantic.primary', message: 'not a colour' }] }, 400),
)
await expect(request('/themes', { method: 'POST', body: {} })).rejects.toThrow(ApiError)
try {
await request('/themes', { method: 'POST', body: {} })
expect.unreachable('the request should have failed')
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
const api = error as ApiError
expect(api.status).toBe(400)
expect(api.code).toBe(ErrorCode.Validation)
expect(api.fieldErrors()).toEqual({ 'color.semantic.primary': 'not a colour' })
}
})
it('reports a non-JSON error body without a parse failure', async () => {
// A proxy returning an HTML error page must surface as a useful message, not
// as "Unexpected token < in JSON".
stubFetch(() => new Response('<html>502 Bad Gateway</html>', { status: 502 }))
try {
await request('/meta')
expect.unreachable('the request should have failed')
} catch (error) {
const api = error as ApiError
expect(api.code).toBe('unexpected_response')
expect(api.message).toContain('502')
}
})
it('wraps a network failure as an ApiError', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))),
)
try {
await request('/meta')
expect.unreachable('the request should have failed')
} catch (error) {
expect(error).toBeInstanceOf(ApiError)
expect((error as ApiError).code).toBe('network_error')
expect((error as ApiError).status).toBe(0)
}
})
it('lets an abort propagate unchanged so a query can cancel cleanly', async () => {
const abort = new DOMException('aborted', 'AbortError')
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(abort)))
await expect(request('/meta')).rejects.toBe(abort)
})
})
describe('ApiError predicates', () => {
it('classifies the statuses a component branches on', () => {
const make = (status: number) => new ApiError(status, { code: 'x', message: 'y' })
expect(make(401).isUnauthorized).toBe(true)
expect(make(403).isForbidden).toBe(true)
expect(make(409).isConflict).toBe(true)
expect(make(500).isForbidden).toBe(false)
})
})
describe('endpoint helpers', () => {
it('encodes slugs into the path', async () => {
const mock = stubFetch(() => json({}))
await listThemes({ q: 'a b' })
expect(mock.mock.calls[0]![0]).toContain('q=a+b')
mock.mockClear()
stubFetch(() => json({}))
await updateTheme('my theme', { name: 'x' })
expect(vi.mocked(fetch).mock.calls[0]![0]).toContain('/themes/my%20theme')
})
it('reads the session without throwing when unauthenticated', async () => {
stubFetch(() => json({ authenticated: false, mode: 'oidc' }))
await expect(getSession()).resolves.toMatchObject({ authenticated: false })
})
})

538
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,538 @@
/**
* Typed client for the go-bananas API.
*
* Every network call in the application goes through this module. Centralising it
* means the base URL, the credentials policy, the error shape and the request id
* are handled once, and a component never has to think about any of them.
*
* Credentials: the session is an HttpOnly cookie, so `credentials: 'include'` is
* what makes authentication work at all. There is no token in JavaScript
* reachable storage, which is what makes an XSS bug unable to exfiltrate a
* session.
*/
import type {
ApiErrorBody,
AppConfig,
Appearance,
AuditPage,
ContrastResult,
Manifest,
RampResponse,
Session,
Stats,
Theme,
ThemePage,
ThemeStatus,
ThemeVersion,
ThemeVersionSummary,
TokenDoc,
TokenSchema,
User,
UserPreferences,
ValidationResult,
VersionDiff,
} from './types'
/** Machine-readable error codes. These are part of the API contract. */
export const ErrorCode = {
BadRequest: 'bad_request',
Validation: 'validation_failed',
Unauthorized: 'unauthorized',
Forbidden: 'forbidden',
NotFound: 'not_found',
Conflict: 'conflict',
Immutable: 'immutable',
RateLimited: 'rate_limited',
Internal: 'internal_error',
NotConfigured: 'not_configured',
} as const
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
/**
* An API error carrying the server's structured detail.
*
* Being a real Error subclass matters: React Query and any `catch` block treat it
* as an Error, and the stack is preserved for the browser console.
*/
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly details: ApiErrorBody['details']
readonly requestId?: string
constructor(status: number, body: ApiErrorBody) {
super(body.message || `request failed with status ${status}`)
this.name = 'ApiError'
this.status = status
this.code = body.code || 'unknown'
this.details = body.details
this.requestId = body.requestId
}
/** True when the caller lacks the required role. */
get isForbidden(): boolean {
return this.status === 403
}
/** True when there is no usable session. */
get isUnauthorized(): boolean {
return this.status === 401
}
/** True when the write lost an optimistic-concurrency race. */
get isConflict(): boolean {
return this.status === 409
}
/** The per-field messages, keyed by field path. */
fieldErrors(): Record<string, string> {
const out: Record<string, string> = {}
for (const detail of this.details ?? []) {
out[detail.field] = detail.message
}
return out
}
}
/** Options for a single request. */
type RequestOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
body?: unknown
query?: Record<string, string | number | boolean | undefined | null>
signal?: AbortSignal
/** Absolute URL, for the runtime endpoints which live under /rt/v1. */
absolute?: boolean
}
/** The API base, derived from where the UI is served. */
function apiBase(): string {
// A deployment under a sub-path advertises it on the document element so that
// the UI does not have to guess or be rebuilt per environment.
const configured = document.documentElement.getAttribute('data-bananas-base-path')
if (configured) return `${configured.replace(/\/$/, '')}/api/v1`
return '/api/v1'
}
/** Builds a query string, omitting empty values. */
function queryString(query: RequestOptions['query']): string {
if (!query) return ''
const params = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
params.set(key, String(value))
}
const encoded = params.toString()
return encoded ? `?${encoded}` : ''
}
/**
* Performs one request and decodes the response.
*
* Exported as the low-level transport primitive: it is what every function in this
* module is built on, and testing the credential policy, the error mapping and the
* abort behaviour is only meaningful at this level.
*/
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = `${options.absolute ? '' : apiBase()}${path}${queryString(options.query)}`
const headers: Record<string, string> = {
accept: 'application/json',
}
if (options.body !== undefined) {
headers['content-type'] = 'application/json'
}
let response: Response
try {
response = await fetch(url, {
method: options.method ?? 'GET',
// The session is a cookie; this is what makes the admin UI authenticate.
credentials: 'include',
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: options.signal,
})
} catch (cause) {
// A network failure is not an API error, but a caller should still be able to
// treat it as one rather than handling two error shapes.
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause
throw new ApiError(0, {
code: 'network_error',
message: 'could not reach the server; check your connection and try again',
})
}
if (response.status === 204) return undefined as T
const text = await response.text()
let payload: unknown = null
if (text) {
try {
payload = JSON.parse(text)
} catch {
// A non-JSON body from a proxy or a crash means the request never reached
// the API; reporting the status is more useful than a parse error.
if (!response.ok) {
throw new ApiError(response.status, {
code: 'unexpected_response',
message: `the server returned a non-JSON response (status ${response.status})`,
})
}
throw new ApiError(response.status, {
code: 'unexpected_response',
message: 'the server returned a response that could not be parsed',
})
}
}
if (!response.ok) {
throw new ApiError(
response.status,
(payload as ApiErrorBody) ?? {
code: 'unknown',
message: `request failed with status ${response.status}`,
},
)
}
return payload as T
}
/* -------------------------------------------------------------------------- */
/* Session and configuration */
/* -------------------------------------------------------------------------- */
/** Reads the current session. Never fails: an absent session is a valid state. */
export function getSession(signal?: AbortSignal): Promise<Session> {
return request<Session>('/auth/me', { signal })
}
/** Reads the caller's own interface preferences. Empty when nothing is chosen. */
export function getPreferences(signal?: AbortSignal): Promise<UserPreferences> {
return request<UserPreferences>('/me/preferences', { signal })
}
/**
* Records the caller's interface preferences and returns what the server stored.
*
* An empty selectedTheme clears the choice. The response is authoritative: the
* server rejects a theme that is not published and resolves one that has since
* disappeared, so the UI should apply the returned value rather than its own.
*/
export function updatePreferences(preferences: {
selectedTheme: string
selectedAppearance: Appearance
}): Promise<UserPreferences> {
return request<UserPreferences>('/me/preferences', { method: 'PUT', body: preferences })
}
/** Forgets the caller's preferences, returning them to the interface defaults. */
export function resetPreferences(): Promise<UserPreferences> {
return request<UserPreferences>('/me/preferences', { method: 'DELETE' })
}
/** Reads the client-visible deployment configuration. */
export function getConfig(signal?: AbortSignal): Promise<AppConfig> {
return request<AppConfig>('/auth/config', { signal })
}
/** Ends the session and returns the URL the browser should follow. */
export async function logout(): Promise<string> {
try {
const response = await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: { accept: 'application/json' },
})
if (response.ok) {
const body = (await response.json()) as { redirect?: string }
if (body.redirect) return body.redirect
}
} catch {
// A logout that cannot reach the server still ends the local session once
// the cookie expires; falling through to the hard redirect is acceptable.
}
return '/'
}
/* -------------------------------------------------------------------------- */
/* Themes */
/* -------------------------------------------------------------------------- */
/** Query parameters for a theme listing. */
export type ListThemesQuery = {
q?: string
status?: ThemeStatus | ''
system?: boolean
tag?: string
limit?: number
offset?: number
sort?: string
order?: 'asc' | 'desc'
}
/** Lists themes. */
export function listThemes(query: ListThemesQuery = {}, signal?: AbortSignal) {
return request<ThemePage>('/themes', { query: { ...query }, signal })
}
/** Loads one theme with its token document. */
export function getTheme(slug: string, signal?: AbortSignal) {
return request<Theme>(`/themes/${encodeURIComponent(slug)}`, { signal })
}
/** Fields accepted when creating a theme. */
export type ThemeInput = {
slug: string
name: string
description?: string
status?: ThemeStatus
defaultAppearance?: Appearance
tokens?: TokenDoc
meta?: Record<string, unknown>
message?: string
}
/** Creates a theme. */
export function createTheme(input: ThemeInput) {
return request<Theme>('/themes', { method: 'POST', body: input })
}
/** Fields accepted when updating a theme. Nil fields are left unchanged. */
export type ThemeUpdate = {
slug?: string
name?: string
description?: string
status?: ThemeStatus
defaultAppearance?: Appearance
tokens?: TokenDoc
meta?: Record<string, unknown>
message?: string
/** Optimistic concurrency: rejected when the stored version has moved on. */
baseVersion?: number
}
/** Applies a partial update. */
export function updateTheme(slug: string, update: ThemeUpdate) {
return request<Theme>(`/themes/${encodeURIComponent(slug)}`, { method: 'PATCH', body: update })
}
/** Deletes a theme. Refused for built-in themes. */
export function deleteTheme(slug: string) {
return request<void>(`/themes/${encodeURIComponent(slug)}`, { method: 'DELETE' })
}
/** Publishes a theme. */
export function publishTheme(slug: string) {
return request<Theme>(`/themes/${encodeURIComponent(slug)}/publish`, { method: 'POST' })
}
/** Archives a theme. */
export function archiveTheme(slug: string) {
return request<Theme>(`/themes/${encodeURIComponent(slug)}/archive`, { method: 'POST' })
}
/** Duplicates a theme under a new slug. */
export function duplicateTheme(slug: string, newSlug: string) {
return request<Theme>(`/themes/${encodeURIComponent(slug)}/duplicate`, {
method: 'POST',
body: { slug: newSlug },
})
}
/** Imports a theme from a portable document. */
export function importTheme(input: {
slug: string
name?: string
tokens: TokenDoc
meta?: Record<string, unknown>
asDraft?: boolean
overwrite?: boolean
message?: string
}) {
return request<Theme>('/themes/import', { method: 'POST', body: input })
}
/** The export envelope for a theme. */
export type ThemeExport = {
$schema: string
slug: string
name: string
status: ThemeStatus
version: number
tokens: TokenDoc
meta?: Record<string, unknown>
exportedAt: string
exportedBy: string
}
/** Exports a theme. */
export function exportTheme(slug: string, signal?: AbortSignal) {
return request<ThemeExport>(`/themes/${encodeURIComponent(slug)}/export`, { signal })
}
/* -------------------------------------------------------------------------- */
/* History */
/* -------------------------------------------------------------------------- */
/** Lists a theme's history, newest first. */
export function listVersions(slug: string, limit = 50, signal?: AbortSignal) {
return request<{ items: ThemeVersionSummary[] }>(`/themes/${encodeURIComponent(slug)}/versions`, {
query: { limit },
signal,
})
}
/** Loads one historical snapshot. */
export function getVersion(slug: string, version: number, signal?: AbortSignal) {
return request<ThemeVersion>(`/themes/${encodeURIComponent(slug)}/versions/${version}`, {
signal,
})
}
/** Restores a historical snapshot as a new version. */
export function rollbackTheme(slug: string, version: number) {
return request<Theme>(`/themes/${encodeURIComponent(slug)}/versions/${version}/rollback`, {
method: 'POST',
})
}
/** Compares two snapshots. */
export function diffVersions(slug: string, from: number, to: number, signal?: AbortSignal) {
return request<VersionDiff>(`/themes/${encodeURIComponent(slug)}/diff`, {
query: { from, to },
signal,
})
}
/* -------------------------------------------------------------------------- */
/* Token tooling */
/* -------------------------------------------------------------------------- */
/** Validates a token document without saving it. */
export function validateTokens(tokens: TokenDoc, signal?: AbortSignal) {
return request<ValidationResult>('/tokens/validate', { method: 'POST', body: { tokens }, signal })
}
/** Generates a colour ramp. */
export function generateRamp(
options: Partial<{ hue: number; saturation: number; lightness: number; hueSpread: number }>,
signal?: AbortSignal,
) {
return request<RampResponse>('/tokens/palette', { query: options, signal })
}
/** Measures the contrast between two colours. */
export function checkContrast(foreground: string, background: string, signal?: AbortSignal) {
return request<ContrastResult>('/tokens/contrast', {
query: { fg: foreground, bg: background },
signal,
})
}
/** Reads the engine's token schema. */
export function getTokenSchema(signal?: AbortSignal) {
return request<TokenSchema>('/tokens/schema', { signal })
}
/* -------------------------------------------------------------------------- */
/* Administration */
/* -------------------------------------------------------------------------- */
/** Reads the dashboard counters. */
export function getStats(signal?: AbortSignal) {
return request<Stats>('/stats', { signal })
}
/** Lists known accounts. */
export function listUsers(signal?: AbortSignal) {
return request<{ items: User[] }>('/admin/users', { signal })
}
/** Query parameters for the activity log. */
export type AuditQuery = {
actor?: string
action?: string
entityType?: string
entityId?: string
since?: string
limit?: number
offset?: number
}
/** Reads the activity log. */
export function listAudit(query: AuditQuery = {}, signal?: AbortSignal) {
return request<AuditPage>('/admin/audit', { query: { ...query }, signal })
}
/** Reads instance settings. */
export function listSettings(signal?: AbortSignal) {
return request<{ items: Record<string, { key: string; value: string }> }>('/admin/settings', {
signal,
})
}
/** Writes instance settings. */
export function updateSettings(values: Record<string, string>) {
return request<{ updated: string[] }>('/admin/settings', { method: 'PUT', body: values })
}
/** Clears and re-warms the render cache. */
export function rebuildCache() {
return request<{ themes: number; artefacts: number }>('/admin/cache/rebuild', {
method: 'POST',
})
}
/* -------------------------------------------------------------------------- */
/* Runtime */
/* -------------------------------------------------------------------------- */
/** Reads the runtime theme manifest. */
export function getManifest(signal?: AbortSignal) {
return request<Manifest>('/rt/v1/manifest.json', { absolute: true, signal })
}
/** The embed snippet for a theme, in the requested shape. */
export type EmbedTarget = 'html' | 'react' | 'javascript'
/** Reads the integration snippet for a theme. */
export function getEmbedSnippet(slug: string, target: EmbedTarget = 'html', signal?: AbortSignal) {
return request<{ language?: string; snippet?: string; note?: string; html?: string }>(
'/rt/v1/embed',
{ query: { slug, target }, absolute: true, signal },
)
}
/** The runtime JSON payload for one theme. */
export type RuntimeTokens = {
theme: {
slug: string
name: string
version: number
appearance: string
updatedAt: string
}
tokens: TokenDoc
variables: Record<string, string>
meta?: Record<string, unknown>
}
/**
* Reads a theme's resolved tokens from the runtime API.
*
* This is the public, cacheable endpoint a browser would use, so using it here
* means the administration UI is exercising exactly the path consumers depend on.
*/
export function getRuntimeTokens(
slug: string,
signal?: AbortSignal,
appearance?: Appearance,
): Promise<RuntimeTokens> {
return request<RuntimeTokens>('/rt/v1/theme.json', {
absolute: true,
signal,
query: { slug, appearance },
})
}

View file

@ -0,0 +1,60 @@
/**
* Loads the theme controls this service ships to consumers, for its own admin UI.
*
* The administration UI renders the same custom elements it serves under /rt/v1
* rather than a second implementation. That is deliberate: a control that only ever
* runs in the hands of the person who wrote it is a control whose bugs are found by
* that person. Rendering the shipped element means the admin exercises it.
*
* The module is fetched from this same origin at run time rather than imported from
* the SDK package at build time. The admin application deliberately has no build-time
* dependency on the SDK, so this keeps the bundler out of it and guarantees the UI is
* using the artefact that is actually deployed.
*/
const TAG = 'bananas-runtime-controls'
/** Resolves once the custom elements are defined, or rejects if they cannot load. */
let loading: Promise<void> | null = null
/**
* Loads the module exactly once.
*
* The element tag is used as the marker instead of a separate module-level flag: a
* second `<script type="module">` for the same URL would evaluate the module again,
* which re-runs its side effects and replaces the registered element constructor.
* Custom elements cannot be re-registered, so the second script would throw.
*/
export function loadRuntimeControls(base = '/rt/v1'): Promise<void> {
if (loading) return loading
loading = new Promise<void>((resolve, reject) => {
if (customElements.get('bananas-appearance-toggle')) {
resolve()
return
}
const existing = document.querySelector<HTMLScriptElement>(`script[data-${TAG}]`)
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error('theme controls failed to load')), {
once: true,
})
return
}
const script = document.createElement('script')
script.type = 'module'
script.src = `${base}/components.js`
script.dataset[TAG] = ''
script.addEventListener('load', () => resolve(), { once: true })
script.addEventListener(
'error',
() => reject(new Error(`theme controls failed to load from ${base}`)),
{ once: true },
)
document.head.appendChild(script)
})
return loading
}

252
web/src/lib/tokens.test.ts Normal file
View file

@ -0,0 +1,252 @@
/**
* Tests for the token-document helpers.
*
* These are the functions the theme editor leans on hardest, and the ones where a
* silent mistake is most expensive: a lost token, an accidental shared reference
* between two copies of the document, or a pruned group that turns a save into an
* error.
*/
import { describe, expect, it } from 'vitest'
import {
appearanceOverrides,
cloneTokens,
colourMap,
cssVariableName,
deleteToken,
effectiveValue,
getToken,
getTokenValue,
isGroup,
isToken,
listGroups,
listTokens,
looksLikeColour,
setAppearanceOverrides,
setToken,
toPickerValue,
} from '../lib/tokens'
import type { TokenDoc, TokenNode } from '../lib/types'
/** A small but representative document. */
function fixture(): TokenDoc {
return {
$schema: 'https://urpi.be/schemas/go-bananas/tokens/v1',
color: {
palette: {
brand: {
'500': { $type: 'color', $value: '#4f46e5' },
'600': { $type: 'color', $value: '#4338ca' },
},
},
semantic: {
background: { $type: 'color', $value: '#ffffff' },
primary: { $type: 'color', $value: '#4f46e5' },
},
},
space: {
'4': { $type: 'dimension', $value: 1 },
},
$appearances: {
dark: {
color: {
semantic: {
background: { $type: 'color', $value: '#0b1220' },
},
},
},
},
}
}
describe('isToken / isGroup', () => {
it('distinguishes leaves from groups', () => {
expect(isToken({ $value: 1 })).toBe(true)
expect(isToken({ nested: {} })).toBe(false)
expect(isGroup({ nested: {} })).toBe(true)
expect(isGroup({ $value: 1 })).toBe(false)
expect(isGroup(null)).toBe(false)
})
})
describe('getToken', () => {
it('reads a leaf by dotted path', () => {
const token = getToken(fixture(), 'color.semantic.primary')
expect(token?.$value).toBe('#4f46e5')
})
it('returns undefined for a missing path', () => {
expect(getToken(fixture(), 'color.semantic.nope')).toBeUndefined()
expect(getTokenValue(fixture(), 'nope.nope.nope')).toBeUndefined()
})
it('does not mistake a group for a token', () => {
expect(getToken(fixture(), 'color.semantic')).toBeUndefined()
})
})
describe('setToken', () => {
it('creates intermediate groups', () => {
const next = setToken(fixture(), 'border.width.default', 1, 'dimension')
expect(getTokenValue(next, 'border.width.default')).toBe(1)
})
it('does not mutate the source document', () => {
const source = fixture()
const before = JSON.stringify(source)
setToken(source, 'space.4', 99, 'dimension')
expect(JSON.stringify(source)).toBe(before)
})
it('rejects a reserved structural key', () => {
expect(() => setToken(fixture(), '$schema.value', 1, 'dimension')).toThrow(/reserved/)
})
it('allows an unrecognised group, leaving the server as the authority', () => {
// The editor does not carry the engine's group list, so it writes optimistically
// and lets validation reject an unknown group. Duplicating the schema client-side
// would guarantee the two drifted apart.
const next = setToken(fixture(), 'colour.typo', 1, 'dimension')
expect(getTokenValue(next, 'colour.typo')).toBe(1)
})
it('overwrites an existing leaf', () => {
const next = setToken(fixture(), 'color.semantic.primary', '#ff0000', 'color')
expect(getTokenValue(next, 'color.semantic.primary')).toBe('#ff0000')
})
})
describe('deleteToken', () => {
it('removes a leaf and prunes the group it emptied', () => {
let doc = fixture()
doc = setToken(doc, 'border.width.default', 1, 'dimension')
doc = deleteToken(doc, 'border.width.default')
expect(getToken(doc, 'border.width.default')).toBeUndefined()
// The empty `border` and `width` groups must be gone, or validation fails.
expect(doc.border).toBeUndefined()
})
it('keeps siblings', () => {
const next = deleteToken(fixture(), 'color.palette.brand.500')
expect(getToken(next, 'color.palette.brand.600')).toBeDefined()
expect(getToken(next, 'color.palette.brand.500')).toBeUndefined()
})
it('refuses to delete a whole top-level group', () => {
const source = fixture()
expect(deleteToken(source, 'color')).toBe(source)
})
it('is a no-op for a missing path', () => {
const next = deleteToken(fixture(), 'nope.nope')
expect(getToken(next, 'color.semantic.primary')).toBeDefined()
})
})
describe('listTokens / listGroups', () => {
it('lists leaves sorted, excluding structural keys', () => {
const paths = listTokens(fixture())
expect(paths).toContain('color.semantic.primary')
expect(paths).toContain('space.4')
expect(paths.some((path) => path.startsWith('$'))).toBe(false)
expect([...paths].sort()).toEqual(paths)
})
it('lists groups sorted', () => {
const groups = listGroups(fixture())
expect(groups).toContain('color')
expect(groups).toContain('color.semantic')
expect([...groups].sort()).toEqual(groups)
})
})
describe('appearance overrides', () => {
it('reads the overrides for an appearance', () => {
const dark = appearanceOverrides(fixture(), 'dark')
expect(getTokenValue(dark, 'color.semantic.background')).toBe('#0b1220')
})
it('returns an empty document for an unknown appearance', () => {
expect(Object.keys(appearanceOverrides(fixture(), 'sepia'))).toHaveLength(0)
})
it('falls back to the base value when there is no override', () => {
expect(effectiveValue(fixture(), 'color.semantic.primary', 'dark')).toBe('#4f46e5')
})
it('prefers the override when one exists', () => {
expect(effectiveValue(fixture(), 'color.semantic.background', 'dark')).toBe('#0b1220')
})
it('writes overrides without disturbing the base', () => {
const doc = setAppearanceOverrides(fixture(), 'dark', setToken({}, 'color.semantic.text', '#fff', 'color'))
expect(getTokenValue(doc, 'color.semantic.background')).toBe('#ffffff')
expect(getTokenValue(appearanceOverrides(doc, 'dark'), 'color.semantic.text')).toBe('#fff')
})
})
describe('cssVariableName', () => {
it('builds the published name', () => {
expect(cssVariableName('color.semantic.background')).toBe('--bn-color-semantic-background')
expect(cssVariableName('space.4')).toBe('--bn-space-4')
})
it('kebab-cases the camelCase keys DTCG spells that way', () => {
expect(cssVariableName('font.lineHeight.tight')).toBe('--bn-font-line-height-tight')
expect(cssVariableName('font.letterSpacing.wide')).toBe('--bn-font-letter-spacing-wide')
})
it('does not rewrite an ordinary camelCase token name', () => {
// Only the known DTCG keys are aliased; a token genuinely called brandDark
// must survive intact.
expect(cssVariableName('color.brandDark')).toBe('--bn-color-brandDark')
})
it('honours a custom prefix', () => {
expect(cssVariableName('space.4', '--t')).toBe('--t-space-4')
})
})
describe('colour helpers', () => {
it('recognises the colour syntaxes', () => {
for (const value of ['#fff', '#ffffff', '#ffffff80', 'rgb(0,0,0)', 'hsl(0 0% 0%)', 'oklch(0.5 0.1 20)']) {
expect(looksLikeColour(value)).toBe(true)
}
expect(looksLikeColour('1px')).toBe(false)
expect(looksLikeColour('Inter, sans-serif')).toBe(false)
})
it('normalises to a form the native picker accepts', () => {
expect(toPickerValue('#ABC')).toBe('#aabbcc')
expect(toPickerValue('#4F46E5')).toBe('#4f46e5')
expect(toPickerValue('rgb(79, 70, 229)')).toBe('#4f46e5')
// Something unparseable must still yield a valid value, never an empty string
// that would render as a broken swatch.
expect(toPickerValue('var(--x)')).toMatch(/^#[0-9a-f]{6}$/)
})
it('collects only the colour leaves', () => {
const colours = colourMap(fixture(), 'light')
expect(colours['color.semantic.primary']).toBe('#4f46e5')
expect(colours['space.4']).toBeUndefined()
})
})
describe('cloneTokens', () => {
it('produces an independent copy', () => {
const source = fixture()
const copy = cloneTokens(source)
// A structural clone means even a direct mutation of the copy leaves the
// original alone, which is the property that lets the editor hold a working
// copy and revert without a round trip.
const color = copy.color as TokenNode
const semantic = color.semantic as TokenNode
semantic.primary = { $type: 'color', $value: '#000000' }
expect(getTokenValue(source, 'color.semantic.primary')).toBe('#4f46e5')
expect(getTokenValue(copy, 'color.semantic.primary')).toBe('#000000')
})
})

305
web/src/lib/tokens.ts Normal file
View file

@ -0,0 +1,305 @@
/**
* Token-document helpers.
*
* Reading and writing nested design-token trees by dotted path is the single most
* common operation in the editor, and doing it inline in a component is where
* bugs live: a missing intermediate group, an accidental shared reference, a lost
* empty object after a delete. These functions are pure and total, and are unit
* tested.
*/
import type { Token, TokenDoc, TokenNode, TokenValue } from './types'
/** The keys that are structure, not tokens. */
const RESERVED = new Set(['$schema', '$appearances', '$description', '$extensions'])
/** True when a node is a token leaf rather than a group. */
export function isToken(node: unknown): node is Token {
return typeof node === 'object' && node !== null && '$value' in node
}
/** True when a node is a nested group. */
export function isGroup(node: unknown): node is TokenNode {
return typeof node === 'object' && node !== null && !isToken(node)
}
/** Splits a dotted path into segments. */
function segments(path: string): string[] {
return path.split('.').filter((segment) => segment.length > 0)
}
/** Reads the leaf at a dotted path, or undefined. */
export function getToken(doc: TokenDoc, path: string): Token | undefined {
let current: unknown = doc
for (const segment of segments(path)) {
if (!isGroup(current)) return undefined
current = (current)[segment]
}
return isToken(current) ? current : undefined
}
/** Reads the value at a dotted path. */
export function getTokenValue(doc: TokenDoc, path: string): TokenValue | undefined {
return getToken(doc, path)?.$value
}
/**
* Immutably returns a copy of the document with one leaf set.
*
* Every path along the way is cloned, so no caller can accidentally mutate a
* document another part of the UI is holding. Structural keys are rejected: a
* token may not be created at `$schema`, because that key is not a group and a
* document containing one would fail validation with a confusing message.
*/
export function setToken(
doc: TokenDoc,
path: string,
value: TokenValue,
type?: string,
description?: string,
): TokenDoc {
const parts = segments(path)
if (parts.length === 0) return doc
if (RESERVED.has(parts[0]!)) {
throw new Error(`"${parts[0]}" is a reserved document key and cannot be a token group`)
}
const [head, ...rest] = parts
// The clone is typed loosely and narrowed on return: TokenDoc's index signature
// admits only Token | TokenNode, so building it incrementally needs a wider
// local type.
const clone: Record<string, unknown> = { ...doc }
if (rest.length === 0) {
const leaf: Token = { $value: value }
if (type) leaf.$type = type
if (description) leaf.$description = description
clone[head!] = leaf
return clone as TokenDoc
}
const child = clone[head!]
clone[head!] = setToken(isGroup(child) ? child : {}, rest.join('.'), value, type, description)
return clone as TokenDoc
}
/**
* Immutably returns a copy with one leaf removed, pruning groups it leaves empty.
*
* Pruning matters: an empty `{}` group fails validation, so leaving one behind
* would turn a successful delete into a save error.
*/
export function deleteToken(doc: TokenDoc, path: string): TokenDoc {
const parts = segments(path)
if (parts.length < 2) return doc
return prune(doc, parts)
}
/** Recursive helper for deleteToken. */
function prune(node: TokenDoc, parts: string[]): TokenDoc {
const [head, ...rest] = parts
const child = node[head!]
if (child === undefined) return node
const next: Record<string, unknown> = { ...node }
if (rest.length === 0) {
// `head` is the leaf itself: remove it from this node.
delete next[head!]
} else if (isGroup(child)) {
// `head` is an intermediate group: descend into it and come back.
next[head!] = prune(child, rest)
} else {
// A leaf where a group was expected: the path does not exist, so there is
// nothing to delete and the document is returned untouched.
return node
}
return pruneEmptyGroups(next) as TokenDoc
}
/** Removes group objects that have no children left. */
function pruneEmptyGroups(node: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(node)) {
if (key.startsWith('$')) {
out[key] = value
continue
}
if (isGroup(value)) {
const pruned = pruneEmptyGroups(value)
// An empty group would fail validation, so it is removed rather than left
// behind as debris from a delete.
if (Object.keys(pruned).length === 0) continue
out[key] = pruned
continue
}
out[key] = value
}
return out
}
/** Every leaf in the document, as dotted paths in sorted order. */
export function listTokens(doc: TokenDoc): string[] {
const paths: string[] = []
walk(doc, '', paths)
return paths.sort()
}
/** Recursive helper for listTokens. */
function walk(node: Record<string, unknown>, prefix: string, out: string[]): void {
for (const [key, value] of Object.entries(node)) {
if (key.startsWith('$')) continue
const path = prefix ? `${prefix}.${key}` : key
if (isToken(value)) {
out.push(path)
continue
}
if (isGroup(value)) walk(value, path, out)
}
}
/**
* Every group path in the document, as dotted paths in sorted order.
*
* The editor uses these to render the collapsible tree.
*/
export function listGroups(doc: TokenDoc): string[] {
const paths: string[] = []
walkGroups(doc, '', paths)
return paths.sort()
}
/** Recursive helper for listGroups. */
function walkGroups(node: Record<string, unknown>, prefix: string, out: string[]): void {
for (const [key, value] of Object.entries(node)) {
if (key.startsWith('$')) continue
if (!isGroup(value)) continue
const path = prefix ? `${prefix}.${key}` : key
out.push(path)
walkGroups(value, path, out)
}
}
/** The overrides defined for one appearance, or an empty document. */
export function appearanceOverrides(doc: TokenDoc, appearance: string): TokenDoc {
const appearances = doc.$appearances
if (!isGroup(appearances)) return {}
const override = appearances[appearance]
return isGroup(override) ? override : {}
}
/** Replaces the overrides for one appearance. */
export function setAppearanceOverrides(
doc: TokenDoc,
appearance: string,
overrides: TokenDoc,
): TokenDoc {
// The spread is widened because TokenDoc's index signature also admits `string`,
// which a structural spread cannot narrow back down on its own.
const appearances: Record<string, unknown> = isGroup(doc.$appearances)
? { ...doc.$appearances }
: {}
appearances[appearance] = overrides
return { ...doc, $appearances: appearances } as TokenDoc
}
/**
* The colour at a dotted path in one appearance, falling back to the base.
*
* This mirrors the engine's merge order: base document first, then the
* appearance's overrides on top. The editor uses it to show the effective value
* of a token rather than only the authored one.
*/
export function effectiveValue(
doc: TokenDoc,
path: string,
appearance: string,
): TokenValue | undefined {
const override = getTokenValue(appearanceOverrides(doc, appearance), path)
return override ?? getTokenValue(doc, path)
}
/** Every colour token in one appearance, as path to hex or CSS colour string. */
export function colourMap(doc: TokenDoc, appearance: string): Record<string, string> {
const out: Record<string, string> = {}
for (const path of listTokens(doc)) {
if (appearanceOverrides(doc, appearance)) {
const override = getToken(appearanceOverrides(doc, appearance), path)
if (override && typeof override.$value === 'string') {
out[path] = override.$value
continue
}
}
const value = getTokenValue(doc, path)
if (typeof value === 'string' && looksLikeColour(value)) {
out[path] = value
}
}
return out
}
/** True when a value looks like a colour the editor can offer a picker for. */
export function looksLikeColour(value: string): boolean {
const trimmed = value.trim().toLowerCase()
return (
/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(trimmed) ||
trimmed.startsWith('rgb(') ||
trimmed.startsWith('rgba(') ||
trimmed.startsWith('hsl(') ||
trimmed.startsWith('hsla(') ||
trimmed.startsWith('oklch(') ||
trimmed.startsWith('color(')
)
}
/**
* Normalises a colour to a form an `<input type="color">` accepts.
*
* The native picker only understands #rrggbb, so a theme using oklch() or a
* named colour would otherwise render as an empty swatch.
*/
export function toPickerValue(value: string): string {
const trimmed = value.trim()
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(trimmed)
if (hex) {
if (hex[1]!.length === 3) {
// Expand #abc to #aabbcc, lowercased so two spellings of the same colour do
// not produce two different DOM values.
const [r, g, b] = hex[1]!.toLowerCase()
return `#${r}${r}${g}${g}${b}${b}`
}
return trimmed.toLowerCase()
}
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(trimmed)
if (rgb) {
const channel = (raw: string) =>
Math.max(0, Math.min(255, Math.round(Number(raw))))
.toString(16)
.padStart(2, '0')
return `#${channel(rgb[1]!)}${channel(rgb[2]!)}${channel(rgb[3]!)}`
}
// Anything the picker cannot represent falls back to a valid colour rather than
// an empty string, which would render as a broken swatch.
return '#000000'
}
/** The CSS custom property a token path is published under. */
export function cssVariableName(path: string, prefix = '--bn'): string {
const aliases: Record<string, string> = {
lineHeight: 'line-height',
fontSize: 'font-size',
letterSpacing: 'letter-spacing',
fontFamily: 'font-family',
fontWeight: 'font-weight',
}
const parts = segments(path).map((part) => aliases[part] ?? part)
return `${prefix}-${parts.join('-')}`
}
/** Deep-clones a token document. Used to start an editor from a clean copy. */
export function cloneTokens(doc: TokenDoc): TokenDoc {
return structuredClone(doc)
}

315
web/src/lib/types.ts Normal file
View file

@ -0,0 +1,315 @@
/**
* Wire types for the go-bananas API.
*
* These mirror the Go structs in internal/domain and internal/httpapi. They are
* hand-written rather than generated because the API surface is small and stable:
* a code generator would add a build step and a lockfile churn to save perhaps two
* hundred lines, and the compiler already catches any drift the moment a field is
* read.
*
* The API's own error codes are a stable contract and are reproduced here, because
* the client branches on them.
*/
/** Publication state of a theme. */
export type ThemeStatus = 'draft' | 'published' | 'archived'
/** Colour scheme. */
export type Appearance = 'light' | 'dark'
/** A design-token value. Primitive types only, which is what the engine accepts. */
export type TokenValue = string | number | boolean | number[]
/** A single token leaf. */
export type Token = {
$type?: string
$value: TokenValue
$description?: string
}
/** A nested token group. */
export type TokenNode = { [key: string]: Token | TokenNode }
/** A complete token document. */
export type TokenDoc = Record<string, Token | TokenNode | string>
/** Theme metadata. Opaque to the engine apart from `tags`. */
export type ThemeMeta = Record<string, unknown>
/** A theme. */
export type Theme = {
id: string
slug: string
name: string
description?: string
status: ThemeStatus
defaultAppearance: Appearance
tokens: TokenDoc
meta?: ThemeMeta
system: boolean
version: number
createdAt: string
updatedAt: string
createdBy?: string
updatedBy?: string
}
/** A theme without its token document, as returned by list endpoints. */
export type ThemeSummary = Omit<Theme, 'tokens'>
/** A page of themes. */
export type ThemePage = {
items: ThemeSummary[]
total: number
limit: number
offset: number
}
/** One entry in a theme's history. */
export type ThemeVersion = {
themeId: string
version: number
slug: string
name: string
description?: string
status: ThemeStatus
defaultAppearance: Appearance
tokens: TokenDoc
message?: string
author?: string
createdAt: string
}
/** A history entry without its token document. */
export type ThemeVersionSummary = {} & Omit<ThemeVersion, 'tokens'>
/** One changed token between two versions. */
export type TokenDiff = {
path: string
from: string
to: string
}
/** The difference between two versions. */
export type VersionDiff = {
fromVersion: number
toVersion: number
added: string[]
removed: string[]
changed: TokenDiff[]
metaChanged: boolean
}
/** Validation severity. */
export type Severity = 'error' | 'warning' | 'info'
/** One validation finding. */
export type ValidationIssue = {
path: string
severity: Severity
message: string
hint?: string
}
/** The result of validating a token document. */
export type ValidationResult = {
valid: boolean
tokenCount: number
issues: ValidationIssue[]
}
/** A colour ramp generated by the engine. */
export type Ramp = Record<string, Token>
/** Options that produced a ramp. */
export type RampOptions = {
Hue: number
Saturation: number
Lightness: number
HueSpread: number
}
/** The engine's response to a palette generation request. */
export type RampResponse = {
options: RampOptions
shades: string[]
ramp: Ramp
}
/** The WCAG conformance level a contrast ratio satisfies. */
export type WcagLevel = 'fail' | 'aa-large' | 'aa' | 'aaa'
/** A contrast measurement. */
export type ContrastResult = {
foreground: string
background: string
ratio: number
rounded: number
level: WcagLevel
thresholds: Record<'aaLarge' | 'aa' | 'aaa', number>
bestForeground: string
}
/** One token group in the schema. */
export type TokenGroupSchema = {
name: string
kind: string
description: string
recommended: string[]
}
/** The engine's token schema. */
export type TokenSchema = {
id: string
prefix: string
kinds: string[]
groups: TokenGroupSchema[]
shades: string[]
}
/** A stored account. */
export type User = {
subject: string
email?: string
name?: string
roles: string[]
method: string
firstSeen: string
lastLogin: string
}
/** A recorded action. */
export type AuditEntry = {
id: number
at: string
actor: string
actorEmail?: string
action: string
entityType?: string
entityId?: string
summary?: string
detail?: Record<string, unknown>
remoteIp?: string
}
/** The authentication mode a deployment can run in. */
export type AuthMode = 'oidc' | 'dev' | 'none'
/** A page of audit entries. */
export type AuditPage = {
items: AuditEntry[]
total: number
limit: number
offset: number
}
/** An instance setting. */
export type Setting = {
key: string
value: string
updatedAt: string
updatedBy?: string
}
/** Dashboard counters. */
export type Stats = {
store: {
themes: number
themesByStatus: Record<string, number>
users: number
auditEntries: number
databaseSizeBytes: number
versionsRetaind: number
}
cache: {
CSSHits: number
CSSMisses: number
JSONHits: number
JSONMisses: number
Entries: number
}
}
/** The caller's session. */
/**
* What a person has chosen for their own interface.
*
* Both fields are empty when they have chosen nothing, which is a valid state
* rather than a missing one: the UI then applies its own default. selectedTheme
* names a published theme, so editing or unpublishing that theme is reflected
* without touching this value.
*/
export type UserPreferences = {
selectedTheme: string
/** Ignored when selectedTheme is empty. */
selectedAppearance: Appearance
}
export type Session = {
authenticated: boolean
/** The active authentication mode. */
mode: AuthMode
identity?: {
subject: string
email?: string
name?: string
roles: string[]
method: string
expiresAt?: string
}
permissions?: Record<string, boolean>
/**
* The caller's interface preferences, present once authenticated. Reported here
* rather than through a second request so the UI can preselect a theme from the
* same round trip that establishes who the caller is.
*/
preferences?: UserPreferences
devBypass?: boolean
loginUrl?: string
}
/** Client-visible deployment configuration. */
export type AppConfig = {
mode: string
issuer: string
runtimeBase: string
publicBase: string
basePath: string
runtimeApiVersion: string
variablePrefix: string
defaultTheme: string
}
/** A theme in the runtime manifest. */
export type ManifestEntry = {
slug: string
name: string
description?: string
defaultAppearance: Appearance
version: number
tags?: string[]
css: string
tokens: string
}
/** The runtime discovery document. */
export type Manifest = {
apiVersion: string
default?: string
baseUrl: string
themes: ManifestEntry[]
}
/** A field-level validation problem returned by the API. */
export type FieldError = {
field: string
message: string
}
/** The uniform error body. */
export type ApiErrorBody = {
code: string
message: string
details?: FieldError[]
requestId?: string
}

169
web/src/lib/utils.ts Normal file
View file

@ -0,0 +1,169 @@
/**
* Small shared utilities.
*
* Kept deliberately minimal: a class-name joiner and a few formatters. Anything
* that grew a dependency-shaped API here would be better as a module with its own
* tests.
*/
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* Joins class names, resolving conflicting Tailwind utilities in favour of the
* last one.
*
* The conflict resolution is the point of wrapping twMerge: a component that
* accepts a `className` prop is useless if a caller cannot reliably override its
* default padding, because the two classes both match and CSS order decides.
*
* @param inputs Class values in any shape clsx accepts.
* @returns A deduplicated class string.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
/** Formats a byte count for display. */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
const units = ['B', 'kB', 'MB', 'GB', 'TB']
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** exponent
return `${value.toFixed(exponent === 0 ? 0 : value >= 10 ? 0 : 1)} ${units[exponent]}`
}
/** Formats an ISO timestamp as a short local date. */
export function formatDate(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date)
}
/** Formats an ISO timestamp as a relative duration, e.g. "3 hours ago". */
export function formatRelative(iso: string, now = Date.now()): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return '—'
const deltaSeconds = Math.round((date.getTime() - now) / 1000)
const absolute = Math.abs(deltaSeconds)
if (absolute < 45) return 'just now'
const units: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 31_536_000],
['month', 2_592_000],
['week', 604_800],
['day', 86_400],
['hour', 3_600],
['minute', 60],
]
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
for (const [unit, seconds] of units) {
if (absolute >= seconds) {
return formatter.format(Math.round(deltaSeconds / seconds), unit)
}
}
return formatter.format(Math.round(deltaSeconds), 'second')
}
/** Formats a number with locale grouping. */
export function formatNumber(value: number): string {
return new Intl.NumberFormat().format(value)
}
/** Truncates a string in the middle, which keeps both ends readable. */
export function truncateMiddle(value: string, max = 24): string {
if (value.length <= max) return value
const head = Math.ceil((max - 1) / 2)
const tail = Math.floor((max - 1) / 2)
return `${value.slice(0, head)}…${value.slice(value.length - tail)}`
}
/**
* Turns a slug into a human title.
*
* Only used as a placeholder: a real theme has a name field, and inferring one
* from a slug would put a derived string where a real value belongs.
*/
export function titleFromSlug(slug: string): string {
return slug
.split('-')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
/** Extracts the tags from a theme's opaque metadata. */
export function extractTags(meta?: Record<string, unknown>): string[] {
const raw = meta?.tags
if (Array.isArray(raw)) return raw.filter((tag): tag is string => typeof tag === 'string')
if (typeof raw === 'string') {
return raw
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
}
return []
}
/** Debounces a function, cancelling any pending call on unmount. */
export function debounce<A extends unknown[]>(
fn: (...args: A) => void,
delayMs: number,
): ((...args: A) => void) & { cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined
const debounced = (...args: A) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delayMs)
}
debounced.cancel = () => {
if (timer) clearTimeout(timer)
timer = undefined
}
return debounced
}
/** Copies text to the clipboard, falling back for non-secure contexts. */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// navigator.clipboard is unavailable over plain HTTP, which a local
// development run often is. The textarea fallback keeps the button working.
try {
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', '')
area.style.position = 'fixed'
area.style.opacity = '0'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
document.body.removeChild(area)
return ok
} catch {
return false
}
}
}
/** Downloads a value as a file, used by the export and key-reveal flows. */
export function downloadText(filename: string, content: string, mime = 'application/json'): void {
const blob = new Blob([content], { type: `${mime};charset=utf-8` })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
// Revoking immediately can cancel the download in some browsers; a short delay
// is the reliable compromise.
setTimeout(() => URL.revokeObjectURL(url), 1000)
}

26
web/src/main.tsx Normal file
View file

@ -0,0 +1,26 @@
/**
* Entry point.
*
* StrictMode is enabled deliberately. It double-invokes effects in development,
* which is exactly the check that catches a provider with an unstable dependency
* array — the single most common source of a subtle render loop.
*/
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'
const container = document.getElementById('root')
if (!container) {
// A missing mount point is a build-time mistake, so failing loudly here beats a
// blank page with no explanation.
throw new Error('Go Bananas!! admin: #root is missing from index.html')
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)

225
web/src/routes/audit.tsx Normal file
View file

@ -0,0 +1,225 @@
/**
* The activity log.
*
* Every mutation in the administration API writes a row here. This page is how an
* operator answers "who published that, and when" after the fact.
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, ChevronDown, ChevronRight } from 'lucide-react'
import { listAudit } from '../lib/api'
import { formatDate, formatRelative } from '../lib/utils'
import type { AuditEntry } from '../lib/types'
import {
Badge,
Button,
Card,
EmptyState,
ErrorState,
Field,
Input,
PageHeader,
Select,
Skeleton,
} from '../components/ui'
/** Actions the log records, for the filter dropdown. */
const ACTIONS = [
'theme.create',
'theme.update',
'theme.publish',
'theme.archive',
'theme.delete',
'theme.duplicate',
'theme.rollback',
'theme.import',
'key.create',
'key.revoke',
'auth.login',
'auth.logout',
]
export default function AuditRoute() {
const [actor, setActor] = useState('')
const [action, setAction] = useState('')
const [expanded, setExpanded] = useState<Set<number>>(() => new Set())
const audit = useQuery({
queryKey: ['audit', { actor, action }],
queryFn: ({ signal }) =>
listAudit({
actor: actor || undefined,
action: action || undefined,
limit: 100,
}, signal),
placeholderData: (previous) => previous,
})
function toggle(id: number) {
setExpanded((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="space-y-6">
<PageHeader
title="Activity"
description="Every change made through the administration interface, with the actor, the time and the originating address."
actions={
<Button variant="secondary" onClick={() => void audit.refetch()}>
Refresh
</Button>
}
/>
<Card>
<div className="border-line flex flex-wrap items-end gap-3 border-b px-4 py-3">
<Field label="Actor" htmlFor="audit-actor" className="min-w-[14rem] flex-1">
<Input
id="audit-actor"
value={actor}
onChange={(event) => setActor(event.target.value)}
placeholder="email or subject"
type="search"
className="h-9"
/>
</Field>
<Field label="Action" htmlFor="audit-action" className="min-w-[12rem]">
<Select
id="audit-action"
value={action}
onChange={(event) => setAction(event.target.value)}
className="h-9"
>
<option value="">All actions</option>
{ACTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
</Field>
{actor || action ? (
<Button
variant="ghost"
onClick={() => {
setActor('')
setAction('')
}}
>
Clear
</Button>
) : null}
</div>
{audit.error ? (
<div className="p-4">
<ErrorState
title="Could not load the activity log"
message={audit.error instanceof Error ? audit.error.message : String(audit.error)}
onRetry={() => void audit.refetch()}
/>
</div>
) : audit.isLoading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 8 }, (_, index) => (
<Skeleton key={index} className="h-12" />
))}
</div>
) : audit.data && audit.data.items.length > 0 ? (
<>
<ul className="divide-line divide-y">
{audit.data.items.map((entry) => (
<li key={entry.id}>
<div className="flex items-start gap-3 px-4 py-3">
<Badge tone={toneForAction(entry.action)} className="mt-0.5 shrink-0">
{entry.action}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-ink text-xs">
<span className="font-medium">{actorName(entry)}</span>{' '}
<span className="text-ink-muted">{entry.summary ?? ''}</span>
</p>
<p className="text-ink-subtle mt-0.5 text-[11px]">
{formatDate(entry.at)} &middot; {formatRelative(entry.at)}
{entry.remoteIp ? ` · ${entry.remoteIp}` : ''}
{entry.entityType ? ` · ${entry.entityType}` : ''}
{entry.entityId ? ` ${entry.entityId}` : ''}
</p>
</div>
{entry.detail && Object.keys(entry.detail).length > 0 ? (
<button
type="button"
onClick={() => toggle(entry.id)}
aria-expanded={expanded.has(entry.id)}
aria-label={`Toggle details for entry ${entry.id}`}
className="text-ink-subtle hover:text-ink mt-0.5 shrink-0"
>
{expanded.has(entry.id) ? (
<ChevronDown className="size-3.5" aria-hidden />
) : (
<ChevronRight className="size-3.5" aria-hidden />
)}
</button>
) : null}
</div>
{expanded.has(entry.id) && entry.detail ? (
<pre className="border-line bg-canvas text-ink-muted mx-4 mb-3 overflow-x-auto rounded-control border p-3 font-mono text-[11px]">
{JSON.stringify(entry.detail, null, 2)}
</pre>
) : null}
</li>
))}
</ul>
<div className="border-line text-ink-subtle flex items-center justify-between border-t px-4 py-3 text-xs">
<span>
Showing {audit.data.items.length} of {audit.data.total} entries
</span>
</div>
</>
) : (
<EmptyState
icon={<Activity className="size-8" aria-hidden />}
title="Nothing recorded yet"
description={
actor || action
? 'No entries match these filters.'
: 'Actions taken in the administration interface will appear here.'
}
/>
)}
</Card>
</div>
)
}
/** Maps an action onto a badge tone. */
function toneForAction(action: string): 'neutral' | 'brand' | 'good' | 'warn' | 'bad' | 'info' {
if (action.includes('delete') || action.includes('revoke')) return 'bad'
if (action.includes('publish')) return 'good'
if (action.includes('create') || action.includes('import')) return 'info'
if (action.includes('rollback') || action.includes('archive')) return 'warn'
if (action.startsWith('auth.')) return 'neutral'
return 'brand'
}
/** Picks the friendliest label for an actor. */
function actorName(entry: AuditEntry): string {
if (entry.actorEmail) return entry.actorEmail
if (entry.actor.startsWith('dev:')) return entry.actor.slice(4)
if (entry.actor.startsWith('apikey:')) return 'API key'
if (entry.actor === 'system') return 'go-bananas'
if (entry.actor === 'anonymous') return 'anonymous'
return entry.actor || 'unknown'
}

View file

@ -0,0 +1,298 @@
/**
* The dashboard.
*
* Answers the questions an operator has when they first open the service: is it
* healthy, how many themes exist, how many are actually being served, and is the
* runtime cache doing its job.
*/
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
Activity,
ArrowRight,
HardDrive,
Palette,
Users,
Zap,
} from 'lucide-react'
import { getManifest, getStats, listAudit, listThemes } from '../lib/api'
import { formatBytes, formatNumber, formatRelative } from '../lib/utils'
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
EmptyState,
ErrorState,
PageHeader,
Skeleton,
Stat,
} from '../components/ui'
export default function DashboardRoute() {
const stats = useQuery({ queryKey: ['stats'], queryFn: ({ signal }) => getStats(signal) })
const themes = useQuery({
queryKey: ['themes', { limit: 6, sort: 'updated' }],
queryFn: ({ signal }) => listThemes({ limit: 6, sort: 'updated' }, signal),
})
const audit = useQuery({
queryKey: ['audit', { limit: 8 }],
queryFn: ({ signal }) => listAudit({ limit: 8 }, signal),
})
const manifest = useQuery({ queryKey: ['manifest'], queryFn: ({ signal }) => getManifest(signal) })
const cacheStats = stats.data?.cache
const cacheHits = cacheStats ? cacheStats.CSSHits + cacheStats.JSONHits : 0
const cacheMisses = cacheStats ? cacheStats.CSSMisses + cacheStats.JSONMisses : 0
const hitRate =
cacheHits + cacheMisses > 0 ? Math.round((cacheHits / (cacheHits + cacheMisses)) * 100) : null
return (
<div className="space-y-6">
<PageHeader
title="Dashboard"
description="An overview of this Go Bananas!! instance: what exists, what is being served, and whether the runtime is behaving."
actions={
<Button variant="secondary" onClick={() => void stats.refetch()}>
Refresh
</Button>
}
/>
{stats.error ? (
<ErrorState
title="Could not load statistics"
message={stats.error instanceof Error ? stats.error.message : String(stats.error)}
onRetry={() => void stats.refetch()}
/>
) : null}
{/* Counters. */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Themes"
icon={<Palette className="size-4" aria-hidden />}
value={stats.isLoading ? <Skeleton className="h-8 w-16" /> : formatNumber(stats.data?.store.themes ?? 0)}
hint={
stats.data
? `${stats.data.store.themesByStatus.published ?? 0} published, ${stats.data.store.themesByStatus.draft ?? 0} draft`
: undefined
}
/>
<Stat
label="Signed-in users"
icon={<Users className="size-4" aria-hidden />}
value={stats.isLoading ? <Skeleton className="h-8 w-16" /> : formatNumber(stats.data?.store.users ?? 0)}
hint="seen at least once"
/>
<Stat
label="Database size"
icon={<HardDrive className="size-4" aria-hidden />}
value={stats.isLoading ? <Skeleton className="h-8 w-16" /> : formatBytes(stats.data?.store.databaseSizeBytes ?? 0)}
hint="SQLite, on disk"
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{/* Recently changed themes. */}
<Card className="lg:col-span-2">
<CardHeader
title="Recently changed"
description="The themes most likely to need attention."
actions={
<Link
to="/themes"
className="text-ink-muted hover:text-ink inline-flex items-center gap-1 text-xs"
>
All themes <ArrowRight className="size-3" aria-hidden />
</Link>
}
/>
{themes.isLoading ? (
<CardBody className="space-y-2">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-12" />
))}
</CardBody>
) : themes.data && themes.data.items.length > 0 ? (
<ul className="divide-line divide-y">
{themes.data.items.map((theme) => (
<li key={theme.slug}>
<Link
to={`/themes/${theme.slug}`}
className="hover:bg-raised flex items-center gap-3 px-5 py-3 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-ink truncate text-sm font-medium">{theme.name}</p>
<p className="text-ink-subtle mt-0.5 font-mono text-[11px]">{theme.slug}</p>
</div>
<StatusBadge status={theme.status} />
<span className="text-ink-subtle shrink-0 text-xs">
{formatRelative(theme.updatedAt)}
</span>
<span className="text-ink-subtle tabular w-10 shrink-0 text-right text-xs">
v{theme.version}
</span>
</Link>
</li>
))}
</ul>
) : (
<EmptyState
icon={<Palette className="size-8" aria-hidden />}
title="No themes yet"
description="Create a theme to start serving design tokens at runtime."
/>
)}
</Card>
{/* Runtime health. */}
<div className="space-y-4">
<Card>
<CardHeader title="Runtime" description="What consumers of this service see." />
<CardBody className="space-y-4">
<div>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="text-ink-muted inline-flex items-center gap-1.5">
<Zap className="size-3" aria-hidden /> Cache hit rate
</span>
<span className="text-ink tabular">
{hitRate === null ? '—' : `${hitRate}%`}
</span>
</div>
<div className="bg-raised h-1.5 w-full overflow-hidden rounded-full">
<div
className={
hitRate === null
? 'bg-ink-subtle h-full'
: hitRate > 80
? 'bg-good h-full'
: hitRate > 40
? 'bg-warn h-full'
: 'bg-bad h-full'
}
style={{ width: `${hitRate ?? 0}%` }}
/>
</div>
<p className="text-ink-subtle mt-1.5 text-[11px]">
{formatNumber(cacheHits)} hits, {formatNumber(cacheMisses)} misses across{' '}
{cacheStats?.Entries ?? 0} cached artefacts.
</p>
</div>
<dl className="space-y-2 text-xs">
<Row label="Published themes" value={String(manifest.data?.themes.length ?? 0)} />
<Row label="Runtime API" value={`v${manifest.data?.apiVersion ?? '—'}`} />
<Row
label="History entries"
value={formatNumber(stats.data?.store.versionsRetaind ?? 0)}
/>
</dl>
</CardBody>
</Card>
<Card>
<CardHeader title="Activity" description="The most recent recorded actions." />
{audit.isLoading ? (
<CardBody className="space-y-2">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton key={index} className="h-8" />
))}
</CardBody>
) : audit.data && audit.data.items.length > 0 ? (
<ul className="divide-line divide-y">
{audit.data.items.map((entry) => (
<li key={entry.id} className="px-5 py-2.5">
<p className="text-ink-muted truncate text-xs">
<span className="text-ink font-medium">{actorName(entry)}</span>{' '}
{entry.summary ?? entry.action}
</p>
<p className="text-ink-subtle mt-0.5 text-[11px]">
{formatRelative(entry.at)}
{entry.remoteIp ? ` · ${entry.remoteIp}` : ''}
</p>
</li>
))}
</ul>
) : (
<EmptyState
icon={<Activity className="size-7" aria-hidden />}
title="Nothing recorded yet"
description="Actions taken in the administration interface will appear here."
/>
)}
</Card>
</div>
</div>
<Card>
<CardHeader
title="Runtime delivery"
description="Every published theme is served as CSS custom properties and JSON tokens. Nothing is installed by consumers."
actions={
<Link
to="/integrate"
className="text-brand hover:underline inline-flex items-center gap-1.5 text-xs font-medium"
>
Integration guide <ArrowRight className="size-3" aria-hidden />
</Link>
}
/>
<CardBody>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Endpoint
path="/rt/v1/theme.css?slug=…"
description="A theme's stylesheet, ready for a link tag."
/>
<Endpoint
path="/rt/v1/theme.json?slug=…"
description="Resolved tokens, flat and nested."
/>
<Endpoint
path="/rt/v1/manifest.json"
description="The theme list a picker builds from."
/>
</div>
</CardBody>
</Card>
</div>
)
}
/** A label/value row. */
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-2">
<dt className="text-ink-muted">{label}</dt>
<dd className="text-ink tabular font-medium">{value}</dd>
</div>
)
}
/** One runtime endpoint, described. */
function Endpoint({ path, description }: { path: string; description: string }) {
return (
<div className="border-line bg-canvas rounded-control border p-3">
<p className="text-brand font-mono text-xs">{path}</p>
<p className="text-ink-muted mt-1.5 text-[11px] leading-relaxed">{description}</p>
</div>
)
}
/** Maps a status onto a badge tone. */
export function StatusBadge({ status }: { status: string }) {
const tone =
status === 'published' ? 'good' : status === 'draft' ? 'warn' : 'neutral'
return <Badge tone={tone}>{status}</Badge>
}
/** Picks the friendliest available label for an audit actor. */
function actorName(entry: { actor: string; actorEmail?: string }): string {
if (entry.actorEmail) return entry.actorEmail
if (entry.actor.startsWith('dev:')) return entry.actor.slice(4)
if (entry.actor.startsWith('apikey:')) return 'API key'
return entry.actor || 'someone'
}

View file

@ -0,0 +1,264 @@
/**
* The integration guide.
*
* This page exists because the first integration is the hardest one, and the
* whole point of the runtime delivery model is that it takes one line of markup
* rather than a package install. Everything here is generated from this
* deployment's own configuration, so it cannot drift from reality.
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Code2, FileCode2, Package } from 'lucide-react'
import { getConfig, getEmbedSnippet, getManifest, type EmbedTarget } from '../lib/api'
import { Badge, Card, CardBody, CardHeader, CopyButton, ErrorState, PageHeader, Select, Skeleton, Tabs } from '../components/ui'
/** The integration options offered, in the order a developer would try them. */
const OPTIONS: {
id: EmbedTarget
label: string
icon: typeof Code2
blurb: string
requires: string
}[] = [
{
id: 'html',
label: 'Any HTML page',
icon: FileCode2,
blurb: 'A stylesheet link and a script tag. Works in a server-rendered page, a Rails view, a Django template, anything.',
requires: 'Nothing',
},
{
id: 'javascript',
label: 'Plain JavaScript',
icon: Code2,
blurb: 'An ES module you import by URL. Use it when you want to switch themes at run time from your own code.',
requires: 'ES modules',
},
{
id: 'react',
label: 'React',
icon: Package,
blurb: 'A provider component and hooks, loaded through an import map. No npm dependency, no build step.',
requires: 'React 18+',
},
]
export default function IntegrateRoute() {
const [target, setTarget] = useState<EmbedTarget>('html')
const [slug, setSlug] = useState('')
const config = useQuery({ queryKey: ['config'], queryFn: ({ signal }) => getConfig(signal) })
const manifest = useQuery({ queryKey: ['manifest'], queryFn: ({ signal }) => getManifest(signal) })
const themes = manifest.data?.themes ?? []
const selectedSlug = slug || themes[0]?.slug || 'midnight'
const snippet = useQuery({
queryKey: ['embed', selectedSlug, target],
queryFn: ({ signal }) => getEmbedSnippet(selectedSlug, target, signal),
})
return (
<div className="space-y-6">
<PageHeader
title="Integrate"
description="Go Bananas!! delivers themes at run time, so consuming an application installs nothing. Pick the integration that matches your stack and paste."
/>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="space-y-4">
<Card>
<div className="border-line flex flex-wrap items-center gap-3 border-b px-4 py-3">
<Tabs
value={target}
onChange={(next) => setTarget(next as EmbedTarget)}
className="border-b-0"
tabs={OPTIONS.map((option) => ({ id: option.id, label: option.label }))}
/>
<div className="ml-auto flex items-center gap-2">
<label htmlFor="integrate-theme" className="sr-only">
Theme
</label>
<Select
id="integrate-theme"
value={selectedSlug}
onChange={(event) => setSlug(event.target.value)}
className="h-8 w-40 text-xs"
>
{themes.map((theme) => (
<option key={theme.slug} value={theme.slug}>
{theme.name}
</option>
))}
{themes.length === 0 ? <option value={selectedSlug}>{selectedSlug}</option> : null}
</Select>
</div>
</div>
<CardBody className="space-y-4">
<p className="text-ink-muted text-xs leading-relaxed">
{OPTIONS.find((option) => option.id === target)?.blurb}
</p>
{snippet.isLoading ? (
<Skeleton className="h-40" />
) : snippet.error ? (
<ErrorState
title="Could not build the snippet"
message={snippet.error instanceof Error ? snippet.error.message : String(snippet.error)}
onRetry={() => void snippet.refetch()}
/>
) : (
<>
<div className="border-line bg-canvas relative overflow-x-auto rounded-card border">
<pre className="text-ink-muted p-4 font-mono text-xs leading-relaxed">
<code>{snippet.data?.snippet ?? 'Select a theme to see the snippet.'}</code>
</pre>
{snippet.data?.snippet ? (
<div className="absolute right-3 top-3">
<CopyButton value={snippet.data.snippet} />
</div>
) : null}
</div>
{snippet.data?.note ? (
<p className="text-info bg-info/8 rounded-control border border-info/30 p-2.5 text-[11px] leading-relaxed">
{snippet.data.note}
</p>
) : null}
</>
)}
</CardBody>
</Card>
<Card>
<CardHeader title="How it works" />
<CardBody className="space-y-4">
<ol className="space-y-3">
{[
{
title: 'The server publishes CSS custom properties',
body: 'Each theme renders to a stylesheet whose variables are all prefixed --bn-. Nothing else about your application has to change.',
},
{
title: 'Your page asks for the stylesheet',
body: 'One <link> tag. The response is immutable by URL, so a CDN caches it hard and revalidates cheaply.',
},
{
title: 'The theme attribute scopes it',
body: 'Setting data-bananas-theme="midnight" on <html> activates the theme. Setting it on any element scopes that theme to a subtree, so several themes can coexist on one page.',
},
{
title: 'Dark mode is a second attribute',
body: 'data-bananas-appearance="dark" swaps to the dark scheme. The runtime also honours a .dark ancestor class and prefers-color-scheme, whichever your application already uses.',
},
].map((step, index) => (
<li key={step.title} className="flex gap-3">
<span className="bg-brand-subtle text-brand tabular grid size-6 shrink-0 place-items-center rounded-full text-[11px] font-semibold">
{index + 1}
</span>
<div>
<p className="text-ink text-xs font-medium">{step.title}</p>
<p className="text-ink-muted mt-0.5 text-[11px] leading-relaxed">{step.body}</p>
</div>
</li>
))}
</ol>
</CardBody>
</Card>
<Card>
<CardHeader title="Reading tokens in JavaScript" />
<CardBody className="space-y-3">
<p className="text-ink-muted text-xs leading-relaxed">
Some things cannot read CSS: a canvas chart, a PDF generator, an email template.
Fetch the JSON token document and read the values directly.
</p>
<div className="border-line bg-canvas overflow-x-auto rounded-card border">
<pre className="text-ink-muted p-4 font-mono text-xs leading-relaxed">
<code>{`const response = await fetch(
'${config.data?.runtimeBase ?? '/rt/v1'}/theme.json?slug=${selectedSlug}'
)
const { variables } = await response.json()
const primary = variables['--bn-color-semantic-primary']
const radius = variables['--bn-radius-md']`}</code>
</pre>
</div>
</CardBody>
</Card>
</div>
<div className="space-y-4">
<Card>
<CardHeader title="This deployment" />
<CardBody className="space-y-3 text-xs">
<Row label="Runtime base" value={config.data?.runtimeBase ?? '—'} mono />
<Row label="API version" value={`v${config.data?.runtimeApiVersion ?? '—'}`} />
<Row label="Variable prefix" value={config.data?.variablePrefix ?? '--bn'} mono />
<Row label="Authentication" value={config.data?.mode ?? '—'} />
<Row label="Published themes" value={String(themes.length)} />
</CardBody>
</Card>
<Card>
<CardHeader title="Available themes" />
<ul className="divide-line divide-y">
{themes.map((theme) => (
<li key={theme.slug} className="px-4 py-2.5">
<p className="text-ink text-xs font-medium">{theme.name}</p>
<p className="text-ink-subtle mt-0.5 font-mono text-[10px]">{theme.slug}</p>
{theme.tags?.length ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{theme.tags.slice(0, 4).map((tag) => (
<Badge key={tag} tone="neutral" className="text-[10px]">
{tag}
</Badge>
))}
</div>
) : null}
</li>
))}
{themes.length === 0 ? (
<li className="text-ink-subtle px-4 py-6 text-center text-xs">
No themes have been published yet.
</li>
) : null}
</ul>
</Card>
<Card>
<CardHeader title="Verifying it works" />
<CardBody>
<p className="text-ink-muted text-[11px] leading-relaxed">
Open the runtime endpoint directly. If the response is CSS, the integration will
work in any application.
</p>
<a
href={`${config.data?.runtimeBase ?? '/rt/v1'}/theme.css?slug=${selectedSlug}`}
target="_blank"
rel="noreferrer"
className="text-brand hover:underline mt-2 inline-block font-mono text-[11px]"
>
/rt/v1/theme.css?slug={selectedSlug}
</a>
</CardBody>
</Card>
</div>
</div>
</div>
)
}
/** A label/value row. */
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-3">
<span className="text-ink-muted shrink-0">{label}</span>
<span className={`text-ink text-right ${mono ? 'font-mono text-[11px]' : ''}`}>{value}</span>
</div>
)
}

102
web/src/routes/login.tsx Normal file
View file

@ -0,0 +1,102 @@
/**
* The sign-in page.
*
* When the deployment uses an identity provider this page just redirects to it;
* the interesting behaviour is the development bypass, where the page explains
* what is happening rather than appearing to do nothing.
*/
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, Loader2, LogIn } from 'lucide-react'
import { getSession } from '../lib/api'
import { Button, Card, CardBody } from '../components/ui'
export default function LoginRoute() {
const session = useQuery({
queryKey: ['session'],
queryFn: ({ signal }) => getSession(signal),
staleTime: 0,
})
// In the common case there is nothing to decide: send the browser straight to the
// provider. Doing it in an effect rather than during render keeps the navigation
// out of React's render cycle, which is what React expects.
useEffect(() => {
if (session.data?.authenticated) {
window.location.assign('/')
return
}
if (session.data?.loginUrl) {
window.location.assign(session.data.loginUrl)
}
}, [session.data])
const loading = session.isLoading
const devBypass = session.data?.devBypass
return (
<div className="grid min-h-screen place-items-center p-6">
<Card className="w-full max-w-md">
<CardBody className="space-y-5 text-center">
<div>
<h1 className="text-ink text-lg font-semibold tracking-tight">Go Bananas!!</h1>
<p className="text-ink-muted mt-1.5 text-xs leading-relaxed">
Sign in to manage themes for this instance.
</p>
</div>
{loading ? (
<div className="text-ink-muted flex items-center justify-center gap-2 py-6 text-xs">
<Loader2 className="size-4 animate-spin" aria-hidden />
Checking your session…
</div>
) : devBypass ? (
<div className="space-y-4">
<div className="border-warn/40 bg-warn/8 flex items-start gap-2.5 rounded-control border p-3 text-left">
<AlertTriangle className="text-warn mt-0.5 size-4 shrink-0" aria-hidden />
<div>
<p className="text-ink text-xs font-medium">Development mode</p>
<p className="text-ink-muted mt-1 text-[11px] leading-relaxed">
Authentication is bypassed: every request is treated as a local administrator.
This mode cannot be enabled outside a development environment.
</p>
</div>
</div>
<Button variant="primary" size="lg" className="w-full" onClick={() => window.location.assign('/')}>
Continue
</Button>
<p className="text-ink-subtle text-[11px] leading-relaxed">
To try the role model, append <code className="text-ink-muted">?dev-user=someone@example.com</code> to the
URL. Roles come from the <code className="text-ink-muted">DEV_USERS</code> setting.
</p>
</div>
) : (
<div className="space-y-4">
<Button
variant="primary"
size="lg"
className="w-full"
icon={<LogIn className="size-4" aria-hidden />}
onClick={() => window.location.assign(session.data?.loginUrl ?? '/auth/login')}
>
Sign in
</Button>
{session.error ? (
<p className="text-bad text-[11px]">{session.error.message}</p>
) : (
<p className="text-ink-subtle text-[11px]">
You will be redirected to the identity provider.
</p>
)}
</div>
)}
</CardBody>
</Card>
</div>
)
}

210
web/src/routes/settings.tsx Normal file
View file

@ -0,0 +1,210 @@
/**
* Instance settings.
*
* Only the settings the engine actually reads at runtime are editable here, and
* each one is annotated with what it affects. Anything requiring a restart is
* environment configuration and belongs in the Helm chart, not in this page.
*/
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Save, ShieldCheck } from 'lucide-react'
import { getManifest, listSettings, updateSettings } from '../lib/api'
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Field,
PageHeader,
Select,
Skeleton,
} from '../components/ui'
export default function SettingsRoute() {
const queryClient = useQueryClient()
const settings = useQuery({ queryKey: ['settings'], queryFn: ({ signal }) => listSettings(signal) })
const manifest = useQuery({ queryKey: ['manifest'], queryFn: ({ signal }) => getManifest(signal) })
const [form, setForm] = useState<{ defaultTheme: string; defaultAppearance: string } | null>(null)
// The form is seeded from the server once and then owned by the user. The seed
// happens during render rather than in an effect, so the inputs are populated
// on the first paint instead of flashing empty and repopulating.
const items = settings.data?.items
if (items && form === null) {
setForm({
defaultTheme: items['default.theme']?.value ?? '',
defaultAppearance: items['default.appearance']?.value ?? 'light',
})
}
const save = useMutation({
mutationFn: updateSettings,
onSuccess: (result) => {
toast.success('Settings saved', { description: result.updated.join(', ') })
void queryClient.invalidateQueries({ queryKey: ['settings'] })
void queryClient.invalidateQueries({ queryKey: ['manifest'] })
},
onError: (error) => {
toast.error('Could not save the settings', {
description: error instanceof Error ? error.message : String(error),
})
},
})
const themes = manifest.data?.themes ?? []
return (
<div className="space-y-6">
<PageHeader
title="Settings"
description="Instance-level defaults. Everything else is configured by environment variable and delivered by the Helm chart."
actions={
<Button
variant="primary"
icon={<Save className="size-4" aria-hidden />}
loading={save.isPending}
disabled={form === null}
onClick={() => {
if (!form) return
save.mutate({
'default.theme': form.defaultTheme,
'default.appearance': form.defaultAppearance,
})
}}
>
Save
</Button>
}
/>
<Card>
<CardHeader
title="Defaults"
description="What a consumer gets when it does not choose a theme explicitly."
/>
<CardBody className="space-y-4">
{settings.isLoading ? (
<Skeleton className="h-24" />
) : (
<>
<Field
label="Default theme"
htmlFor="setting-default-theme"
hint="Used by the theme picker in the runtime loader and by the embed snippet generator."
>
<Select
id="setting-default-theme"
value={form?.defaultTheme ?? ''}
onChange={(event) => setForm({ ...form!, defaultTheme: event.target.value })}
className="max-w-sm"
>
<option value="">No default (consumers must choose)</option>
{themes.map((theme) => (
<option key={theme.slug} value={theme.slug}>
{theme.name} ({theme.slug})
</option>
))}
</Select>
</Field>
<Field
label="Default appearance"
htmlFor="setting-default-appearance"
hint="The colour scheme applied when a consumer asks for the theme without specifying one."
>
<Select
id="setting-default-appearance"
value={form?.defaultAppearance ?? 'light'}
onChange={(event) => setForm({ ...form!, defaultAppearance: event.target.value })}
className="max-w-[12rem]"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</Select>
</Field>
</>
)}
</CardBody>
</Card>
<Card>
<CardHeader
title="Deployment configuration"
description="Read-only. These come from the environment and changing them requires a rollout."
/>
<CardBody className="space-y-3">
<ReadOnlyRow
label="Authentication mode"
value={settings.data?.items['runtime.public'] ? 'public runtime' : 'restricted runtime'}
badge={settings.data?.items['runtime.public']?.value === 'true' ? 'public' : 'restricted'}
/>
<ReadOnlyRow
label="Runtime API base"
value={manifest.data?.baseUrl ?? '—'}
/>
<ReadOnlyRow
label="Contract version"
value={`v${manifest.data?.apiVersion ?? '—'}`}
/>
<ReadOnlyRow
label="Database size"
value={
settings.data?.items.environment
? `${settings.data.items.environment.value}`
: '—'
}
/>
</CardBody>
</Card>
<Card>
<CardHeader
title="What is not here"
description="Deliberately, so that nobody goes looking in the wrong place."
/>
<CardBody>
<ul className="text-ink-muted space-y-2 text-xs leading-relaxed">
{[
'OIDC issuer, client credentials and group mappings — set by the Helm chart so that secrets never pass through the browser.',
'Database path, pool size and busy timeout — a storage decision that belongs in the deployment, not in a database row.',
'Log level and format — read once at start-up.',
'CORS origins and trusted proxies — network policy that must match the actual ingress.',
].map((item) => (
<li key={item} className="flex gap-2">
<ShieldCheck className="text-ink-subtle mt-0.5 size-3.5 shrink-0" aria-hidden />
<span>{item}</span>
</li>
))}
</ul>
</CardBody>
</Card>
</div>
)
}
/** A read-only configuration value. */
function ReadOnlyRow({
label,
value,
badge,
}: {
label: string
value: string
badge?: string
}) {
return (
<div className="flex items-start justify-between gap-3 border-b pb-3 last:border-b-0 last:pb-0">
<span className="text-ink-muted text-xs">{label}</span>
<span className="flex items-center gap-2 text-right">
{badge ? <Badge tone="neutral">{badge}</Badge> : null}
<code className="text-ink font-mono text-[11px]">{value}</code>
</span>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,591 @@
/**
* The theme list.
*
* A searchable, filterable table with a visual swatch per theme, because the
* fastest way to recognise a theme is to see it rather than to read its name.
*/
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Copy,
Download,
Filter,
Palette,
Plus,
Search,
Trash2,
} from 'lucide-react'
import {
createTheme,
deleteTheme,
duplicateTheme,
exportTheme,
getRuntimeTokens,
listThemes,
} from '../lib/api'
import { cloneTokens } from '../lib/tokens'
import type { ThemeStatus, ThemeSummary, TokenDoc } from '../lib/types'
import { downloadText, extractTags, formatRelative } from '../lib/utils'
import {
Badge,
Button,
Card,
Dialog,
EmptyState,
ErrorState,
Field,
Input,
PageHeader,
Select,
Skeleton,
Tabs,
Textarea,
} from '../components/ui'
import { StatusBadge } from './dashboard'
/** Status filters shown as tabs. `all` is a local sentinel, not an API value. */
type StatusFilter = ThemeStatus | 'all'
export default function ThemeListRoute() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [status, setStatus] = useState<StatusFilter>('all')
const [createOpen, setCreateOpen] = useState(false)
const [duplicateTarget, setDuplicateTarget] = useState<ThemeSummary | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ThemeSummary | null>(null)
const themes = useQuery({
queryKey: ['themes', { search, status }],
queryFn: ({ signal }) =>
listThemes(
{ q: search || undefined, status: status === 'all' ? undefined : status, limit: 100 },
signal,
),
// Typing in the search box must not fire a request per keystroke.
placeholderData: (previous) => previous,
})
const removeTheme = useMutation({
mutationFn: deleteTheme,
onSuccess: (_result, slug) => {
toast.success('Theme deleted', { description: slug })
void queryClient.invalidateQueries({ queryKey: ['themes'] })
setDeleteTarget(null)
},
onError: (error) => {
toast.error('Could not delete the theme', {
description: error instanceof Error ? error.message : String(error),
})
},
})
async function handleExport(theme: ThemeSummary) {
try {
const document_ = await exportTheme(theme.slug)
downloadText(`${theme.slug}.theme.json`, JSON.stringify(document_, null, 2))
toast.success('Theme exported', { description: `${theme.slug}.theme.json` })
} catch (error) {
toast.error('Could not export the theme', {
description: error instanceof Error ? error.message : String(error),
})
}
}
return (
<div className="space-y-6">
<PageHeader
title="Themes"
description="Every theme is a versioned bundle of design tokens. Publishing one makes it available to every consuming application at runtime."
actions={
<Button variant="primary" icon={<Plus className="size-4" aria-hidden />} onClick={() => setCreateOpen(true)}>
New theme
</Button>
}
/>
<Card>
<div className="border-line flex flex-wrap items-center gap-3 border-b px-4 py-3">
<div className="relative min-w-[14rem] flex-1">
<Search
className="text-ink-subtle pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden
/>
<label htmlFor="theme-search" className="sr-only">
Search themes
</label>
<Input
id="theme-search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name, slug or description"
className="pl-9"
type="search"
/>
</div>
<Tabs
value={status}
onChange={(next) => setStatus(next as StatusFilter)}
className="border-b-0"
tabs={[
{ id: 'all', label: 'All' },
{ id: 'published', label: 'Published' },
{ id: 'draft', label: 'Draft' },
{ id: 'archived', label: 'Archived' },
]}
/>
</div>
{themes.error ? (
<div className="p-4">
<ErrorState
title="Could not load themes"
message={themes.error instanceof Error ? themes.error.message : String(themes.error)}
onRetry={() => void themes.refetch()}
/>
</div>
) : themes.isLoading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }, (_, index) => (
<Skeleton key={index} className="h-14" />
))}
</div>
) : themes.data && themes.data.items.length > 0 ? (
<ul className="divide-line divide-y">
{themes.data.items.map((theme) => (
<li
key={theme.slug}
className="hover:bg-raised/40 flex flex-wrap items-center gap-3 px-4 py-3 transition-colors sm:flex-nowrap"
>
<ThemeSwatch theme={theme} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link
to={`/themes/${theme.slug}`}
className="text-ink hover:text-brand truncate text-sm font-medium"
>
{theme.name}
</Link>
<StatusBadge status={theme.status} />
{theme.system ? <Badge tone="info">built-in</Badge> : null}
</div>
<div className="text-ink-subtle mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span className="font-mono">{theme.slug}</span>
<span aria-hidden>·</span>
<span>v{theme.version}</span>
<span aria-hidden>·</span>
<span>updated {formatRelative(theme.updatedAt)}</span>
{extractTags(theme.meta).slice(0, 3).map((tag) => (
<Badge key={tag} tone="neutral" className="text-[10px]">
{tag}
</Badge>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
title="Duplicate"
aria-label={`Duplicate ${theme.name}`}
onClick={() => setDuplicateTarget(theme)}
>
<Copy className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Export"
aria-label={`Export ${theme.name}`}
onClick={() => void handleExport(theme)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={theme.system ? 'Built-in themes cannot be deleted' : 'Delete'}
aria-label={`Delete ${theme.name}`}
disabled={theme.system}
onClick={() => setDeleteTarget(theme)}
>
<Trash2 className="size-4" />
</Button>
</div>
</li>
))}
</ul>
) : (
<EmptyState
icon={search ? <Filter className="size-8" aria-hidden /> : <Palette className="size-8" aria-hidden />}
title={search ? 'No themes match that search' : 'No themes yet'}
description={
search
? 'Try a different term, or clear the status filter.'
: 'Create your first theme, or start from one of the built-ins by duplicating it.'
}
action={
search ? (
<Button
variant="secondary"
onClick={() => {
setSearch('')
setStatus('all')
}}
>
Clear filters
</Button>
) : (
<Button variant="primary" onClick={() => setCreateOpen(true)}>
New theme
</Button>
)
}
/>
)}
</Card>
<CreateThemeDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
existing={themes.data?.items ?? []}
/>
<DuplicateDialog
theme={duplicateTarget}
existing={themes.data?.items ?? []}
onClose={() => setDuplicateTarget(null)}
/>
<Dialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title="Delete this theme?"
description={`"${deleteTarget?.name}" and its entire history will be removed. Applications pinned to this slug will stop resolving it.`}
footer={
<>
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="danger"
loading={removeTheme.isPending}
onClick={() => deleteTarget && removeTheme.mutate(deleteTarget.slug)}
>
Delete permanently
</Button>
</>
}
/>
</div>
)
}
/**
* A colour strip standing in for the theme.
*
* The colours come from the runtime's own resolved tokens rather than from a
* thumbnail stored at edit time. That means the strip is always exactly what
* consumers will see, including after a rollback or a refresh that the cached
* screenshot would not have caught.
*/
function ThemeSwatch({ theme }: { theme: ThemeSummary }) {
const steps = usePalette(theme)
if (!steps) {
return (
<div
className="border-line bg-raised text-ink-subtle grid size-11 shrink-0 place-items-center rounded-control border text-[10px] font-medium"
aria-hidden
>
{theme.defaultAppearance === 'dark' ? 'dark' : 'light'}
</div>
)
}
return (
<div
className="border-line size-11 shrink-0 overflow-hidden rounded-control border"
aria-hidden
title={`${theme.name} colour strip`}
>
<div className="swatch-strip h-full flex-col">
{steps.map((colour, index) => (
<span key={index} className="flex-1" style={{ background: colour }} />
))}
</div>
</div>
)
}
/**
* Fetches the palette steps a swatch renders.
*
* Returns null for anything that is not published, or while loading, so the caller
* can show a neutral placeholder rather than an empty frame.
*/
function usePalette(theme: ThemeSummary): string[] | null {
const { data } = useQuery({
queryKey: ['palette', theme.slug, theme.version],
queryFn: ({ signal }) => getRuntimeTokens(theme.slug, signal),
enabled: theme.status === 'published',
// A palette changes only when the theme does, so it is safe to keep for a
// long time; the version in the key is what invalidates it.
staleTime: 10 * 60_000,
})
if (theme.status !== 'published' || !data) return null
const variables = data.variables ?? {}
const steps = ['300', '400', '500', '600', '700']
.map((step) => variables[`--bn-color-brand-${step}`])
.filter((value): value is string => typeof value === 'string' && value.length > 0)
if (steps.length === 0) {
// A theme with no brand ramp still deserves a strip: the semantic roles are
// what most consumers actually use.
const semantic = ['background', 'surface-raised', 'primary', 'primary-hover', 'text']
.map((role) => variables[`--bn-color-semantic-${role}`])
.filter((value): value is string => typeof value === 'string' && value.length > 0)
return semantic.length > 0 ? semantic : null
}
return steps
}
/** Dialog for creating a theme. */
function CreateThemeDialog({
open,
onClose,
existing,
}: {
open: boolean
onClose: () => void
existing: ThemeSummary[]
}) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [slugTouched, setSlugTouched] = useState(false)
const [description, setDescription] = useState('')
const [source, setSource] = useState('')
const takenSlugs = new Set(existing.map((theme) => theme.slug))
const createThemeMutation = useMutation({
mutationFn: createTheme,
onSuccess: (theme) => {
toast.success('Theme created', { description: `${theme.name} (${theme.slug})` })
void queryClient.invalidateQueries({ queryKey: ['themes'] })
reset()
onClose()
},
onError: (error) => {
toast.error('Could not create the theme', {
description: error instanceof Error ? error.message : String(error),
})
},
})
function reset() {
setName('')
setSlug('')
setSlugTouched(false)
setDescription('')
setSource('')
}
// Deriving the slug from the name is a convenience, so it stops as soon as the
// field is edited by hand: silently rewriting an operator's chosen slug is
// worse than not suggesting one at all.
const derivedSlug = slugTouched
? slug
: name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
const slugError = derivedSlug
? takenSlugs.has(derivedSlug)
? 'That slug is already taken'
: !/^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/.test(derivedSlug)
? 'Use lowercase letters, digits and hyphens'
: undefined
: 'A slug is required'
const canSubmit = name.trim().length > 0 && !slugError
function submit() {
if (!canSubmit) return
createThemeMutation.mutate({
name: name.trim(),
slug: derivedSlug,
description: description.trim() || undefined,
status: 'draft',
tokens: starterTokens(source),
})
}
return (
<Dialog
open={open}
onClose={onClose}
title="New theme"
description="Themes start as drafts. Nothing is served to applications until you publish."
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" disabled={!canSubmit} loading={createThemeMutation.isPending} onClick={submit}>
Create draft
</Button>
</>
}
>
<div className="space-y-4">
<Field label="Name" htmlFor="new-theme-name">
<Input
id="new-theme-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Acme Product"
autoFocus
/>
</Field>
<Field label="Slug" htmlFor="new-theme-slug" hint="Used in every runtime URL. Cannot be changed after consumers depend on it." error={slugError || undefined}>
<Input
id="new-theme-slug"
value={derivedSlug}
onChange={(event) => {
setSlugTouched(true)
setSlug(event.target.value)
}}
placeholder="acme-product"
className="font-mono"
aria-invalid={Boolean(slugError)}
/>
</Field>
<Field label="Description" htmlFor="new-theme-description" hint="Optional. Shown in the theme list and the runtime manifest.">
<Input
id="new-theme-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="The default theme for Acme's customer-facing applications"
/>
</Field>
<Field
label="Start from"
htmlFor="new-theme-source"
hint="Paste an exported theme document to seed this one, or leave empty for a minimal draft."
>
<Textarea
id="new-theme-source"
value={source}
onChange={(event) => setSource(event.target.value)}
rows={4}
placeholder='{ "tokens": { … } }'
/>
</Field>
</div>
</Dialog>
)
}
/** Dialog for duplicating a theme. */
function DuplicateDialog({
theme,
existing,
onClose,
}: {
theme: ThemeSummary | null
existing: ThemeSummary[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const [slug, setSlug] = useState('')
const duplicate = useMutation({
mutationFn: ({ from, to }: { from: string; to: string }) => duplicateTheme(from, to),
onSuccess: (created) => {
toast.success('Theme duplicated', { description: `${created.name} (${created.slug})` })
void queryClient.invalidateQueries({ queryKey: ['themes'] })
setSlug('')
onClose()
},
onError: (error) => {
toast.error('Could not duplicate the theme', {
description: error instanceof Error ? error.message : String(error),
})
},
})
const taken = new Set(existing.map((entry) => entry.slug))
const error = slug && taken.has(slug) ? 'That slug is already taken' : undefined
return (
<Dialog
open={theme !== null}
onClose={onClose}
title={`Duplicate "${theme?.name ?? ''}"`}
description="The copy starts as a draft with its own history, so you can change it freely."
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
disabled={!slug || Boolean(error)}
loading={duplicate.isPending}
onClick={() => theme && duplicate.mutate({ from: theme.slug, to: slug })}
>
Duplicate
</Button>
</>
}
>
<Field label="New slug" htmlFor="duplicate-slug" error={error}>
<Input
id="duplicate-slug"
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder={`${theme?.slug ?? 'theme'}-v2`}
className="font-mono"
autoFocus
/>
</Field>
</Dialog>
)
}
/**
* Builds the token document a new theme starts from.
*
* An empty document is a valid draft: the editor can fill it in and validation
* only becomes a gate at publication time, which is what makes building a theme
* up incrementally practical.
*/
function starterTokens(source: string): TokenDoc {
if (source.trim()) {
try {
const parsed = JSON.parse(source) as { tokens?: TokenDoc }
if (parsed.tokens) return parsed.tokens
} catch {
// A malformed paste should not block creation; the user sees the editor with
// an empty document and can paste again.
}
}
return cloneTokens({})
}
export { Select }

338
web/src/routes/versions.tsx Normal file
View file

@ -0,0 +1,338 @@
/**
* Version history, diffing and rollback.
*
* The point of this page is confidence: an operator needs to see exactly what
* changed between two revisions and be able to go back to any of them. Rollback
* appends rather than rewrites, so nothing already deployed can be un-deployed.
*/
import { useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { ArrowRight, History } from 'lucide-react'
import { diffVersions, getTheme, listVersions, rollbackTheme } from '../lib/api'
import { cn, formatDate } from '../lib/utils'
import type { ThemeVersionSummary } from '../lib/types'
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Dialog,
EmptyState,
ErrorState,
PageHeader,
Select,
Skeleton,
} from '../components/ui'
import { StatusBadge } from './dashboard'
export default function VersionsRoute() {
const { slug = '' } = useParams()
const queryClient = useQueryClient()
const theme = useQuery({ queryKey: ['theme', slug], queryFn: ({ signal }) => getTheme(slug, signal) })
const versions = useQuery({
queryKey: ['versions', slug],
queryFn: ({ signal }) => listVersions(slug, 100, signal),
})
const [fromVersion, setFromVersion] = useState<number | null>(null)
const [toVersion, setToVersion] = useState<number | null>(null)
const [rollbackTarget, setRollbackTarget] = useState<ThemeVersionSummary | null>(null)
// Default the diff to the two most recent revisions, which is what an operator
// almost always wants to see first.
const effectiveFrom = fromVersion ?? versions.data?.items[1]?.version ?? null
const effectiveTo = toVersion ?? versions.data?.items[0]?.version ?? null
const rollback = useMutation({
mutationFn: (version: number) => rollbackTheme(slug, version),
onSuccess: (updated) => {
toast.success('Rolled back', {
description: `${updated.name} is now at version ${updated.version}, restored from ${rollbackTarget?.version}`,
})
setRollbackTarget(null)
void queryClient.invalidateQueries({ queryKey: ['theme', slug] })
void queryClient.invalidateQueries({ queryKey: ['versions', slug] })
},
onError: (error) => {
toast.error('Could not roll back', {
description: error instanceof Error ? error.message : String(error),
})
},
})
return (
<div className="space-y-6">
<PageHeader
title={
<>
History &middot;{' '}
<Link to={`/themes/${slug}`} className="hover:text-brand">
{theme.data?.name ?? slug}
</Link>
</>
}
description="Every save appends an immutable snapshot. Rolling back creates a new version rather than deleting anything, so a deployed application never loses the revision it was built against."
actions={
<Button variant="secondary" onClick={() => void versions.refetch()}>
Refresh
</Button>
}
/>
<div className="grid gap-4 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)]">
<Card className="overflow-hidden">
<CardHeader title="Revisions" />
{versions.error ? (
<div className="p-4">
<ErrorState
title="Could not load the history"
message={versions.error instanceof Error ? versions.error.message : String(versions.error)}
onRetry={() => void versions.refetch()}
/>
</div>
) : versions.isLoading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 6 }, (_, index) => (
<Skeleton key={index} className="h-14" />
))}
</div>
) : versions.data && versions.data.items.length > 0 ? (
<ol className="divide-line divide-y">
{versions.data.items.map((version, index) => {
const isCurrent = index === 0
return (
<li
key={version.version}
className={cn(
'px-4 py-3 transition-colors',
effectiveTo === version.version && 'bg-brand-subtle',
)}
>
<div className="flex items-center gap-2">
<span className="text-ink tabular text-xs font-semibold">
v{version.version}
</span>
{isCurrent ? <Badge tone="good">current</Badge> : null}
<StatusBadge status={version.status} />
</div>
<p className="text-ink-muted mt-1 text-[11px] leading-relaxed">
{version.message || 'No description'}
</p>
<p className="text-ink-subtle mt-1 text-[10px]">
{formatDate(version.createdAt)}
{version.author ? ` · ${version.author}` : ''}
</p>
<div className="mt-2 flex items-center gap-1.5">
<Button
size="sm"
variant="secondary"
onClick={() => setToVersion(version.version)}
>
Compare to
</Button>
{!isCurrent ? (
<Button
size="sm"
variant="ghost"
onClick={() => setRollbackTarget(version)}
>
Restore
</Button>
) : null}
</div>
</li>
)
})}
</ol>
) : (
<EmptyState
icon={<History className="size-7" aria-hidden />}
title="No revisions yet"
description="A revision is recorded on every save."
/>
)}
</Card>
<div className="space-y-4">
<Card>
<CardHeader
title="Compare"
description="Token-level differences between two revisions."
/>
<CardBody className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label htmlFor="diff-from" className="text-ink-muted mb-1 block text-xs font-medium">
From
</label>
<Select
id="diff-from"
value={effectiveFrom ?? ''}
onChange={(event) => setFromVersion(Number(event.target.value))}
className="w-32"
>
{(versions.data?.items ?? []).map((version) => (
<option key={version.version} value={version.version}>
v{version.version}
</option>
))}
</Select>
</div>
<ArrowRight className="text-ink-subtle mb-2.5 size-4" aria-hidden />
<div>
<label htmlFor="diff-to" className="text-ink-muted mb-1 block text-xs font-medium">
To
</label>
<Select
id="diff-to"
value={effectiveTo ?? ''}
onChange={(event) => setToVersion(Number(event.target.value))}
className="w-32"
>
{(versions.data?.items ?? []).map((version) => (
<option key={version.version} value={version.version}>
v{version.version}
</option>
))}
</Select>
</div>
</div>
{effectiveFrom !== null && effectiveTo !== null && effectiveFrom !== effectiveTo ? (
<DiffView slug={slug} from={effectiveFrom} to={effectiveTo} />
) : (
<p className="text-ink-subtle py-6 text-center text-xs">
Choose two different revisions to compare.
</p>
)}
</CardBody>
</Card>
</div>
</div>
<Dialog
open={rollbackTarget !== null}
onClose={() => setRollbackTarget(null)}
title={`Restore version ${rollbackTarget?.version ?? ''}?`}
description="The token document from that revision becomes the current one, recorded as a new version. Nothing is deleted."
footer={
<>
<Button variant="ghost" onClick={() => setRollbackTarget(null)}>
Cancel
</Button>
<Button
variant="primary"
loading={rollback.isPending}
onClick={() => rollbackTarget && rollback.mutate(rollbackTarget.version)}
>
Restore this version
</Button>
</>
}
/>
</div>
)
}
/** Renders the token-level diff between two revisions. */
function DiffView({ slug, from, to }: { slug: string; from: number; to: number }) {
const diff = useQuery({
queryKey: ['diff', slug, from, to],
queryFn: ({ signal }) => diffVersions(slug, from, to, signal),
})
const summary = useMemo(() => {
if (!diff.data) return null
const { changed, added, removed } = diff.data
return [
{ label: 'changed', count: changed.length, tone: 'text-warn' },
{ label: 'added', count: added.length, tone: 'text-good' },
{ label: 'removed', count: removed.length, tone: 'text-bad' },
]
}, [diff.data])
if (diff.isLoading) return <Skeleton className="h-48" />
if (diff.error) {
return (
<ErrorState
title="Could not compare the revisions"
message={diff.error instanceof Error ? diff.error.message : String(diff.error)}
onRetry={() => void diff.refetch()}
/>
)
}
if (!diff.data) return null
if (summary?.every((entry) => entry.count === 0)) {
return (
<EmptyState
title="No differences"
description={`Version ${from} and version ${to} have identical token documents.`}
/>
)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3 text-xs">
{summary?.map((entry) => (
<span key={entry.label} className={entry.tone}>
<span className="tabular font-semibold">{entry.count}</span> {entry.label}
</span>
))}
</div>
{diff.data.changed.length > 0 ? (
<div>
<p className="text-ink-muted mb-2 text-xs font-medium">Changed</p>
<ul className="border-line divide-line divide-y rounded-card border">
{diff.data.changed.map((change) => (
<li key={change.path} className="px-3 py-2">
<p className="text-ink-muted font-mono text-[11px]">{change.path}</p>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px]">
<code className="bg-bad/12 text-bad rounded px-1.5 py-0.5">{change.from}</code>
<ArrowRight className="text-ink-subtle size-3" aria-hidden />
<code className="bg-good/12 text-good rounded px-1.5 py-0.5">{change.to}</code>
</div>
</li>
))}
</ul>
</div>
) : null}
{diff.data.added.length > 0 ? (
<div>
<p className="text-ink-muted mb-2 text-xs font-medium">Added</p>
<ul className="text-good space-y-1 font-mono text-[11px]">
{diff.data.added.slice(0, 40).map((path) => (
<li key={path}>+ {path}</li>
))}
{diff.data.added.length > 40 ? (
<li className="text-ink-subtle">… and {diff.data.added.length - 40} more</li>
) : null}
</ul>
</div>
) : null}
{diff.data.removed.length > 0 ? (
<div>
<p className="text-ink-muted mb-2 text-xs font-medium">Removed</p>
<ul className="text-bad space-y-1 font-mono text-[11px]">
{diff.data.removed.slice(0, 40).map((path) => (
<li key={path}>− {path}</li>
))}
{diff.data.removed.length > 40 ? (
<li className="text-ink-subtle">… and {diff.data.removed.length - 40} more</li>
) : null}
</ul>
</div>
) : null}
</div>
)
}

46
web/src/test/setup.ts Normal file
View file

@ -0,0 +1,46 @@
/**
* Vitest setup.
*
* jest-dom's matchers are registered globally because asserting on accessible
* roles and text is the right way to test a UI, and doing it consistently means a
* test failure points at a real defect rather than at a different query style.
*/
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
// jsdom implements neither of these, and both are used by the layout and the
// token preview. Stubbing them here is better than guarding every call site.
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}),
})
}
if (!window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn()
}

40
web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,40 @@
/// <reference types="vite/client" />
/**
* Side-effect CSS imports.
*
* TypeScript 5.6 and later resolve a bare `import './x.css'` against the ambient
* module declarations rather than the file system, so the declaration has to be
* written out. Without it, importing the stylesheet is a compile error even though
* the bundler handles it correctly.
*/
declare module '*.css' {
const content: string
export default content
}
/**
* Bananas runtime controls - loaded dynamically from /rt/v1/components.js
*/
export type AppearanceMode = 'admin' | 'light' | 'dark'
export interface ContractState {
state: {
theme: string | null
mode: AppearanceMode
system: boolean
source: string | null
}
storageKey: string
set(patch: Partial<ContractState['state']>, source?: string | null): ContractState['state']
subscribe(fn: (state: ContractState['state']) => void): () => void
selectTheme(slug: string | null, defaultMode?: string | null): ContractState['state']
selectMode(mode: AppearanceMode): ContractState['state']
followSystem(): ContractState['state']
}
export interface BananasElement extends HTMLElement {
value: string
}
export function getContractState(options?: { storageKey?: string }): ContractState