@mrmeg/expo-ui 0.18.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,139 @@
3
3
  All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.20.0]
7
+
8
+ ### Added
9
+
10
+ - **`surfaceSunken` semantic token.** The app-chrome tier below `background`,
11
+ so sidebars, rails, and side panels read as a layer *beneath* the content
12
+ pane instead of the same surface. Dark `#050506` (below zinc-950), light
13
+ `#FAFAFA` (zinc-50). Elevation is now a surface-tier ladder rather than a
14
+ shadow scale: `surfaceSunken` < `background` < `card`/`popover` < `muted`.
15
+
16
+ - **`borderStrong` semantic token.** A hairline for elements sitting on a
17
+ filled surface — chips on `muted`, raised panels — one visible step above
18
+ the fill, where `border` shares `muted`'s hex and disappears. Dark
19
+ `#3F3F46` (zinc-700), light `#D4D4D8` (zinc-300). `border` stays the
20
+ hairline for elements on `background`/`card`.
21
+
22
+ - **SSR theme seeding (`SsrThemeSeedContext`).** On a server-rendered web app,
23
+ `useTheme`/`useStyles` had no way to know the visitor's theme during the
24
+ server render, so every SSR page shipped a fully light-themed tree that
25
+ recolored after hydration. The store can't carry a per-request value — it's a
26
+ module singleton shared by concurrent SSR requests — so the new
27
+ `SsrThemeSeedContext` (with `SsrThemeSeed`, `SSR_THEME_SEED_DEFAULT`, and
28
+ `useSsrThemeSeed`) is the per-request channel. Provide it from a signal the
29
+ server and browser read identically (a cookie), and `useTheme` resolves from
30
+ it on web until persistence has loaded.
31
+
32
+ `SSR_THEME_SEED_DEFAULT` is `system`/light, i.e. what the store boots with —
33
+ so a host that receives no signal renders exactly as it did before the seed
34
+ existed. Note that this default is a *fallback*, not a reading: a host that
35
+ also writes the resolved scheme into its served HTML (`<html data-theme>`, a
36
+ blocking script's early-return, a `prefers-color-scheme` CSS fallback) must
37
+ distinguish "seed came from the visitor" from "seed is the default" and skip
38
+ the write in the latter case, or it will pin a dark-OS first-time visitor to
39
+ light with its own failsafes disabled.
40
+
41
+ - **`user-theme-preference` cookie mirror.** On web, `setTheme` now dual-writes
42
+ `localStorage` **and** a `user-theme-preference` cookie (`path=/;
43
+ max-age≈1y; SameSite=Lax`, exported as `THEME_COOKIE_NAME`), and `loadTheme`
44
+ backfills/repairs it from `localStorage`, so a server-rendered host can read
45
+ the visitor's theme off the request. `localStorage` remains the source of
46
+ truth; the cookie is a render hint only. Native persistence is unchanged.
47
+
48
+ - **`hasLoadedTheme` on the theme store.** False until `loadTheme()` has read
49
+ persistence (or `setTheme` was called). While it's false on web, `useTheme`
50
+ resolves from the SSR seed instead of store state; after that, store state
51
+ wins for good. Mirrors the `hasLoadedOnboarding` pattern.
52
+
53
+ ### Changed
54
+
55
+ - **Dim text is more readable in both schemes (visual change).**
56
+ `textDim`/`mutedForeground` moved from `#A1A1AA` → `#B0B0B8` in dark
57
+ (~9.2:1 on `background`, ~6.9:1 on `muted`, up from ~7.8:1/~5.9:1) and from
58
+ zinc-500 `#71717A` → zinc-600 `#52525B` in light (~7.7:1 on white, up from
59
+ ~4.8:1). Captions, timestamps, meta rows, and neutral `StatCard` changes all
60
+ render a step brighter (dark) or darker (light) than before; both stay
61
+ clearly dimmer than `text`, so hierarchy is preserved. A new package test
62
+ enforces the floors (≥7:1 on `background`/`card`/`popover`, ≥6:1 on
63
+ `muted`/`secondary`), so these tokens cannot be dimmed back down silently.
64
+ Apps that hardcoded the old hexes, or asserted them in tests, need to update
65
+ those literals; apps using the tokens get the change for free.
66
+
67
+ - **`Drawer` chrome paints `surfaceSunken`.** Both the overlay drawer panel and
68
+ the in-flow rail switched from `background` to `surfaceSunken`, so navigation
69
+ chrome is visibly darker than the content it sits beside (dark) or a faint
70
+ step off white (light). Override via the existing `style` props, or re-skin
71
+ `surfaceSunken` through `setColors`/`ThemeColorScope`, to restore the old
72
+ flat look.
73
+
74
+ - **`syncThemeFromEnvironment()` starts the OS listener before reading
75
+ persistence.** `loadTheme()` is what flips `hasLoadedTheme` (the moment web
76
+ renders stop trusting the SSR seed), so the real OS scheme is now read first
77
+ and a `system` user never sees a frame of the boot-default light.
78
+
79
+ ## [0.19.0]
80
+
81
+ ### Added
82
+
83
+ - **`Avatar` and `AvatarGroup`.** The package had `SkeletonAvatar` (a loading
84
+ placeholder) but no real avatar, so consumers hand-rolled circles. `Avatar`
85
+ renders an image with a graceful fallback chain — image → initials → icon —
86
+ and downgrades to initials at runtime when the image fails to load, so a
87
+ dead URL never leaves an empty hole. Accepts `source`, `name` (supplies 1–2
88
+ initials and the default accessibility label), `icon` (Feather, defaults to
89
+ `user`), `size` (`"sm" | "md" | "lg"` = 32/40/48, or an explicit pixel
90
+ diameter), and `shape` (`"circle" | "square"`). Initials derivation is
91
+ grapheme-aware: a decomposed accented character keeps its mark and an emoji
92
+ is not sliced into a lone surrogate.
93
+
94
+ `AvatarGroup` stacks children with a ring in the theme `background` color so
95
+ the stack reads on any surface, collapses anything past `max` into a `+N`
96
+ tile, propagates its `size` and `shape` to children that don't set their own,
97
+ and announces the total count (including the collapsed overflow) to screen
98
+ readers.
99
+
100
+ Uses React Native's own `Image`, so there is no new peer dependency.
101
+
102
+ - **`Carousel` — horizontally snapping slide row with dot indicators.**
103
+ Children-based: each child becomes one slide at the resolved item width.
104
+
105
+ ```tsx
106
+ <Carousel itemWidth={0.8} onIndexChange={setPage}>
107
+ {testimonials.map((t) => (
108
+ <Card key={t.name}>
109
+ <BodyText>{t.quote}</BodyText>
110
+ </Card>
111
+ ))}
112
+ </Carousel>
113
+ ```
114
+
115
+ `itemWidth` (default `0.85`) is a fraction of the carousel's own width when
116
+ `<= 1` — leaving the next slide peeking — and absolute pixels when `> 1`.
117
+ `gap`, `contentPadding`, `showDots`, `initialIndex`, `onIndexChange`, and
118
+ `snap` cover the rest; dots are plain themed views (active uses `accent`)
119
+ and auto-hide for a single slide. `onIndexChange` fires once per settle.
120
+
121
+ Built on `ScrollView`, not a virtualized list, so every slide is in the
122
+ server-rendered tree — web SSR ships the real slide content instead of an
123
+ empty scroller that fills in after measurement. No animation library is
124
+ involved: native snapping uses `snapToInterval`, and because
125
+ react-native-web drops that prop, web gets equivalent CSS scroll-snap
126
+ (`scroll-snap-type`/`scroll-snap-align` plus a `scroll-padding-left` that
127
+ keeps page `i` at `i * (itemWidth + gap)` on both platforms). Wheel and
128
+ trackpad scrolling keep the dots live via throttled scroll ticks, since RNW
129
+ emits no momentum events.
130
+
131
+ Accessibility: the dot row is a `tablist` of `tab`s carrying
132
+ `selected` state and per-slide labels, plus a visually clipped
133
+ `aria-live="polite"` "N of M" page announcement.
134
+
135
+ `getCarouselIndex(offsetX, interval, count)` and
136
+ `resolveCarouselItemWidth(itemWidth, containerWidth)` are exported for hosts
137
+ building custom controls on the same math.
138
+
6
139
  ## [0.18.0]
7
140
 
8
141
  ### Added
package/LLM_USAGE.md CHANGED
@@ -107,11 +107,13 @@ configureExpoUiI18n((key, options) => i18n.t(key, options));
107
107
  Useful theme tokens include:
108
108
 
109
109
  ```tsx
110
+ theme.colors.surfaceSunken;
110
111
  theme.colors.background;
111
112
  theme.colors.foreground;
112
113
  theme.colors.card;
113
114
  theme.colors.popover;
114
115
  theme.colors.border;
116
+ theme.colors.borderStrong;
115
117
  theme.colors.input;
116
118
  theme.colors.ring;
117
119
  theme.colors.primary;
@@ -131,6 +133,13 @@ Token intent:
131
133
  - `input`: form-control border color
132
134
  - `ring`: focus outline color
133
135
  - `popover`: elevated overlay surface
136
+ - `surfaceSunken`: app-chrome surface one tier below `background`
137
+ - `borderStrong`: hairline for elements on filled surfaces, where `border` would blend in
138
+
139
+ Elevation is a surface-tier ladder, not shadow depth:
140
+ `surfaceSunken` (chrome) < `background` (content) < `card`/`popover` (raised)
141
+ < `muted` (chips, insets). Border a raised or filled element with
142
+ `borderStrong`; `border` is for hairlines on `background`/`card`.
134
143
 
135
144
  Use `getShadowStyle()` for package surfaces that need elevation. It supports
136
145
  `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`,
@@ -187,10 +196,12 @@ Use this table before creating a new app-local primitive.
187
196
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections |
188
197
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs |
189
198
  | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
199
+ | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Hand-rolled circles with a nested `Image` and initials `Text` | Account menu, comment authors, assignee pickers, overlapping team stacks |
190
200
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
191
201
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms |
192
202
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs |
193
203
  | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels | List items, pricing plans, settings sections, summaries |
204
+ | `Carousel` | Horizontally snapping slide rows with dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries |
194
205
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters |
195
206
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text |
196
207
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user |
@@ -227,8 +238,10 @@ Use this table before creating a new app-local primitive.
227
238
  - Use `RadioGroup` for small mutually exclusive choices and `Select` for longer option sets.
228
239
  - Use `Dialog` for blocking decisions, `Popover` for contextual controls, `Tooltip` for short explanations, and `DropdownMenu` for action lists.
229
240
  - Use `Card` for individual repeated or framed items, not as a wrapper around full page sections.
241
+ - Use `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive server rendering; reach for `FlatList` when the data set is large or unbounded.
230
242
  - Use `EmptyState` for no-data or recoverable error regions.
231
243
  - Use `Skeleton` for loading content with stable layout.
244
+ - Use `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent or fails, plus the default accessibility label. Never hand-roll a circle with a nested `Image` and initials `Text`. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring.
232
245
  - Use `Progress` for real progress or indeterminate long-running work.
233
246
  - Use `Drawer.Header` with `icon`, `title`, and `action` for a compact app-brand row; place `Drawer.ToggleCollapse` in `action` for a trailing rail control.
234
247
 
package/README.md CHANGED
@@ -101,7 +101,9 @@ const styles = StyleSheet.create({
101
101
  });
102
102
  ```
103
103
 
104
- `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. `getShadowStyle(type)` supports `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`, `cardHover`, and `cardSubtle`. Use semantic tokens such as `theme.colors.background`, `foreground`, `card`, `popover`, `border`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
104
+ `useTheme()` returns the active `theme`, resolved `scheme`, persisted `currentTheme`, `setTheme`, `toggleTheme`, cross-platform shadow helpers, a web focus-ring helper, contrast helpers, and `withAlpha`. `getShadowStyle(type)` supports `base`, `soft`, `sharp`, `subtle`, `elevated`, `glow`, `glass`, `card`, `cardHover`, and `cardSubtle`. Use semantic tokens such as `theme.colors.surfaceSunken`, `background`, `foreground`, `card`, `popover`, `border`, `borderStrong`, `input`, `ring`, `primary`, `secondary`, `accent`, `mutedForeground`, `destructive`, `success`, and `warning`. `primary` is the neutral action color, `secondary` is a neutral secondary surface, `accent` is the teal highlight color, `input` is the default form-control border, and `ring` is the focus outline color.
105
+
106
+ Surfaces are layered tiers rather than shadow depths: `surfaceSunken` (app chrome, e.g. the Drawer rail) sits below `background` (content), which sits below `card`/`popover` (raised panels), which sit below `muted` (chips and insets). `border` is the hairline for elements on `background`/`card`; use `borderStrong` when the element sits on a filled surface, where `border` would blend into the fill. `textDim`/`mutedForeground` are tuned for readability — at least 7:1 against `background`/`card` and 6:1 against `muted` in both schemes — and a package test enforces those floors.
105
107
 
106
108
  When `currentTheme` is `"system"`, the package tracks the OS color scheme and
107
109
  updates all `useTheme()` and `useStyles()` consumers through the package theme
@@ -295,10 +297,12 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
295
297
  | `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent` | Multi-section disclosure | Custom FAQ/settings expanders | FAQ lists, grouped settings, help sections, dense detail pages |
296
298
  | `Alert` | Cross-platform imperative alerts | Direct `window.alert` or duplicated RN/web branching | Confirm destructive actions, native alert dialogs, simple blocking messages |
297
299
  | `AnimatedView` | Entrance and visibility animation | Hand-rolled one-off Animated wrappers | Staggered list rows, revealed panels, animated empty states |
300
+ | `Avatar`, `AvatarGroup` | Profile images with initials/icon fallback | Hand-rolled circles with a nested `Image` and initials `Text` | Account menu, comment authors, assignee pickers, testimonial cards, overlapping team/collaborator stacks |
298
301
  | `Badge` | Short status labels | Custom pill `View` + `Text` | Draft/active states, counts, plan labels, role tags |
299
302
  | `BottomSheet` | Mobile-first modal sheets | Custom absolute-position sheets | Action pickers, mobile filters, keyboard-aware quick edit forms, contextual details |
300
303
  | `Button` | Commands and CTAs | Pressable plus custom text styling | Submit, save, cancel, delete, navigation CTAs, icon-accessory buttons; loading state preserves resting width |
301
304
  | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` | Framed content groups | Ad hoc bordered panels | List items, pricing plans, settings sections, summaries, dashboards |
305
+ | `Carousel` | Horizontally snapping slide rows with dot indicators | Hand-rolled snap `ScrollView` plus manual offset math | Testimonial strips, onboarding pages, feature highlights, image galleries; `itemWidth` below 1 is a fraction of the carousel width so the next slide peeks |
302
306
  | `Checkbox` | Boolean selection | Custom checkmark controls | Terms consent, checklist items, multi-select filters, notification opt-ins |
303
307
  | `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` | One-off disclosure | Local animated height wrappers | Advanced settings, hidden helper text, optional details |
304
308
  | `Dialog`, `AlertDialog` | Modal decisions and custom modal content | Custom modal overlays | Confirm delete, edit profile, invite user, blocking warnings |
@@ -380,6 +384,8 @@ Use `Skeleton` components for loading content with stable dimensions, `EmptyStat
380
384
 
381
385
  Use `Toggle` for one pressed state, `ToggleGroup` for a related set of pressed states, `Switch` for binary settings, `RadioGroup` for small mutually exclusive choices, and `Select` for longer option sets.
382
386
 
387
+ Pass `Avatar` both a `source` and a `name` whenever you have them: `name` supplies the initials shown while the image is missing or after it fails, and the default accessibility label. `SkeletonAvatar` is the loading placeholder, not a substitute. Inside `AvatarGroup`, child avatars inherit the group's `size` and `shape` and gain the ring, so set those on the group rather than per child.
388
+
383
389
  ### Quick Examples
384
390
 
385
391
  Card, badge, and CTA:
@@ -0,0 +1,80 @@
1
+ import React from "react";
2
+ import { type ImageSourcePropType, type StyleProp, type ViewStyle } from "react-native";
3
+ import { type IconName } from "./Icon";
4
+ /** Named size tokens, or an explicit pixel diameter. */
5
+ export type AvatarSize = "sm" | "md" | "lg" | number;
6
+ export type AvatarShape = "circle" | "square";
7
+ /**
8
+ * Derive 1–2 initials from a display name.
9
+ *
10
+ * First + last word (middle names are skipped, as in most avatar stacks), one
11
+ * initial for a single-word name, and `""` when there is nothing to derive —
12
+ * callers treat that as "fall through to the icon".
13
+ *
14
+ * Grapheme-aware by way of `Array.from`, so an accented character written as
15
+ * base + combining mark keeps its accent and an astral code point (emoji) is
16
+ * not sliced into a lone surrogate. `toLocaleUpperCase` is deliberate: a
17
+ * locale-sensitive uppercase is right for names.
18
+ */
19
+ export declare function getAvatarInitials(name?: string): string;
20
+ export interface AvatarProps {
21
+ /** Image to display. Falls back to initials (then the icon) if it fails to load. */
22
+ source?: ImageSourcePropType;
23
+ /** Display name; supplies the fallback initials and the default accessibility label. */
24
+ name?: string;
25
+ /** Feather icon rendered when there is no image and no derivable initials. @default "user" */
26
+ icon?: IconName;
27
+ /** Size token or explicit pixel diameter. Inherited from `AvatarGroup` when unset. @default "md" */
28
+ size?: AvatarSize;
29
+ /** Inherited from `AvatarGroup` when unset. @default "circle" */
30
+ shape?: AvatarShape;
31
+ /**
32
+ * Accessibility label. Defaults to `name`; when neither is set the avatar is
33
+ * decorative and stays out of the accessibility tree.
34
+ */
35
+ accessibilityLabel?: string;
36
+ style?: StyleProp<ViewStyle>;
37
+ }
38
+ /**
39
+ * Avatar Component
40
+ *
41
+ * A circular (or rounded-square) profile image with a graceful fallback chain:
42
+ * image → initials → icon. A broken or slow-failing image downgrades to
43
+ * initials at runtime, so a dead URL never leaves an empty hole.
44
+ *
45
+ * Usage:
46
+ * ```tsx
47
+ * <Avatar source={{ uri: user.avatarUrl }} name={user.fullName} />
48
+ * <Avatar name="Ada Lovelace" size="lg" />
49
+ * <Avatar icon="camera" shape="square" />
50
+ * ```
51
+ */
52
+ declare function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }: AvatarProps): React.JSX.Element;
53
+ export interface AvatarGroupProps {
54
+ children?: React.ReactNode;
55
+ /** Maximum number of avatars to render; the rest collapse into a `+N` tile. */
56
+ max?: number;
57
+ /** Size applied to child avatars that don't set their own. @default "md" */
58
+ size?: AvatarSize;
59
+ /** Shape applied to child avatars that don't set their own. @default "circle" */
60
+ shape?: AvatarShape;
61
+ /** Defaults to `"<n> avatars"`, counting the collapsed overflow. */
62
+ accessibilityLabel?: string;
63
+ style?: StyleProp<ViewStyle>;
64
+ }
65
+ /**
66
+ * AvatarGroup Component
67
+ *
68
+ * Overlapping stack of avatars, each ringed in the theme `background` color so
69
+ * the stack reads on any surface. Earlier avatars sit on top; anything past
70
+ * `max` collapses into a `+N` tile.
71
+ *
72
+ * Usage:
73
+ * ```tsx
74
+ * <AvatarGroup max={3}>
75
+ * {members.map((m) => <Avatar key={m.id} source={{ uri: m.avatarUrl }} name={m.name} />)}
76
+ * </AvatarGroup>
77
+ * ```
78
+ */
79
+ declare function AvatarGroup({ children, max, size, shape, accessibilityLabel, style: styleOverride, }: AvatarGroupProps): React.JSX.Element;
80
+ export { Avatar, AvatarGroup };
@@ -0,0 +1,234 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { use, useState } from "react";
3
+ import { Image, StyleSheet, View, } from "react-native";
4
+ import { useTheme } from "../hooks/useTheme.js";
5
+ import { spacing } from "../constants/spacing.js";
6
+ import { Icon } from "./Icon.js";
7
+ import { StyledText } from "./StyledText.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
9
+ const SIZE_TOKENS = {
10
+ sm: spacing.iconLg, // 32
11
+ md: spacing.iconLg + spacing.sm, // 40
12
+ lg: spacing.iconXl, // 48
13
+ };
14
+ function isSizeToken(size) {
15
+ return typeof size === "string";
16
+ }
17
+ function resolveSize(size) {
18
+ return isSizeToken(size) ? SIZE_TOKENS[size] : size;
19
+ }
20
+ /** Rounded-square radius scales with the avatar so 32px and 88px read the same. */
21
+ function squareRadius(px) {
22
+ return Math.round(px / 4);
23
+ }
24
+ /** Initials/icon scale with the avatar rather than jumping between text tokens. */
25
+ function contentSize(px) {
26
+ return { fontSize: Math.round(px * 0.4), iconSize: Math.round(px * 0.5) };
27
+ }
28
+ /** Overlap for stacked avatars — 30% keeps ~70% of each face visible. */
29
+ function overlap(px) {
30
+ return -Math.round(px * 0.3);
31
+ }
32
+ /**
33
+ * Derive 1–2 initials from a display name.
34
+ *
35
+ * First + last word (middle names are skipped, as in most avatar stacks), one
36
+ * initial for a single-word name, and `""` when there is nothing to derive —
37
+ * callers treat that as "fall through to the icon".
38
+ *
39
+ * Grapheme-aware by way of `Array.from`, so an accented character written as
40
+ * base + combining mark keeps its accent and an astral code point (emoji) is
41
+ * not sliced into a lone surrogate. `toLocaleUpperCase` is deliberate: a
42
+ * locale-sensitive uppercase is right for names.
43
+ */
44
+ export function getAvatarInitials(name) {
45
+ if (!name)
46
+ return "";
47
+ const words = name.trim().split(/\s+/).filter(Boolean);
48
+ if (words.length === 0)
49
+ return "";
50
+ const picked = words.length === 1 ? [words[0]] : [words[0], words[words.length - 1]];
51
+ return picked
52
+ .map((word) => {
53
+ const [first] = Array.from(word.normalize("NFC"));
54
+ return first ?? "";
55
+ })
56
+ .join("")
57
+ .toLocaleUpperCase();
58
+ }
59
+ /**
60
+ * Identity of an image source by content, not object identity.
61
+ *
62
+ * `source={{ uri }}` allocates a new object on every render, so a failure
63
+ * flag keyed on object identity would reset immediately and loop
64
+ * image → error → image. Returns `null` when there is no usable image.
65
+ */
66
+ function getSourceKey(source) {
67
+ if (source == null)
68
+ return null;
69
+ if (typeof source === "number")
70
+ return `asset:${source}`;
71
+ if (Array.isArray(source)) {
72
+ const key = source.map((entry) => entry?.uri ?? "").filter(Boolean).join("|");
73
+ return key || null;
74
+ }
75
+ return source.uri || null;
76
+ }
77
+ const AvatarGroupContext = React.createContext(null);
78
+ /**
79
+ * Avatar Component
80
+ *
81
+ * A circular (or rounded-square) profile image with a graceful fallback chain:
82
+ * image → initials → icon. A broken or slow-failing image downgrades to
83
+ * initials at runtime, so a dead URL never leaves an empty hole.
84
+ *
85
+ * Usage:
86
+ * ```tsx
87
+ * <Avatar source={{ uri: user.avatarUrl }} name={user.fullName} />
88
+ * <Avatar name="Ada Lovelace" size="lg" />
89
+ * <Avatar icon="camera" shape="square" />
90
+ * ```
91
+ */
92
+ function Avatar({ source, name, icon, size, shape, accessibilityLabel, style: styleOverride, }) {
93
+ const { theme } = useTheme();
94
+ const styles = themedStyles(theme);
95
+ const group = use(AvatarGroupContext);
96
+ const px = resolveSize(size ?? group?.size ?? "md");
97
+ const resolvedShape = shape ?? group?.shape ?? "circle";
98
+ const sourceKey = getSourceKey(source);
99
+ // Keyed on the source's content, so a retry with a new URL clears the
100
+ // failure while a re-rendered identical URL keeps it.
101
+ const [failedKey, setFailedKey] = useState(null);
102
+ const showImage = sourceKey !== null && failedKey !== sourceKey;
103
+ const initials = getAvatarInitials(name);
104
+ const label = accessibilityLabel ?? name;
105
+ return (_jsx(AvatarSurface, { px: px, shape: resolvedShape, ring: group?.ring ?? false, accessibilityLabel: label, style: [styles.fallback, styleOverride], children: showImage ? (_jsx(Image, { source: source, resizeMode: "cover", style: geometry.image, onError: () => setFailedKey(sourceKey) })) : initials ? (_jsx(AvatarLabel, { px: px, style: styles.fallbackText, children: initials })) : (_jsx(Icon, { name: icon ?? "user", size: contentSize(px).iconSize, color: "mutedForeground", decorative: true })) }));
106
+ }
107
+ /**
108
+ * Shared chrome for an avatar-shaped tile — used by `Avatar` and by
109
+ * `AvatarGroup`'s `+N` overflow tile so both stay the same size, shape, and
110
+ * ring without duplicating the geometry rules.
111
+ */
112
+ function AvatarSurface({ px, shape, ring, accessibilityLabel, style, children, }) {
113
+ const { theme } = useTheme();
114
+ const styles = themedStyles(theme);
115
+ const token = SIZE_TOKEN_FOR_PX[px];
116
+ return (_jsx(View
117
+ // Only join the accessibility tree when there is something to announce;
118
+ // an unlabeled avatar is decorative next to the name it sits beside.
119
+ , {
120
+ // Only join the accessibility tree when there is something to announce;
121
+ // an unlabeled avatar is decorative next to the name it sits beside.
122
+ accessible: accessibilityLabel ? true : undefined,
123
+ // RN's role name is "image"; RNW maps it to aria-role "img" on the web.
124
+ accessibilityRole: accessibilityLabel ? "image" : undefined, accessibilityLabel: accessibilityLabel, style: [
125
+ geometry.base,
126
+ // Token sizes hit a pre-registered rule; a custom pixel size falls back
127
+ // to an inline style (which always ships in the SSR HTML).
128
+ token
129
+ ? geometry[shape === "circle" ? `${token}Circle` : `${token}Square`]
130
+ : { width: px, height: px, borderRadius: shape === "circle" ? spacing.radiusFull : squareRadius(px) },
131
+ ring && geometry.ring,
132
+ ring && styles.ring,
133
+ style,
134
+ ], children: children }));
135
+ }
136
+ function AvatarLabel({ px, style, children, }) {
137
+ return (_jsx(StyledText, { accessible: false, selectable: false, fontWeight: "semibold", style: [geometry.labelText, { fontSize: contentSize(px).fontSize }, style], children: children }));
138
+ }
139
+ /**
140
+ * AvatarGroup Component
141
+ *
142
+ * Overlapping stack of avatars, each ringed in the theme `background` color so
143
+ * the stack reads on any surface. Earlier avatars sit on top; anything past
144
+ * `max` collapses into a `+N` tile.
145
+ *
146
+ * Usage:
147
+ * ```tsx
148
+ * <AvatarGroup max={3}>
149
+ * {members.map((m) => <Avatar key={m.id} source={{ uri: m.avatarUrl }} name={m.name} />)}
150
+ * </AvatarGroup>
151
+ * ```
152
+ */
153
+ function AvatarGroup({ children, max, size = "md", shape = "circle", accessibilityLabel, style: styleOverride, }) {
154
+ const { theme } = useTheme();
155
+ const styles = themedStyles(theme);
156
+ // toArray drops null/undefined/false children so a conditionally rendered
157
+ // avatar doesn't inflate the count or the overflow tally.
158
+ const items = React.Children.toArray(children);
159
+ const visible = max != null && max >= 0 ? items.slice(0, max) : items;
160
+ const overflowCount = items.length - visible.length;
161
+ const px = resolveSize(size);
162
+ const token = SIZE_TOKEN_FOR_PX[px];
163
+ const stackStyle = token ? geometry[`${token}Stack`] : { marginLeft: overlap(px) };
164
+ const totalTiles = visible.length + (overflowCount > 0 ? 1 : 0);
165
+ const contextValue = React.useMemo(() => ({ size, shape, ring: true }), [size, shape]);
166
+ return (_jsx(View, { accessible: true, accessibilityRole: "image", accessibilityLabel: accessibilityLabel ?? `${items.length} avatars`, style: [geometry.group, styleOverride], children: _jsxs(AvatarGroupContext.Provider, { value: contextValue, children: [visible.map((child, index) => (_jsx(View
167
+ // toArray assigns every child a stable key, so reordering the list
168
+ // moves the wrapper with its avatar instead of remounting both.
169
+ , {
170
+ // Earlier avatars paint over later ones, so the stack reads
171
+ // left-to-right instead of the DOM's later-wins order.
172
+ style: [index > 0 && stackStyle, { zIndex: totalTiles - index }], children: child }, React.isValidElement(child) ? child.key : index))), overflowCount > 0 ? (_jsx(View, { style: [visible.length > 0 && stackStyle, { zIndex: 0 }], children: _jsx(AvatarSurface, { px: px, shape: shape, ring: true, style: styles.overflow, children: _jsx(AvatarLabel, { px: px, style: styles.overflowText, children: `+${overflowCount}` }) }) })) : null] }) }));
173
+ }
174
+ /** px → token, so a size passed as a raw number still hits the registered rules. */
175
+ const SIZE_TOKEN_FOR_PX = {
176
+ [SIZE_TOKENS.sm]: "sm",
177
+ [SIZE_TOKENS.md]: "md",
178
+ [SIZE_TOKENS.lg]: "lg",
179
+ };
180
+ // Geometry carries no theme values, so a plain module-scope sheet is enough to
181
+ // get these rules into the server-rendered <head>. Only colors go through
182
+ // createThemedStyles below. See docs/ssr-hydration.md.
183
+ const geometry = StyleSheet.create({
184
+ base: {
185
+ alignItems: "center",
186
+ justifyContent: "center",
187
+ overflow: "hidden",
188
+ },
189
+ image: {
190
+ width: "100%",
191
+ height: "100%",
192
+ },
193
+ labelText: {
194
+ // The tile is the touch/visual target; text must not add its own metrics.
195
+ includeFontPadding: false,
196
+ textAlign: "center",
197
+ userSelect: "none",
198
+ },
199
+ ring: {
200
+ borderWidth: spacing.xxs,
201
+ },
202
+ group: {
203
+ flexDirection: "row",
204
+ alignItems: "center",
205
+ },
206
+ smCircle: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: spacing.radiusFull },
207
+ mdCircle: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: spacing.radiusFull },
208
+ lgCircle: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: spacing.radiusFull },
209
+ smSquare: { width: SIZE_TOKENS.sm, height: SIZE_TOKENS.sm, borderRadius: squareRadius(SIZE_TOKENS.sm) },
210
+ mdSquare: { width: SIZE_TOKENS.md, height: SIZE_TOKENS.md, borderRadius: squareRadius(SIZE_TOKENS.md) },
211
+ lgSquare: { width: SIZE_TOKENS.lg, height: SIZE_TOKENS.lg, borderRadius: squareRadius(SIZE_TOKENS.lg) },
212
+ smStack: { marginLeft: overlap(SIZE_TOKENS.sm) },
213
+ mdStack: { marginLeft: overlap(SIZE_TOKENS.md) },
214
+ lgStack: { marginLeft: overlap(SIZE_TOKENS.lg) },
215
+ });
216
+ const createStyles = (theme) => StyleSheet.create({
217
+ fallback: {
218
+ backgroundColor: theme.colors.muted,
219
+ },
220
+ fallbackText: {
221
+ color: theme.colors.mutedForeground,
222
+ },
223
+ ring: {
224
+ borderColor: theme.colors.background,
225
+ },
226
+ overflow: {
227
+ backgroundColor: theme.colors.accent,
228
+ },
229
+ overflowText: {
230
+ color: theme.colors.accentForeground,
231
+ },
232
+ });
233
+ const themedStyles = createThemedStyles(createStyles);
234
+ export { Avatar, AvatarGroup };
@@ -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)",
@@ -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
@@ -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.18.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": [