@oxyhq/bloom 0.51.0 → 0.52.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.
Files changed (122) hide show
  1. package/lib/commonjs/frosted-icon-button/shared.js +11 -1
  2. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -1
  3. package/lib/commonjs/tab-bar/TabBarBase.js +30 -5
  4. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  5. package/lib/commonjs/tab-bar/glyph.js +9 -4
  6. package/lib/commonjs/tab-bar/glyph.js.map +1 -1
  7. package/lib/commonjs/tab-bar/glyph.native.js +7 -2
  8. package/lib/commonjs/tab-bar/glyph.native.js.map +1 -1
  9. package/lib/commonjs/tab-bar/index.js +7 -0
  10. package/lib/commonjs/tab-bar/index.js.map +1 -1
  11. package/lib/commonjs/tab-bar/index.web.js +7 -0
  12. package/lib/commonjs/tab-bar/index.web.js.map +1 -1
  13. package/lib/commonjs/tab-bar/shared.js +32 -1
  14. package/lib/commonjs/tab-bar/shared.js.map +1 -1
  15. package/lib/commonjs/tab-bar/surface-solid.js +43 -0
  16. package/lib/commonjs/tab-bar/surface-solid.js.map +1 -0
  17. package/lib/commonjs/tab-bar/surface.js +5 -42
  18. package/lib/commonjs/tab-bar/surface.js.map +1 -1
  19. package/lib/commonjs/tab-bar/surface.native.js +65 -8
  20. package/lib/commonjs/tab-bar/surface.native.js.map +1 -1
  21. package/lib/commonjs/tab-bar/use-reserved-space.js +50 -0
  22. package/lib/commonjs/tab-bar/use-reserved-space.js.map +1 -0
  23. package/lib/module/frosted-icon-button/shared.js +11 -1
  24. package/lib/module/frosted-icon-button/shared.js.map +1 -1
  25. package/lib/module/tab-bar/TabBarBase.js +31 -6
  26. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  27. package/lib/module/tab-bar/glyph.js +9 -4
  28. package/lib/module/tab-bar/glyph.js.map +1 -1
  29. package/lib/module/tab-bar/glyph.native.js +7 -2
  30. package/lib/module/tab-bar/glyph.native.js.map +1 -1
  31. package/lib/module/tab-bar/index.js +1 -0
  32. package/lib/module/tab-bar/index.js.map +1 -1
  33. package/lib/module/tab-bar/index.web.js +1 -0
  34. package/lib/module/tab-bar/index.web.js.map +1 -1
  35. package/lib/module/tab-bar/shared.js +30 -0
  36. package/lib/module/tab-bar/shared.js.map +1 -1
  37. package/lib/module/tab-bar/surface-solid.js +37 -0
  38. package/lib/module/tab-bar/surface-solid.js.map +1 -0
  39. package/lib/module/tab-bar/surface.js +10 -23
  40. package/lib/module/tab-bar/surface.js.map +1 -1
  41. package/lib/module/tab-bar/surface.native.js +68 -9
  42. package/lib/module/tab-bar/surface.native.js.map +1 -1
  43. package/lib/module/tab-bar/use-reserved-space.js +46 -0
  44. package/lib/module/tab-bar/use-reserved-space.js.map +1 -0
  45. package/lib/typescript/commonjs/benefit-list/types.d.ts +2 -1
  46. package/lib/typescript/commonjs/benefit-list/types.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/fab/types.d.ts +4 -2
  48. package/lib/typescript/commonjs/fab/types.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +11 -1
  50. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +2 -1
  52. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/tab-bar/glyph.d.ts +1 -1
  55. package/lib/typescript/commonjs/tab-bar/glyph.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts +1 -1
  57. package/lib/typescript/commonjs/tab-bar/glyph.native.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/tab-bar/index.d.ts +1 -0
  59. package/lib/typescript/commonjs/tab-bar/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/tab-bar/index.web.d.ts +1 -0
  61. package/lib/typescript/commonjs/tab-bar/index.web.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/tab-bar/shared.d.ts +25 -0
  63. package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts +6 -0
  65. package/lib/typescript/commonjs/tab-bar/surface-solid.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/tab-bar/surface.d.ts +28 -5
  67. package/lib/typescript/commonjs/tab-bar/surface.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/tab-bar/surface.native.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/tab-bar/types.d.ts +43 -4
  70. package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts +25 -0
  72. package/lib/typescript/commonjs/tab-bar/use-reserved-space.d.ts.map +1 -0
  73. package/lib/typescript/module/benefit-list/types.d.ts +2 -1
  74. package/lib/typescript/module/benefit-list/types.d.ts.map +1 -1
  75. package/lib/typescript/module/fab/types.d.ts +4 -2
  76. package/lib/typescript/module/fab/types.d.ts.map +1 -1
  77. package/lib/typescript/module/frosted-icon-button/shared.d.ts +11 -1
  78. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -1
  79. package/lib/typescript/module/frosted-icon-button/types.d.ts +2 -1
  80. package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -1
  81. package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
  82. package/lib/typescript/module/tab-bar/glyph.d.ts +1 -1
  83. package/lib/typescript/module/tab-bar/glyph.d.ts.map +1 -1
  84. package/lib/typescript/module/tab-bar/glyph.native.d.ts +1 -1
  85. package/lib/typescript/module/tab-bar/glyph.native.d.ts.map +1 -1
  86. package/lib/typescript/module/tab-bar/index.d.ts +1 -0
  87. package/lib/typescript/module/tab-bar/index.d.ts.map +1 -1
  88. package/lib/typescript/module/tab-bar/index.web.d.ts +1 -0
  89. package/lib/typescript/module/tab-bar/index.web.d.ts.map +1 -1
  90. package/lib/typescript/module/tab-bar/shared.d.ts +25 -0
  91. package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
  92. package/lib/typescript/module/tab-bar/surface-solid.d.ts +6 -0
  93. package/lib/typescript/module/tab-bar/surface-solid.d.ts.map +1 -0
  94. package/lib/typescript/module/tab-bar/surface.d.ts +28 -5
  95. package/lib/typescript/module/tab-bar/surface.d.ts.map +1 -1
  96. package/lib/typescript/module/tab-bar/surface.native.d.ts.map +1 -1
  97. package/lib/typescript/module/tab-bar/types.d.ts +43 -4
  98. package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
  99. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts +25 -0
  100. package/lib/typescript/module/tab-bar/use-reserved-space.d.ts.map +1 -0
  101. package/package.json +2 -8
  102. package/src/__tests__/TabBar.test.tsx +82 -2
  103. package/src/__tests__/TabBarGlassFallback.test.tsx +234 -0
  104. package/src/__tests__/TabBarLongPress.test.tsx +258 -0
  105. package/src/__tests__/TabBarReservedSpace.test.tsx +150 -0
  106. package/src/__tests__/TabBarWebFork.test.ts +47 -0
  107. package/src/__tests__/icon-references.test.ts +243 -0
  108. package/src/benefit-list/types.ts +2 -1
  109. package/src/fab/types.ts +4 -2
  110. package/src/frosted-icon-button/shared.ts +11 -1
  111. package/src/frosted-icon-button/types.ts +2 -1
  112. package/src/tab-bar/TabBarBase.tsx +39 -5
  113. package/src/tab-bar/glyph.native.tsx +5 -2
  114. package/src/tab-bar/glyph.tsx +8 -4
  115. package/src/tab-bar/index.ts +1 -0
  116. package/src/tab-bar/index.web.tsx +1 -0
  117. package/src/tab-bar/shared.ts +38 -0
  118. package/src/tab-bar/surface-solid.tsx +40 -0
  119. package/src/tab-bar/surface.native.tsx +77 -15
  120. package/src/tab-bar/surface.tsx +10 -29
  121. package/src/tab-bar/types.ts +43 -4
  122. package/src/tab-bar/use-reserved-space.ts +44 -0
