@mrmeg/expo-ui 0.18.0 → 0.21.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.
@@ -5,9 +5,20 @@ export interface LabelProps {
5
5
  */
6
6
  children: string;
7
7
  /**
8
- * Native ID to associate with a form control
8
+ * The label's OWN id. On web react-native-web maps it to `id`; on native it
9
+ * is the `nativeID` a control's `accessibilityLabelledBy` can point at.
10
+ *
11
+ * Must differ from the paired input's id — see `htmlFor`.
9
12
  */
10
13
  nativeID?: string;
14
+ /**
15
+ * WEB ONLY — the id of the input this label describes (the input's
16
+ * `nativeID`). Passing it makes `@rn-primitives/label` render a real
17
+ * `<label for="…">`, which is what associates the two and makes clicking the
18
+ * label focus the input. No-op on native, where association goes the other
19
+ * way (the control points at the label's `nativeID`).
20
+ */
21
+ htmlFor?: string;
11
22
  /**
12
23
  * Whether the field is required (shows asterisk)
13
24
  */
@@ -37,25 +48,34 @@ export interface LabelProps {
37
48
  /**
38
49
  * Label component for form fields using @rn-primitives/label
39
50
  *
40
- * Provides accessible labeling for form controls with automatic
41
- * association via nativeID.
51
+ * Accessible labeling for form controls. Association needs **two distinct
52
+ * ids**: `nativeID` is the label's own id, `htmlFor` is the input's id. Giving
53
+ * both the same value renders two elements with one id on web and associates
54
+ * nothing.
42
55
  *
43
56
  * Usage:
44
57
  * ```tsx
45
- * // Basic label
46
- * <Label nativeID="email-input">Email</Label>
58
+ * // Paired with an input. `htmlFor` (web) makes this a real <label for>, so
59
+ * // clicking it focuses the input; `nativeID` names the label itself, which is
60
+ * // what a control's `accessibilityLabelledBy` points at on native.
61
+ * <Label nativeID="email-label" htmlFor="email-input">Email</Label>
47
62
  * <TextInput nativeID="email-input" />
48
63
  *
49
64
  * // Required field
50
- * <Label nativeID="password" required>Password</Label>
65
+ * <Label nativeID="password-label" htmlFor="password-input" required>Password</Label>
66
+ * <TextInput nativeID="password-input" secureTextEntry />
51
67
  *
52
68
  * // With error state
53
- * <Label nativeID="username" error>Username</Label>
69
+ * <Label nativeID="username-label" htmlFor="username-input" error>Username</Label>
70
+ *
71
+ * // Standalone caption with no control to associate — omit htmlFor
72
+ * <Label>Filters</Label>
54
73
  *
55
- * // With press handler to focus input
56
- * <Label nativeID="search" onPress={() => inputRef.current?.focus()}>
74
+ * // With press handler to focus input (needed on native, where htmlFor is a
75
+ * // no-op and pressing the label does nothing on its own)
76
+ * <Label nativeID="search-label" htmlFor="search-input" onPress={() => inputRef.current?.focus()}>
57
77
  * Search
58
78
  * </Label>
59
79
  * ```
60
80
  */
61
- export declare function Label({ children, nativeID, required, size, error, disabled, style, onPress, }: LabelProps): import("react").JSX.Element;
81
+ export declare function Label({ children, nativeID, htmlFor, required, size, error, disabled, style, onPress, }: LabelProps): import("react").JSX.Element;
@@ -14,28 +14,37 @@ const SIZE_CONFIGS = {
14
14
  /**
15
15
  * Label component for form fields using @rn-primitives/label
16
16
  *
17
- * Provides accessible labeling for form controls with automatic
18
- * association via nativeID.
17
+ * Accessible labeling for form controls. Association needs **two distinct
18
+ * ids**: `nativeID` is the label's own id, `htmlFor` is the input's id. Giving
19
+ * both the same value renders two elements with one id on web and associates
20
+ * nothing.
19
21
  *
20
22
  * Usage:
21
23
  * ```tsx
22
- * // Basic label
23
- * <Label nativeID="email-input">Email</Label>
24
+ * // Paired with an input. `htmlFor` (web) makes this a real <label for>, so
25
+ * // clicking it focuses the input; `nativeID` names the label itself, which is
26
+ * // what a control's `accessibilityLabelledBy` points at on native.
27
+ * <Label nativeID="email-label" htmlFor="email-input">Email</Label>
24
28
  * <TextInput nativeID="email-input" />
25
29
  *
26
30
  * // Required field
27
- * <Label nativeID="password" required>Password</Label>
31
+ * <Label nativeID="password-label" htmlFor="password-input" required>Password</Label>
32
+ * <TextInput nativeID="password-input" secureTextEntry />
28
33
  *
29
34
  * // With error state
30
- * <Label nativeID="username" error>Username</Label>
35
+ * <Label nativeID="username-label" htmlFor="username-input" error>Username</Label>
31
36
  *
32
- * // With press handler to focus input
33
- * <Label nativeID="search" onPress={() => inputRef.current?.focus()}>
37
+ * // Standalone caption with no control to associate — omit htmlFor
38
+ * <Label>Filters</Label>
39
+ *
40
+ * // With press handler to focus input (needed on native, where htmlFor is a
41
+ * // no-op and pressing the label does nothing on its own)
42
+ * <Label nativeID="search-label" htmlFor="search-input" onPress={() => inputRef.current?.focus()}>
34
43
  * Search
35
44
  * </Label>
36
45
  * ```
37
46
  */
38
- export function Label({ children, nativeID, required, size = "md", error, disabled, style, onPress, }) {
47
+ export function Label({ children, nativeID, htmlFor, required, size = "md", error, disabled, style, onPress, }) {
39
48
  const { theme } = useTheme();
40
49
  const styles = themedStyles(theme);
41
50
  const sizeConfig = SIZE_CONFIGS[size];
@@ -53,7 +62,7 @@ export function Label({ children, nativeID, required, size = "md", error, disabl
53
62
  return (_jsx(LabelPrimitive.Root, { nativeID: nativeID, onPress: onPress, style: {
54
63
  ...styles.root,
55
64
  ...(Platform.OS === "web" && onPress && { cursor: "pointer" }),
56
- }, children: _jsxs(LabelPrimitive.Text, { style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
65
+ }, children: _jsxs(LabelPrimitive.Text, { htmlFor: htmlFor, style: textStyle, children: [children, required && _jsx(StyledText, { selectable: false, fontWeight: "bold", style: styles.required, children: " *" })] }) }));
57
66
  }
58
67
  const createStyles = (theme) => StyleSheet.create({
59
68
  root: {
@@ -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)",
@@ -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,11 @@ 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 uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
33
+ * window can't be read while the HTML shell is rendered in Node at export
34
+ * time, and reading it during the browser's first render would disagree with
35
+ * that markup. A post-mount effect swaps in the real viewport and follows
36
+ * resize from there.
27
37
  */
28
38
  export declare const useDimensions: () => WindowDimensions;
29
39
  export {};
@@ -1,6 +1,19 @@
1
- import { use, useEffect, useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { Platform, useWindowDimensions } from "react-native";
3
- import { SsrViewportContext, SSR_VIEWPORT_DEFAULT_HEIGHT, } from "../state/SsrViewportContext.js";
3
+ /**
4
+ * Viewport the first web render is computed from, before the real window can
5
+ * be read. Desktop is chosen because:
6
+ * 1. A desktop visitor sees no reflow — their real viewport lands in the
7
+ * same breakpoint.
8
+ * 2. A mobile visitor gets one frame of desktop-styled content before the
9
+ * post-mount effect snaps to real dimensions — better than the inverse
10
+ * (every desktop visitor seeing mobile-tiny, then snapping).
11
+ * 3. It matches the frame the app hands `SafeAreaProvider`, and it is the
12
+ * value the HTML shell `expo export` renders in Node is built from, so
13
+ * the browser's first render agrees with the markup it hydrates.
14
+ */
15
+ export const DEFAULT_VIEWPORT_WIDTH = 1280;
16
+ export const DEFAULT_VIEWPORT_HEIGHT = 800;
4
17
  export const SCREEN_SIZES = {
5
18
  SMALL: 768,
6
19
  MEDIUM: 1000,
@@ -20,53 +33,32 @@ const calculateDimensionFlags = (width, height) => {
20
33
  isLargeScreen: width > SCREEN_SIZES.MEDIUM,
21
34
  };
22
35
  };
23
- // 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.
27
- const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
28
- const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
29
- function writeViewportCookie(width) {
30
- if (typeof document === "undefined")
31
- return;
32
- // Round to nearest 10 so resize-driven writes don't bust HTTP caching on
33
- // every pixel of horizontal movement.
34
- const rounded = Math.round(width / 10) * 10;
35
- document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
36
- }
37
36
  /**
38
37
  * Provides a consistent way to access window dimensions and screen size
39
38
  * information across mobile and web.
40
39
  *
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.
40
+ * On web the first render uses `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT` — the
41
+ * window can't be read while the HTML shell is rendered in Node at export
42
+ * time, and reading it during the browser's first render would disagree with
43
+ * that markup. A post-mount effect swaps in the real viewport and follows
44
+ * resize from there.
50
45
  */
51
46
  export const useDimensions = () => {
52
47
  const isWeb = Platform.OS === "web";
53
- const ssrWidth = use(SsrViewportContext);
54
48
  // Native reads come from useWindowDimensions, which subscribes to rotation /
55
49
  // split-screen / resize and tears the listener down for us — no manual
56
50
  // 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.
51
+ // from the default frame + the resize listener below.
58
52
  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));
53
+ const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
62
54
  // 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.
55
+ // this in an effect (not render) keeps the first render identical to the
56
+ // exported markup it hydrates.
64
57
  useEffect(() => {
65
58
  if (!isWeb)
66
59
  return;
67
60
  const syncFromWindow = () => {
68
61
  setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
69
- writeViewportCookie(window.innerWidth);
70
62
  };
71
63
  syncFromWindow();
72
64
  window.addEventListener("resize", syncFromWindow);
@@ -1,17 +1,3 @@
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;
15
1
  interface LoadResourcesResult {
16
2
  loaded: boolean;
17
3
  error: Error | null;
@@ -4,45 +4,17 @@ 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
- /**
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
- }
46
18
  // The four static Inter weights StyledText's native family keys point at
47
19
  // (see constants/fonts.ts). Native-only: web never renders these family names
48
20
  // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
@@ -96,10 +68,6 @@ function ensureWebFontStylesheet() {
96
68
  * un-download Inter. The Feather icon font always loads.
97
69
  */
98
70
  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();
103
71
  const [loaded, setLoaded] = useState(false);
104
72
  const [error, setError] = useState(null);
105
73
  useEffect(() => {
@@ -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
@@ -2,4 +2,3 @@ export * from "./globalUIStore";
2
2
  export * from "./notify";
3
3
  export * from "./themeStore";
4
4
  export * from "./themeColorScope";
5
- export * from "./SsrViewportContext";
@@ -2,4 +2,3 @@ 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 "./SsrViewportContext.js";
@@ -2,6 +2,13 @@ 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
+ * Key the persisted preference lives under (`localStorage` on web,
7
+ * `AsyncStorage` on native). Exported so a host app's pre-boot script — e.g.
8
+ * the blocking color-scheme script in `app/+html.tsx` — can read the same
9
+ * value the store does.
10
+ */
11
+ export declare const THEME_STORAGE_KEY = "user-theme-preference";
5
12
  /**
6
13
  * Per-scheme color overrides a host app can inject to brand the package.
7
14
  *
@@ -44,6 +51,14 @@ export type ShapeOverrides = {
44
51
  export type ThemeStore = {
45
52
  userTheme: ThemePreference;
46
53
  systemTheme: ResolvedTheme;
54
+ /**
55
+ * True once the persisted preference has actually been read (or explicitly
56
+ * set by the user). Until then `userTheme` / `systemTheme` still hold the
57
+ * boot defaults ("system" / light), so a host app that needs to hold a
58
+ * paint until the real preference is known can wait on this. Mirrors
59
+ * `hasLoadedOnboarding` in the host app's onboarding store.
60
+ */
61
+ hasLoadedTheme: boolean;
47
62
  /**
48
63
  * App-injected palette overrides, applied by `useTheme` on top of the
49
64
  * package defaults. Empty by default — zero override means the package