Files
cinny/src/app/pages/App.tsx
T

219 lines
8.4 KiB
TypeScript
Raw Normal View History

import React, { ReactNode, useEffect } from 'react';
2026-06-28 22:21:09 -04:00
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
2026-06-28 22:21:09 -04:00
import {
Box,
Button,
config,
OverlayContainerProvider,
PopOutContainerProvider,
Text,
toRem,
TooltipContainerProvider,
} from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
2024-01-21 23:50:56 +11:00
import { ClientConfigLoader } from '../components/ClientConfigLoader';
import { ClientConfigProvider } from '../hooks/useClientConfig';
2024-01-21 23:50:56 +11:00
import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
2024-01-24 00:06:55 +11:00
import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar';
import { customWindowChromeAtom } from '../state/customWindowChrome';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
inter: "'InterVariable', sans-serif",
'jetbrains-mono': "'JetBrains Mono', monospace",
'fira-code': "'Fira Code', monospace",
};
function AppearanceEffects() {
const settings = useAtomValue(settingsAtom);
useEffect(() => {
const color = settings.mentionHighlightColor;
if (color) {
document.body.style.setProperty('--mention-highlight-bg', color);
// WCAG 2.1 relative luminance with gamma linearization
const toLinear = (c: number) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const lum = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
document.body.style.setProperty('--mention-highlight-text', lum > 0.179 ? '#000' : '#fff');
// Derive a visible border: same hue, reduced alpha
document.body.style.setProperty('--mention-highlight-border', `rgba(${r},${g},${b},0.5)`);
} else {
document.body.style.removeProperty('--mention-highlight-bg');
document.body.style.removeProperty('--mention-highlight-text');
document.body.style.removeProperty('--mention-highlight-border');
}
}, [settings.mentionHighlightColor]);
useEffect(() => {
// Custom accent color applies only to non-TDS themes. When Lotus Terminal
// (TDS) is active it has its own fixed palette, so we remove any overrides.
const accent = settings.customAccentColor;
if (accent && !settings.lotusTerminal && applyCustomAccent(accent)) {
return () => removeCustomAccent();
}
removeCustomAccent();
return undefined;
}, [settings.customAccentColor, settings.lotusTerminal]);
useEffect(() => {
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
document.body.style.setProperty('--font-secondary', font);
}, [settings.fontFamily]);
return null;
}
function TauriEffects() {
useTauriNotificationBadge();
return null;
}
// P5-47 — opt-in TDS window chrome. `useTauriWindowChrome` keeps the native OS
// window decorations in sync with the setting; when a desktop user enables
// custom chrome we replace the OS titlebar with <TitleBar/>. When off (the
// default, and always in the browser) this returns children unchanged, so there
// is zero layout impact for everyone else.
function DesktopChrome({ children }: { children: ReactNode }) {
const customChrome = useAtomValue(customWindowChromeAtom);
useTauriWindowChrome();
const useChrome = isTauri() && customChrome;
// Keep the wrapper element structure STABLE across the toggle so flipping the
// setting never changes the element type in `children`'s ancestry — otherwise
// React would unmount/remount the whole RouterProvider subtree (losing scroll,
// menus, unsaved composer state). When off, both wrappers use `display:contents`
// so they generate no box → zero layout impact (also the browser default path).
return (
<div
style={
useChrome
? { display: 'flex', flexDirection: 'column', height: '100vh' }
: { display: 'contents' }
}
>
{useChrome && <TitleBar />}
<div style={useChrome ? { flexGrow: 1, minHeight: 0 } : { display: 'contents' }}>
{children}
</div>
</div>
);
}
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
if (!settings.nightLightEnabled) return null;
return (
<div
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: zIndices.nightLight,
backgroundColor: `rgba(255, 140, 0, ${(settings.nightLightOpacity ?? 30) / 100})`,
}}
/>
);
}
2024-01-21 23:50:56 +11:00
const queryClient = new QueryClient();
2024-01-21 23:50:56 +11:00
function App() {
const screenSize = useScreenSize();
useCompositionEndTracking();
const portalContainer = document.getElementById('portalContainer') ?? undefined;
2025-08-29 15:04:52 +05:30
// OIDC/next-gen-auth callback is a real (non-hash) path: OAuth redirect_uris
// can't contain a fragment, so it must be handled OUTSIDE the router. Render
// the standalone callback page before the RouterProvider mounts. It needs no
// app providers (it only touches the SDK + localStorage).
if (window.location.pathname.endsWith(OIDC_CALLBACK_PATH)) {
return <OidcCallback />;
}
2024-01-21 23:50:56 +11:00
return (
2026-06-28 22:21:09 -04:00
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ height: '100vh', padding: config.space.S700, textAlign: 'center' }}
>
2026-06-28 22:21:09 -04:00
<Text size="H2">Something went wrong</Text>
<Text size="T300" priority="300" style={{ maxWidth: toRem(400) }}>
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
2026-06-28 22:21:09 -04:00
</Text>
<Button variant="Primary" onClick={resetErrorBoundary}>
<Text as="span" size="B400">
Try again
</Text>
</Button>
</Box>
)}
>
<TooltipContainerProvider value={portalContainer}>
<PopOutContainerProvider value={portalContainer}>
<OverlayContainerProvider value={portalContainer}>
<ScreenSizeProvider value={screenSize}>
<FeatureCheck>
<ClientConfigLoader
fallback={() => <ConfigConfigLoading />}
error={(err, retry, ignore) => (
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
>
{(clientConfig) => (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
)}
</ClientConfigLoader>
</FeatureCheck>
</ScreenSizeProvider>
</OverlayContainerProvider>
</PopOutContainerProvider>
</TooltipContainerProvider>
2026-06-28 22:21:09 -04:00
</ErrorBoundary>
2024-01-21 23:50:56 +11:00
);
}
export default App;