@@ -49,7 +49,9 @@ const NEUTRAL_AND_WEB_FILES = [
49
49
  'tab-bar/shared.ts',
50
50
  'tab-bar/TabBarBase.tsx',
51
51
  'tab-bar/minimize-context.tsx',
52
+ 'tab-bar/use-reserved-space.ts',
52
53
  'tab-bar/surface.tsx',
54
+ 'tab-bar/surface-solid.tsx',
53
55
  'tab-bar/surface.web.tsx',
54
56
  'tab-bar/glyph.tsx',
55
57
  'progressive-blur/index.web.tsx',
@@ -128,6 +130,17 @@ describe('tab-bar platform split', () => {
128
130
  expect(moduleSpecifiers(read('tab-bar/glyph.native.tsx'))).toContain('expo-symbols');
129
131
  });
130
132
 
133
+ it('the native surface takes its fallback from surface-solid, never ./surface', () => {
134
+ // Metro resolves a bare `./surface` to `surface.native.tsx` from ANY caller,
135
+ // so importing the neutral sibling by that name would be a self-import — which
136
+ // is the entire reason `surface-solid.tsx` exists as a separate name.
137
+ const specifiers = moduleSpecifiers(read('tab-bar/surface.native.tsx'));
138
+ expect(specifiers).toContain('./surface-solid');
139
+ expect(specifiers).not.toContain('./surface');
140
+ // And the neutral variant must be that same component, not a second copy.
141
+ expect(moduleSpecifiers(read('tab-bar/surface.tsx'))).toContain('./surface-solid');
142
+ });
143
+
131
144
  it('the router packages live only under tab-bar/expo-router', () => {
132
145
  const router = moduleSpecifiers(read('tab-bar/expo-router/RouterTabBar.tsx'));
133
146
  expect(router.some((s) => isFrom(s, 'expo-router'))).toBe(true);
@@ -160,6 +173,8 @@ describe('tab-bar platform split', () => {
160
173
  describe('tab-bar published surface', () => {
161
174
  const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')) as {
162
175
  exports: Record<string, { browser?: { import?: string; require?: string } }>;
176
+ peerDependencies: Record<string, string>;
177
+ peerDependenciesMeta: Record<string, { optional?: boolean }>;
163
178
  };
164
179
 
165
180
  it('exports ./tab-bar with a browser condition → index.web.js', () => {
@@ -184,6 +199,38 @@ describe('tab-bar published surface', () => {
184
199
  expect(entry?.browser).toBeUndefined();
185
200
  });
186
201
 
202
+ it('declares the two Apple-only native peers REQUIRED, keeping their ranges', () => {
203
+ // `expo-glass-effect` and `expo-symbols` are imported STATICALLY, by
204
+ // `tab-bar/surface.native` and `tab-bar/glyph.native`. That is what settles
205
+ // this: optional means "you may omit this", but omitting it does not degrade
206
+ // — the native bundle fails to RESOLVE the specifier and the build dies. An
207
+ // optional peer plus a static import is an incoherent pair, and the only
208
+ // thing marking them optional would achieve is silencing the one warning
209
+ // that tells a consumer their build is about to break. Declared the same way
210
+ // `expo-blur` already is, for the same reason.
211
+ //
212
+ // The RANGE carries the second half: both files import symbols that arrived
213
+ // after 1.0 — `isGlassEffectAPIAvailable` first shipped in
214
+ // expo-glass-effect 0.1.9 — so an older installed copy is a real
215
+ // incompatibility, not merely an absent one.
216
+ for (const peer of ['expo-glass-effect', 'expo-symbols']) {
217
+ expect(pkg.peerDependencies[peer]).toMatch(/^>=\d/);
218
+ expect(pkg.peerDependenciesMeta[peer]).toBeUndefined();
219
+ }
220
+ });
221
+
222
+ it('keeps the cross-platform native peers REQUIRED', () => {
223
+ // `expo-blur` and `expo-image` are apple+android native modules imported from
224
+ // platform-NEUTRAL files (`progressive-blur/index.tsx`,
225
+ // `frosted-icon-button/FrostedIconButton.tsx`, `zoomable-image-gallery/`), so
226
+ // every native platform genuinely needs them. Marking them optional would
227
+ // silence the one warning that tells a consumer their build is about to fail.
228
+ for (const peer of ['expo-blur', 'expo-image']) {
229
+ expect(pkg.peerDependencies[peer]).toBeDefined();
230
+ expect(pkg.peerDependenciesMeta[peer]).toBeUndefined();
231
+ }
232
+ });
233
+
187
234
  it('the generator lists exactly the right subpaths as web-forked', () => {
188
235
  const gen = readFileSync(join(REPO_ROOT, 'scripts/generate-platform-exports.mjs'), 'utf8');
189
236
  const block = /const WEB_FORKED_SUBPATHS = new Set\(\[([\s\S]*?)\]\);/.exec(gen)?.[1];
@@ -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,9 +106,19 @@ 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
+ *
113
+ * KNOWN LIMIT, deliberately not papered over: an icon component that paints from
114
+ * a `color` PROP rather than from `fill`/`style.color` ignores this entirely, and
115
+ * does so SILENTLY — nothing throws, the glyph renders, it simply never takes the
116
+ * tint. `color` is not injected alongside `fill` because the prop is not ours to
117
+ * claim: components use it for semantic variants (`color="danger"`), so writing an
118
+ * `rgb(…)` string into it can produce a worse failure than the one it fixes, and
119
+ * an icon already carrying its own `color` would be clobbered. Consumers with such
120
+ * a set pre-color the element themselves; the tab bar additionally offers
121
+ * `TabBarItem.activeIcon`, which carries selection by shape instead of by tint.
112
122
  */
113
123
  export function applyIconColor(node: ReactNode, color: string): ReactNode {
114
124
  if (!isValidElement(node)) return node;
@@ -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.
@@ -49,10 +49,12 @@ import {
49
49
  ITEM_GAP,
50
50
  ITEM_PAD_V,
51
51
  LABEL_FONT_SIZE,
52
+ LONG_PRESS_MIN_DURATION,
52
53
  MINIMIZED_HEIGHT,
53
54
  MINIMIZED_INSET,
54
55
  ROW_PAD_H,
55
56
  SLIDE_SPRING,
57
+ tabBarBottomGap,
56
58
  useTabBarTheme,
57
59
  type TabBarGlyphProps,
58
60
  type TabBarSurfaceProps,
@@ -93,6 +95,7 @@ function TabBarBody({
93
95
  children,
94
96
  activeIndex,
95
97
  onIndexChange,
98
+ onIndexLongPress,
96
99
  theme: themeOverrides,
97
100
  haptics = true,
98
101
  style,
@@ -121,6 +124,14 @@ function TabBarBody({
121
124
  // scrubbing makes the content jump under the finger.
122
125
  const selectIndex = useCallback((index: number) => onIndexChange?.(index), [onIndexChange]);
123
126
 
127
+ // Long press is reported through the same shape, and deliberately does NOT
128
+ // select: it is a secondary action on the tab under the finger.
129
+ const longPressIndex = useCallback(
130
+ (index: number) => onIndexLongPress?.(index),
131
+ [onIndexLongPress],
132
+ );
133
+ const hasLongPress = onIndexLongPress !== undefined;
134
+
124
135
  // CONTROLLED path: the bar owns the highlight when `activeIndex` is provided,
125
136
  // so it tracks the prop even when a consumer renders custom children instead
126
137
  // of `TabBarButton`s. Syncing an imperative animation to a controlled prop is
@@ -200,11 +211,30 @@ function TabBarBody({
200
211
  runOnJS(selectIndex)(index);
201
212
  });
202
213
 
203
- return Gesture.Race(pan, tap);
214
+ if (!hasLongPress) return Gesture.Race(pan, tap);
215
+
216
+ // Long press: the ONLY path to one, because the detector consumes the
217
+ // bar's touches and a button's inner `Pressable` therefore never sees a
218
+ // real finger (see `TabBarButtonBody`). Added to the race only when a
219
+ // handler was supplied — an always-armed long press would cancel the pan
220
+ // for anyone who rests a finger before scrubbing, and no existing consumer
221
+ // asked for that. It cannot cost a tap either: the tap gesture gives up at
222
+ // 400ms, before this one can activate at 500ms, so the two are never
223
+ // candidates at the same instant. Resolves its index through the same
224
+ // `indexAtX` worklet as tap and pan, so all three agree on the geometry.
225
+ const longPress = Gesture.LongPress()
226
+ .minDuration(LONG_PRESS_MIN_DURATION)
227
+ .onStart((event) => {
228
+ runOnJS(longPressIndex)(Math.round(indexAtX(event.x, progress.value)));
229
+ });
230
+
231
+ return Gesture.Race(pan, tap, longPress);
204
232
  }, [
205
233
  windowWidth,
206
234
  tabCount,
207
235
  selectIndex,
236
+ hasLongPress,
237
+ longPressIndex,
208
238
  tick,
209
239
  isDragging,
210
240
  lastTicked,
@@ -287,7 +317,9 @@ function TabBarBody({
287
317
  };
288
318
  }, [progress, slideIndex, windowWidth, tabCount]);
289
319
 
290
- const bottomOffset = Math.max(insets.bottom - 16, 12);
320
+ // Shared with `useTabBarReservedSpace`, so a consumer reserving layout space
321
+ // for the bar can never drift from where the bar actually sits.
322
+ const bottomOffset = tabBarBottomGap(insets.bottom);
291
323
  const barContext = useMemo(
292
324
  () => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
293
325
  [slideIndex, isDragging, theme, activeIndex, selectIndex],
@@ -430,11 +462,13 @@ function TabBarButtonBody({
430
462
  }
431
463
  >
432
464
  <Animated.View style={[styles.itemBox, boxStyle]}>
433
- {/* Inactive glyph underneath, active glyph crossfading on top. */}
465
+ {/* Inactive glyph underneath, active glyph crossfading on top. `active`
466
+ tells each layer which one it is, so an item with an `activeIcon`
467
+ crossfades between two different nodes and not just two tints. */}
434
468
  <View>
435
- <Glyph item={item} tint={theme.inactiveTint} size={ICON_SIZE} />
469
+ <Glyph item={item} tint={theme.inactiveTint} size={ICON_SIZE} active={false} />
436
470
  <Animated.View style={[StyleSheet.absoluteFill, styles.glyphLayer, activeGlyphStyle]}>
437
- <Glyph item={item} tint={theme.activeTint} size={ICON_SIZE} />
471
+ <Glyph item={item} tint={theme.activeTint} size={ICON_SIZE} active />
438
472
  </Animated.View>
439
473
  </View>
440
474
  {/* Fades out and is clipped by the shrinking box — no layout anim. */}
@@ -17,7 +17,7 @@ import { Platform, View } from 'react-native';
17
17
  import { applyIconColor } from '../frosted-icon-button/shared';
18
18
  import type { TabBarGlyphProps } from './shared';
19
19
 
20
- export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
20
+ export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
21
21
  if (item.sfSymbol && Platform.OS === 'ios') {
22
22
  // `TabBarItem.sfSymbol` is a plain `string` because `types.ts` may import
23
23
  // from react/react-native only — pulling `expo-symbols`' `SFSymbol` union
@@ -36,9 +36,12 @@ export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
36
36
  );
37
37
  }
38
38
 
39
+ // Same node-swapping crossfade as the neutral variant: the active layer takes
40
+ // `activeIcon` when the item has one. An SF Symbol never reaches here, and is
41
+ // tinted natively above, so the tint crossfade remains its correct expression.
39
42
  return (
40
43
  <View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
41
- {applyIconColor(item.icon, tint)}
44
+ {applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
42
45
  </View>
43
46
  );
44
47
  }
@@ -2,8 +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
- * NEUTRAL default variant of a tab glyph: renders the item's own `icon` node,
6
- * tinted for this crossfade layer.
5
+ * NEUTRAL default variant of a tab glyph: renders the item's own `icon` node
6
+ * or its `activeIcon` on the active layer — tinted for this crossfade layer.
7
7
  *
8
8
  * `item.icon` is the primary icon API for the whole family — a Bloom icon
9
9
  * element, exactly like `TabsTrigger` and `FrostedIconButton` take. `sfSymbol`
@@ -17,13 +17,17 @@ import { View } from 'react-native';
17
17
  import { applyIconColor } from '../frosted-icon-button/shared';
18
18
  import type { TabBarGlyphProps } from './shared';
19
19
 
20
- export function TabBarGlyph({ item, tint, size }: TabBarGlyphProps) {
20
+ export function TabBarGlyph({ item, tint, size, active }: TabBarGlyphProps) {
21
21
  // The tint is injected as the icon's `fill` FALLBACK — an icon that sets its
22
22
  // own `fill` (a brand mark, say) keeps it, so the crossfade layers simply
23
23
  // render it twice at full opacity and it never flickers.
24
+ //
25
+ // `activeIcon` is what makes the crossfade able to swap NODES: an icon set
26
+ // whose selected state is a different SHAPE (outline → filled) cannot be
27
+ // expressed by tinting one node two ways. Absent, the layer renders `icon`.
24
28
  return (
25
29
  <View style={{ height: size, alignItems: 'center', justifyContent: 'center' }}>
26
- {applyIconColor(item.icon, tint)}
30
+ {applyIconColor(active ? (item.activeIcon ?? item.icon) : item.icon, tint)}
27
31
  </View>
28
32
  );
29
33
  }
@@ -29,4 +29,5 @@ export {
29
29
  useTabBarMinimized,
30
30
  } from './minimize-context';
31
31
  export type { MinimizeState } from './minimize-context';
32
+ export { useTabBarReservedSpace } from './use-reserved-space';
32
33
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
@@ -31,4 +31,5 @@ export {
31
31
  useTabBarMinimized,
32
32
  } from './minimize-context';
33
33
  export type { MinimizeState } from './minimize-context';
34
+ export { useTabBarReservedSpace } from './use-reserved-space';
34
35
  export type { TabBarButtonProps, TabBarItem, TabBarProps, TabBarTheme } from './types';
@@ -51,6 +51,27 @@ export const BLUR_BLEED = 44;
51
51
  /** Label type size. Small by design — the icon carries the meaning. */
52
52
  export const LABEL_FONT_SIZE = 9.5;
53
53
 
54
+ /**
55
+ * How much of the bottom safe-area inset the bar's own gap absorbs. The pill
56
+ * already floats clear of the content, so sitting the FULL inset below it
57
+ * strands it in the middle of the screen on a home-indicator device.
58
+ */
59
+ const SAFE_AREA_ABSORBED = 16;
60
+ /** Gap floor, for a device (or a browser) reporting no bottom inset at all. */
61
+ const MIN_BOTTOM_GAP = 12;
62
+
63
+ /**
64
+ * Vertical gap between the bottom of the pill and the bottom of the window.
65
+ *
66
+ * The safe-area inset is folded in HERE, which is why anything reserving layout
67
+ * space for the bar must go through `useTabBarReservedSpace` (which derives its
68
+ * number from this same function) rather than adding `insets.bottom` to a
69
+ * hardcoded height — that double-counts the inset.
70
+ */
71
+ export function tabBarBottomGap(bottomInset: number): number {
72
+ return Math.max(bottomInset - SAFE_AREA_ABSORBED, MIN_BOTTOM_GAP);
73
+ }
74
+
54
75
  /**
55
76
  * Slide spring: interruptible by design — rapid tab-hopping retargets with
56
77
  * preserved velocity. Slight under-damping gives the pill a tiny settle, safe
@@ -58,6 +79,15 @@ export const LABEL_FONT_SIZE = 9.5;
58
79
  */
59
80
  export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
60
81
 
82
+ /**
83
+ * How long a press must be held before `onIndexLongPress` fires.
84
+ *
85
+ * Comfortably above the tap gesture's own 400ms ceiling: the two can never both
86
+ * be candidates at the same instant, so arming the long press cannot cost the
87
+ * bar a tap. Matches the platform long-press feel (iOS ~0.5s).
88
+ */
89
+ export const LONG_PRESS_MIN_DURATION = 500;
90
+
61
91
  // ---------------------------------------------------------------------------
62
92
  // Theme
63
93
  // ---------------------------------------------------------------------------
@@ -156,4 +186,12 @@ export interface TabBarGlyphProps {
156
186
  tint: string;
157
187
  /** Rendered icon size in px ({@link ICON_SIZE}). */
158
188
  size: number;
189
+ /**
190
+ * Which crossfade layer this is: `false` for the inactive one underneath,
191
+ * `true` for the one fading in on top. The active layer renders
192
+ * `item.activeIcon` when the item has one, so a set that expresses selection
193
+ * by shape (outline → filled) crossfades between two different nodes rather
194
+ * than between two tints of the same node.
195
+ */
196
+ active: boolean;
159
197
  }
@@ -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
+ });