import { useEffect, useState } from 'react'
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
import {
ColorSchemeScript,
MantineProvider,
mantineHtmlProps,
type MantineThemeOverride,
} from '@pikku/mantine/core'
import '@mantine/core/styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PikkuProvider, createPikku } from '@pikku/react'
import { PikkuFetch } from '@project/functions-sdk/pikku/pikku-fetch.gen'
import { PikkuRPC } from '@project/functions-sdk/pikku/pikku-rpc.gen'
import {
activeColorScheme,
activeId,
activeTheme,
buildMantineTheme,
googleFontsHref,
themeColorSchemes,
themes,
type ColorScheme,
type Theme,
} from '@project/mantine-themes'
import { defaultLocale, localeDir, supportedLocales, setActiveLocale } from '@/i18n/config'
import { apiUrl } from '@/lib/env'
import { PreferencesContext } from '@/contexts/preferences'
const LOCALE_KEY = 'app-locale'
const THEME_KEY = 'app-theme'
// The active theme id THEME_KEY was saved against — a preference only holds
// while that theme is still the workspace's active one, so a builder-side
// switch (which changes active.json) always wins over a stale local pick.
const THEME_SAVED_ACTIVE_KEY = 'app-theme-saved-active'
const fontsHref = googleFontsHref()
// Root route owns the full HTML document for SSR ( so the tree
// mirrors for RTL locales) and wraps the app in the Mantine + react-query +
// Pikku providers. Mantine v8 SSR needs ColorSchemeScript in
and
// mantineHtmlProps on ; @mantine/core/styles.css ships the static CSS.
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Pikku App' },
],
}),
component: RootDocument,
})
function RootDocument() {
// Seed from the build-time active theme (active.json) so the applied theme
// drives the initial + SSR render; useEffect syncs from localStorage after
// hydration. Seeding 'default' here was the "applied theme never shows" bug.
const [themeId, setThemeIdRaw] = useState(activeId)
const [locale, setLocaleRaw] = useState(defaultLocale)
// Transient override from the fabric console live-preview (not persisted) —
// the console injects a full theme spec into the iframe so the builder sees
// the look instantly. Takes precedence over the persisted theme.
const [previewTheme, setPreviewTheme] = useState(null)
// The scheme that goes with the live-preview spec (a light style must render
// light), tracked alongside previewTheme so a preview flips the scheme too.
const [previewScheme, setPreviewScheme] = useState(null)
const effectiveTheme = previewTheme ?? themes[themeId] ?? activeTheme
// Force the active/previewed theme's natural scheme — a light-first style boots
// light, a dark-first style dark — rather than the old hardcoded dark.
const colorScheme: ColorScheme = previewScheme ?? themeColorSchemes[themeId] ?? activeColorScheme
// Sync preferences from localStorage after hydration.
useEffect(() => {
const savedTheme = localStorage.getItem(THEME_KEY)
const savedAgainst = localStorage.getItem(THEME_SAVED_ACTIVE_KEY)
if (savedTheme && themes[savedTheme] && savedAgainst === activeId) {
setThemeIdRaw(savedTheme)
} else if (savedTheme) {
localStorage.removeItem(THEME_KEY)
localStorage.removeItem(THEME_SAVED_ACTIVE_KEY)
}
const savedLocale = localStorage.getItem(LOCALE_KEY)
if (
savedLocale &&
supportedLocales.includes(savedLocale as (typeof supportedLocales)[number])
) {
setLocaleRaw(savedLocale)
setActiveLocale(savedLocale as (typeof supportedLocales)[number])
}
}, [])
// Keep in sync with locale changes after hydration.
useEffect(() => {
document.documentElement.lang = locale
document.documentElement.dir = localeDir(locale)
}, [locale])
// Fabric console live-preview: postMessage injects a theme spec to override
// the active theme without persisting. Accepts `theme` (a full spec) or the
// legacy `palette`; null resets to the persisted theme.
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data
if (data?.source !== 'fabric-console' || data.type !== 'set-theme') return
try {
const spec = (data.theme ?? data.palette) as Theme | null
setPreviewTheme(spec ? buildMantineTheme(spec) : null)
setPreviewScheme(spec?.structure?.defaultColorScheme ?? null)
} catch (err) {
console.warn('[mantine-themes] ignoring bad theme payload', err)
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
const setLocale = (next: string) => {
localStorage.setItem(LOCALE_KEY, next)
setLocaleRaw(next)
setActiveLocale(next as (typeof supportedLocales)[number])
}
const setThemeId = (next: string) => {
localStorage.setItem(THEME_KEY, next)
localStorage.setItem(THEME_SAVED_ACTIVE_KEY, activeId)
setThemeIdRaw(next)
setPreviewTheme(null)
setPreviewScheme(null)
}
// Per-render instances keep server requests from sharing client/cache state.
const [queryClient] = useState(() => new QueryClient())
const [pikku] = useState(() =>
createPikku(PikkuFetch, PikkuRPC, {
serverUrl: apiUrl(),
credentials: 'include',
}),
)
return (
{/* Inline data-URI icon so the browser's automatic /favicon.ico request
never 404s (no asset to ship, no network round-trip). Gradient spark
matches the Wordmark; the build agent rebrands this per project. */}
{fontsHref && (
<>
>
)}
)
}