refactor
Some checks are pending
Build, test, publish, deploy / build-test (push) Waiting to run
Build, test, publish, deploy / publish (push) Blocked by required conditions
Build, test, publish, deploy / deploy-prd (push) Blocked by required conditions

This commit is contained in:
Daniel Noulet 2026-10-10 02:30:01 +02:00
parent d0ea6e9eec
commit 2967f1a5af
31 changed files with 469 additions and 165 deletions

View file

@ -28,7 +28,11 @@ export default tseslint.config(
{
languageOptions: {
parserOptions: {
projectService: true,
// The app build project excludes test files (so the production image build
// never compiles the SDK-coupled integration test). ESLint still needs type
// info for every file it lints, so point it at BOTH projects explicitly
// rather than relying on projectService to discover the test project.
project: ['./tsconfig.app.json', './tsconfig.test.json'],
tsconfigRootDir: import.meta.dirname,
},
globals: { ...globals.browser, ...globals.es2022 },

View file

@ -7,11 +7,11 @@
"license": "SEE LICENSE IN ../LICENSE",
"scripts": {
"dev": "vite",
"build": "tsc --build && vite build",
"build": "tsc -p tsconfig.app.json && vite build",
"preview": "vite preview",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --build --force",
"typecheck": "tsc -p tsconfig.app.json --noEmit && tsc -p tsconfig.test.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
@ -56,4 +56,4 @@
"engines": {
"node": ">=22"
}
}
}

View file

@ -16,6 +16,11 @@ export type AdminThemeValue = {
previewSlug: string | null
setMode: (mode: AdminThemeMode) => void
setPreviewSlug: (slug: string | null) => void
/**
* Sets mode and preview slug atomically. Used by the shipped controls so that
* picking a theme does not race with picking its default appearance.
*/
choose: (choice: { mode: AdminThemeMode; slug: string | null }) => void
/** The slugs available to preview, from the runtime manifest. */
available: string[]
/** The runtime base URL, for building preview URLs. */

View file

@ -21,11 +21,27 @@ vi.mock('../lib/api', () => ({
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
}))
// Mock the control loader so the origin it is asked to load from can be asserted
// directly, rather than depending on the loader's module-level singleton promise.
vi.mock('../lib/runtime-controls', () => ({
loadRuntimeControls: vi.fn(() => new Promise<void>(() => undefined)),
}))
const api = await import('../lib/api')
const runtimeControls = await import('../lib/runtime-controls')
const THEME_ATTRIBUTE = 'data-bananas-theme'
const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
const FULL_ENTRY = {
slug: 'midnight',
name: 'Midnight',
defaultAppearance: 'dark',
version: 1,
css: '/rt/v1/theme.css?slug=midnight',
tokens: '/rt/v1/theme.json?slug=midnight',
} as const
/** A probe that renders the current choice and exposes the setters. */
function Probe() {
const { mode, previewSlug, available, setMode, setPreviewSlug } = useAdminTheme()
@ -110,20 +126,26 @@ describe('with a stored preference', () => {
// 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'),
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 () => {
// While the shipped controls are not ready — in this file loadRuntimeControls is
// mocked and never resolves — the provider is the only writer, and the preview
// stylesheet is scoped to exactly these attributes. If the provider did not apply
// them, the fallback switcher would change state but repaint nothing, which is the
// "I can't select a theme / light-dark does nothing" bug. (The single-writer handoff,
// where the controls take over once loaded, is covered by the real-controls
// integration test.)
it('applies the document attributes itself while the controls are not ready', 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)
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight')
expect(document.documentElement.getAttribute(APPEARANCE_ATTRIBUTE)).toBe('dark')
})
it('writes the choice back to the API when the mode changes', async () => {
@ -186,3 +208,41 @@ describe('when persisting fails', () => {
expect(screen.getByTestId('mode')).toHaveTextContent('theme-dark')
})
})
// TestTheAdminConsumesItsRuntimeSameOrigin: a manifest (or config) that advertises a
// DIFFERENT absolute origin than the page itself — a CDN in front, a public host that
// is not the one you opened, `localhost` vs `127.0.0.1` — must not make the admin
// fetch its own controls and preview stylesheet from that other origin. The page's CSP
// is `script-src 'self'`, so a cross-origin `<script type=module>` and the module's
// dynamic `import()` of its chunk are blocked, `controlsReady` never becomes true, and
// the switcher silently falls back with no theme selectable. The admin must therefore
// always load `/rt/v1` from its own origin.
describe('the runtime is consumed same-origin', () => {
it('ignores the absolute public base when loading controls and the preview', async () => {
vi.mocked(api.getManifest).mockResolvedValue({
apiVersion: '1',
// An origin that is deliberately not the page's own.
baseUrl: 'https://themes.example.com/rt/v1',
themes: [FULL_ENTRY],
})
vi.mocked(api.getSession).mockResolvedValue({
authenticated: true,
mode: 'dev',
preferences: { selectedTheme: 'midnight', selectedAppearance: 'dark' },
} as unknown as Awaited<ReturnType<typeof api.getSession>>)
renderProvider()
// The shipped controls are requested from the admin's own origin, not the CDN.
await waitFor(() => expect(runtimeControls.loadRuntimeControls).toHaveBeenCalledWith('/rt/v1'))
// The previewed palette is loaded from the admin's own origin too.
await waitFor(() =>
expect(
document
.querySelector('link[data-bananas-admin="bananas-admin-preview"]')
?.getAttribute('href'),
).toBe('/rt/v1/theme.css?slug=midnight&variant=full'),
)
})
})

View file

@ -29,7 +29,6 @@ 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'
@ -103,7 +102,30 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
const available = useMemo(() => manifest?.themes.map((theme) => theme.slug) ?? [], [manifest])
const runtimeBase = manifest?.baseUrl ?? '/rt/v1'
// The default appearance of each published theme, so that picking a theme in the
// fallback switcher previews it in the scheme its author chose — exactly what the
// shipped controls do. Without this, choosing a theme while in the admin chrome would
// set the slug but leave `mode: 'admin'`, so nothing repaints and the dropdown looks
// dead: the "I select a theme and nothing happens" symptom.
const defaultAppearanceOf = useMemo(() => {
const map = new Map<string, 'light' | 'dark'>()
for (const theme of manifest?.themes ?? []) {
map.set(theme.slug, theme.defaultAppearance === 'light' ? 'light' : 'dark')
}
return map
}, [manifest])
// The admin loads the shipped controls and the preview stylesheet from THIS
// origin, not from manifest.baseUrl / config.runtimeBase. Those are the absolute
// *public* origin (or the CDN) meant for consumers pasting a snippet; using them
// here would make the module script and its contract.json fetch cross-origin
// whenever the browser's origin differs from the configured public base — a
// localhost/127.0.0.1 mismatch, a Vite dev proxy, or a CDN in front — and the
// page's CSP (script-src 'self') would then block the controls from ever
// defining, which surfaces as a switcher that cannot pick a theme. apiBase()
// already derives the same-origin path from the optional base-path marker; do the
// same for /rt/v1 so a sub-path deployment resolves correctly too.
const runtimeBase = sameOriginRuntimeBase()
// 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.
@ -140,26 +162,25 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
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)
}
// 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.mode === 'admin') {
void resetPreferences().catch(() => undefined)
} else if (next.slug) {
persist(next.slug, next.mode === 'theme-dark' ? 'dark' : 'light')
}
},
[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],
@ -167,36 +188,48 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
const setPreviewSlug = useCallback(
(slug: string | null) => {
choose({ mode, slug })
// Choosing a concrete theme previews it; choosing none returns to the chrome.
// Used by the fallback switcher's dropdown, which has no separate appearance
// click, so it applies the theme's own default scheme the way the shipped
// controls do.
if (!slug) {
choose({ mode, slug: null })
return
}
const nextMode: AdminThemeMode =
defaultAppearanceOf.get(slug) === 'light' ? 'theme-light' : 'theme-dark'
choose({ mode: nextMode, slug })
},
[choose, mode],
[choose, mode, defaultAppearanceOf],
)
// 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.
// The document attributes are owned by the shipped controls once they are ready,
// so there is one writer and the two can never disagree. BUT until the module has
// upgraded them — and in the fallback case where it never does — nothing else writes
// them. Because the preview stylesheet is scoped to exactly these attributes, leaving
// them unset means picking a theme or toggling light/dark changes state but repaints
// nothing. So while the controls are not ready the provider must apply them itself;
// once they are, it steps back and lets the controls own the attributes.
useEffect(() => {
if (controlsReady) return
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')
}, [mode, previewSlug, controlsReady])
// The preview stylesheet is the one thing only this service knows it needs:
// the full variant of a published theme, scoped to the prefixed attributes the
// runtime emits.
useEffect(() => {
if (mode === 'admin') return
const slug = previewSlug ?? DEFAULT_PREVIEW
const id = 'bananas-admin-preview'
let link = document.querySelector<HTMLLinkElement>(`link[data-bananas-admin="${id}"]`)
if (!link) {
@ -217,21 +250,36 @@ export function AdminThemeProvider({ children }: { children: ReactNode }) {
}, [mode])
const value = useMemo<AdminThemeValue>(
() => ({ mode, previewSlug, setMode, setPreviewSlug, available, runtimeBase, controlsReady }),
[
() => ({
mode,
previewSlug,
setMode,
setPreviewSlug,
choose,
available,
runtimeBase,
controlsReady,
],
}),
[mode, previewSlug, setMode, setPreviewSlug, choose, available, runtimeBase, controlsReady],
)
return <AdminThemeContext.Provider value={value}>{children}</AdminThemeContext.Provider>
}
/**
* The same-origin runtime base the admin consumes for its own controls and preview.
*
* It mirrors `apiBase()` in `lib/api.ts`: both resolve against the page's own origin
* and honour the optional `data-bananas-base-path` marker, so a sub-path deployment
* prefixes `/rt/v1` the same way it prefixes `/api/v1`. Nothing here may point at the
* absolute public/CDN origin — that is cross-origin under CSP `script-src 'self'`.
*/
function sameOriginRuntimeBase(): string {
const configured = document.documentElement.getAttribute('data-bananas-base-path') ?? ''
const prefix = configured.replace(/\/+$/, '')
return `${prefix}/rt/v1`
}
/**
* Reads the persisted choice, tolerating storage being unavailable.
*

View file

@ -40,7 +40,11 @@ export function AppShell() {
const queryClient = useQueryClient()
const [navOpen, setNavOpen] = useState(false)
const { data: session, isLoading, error } = useQuery({
const {
data: session,
isLoading,
error,
} = useQuery({
queryKey: ['session'],
queryFn: ({ signal }) => getSession(signal),
staleTime: 60_000,
@ -117,7 +121,7 @@ export function AppShell() {
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-control px-2.5 py-2 text-sm transition-colors',
'rounded-control flex items-center gap-2.5 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',
@ -159,7 +163,7 @@ export function AppShell() {
<button
type="button"
aria-label="Close navigation"
className="bg-black/60 fixed inset-0 z-30 lg:hidden"
className="fixed inset-0 z-30 bg-black/60 lg:hidden"
onClick={() => setNavOpen(false)}
/>
) : null}
@ -223,15 +227,19 @@ export function AppShell() {
/** 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 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>
)
}
@ -280,7 +288,7 @@ const routeLabels: Record<string, string> = {
function usePathSegments(): { label: string; to: string }[] {
// useLocation is imported lazily through the module scope; declaring the hook
// here keeps Breadcrumbs readable.
return useLocationSegments()
}
@ -297,9 +305,18 @@ function usePathSegments(): { label: string; to: string }[] {
* 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()
/** Exported for integration tests. */
export function ThemeSwitcher() {
const {
mode,
previewSlug,
setMode,
setPreviewSlug,
choose,
available,
runtimeBase,
controlsReady,
} = useAdminThemeSafe()
const selector = useRef<BananasElement>(null)
const toggle = useRef<BananasElement>(null)
@ -314,16 +331,18 @@ function ThemeSwitcher() {
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')
choose({
slug,
mode: detail.mode === 'dark' ? 'theme-dark' : 'theme-light',
})
} else {
choose({ slug: null, mode: 'admin' })
}
}
el.addEventListener('bananas:theme', handler)
return () => el.removeEventListener('bananas:theme', handler)
}, [setPreviewSlug, setMode, controlsReady])
}, [choose, controlsReady])
useEffect(() => {
const el = toggle.current
@ -404,23 +423,21 @@ function LegacyThemeSwitcher({
}) {
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}
<select
aria-label="Preview theme"
value={previewSlug ?? ''}
onChange={(event) => setPreviewSlug(event.target.value)}
className="border-line bg-canvas text-ink-muted rounded-control h-8 max-w-[10rem] 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>
<div
className="border-line bg-canvas flex rounded-control border p-0.5"
className="border-line bg-canvas rounded-control flex border p-0.5"
role="group"
aria-label="Interface theme"
>
@ -502,6 +519,7 @@ function useAdminThemeSafe() {
setMode: () => {},
previewSlug: null,
setPreviewSlug: () => {},
choose: () => {},
available: [] as string[],
runtimeBase: '/rt/v1',
// Without a provider the module is never asked for, so the fallback control
@ -523,4 +541,4 @@ function useLocationSegments(): { label: string; to: string }[] {
out.push({ label: routeLabels[part] ?? part, to: accumulated })
}
return out
}
}

12
web/src/global.d.ts vendored
View file

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
/**
* Global type augmentations for bananas runtime controls.
* This file must not have any imports/exports to be treated as a global script.
@ -6,13 +8,19 @@
declare global {
namespace JSX {
interface IntrinsicElements {
'bananas-theme-selector': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
'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> & {
'bananas-appearance-toggle': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & {
label?: string
'show-labels'?: boolean
}

View file

@ -71,7 +71,7 @@ export default function DashboardRoute() {
) : null}
{/* Counters. */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Stat
label="Themes"
icon={<Palette className="size-4" aria-hidden />}

View file

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
/// <reference types="vite/client" />
/**

View file

@ -27,5 +27,11 @@
"types": ["vitest/globals", "@testing-library/jest-dom"],
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
// The production build must not compile test files. One test imports the shipped
// SDK source across packages (`sdk/src/*.js`) to drive the real custom elements; that
// coupling belongs to the test project (tsconfig.test.json), not to the app image
// build, where the SDK may not even be on disk yet. `tsc -p tsconfig.app.json` is what
// the Dockerfile runs, so tests are simply out of this project's file set.
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}

View file

@ -36,11 +36,11 @@ export default defineConfig({
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 },
'/api': { target: 'http://localhost:8080', changeOrigin: true },
'/rt': { target: 'http://localhost:8080', changeOrigin: true },
'/auth': { target: 'http://localhost:8080', changeOrigin: true },
'/healthz': { target: 'http://localhost:8080', changeOrigin: true },
'/readyz': { target: 'http://localhost:8080', changeOrigin: true },
},
},
test: {

View file

@ -1,3 +1,5 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
/**
@ -7,6 +9,15 @@ import { defineConfig } from 'vitest/config'
* errors about the `test` key.
*/
export default defineConfig({
// The integration tests import the actual shipped SDK modules (sdk/src/*) to drive
// the real custom elements. Those live one directory up, outside the web package
// root, so Vite's default filesystem allow-list has to be widened to the repository
// root for the cross-package import to resolve.
server: {
fs: {
allow: [fileURLToPath(new URL('..', import.meta.url))],
},
},
test: {
// The admin interface is a browser application: components touch the DOM,
// localStorage and matchMedia, so jsdom is required rather than merely useful.