@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.
@@ -2,6 +2,16 @@ import { Appearance, Platform } from "react-native";
2
2
  import { create } from "zustand";
3
3
  import AsyncStorage from "@react-native-async-storage/async-storage";
4
4
  const THEME_KEY = "user-theme-preference";
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 const THEME_STORAGE_KEY = THEME_KEY;
12
+ function isThemePreference(value) {
13
+ return value === "system" || value === "light" || value === "dark";
14
+ }
5
15
  export function resolveThemePreference(userTheme, systemTheme) {
6
16
  return userTheme === "system" ? systemTheme : userTheme;
7
17
  }
@@ -13,9 +23,13 @@ function getSystemTheme() {
13
23
  }
14
24
  export const useThemeStore = create((set) => ({
15
25
  userTheme: "system",
16
- // Always start with "light" so SSR and the first client render agree.
17
- // Real values are populated by `syncThemeFromEnvironment()` after mount.
26
+ // Always start with "light" so a web app's first render matches the HTML
27
+ // shell that was rendered in Node at export time (no `window`, no storage).
28
+ // Real values arrive from `syncThemeFromEnvironment()` after mount; a host
29
+ // app's pre-boot script paints the correct background in the meantime.
18
30
  systemTheme: "light",
31
+ // False until persistence has been read.
32
+ hasLoadedTheme: false,
19
33
  // No overrides by default: the package renders with its built-in palette
20
34
  // until a host app calls `setColors`.
21
35
  colorOverrides: {},
@@ -36,6 +50,7 @@ export const useThemeStore = create((set) => ({
36
50
  setTheme: (theme) => {
37
51
  set({
38
52
  userTheme: theme,
53
+ hasLoadedTheme: true,
39
54
  ...(theme === "system" ? { systemTheme: getSystemTheme() } : {}),
40
55
  });
41
56
  // Save directly when setting theme
@@ -43,8 +58,9 @@ export const useThemeStore = create((set) => ({
43
58
  AsyncStorage.setItem(THEME_KEY, theme).catch(() => {
44
59
  // Silently fail if storage is not available
45
60
  });
61
+ return;
46
62
  }
47
- else if (typeof window !== "undefined" && window.localStorage) {
63
+ if (typeof window !== "undefined" && window.localStorage) {
48
64
  localStorage.setItem(THEME_KEY, theme);
49
65
  }
50
66
  },
@@ -54,19 +70,23 @@ export const useThemeStore = create((set) => ({
54
70
  loadTheme: () => {
55
71
  if (Platform.OS !== "web") {
56
72
  AsyncStorage.getItem(THEME_KEY).then((saved) => {
57
- if (saved && (saved === "system" || saved === "light" || saved === "dark")) {
73
+ if (saved && isThemePreference(saved)) {
58
74
  set({ userTheme: saved });
59
75
  }
60
76
  }).catch(() => {
61
77
  // Use default if loading fails
78
+ }).finally(() => {
79
+ set({ hasLoadedTheme: true });
62
80
  });
81
+ return;
63
82
  }
64
- else if (typeof window !== "undefined" && window.localStorage) {
83
+ if (typeof window !== "undefined" && window.localStorage) {
65
84
  const saved = localStorage.getItem(THEME_KEY);
66
- if (saved && (saved === "system" || saved === "light" || saved === "dark")) {
85
+ if (saved && isThemePreference(saved)) {
67
86
  set({ userTheme: saved });
68
87
  }
69
88
  }
89
+ set({ hasLoadedTheme: true });
70
90
  }
71
91
  }));
72
92
  let stopSystemThemeListener = null;
@@ -112,13 +132,21 @@ export function startSystemThemeListener() {
112
132
  // environment (persisted preference + OS color scheme listener). Safe to
113
133
  // call multiple times — `startSystemThemeListener` is idempotent — and
114
134
  // returns the unsubscribe so it can be used directly inside `useEffect`.
135
+ //
136
+ // The listener starts BEFORE `loadTheme()` on purpose: reading the real OS
137
+ // scheme first means a `system` user resolves straight from the boot-default
138
+ // "light" to their actual scheme in one commit.
115
139
  export function syncThemeFromEnvironment() {
140
+ const stop = startSystemThemeListener();
116
141
  useThemeStore.getState().loadTheme();
117
- return startSystemThemeListener();
142
+ return stop;
118
143
  }
119
- // Native has no SSR mismatch concern, so keep the historical auto-init
120
- // behavior there. On web the host app must call `syncThemeFromEnvironment()`
121
- // from a top-level `useEffect` to avoid hydration mismatches.
144
+ // Native can read persistence at module load, so keep the historical
145
+ // auto-init behavior there. On web the host app must call
146
+ // `syncThemeFromEnvironment()` from a top-level `useEffect`: web bundles are
147
+ // also evaluated in Node when `expo export` renders the HTML shell, where
148
+ // `window`/`localStorage` don't exist, and reading them during the browser's
149
+ // first render would disagree with the markup being hydrated.
122
150
  if (Platform.OS !== "web") {
123
151
  useThemeStore.getState().loadTheme();
124
152
  startSystemThemeListener();
package/llms-full.md CHANGED
@@ -51,9 +51,14 @@ package `tx` props translated by its app-owned i18n instance.
51
51
  ## Theme Rules
52
52
 
53
53
  Use `useTheme()` and `useStyles()` from `@mrmeg/expo-ui/hooks`. Use semantic
54
- tokens such as `background`, `foreground`, `card`, `popover`, `border`,
55
- `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`,
56
- `destructive`, `success`, and `warning`.
54
+ tokens such as `surfaceSunken`, `background`, `foreground`, `card`, `popover`,
55
+ `border`, `borderStrong`, `input`, `ring`, `primary`, `secondary`, `accent`,
56
+ `mutedForeground`, `destructive`, `success`, and `warning`.
57
+
58
+ Surfaces form a tier ladder rather than a shadow scale: `surfaceSunken` (app
59
+ chrome such as the Drawer rail) < `background` (content) < `card`/`popover`
60
+ (raised) < `muted` (chips, insets). Use `borderStrong` for hairlines on filled
61
+ or raised elements, where `border` would blend into the fill.
57
62
 
58
63
  Use `StyledText` and semantic text aliases instead of raw `Text` for app UI.
59
64
  Use package controls instead of hardcoded Pressable/View/Text combinations.
@@ -104,7 +109,7 @@ Use this catalog before creating a new app-local primitive.
104
109
  | `Icon` | `@mrmeg/expo-ui/components` | Feather or custom icons with theme tokens | Avoid raw vector icons with hardcoded colors. |
105
110
  | `InputOTP` | `@mrmeg/expo-ui/components` | Verification code entry | Prefer over manually managed text input groups. |
106
111
  | `KeyboardAvoidingView` | `@mrmeg/expo-ui/components` | Native keyboard-aware layout roots, composer footers, and form-heavy subtrees | `UIProvider` already mounts one root by default; use this directly only for custom subtrees. |
107
- | `Label` | `@mrmeg/expo-ui/components` | Accessible form labels | Use with package form controls. |
112
+ | `Label` | `@mrmeg/expo-ui/components` | Accessible form labels | Pair with two distinct ids: `nativeID` is the label's id, `htmlFor` is the input's `nativeID`. Never reuse one id for both. |
108
113
  | `MaxWidthContainer` | `@mrmeg/expo-ui/components` | Centered responsive width | Use for web and tablet constrained layouts. |
109
114
  | `Notification` | `@mrmeg/expo-ui/components` | Global toast surface | Trigger through `notify` (or `globalUIStore` for subscriptions/tests) with root `UIProvider`; optional actions dismiss after press. |
110
115
  | `Popover` | `@mrmeg/expo-ui/components` | Anchored contextual content | Requires root `UIProvider` portal setup. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.18.0",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -1,41 +0,0 @@
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
- /**
17
- * 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
- * ```
40
- */
41
- export declare const SsrViewportContext: import("react").Context<number>;
@@ -1,42 +0,0 @@
1
- import { createContext } from "react";
2
- /**
3
- * Default viewport used during SSR and the initial client render. Desktop is
4
- * chosen because:
5
- * 1. Most web traffic for app dashboards / marketing pages skews desktop.
6
- * 2. Desktop visitors see no layout reflow on hydration (SSR layout matches
7
- * their actual viewport's breakpoint).
8
- * 3. Mobile visitors get one frame of desktop-styled content before
9
- * `useDimensions`'s post-mount effect snaps to real dimensions — better
10
- * than the inverse (every desktop visitor sees mobile-tiny then snap).
11
- *
12
- * Projects that skew mobile can override with their own Provider higher in
13
- * the tree, or per-page via a loader that detects from cookie / User-Agent.
14
- */
15
- export const SSR_VIEWPORT_DEFAULT_WIDTH = 1280;
16
- export const SSR_VIEWPORT_DEFAULT_HEIGHT = 800;
17
- /**
18
- * Per-request SSR viewport width. Consumed by `useDimensions` to seed the
19
- * initial responsive render so it matches what the user sees.
20
- *
21
- * Set the provider near the top of your tree (typically in a route component
22
- * whose loader detects the viewport from a cookie or User-Agent):
23
- *
24
- * ```tsx
25
- * import { SsrViewportContext } from "@mrmeg/expo-ui/state";
26
- * import { detectSsrViewportWidth } from "@/server/lib/ssrViewport";
27
- *
28
- * export const loader: LoaderFunction<{ ssrViewportWidth?: number }> = (request) => ({
29
- * ssrViewportWidth: detectSsrViewportWidth(request),
30
- * });
31
- *
32
- * export default function Page() {
33
- * const { ssrViewportWidth } = useLoaderData<typeof loader>();
34
- * return (
35
- * <SsrViewportContext.Provider value={ssrViewportWidth ?? SSR_VIEWPORT_DEFAULT_WIDTH}>
36
- * ...
37
- * </SsrViewportContext.Provider>
38
- * );
39
- * }
40
- * ```
41
- */
42
- export const SsrViewportContext = createContext(SSR_VIEWPORT_DEFAULT_WIDTH);