diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index 1ad174863c..755fc5e1be 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -30,16 +30,16 @@ const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => {
}, [clearStorage]);
return (
-
-
+
+
{!didStudioInit && }
-
-
+
+
);
};
diff --git a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx
index 9aa70a5512..0a21348e98 100644
--- a/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx
+++ b/invokeai/frontend/web/src/app/components/GlobalHookIsolator.tsx
@@ -2,6 +2,7 @@ import { useGlobalModifiersInit } from '@invoke-ai/ui-library';
import { setupListeners } from '@reduxjs/toolkit/query';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import { useStudioInitAction } from 'app/hooks/useStudioInitAction';
+import { useSyncLangDirection } from 'app/hooks/useSyncLangDirection';
import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus';
import { useLogger } from 'app/logging/useLogger';
import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig';
@@ -50,6 +51,7 @@ export const GlobalHookIsolator = memo(
useNavigationApi();
useDndMonitor();
useSyncNodeErrors();
+ useSyncLangDirection();
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
// and/or in progress canvas sessions.
diff --git a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx
index 25ee01903b..437cecd492 100644
--- a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx
+++ b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx
@@ -3,43 +3,39 @@ import 'overlayscrollbars/overlayscrollbars.css';
import '@xyflow/react/dist/base.css';
import 'common/components/OverlayScrollbars/overlayscrollbars.css';
-import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
+import { ChakraProvider, DarkMode, extendTheme, theme as baseTheme, TOAST_OPTIONS } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { $direction } from 'app/hooks/useSyncLangDirection';
import type { ReactNode } from 'react';
-import { memo, useEffect, useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
+import { memo, useMemo } from 'react';
type ThemeLocaleProviderProps = {
children: ReactNode;
};
+const buildTheme = (direction: 'ltr' | 'rtl') => {
+ return extendTheme({
+ ...baseTheme,
+ direction,
+ shadows: {
+ ...baseTheme.shadows,
+ selected:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
+ hoverSelected:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
+ hoverUnselected:
+ 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)',
+ selectedForCompare:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
+ hoverSelectedForCompare:
+ 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
+ },
+ });
+};
+
function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) {
- const { i18n } = useTranslation();
-
- const direction = i18n.dir();
-
- const theme = useMemo(() => {
- return extendTheme({
- ..._theme,
- direction,
- shadows: {
- ..._theme.shadows,
- selected:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
- hoverSelected:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)',
- hoverUnselected:
- 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)',
- selectedForCompare:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
- hoverSelectedForCompare:
- 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)',
- },
- });
- }, [direction]);
-
- useEffect(() => {
- document.body.dir = direction;
- }, [direction]);
+ const direction = useStore($direction);
+ const theme = useMemo(() => buildTheme(direction), [direction]);
return (
diff --git a/invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts b/invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts
new file mode 100644
index 0000000000..da1e0dbbcb
--- /dev/null
+++ b/invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts
@@ -0,0 +1,36 @@
+import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
+import { atom } from 'nanostores';
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+
+/**
+ * Global atom storing the language direction, to be consumed by the Chakra theme.
+ *
+ * Why do we need this? We have a kind of catch-22:
+ * - The Chakra theme needs to know the language direction to apply the correct styles.
+ * - The language direction is determined by i18n and the language selection.
+ * - We want our error boundary to be themed.
+ * - It's possible that i18n can throw if the language selection is invalid or not supported.
+ *
+ * Previously, we had the logic in this file in the theme provider, which wrapped the error boundary. The error
+ * was properly themed. But then, if i18n threw in the theme provider, the error boundary does not catch the
+ * error. The app would crash to a white screen.
+ *
+ * We tried swapping the component hierarchy so that the error boundary wraps the theme provider, but then the
+ * error boundary isn't themed!
+ *
+ * The solution is to move this i18n direction logic out of the theme provider and into a hook that we can use
+ * within the error boundary. The error boundary will be themed, _and_ catch any i18n errors.
+ */
+export const $direction = atom<'ltr' | 'rtl'>('ltr');
+
+export const useSyncLangDirection = () => {
+ useAssertSingleton('useSyncLangDirection');
+ const { i18n, t } = useTranslation();
+
+ useEffect(() => {
+ const direction = i18n.dir();
+ $direction.set(direction);
+ document.body.dir = direction;
+ }, [i18n, t]);
+};