@mrmeg/expo-ui 0.17.0 → 0.20.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.
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+ import { type StyleProp, type ViewStyle } from "react-native";
3
+ export interface CarouselProps {
4
+ /** Slides to page through. Each child is wrapped in a fixed-width slide. */
5
+ children?: React.ReactNode;
6
+ /**
7
+ * Slide width. Values `<= 1` are a fraction of the carousel's own width —
8
+ * the default `0.85` leaves the next slide peeking. Values `> 1` are
9
+ * absolute pixels.
10
+ * @default 0.85
11
+ */
12
+ itemWidth?: number;
13
+ /** Space between slides. @default spacing.md */
14
+ gap?: number;
15
+ /** Horizontal inset before the first and after the last slide. @default spacing.lg */
16
+ contentPadding?: number;
17
+ /** Render the dot indicators. Auto-hidden for a single slide. @default true */
18
+ showDots?: boolean;
19
+ /** Slide shown on mount. @default 0 */
20
+ initialIndex?: number;
21
+ /** Called with the new active index whenever it changes. */
22
+ onIndexChange?: (index: number) => void;
23
+ /** Snap to slide boundaries. @default true */
24
+ snap?: boolean;
25
+ /** Custom style override for the outer container. */
26
+ style?: StyleProp<ViewStyle>;
27
+ /** Custom style override for the scroll content row. */
28
+ contentContainerStyle?: StyleProp<ViewStyle>;
29
+ /**
30
+ * testID prefix. Emits `<testID>`, `-scroll`, `-item-<i>`, `-dots`,
31
+ * `-dot-<i>`, and `-status`.
32
+ * @default "carousel"
33
+ */
34
+ testID?: string;
35
+ }
36
+ /**
37
+ * Active slide index for a horizontal scroll offset.
38
+ *
39
+ * `interval` is the slide pitch (item width + gap), so page `i` sits at
40
+ * `i * interval` for both the native snap offsets and web's CSS scroll-snap
41
+ * (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
42
+ * overscroll at either end can't report an out-of-range page. Returns `0` for
43
+ * a degenerate interval — which is also the pre-measurement state on the
44
+ * server.
45
+ */
46
+ export declare function getCarouselIndex(offsetX: number, interval: number, count: number): number;
47
+ /** Resolves the `itemWidth` prop against a container width (fraction vs px). */
48
+ export declare function resolveCarouselItemWidth(itemWidth: number, containerWidth: number): number;
49
+ /**
50
+ * Carousel Component
51
+ *
52
+ * Horizontally snapping row of slides with dot indicators and an optional
53
+ * peek of the next slide. Children-based: every child becomes one slide at the
54
+ * resolved item width.
55
+ *
56
+ * Built on `ScrollView` — not a virtualized list — on purpose: every slide is
57
+ * in the server-rendered tree, so web SSR ships the real content instead of an
58
+ * empty scroller that only fills in after measurement. Snapping uses the
59
+ * platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
60
+ * animation library is involved.
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * <Carousel itemWidth={0.8} onIndexChange={setPage}>
65
+ * {testimonials.map((t) => (
66
+ * <Card key={t.name}>
67
+ * <BodyText>{t.quote}</BodyText>
68
+ * </Card>
69
+ * ))}
70
+ * </Carousel>
71
+ * ```
72
+ */
73
+ export declare function Carousel({ children, itemWidth, gap, contentPadding, showDots, initialIndex, onIndexChange, snap, style: styleOverride, contentContainerStyle, testID, }: CarouselProps): React.JSX.Element;
@@ -0,0 +1,184 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+ import { Platform, ScrollView, StyleSheet, Text, View, } from "react-native";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { useDimensions } from "../hooks/useDimensions.js";
6
+ import { spacing } from "../constants/spacing.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
+ // ============================================================================
9
+ // Index math
10
+ // ============================================================================
11
+ /**
12
+ * Active slide index for a horizontal scroll offset.
13
+ *
14
+ * `interval` is the slide pitch (item width + gap), so page `i` sits at
15
+ * `i * interval` for both the native snap offsets and web's CSS scroll-snap
16
+ * (see `scrollPaddingLeft` below). Clamped to `[0, count - 1]` so rubber-band
17
+ * overscroll at either end can't report an out-of-range page. Returns `0` for
18
+ * a degenerate interval — which is also the pre-measurement state on the
19
+ * server.
20
+ */
21
+ export function getCarouselIndex(offsetX, interval, count) {
22
+ if (!Number.isFinite(offsetX) || !Number.isFinite(interval))
23
+ return 0;
24
+ if (interval <= 0 || count <= 0)
25
+ return 0;
26
+ const raw = Math.round(offsetX / interval);
27
+ if (raw < 0)
28
+ return 0;
29
+ if (raw > count - 1)
30
+ return count - 1;
31
+ return raw;
32
+ }
33
+ /** Resolves the `itemWidth` prop against a container width (fraction vs px). */
34
+ export function resolveCarouselItemWidth(itemWidth, containerWidth) {
35
+ if (!Number.isFinite(itemWidth) || itemWidth <= 0)
36
+ return 0;
37
+ if (itemWidth > 1)
38
+ return Math.round(itemWidth);
39
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0)
40
+ return 0;
41
+ return Math.round(containerWidth * itemWidth);
42
+ }
43
+ function clampIndex(index, count) {
44
+ if (count <= 0)
45
+ return 0;
46
+ return Math.min(count - 1, Math.max(0, Math.trunc(index)));
47
+ }
48
+ // ============================================================================
49
+ // Carousel
50
+ // ============================================================================
51
+ /**
52
+ * Carousel Component
53
+ *
54
+ * Horizontally snapping row of slides with dot indicators and an optional
55
+ * peek of the next slide. Children-based: every child becomes one slide at the
56
+ * resolved item width.
57
+ *
58
+ * Built on `ScrollView` — not a virtualized list — on purpose: every slide is
59
+ * in the server-rendered tree, so web SSR ships the real content instead of an
60
+ * empty scroller that only fills in after measurement. Snapping uses the
61
+ * platform scroller (`snapToInterval` on native, CSS scroll-snap on web); no
62
+ * animation library is involved.
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * <Carousel itemWidth={0.8} onIndexChange={setPage}>
67
+ * {testimonials.map((t) => (
68
+ * <Card key={t.name}>
69
+ * <BodyText>{t.quote}</BodyText>
70
+ * </Card>
71
+ * ))}
72
+ * </Carousel>
73
+ * ```
74
+ */
75
+ export function Carousel({ children, itemWidth = 0.85, gap = spacing.md, contentPadding = spacing.lg, showDots = true, initialIndex = 0, onIndexChange, snap = true, style: styleOverride, contentContainerStyle, testID = "carousel", }) {
76
+ const { theme } = useTheme();
77
+ const styles = themedStyles(theme);
78
+ const items = React.Children.toArray(children);
79
+ const count = items.length;
80
+ // Width source of truth: the measured container after mount, seeded from the
81
+ // viewport so the server and the client's first render agree. `useDimensions`
82
+ // reads the SSR viewport context rather than `window`, which is what keeps
83
+ // that seed hydration-safe (docs/ssr-hydration.md §4).
84
+ const { width: viewportWidth } = useDimensions();
85
+ const [measuredWidth, setMeasuredWidth] = useState(null);
86
+ const containerWidth = measuredWidth ?? viewportWidth;
87
+ const slideWidth = resolveCarouselItemWidth(itemWidth, containerWidth);
88
+ const interval = slideWidth + gap;
89
+ const [activeIndex, setActiveIndex] = useState(() => clampIndex(initialIndex, count));
90
+ // Mirrors activeIndex so the scroll handler can dedupe without re-reading
91
+ // state: one settle emits many scroll events, onIndexChange must fire once.
92
+ const activeIndexRef = useRef(activeIndex);
93
+ const scrollRef = useRef(null);
94
+ const hasScrolledToInitial = useRef(false);
95
+ const commitIndex = useCallback((next) => {
96
+ if (next === activeIndexRef.current)
97
+ return;
98
+ activeIndexRef.current = next;
99
+ setActiveIndex(next);
100
+ onIndexChange?.(next);
101
+ }, [onIndexChange]);
102
+ const handleScroll = useCallback((event) => {
103
+ commitIndex(getCarouselIndex(event.nativeEvent.contentOffset.x, interval, count));
104
+ }, [commitIndex, count, interval]);
105
+ const handleLayout = useCallback((event) => {
106
+ const next = event.nativeEvent.layout.width;
107
+ if (!(next > 0))
108
+ return;
109
+ setMeasuredWidth((current) => current !== null && Math.abs(current - next) < 1 ? current : next);
110
+ }, []);
111
+ // Jump to initialIndex once, after the first real layout — the seeded
112
+ // viewport width would put the offset in the wrong place. `contentOffset` is
113
+ // iOS-only, so drive it imperatively for Android/web parity.
114
+ const targetIndex = clampIndex(initialIndex, count);
115
+ useEffect(() => {
116
+ if (hasScrolledToInitial.current || measuredWidth === null || interval <= 0)
117
+ return;
118
+ hasScrolledToInitial.current = true;
119
+ if (targetIndex === 0)
120
+ return;
121
+ scrollRef.current?.scrollTo({ x: targetIndex * interval, animated: false });
122
+ }, [interval, measuredWidth, targetIndex]);
123
+ const isWeb = Platform.OS === "web";
124
+ const useCssSnap = isWeb && snap;
125
+ const dotsVisible = showDots && count > 1;
126
+ return (_jsxs(View, { style: [styles.container, styleOverride], onLayout: handleLayout, testID: testID, children: [_jsx(ScrollView, { ref: scrollRef, testID: `${testID}-scroll`, horizontal: true, showsHorizontalScrollIndicator: false,
127
+ // Native snapping. react-native-web drops these (they aren't in its
128
+ // forwarded-prop list), so web uses the CSS scroll-snap styles instead.
129
+ snapToInterval: snap && interval > 0 ? interval : undefined, snapToAlignment: "start", decelerationRate: snap ? "fast" : "normal",
130
+ // react-native-web only emits scroll ticks when the throttle is > 0 and
131
+ // never emits momentum events — this is what keeps the dots live for
132
+ // wheel/trackpad scrolling.
133
+ scrollEventThrottle: 16, onScroll: handleScroll, onMomentumScrollEnd: handleScroll, style: useCssSnap
134
+ ? [webStyles.scroller, { scrollPaddingLeft: contentPadding }]
135
+ : undefined, contentContainerStyle: [{ paddingHorizontal: contentPadding, gap }, contentContainerStyle], children: items.map((child, index) => (_jsx(View, { testID: `${testID}-item-${index}`, style: [slideWidth > 0 && { width: slideWidth }, useCssSnap && webStyles.item], children: child }, React.isValidElement(child) && child.key != null ? child.key : index))) }), count > 1 && (_jsx(View, { testID: `${testID}-status`, style: styles.status, "aria-live": "polite", children: _jsx(Text, { style: styles.statusText, children: `${activeIndex + 1} of ${count}` }) })), dotsVisible && (_jsx(View, { testID: `${testID}-dots`, style: styles.dots, accessibilityRole: "tablist", children: items.map((_, index) => (_jsx(View, { testID: `${testID}-dot-${index}`, accessibilityRole: "tab", accessibilityState: { selected: index === activeIndex }, accessibilityLabel: `Slide ${index + 1} of ${count}`, style: [styles.dot, index === activeIndex ? styles.dotActive : styles.dotInactive] }, index))) }))] }));
136
+ }
137
+ // ============================================================================
138
+ // Styles
139
+ // ============================================================================
140
+ const DOT_SIZE = 8;
141
+ // Web-only CSS scroll-snap. `scrollPaddingLeft` is applied inline at the call
142
+ // site (it tracks the contentPadding prop) so the leading inset survives the
143
+ // browser snapping slide 0 to the scrollport start — which also makes page `i`
144
+ // land on `i * interval`, matching the native snap offsets.
145
+ const webStyles = StyleSheet.create({
146
+ scroller: { scrollSnapType: "x mandatory" },
147
+ item: { scrollSnapAlign: "start" },
148
+ });
149
+ const createStyles = (theme) => StyleSheet.create({
150
+ container: {
151
+ width: "100%",
152
+ },
153
+ // Clipped to 1x1 rather than removed: screen readers skip
154
+ // display:none/opacity:0 nodes, and there is no visual affordance to show.
155
+ status: {
156
+ position: "absolute",
157
+ width: 1,
158
+ height: 1,
159
+ overflow: "hidden",
160
+ },
161
+ statusText: {
162
+ fontSize: 1,
163
+ color: "transparent",
164
+ },
165
+ dots: {
166
+ flexDirection: "row",
167
+ alignItems: "center",
168
+ justifyContent: "center",
169
+ gap: spacing.xs,
170
+ marginTop: spacing.md,
171
+ },
172
+ dot: {
173
+ width: DOT_SIZE,
174
+ height: DOT_SIZE,
175
+ borderRadius: spacing.radiusFull,
176
+ },
177
+ dotActive: {
178
+ backgroundColor: theme.colors.accent,
179
+ },
180
+ dotInactive: {
181
+ backgroundColor: theme.colors.border,
182
+ },
183
+ });
184
+ const themedStyles = createThemedStyles(createStyles);
@@ -395,7 +395,9 @@ function DrawerOverlayContent({ swipeEnabled = true, swipeThreshold = 0.3, veloc
395
395
  bottom: 0,
396
396
  width,
397
397
  [side]: 0,
398
- backgroundColor: theme.colors.background,
398
+ // App chrome sits on the sunken tier so it reads as a layer beneath the
399
+ // content pane rather than the same surface.
400
+ backgroundColor: theme.colors.surfaceSunken,
399
401
  borderColor: theme.colors.border,
400
402
  ...(side === "left"
401
403
  ? { borderRightWidth: 1 }
@@ -502,7 +504,8 @@ swipeEnabled: _swipeEnabled, swipeThreshold: _swipeThreshold, velocityThreshold:
502
504
  const panelStyle = {
503
505
  width: Platform.OS === "web" ? targetWidth : widthAnim,
504
506
  overflow: "hidden",
505
- backgroundColor: theme.colors.background,
507
+ // Rail chrome sits on the sunken tier, one step below the content pane.
508
+ backgroundColor: theme.colors.surfaceSunken,
506
509
  borderColor: theme.colors.border,
507
510
  ...(side === "left" ? { borderRightWidth: 1 } : { borderLeftWidth: 1 }),
508
511
  paddingTop: insets.top,
@@ -1,10 +1,12 @@
1
1
  export * from "./Accordion";
2
2
  export * from "./Alert";
3
3
  export * from "./AnimatedView";
4
+ export * from "./Avatar";
4
5
  export * from "./Badge";
5
6
  export * from "./BottomSheet";
6
7
  export * from "./Button";
7
8
  export * from "./Card";
9
+ export * from "./Carousel";
8
10
  export * from "./Checkbox";
9
11
  export * from "./Collapsible";
10
12
  export * from "./Dialog";
@@ -1,10 +1,12 @@
1
1
  export * from "./Accordion.js";
2
2
  export * from "./Alert.js";
3
3
  export * from "./AnimatedView.js";
4
+ export * from "./Avatar.js";
4
5
  export * from "./Badge.js";
5
6
  export * from "./BottomSheet.js";
6
7
  export * from "./Button.js";
7
8
  export * from "./Card.js";
9
+ export * from "./Carousel.js";
8
10
  export * from "./Checkbox.js";
9
11
  export * from "./Collapsible.js";
10
12
  export * from "./Dialog.js";
@@ -22,11 +22,13 @@ declare const palette: {
22
22
  readonly gray900: "#18181B";
23
23
  readonly gray950: "#09090B";
24
24
  readonly black: "#000000";
25
+ readonly dark950: "#050506";
25
26
  readonly dark900: "#09090B";
26
27
  readonly dark800: "#18181B";
27
28
  readonly dark700: "#27272A";
28
29
  readonly dark600: "#3F3F46";
29
30
  readonly dark400: "#A1A1AA";
31
+ readonly dark350: "#B0B0B8";
30
32
  readonly dark300: "#D4D4D8";
31
33
  readonly dark100: "#F4F4F5";
32
34
  readonly green500: "#22C55E";
@@ -40,6 +42,7 @@ declare const palette: {
40
42
  * Semantic color interface - what colors mean in the UI
41
43
  */
42
44
  export interface ThemeColors {
45
+ surfaceSunken: string;
43
46
  background: string;
44
47
  foreground: string;
45
48
  card: string;
@@ -61,6 +64,7 @@ export interface ThemeColors {
61
64
  success: string;
62
65
  warning: string;
63
66
  border: string;
67
+ borderStrong: string;
64
68
  input: string;
65
69
  ring: string;
66
70
  overlay: string;
@@ -25,11 +25,15 @@ const palette = {
25
25
  gray950: "#09090B",
26
26
  black: "#000000",
27
27
  // Dark mode surfaces — zinc-based
28
+ // `dark950` sits below zinc-950 as the app-chrome tier; `dark350` is a
29
+ // zinc-350 midpoint used for dim text that stays comfortably readable.
30
+ dark950: "#050506",
28
31
  dark900: "#09090B",
29
32
  dark800: "#18181B",
30
33
  dark700: "#27272A",
31
34
  dark600: "#3F3F46",
32
35
  dark400: "#A1A1AA",
36
+ dark350: "#B0B0B8",
33
37
  dark300: "#D4D4D8",
34
38
  dark100: "#F4F4F5",
35
39
  // Status
@@ -44,6 +48,7 @@ const lightTheme = {
44
48
  dark: false,
45
49
  fonts: navigationFonts,
46
50
  colors: {
51
+ surfaceSunken: palette.gray50,
47
52
  background: palette.white,
48
53
  foreground: palette.gray950,
49
54
  card: palette.white,
@@ -51,13 +56,13 @@ const lightTheme = {
51
56
  popover: palette.white,
52
57
  popoverForeground: palette.gray950,
53
58
  text: palette.gray950,
54
- textDim: palette.gray500,
59
+ textDim: palette.gray600,
55
60
  primary: palette.gray900,
56
61
  primaryForeground: palette.gray50,
57
62
  secondary: palette.gray100,
58
63
  secondaryForeground: palette.gray900,
59
64
  muted: palette.gray100,
60
- mutedForeground: palette.gray500,
65
+ mutedForeground: palette.gray600,
61
66
  accent: palette.teal500,
62
67
  accentForeground: palette.white,
63
68
  destructive: palette.red500,
@@ -65,6 +70,7 @@ const lightTheme = {
65
70
  success: palette.green500,
66
71
  warning: palette.amber500,
67
72
  border: palette.gray200,
73
+ borderStrong: palette.gray300,
68
74
  input: palette.gray200,
69
75
  ring: palette.gray400,
70
76
  overlay: "rgba(0, 0, 0, 0.5)",
@@ -82,6 +88,7 @@ const darkTheme = {
82
88
  dark: true,
83
89
  fonts: navigationFonts,
84
90
  colors: {
91
+ surfaceSunken: palette.dark950,
85
92
  background: palette.dark900,
86
93
  foreground: palette.dark100,
87
94
  card: palette.dark800,
@@ -89,13 +96,13 @@ const darkTheme = {
89
96
  popover: palette.dark800,
90
97
  popoverForeground: palette.dark100,
91
98
  text: palette.dark100,
92
- textDim: palette.dark400,
99
+ textDim: palette.dark350,
93
100
  primary: palette.gray50,
94
101
  primaryForeground: palette.gray900,
95
102
  secondary: palette.gray800,
96
103
  secondaryForeground: palette.gray50,
97
104
  muted: palette.dark700,
98
- mutedForeground: palette.dark400,
105
+ mutedForeground: palette.dark350,
99
106
  accent: palette.teal400,
100
107
  accentForeground: palette.gray900,
101
108
  destructive: palette.red400,
@@ -103,6 +110,7 @@ const darkTheme = {
103
110
  success: palette.green400,
104
111
  warning: palette.amber400,
105
112
  border: palette.dark700,
113
+ borderStrong: palette.dark600,
106
114
  input: palette.dark700,
107
115
  ring: palette.dark400,
108
116
  overlay: "rgba(0, 0, 0, 0.7)",
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Registers the Feather icon font in expo-font's per-request SSR store.
3
+ *
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.
10
+ *
11
+ * No-op on native and on client web (both already covered: native by the
12
+ * effect below, client web by the module-scope call above).
13
+ */
14
+ export declare function ensureIconFontRegistered(): void;
1
15
  interface LoadResourcesResult {
2
16
  loaded: boolean;
3
17
  error: Error | null;
@@ -4,15 +4,45 @@ 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 so expo-font registers Feather in its SSR
8
- // serverContext. When the server renders the HTML document, expo-font emits
9
- // an @font-face <style> into the head — without this kickoff, server-rendered
10
- // <Icon> components paint as empty glyphs until hydration runs the effect
11
- // below, producing a visible icon-pop flash on icon-heavy SSR screens
12
- // (e.g. OnboardingFlow). On the client this also primes the font ahead of
13
- // the effect; the effect's loadAsync becomes a no-op for the already-loaded
14
- // font.
15
- void Font.loadAsync(Feather.font);
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).
12
+ //
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.
20
+ if (typeof window !== "undefined") {
21
+ void Font.loadAsync(Feather.font);
22
+ }
23
+ /**
24
+ * Registers the Feather icon font in expo-font's per-request SSR store.
25
+ *
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.
32
+ *
33
+ * No-op on native and on client web (both already covered: native by the
34
+ * effect below, client web by the module-scope call above).
35
+ */
36
+ export function ensureIconFontRegistered() {
37
+ if (Platform.OS !== "web" || typeof window !== "undefined")
38
+ return;
39
+ if (Font.isLoaded("feather"))
40
+ return; // per-request store; cheap
41
+ // 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
43
+ // must be loud.
44
+ Font.loadAsync(Feather.font);
45
+ }
16
46
  // The four static Inter weights StyledText's native family keys point at
17
47
  // (see constants/fonts.ts). Native-only: web never renders these family names
18
48
  // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
@@ -66,6 +96,10 @@ function ensureWebFontStylesheet() {
66
96
  * un-download Inter. The Feather icon font always loads.
67
97
  */
68
98
  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.
102
+ ensureIconFontRegistered();
69
103
  const [loaded, setLoaded] = useState(false);
70
104
  const [error, setError] = useState(null);
71
105
  useEffect(() => {
@@ -3,6 +3,7 @@ import { colors } 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";
6
7
  import { spacing as spacingConstants } from "../constants/spacing.js";
7
8
  // Module-level cache for contrast calculations to avoid memory leak
8
9
  // and share across components
@@ -45,11 +46,24 @@ function getCachedOrCompute(key, compute) {
45
46
  * - getShadowStyle('base') → { boxShadow: "0px 2px 6px rgba(0, 0, 0, 0.05), 0px 4px 12px rgba(0, 0, 0, 0.03)" }
46
47
  */
47
48
  export function useTheme() {
48
- const userTheme = useThemeStore((s) => s.userTheme);
49
- const systemTheme = useThemeStore((s) => s.systemTheme);
49
+ const storeUserTheme = useThemeStore((s) => s.userTheme);
50
+ const storeSystemTheme = useThemeStore((s) => s.systemTheme);
51
+ const hasLoadedTheme = useThemeStore((s) => s.hasLoadedTheme);
50
52
  const setTheme = useThemeStore((s) => s.setTheme);
51
53
  const colorOverrides = useThemeStore((s) => s.colorOverrides);
52
54
  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;
53
67
  // Determine which theme to use (user preference or system)
54
68
  const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
55
69
  const base = colors[effectiveScheme];
@@ -2,4 +2,5 @@ export * from "./globalUIStore";
2
2
  export * from "./notify";
3
3
  export * from "./themeStore";
4
4
  export * from "./themeColorScope";
5
+ export * from "./ssrTheme";
5
6
  export * from "./SsrViewportContext";
@@ -2,4 +2,5 @@ export * from "./globalUIStore.js";
2
2
  export * from "./notify.js";
3
3
  export * from "./themeStore.js";
4
4
  export * from "./themeColorScope.js";
5
+ export * from "./ssrTheme.js";
5
6
  export * from "./SsrViewportContext.js";
@@ -0,0 +1,41 @@
1
+ import type { ResolvedTheme, ThemePreference } from "./themeStore";
2
+ /**
3
+ * The theme values the FIRST render must use on web — server and client alike.
4
+ *
5
+ * `useThemeStore` is a module singleton shared by every concurrent SSR request,
6
+ * so a per-request theme can never be written into it. This context is the
7
+ * per-request channel instead: the host app resolves it from the request (a
8
+ * cookie the client mirrors) and provides it above the tree, and `useTheme`
9
+ * reads it until the store has loaded real persistence after mount.
10
+ */
11
+ export type SsrThemeSeed = {
12
+ userTheme: ThemePreference;
13
+ systemTheme: ResolvedTheme;
14
+ };
15
+ /**
16
+ * What a first-time visitor gets: no persisted preference, light scheme. Same
17
+ * values the store boots with, so a host app that never provides the context
18
+ * behaves exactly as it did before the seed existed.
19
+ */
20
+ export declare const SSR_THEME_SEED_DEFAULT: SsrThemeSeed;
21
+ /**
22
+ * Per-request theme seed consumed by `useTheme`/`useStyles` while
23
+ * `hasLoadedTheme` is false (i.e. on the server render and the client's first
24
+ * render, before `syncThemeFromEnvironment()` reads real persistence).
25
+ *
26
+ * Provide it near the top of the tree from a value BOTH the server and the
27
+ * browser can derive from the same bytes — a cookie. Reading `localStorage`
28
+ * for this would reintroduce the hydration mismatch it exists to remove,
29
+ * because the server cannot see `localStorage`.
30
+ *
31
+ * ```tsx
32
+ * import { SsrThemeSeedContext } from "@mrmeg/expo-ui/state";
33
+ *
34
+ * <SsrThemeSeedContext.Provider value={seedFromCookie}>
35
+ * <App />
36
+ * </SsrThemeSeedContext.Provider>
37
+ * ```
38
+ */
39
+ export declare const SsrThemeSeedContext: import("react").Context<SsrThemeSeed>;
40
+ /** Read the active SSR theme seed (the default when no provider is mounted). */
41
+ export declare function useSsrThemeSeed(): SsrThemeSeed;
@@ -0,0 +1,33 @@
1
+ import { createContext, use } from "react";
2
+ /**
3
+ * What a first-time visitor gets: no persisted preference, light scheme. Same
4
+ * values the store boots with, so a host app that never provides the context
5
+ * behaves exactly as it did before the seed existed.
6
+ */
7
+ export const SSR_THEME_SEED_DEFAULT = {
8
+ userTheme: "system",
9
+ systemTheme: "light",
10
+ };
11
+ /**
12
+ * Per-request theme seed consumed by `useTheme`/`useStyles` while
13
+ * `hasLoadedTheme` is false (i.e. on the server render and the client's first
14
+ * render, before `syncThemeFromEnvironment()` reads real persistence).
15
+ *
16
+ * Provide it near the top of the tree from a value BOTH the server and the
17
+ * browser can derive from the same bytes — a cookie. Reading `localStorage`
18
+ * for this would reintroduce the hydration mismatch it exists to remove,
19
+ * because the server cannot see `localStorage`.
20
+ *
21
+ * ```tsx
22
+ * import { SsrThemeSeedContext } from "@mrmeg/expo-ui/state";
23
+ *
24
+ * <SsrThemeSeedContext.Provider value={seedFromCookie}>
25
+ * <App />
26
+ * </SsrThemeSeedContext.Provider>
27
+ * ```
28
+ */
29
+ export const SsrThemeSeedContext = createContext(SSR_THEME_SEED_DEFAULT);
30
+ /** Read the active SSR theme seed (the default when no provider is mounted). */
31
+ export function useSsrThemeSeed() {
32
+ return use(SsrThemeSeedContext);
33
+ }
@@ -2,6 +2,16 @@ import type { ThemeColors } from "../constants/colors";
2
2
  import type { FontOverrides } from "../constants/fonts";
3
3
  export type ThemePreference = "system" | "light" | "dark";
4
4
  export type ResolvedTheme = "light" | "dark";
5
+ /**
6
+ * Cookie name the web build mirrors the persisted preference into, so a
7
+ * server-rendered host can seed its first render with the visitor's real
8
+ * theme instead of shipping a light tree that recolors after hydration.
9
+ *
10
+ * Same name as the `localStorage` key on purpose: one string to grep, and the
11
+ * blocking color-scheme script in a host's `+html.tsx` already reads it.
12
+ * `localStorage` stays the source of truth; the cookie is a render hint only.
13
+ */
14
+ export declare const THEME_COOKIE_NAME = "user-theme-preference";
5
15
  /**
6
16
  * Per-scheme color overrides a host app can inject to brand the package.
7
17
  *
@@ -44,6 +54,15 @@ export type ShapeOverrides = {
44
54
  export type ThemeStore = {
45
55
  userTheme: ThemePreference;
46
56
  systemTheme: ResolvedTheme;
57
+ /**
58
+ * True once the persisted preference has actually been read (or explicitly
59
+ * set by the user). Until then, a web render must not trust `userTheme` /
60
+ * `systemTheme` — they still hold the SSR-safe boot defaults, and `useTheme`
61
+ * uses `SsrThemeSeedContext` (cookie-derived, identical on server and
62
+ * client) instead. Mirrors `hasLoadedOnboarding` in the host app's
63
+ * onboarding store.
64
+ */
65
+ hasLoadedTheme: boolean;
47
66
  /**
48
67
  * App-injected palette overrides, applied by `useTheme` on top of the
49
68
  * package defaults. Empty by default — zero override means the package