@mrmeg/expo-ui 0.4.3 → 0.6.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.
package/LLM_USAGE.md CHANGED
@@ -37,7 +37,7 @@ near the root when the app uses package feedback or overlay components.
37
37
  `@rn-primitives` portal host.
38
38
 
39
39
  ```tsx
40
- import { ThemeProvider } from "@react-navigation/native";
40
+ import { ThemeProvider } from "expo-router";
41
41
  import { UIProvider } from "@mrmeg/expo-ui/components";
42
42
  import { colors } from "@mrmeg/expo-ui/constants";
43
43
  import { useResources, useTheme } from "@mrmeg/expo-ui/hooks";
@@ -149,6 +149,17 @@ OS color-scheme subscription, including web `prefers-color-scheme`. Do not add
149
149
  app-local Appearance or `matchMedia` listeners for package components; import
150
150
  `useTheme()`, `useStyles()`, and `useThemeStore` from `@mrmeg/expo-ui`.
151
151
 
152
+ `useTheme()` resolves colors in three layers, last wins: package defaults →
153
+ global brand (`useThemeStore.getState().setColors(overrides)`) → scoped
154
+ override (`ThemeColorScope`). Each override is `{ light?, dark? }` of
155
+ `Partial<ThemeColors>`; only the keys you pass are replaced. Call `setColors`
156
+ once to forward the app's brand palette globally. Wrap a subtree in
157
+ `<ThemeColorScope colors={{ light, dark }}>` for transient per-subtree theming
158
+ (user-created palettes, previews, embeds) that must not leak globally — it is
159
+ React context, scoped keys win over the global brand inside it, and nested
160
+ scopes merge (inner wins, outer fills in). With no override at either layer,
161
+ `useTheme()` returns the base theme by reference.
162
+
152
163
  ## Component Use-Case Index
153
164
 
154
165
  Use this table before creating a new app-local primitive.
package/README.md CHANGED
@@ -122,6 +122,47 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
122
122
  }));
123
123
  ```
124
124
 
125
+ ### Color overrides
126
+
127
+ The package ships a neutral default palette. `useTheme()` resolves colors in
128
+ three layers, last wins:
129
+
130
+ 1. **Package defaults** — the built-in zinc/teal palette.
131
+ 2. **Global brand** (`setColors`) — your app's one palette, applied everywhere.
132
+ 3. **Scoped override** (`ThemeColorScope`) — a per-subtree palette layered on
133
+ top of the brand, for transient theming that should not leak globally.
134
+
135
+ With no override at either layer, `useTheme()` returns the base theme by
136
+ reference — identical to pre-override behavior, with no extra allocation.
137
+
138
+ Forward your app's brand palette once (e.g. from a top-level effect). Each
139
+ scheme is a `Partial<ThemeColors>`, so you only specify the keys you want to
140
+ re-skin:
141
+
142
+ ```tsx
143
+ import { useThemeStore } from "@mrmeg/expo-ui/state";
144
+
145
+ useThemeStore.getState().setColors({
146
+ light: { primary: "#7c3aed", accent: "#14b8a6" },
147
+ dark: { primary: "#a78bfa", accent: "#2dd4bf" },
148
+ });
149
+ ```
150
+
151
+ Use `ThemeColorScope` to override colors for one subtree without touching the
152
+ global theme — a survey with a user-created palette, a preview pane, an embed.
153
+ It is React context, so it applies only inside the provider and unwinds when
154
+ that subtree unmounts. Nested scopes compose (inner keys win, outer fill in),
155
+ and a scoped key wins over the global brand for components inside it:
156
+
157
+ ```tsx
158
+ import { ThemeColorScope } from "@mrmeg/expo-ui/state";
159
+
160
+ <ThemeColorScope colors={{ light: surveyColors, dark: surveyColors }}>
161
+ {/* every package component in here renders with the survey palette */}
162
+ <SurveyScreen />
163
+ </ThemeColorScope>
164
+ ```
165
+
125
166
  Use `StyledText` for theme-aware text:
126
167
 
127
168
  ```tsx
@@ -307,7 +348,7 @@ LLM rules:
307
348
  Call `useResources()` once near the Expo app root before hiding the splash screen:
308
349
 
309
350
  ```tsx
310
- import { ThemeProvider } from "@react-navigation/native";
351
+ import { ThemeProvider } from "expo-router";
311
352
  import { colors } from "@mrmeg/expo-ui/constants";
312
353
  import { useResources, useTheme } from "@mrmeg/expo-ui/hooks";
313
354
  import { UIProvider } from "@mrmeg/expo-ui/components";
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from "react";
2
2
  import { colors } from "../constants/colors.js";
3
3
  import { Platform, StyleSheet } from "react-native";
4
4
  import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
5
+ import { useThemeColorScope } from "../state/themeColorScope.js";
5
6
  import { spacing as spacingConstants } from "../constants/spacing.js";
6
7
  // Module-level cache for contrast calculations to avoid memory leak
7
8
  // and share across components
@@ -47,9 +48,30 @@ export function useTheme() {
47
48
  const userTheme = useThemeStore((s) => s.userTheme);
48
49
  const systemTheme = useThemeStore((s) => s.systemTheme);
49
50
  const setTheme = useThemeStore((s) => s.setTheme);
51
+ const colorOverrides = useThemeStore((s) => s.colorOverrides);
52
+ const scoped = useThemeColorScope();
50
53
  // Determine which theme to use (user preference or system)
51
54
  const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
52
- const theme = colors[effectiveScheme];
55
+ const base = colors[effectiveScheme];
56
+ // Layer overrides on top of the package defaults for the active scheme, in
57
+ // precedence order: package default → global store (app brand) → scoped
58
+ // context (per-subtree, e.g. a survey theme). When neither override is
59
+ // present we return the base theme *by reference* so memoization and identity
60
+ // checks downstream stay stable (and the package behaves exactly as it did
61
+ // before overrides existed).
62
+ const storeOverride = colorOverrides[effectiveScheme]; // global app brand
63
+ const scopedOverride = scoped?.[effectiveScheme]; // subtree override
64
+ const theme = useMemo(() => {
65
+ const hasStore = storeOverride && Object.keys(storeOverride).length > 0;
66
+ const hasScoped = scopedOverride && Object.keys(scopedOverride).length > 0;
67
+ if (!hasStore && !hasScoped) {
68
+ return base; // identity preserved — no allocation, stable references
69
+ }
70
+ return {
71
+ ...base,
72
+ colors: { ...base.colors, ...storeOverride, ...scopedOverride },
73
+ };
74
+ }, [base, storeOverride, scopedOverride]);
53
75
  // Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme
54
76
  useEffect(() => {
55
77
  if (Platform.OS === "web" && typeof document !== "undefined") {
@@ -1,3 +1,4 @@
1
1
  export * from "./globalUIStore";
2
2
  export * from "./themeStore";
3
+ export * from "./themeColorScope";
3
4
  export * from "./SsrViewportContext";
@@ -1,3 +1,4 @@
1
1
  export * from "./globalUIStore.js";
2
2
  export * from "./themeStore.js";
3
+ export * from "./themeColorScope.js";
3
4
  export * from "./SsrViewportContext.js";
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ColorOverrides } from "./themeStore";
3
+ /** Read the active scoped override (null when not inside a scope). */
4
+ export declare function useThemeColorScope(): ColorOverrides | null;
5
+ export declare function ThemeColorScope({ colors, children, }: {
6
+ /** Per-scheme partial overrides — same shape as `setColors`. */
7
+ colors: ColorOverrides;
8
+ children: ReactNode;
9
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo } from "react";
3
+ /**
4
+ * Per-subtree color overrides, layered on top of the global theme by `useTheme`.
5
+ *
6
+ * Unlike `setColors` (a global singleton on the theme store, meant for the app's
7
+ * one brand palette), this is React context: it applies only to components
8
+ * rendered inside the provider, and unwinds automatically when that subtree
9
+ * unmounts. Use it for transient, scoped theming — a survey with custom brand
10
+ * colors, a preview pane, an embed — where a global swap would bleed into the
11
+ * rest of the app.
12
+ */
13
+ const ThemeColorScopeContext = createContext(null);
14
+ /** Read the active scoped override (null when not inside a scope). */
15
+ export function useThemeColorScope() {
16
+ return useContext(ThemeColorScopeContext);
17
+ }
18
+ // Nested scopes layer: the inner scope's keys win, the outer scope's fill in.
19
+ function mergeScopes(parent, next) {
20
+ if (!parent)
21
+ return next;
22
+ return {
23
+ light: { ...parent.light, ...next.light },
24
+ dark: { ...parent.dark, ...next.dark },
25
+ };
26
+ }
27
+ export function ThemeColorScope({ colors, children, }) {
28
+ const parent = useContext(ThemeColorScopeContext);
29
+ const value = useMemo(() => mergeScopes(parent, colors), [parent, colors]);
30
+ return (_jsx(ThemeColorScopeContext.Provider, { value: value, children: children }));
31
+ }
@@ -1,10 +1,40 @@
1
+ import type { ThemeColors } from "../constants/colors";
1
2
  export type ThemePreference = "system" | "light" | "dark";
2
3
  export type ResolvedTheme = "light" | "dark";
4
+ /**
5
+ * Per-scheme color overrides a host app can inject to brand the package.
6
+ *
7
+ * The package ships a neutral default palette (see `constants/colors.ts`).
8
+ * A consuming app almost always has its own brand palette; without a way to
9
+ * push it in, package components (Badge, Button, inputs, …) render with the
10
+ * package's colors while app-authored siblings render with the app's — the
11
+ * two disagree on what e.g. `primary` means, producing collisions such as
12
+ * white text on a white badge. `setColors` lets the app forward its palette
13
+ * once so every package component resolves against the same source of truth.
14
+ *
15
+ * Each scheme is `Partial<ThemeColors>`: only the keys provided are overridden,
16
+ * so an app can re-skin `primary`/`accent` while inheriting neutral defaults.
17
+ */
18
+ export type ColorOverrides = {
19
+ light?: Partial<ThemeColors>;
20
+ dark?: Partial<ThemeColors>;
21
+ };
3
22
  export type ThemeStore = {
4
23
  userTheme: ThemePreference;
5
24
  systemTheme: ResolvedTheme;
25
+ /**
26
+ * App-injected palette overrides, applied by `useTheme` on top of the
27
+ * package defaults. Empty by default — zero override means the package
28
+ * behaves exactly as before this field existed (fully backward compatible).
29
+ */
30
+ colorOverrides: ColorOverrides;
6
31
  setTheme: (theme: ThemePreference) => void;
7
32
  setSystemTheme: (theme: ResolvedTheme) => void;
33
+ /**
34
+ * Replace the active color overrides. Pass `{}` (or omit both schemes) to
35
+ * clear overrides and fall back to the package defaults.
36
+ */
37
+ setColors: (overrides: ColorOverrides) => void;
8
38
  loadTheme: () => void;
9
39
  };
10
40
  export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;
@@ -16,6 +16,12 @@ export const useThemeStore = create((set) => ({
16
16
  // Always start with "light" so SSR and the first client render agree.
17
17
  // Real values are populated by `syncThemeFromEnvironment()` after mount.
18
18
  systemTheme: "light",
19
+ // No overrides by default: the package renders with its built-in palette
20
+ // until a host app calls `setColors`.
21
+ colorOverrides: {},
22
+ setColors: (overrides) => {
23
+ set({ colorOverrides: overrides ?? {} });
24
+ },
19
25
  setTheme: (theme) => {
20
26
  set({
21
27
  userTheme: theme,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [