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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
129
web/eslint.config.js
Normal file
129
web/eslint.config.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import js from '@eslint/js'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
/**
|
||||
* ESLint flat configuration.
|
||||
*
|
||||
* Two decisions are worth stating:
|
||||
*
|
||||
* 1. Type-aware linting is on (`projectService`), because the rules that catch
|
||||
* real bugs in a codebase using `unknown` at its boundaries need type
|
||||
* information. This is also why TypeScript is pinned to 6.x: see ADR 0004.
|
||||
*
|
||||
* 2. `--max-warnings 0` in the npm script. A warning that is tolerated is a
|
||||
* warning nobody reads, so the build is the place it stops.
|
||||
*/
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist', 'coverage', 'node_modules', '*.config.js', '*.config.ts'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
globals: { ...globals.browser, ...globals.es2022 },
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
|
||||
// --- correctness -----------------------------------------------------
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': [
|
||||
'error',
|
||||
// A promise-returning handler is the normal React pattern; the void
|
||||
// operator is the fix for the cases that are actually a mistake.
|
||||
{ checksVoidReturn: { arguments: false, attributes: false } },
|
||||
],
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||
'@typescript-eslint/restrict-template-expressions': [
|
||||
'error',
|
||||
{ allowNumber: true, allowBoolean: true },
|
||||
],
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
||||
// `||` is used deliberately where an empty string should fall through,
|
||||
// which is exactly what `??` does not do.
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
||||
// A type alias reads better than an interface for everything in this
|
||||
// codebase, and the two are interchangeable in practice.
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
|
||||
// --- readability -----------------------------------------------------
|
||||
// Both return-type annotation rules are off by design.
|
||||
//
|
||||
// `tsc` with `strict` already enforces a type at every boundary: a
|
||||
// component with a wrongly-typed prop is a compile error whether or not
|
||||
// its return type is written out. What the ESLint rules add is annotation
|
||||
// churn — one per callback, and `(): JSX.Element =>` on components that
|
||||
// legitimately start returning null and then need the annotation widened.
|
||||
// That is a maintenance cost with no defect class behind it.
|
||||
//
|
||||
// What *is* enforced instead: every component declares a typed props
|
||||
// interface, every exported non-component function carries a JSDoc
|
||||
// `@returns`, and `strict` plus `noUncheckedIndexedAccess` run on every
|
||||
// build. See docs/standards.md.
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
// An empty function is a legitimate no-op: a ResizeObserver stub, an
|
||||
// intentionally ignored error, a deliberate empty effect body.
|
||||
'@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions', 'methods'] }],
|
||||
// A leading or trailing underscore is the convention for an intentionally
|
||||
// unused binding, and no-unused-vars above is configured to honour it. The
|
||||
// two rules have to agree or every intentionally-ignored value is a
|
||||
// naming error.
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['camelCase', 'UPPER_CASE', 'PascalCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{ selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow' },
|
||||
{ selector: 'typeLike', format: ['PascalCase'] },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
|
||||
],
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
'prefer-const': 'error',
|
||||
'object-shorthand': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Tests are allowed to reach into internals and to use `any` in a double.
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.test.tsx', 'src/test/**'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
29
web/index.html
Normal file
29
web/index.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Administration interface for Go Bananas!!, the runtime design-token service."
|
||||
/>
|
||||
<title>Go Bananas!!</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>
|
||||
<div style="padding: 2rem; font-family: system-ui, sans-serif">
|
||||
<h1>JavaScript is required</h1>
|
||||
<p>
|
||||
The Go Bananas!! administration interface is a single-page application. The theme
|
||||
runtime itself needs no JavaScript — a stylesheet link is enough to consume a
|
||||
published theme from any application.
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4692
web/package-lock.json
generated
Normal file
4692
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
59
web/package.json
Normal file
59
web/package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "@go-bananas/admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Administration UI for go-bananas, the runtime design-token service.",
|
||||
"license": "SEE LICENSE IN ../LICENSE",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --build && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"typecheck": "tsc --build --force",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.104.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "1.52.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-router-dom": "7.18.4",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.7",
|
||||
"@types/node": "26.6.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"globals": "17.13.0",
|
||||
"jsdom": "30.1.2",
|
||||
"prettier": "3.9.9",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.0",
|
||||
"vite": "8.3.2",
|
||||
"vitest": "5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
5
web/public/favicon.svg
Normal file
5
web/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<rect width="24" height="24" rx="5" fill="#8b7cf6"/>
|
||||
<path d="M4 9c0-1.1.9-2 2-2h2.6c.6 0 1.1.2 1.5.6l6.2 5.5c.2.2.3.5.3.8V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9Z" fill="#0f0d1f" opacity=".9"/>
|
||||
<path d="M20 9c0-1.1-.9-2-2-2h-.9c-.6 0-1.1.2-1.5.6L9.4 13c-.2.2-.3.5-.3.8V19a2 2 0 0 0 2 2H18a2 2 0 0 0 2-2V9Z" fill="#0f0d1f" opacity=".55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
78
web/src/App.tsx
Normal file
78
web/src/App.tsx
Normal 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} />
|
||||
}
|
||||
34
web/src/components/admin-theme-context.ts
Normal file
34
web/src/components/admin-theme-context.ts
Normal 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)
|
||||
188
web/src/components/admin-theme.test.tsx
Normal file
188
web/src/components/admin-theme.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
264
web/src/components/admin-theme.tsx
Normal file
264
web/src/components/admin-theme.tsx
Normal 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 */
|
||||
}
|
||||
}
|
||||
39
web/src/components/providers.tsx
Normal file
39
web/src/components/providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
web/src/components/query-client.ts
Normal file
39
web/src/components/query-client.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
526
web/src/components/shell.tsx
Normal file
526
web/src/components/shell.tsx
Normal 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
|
||||
}
|
||||
401
web/src/components/theme/preview.tsx
Normal file
401
web/src/components/theme/preview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
web/src/components/theme/token-control.test.tsx
Normal file
193
web/src/components/theme/token-control.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
343
web/src/components/theme/token-control.tsx
Normal file
343
web/src/components/theme/token-control.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
598
web/src/components/ui/index.tsx
Normal file
598
web/src/components/ui/index.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
web/src/components/use-admin-theme.ts
Normal file
19
web/src/components/use-admin-theme.ts
Normal 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
21
web/src/global.d.ts
vendored
Normal 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
211
web/src/index.css
Normal 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
153
web/src/lib/api.test.ts
Normal 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
538
web/src/lib/api.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
60
web/src/lib/runtime-controls.ts
Normal file
60
web/src/lib/runtime-controls.ts
Normal 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
252
web/src/lib/tokens.test.ts
Normal 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
305
web/src/lib/tokens.ts
Normal 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
315
web/src/lib/types.ts
Normal 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
169
web/src/lib/utils.ts
Normal 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
26
web/src/main.tsx
Normal 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
225
web/src/routes/audit.tsx
Normal 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)} · {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'
|
||||
}
|
||||
|
||||
298
web/src/routes/dashboard.tsx
Normal file
298
web/src/routes/dashboard.tsx
Normal 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'
|
||||
}
|
||||
264
web/src/routes/integrate.tsx
Normal file
264
web/src/routes/integrate.tsx
Normal 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
102
web/src/routes/login.tsx
Normal 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
210
web/src/routes/settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
1029
web/src/routes/theme-detail.tsx
Normal file
1029
web/src/routes/theme-detail.tsx
Normal file
File diff suppressed because it is too large
Load diff
591
web/src/routes/theme-list.tsx
Normal file
591
web/src/routes/theme-list.tsx
Normal 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
338
web/src/routes/versions.tsx
Normal 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 ·{' '}
|
||||
<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
46
web/src/test/setup.ts
Normal 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
40
web/src/vite-env.d.ts
vendored
Normal 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
|
||||
31
web/tsconfig.app.json
Normal file
31
web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"allowImportingTsExtensions": false,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
web/tsconfig.node.json
Normal file
24
web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
52
web/vite.config.ts
Normal file
52
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
/**
|
||||
* The admin UI is served by the Go binary from web/dist, and in development by
|
||||
* Vite with a proxy to the API. The proxy matters because the admin UI must be
|
||||
* same-origin in production: the session cookie is HttpOnly and SameSite=Lax, so
|
||||
* a cross-origin development setup would not authenticate at all and the problem
|
||||
* would look like a bug rather than a topology difference.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
// Split the vendor code out so that an application-only deploy does not
|
||||
// invalidate the framework chunk in every user's cache. Rolldown takes a
|
||||
// function rather than the object form esbuild used.
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
if (/[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id)) {
|
||||
return 'react'
|
||||
}
|
||||
if (id.includes('@tanstack')) return 'query'
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8081', changeOrigin: true },
|
||||
'/rt': { target: 'http://localhost:8081', changeOrigin: true },
|
||||
'/auth': { target: 'http://localhost:8081', changeOrigin: true },
|
||||
'/healthz': { target: 'http://localhost:8081', changeOrigin: true },
|
||||
'/readyz': { target: 'http://localhost:8081', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
20
web/vitest.config.ts
Normal file
20
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
/**
|
||||
* Test configuration, kept separate from vite.config.ts so that the test options
|
||||
* are not part of the production build configuration. The type definitions of the
|
||||
* two tools overlap but are not identical, and mixing them produces confusing
|
||||
* errors about the `test` key.
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// The admin interface is a browser application: components touch the DOM,
|
||||
// localStorage and matchMedia, so jsdom is required rather than merely useful.
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
// Tests live next to what they test.
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue