refactor
This commit is contained in:
parent
d0ea6e9eec
commit
2967f1a5af
31 changed files with 469 additions and 165 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
12
web/src/global.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue