@open-mercato/ui 0.6.7-develop.6870.1.b69802067a → 0.6.8-develop.6874.1.982d6097d8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/utils/api.js +13 -3
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/theme/ThemeProvider.js +1 -1
- package/dist/theme/ThemeProvider.js.map +2 -2
- package/dist/theme/index.js +3 -0
- package/dist/theme/index.js.map +2 -2
- package/dist/theme/theme-init-script.js +17 -0
- package/dist/theme/theme-init-script.js.map +7 -0
- package/package.json +4 -4
- package/src/backend/utils/__tests__/api.test.ts +27 -0
- package/src/backend/utils/api.ts +20 -3
- package/src/theme/ThemeProvider.tsx +1 -2
- package/src/theme/__tests__/theme-init-script.test.ts +70 -0
- package/src/theme/index.ts +1 -0
- package/src/theme/theme-init-script.ts +23 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -38,6 +38,15 @@ function isSameOriginRequest(input) {
|
|
|
38
38
|
async function withScopedApiHeaders(headers, run) {
|
|
39
39
|
return scopedHeaders.withScopedHeaders(headers, run);
|
|
40
40
|
}
|
|
41
|
+
function readPathname() {
|
|
42
|
+
return typeof window !== "undefined" ? window.location?.pathname ?? "" : "";
|
|
43
|
+
}
|
|
44
|
+
function isLoginPathname(pathname) {
|
|
45
|
+
return pathname.startsWith("/login");
|
|
46
|
+
}
|
|
47
|
+
function isPortalPathname(pathname) {
|
|
48
|
+
return /\/[^/]+\/portal(\/|$)/.test(pathname);
|
|
49
|
+
}
|
|
41
50
|
class UnauthorizedError extends Error {
|
|
42
51
|
constructor(message = "Unauthorized") {
|
|
43
52
|
super(message);
|
|
@@ -110,10 +119,11 @@ async function apiFetch(input, init) {
|
|
|
110
119
|
const requestHeaders = new Headers(mergedInit?.headers);
|
|
111
120
|
const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, "x-om-unauthorized-redirect");
|
|
112
121
|
const disableForbiddenRedirect = readRedirectOverride(requestHeaders, "x-om-forbidden-redirect");
|
|
122
|
+
const requestPathname = readPathname();
|
|
113
123
|
const res = await baseFetch(input, mergedInit);
|
|
114
|
-
const
|
|
115
|
-
const onLoginPage =
|
|
116
|
-
const onPortalRoute =
|
|
124
|
+
const responsePathname = readPathname();
|
|
125
|
+
const onLoginPage = isLoginPathname(requestPathname) || isLoginPathname(responsePathname);
|
|
126
|
+
const onPortalRoute = isPortalPathname(requestPathname) || isPortalPathname(responsePathname);
|
|
117
127
|
if (res.status === 401) {
|
|
118
128
|
if (!onLoginPage && !onPortalRoute && !disableUnauthorizedRedirect) {
|
|
119
129
|
redirectToSessionRefresh();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/api.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n// Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)\n// Used across UI data utilities to avoid duplication.\nimport { flash } from '../FlashMessages'\nimport { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'apiFetch' })\n\nconst scopedHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {\n const headers = new Headers(base ?? {})\n for (const [key, value] of Object.entries(scoped)) {\n if (headers.has(key)) continue\n headers.set(key, value)\n }\n return headers\n}\n\nfunction readRedirectOverride(headers: Headers, headerName: string): boolean {\n return headers.get(headerName) === '0'\n}\n\nfunction isSameOriginRequest(input: RequestInfo | URL): boolean {\n if (typeof window === 'undefined') return false\n const host = window.location?.host\n if (!host) return false\n let urlString: string\n if (typeof input === 'string') urlString = input\n else if (input instanceof URL) urlString = input.toString()\n else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url\n else return false\n if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true\n try {\n return new URL(urlString).host === host\n } catch {\n return false\n }\n}\n\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\n}\n\nexport class UnauthorizedError extends Error {\n readonly status = 401\n constructor(message = 'Unauthorized') {\n super(message)\n this.name = 'UnauthorizedError'\n }\n}\n\nexport function redirectToSessionRefresh() {\n if (typeof window === 'undefined') return\n const current = window.location.pathname + window.location.search\n // Avoid redirect loops if already on an auth/session route\n if (window.location.pathname.startsWith('/api/auth')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash('Session expired. Redirecting to sign in\u2026', 'warning')\n setTimeout(() => {\n window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`\n }, 20)\n } catch {\n // no-op\n }\n}\n\nexport class ForbiddenError extends Error {\n readonly status = 403\n readonly requiredFeatures: string[] | null\n readonly requiredRoles: string[] | null\n\n constructor(\n message = 'Forbidden',\n options?: { requiredFeatures?: string[] | null; requiredRoles?: string[] | null },\n ) {\n super(message)\n this.name = 'ForbiddenError'\n this.requiredFeatures = options?.requiredFeatures?.length ? [...options.requiredFeatures] : null\n this.requiredRoles = options?.requiredRoles?.length ? [...options.requiredRoles] : null\n }\n}\n\nlet DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']\n\nexport function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {\n if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {\n DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)\n }\n}\n\nfunction formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }): string {\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n if (features.length) {\n return `Access denied: you are missing the required permission \"${features.join(', ')}\". Contact your administrator.`\n }\n if (effectiveRoles.length) {\n return `Access denied: this area requires the role \"${effectiveRoles.join(', ')}\". Contact your administrator.`\n }\n return 'Access denied: you do not have permission to perform this action.'\n}\n\n/**\n * Signal a forbidden access attempt for an authenticated user via a flash banner.\n *\n * Authenticated 403 responses must never redirect to `/login` \u2014 that creates an\n * infinite loop because the login page detects the active session and bounces\n * the user back to the failing destination (see GH #2070). Pages that need an\n * inline banner should catch `ForbiddenError` and render `AccessDeniedMessage`\n * from `@open-mercato/ui/backend/detail`.\n */\nexport function notifyForbiddenAccess(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n // Portal routes have their own customer auth \u2014 keep the existing no-op contract.\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash(formatForbiddenAccessMessage(options), 'warning')\n } catch {\n // no-op\n }\n}\n\n/**\n * @deprecated Renamed to {@link notifyForbiddenAccess}. The previous name\n * implied a `/login` redirect that no longer happens (see GH #2070). Kept as an\n * exported alias for one minor version so third-party module imports keep\n * building; update imports to `notifyForbiddenAccess`.\n */\nexport const redirectToForbiddenLogin = notifyForbiddenAccess\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n const originalFetch =\n typeof window !== 'undefined'\n ? (window as Window & { __omOriginalFetch?: FetchType }).__omOriginalFetch\n : undefined\n const fallbackFetch = (globalThis as typeof globalThis & { fetch?: FetchType }).fetch\n const baseFetch = originalFetch ?? fallbackFetch\n if (!baseFetch) {\n return new Response(\n JSON.stringify({ error: 'Fetch API is not available in this runtime' }),\n { status: 503, headers: { 'content-type': 'application/json' } },\n )\n }\n const scoped = scopedHeaders.resolveScopedHeaders()\n const baseInit: RequestInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init ?? {}\n // Only auto-inject credentials: 'include' for same-origin requests so cookies\n // round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)\n // without leaking session cookies to third-party hosts.\n const mergedInit: RequestInit = baseInit.credentials\n ? baseInit\n : isSameOriginRequest(input)\n ? { ...baseInit, credentials: 'include' }\n : baseInit\n const requestHeaders = new Headers(mergedInit?.headers)\n const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')\n const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')\n const res = await baseFetch(input, mergedInit)\n const pathname = typeof window !== 'undefined' ? window.location.pathname : ''\n const onLoginPage = pathname.startsWith('/login')\n const onPortalRoute = /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\n if (res.status === 401) {\n // Trigger same redirect flow as protected pages\n // Skip for staff login page and all portal routes (portal has its own auth)\n if (!onLoginPage && !onPortalRoute && !disableUnauthorizedRedirect) {\n redirectToSessionRefresh()\n // Throw a typed error for callers that might still handle it\n throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))\n }\n return res\n }\n if (res.status === 403) {\n // Try to read requiredRoles from JSON body; ignore if not JSON\n let roles: string[] | null = null\n let features: string[] | null = null\n let payload: unknown = null\n const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)\n if (aclData && typeof aclData === 'object') {\n if (Array.isArray(aclData.requiredRoles)) {\n roles = aclData.requiredRoles.map((r) => String(r))\n }\n if (Array.isArray(aclData.requiredFeatures)) {\n features = aclData.requiredFeatures.map((f) => String(f))\n }\n payload = aclData\n }\n // Only redirect if not already on login page or a portal route\n if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {\n const target =\n typeof input === 'string'\n ? input\n : input instanceof URL\n ? input.toString()\n : (typeof Request !== 'undefined' && input instanceof Request)\n ? input.url\n : 'unknown'\n try {\n logger.warn('Forbidden response', {\n url: target,\n status: res.status,\n requiredRoles: roles,\n requiredFeatures: features,\n details: payload,\n })\n } catch {}\n const hasAclHints = Boolean((roles && roles.length) || (features && features.length))\n if (hasAclHints) {\n notifyForbiddenAccess({ requiredRoles: roles, requiredFeatures: features })\n }\n let msg = 'Forbidden'\n if (aclData && typeof aclData === 'object') {\n if (typeof aclData.error === 'string') {\n msg = aclData.error\n } else if (typeof aclData.message === 'string') {\n msg = aclData.message\n }\n } else {\n msg = await res.clone().text().catch(() => 'Forbidden')\n }\n // Attach ACL hints so callers (e.g. flashMutationError) can name the\n // missing permission instead of surfacing a bare \"Forbidden\" toast.\n throw new ForbiddenError(msg, { requiredFeatures: features, requiredRoles: roles })\n }\n // If already on login, just return the response for the caller to handle\n }\n try {\n const header = res.headers.get('x-om-operation')\n const metadata = deserializeOperationMetadata(header)\n if (metadata) pushOperation(metadata)\n } catch {\n // ignore malformed headers\n }\n try {\n const warningRaw = res.headers.get('x-om-partial-index')\n if (warningRaw) {\n const parsed = JSON.parse(warningRaw) as Record<string, unknown>\n if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {\n const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')\n if (entity) {\n const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null\n const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null\n const scope = parsed.scope === 'global' ? 'global' : 'scoped'\n const entityLabel =\n typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()\n ? parsed.entityLabel.trim()\n : entity\n pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })\n }\n }\n }\n } catch {\n // ignore malformed headers\n }\n return res\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\n// Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)\n// Used across UI data utilities to avoid duplication.\nimport { flash } from '../FlashMessages'\nimport { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'apiFetch' })\n\nconst scopedHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {\n const headers = new Headers(base ?? {})\n for (const [key, value] of Object.entries(scoped)) {\n if (headers.has(key)) continue\n headers.set(key, value)\n }\n return headers\n}\n\nfunction readRedirectOverride(headers: Headers, headerName: string): boolean {\n return headers.get(headerName) === '0'\n}\n\nfunction isSameOriginRequest(input: RequestInfo | URL): boolean {\n if (typeof window === 'undefined') return false\n const host = window.location?.host\n if (!host) return false\n let urlString: string\n if (typeof input === 'string') urlString = input\n else if (input instanceof URL) urlString = input.toString()\n else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url\n else return false\n if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true\n try {\n return new URL(urlString).host === host\n } catch {\n return false\n }\n}\n\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\n}\n\nfunction readPathname(): string {\n return typeof window !== 'undefined' ? window.location?.pathname ?? '' : ''\n}\n\nfunction isLoginPathname(pathname: string): boolean {\n return pathname.startsWith('/login')\n}\n\nfunction isPortalPathname(pathname: string): boolean {\n return /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\n}\n\nexport class UnauthorizedError extends Error {\n readonly status = 401\n constructor(message = 'Unauthorized') {\n super(message)\n this.name = 'UnauthorizedError'\n }\n}\n\nexport function redirectToSessionRefresh() {\n if (typeof window === 'undefined') return\n const current = window.location.pathname + window.location.search\n // Avoid redirect loops if already on an auth/session route\n if (window.location.pathname.startsWith('/api/auth')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash('Session expired. Redirecting to sign in\u2026', 'warning')\n setTimeout(() => {\n window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`\n }, 20)\n } catch {\n // no-op\n }\n}\n\nexport class ForbiddenError extends Error {\n readonly status = 403\n readonly requiredFeatures: string[] | null\n readonly requiredRoles: string[] | null\n\n constructor(\n message = 'Forbidden',\n options?: { requiredFeatures?: string[] | null; requiredRoles?: string[] | null },\n ) {\n super(message)\n this.name = 'ForbiddenError'\n this.requiredFeatures = options?.requiredFeatures?.length ? [...options.requiredFeatures] : null\n this.requiredRoles = options?.requiredRoles?.length ? [...options.requiredRoles] : null\n }\n}\n\nlet DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']\n\nexport function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {\n if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {\n DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)\n }\n}\n\nfunction formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }): string {\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n if (features.length) {\n return `Access denied: you are missing the required permission \"${features.join(', ')}\". Contact your administrator.`\n }\n if (effectiveRoles.length) {\n return `Access denied: this area requires the role \"${effectiveRoles.join(', ')}\". Contact your administrator.`\n }\n return 'Access denied: you do not have permission to perform this action.'\n}\n\n/**\n * Signal a forbidden access attempt for an authenticated user via a flash banner.\n *\n * Authenticated 403 responses must never redirect to `/login` \u2014 that creates an\n * infinite loop because the login page detects the active session and bounces\n * the user back to the failing destination (see GH #2070). Pages that need an\n * inline banner should catch `ForbiddenError` and render `AccessDeniedMessage`\n * from `@open-mercato/ui/backend/detail`.\n */\nexport function notifyForbiddenAccess(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n // Portal routes have their own customer auth \u2014 keep the existing no-op contract.\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash(formatForbiddenAccessMessage(options), 'warning')\n } catch {\n // no-op\n }\n}\n\n/**\n * @deprecated Renamed to {@link notifyForbiddenAccess}. The previous name\n * implied a `/login` redirect that no longer happens (see GH #2070). Kept as an\n * exported alias for one minor version so third-party module imports keep\n * building; update imports to `notifyForbiddenAccess`.\n */\nexport const redirectToForbiddenLogin = notifyForbiddenAccess\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n const originalFetch =\n typeof window !== 'undefined'\n ? (window as Window & { __omOriginalFetch?: FetchType }).__omOriginalFetch\n : undefined\n const fallbackFetch = (globalThis as typeof globalThis & { fetch?: FetchType }).fetch\n const baseFetch = originalFetch ?? fallbackFetch\n if (!baseFetch) {\n return new Response(\n JSON.stringify({ error: 'Fetch API is not available in this runtime' }),\n { status: 503, headers: { 'content-type': 'application/json' } },\n )\n }\n const scoped = scopedHeaders.resolveScopedHeaders()\n const baseInit: RequestInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init ?? {}\n // Only auto-inject credentials: 'include' for same-origin requests so cookies\n // round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)\n // without leaking session cookies to third-party hosts.\n const mergedInit: RequestInit = baseInit.credentials\n ? baseInit\n : isSameOriginRequest(input)\n ? { ...baseInit, credentials: 'include' }\n : baseInit\n const requestHeaders = new Headers(mergedInit?.headers)\n const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')\n const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')\n // Snapshot the pathname BEFORE the request is sent. A 401 for a request that\n // started on the login page must stay silent even when the response lands after\n // the post-login client-side navigation to /backend \u2014 otherwise the stale\n // pre-auth 401 raises a bogus \"Session expired\" banner right after signing in.\n const requestPathname = readPathname()\n const res = await baseFetch(input, mergedInit)\n const responsePathname = readPathname()\n const onLoginPage = isLoginPathname(requestPathname) || isLoginPathname(responsePathname)\n const onPortalRoute = isPortalPathname(requestPathname) || isPortalPathname(responsePathname)\n if (res.status === 401) {\n // Trigger same redirect flow as protected pages\n // Skip for staff login page and all portal routes (portal has its own auth)\n if (!onLoginPage && !onPortalRoute && !disableUnauthorizedRedirect) {\n redirectToSessionRefresh()\n // Throw a typed error for callers that might still handle it\n throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))\n }\n return res\n }\n if (res.status === 403) {\n // Try to read requiredRoles from JSON body; ignore if not JSON\n let roles: string[] | null = null\n let features: string[] | null = null\n let payload: unknown = null\n const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)\n if (aclData && typeof aclData === 'object') {\n if (Array.isArray(aclData.requiredRoles)) {\n roles = aclData.requiredRoles.map((r) => String(r))\n }\n if (Array.isArray(aclData.requiredFeatures)) {\n features = aclData.requiredFeatures.map((f) => String(f))\n }\n payload = aclData\n }\n // Only redirect if not already on login page or a portal route\n if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {\n const target =\n typeof input === 'string'\n ? input\n : input instanceof URL\n ? input.toString()\n : (typeof Request !== 'undefined' && input instanceof Request)\n ? input.url\n : 'unknown'\n try {\n logger.warn('Forbidden response', {\n url: target,\n status: res.status,\n requiredRoles: roles,\n requiredFeatures: features,\n details: payload,\n })\n } catch {}\n const hasAclHints = Boolean((roles && roles.length) || (features && features.length))\n if (hasAclHints) {\n notifyForbiddenAccess({ requiredRoles: roles, requiredFeatures: features })\n }\n let msg = 'Forbidden'\n if (aclData && typeof aclData === 'object') {\n if (typeof aclData.error === 'string') {\n msg = aclData.error\n } else if (typeof aclData.message === 'string') {\n msg = aclData.message\n }\n } else {\n msg = await res.clone().text().catch(() => 'Forbidden')\n }\n // Attach ACL hints so callers (e.g. flashMutationError) can name the\n // missing permission instead of surfacing a bare \"Forbidden\" toast.\n throw new ForbiddenError(msg, { requiredFeatures: features, requiredRoles: roles })\n }\n // If already on login, just return the response for the caller to handle\n }\n try {\n const header = res.headers.get('x-om-operation')\n const metadata = deserializeOperationMetadata(header)\n if (metadata) pushOperation(metadata)\n } catch {\n // ignore malformed headers\n }\n try {\n const warningRaw = res.headers.get('x-om-partial-index')\n if (warningRaw) {\n const parsed = JSON.parse(warningRaw) as Record<string, unknown>\n if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {\n const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')\n if (entity) {\n const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null\n const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null\n const scope = parsed.scope === 'global' ? 'global' : 'scoped'\n const entityLabel =\n typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()\n ? parsed.entityLabel.trim()\n : entity\n pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })\n }\n }\n }\n } catch {\n // ignore malformed headers\n }\n return res\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,9 +3,9 @@ import { Fragment, jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { createContext, useContext } from "react";
|
|
5
5
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
6
|
+
import { THEME_STORAGE_KEY } from "./theme-init-script.js";
|
|
6
7
|
const logger = createLogger("ui").child({ component: "ThemeProvider" });
|
|
7
8
|
const ThemeContext = createContext(void 0);
|
|
8
|
-
const THEME_STORAGE_KEY = "om-theme";
|
|
9
9
|
function getSystemTheme() {
|
|
10
10
|
if (typeof window === "undefined") return "light";
|
|
11
11
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/theme/ThemeProvider.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { createContext, useContext } from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'ThemeProvider' })\n\nexport type Theme = 'light' | 'dark' | 'system'\n\ntype ThemeContextValue = {\n theme: Theme\n resolvedTheme: 'light' | 'dark'\n setTheme: (theme: Theme) => void\n}\n\nconst ThemeContext = createContext<ThemeContextValue | undefined>(undefined)\n\
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { createContext, useContext } from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { THEME_STORAGE_KEY } from './theme-init-script'\n\nconst logger = createLogger('ui').child({ component: 'ThemeProvider' })\n\nexport type Theme = 'light' | 'dark' | 'system'\n\ntype ThemeContextValue = {\n theme: Theme\n resolvedTheme: 'light' | 'dark'\n setTheme: (theme: Theme) => void\n}\n\nconst ThemeContext = createContext<ThemeContextValue | undefined>(undefined)\n\nfunction getSystemTheme(): 'light' | 'dark' {\n if (typeof window === 'undefined') return 'light'\n return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\n}\n\nfunction getStoredTheme(): Theme {\n if (typeof window === 'undefined') return 'system'\n try {\n const stored = localStorage.getItem(THEME_STORAGE_KEY)\n if (stored === 'light' || stored === 'dark' || stored === 'system') {\n return stored\n }\n } catch (error) {\n // localStorage may be unavailable in private browsing, iframes, or restricted contexts\n // Theme will default to system preference - this is expected graceful degradation\n if (process.env.NODE_ENV === 'development') {\n logger.warn('localStorage read failed', { err: error })\n }\n }\n return 'system'\n}\n\nfunction applyTheme(resolvedTheme: 'light' | 'dark') {\n const root = document.documentElement\n if (resolvedTheme === 'dark') {\n root.classList.add('dark')\n } else {\n root.classList.remove('dark')\n }\n}\n\nexport function ThemeProvider({ children }: { children: React.ReactNode }) {\n const [theme, setThemeState] = React.useState<Theme>('system')\n const [resolvedTheme, setResolvedTheme] = React.useState<'light' | 'dark'>('light')\n const [mounted, setMounted] = React.useState(false)\n\n // Initialize theme from localStorage on mount\n React.useEffect(() => {\n const stored = getStoredTheme()\n setThemeState(stored)\n const resolved = stored === 'system' ? getSystemTheme() : stored\n setResolvedTheme(resolved)\n applyTheme(resolved)\n setMounted(true)\n }, [])\n\n // Listen for system theme changes\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n\n const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')\n const handleChange = () => {\n if (theme === 'system') {\n const newResolved = getSystemTheme()\n setResolvedTheme(newResolved)\n applyTheme(newResolved)\n }\n }\n\n mediaQuery.addEventListener('change', handleChange)\n return () => mediaQuery.removeEventListener('change', handleChange)\n }, [theme])\n\n const setTheme = React.useCallback((newTheme: Theme) => {\n setThemeState(newTheme)\n try {\n localStorage.setItem(THEME_STORAGE_KEY, newTheme)\n } catch (error) {\n // localStorage may be unavailable - theme still works for this session, just won't persist\n if (process.env.NODE_ENV === 'development') {\n logger.warn('localStorage write failed', { err: error })\n }\n }\n const resolved = newTheme === 'system' ? getSystemTheme() : newTheme\n setResolvedTheme(resolved)\n applyTheme(resolved)\n }, [])\n\n const value = React.useMemo(\n () => ({ theme, resolvedTheme, setTheme }),\n [theme, resolvedTheme, setTheme]\n )\n\n // Prevent flash of wrong theme during hydration\n if (!mounted) {\n return <>{children}</>\n }\n\n return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>\n}\n\nexport function useTheme(): ThemeContextValue {\n const context = useContext(ThemeContext)\n if (context === undefined) {\n // Return safe defaults when not in provider (e.g., server render)\n return {\n theme: 'system',\n resolvedTheme: 'light',\n setTheme: () => {},\n }\n }\n return context\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/theme/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { ThemeProvider, useTheme } from "./ThemeProvider.js";
|
|
2
|
+
import { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from "./theme-init-script.js";
|
|
2
3
|
import { ThemeToggle } from "./ThemeToggle.js";
|
|
3
4
|
import { QueryProvider } from "./QueryProvider.js";
|
|
4
5
|
export {
|
|
5
6
|
QueryProvider,
|
|
7
|
+
THEME_INIT_SCRIPT,
|
|
8
|
+
THEME_STORAGE_KEY,
|
|
6
9
|
ThemeProvider,
|
|
7
10
|
ThemeToggle,
|
|
8
11
|
useTheme
|
package/dist/theme/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/theme/index.ts"],
|
|
4
|
-
"sourcesContent": ["export { ThemeProvider, useTheme } from './ThemeProvider'\nexport { ThemeToggle } from './ThemeToggle'\nexport { QueryProvider } from './QueryProvider'\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,eAAe,gBAAgB;AACxC,SAAS,mBAAmB;AAC5B,SAAS,qBAAqB;",
|
|
4
|
+
"sourcesContent": ["export { ThemeProvider, useTheme } from './ThemeProvider'\nexport { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from './theme-init-script'\nexport { ThemeToggle } from './ThemeToggle'\nexport { QueryProvider } from './QueryProvider'\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,eAAe,gBAAgB;AACxC,SAAS,mBAAmB,yBAAyB;AACrD,SAAS,mBAAmB;AAC5B,SAAS,qBAAqB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const THEME_STORAGE_KEY = "om-theme";
|
|
2
|
+
const THEME_INIT_SCRIPT = `
|
|
3
|
+
(function () {
|
|
4
|
+
try {
|
|
5
|
+
var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
|
6
|
+
var theme = stored === 'dark' ? 'dark'
|
|
7
|
+
: stored === 'light' ? 'light'
|
|
8
|
+
: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
9
|
+
if (theme === 'dark') document.documentElement.classList.add('dark');
|
|
10
|
+
} catch (error) {}
|
|
11
|
+
})();
|
|
12
|
+
`;
|
|
13
|
+
export {
|
|
14
|
+
THEME_INIT_SCRIPT,
|
|
15
|
+
THEME_STORAGE_KEY
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=theme-init-script.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/theme/theme-init-script.ts"],
|
|
4
|
+
"sourcesContent": ["export const THEME_STORAGE_KEY = 'om-theme'\n\n/**\n * Inline source for the root layout's theme initializer.\n *\n * It MUST be rendered as a plain, non-deferred `<script>` element so the browser\n * executes it while parsing the document, before the first paint. Deferring it \u2014\n * for example through `next/script` with `strategy=\"beforeInteractive\"`, which in\n * the App Router only queues the code onto `self.__next_s` for the client runtime\n * to replay once hydration starts \u2014 makes the page paint in light theme first and\n * flash to dark afterwards.\n */\nexport const THEME_INIT_SCRIPT = `\n(function () {\n try {\n var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});\n var theme = stored === 'dark' ? 'dark'\n : stored === 'light' ? 'light'\n : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';\n if (theme === 'dark') document.documentElement.classList.add('dark');\n } catch (error) {}\n})();\n`\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,oBAAoB;AAY1B,MAAM,oBAAoB;AAAA;AAAA;AAAA,wCAGO,KAAK,UAAU,iBAAiB,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8-develop.6874.1.982d6097d8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.
|
|
158
|
+
"@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.
|
|
165
|
+
"@open-mercato/shared": "0.6.8-develop.6874.1.982d6097d8",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
|
@@ -186,5 +186,5 @@
|
|
|
186
186
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
187
187
|
"directory": "packages/ui"
|
|
188
188
|
},
|
|
189
|
-
"stableVersion": "0.6.
|
|
189
|
+
"stableVersion": "0.6.7"
|
|
190
190
|
}
|
|
@@ -149,6 +149,33 @@ describe('apiFetch', () => {
|
|
|
149
149
|
expect(flash).not.toHaveBeenCalled()
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
+
it('stays silent when a login-page 401 lands after the post-login navigation', async () => {
|
|
153
|
+
window.history.pushState({}, '', '/login')
|
|
154
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () => {
|
|
155
|
+
// The sign-in completes while the pre-auth probe is still in flight, so the
|
|
156
|
+
// app has already client-navigated to /backend when the 401 arrives.
|
|
157
|
+
window.history.pushState({}, '', '/backend')
|
|
158
|
+
return createMockResponse(401, { error: 'Unauthorized' })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const result = await apiFetch('/api/auth/feature-check', { method: 'POST' })
|
|
162
|
+
|
|
163
|
+
expect(result.status).toBe(401)
|
|
164
|
+
expect(flash).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('stays silent when a 401 lands after navigating to the login page', async () => {
|
|
168
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () => {
|
|
169
|
+
window.history.pushState({}, '', '/login')
|
|
170
|
+
return createMockResponse(401, { error: 'Unauthorized' })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const result = await apiFetch('/api/private')
|
|
174
|
+
|
|
175
|
+
expect(result.status).toBe(401)
|
|
176
|
+
expect(flash).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
152
179
|
it('throws UnauthorizedError for 401 responses by default', async () => {
|
|
153
180
|
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
|
|
154
181
|
createMockResponse(401, { error: 'Unauthorized' }),
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -47,6 +47,18 @@ export async function withScopedApiHeaders<T>(headers: Record<string, string>, r
|
|
|
47
47
|
return scopedHeaders.withScopedHeaders(headers, run)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function readPathname(): string {
|
|
51
|
+
return typeof window !== 'undefined' ? window.location?.pathname ?? '' : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isLoginPathname(pathname: string): boolean {
|
|
55
|
+
return pathname.startsWith('/login')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPortalPathname(pathname: string): boolean {
|
|
59
|
+
return /\/[^/]+\/portal(\/|$)/.test(pathname)
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
export class UnauthorizedError extends Error {
|
|
51
63
|
readonly status = 401
|
|
52
64
|
constructor(message = 'Unauthorized') {
|
|
@@ -166,10 +178,15 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
166
178
|
const requestHeaders = new Headers(mergedInit?.headers)
|
|
167
179
|
const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')
|
|
168
180
|
const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')
|
|
181
|
+
// Snapshot the pathname BEFORE the request is sent. A 401 for a request that
|
|
182
|
+
// started on the login page must stay silent even when the response lands after
|
|
183
|
+
// the post-login client-side navigation to /backend — otherwise the stale
|
|
184
|
+
// pre-auth 401 raises a bogus "Session expired" banner right after signing in.
|
|
185
|
+
const requestPathname = readPathname()
|
|
169
186
|
const res = await baseFetch(input, mergedInit)
|
|
170
|
-
const
|
|
171
|
-
const onLoginPage =
|
|
172
|
-
const onPortalRoute =
|
|
187
|
+
const responsePathname = readPathname()
|
|
188
|
+
const onLoginPage = isLoginPathname(requestPathname) || isLoginPathname(responsePathname)
|
|
189
|
+
const onPortalRoute = isPortalPathname(requestPathname) || isPortalPathname(responsePathname)
|
|
173
190
|
if (res.status === 401) {
|
|
174
191
|
// Trigger same redirect flow as protected pages
|
|
175
192
|
// Skip for staff login page and all portal routes (portal has its own auth)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { createContext, useContext } from 'react'
|
|
5
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
+
import { THEME_STORAGE_KEY } from './theme-init-script'
|
|
6
7
|
|
|
7
8
|
const logger = createLogger('ui').child({ component: 'ThemeProvider' })
|
|
8
9
|
|
|
@@ -16,8 +17,6 @@ type ThemeContextValue = {
|
|
|
16
17
|
|
|
17
18
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined)
|
|
18
19
|
|
|
19
|
-
const THEME_STORAGE_KEY = 'om-theme'
|
|
20
|
-
|
|
21
20
|
function getSystemTheme(): 'light' | 'dark' {
|
|
22
21
|
if (typeof window === 'undefined') return 'light'
|
|
23
22
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from '../theme-init-script'
|
|
2
|
+
|
|
3
|
+
function mockSystemTheme(prefersDark: boolean) {
|
|
4
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
5
|
+
writable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
value: (query: string) => ({
|
|
8
|
+
matches: query.includes('prefers-color-scheme: dark') ? prefersDark : false,
|
|
9
|
+
media: query,
|
|
10
|
+
addEventListener: () => {},
|
|
11
|
+
removeEventListener: () => {},
|
|
12
|
+
}),
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function runThemeInitScript() {
|
|
17
|
+
new Function(THEME_INIT_SCRIPT)()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('THEME_INIT_SCRIPT', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
document.documentElement.classList.remove('dark')
|
|
23
|
+
window.localStorage.clear()
|
|
24
|
+
jest.restoreAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('applies the dark class when the stored preference is dark', () => {
|
|
28
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
|
29
|
+
mockSystemTheme(false)
|
|
30
|
+
|
|
31
|
+
runThemeInitScript()
|
|
32
|
+
|
|
33
|
+
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('keeps the light theme when the stored preference is light, even if the system prefers dark', () => {
|
|
37
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, 'light')
|
|
38
|
+
mockSystemTheme(true)
|
|
39
|
+
|
|
40
|
+
runThemeInitScript()
|
|
41
|
+
|
|
42
|
+
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('falls back to the system preference when nothing is stored', () => {
|
|
46
|
+
mockSystemTheme(true)
|
|
47
|
+
|
|
48
|
+
runThemeInitScript()
|
|
49
|
+
|
|
50
|
+
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('stays on the light theme when nothing is stored and the system prefers light', () => {
|
|
54
|
+
mockSystemTheme(false)
|
|
55
|
+
|
|
56
|
+
runThemeInitScript()
|
|
57
|
+
|
|
58
|
+
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('degrades gracefully when localStorage is unavailable', () => {
|
|
62
|
+
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
63
|
+
throw new Error('localStorage is blocked')
|
|
64
|
+
})
|
|
65
|
+
mockSystemTheme(true)
|
|
66
|
+
|
|
67
|
+
expect(() => runThemeInitScript()).not.toThrow()
|
|
68
|
+
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
package/src/theme/index.ts
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const THEME_STORAGE_KEY = 'om-theme'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline source for the root layout's theme initializer.
|
|
5
|
+
*
|
|
6
|
+
* It MUST be rendered as a plain, non-deferred `<script>` element so the browser
|
|
7
|
+
* executes it while parsing the document, before the first paint. Deferring it —
|
|
8
|
+
* for example through `next/script` with `strategy="beforeInteractive"`, which in
|
|
9
|
+
* the App Router only queues the code onto `self.__next_s` for the client runtime
|
|
10
|
+
* to replay once hydration starts — makes the page paint in light theme first and
|
|
11
|
+
* flash to dark afterwards.
|
|
12
|
+
*/
|
|
13
|
+
export const THEME_INIT_SCRIPT = `
|
|
14
|
+
(function () {
|
|
15
|
+
try {
|
|
16
|
+
var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
|
17
|
+
var theme = stored === 'dark' ? 'dark'
|
|
18
|
+
: stored === 'light' ? 'light'
|
|
19
|
+
: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
20
|
+
if (theme === 'dark') document.documentElement.classList.add('dark');
|
|
21
|
+
} catch (error) {}
|
|
22
|
+
})();
|
|
23
|
+
`
|