@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.
@@ -2,6 +2,26 @@ 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
+ * 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 const THEME_COOKIE_NAME = THEME_KEY;
15
+ const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
16
+ function isThemePreference(value) {
17
+ return value === "system" || value === "light" || value === "dark";
18
+ }
19
+ function writeThemeCookie(theme) {
20
+ if (Platform.OS !== "web" || typeof document === "undefined")
21
+ return;
22
+ document.cookie =
23
+ `${THEME_COOKIE_NAME}=${theme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; SameSite=Lax`;
24
+ }
5
25
  export function resolveThemePreference(userTheme, systemTheme) {
6
26
  return userTheme === "system" ? systemTheme : userTheme;
7
27
  }
@@ -14,8 +34,13 @@ function getSystemTheme() {
14
34
  export const useThemeStore = create((set) => ({
15
35
  userTheme: "system",
16
36
  // Always start with "light" so SSR and the first client render agree.
17
- // Real values are populated by `syncThemeFromEnvironment()` after mount.
37
+ // Real values are populated by `syncThemeFromEnvironment()` after mount;
38
+ // until then a web render reads `SsrThemeSeedContext` (cookie-derived) so
39
+ // the server and the browser paint the visitor's actual theme.
18
40
  systemTheme: "light",
41
+ // False until persistence has been read. `useTheme` uses this to decide
42
+ // whether to trust store state or the per-request SSR seed.
43
+ hasLoadedTheme: false,
19
44
  // No overrides by default: the package renders with its built-in palette
20
45
  // until a host app calls `setColors`.
21
46
  colorOverrides: {},
@@ -36,6 +61,7 @@ export const useThemeStore = create((set) => ({
36
61
  setTheme: (theme) => {
37
62
  set({
38
63
  userTheme: theme,
64
+ hasLoadedTheme: true,
39
65
  ...(theme === "system" ? { systemTheme: getSystemTheme() } : {}),
40
66
  });
41
67
  // Save directly when setting theme
@@ -43,10 +69,14 @@ export const useThemeStore = create((set) => ({
43
69
  AsyncStorage.setItem(THEME_KEY, theme).catch(() => {
44
70
  // Silently fail if storage is not available
45
71
  });
72
+ return;
46
73
  }
47
- else if (typeof window !== "undefined" && window.localStorage) {
74
+ if (typeof window !== "undefined" && window.localStorage) {
48
75
  localStorage.setItem(THEME_KEY, theme);
49
76
  }
77
+ // Dual-write: localStorage for the client, cookie for the next SSR render
78
+ // (so the server paints the right theme on the visitor's next request).
79
+ writeThemeCookie(theme);
50
80
  },
51
81
  setSystemTheme: (theme) => {
52
82
  set({ systemTheme: theme });
@@ -54,19 +84,32 @@ export const useThemeStore = create((set) => ({
54
84
  loadTheme: () => {
55
85
  if (Platform.OS !== "web") {
56
86
  AsyncStorage.getItem(THEME_KEY).then((saved) => {
57
- if (saved && (saved === "system" || saved === "light" || saved === "dark")) {
87
+ if (saved && isThemePreference(saved)) {
58
88
  set({ userTheme: saved });
59
89
  }
60
90
  }).catch(() => {
61
91
  // Use default if loading fails
92
+ }).finally(() => {
93
+ set({ hasLoadedTheme: true });
62
94
  });
95
+ return;
63
96
  }
64
- else if (typeof window !== "undefined" && window.localStorage) {
97
+ if (typeof window !== "undefined" && window.localStorage) {
65
98
  const saved = localStorage.getItem(THEME_KEY);
66
- if (saved && (saved === "system" || saved === "light" || saved === "dark")) {
99
+ if (saved && isThemePreference(saved)) {
67
100
  set({ userTheme: saved });
101
+ // Backfill / repair the cookie so a visitor who set their preference
102
+ // before this mechanism existed (or who cleared cookies) gets a
103
+ // correctly themed server render on their very next request.
104
+ writeThemeCookie(saved);
105
+ }
106
+ else {
107
+ // Nothing persisted: mirror the implicit "system" default so the
108
+ // server stops guessing from a stale cookie.
109
+ writeThemeCookie("system");
68
110
  }
69
111
  }
112
+ set({ hasLoadedTheme: true });
70
113
  }
71
114
  }));
72
115
  let stopSystemThemeListener = null;
@@ -112,9 +155,15 @@ export function startSystemThemeListener() {
112
155
  // environment (persisted preference + OS color scheme listener). Safe to
113
156
  // call multiple times — `startSystemThemeListener` is idempotent — and
114
157
  // returns the unsubscribe so it can be used directly inside `useEffect`.
158
+ //
159
+ // The listener starts BEFORE `loadTheme()` on purpose: `loadTheme` is what
160
+ // flips `hasLoadedTheme`, which is the moment web renders stop trusting the
161
+ // SSR seed and start trusting store state. Reading the real OS scheme first
162
+ // means a `system` user never sees a frame of the boot-default "light".
115
163
  export function syncThemeFromEnvironment() {
164
+ const stop = startSystemThemeListener();
116
165
  useThemeStore.getState().loadTheme();
117
- return startSystemThemeListener();
166
+ return stop;
118
167
  }
119
168
  // Native has no SSR mismatch concern, so keep the historical auto-init
120
169
  // behavior there. On web the host app must call `syncThemeFromEnvironment()`
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.17.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [