@mrmeg/expo-ui 0.20.0 → 0.22.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LLM_USAGE.md +4 -3
  3. package/README.md +2 -2
  4. package/dist/components/Avatar.d.ts +19 -5
  5. package/dist/components/Avatar.js +84 -22
  6. package/dist/components/BottomSheet.js +1 -1
  7. package/dist/components/Carousel.d.ts +32 -6
  8. package/dist/components/Carousel.js +79 -17
  9. package/dist/components/DismissKeyboard.js +1 -1
  10. package/dist/components/KeyboardAvoidingView.d.ts +4 -2
  11. package/dist/components/KeyboardAvoidingView.js +5 -3
  12. package/dist/components/Label.d.ts +30 -10
  13. package/dist/components/Label.js +19 -10
  14. package/dist/components/Notification.js +7 -7
  15. package/dist/components/keyboardController.d.ts +46 -0
  16. package/dist/components/keyboardController.js +89 -0
  17. package/dist/components/keyboardController.native.d.ts +9 -0
  18. package/dist/components/keyboardController.native.js +9 -0
  19. package/dist/constants/colors.d.ts +34 -0
  20. package/dist/constants/colors.js +93 -3
  21. package/dist/constants/fonts.js +5 -4
  22. package/dist/hooks/useDimensions.d.ts +21 -9
  23. package/dist/hooks/useDimensions.js +29 -18
  24. package/dist/hooks/useResources.d.ts +10 -7
  25. package/dist/hooks/useResources.js +21 -24
  26. package/dist/hooks/useTheme.d.ts +18 -0
  27. package/dist/hooks/useTheme.js +45 -29
  28. package/dist/lib/themedStyles.d.ts +7 -7
  29. package/dist/lib/themedStyles.js +7 -7
  30. package/dist/state/SsrViewportContext.d.ts +9 -38
  31. package/dist/state/SsrViewportContext.js +9 -38
  32. package/dist/state/index.d.ts +1 -2
  33. package/dist/state/index.js +1 -2
  34. package/dist/state/themeStore.d.ts +9 -13
  35. package/dist/state/themeStore.js +19 -40
  36. package/llms-full.md +1 -1
  37. package/package.json +2 -2
  38. package/dist/state/ssrTheme.d.ts +0 -41
  39. package/dist/state/ssrTheme.js +0 -33
@@ -1,3 +1,4 @@
1
+ import { Platform } from "react-native";
1
2
  import { navigationFonts } from "./fonts.js";
2
3
  /**
3
4
  * Raw color palette - use semantic colors in components instead
@@ -124,9 +125,98 @@ const darkTheme = {
124
125
  notification: palette.teal400,
125
126
  },
126
127
  };
127
- export const colors = {
128
- light: lightTheme,
129
- dark: darkTheme,
128
+ /**
129
+ * Raw hex/rgba theme colors, always literal strings on every platform. Use
130
+ * these for sinks that cannot take CSS `var()` — e.g. `<meta name="theme-color">`,
131
+ * color parsing/math — and for generating the CSS variable definitions below.
132
+ */
133
+ export const rawThemeColors = {
134
+ light: lightTheme.colors,
135
+ dark: darkTheme.colors,
130
136
  };
