@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.
@@ -1,3 +1,3 @@
1
1
  Generated lucide registry with 145 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- [build:ui] found 397 entry points
2
+ [build:ui] found 398 entry points
3
3
  [build:ui] built successfully
@@ -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 pathname = typeof window !== "undefined" ? window.location.pathname : "";
115
- const onLoginPage = pathname.startsWith("/login");
116
- const onPortalRoute = /\/[^/]+\/portal(\/|$)/.test(pathname);
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\nconst THEME_STORAGE_KEY = 'om-theme'\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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",
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": ";AAwGW;AAtGX,YAAY,WAAW;AACvB,SAAS,eAAe,kBAAkB;AAC1C,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAElC,MAAM,SAAS,aAAa,IAAI,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAUtE,MAAM,eAAe,cAA6C,MAAS;AAE3E,SAAS,iBAAmC;AAC1C,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAO,OAAO,WAAW,8BAA8B,EAAE,UAAU,SAAS;AAC9E;AAEA,SAAS,iBAAwB;AAC/B,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,MAAI;AACF,UAAM,SAAS,aAAa,QAAQ,iBAAiB;AACrD,QAAI,WAAW,WAAW,WAAW,UAAU,WAAW,UAAU;AAClE,aAAO;AAAA,IACT;AAAA,EACF,SAAS,OAAO;AAGd,QAAI,QAAQ,IAAI,aAAa,eAAe;AAC1C,aAAO,KAAK,4BAA4B,EAAE,KAAK,MAAM,CAAC;AAAA,IACxD;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,WAAW,eAAiC;AACnD,QAAM,OAAO,SAAS;AACtB,MAAI,kBAAkB,QAAQ;AAC5B,SAAK,UAAU,IAAI,MAAM;AAAA,EAC3B,OAAO;AACL,SAAK,UAAU,OAAO,MAAM;AAAA,EAC9B;AACF;AAEO,SAAS,cAAc,EAAE,SAAS,GAAkC;AACzE,QAAM,CAAC,OAAO,aAAa,IAAI,MAAM,SAAgB,QAAQ;AAC7D,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAA2B,OAAO;AAClF,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAGlD,QAAM,UAAU,MAAM;AACpB,UAAM,SAAS,eAAe;AAC9B,kBAAc,MAAM;AACpB,UAAM,WAAW,WAAW,WAAW,eAAe,IAAI;AAC1D,qBAAiB,QAAQ;AACzB,eAAW,QAAQ;AACnB,eAAW,IAAI;AAAA,EACjB,GAAG,CAAC,CAAC;AAGL,QAAM,UAAU,MAAM;AACpB,QAAI,OAAO,WAAW,YAAa;AAEnC,UAAM,aAAa,OAAO,WAAW,8BAA8B;AACnE,UAAM,eAAe,MAAM;AACzB,UAAI,UAAU,UAAU;AACtB,cAAM,cAAc,eAAe;AACnC,yBAAiB,WAAW;AAC5B,mBAAW,WAAW;AAAA,MACxB;AAAA,IACF;AAEA,eAAW,iBAAiB,UAAU,YAAY;AAClD,WAAO,MAAM,WAAW,oBAAoB,UAAU,YAAY;AAAA,EACpE,GAAG,CAAC,KAAK,CAAC;AAEV,QAAM,WAAW,MAAM,YAAY,CAAC,aAAoB;AACtD,kBAAc,QAAQ;AACtB,QAAI;AACF,mBAAa,QAAQ,mBAAmB,QAAQ;AAAA,IAClD,SAAS,OAAO;AAEd,UAAI,QAAQ,IAAI,aAAa,eAAe;AAC1C,eAAO,KAAK,6BAA6B,EAAE,KAAK,MAAM,CAAC;AAAA,MACzD;AAAA,IACF;AACA,UAAM,WAAW,aAAa,WAAW,eAAe,IAAI;AAC5D,qBAAiB,QAAQ;AACzB,eAAW,QAAQ;AAAA,EACrB,GAAG,CAAC,CAAC;AAEL,QAAM,QAAQ,MAAM;AAAA,IAClB,OAAO,EAAE,OAAO,eAAe,SAAS;AAAA,IACxC,CAAC,OAAO,eAAe,QAAQ;AAAA,EACjC;AAGA,MAAI,CAAC,SAAS;AACZ,WAAO,gCAAG,UAAS;AAAA,EACrB;AAEA,SAAO,oBAAC,aAAa,UAAb,EAAsB,OAAe,UAAS;AACxD;AAEO,SAAS,WAA8B;AAC5C,QAAM,UAAU,WAAW,YAAY;AACvC,MAAI,YAAY,QAAW;AAEzB,WAAO;AAAA,MACL,OAAO;AAAA,MACP,eAAe;AAAA,MACf,UAAU,MAAM;AAAA,MAAC;AAAA,IACnB;AAAA,EACF;AACA,SAAO;AACT;",
6
6
  "names": []
7
7
  }
@@ -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
@@ -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.7-develop.6870.1.b69802067a",
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.7-develop.6870.1.b69802067a",
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.7-develop.6870.1.b69802067a",
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.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' }),
@@ -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 pathname = typeof window !== 'undefined' ? window.location.pathname : ''
171
- const onLoginPage = pathname.startsWith('/login')
172
- const onPortalRoute = /\/[^/]+\/portal(\/|$)/.test(pathname)
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
+ })
@@ -1,3 +1,4 @@
1
1
  export { ThemeProvider, useTheme } from './ThemeProvider'
2
+ export { THEME_INIT_SCRIPT, THEME_STORAGE_KEY } from './theme-init-script'
2
3
  export { ThemeToggle } from './ThemeToggle'
3
4
  export { QueryProvider } from './QueryProvider'
@@ -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
+ `