@oxyhq/bloom 0.51.0 → 0.51.1

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.
Files changed (54) hide show
  1. package/lib/commonjs/frosted-icon-button/shared.js +1 -1
  2. package/lib/commonjs/tab-bar/surface-solid.js +43 -0
  3. package/lib/commonjs/tab-bar/surface-solid.js.map +1 -0
  4. package/lib/commonjs/tab-bar/surface.js +5 -42
  5. package/lib/commonjs/tab-bar/surface.js.map +1 -1
  6. package/lib/commonjs/tab-bar/surface.native.js +65 -8
  7. package/lib/commonjs/tab-bar/surface.native.js.map +1 -1
  8. package/lib/module/frosted-icon-button/shared.js +1 -1
  9. package/lib/module/tab-bar/surface-solid.js +37 -0
  10. package/lib/module/tab-bar/surface-solid.js.map +1 -0
  11. package/lib/module/tab-bar/surface.js +10 -23
  12. package/lib/module/tab-bar/surface.js.map +1 -1
  13. package/lib/module/tab-bar/surface.native.js +68 -9
  14. package/lib/module/tab-bar/surface.native.js.map +1 -1
  15. package/lib/typescript/commonjs/benefit-list/types.d.ts +2 -1
  16. package/lib/typescript/commonjs/benefit-list/types.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/fab/types.d.ts +4 -2
  18. package/lib/typescript/commonjs/fab/types.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +1 -1
  20. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +2 -1
  21. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts +6 -0
  23. package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts.map +1 -0
  24. package/lib/typescript/commonjs/tab-bar/surface.d.ts +28 -5
  25. package/lib/typescript/commonjs/tab-bar/surface.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/tab-bar/surface.native.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/tab-bar/types.d.ts +6 -3
  28. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  29. package/lib/typescript/module/benefit-list/types.d.ts +2 -1
  30. package/lib/typescript/module/benefit-list/types.d.ts.map +1 -1
  31. package/lib/typescript/module/fab/types.d.ts +4 -2
  32. package/lib/typescript/module/fab/types.d.ts.map +1 -1
  33. package/lib/typescript/module/frosted-icon-button/shared.d.ts +1 -1
  34. package/lib/typescript/module/frosted-icon-button/types.d.ts +2 -1
  35. package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -1
  36. package/lib/typescript/module/tab-bar/surface-solid.d.ts +6 -0
  37. package/lib/typescript/module/tab-bar/surface-solid.d.ts.map +1 -0
  38. package/lib/typescript/module/tab-bar/surface.d.ts +28 -5
  39. package/lib/typescript/module/tab-bar/surface.d.ts.map +1 -1
  40. package/lib/typescript/module/tab-bar/surface.native.d.ts.map +1 -1
  41. package/lib/typescript/module/tab-bar/types.d.ts +6 -3
  42. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  43. package/package.json +2 -8
  44. package/src/__tests__/TabBarGlassFallback.test.tsx +234 -0
  45. package/src/__tests__/TabBarWebFork.test.ts +46 -0
  46. package/src/__tests__/icon-references.test.ts +243 -0
  47. package/src/benefit-list/types.ts +2 -1
  48. package/src/fab/types.ts +4 -2
  49. package/src/frosted-icon-button/shared.ts +1 -1
  50. package/src/frosted-icon-button/types.ts +2 -1
  51. package/src/tab-bar/surface-solid.tsx +40 -0
  52. package/src/tab-bar/surface.native.tsx +77 -15
  53. package/src/tab-bar/surface.tsx +10 -29
  54. package/src/tab-bar/types.ts +6 -3