137
+ const themeColorTokens = Object.keys(lightTheme.colors);
138
+ // `surfaceSunken` → `--c-surface-sunken`
139
+ function cssVarName(token) {
140
+ return `--c-${token.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
141
+ }
142
+ // Reverse lookup: "var(--c-surface-sunken)" → "surfaceSunken"
143
+ const varToToken = new Map(themeColorTokens.map((token) => [`var(${cssVarName(token)})`, token]));
144
+ /**
145
+ * Resolves a theme color value to a literal color string. On web, theme
146
+ * colors are `var(--c-*)` references; this maps one back to the given
147
+ * scheme's raw hex/rgba. Literal inputs (including app override colors)
148
+ * pass through unchanged.
149
+ */
150
+ export function resolveRawColor(color, scheme) {
151
+ const token = varToToken.get(color);
152
+ return token ? rawThemeColors[scheme][token] : color;
153
+ }
154
+ // "R, G, B" from "#RRGGBB", "#RGB", or "rgb(a)(R, G, B[, A])" (alpha dropped).
155
+ function toRgbTriplet(value) {
156
+ const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
157
+ if (rgbaMatch)
158
+ return `${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}`;
159
+ let hex = value.replace(/^#/, "");
160
+ if (hex.length === 3)
161
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
162
+ const match = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
163
+ if (!match)
164
+ return null;
165
+ return `${parseInt(match[1], 16)}, ${parseInt(match[2], 16)}, ${parseInt(match[3], 16)}`;
166
+ }
167
+ /**
168
+ * CSS custom-property definitions for every semantic theme color, for
169
+ * embedding in a web app's `+html.tsx` global `<style>`. Each token gets
170
+ * `--c-<kebab-token>` (the color value) and `--c-<kebab-token>-rgb` (a
171
+ * comma-separated RGB triplet, alpha stripped — `withAlpha` builds
172
+ * `rgba(var(--c-x-rgb), a)` from it).
173
+ *
174
+ * Light values are defined on `:root`; dark values apply under
175
+ * `html[data-theme="dark"]`, with a `prefers-color-scheme` fallback for the
176
+ * paint before any theme script stamps `data-theme`.
177
+ *
178
+ * Apps that override brand colors can pass those overrides so the emitted
179
+ * CSS matches their theme.
180
+ */
181
+ export function getThemeCssVariables(overrides) {
182
+ const declarations = (scheme) => themeColorTokens
183
+ .map((token) => {
184
+ const value = overrides?.[scheme]?.[token] ?? rawThemeColors[scheme][token];
185
+ const name = cssVarName(token);
186
+ const rgb = toRgbTriplet(value);
187
+ return `${name}: ${value};${rgb ? ` ${name}-rgb: ${rgb};` : ""}`;
188
+ })
189
+ .join("\n ");
190
+ return `
191
+ :root {
192
+ ${declarations("light")}
193
+ }
194
+
195
+ @media (prefers-color-scheme: dark) {
196
+ html:not([data-theme]) {
197
+ ${declarations("dark")}
198
+ }
199
+ }
200
+
201
+ html[data-theme="dark"] {
202
+ ${declarations("dark")}
203
+ }
204
+ `;
205
+ }
206
+ // On web, semantic theme colors resolve to CSS custom properties so the
207
+ // build-time exported HTML is theme-agnostic: the first frame paints in the
208
+ // visitor's scheme purely from CSS, before any JS runs. Native keeps literal
209
+ // values (no CSS engine). The `navigation` maps stay literal on both
210
+ // platforms: @react-navigation internals color-parse their theme values.
211
+ const webVarColors = Object.fromEntries(themeColorTokens.map((token) => [token, `var(${cssVarName(token)})`]));
212
+ export const colors = Platform.OS === "web"
213
+ ? {
214
+ light: { ...lightTheme, colors: { ...webVarColors } },
215
+ dark: { ...darkTheme, colors: { ...webVarColors } },
216
+ }
217
+ : {
218
+ light: lightTheme,
219
+ dark: darkTheme,
220
+ };
131
221
  // Export palette for rare one-off cases
132
222
  export { palette };
@@ -2,10 +2,11 @@ import { Platform } from "react-native";
2
2
  // Web font stack fallback
3
3
  const WEB_FONT_STACK = "system-ui, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"";
4
4
  // IMPORTANT: do NOT key these on `typeof document` / `typeof navigator`.
5
- // On a web bundle, Node SSR sees `undefined` for both and the client sees them,
6
- // producing different snapshot values at module load -> hydration mismatch on
7
- // every <StyledText>. `Platform.OS` (from react-native-web) returns "web" in
8
- // both environments, so the value is stable.
5
+ // A web bundle is also evaluated in Node when a host app's HTML shell is
6
+ // rendered at export time, where both are `undefined` while the browser sees
7
+ // them — that produces different module-load snapshots and a hydration
8
+ // mismatch on every <StyledText>. `Platform.OS` (from react-native-web)
9
+ // returns "web" in both environments, so the value is stable.
9
10
  const isWebRuntime = Platform.OS === "web";
10
11
  const isReactNativeRuntime = Platform.OS !== "web";
11
12
  // Georgia ships as one face per platform; every weight slot points at the
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Viewport the first web render is computed from, before the real window can
3
+ * be read. Desktop is chosen because:
4
+ * 1. A desktop visitor sees no reflow — their real viewport lands in the
5
+ * same breakpoint.
6
+ * 2. A mobile visitor gets one frame of desktop-styled content before the
7
+ * post-mount effect snaps to real dimensions — better than the inverse
8
+ * (every desktop visitor seeing mobile-tiny, then snapping).
9
+ * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
10
+ * value the HTML shell `expo export` renders in Node is built from, so
11
+ * the browser's first render agrees with the markup it hydrates.
12
+ */
13
+ export declare const DEFAULT_VIEWPORT_WIDTH = 1280;
14
+ export declare const DEFAULT_VIEWPORT_HEIGHT = 800;
1
15
  export declare const SCREEN_SIZES: {
2
16
  readonly SMALL: 768;
3
17
  readonly MEDIUM: 1000;
@@ -15,15 +29,13 @@ type WindowDimensions = {
15
29
  * Provides a consistent way to access window dimensions and screen size
16
30
  * information across mobile and web.
17
31
  *
18
- * On web SSR, the initial width comes from `SsrViewportContext`, which a
19
- * route's loader can populate from a `mrmeg-vw` cookie (precise, from
20
- * previous visit) or User-Agent heuristics. Both server and the initial
21
- * client render read the same context value so hydration matches; after
22
- * mount, real dimensions take over and the cookie is updated for next time.
23
- *
24
- * Routes that don't provide the context get the package default
25
- * (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
26
- * frame of desktop layout before snapping.
32
+ * On web the first render can't read the window: server-side there is no DOM,
33
+ * and reading it during the browser's first render would disagree with the
34
+ * markup it hydrates. The width comes from `SsrViewportContext` when the host
35
+ * provides a per-request value (server render + first client render must
36
+ * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
37
+ * A post-mount effect swaps in the real viewport and follows resize from
38
+ * there.
27
39
  */
28
40
  export declare const useDimensions: () => WindowDimensions;
29
41
  export {};
@@ -1,6 +1,20 @@
1
1
  import { use, useEffect, useState } from "react";
2
2
  import { Platform, useWindowDimensions } from "react-native";
3
- import { SsrViewportContext, SSR_VIEWPORT_DEFAULT_HEIGHT, } from "../state/SsrViewportContext.js";
3
+ import { SsrViewportContext } from "../state/SsrViewportContext.js";
4
+ /**
5
+ * Viewport the first web render is computed from, before the real window can
6
+ * be read. Desktop is chosen because:
7
+ * 1. A desktop visitor sees no reflow — their real viewport lands in the
8
+ * same breakpoint.
9
+ * 2. A mobile visitor gets one frame of desktop-styled content before the
10
+ * post-mount effect snaps to real dimensions — better than the inverse
11
+ * (every desktop visitor seeing mobile-tiny, then snapping).
12
+ * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
13
+ * value the HTML shell `expo export` renders in Node is built from, so
14
+ * the browser's first render agrees with the markup it hydrates.
15
+ */
16
+ export const DEFAULT_VIEWPORT_WIDTH = 1280;
17
+ export const DEFAULT_VIEWPORT_HEIGHT = 800;
4
18
  export const SCREEN_SIZES = {
5
19
  SMALL: 768,
6
20
  MEDIUM: 1000,
@@ -21,9 +35,9 @@ const calculateDimensionFlags = (width, height) => {
21
35
  };
22
36
  };
23
37
  // Persist the real viewport width as a cookie on first mount so subsequent
24
- // SSR requests can render with the user's actual layout (no flash on repeat
25
- // visits). The server reads this cookie via your route loader using
26
- // `detectSsrViewportWidth(request)` from server/lib/ssrViewport.ts.
38
+ // SSR requests can render at the user's actual layout (no reflow on repeat
39
+ // visits). The server reads this cookie via resolveSsrViewportWidth in
40
+ // server/lib/ssrViewport.ts.
27
41
  const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
28
42
  const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
29
43
  function writeViewportCookie(width) {
@@ -38,15 +52,13 @@ function writeViewportCookie(width) {
38
52
  * Provides a consistent way to access window dimensions and screen size
39
53
  * information across mobile and web.
40
54
  *
41
- * On web SSR, the initial width comes from `SsrViewportContext`, which a
42
- * route's loader can populate from a `mrmeg-vw` cookie (precise, from
43
- * previous visit) or User-Agent heuristics. Both server and the initial
44
- * client render read the same context value so hydration matches; after
45
- * mount, real dimensions take over and the cookie is updated for next time.
46
- *
47
- * Routes that don't provide the context get the package default
48
- * (`SSR_VIEWPORT_DEFAULT_WIDTH` = 1280) — desktop-correct, mobile gets one
49
- * frame of desktop layout before snapping.
55
+ * On web the first render can't read the window: server-side there is no DOM,
56
+ * and reading it during the browser's first render would disagree with the
57
+ * markup it hydrates. The width comes from `SsrViewportContext` when the host
58
+ * provides a per-request value (server render + first client render must
59
+ * provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
60
+ * A post-mount effect swaps in the real viewport and follows resize from
61
+ * there.
50
62
  */
51
63
  export const useDimensions = () => {
52
64
  const isWeb = Platform.OS === "web";
@@ -54,13 +66,12 @@ export const useDimensions = () => {
54
66
  // Native reads come from useWindowDimensions, which subscribes to rotation /
55
67
  // split-screen / resize and tears the listener down for us — no manual
56
68
  // Dimensions.addEventListener to leak. On web we ignore it and drive layout
57
- // from the SSR context + the resize listener below so hydration stays exact.
69
+ // from the seeded frame + the resize listener below.
58
70
  const native = useWindowDimensions();
59
- // Lazy initializer: both server and client first render compute identical
60
- // flags from the context value, so hydration matches.
61
- const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth, SSR_VIEWPORT_DEFAULT_HEIGHT));
71
+ const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
62
72
  // Web: read the real viewport after mount and follow resize events. Keeping
63
- // this in an effect (not render) preserves the SSR-matched first paint.
73
+ // this in an effect (not render) keeps the first render identical to the
74
+ // exported markup it hydrates.
64
75
  useEffect(() => {
65
76
  if (!isWeb)
66
77
  return;
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Registers the Feather icon font in expo-font's per-request SSR store.
2
+ * Registers the Feather icon font in expo-font's server store during
3
+ * export-time rendering (`expo export` renders every route's HTML shell in
4
+ * Node, where the module-scope client load above is guarded off).
3
5
  *
4
- * Must be called from a **render body** on server-web (see `useResources`
5
- * below): expo-font enters an `AsyncLocalStorage` store per request, so a
6
- * module-scope registration lands in at most one request's store. Registering
7
- * during render means every request emits the `@font-face` rule into
8
- * `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
9
- * the client's first render.
6
+ * Must be called from a **render body** (see `useResources` below): expo-font
7
+ * scopes its server store per render context, so a module-scope registration
8
+ * lands in at most one route's shell. Registering during render means every
9
+ * exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
10
+ * without it the shell contains an EMPTY icon text node while the client's
11
+ * first render (font pre-loaded above) contains the glyph, and React throws
12
+ * hydration error #418 on every cold load and regenerates the tree.
10
13
  *
11
14
  * No-op on native and on client web (both already covered: native by the
12
15
  * effect below, client web by the module-scope call above).
@@ -4,31 +4,29 @@ import Feather from "@expo/vector-icons/Feather";
4
4
  import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
5
5
  import { Platform } from "react-native";
6
6
  import { useThemeStore } from "../state/themeStore.js";
7
- // Eager, module-scope load that primes the Feather font on the *client* before
8
- // first render: on web it synchronously injects the @font-face rule, which is
9
- // what makes the client's `Font.isLoaded("feather")` check read true during
10
- // hydration; on native it just starts the fetch ahead of the effect below
11
- // (which then no-ops for the already-loaded font).
7
+ // Eager, module-scope load that primes the Feather font before first render:
8
+ // on web it synchronously injects the @font-face rule, so icons have their
9
+ // glyphs on the first paint; on native it just starts the fetch ahead of the
10
+ // effect below (which then no-ops for the already-loaded font).
12
11
  //
13
- // Deliberately guarded off server-web. expo-font's SSR store is per-request
14
- // AsyncLocalStorage, and Metro's inlineRequires defers this module's evaluation
15
- // until it is first required during a render — so on the server this line only
16
- // ever populated the store of whichever request happened to warm the module
17
- // cache. Requests 2+ then shipped HTML with no @font-face and empty icons while
18
- // the client rendered glyphs (React #418). Server registration happens
19
- // per-request in ensureIconFontRegistered() instead.
12
+ // Guarded on `window` because web bundles are also evaluated in Node when
13
+ // `expo export` renders the HTML shell, where there is no document to inject
14
+ // into.
20
15
  if (typeof window !== "undefined") {
21
16
  void Font.loadAsync(Feather.font);
22
17
  }
23
18
  /**
24
- * Registers the Feather icon font in expo-font's per-request SSR store.
19
+ * Registers the Feather icon font in expo-font's server store during
20
+ * export-time rendering (`expo export` renders every route's HTML shell in
21
+ * Node, where the module-scope client load above is guarded off).
25
22
  *
26
- * Must be called from a **render body** on server-web (see `useResources`
27
- * below): expo-font enters an `AsyncLocalStorage` store per request, so a
28
- * module-scope registration lands in at most one request's store. Registering
29
- * during render means every request emits the `@font-face` rule into
30
- * `<style id="expo-generated-fonts">` and server-rendered `<Icon>`s agree with
31
- * the client's first render.
23
+ * Must be called from a **render body** (see `useResources` below): expo-font
24
+ * scopes its server store per render context, so a module-scope registration
25
+ * lands in at most one route's shell. Registering during render means every
26
+ * exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
27
+ * without it the shell contains an EMPTY icon text node while the client's
28
+ * first render (font pre-loaded above) contains the glyph, and React throws
29
+ * hydration error #418 on every cold load and regenerates the tree.
32
30
  *
33
31
  * No-op on native and on client web (both already covered: native by the
34
32
  * effect below, client web by the module-scope call above).
@@ -37,9 +35,9 @@ export function ensureIconFontRegistered() {
37
35
  if (Platform.OS !== "web" || typeof window !== "undefined")
38
36
  return;
39
37
  if (Font.isLoaded("feather"))
40
- return; // per-request store; cheap
38
+ return;
41
39
  // Synchronous on server-web (expo-font routes to registerStaticFont). No
42
- // `void`/try-catch: a throw here means we're outside a request scope, which
40
+ // `void`/try-catch: a throw here means we're outside a render scope, which
43
41
  // must be loud.
44
42
  Font.loadAsync(Feather.font);
45
43
  }
@@ -96,9 +94,8 @@ function ensureWebFontStylesheet() {
96
94
  * un-download Inter. The Feather icon font always loads.
97
95
  */
98
96
  export const useResources = () => {
99
- // Render-body (not effect) so server-web registers the icon font in *this*
100
- // request's expo-font store — effects never run on the server and a
101
- // module-scope call only reaches one request. See ensureIconFontRegistered.
97
+ // Export-time renders need the icon font registered per render scope; the
98
+ // module-scope call only reaches the client. See ensureIconFontRegistered.
102
99
  ensureIconFontRegistered();
103
100
  const [loaded, setLoaded] = useState(false);
104
101
  const [error, setError] = useState(null);
@@ -39,6 +39,24 @@ export declare function useTheme(): ExtendedColorScheme & {
39
39
  setTheme: (theme: "system" | "light" | "dark") => void;
40
40
  currentTheme: "system" | "light" | "dark";
41
41
  };
42
+ /**
43
+ * Generates an alpha-modified version of a color
44
+ * @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
45
+ * @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
46
+ * @returns rgba color string with the specified alpha
47
+ *
48
+ * On web, theme colors are `var(--c-*)` references. Those rewrite onto the
49
+ * companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
50
+ * result is pure CSS that re-themes with the variable — usable from
51
+ * module-scope style factories, which run once for both schemes.
52
+ *
53
+ * The result is wrapped in `var(--c-unset, …)` — a custom property that is
54
+ * never defined, so browsers always use the fallback. react-native-web's
55
+ * style compiler only passes a color value through verbatim when it starts
56
+ * with `var(`; a bare `rgba(var(…))` fails its color parser and the
57
+ * declaration is silently dropped.
58
+ */
59
+ export declare function withAlpha(color: string, alpha: number): string;
42
60
  /**
43
61
  * Style factory context passed to the createStyles callback
44
62
  */
@@ -1,9 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo } from "react";
2
- import { colors } from "../constants/colors.js";
2
+ import { colors, resolveRawColor } from "../constants/colors.js";
3
3
  import { Platform, StyleSheet } from "react-native";
4
4
  import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
5
  import { useThemeColorScope } from "../state/themeColorScope.js";
6
- import { useSsrThemeSeed } from "../state/ssrTheme.js";
7
6
  import { spacing as spacingConstants } from "../constants/spacing.js";
8
7
  // Module-level cache for contrast calculations to avoid memory leak
9
8
  // and share across components
@@ -46,24 +45,11 @@ function getCachedOrCompute(key, compute) {
46
45
  * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
47
46
  */
48
47
  export function useTheme() {
49
- const storeUserTheme = useThemeStore((s) => s.userTheme);
50
- const storeSystemTheme = useThemeStore((s) => s.systemTheme);
51
- const hasLoadedTheme = useThemeStore((s) => s.hasLoadedTheme);
48
+ const userTheme = useThemeStore((s) => s.userTheme);
49
+ const systemTheme = useThemeStore((s) => s.systemTheme);
52
50
  const setTheme = useThemeStore((s) => s.setTheme);
53
51
  const colorOverrides = useThemeStore((s) => s.colorOverrides);
54
52
  const scoped = useThemeColorScope();
55
- const seed = useSsrThemeSeed();
56
- // Before persistence has been read, a web render must resolve from the
57
- // per-request SSR seed instead of store state. The store is a module
58
- // singleton shared across concurrent SSR requests, so it can only ever hold
59
- // the SSR-safe boot defaults ("system"/light); the seed is cookie-derived,
60
- // which is the one signal the server and the browser read identically —
61
- // that's what keeps the hydrated tree matching the server HTML. After mount
62
- // `syncThemeFromEnvironment()` flips `hasLoadedTheme` and store state (real
63
- // localStorage/AsyncStorage + live OS listener) takes over for good.
64
- const useSeed = Platform.OS === "web" && !hasLoadedTheme;
65
- const userTheme = useSeed ? seed.userTheme : storeUserTheme;
66
- const systemTheme = useSeed ? seed.systemTheme : storeSystemTheme;
67
53
  // Determine which theme to use (user preference or system)
68
54
  const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
69
55
  const base = colors[effectiveScheme];
@@ -184,10 +170,12 @@ export function useTheme() {
184
170
  boxShadow: `0 0 0 ${offset}px ${theme.colors.background}, 0 0 0 ${offset + 2}px ${theme.colors.ring}`,
185
171
  };
186
172
  }, [theme.colors.background, theme.colors.ring]);
187
- // Helper to calculate contrast ratio between two colors
173
+ // Helper to calculate contrast ratio between two colors.
174
+ // On web theme colors are `var(--c-*)` strings, which cannot be parsed —
175
+ // resolve them to the active scheme's literal value first.
188
176
  const getContrastRatio = useCallback((color1, color2) => {
189
- const rgb1 = parseColor(color1);
190
- const rgb2 = parseColor(color2);
177
+ const rgb1 = parseColor(resolveRawColor(color1, effectiveScheme));
178
+ const rgb2 = parseColor(resolveRawColor(color2, effectiveScheme));
191
179
  if (!rgb1 || !rgb2) {
192
180
  console.error("Invalid colors for contrast ratio:", color1, color2);
193
181
  return 1; // Default to minimum contrast
@@ -195,13 +183,25 @@ export function useTheme() {
195
183
  const lum1 = calculateLuminance(rgb1[0], rgb1[1], rgb1[2]);
196
184
  const lum2 = calculateLuminance(rgb2[0], rgb2[1], rgb2[2]);
197
185
  return calculateContrastRatio(lum1, lum2);
198
- }, []);
186
+ }, [effectiveScheme]);
199
187
  // Cached version of getContrastingColor for performance
200
- // Uses module-level cache to prevent memory leak
188
+ // Uses module-level cache to prevent memory leak. `var(--c-*)` inputs are
189
+ // resolved per scheme BEFORE the cache so keys stay scheme-specific, and
190
+ // the winning candidate is returned as originally passed (a `var()` input
191
+ // stays a `var()` output, so the picked color keeps re-theming via CSS).
201
192
  const getCachedContrastingColor = useCallback((backgroundColor, color1, color2) => {
202
- const cacheKey = `${backgroundColor}-${color1}-${color2}`;
203
- return getCachedOrCompute(cacheKey, () => getBetterContrast(backgroundColor, color1, color2));
204
- }, []);
193
+ const bg = resolveRawColor(backgroundColor, effectiveScheme);
194
+ const c1 = color1 === undefined ? undefined : resolveRawColor(color1, effectiveScheme);
195
+ const c2 = color2 === undefined ? undefined : resolveRawColor(color2, effectiveScheme);
196
+ const cacheKey = `${bg}-${c1}-${c2}`;
197
+ const winner = getCachedOrCompute(cacheKey, () => getBetterContrast(bg, c1, c2));
198
+ if (color1 !== undefined && winner === c1)
199
+ return color1;
200
+ if (color2 !== undefined && winner === c2)
201
+ return color2;
202
+ return winner;
203
+ }, [effectiveScheme]);
204
+ const getResolvedTextColorForBackground = useCallback((backgroundColor) => getTextColorForBackground(resolveRawColor(backgroundColor, effectiveScheme)), [effectiveScheme]);
205
205
  return useMemo(() => ({
206
206
  theme,
207
207
  scheme: theme.dark ? "dark" : "light",
@@ -211,13 +211,14 @@ export function useTheme() {
211
211
  setTheme,
212
212
  currentTheme: userTheme,
213
213
  getContrastingColor: getCachedContrastingColor,
214
- getTextColorForBackground,
214
+ getTextColorForBackground: getResolvedTextColorForBackground,
215
215
  withAlpha,
216
216
  getContrastRatio,
217
217
  }), [
218
218
  getCachedContrastingColor,
219
219
  getContrastRatio,
220
220
  getFocusRingStyle,
221
+ getResolvedTextColorForBackground,
221
222
  getShadowStyle,
222
223
  setTheme,
223
224
  theme,
@@ -340,11 +341,26 @@ function getTextColorForBackground(backgroundColor) {
340
341
  }
341
342
  /**
342
343
  * Generates an alpha-modified version of a color
343
- * @param color - The color to modify (hex or rgba)
344
- * @param alpha - Alpha value between 0 and 1
344
+ * @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
345
+ * @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
345
346
  * @returns rgba color string with the specified alpha
347
+ *
348
+ * On web, theme colors are `var(--c-*)` references. Those rewrite onto the
349
+ * companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
350
+ * result is pure CSS that re-themes with the variable — usable from
351
+ * module-scope style factories, which run once for both schemes.
352
+ *
353
+ * The result is wrapped in `var(--c-unset, …)` — a custom property that is
354
+ * never defined, so browsers always use the fallback. react-native-web's
355
+ * style compiler only passes a color value through verbatim when it starts
356
+ * with `var(`; a bare `rgba(var(…))` fails its color parser and the
357
+ * declaration is silently dropped.
346
358
  */
347
- function withAlpha(color, alpha) {
359
+ export function withAlpha(color, alpha) {
360
+ const varMatch = /^var\((--c-[a-z0-9-]+)\)$/.exec(color);
361
+ if (varMatch) {
362
+ return `var(--c-unset, rgba(var(${varMatch[1]}-rgb), ${alpha}))`;
363
+ }
348
364
  const rgb = parseColor(color);
349
365
  if (!rgb) {
350
366
  console.error("Invalid color for alpha:", color);
@@ -1,17 +1,17 @@
1
1
  import { type Theme } from "../constants/colors";
2
2
  /**
3
3
  * Registers theme-dependent styles at module scope so react-native-web
4
- * inserts their CSS rules into the server-side sheet before the document
5
- * <head> is serialized.
4
+ * inserts their CSS rules into the sheet before the prerendered document
5
+ * <head> is serialized at export time.
6
6
  *
7
7
  * Styles created lazily during render — the
8
8
  * `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
9
9
  * RNW's sheet *after* expo-router has already captured the
10
- * `<style id="react-native-stylesheet">` snapshot for the head. The SSR
11
- * HTML then references class names that have no rules, which paints
12
- * completely unstyled until hydration re-inserts them client-side (worst
13
- * on the first request after a server cold start, and on every dev
14
- * rebundle). See docs/ssr-hydration.md.
10
+ * `<style id="react-native-stylesheet">` snapshot for the head. The exported
11
+ * HTML shell then references class names that have no rules, which paints
12
+ * completely unstyled until the client re-inserts them on hydration (worst
13
+ * on a cold load of the shell, and on every dev rebundle). See "Enable
14
+ * Server Output" in docs/server-guide.md.
15
15
  *
16
16
  * Both base themes are computed eagerly at module load. Themes carrying
17
17
  * color overrides (global brand or scoped) are computed once per theme
@@ -1,17 +1,17 @@
1
1
  import { colors } from "../constants/colors.js";
2
2
  /**
3
3
  * Registers theme-dependent styles at module scope so react-native-web
4
- * inserts their CSS rules into the server-side sheet before the document
5
- * <head> is serialized.
4
+ * inserts their CSS rules into the sheet before the prerendered document
5
+ * <head> is serialized at export time.
6
6
  *
7
7
  * Styles created lazily during render — the
8
8
  * `useMemo(() => createStyles(theme), [theme])` idiom — are inserted into
9
9
  * RNW's sheet *after* expo-router has already captured the
10
- * `<style id="react-native-stylesheet">` snapshot for the head. The SSR
11
- * HTML then references class names that have no rules, which paints
12
- * completely unstyled until hydration re-inserts them client-side (worst
13
- * on the first request after a server cold start, and on every dev
14
- * rebundle). See docs/ssr-hydration.md.
10
+ * `<style id="react-native-stylesheet">` snapshot for the head. The exported
11
+ * HTML shell then references class names that have no rules, which paints
12
+ * completely unstyled until the client re-inserts them on hydration (worst
13
+ * on a cold load of the shell, and on every dev rebundle). See "Enable
14
+ * Server Output" in docs/server-guide.md.
15
15
  *
16
16
  * Both base themes are computed eagerly at module load. Themes carrying
17
17
  * color overrides (global brand or scoped) are computed once per theme
@@ -1,41 +1,12 @@
1
- /**
2
- * Default viewport used during SSR and the initial client render. Desktop is
3
- * chosen because:
4
- * 1. Most web traffic for app dashboards / marketing pages skews desktop.
5
- * 2. Desktop visitors see no layout reflow on hydration (SSR layout matches
6
- * their actual viewport's breakpoint).
7
- * 3. Mobile visitors get one frame of desktop-styled content before
8
- * `useDimensions`'s post-mount effect snaps to real dimensions — better
9
- * than the inverse (every desktop visitor sees mobile-tiny then snap).
10
- *
11
- * Projects that skew mobile can override with their own Provider higher in
12
- * the tree, or per-page via a loader that detects from cookie / User-Agent.
13
- */
14
- export declare const SSR_VIEWPORT_DEFAULT_WIDTH = 1280;
15
- export declare const SSR_VIEWPORT_DEFAULT_HEIGHT = 800;
16
1
  /**
17
2
  * Per-request SSR viewport width. Consumed by `useDimensions` to seed the
18
- * initial responsive render so it matches what the user sees.
19
- *
20
- * Set the provider near the top of your tree (typically in a route component
21
- * whose loader detects the viewport from a cookie or User-Agent):
22
- *
23
- * ```tsx
24
- * import { SsrViewportContext } from "@mrmeg/expo-ui/state";
25
- * import { detectSsrViewportWidth } from "@/server/lib/ssrViewport";
26
- *
27
- * export const loader: LoaderFunction<{ ssrViewportWidth?: number }> = (request) => ({
28
- * ssrViewportWidth: detectSsrViewportWidth(request),
29
- * });
30
- *
31
- * export default function Page() {
32
- * const { ssrViewportWidth } = useLoaderData<typeof loader>();
33
- * return (
34
- * <SsrViewportContext.Provider value={ssrViewportWidth ?? SSR_VIEWPORT_DEFAULT_WIDTH}>
35
- * ...
36
- * </SsrViewportContext.Provider>
37
- * );
38
- * }
39
- * ```
3
+ * server render and the browser's FIRST render, so a server that detected a
4
+ * phone lays the tree out at phone width and hydration matches byte-for-byte.
5
+ *
6
+ * `null` (the default, when no provider is mounted) means "no per-request
7
+ * signal" and `useDimensions` falls back to `DEFAULT_VIEWPORT_WIDTH`. The
8
+ * host app is expected to provide the same value on the server and on the
9
+ * client's first render — both derived from signals both sides can read
10
+ * (cookie / User-Agent), never from `window`.
40
11
  */
41
- export declare const SsrViewportContext: import("react").Context<number>;
12
+ export declare const SsrViewportContext: import("react").Context<number | null>;