@@ -0,0 +1,243 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { extname, join, relative } from 'node:path';
3
+
4
+ /**
5
+ * Every `Icons.*` reference in this repo must name an identifier `src/icons`
6
+ * actually exports.
7
+ *
8
+ * Icon exports carry their full style and corner suffix
9
+ * (`Home_Stroke2_Corner0_Rounded`), there are 250-odd of them, and a shortened
10
+ * name is not a compile error anywhere it usually appears: in a DOC COMMENT or an
11
+ * `.mdx` example, `tsc` never sees it. A reader copies it, gets `undefined`, and
12
+ * React reports "Element type is invalid … but got: undefined" against the
13
+ * consumer's own file — which is exactly how a phantom release blocker was filed
14
+ * against the tab bar's native surface.
15
+ *
16
+ * Two shapes are checked, because the real incident needed both:
17
+ *
18
+ * 1. `Icons.Home` — a member that does not exist.
19
+ * 2. `import { Icons } from '@oxyhq/bloom/icons'` — the NAMESPACE imported as a
20
+ * named export. `Icons` is assembled by the ROOT barrel
21
+ * (`export * as Icons from './icons'` in `src/index.ts`); the `./icons`
22
+ * subpath exports the components flat, so that import is `undefined` too.
23
+ * `import * as Icons from '@oxyhq/bloom/icons'` and
24
+ * `import { Icons } from '@oxyhq/bloom'` are both correct and must stay
25
+ * unflagged.
26
+ *
27
+ * Comments are deliberately NOT stripped here — the inverse of
28
+ * `TabBarWebFork.test.ts`, which blanks them so a doc comment naming a package is
29
+ * not mistaken for an import of it. Here the doc comments ARE the surface under
30
+ * test: four of the five instances this test was written for lived in JSDoc.
31
+ *
32
+ * Identifiers are matched against the UNION of everything exported anywhere under
33
+ * `src/icons`, because references legitimately target the barrel
34
+ * (`../icons`), a single icon file (`../icons/Check`) and `../icons/common` alike.
35
+ */
36
+
37
+ const SRC = join(__dirname, '..');
38
+ const REPO_ROOT = join(SRC, '..');
39
+ const ICONS_DIR = join(SRC, 'icons');
40
+
41
+ const SCANNED_EXTENSIONS = ['.ts', '.tsx', '.mdx', '.md'];
42
+
43
+ /** True for a specifier that resolves to an icons module, package or relative. */
44
+ const ICONS_MODULE = /(?:^|\/)icons(?:\/|$)/;
45
+
46
+ function walk(dir: string, out: string[] = []): string[] {
47
+ for (const entry of readdirSync(dir)) {
48
+ // Agent worktrees under `.claude/` are full copies of this repo — scanning
49
+ // them would double every file and report another checkout's problems.
50
+ if (entry === 'node_modules' || entry === 'lib' || entry.startsWith('.')) continue;
51
+ const full = join(dir, entry);
52
+ if (statSync(full).isDirectory()) walk(full, out);
53
+ else if (SCANNED_EXTENSIONS.includes(extname(full))) out.push(full);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Every identifier a module exports, across every export shape in use here. */
59
+ function exportedNames(source: string): string[] {
60
+ const found = new Set<string>();
61
+
62
+ // `export const X` / `export function X` / `export class X` /
63
+ // `export type X =` / `export interface X`
64
+ for (const m of source.matchAll(
65
+ /export\s+(?:declare\s+)?(?:const|let|var|function|class|type|interface)\s+([A-Za-z0-9_$]+)/g,
66
+ )) {
67
+ if (m[1]) found.add(m[1]);
68
+ }
69
+
70
+ // `export { A, B as C }` and `export type { A, B } from './x'` — the form that
71
+ // carries `IconStyle` / `Props` out of `icons/common.tsx` through the barrel.
72
+ // Missing it is what made an earlier pass of this check report two phantom
73
+ // failures, and a check with known noise is a check somebody disables.
74
+ for (const m of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
75
+ for (const name of namedBindings(m[1] ?? '', 'export')) found.add(name);
76
+ }
77
+
78
+ return [...found];
79
+ }
80
+
81
+ /**
82
+ * The bindings a brace list introduces.
83
+ *
84
+ * `as` means opposite things in the two directions: `export { A as B }` publishes
85
+ * `B`, while `import { A as B }` reads `A`. Getting that backwards would make the
86
+ * check blind to exactly the imports it exists to catch.
87
+ */
88
+ function namedBindings(list: string, direction: 'import' | 'export'): string[] {
89
+ const names: string[] = [];
90
+ for (const raw of list.split(',')) {
91
+ const part = raw.trim().replace(/^type\s+/, '');
92
+ if (!part) continue;
93
+ const sides = part.split(/\s+as\s+/).map((s) => s.trim());
94
+ const name = direction === 'export' ? sides[sides.length - 1] : sides[0];
95
+ if (name && /^[A-Za-z0-9_$]+$/.test(name) && name !== 'default') names.push(name);
96
+ }
97
+ return names;
98
+ }
99
+
100
+ /** The union of every identifier exported anywhere under `src/icons`. */
101
+ function iconExportUnion(): Set<string> {
102
+ const union = new Set<string>();
103
+ for (const file of walk(ICONS_DIR)) {
104
+ for (const name of exportedNames(readFileSync(file, 'utf8'))) union.add(name);
105
+ }
106
+ return union;
107
+ }
108
+
109
+ interface Unresolved {
110
+ line: number;
111
+ ref: string;
112
+ }
113
+
114
+ /** Every icon reference in `source` that `exported` cannot satisfy. */
115
+ function unresolvedIconRefs(source: string, exported: Set<string>): Unresolved[] {
116
+ const problems: Unresolved[] = [];
117
+
118
+ source.split('\n').forEach((text, index) => {
119
+ const line = index + 1;
120
+
121
+ for (const m of text.matchAll(/\bIcons\.([A-Za-z0-9_$]+)/g)) {
122
+ if (m[1] && !exported.has(m[1])) problems.push({ line, ref: `Icons.${m[1]}` });
123
+ }
124
+
125
+ // Braces only: `import * as Icons from …` is the correct namespace form, and a
126
+ // bare `import '…/icons'` binds nothing.
127
+ const imported = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/.exec(text);
128
+ const specifier = imported?.[2];
129
+ if (imported && specifier && ICONS_MODULE.test(specifier)) {
130
+ for (const name of namedBindings(imported[1] ?? '', 'import')) {
131
+ if (!exported.has(name)) problems.push({ line, ref: `{ ${name} } from '${specifier}'` });
132
+ }
133
+ }
134
+ });
135
+
136
+ return problems;
137
+ }
138
+
139
+ const ICON_EXPORTS = iconExportUnion();
140
+
141
+ const SCANNED = [
142
+ ...walk(SRC),
143
+ ...walk(join(REPO_ROOT, 'docs')),
144
+ join(REPO_ROOT, 'README.md'),
145
+ join(REPO_ROOT, 'AGENTS.md'),
146
+ ];
147
+
148
+ /**
149
+ * This file is the one exclusion, and it has to be: it SPELLS the mistakes it
150
+ * forbids — in its probes, in the doc comment above, and in the `Icons.${…}`
151
+ * template that formats a finding. Nothing outside a test copies an example out of
152
+ * a test. The list is pinned to exactly one path in the first assertion below, so
153
+ * it can neither grow quietly nor become a silent no-op through a typo.
154
+ */
155
+ const EXCLUDED = [join(__dirname, 'icon-references.test.ts')];
156
+ const CHECKED = SCANNED.filter((file) => !EXCLUDED.includes(file));
157
+
158
+ describe('icon reference integrity', () => {
159
+ it('reads a plausible repo — neither list may be empty or truncated', () => {
160
+ // Without these floors a broken traversal or a broken extractor makes every
161
+ // assertion below pass by finding nothing at all.
162
+ expect(ICON_EXPORTS.size).toBeGreaterThan(200);
163
+ expect(CHECKED.length).toBeGreaterThan(300);
164
+ expect(CHECKED).toContain(join(SRC, 'tab-bar', 'types.ts'));
165
+ expect(CHECKED).toContain(join(REPO_ROOT, 'docs', 'tab-bar.mdx'));
166
+
167
+ // Exactly one file is exempt, and it really is in the scan — a misspelled path
168
+ // here would make the exemption a no-op and the suite fail on itself instead.
169
+ expect(EXCLUDED).toHaveLength(1);
170
+ expect(SCANNED).toContain(EXCLUDED[0]);
171
+ expect(CHECKED).not.toContain(EXCLUDED[0]);
172
+ });
173
+
174
+ it('collects every export shape src/icons uses, types included', () => {
175
+ // The suffixed component itself, the helpers, and the two TYPE exports that an
176
+ // earlier version of this check mistook for phantom references.
177
+ for (const name of [
178
+ 'Home_Stroke2_Corner0_Rounded',
179
+ 'PlusLarge_Stroke2_Corner0_Rounded',
180
+ 'sizes',
181
+ 'useCommonSVGProps',
182
+ 'createSinglePathSVG',
183
+ 'IconStyle',
184
+ 'Props',
185
+ ]) {
186
+ expect([...ICON_EXPORTS]).toContain(name);
187
+ }
188
+ // And nothing shortened is in there, which is the premise of the whole test.
189
+ expect(ICON_EXPORTS.has('Home')).toBe(false);
190
+ expect(ICON_EXPORTS.has('Icons')).toBe(false);
191
+ });
192
+
193
+ it('resolves the export/import directions of `as` correctly', () => {
194
+ // `export { A as B }` publishes B; `import { A as B }` reads A.
195
+ expect(exportedNames("export { TimesLarge_Stroke2_Corner0_Rounded as X } from './Times';")).toEqual(
196
+ ['X'],
197
+ );
198
+ expect(namedBindings('Check_Stroke2_Corner0_Rounded as CheckIcon', 'import')).toEqual([
199
+ 'Check_Stroke2_Corner0_Rounded',
200
+ ]);
201
+ });
202
+
203
+ it('flags the two mistakes that have actually been made', () => {
204
+ const probe = [
205
+ "import { Icons } from '@oxyhq/bloom/icons';",
206
+ 'const a = <Icons.Home />;',
207
+ "import { Bell, Home } from '@oxyhq/bloom/icons';",
208
+ " * A doc comment saying `<Icons.Lock size=\"sm\" />`, which is where four of these lived.",
209
+ ].join('\n');
210
+
211
+ expect(unresolvedIconRefs(probe, ICON_EXPORTS)).toEqual([
212
+ { line: 1, ref: "{ Icons } from '@oxyhq/bloom/icons'" },
213
+ { line: 2, ref: 'Icons.Home' },
214
+ { line: 3, ref: "{ Bell } from '@oxyhq/bloom/icons'" },
215
+ { line: 3, ref: "{ Home } from '@oxyhq/bloom/icons'" },
216
+ { line: 4, ref: 'Icons.Lock' },
217
+ ]);
218
+ });
219
+
220
+ it('leaves every correct form alone', () => {
221
+ const probe = [
222
+ "import * as Icons from '@oxyhq/bloom/icons';",
223
+ "import { Icons } from '@oxyhq/bloom';",
224
+ 'const a = <Icons.Home_Stroke2_Corner0_Rounded />;',
225
+ "import { Check_Stroke2_Corner0_Rounded as CheckIcon } from '../icons/Check';",
226
+ "import { type IconStyle, type Props as SVGIconProps } from '../icons/common';",
227
+ '| `Icons` | `export * as Icons` | keep namespace | `Icons.*` |',
228
+ "import { useState } from 'react';",
229
+ ].join('\n');
230
+
231
+ expect(unresolvedIconRefs(probe, ICON_EXPORTS)).toEqual([]);
232
+ });
233
+
234
+ it('every icon reference in src, docs, README and AGENTS resolves', () => {
235
+ const problems = CHECKED.flatMap((file) =>
236
+ unresolvedIconRefs(readFileSync(file, 'utf8'), ICON_EXPORTS).map(
237
+ ({ line, ref }) => `${relative(REPO_ROOT, file)}:${line} ${ref}`,
238
+ ),
239
+ );
240
+
241
+ expect(problems).toEqual([]);
242
+ });
243
+ });
@@ -4,7 +4,8 @@ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
4
4
  export interface BenefitRowProps {
5
5
  /**
6
6
  * Leading icon — rendered inside a rounded `fill-secondary` square. Pass a
7
- * Bloom icon element (e.g. `<Icons.Lock size="sm" />`) or any node.
7
+ * Bloom icon element (e.g. `<Icons.Lock_Stroke2_Corner0_Rounded size="sm" />`,
8
+ * suffix included — there is no shortened alias) or any node.
8
9
  */
9
10
  icon: ReactNode;
10
11
  /**
package/src/fab/types.ts CHANGED
@@ -63,8 +63,10 @@ export interface FabProps {
63
63
 
64
64
  /**
65
65
  * The icon to render inside the FAB. Typically a Bloom icon element
66
- * (`<Icons.Plus .../>`). For an icon-only FAB this is the whole content; for
67
- * the extended variant it sits to the left of the `label`.
66
+ * (`<Icons.PlusLarge_Stroke2_Corner0_Rounded />` icon exports carry their full
67
+ * style and corner suffix, and there is no shortened alias). For an icon-only
68
+ * FAB this is the whole content; for the extended variant it sits to the left of
69
+ * the `label`.
68
70
  *
69
71
  * `children` is accepted as an alias/override of `icon` for ergonomics — if
70
72
  * both are given, `icon` wins and `children` is ignored.
@@ -106,7 +106,7 @@ type FillableElement = ReactElement<{ fill?: unknown }>;
106
106
  * Inject `color` as the icon's `fill` — but only as a FALLBACK: if the caller
107
107
  * already set an explicit `fill` on the icon element, it wins and the node is
108
108
  * returned untouched. Bloom icons resolve their paint as `fill ?? style.color`,
109
- * so a bare `<Icons.X />` gets the theme-aware color for free while an
109
+ * so any bare Bloom icon element gets the theme-aware color for free while an
110
110
  * explicitly-colored icon is never overridden. Non-element children (plain
111
111
  * text/strings) pass through unchanged.
112
112
  */
@@ -18,7 +18,8 @@ export interface FrostedIconButtonProps {
18
18
 
19
19
  /**
20
20
  * The icon to render, centered inside the circle. Typically a Bloom icon
21
- * element (`<Icons.ArrowLeft… />`). If the icon does not set its own `fill`,
21
+ * element (`<Icons.ArrowLeft_Stroke2_Corner0_Rounded />` icon exports carry
22
+ * their full style and corner suffix). If the icon does not set its own `fill`,
22
23
  * the button injects the theme-aware icon color (foreground when frosted,
23
24
  * `primaryForeground` when `active`) as a fallback so a bare icon is colored
24
25
  * correctly out of the box; an explicit `fill` on the icon always wins.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
3
+ * (MIT © 2026 David Mokos). See the top-level NOTICE.
4
+ *
5
+ * The tab bar's NON-GLASS capsule surface: a plain animated view filled with the
6
+ * theme's near-opaque fallback. Android, pre-iOS-26 devices and any build without
7
+ * a usable `expo-glass-effect` all paint this.
8
+ *
9
+ * It lives in its own module because TWO variants render it and one of them
10
+ * cannot reach the other: Metro resolves a bare `./surface` to
11
+ * `surface.native.tsx` no matter which file asks, so `surface.native.tsx`
12
+ * importing its neutral sibling for the fallback would import ITSELF. A name with
13
+ * no platform variants resolves to this one file everywhere — which is the whole
14
+ * point, so please do not fold it back into `surface.tsx`.
15
+ */
16
+ import { StyleSheet } from 'react-native';
17
+ import Animated from 'react-native-reanimated';
18
+
19
+ import type { TabBarSurfaceProps } from './shared';
20
+
21
+ export function SolidTabBarSurface({ theme, style }: TabBarSurfaceProps) {
22
+ return (
23
+ <Animated.View
24
+ style={[
25
+ StyleSheet.absoluteFill,
26
+ styles.surface,
27
+ { backgroundColor: theme.solidFallback },
28
+ style,
29
+ ]}
30
+ />
31
+ );
32
+ }
33
+
34
+ SolidTabBarSurface.displayName = 'SolidTabBarSurface';
35
+
36
+ const styles = StyleSheet.create({
37
+ surface: {
38
+ borderCurve: 'continuous',
39
+ },
40
+ });
@@ -2,7 +2,8 @@
2
2
  * Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
3
3
  * (MIT © 2026 David Mokos). See the top-level NOTICE.
4
4
  *
5
- * NATIVE variant of the tab bar's capsule surface — real liquid glass.
5
+ * NATIVE variant of the tab bar's capsule surface — real liquid glass, degrading
6
+ * to the solid surface whenever the glass is not actually usable.
6
7
  *
7
8
  * `expo-glass-effect` is imported STATICALLY and this file is the only place it
8
9
  * appears: Metro selects `.native` on iOS/Android, so the import never reaches
@@ -14,18 +15,88 @@
14
15
  *
15
16
  * `isLiquidGlassAvailable()` is false on Android and pre-iOS-26, where the
16
17
  * package's `GlassView` has no material to render; those platforms get the same
17
- * opaque fallback the neutral variant paints.
18
+ * opaque fallback the neutral variant paints. "Installed" is not "usable"
19
+ * though, and BOTH remaining hazards sit at MODULE scope, which is why neither
20
+ * can be handled by a branch inside the component:
21
+ *
22
+ * 1. THE NATIVE MODULE. On iOS `isLiquidGlassAvailable()` resolves
23
+ * `ExpoGlassEffect` through `requireNativeModule`, which THROWS
24
+ * `Cannot find native module …` by contract when that module is not in the
25
+ * binary. For these apps it is a normal deploy state rather than a
26
+ * misconfiguration: an OTA JS update can land on a binary built before the
27
+ * package was linked, while the JS side always resolves because expo-router
28
+ * depends on it. Unguarded, the throw escapes render and takes down the whole
29
+ * screen the bar is on.
30
+ * 2. THE EXPORTS. `Animated.createAnimatedComponent(GlassView)` runs at IMPORT
31
+ * time, so a tree that resolves the specifier to something without
32
+ * `GlassView` hands React an `undefined` element type before any render
33
+ * happens.
34
+ *
35
+ * `isGlassEffectAPIAvailable()` is upstream's own third condition: on some iOS 26
36
+ * betas `UIGlassEffect` is missing and touching `GlassView` crashes NATIVELY,
37
+ * below JS (expo/expo#40911). It first shipped in expo-glass-effect 0.1.9, which is
38
+ * exactly why the peer floor is `>=0.1.9`: this file imports the symbol STATICALLY,
39
+ * and a floor predating it would be the same incoherence as declaring a
40
+ * statically-imported package optional. The `typeof` guard below is still not
41
+ * redundant — a peer range only WARNS, so a consumer can resolve an older copy
42
+ * anyway. A missing probe therefore means "not applicable", never "unavailable";
43
+ * reading it the other way would delete the glass look from every working device.
44
+ *
45
+ * Everything that fails routes to the SAME solid surface Android already paints —
46
+ * the intended fallback path, not a new one.
18
47
  */
19
- import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
48
+ import { GlassView, isGlassEffectAPIAvailable, isLiquidGlassAvailable } from 'expo-glass-effect';
20
49
  import { StyleSheet } from 'react-native';
21
50
  import Animated from 'react-native-reanimated';
22
51
 
23
52
  import type { TabBarSurfaceProps } from './shared';
53
+ import { SolidTabBarSurface } from './surface-solid';
54
+
55
+ /**
56
+ * The animated glass view, or `null` when the package did not deliver the two
57
+ * exports this file needs. Built once, and guarded HERE rather than in the
58
+ * component because `createAnimatedComponent` runs on import (hazard 2 above).
59
+ */
60
+ const AnimatedGlassView =
61
+ Boolean(GlassView) && typeof isLiquidGlassAvailable === 'function'
62
+ ? Animated.createAnimatedComponent(GlassView)
63
+ : null;
24
64
 
25
- const AnimatedGlassView = Animated.createAnimatedComponent(GlassView);
65
+ /** Memo for {@link glassIsUsable} — the answer cannot change within a session. */
66
+ let glassUsable: boolean | undefined;
67
+
68
+ /**
69
+ * Whether this build can really render glass. Probed once: the native lookup
70
+ * behind it is not free, and on the failing path it throws.
71
+ *
72
+ * The `catch` reports rather than swallows. A bar that silently painted itself
73
+ * opaque on every iOS device would be far more expensive to find than one warning
74
+ * line, and reaching this branch always means a real, fixable packaging mistake.
75
+ * Warning exactly once falls out of the memo — the body runs at most one time.
76
+ */
77
+ function glassIsUsable(): boolean {
78
+ if (glassUsable !== undefined) return glassUsable;
79
+
80
+ try {
81
+ glassUsable =
82
+ isLiquidGlassAvailable() &&
83
+ (typeof isGlassEffectAPIAvailable !== 'function' || isGlassEffectAPIAvailable());
84
+ } catch (error) {
85
+ glassUsable = false;
86
+ // eslint-disable-next-line no-console
87
+ console.warn(
88
+ '[Bloom] TabBar: expo-glass-effect could not report liquid-glass availability, ' +
89
+ 'so the bar is painting its solid surface. Install expo-glass-effect and ' +
90
+ 'rebuild the native app to get the glass capsule.',
91
+ error,
92
+ );
93
+ }
94
+
95
+ return glassUsable;
96
+ }
26
97
 
27
98
  export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
28
- if (isLiquidGlassAvailable()) {
99
+ if (AnimatedGlassView !== null && glassIsUsable()) {
29
100
  return (
30
101
  <AnimatedGlassView
31
102
  glassEffectStyle="regular"
@@ -39,16 +110,7 @@ export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
39
110
  );
40
111
  }
41
112
 
42
- return (
43
- <Animated.View
44
- style={[
45
- StyleSheet.absoluteFill,
46
- styles.surface,
47
- { backgroundColor: theme.solidFallback },
48
- style,
49
- ]}
50
- />
51
- );
113
+ return <SolidTabBarSurface theme={theme} style={style} />;
52
114
  }
53
115
 
54
116
  TabBarSurface.displayName = 'TabBarSurface';
@@ -2,8 +2,7 @@
2
2
  * Ported from expo-glass-tabs v0.1.1 — src/glass-tab-bar.tsx
3
3
  * (MIT © 2026 David Mokos). See the top-level NOTICE.
4
4
  *
5
- * NEUTRAL default variant of the tab bar's capsule surface: a plain animated
6
- * view filled with the theme's solid fallback.
5
+ * NEUTRAL default variant of the tab bar's capsule surface: the non-glass one.
7
6
  *
8
7
  * This is the file resolved by everything that does NOT do React Native's
9
8
  * platform-extension resolution — a consumer's `tsc` (which follows Bloom's
@@ -14,34 +13,16 @@
14
13
  * resolution outright. Same three-way split, for the same reason, as
15
14
  * `theme/native-root-vars{,.native,.web}.ts`.
16
15
  *
16
+ * The surface itself lives in `surface-solid.tsx`, because `surface.native.tsx`
17
+ * renders it as its own fallback and cannot import this file to get it — Metro
18
+ * resolves `./surface` back to `surface.native.tsx`, i.e. to itself. This module's
19
+ * job is to give that one implementation the name every entry module imports.
20
+ *
17
21
  * The variants — all three implement `TabBarSurfaceProps` exactly, so the entry
18
22
  * modules can swap them freely:
19
- * - `surface.native.tsx` — iOS/Android (Metro), real `UIGlassEffect` glass;
23
+ * - `surface.native.tsx` — iOS/Android (Metro), real `UIGlassEffect` glass, with
24
+ * the solid surface as its fallback;
20
25
  * - `surface.web.tsx` — web, a CSS `backdrop-filter`;
21
- * - this file — everywhere else, the opaque fallback.
26
+ * - this file — everywhere else, the opaque fallback alone.
22
27
  */
23
- import { StyleSheet } from 'react-native';
24
- import Animated from 'react-native-reanimated';
25
-
26
- import type { TabBarSurfaceProps } from './shared';
27
-
28
- export function TabBarSurface({ theme, style }: TabBarSurfaceProps) {
29
- return (
30
- <Animated.View
31
- style={[
32
- StyleSheet.absoluteFill,
33
- styles.surface,
34
- { backgroundColor: theme.solidFallback },
35
- style,
36
- ]}
37
- />
38
- );
39
- }
40
-
41
- TabBarSurface.displayName = 'TabBarSurface';
42
-
43
- const styles = StyleSheet.create({
44
- surface: {
45
- borderCurve: 'continuous',
46
- },
47
- });
28
+ export { SolidTabBarSurface as TabBarSurface } from './surface-solid';
@@ -19,9 +19,12 @@ export type TabBarItem = {
19
19
  /** Text under the icon; fades out and is clipped when the bar minimizes. */
20
20
  label: string;
21
21
  /**
22
- * The glyph, as a Bloom icon element (`<Icons.Home />`) or any node. This is
23
- * the primary icon API same convention as `Tabs`/`TabsTrigger` and
24
- * `FrostedIconButton`. The bar renders it TWICE (an inactive layer with the
22
+ * The glyph, as a Bloom icon element (`<Icons.Home_Stroke2_Corner0_Rounded />`)
23
+ * or any node. Every `@oxyhq/bloom/icons` export carries its full style and
24
+ * corner suffix; a shortened `Home` is not an export, and a name that does not
25
+ * exist is simply `undefined`, which React then rejects as an invalid element
26
+ * type. This is the primary icon API — same convention as `Tabs`/`TabsTrigger`
27
+ * and `FrostedIconButton`. The bar renders it TWICE (an inactive layer with the
25
28
  * active layer crossfading on top), injecting the layer's tint as the icon's
26
29
  * `fill` unless the element already sets one.
27
30
  */