@oxyhq/bloom 0.35.7 → 0.36.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 (95) hide show
  1. package/lib/commonjs/content-panel/index.js +37 -9
  2. package/lib/commonjs/content-panel/index.js.map +1 -1
  3. package/lib/commonjs/content-panel/index.web.js +71 -15
  4. package/lib/commonjs/content-panel/index.web.js.map +1 -1
  5. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js +148 -0
  6. package/lib/commonjs/frosted-icon-button/FrostedIconButton.js.map +1 -0
  7. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +166 -0
  8. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -0
  9. package/lib/commonjs/frosted-icon-button/index.js +13 -0
  10. package/lib/commonjs/frosted-icon-button/index.js.map +1 -0
  11. package/lib/commonjs/frosted-icon-button/index.web.js +13 -0
  12. package/lib/commonjs/frosted-icon-button/index.web.js.map +1 -0
  13. package/lib/commonjs/frosted-icon-button/shared.js +112 -0
  14. package/lib/commonjs/frosted-icon-button/shared.js.map +1 -0
  15. package/lib/commonjs/frosted-icon-button/types.js +6 -0
  16. package/lib/commonjs/frosted-icon-button/types.js.map +1 -0
  17. package/lib/commonjs/index.js +10 -1
  18. package/lib/commonjs/index.js.map +1 -1
  19. package/lib/commonjs/index.web.js +46 -37
  20. package/lib/commonjs/index.web.js.map +1 -1
  21. package/lib/module/content-panel/index.js +37 -9
  22. package/lib/module/content-panel/index.js.map +1 -1
  23. package/lib/module/content-panel/index.web.js +71 -15
  24. package/lib/module/content-panel/index.web.js.map +1 -1
  25. package/lib/module/frosted-icon-button/FrostedIconButton.js +143 -0
  26. package/lib/module/frosted-icon-button/FrostedIconButton.js.map +1 -0
  27. package/lib/module/frosted-icon-button/FrostedIconButton.web.js +161 -0
  28. package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -0
  29. package/lib/module/frosted-icon-button/index.js +4 -0
  30. package/lib/module/frosted-icon-button/index.js.map +1 -0
  31. package/lib/module/frosted-icon-button/index.web.js +11 -0
  32. package/lib/module/frosted-icon-button/index.web.js.map +1 -0
  33. package/lib/module/frosted-icon-button/shared.js +107 -0
  34. package/lib/module/frosted-icon-button/shared.js.map +1 -0
  35. package/lib/module/frosted-icon-button/types.js +4 -0
  36. package/lib/module/frosted-icon-button/types.js.map +1 -0
  37. package/lib/module/index.js +1 -0
  38. package/lib/module/index.js.map +1 -1
  39. package/lib/module/index.web.js +1 -0
  40. package/lib/module/index.web.js.map +1 -1
  41. package/lib/typescript/commonjs/content-panel/index.d.ts +24 -6
  42. package/lib/typescript/commonjs/content-panel/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/content-panel/index.web.d.ts +33 -10
  44. package/lib/typescript/commonjs/content-panel/index.web.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts +5 -0
  46. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts +5 -0
  48. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/frosted-icon-button/index.d.ts +3 -0
  50. package/lib/typescript/commonjs/frosted-icon-button/index.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/frosted-icon-button/index.web.d.ts +3 -0
  52. package/lib/typescript/commonjs/frosted-icon-button/index.web.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts +60 -0
  54. package/lib/typescript/commonjs/frosted-icon-button/shared.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts +69 -0
  56. package/lib/typescript/commonjs/frosted-icon-button/types.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/index.d.ts +2 -0
  58. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/index.web.d.ts +2 -0
  60. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  61. package/lib/typescript/module/content-panel/index.d.ts +24 -6
  62. package/lib/typescript/module/content-panel/index.d.ts.map +1 -1
  63. package/lib/typescript/module/content-panel/index.web.d.ts +33 -10
  64. package/lib/typescript/module/content-panel/index.web.d.ts.map +1 -1
  65. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts +5 -0
  66. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.d.ts.map +1 -0
  67. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts +5 -0
  68. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -0
  69. package/lib/typescript/module/frosted-icon-button/index.d.ts +3 -0
  70. package/lib/typescript/module/frosted-icon-button/index.d.ts.map +1 -0
  71. package/lib/typescript/module/frosted-icon-button/index.web.d.ts +3 -0
  72. package/lib/typescript/module/frosted-icon-button/index.web.d.ts.map +1 -0
  73. package/lib/typescript/module/frosted-icon-button/shared.d.ts +60 -0
  74. package/lib/typescript/module/frosted-icon-button/shared.d.ts.map +1 -0
  75. package/lib/typescript/module/frosted-icon-button/types.d.ts +69 -0
  76. package/lib/typescript/module/frosted-icon-button/types.d.ts.map +1 -0
  77. package/lib/typescript/module/index.d.ts +2 -0
  78. package/lib/typescript/module/index.d.ts.map +1 -1
  79. package/lib/typescript/module/index.web.d.ts +2 -0
  80. package/lib/typescript/module/index.web.d.ts.map +1 -1
  81. package/package.json +17 -1
  82. package/src/__tests__/ContentPanelNesting.test.tsx +74 -0
  83. package/src/__tests__/FrostedIconButton.test.tsx +137 -0
  84. package/src/__tests__/FrostedIconButton.web.test.tsx +246 -0
  85. package/src/content-panel/index.tsx +47 -11
  86. package/src/content-panel/index.web.tsx +85 -17
  87. package/src/frosted-icon-button/FrostedIconButton.stories.tsx +65 -0
  88. package/src/frosted-icon-button/FrostedIconButton.tsx +156 -0
  89. package/src/frosted-icon-button/FrostedIconButton.web.tsx +207 -0
  90. package/src/frosted-icon-button/index.ts +2 -0
  91. package/src/frosted-icon-button/index.web.ts +9 -0
  92. package/src/frosted-icon-button/shared.ts +118 -0
  93. package/src/frosted-icon-button/types.ts +81 -0
  94. package/src/index.ts +2 -0
  95. package/src/index.web.ts +2 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import React from 'react';
6
+ import { act } from 'react';
7
+ import { createRoot, type Root } from 'react-dom/client';
8
+ import { getByRole, fireEvent } from '@testing-library/dom';
9
+ import { renderToStaticMarkup } from 'react-dom/server';
10
+ import '@testing-library/jest-dom';
11
+
12
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
13
+ import { FrostedIconButton } from '../frosted-icon-button/FrostedIconButton.web';
14
+ import { parseRgb } from '../theme/color-utils';
15
+
16
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ let container: HTMLDivElement;
19
+ let root: Root;
20
+
21
+ function mount(ui: React.ReactElement, mode: 'light' | 'dark' = 'dark'): HTMLElement {
22
+ act(() => {
23
+ root.render(
24
+ <BloomThemeProvider mode={mode} colorPreset="blue">
25
+ {ui}
26
+ </BloomThemeProvider>,
27
+ );
28
+ });
29
+ return container;
30
+ }
31
+
32
+ beforeEach(() => {
33
+ container = document.createElement('div');
34
+ document.body.appendChild(container);
35
+ root = createRoot(container);
36
+ });
37
+
38
+ afterEach(() => {
39
+ act(() => root.unmount());
40
+ container.remove();
41
+ });
42
+
43
+ // Mention's measured dark page background — the "invisible circle" bug composited
44
+ // a near-black `card` fill onto this and vanished. The frosted chip must read.
45
+ const PAGE_DARK = { r: 11, g: 11, b: 15 };
46
+
47
+ /** Composite an `rgba(...)` surface over an opaque background (source-over). */
48
+ function compositeOver(surface: string, bg: { r: number; g: number; b: number }) {
49
+ const base = parseRgb(surface);
50
+ const m = /rgba?\(([^)]+)\)/i.exec(surface);
51
+ const rawAlpha = m?.[1]?.split(',')[3];
52
+ const a = rawAlpha === undefined ? 1 : Number(rawAlpha);
53
+ if (!base) throw new Error(`not an rgba string: ${surface}`);
54
+ return {
55
+ r: Math.round(bg.r * (1 - a) + base.r * a),
56
+ g: Math.round(bg.g * (1 - a) + base.g * a),
57
+ b: Math.round(bg.b * (1 - a) + base.b * a),
58
+ };
59
+ }
60
+
61
+ describe('FrostedIconButton.web', () => {
62
+ it('renders a real <button> with the accessible label', () => {
63
+ const c = mount(<FrostedIconButton accessibilityLabel="Back" icon={<span>x</span>} />);
64
+ const btn = getByRole(c, 'button', { name: 'Back' });
65
+ expect(btn.tagName).toBe('BUTTON');
66
+ expect(btn).toHaveClass('bloom-frosted-icon-btn');
67
+ });
68
+
69
+ it('self-injects its interaction stylesheet once', () => {
70
+ mount(<FrostedIconButton accessibilityLabel="Back" icon={<span>x</span>} />);
71
+ mount(<FrostedIconButton accessibilityLabel="Fwd" icon={<span>y</span>} />);
72
+ expect(document.querySelectorAll('#bloom-frosted-icon-button-web-css')).toHaveLength(1);
73
+ });
74
+
75
+ // THE bug this component exists to prevent: on a dark SOLID page, a `card`-fill
76
+ // circle composited to near-black and vanished. Prove the frosted surface is a
77
+ // LIGHT tint that reads as a distinct, clearly-lighter circle on rgb(11,11,15).
78
+ describe('reads on a solid dark background (nothing behind it)', () => {
79
+ it('uses a low-opacity LIGHT tint distinguishable from the page', () => {
80
+ const c = mount(<FrostedIconButton accessibilityLabel="Back" icon={<span>x</span>} />);
81
+ const btn = getByRole(c, 'button');
82
+ const surface = btn.style.backgroundColor;
83
+
84
+ // Not the page color, and a genuine translucent (rgba) surface.
85
+ expect(surface).not.toBe('rgb(11, 11, 15)');
86
+ expect(surface).toMatch(/^rgba\(/);
87
+
88
+ // The tint's BASE color is light (each channel high) — a light frost, not a
89
+ // dark card fill.
90
+ const base = parseRgb(surface);
91
+ if (!base) throw new Error('unreachable');
92
+ expect(base.r).toBeGreaterThanOrEqual(200);
93
+ expect(base.g).toBeGreaterThanOrEqual(200);
94
+ expect(base.b).toBeGreaterThanOrEqual(200);
95
+
96
+ // Composited over the page it is clearly LIGHTER than rgb(11,11,15).
97
+ const composite = compositeOver(surface, PAGE_DARK);
98
+ expect(composite.r - PAGE_DARK.r).toBeGreaterThanOrEqual(15);
99
+ expect(composite.g - PAGE_DARK.g).toBeGreaterThanOrEqual(15);
100
+ expect(composite.b - PAGE_DARK.b).toBeGreaterThanOrEqual(15);
101
+ });
102
+
103
+ it('renders a hairline ring and a shadow that survive dark mode', () => {
104
+ const c = mount(<FrostedIconButton accessibilityLabel="Back" icon={<span>x</span>} />);
105
+ const btn = getByRole(c, 'button');
106
+ // Ring: a translucent border COLOR is present inline (per-instance token).
107
+ expect(btn.style.borderColor).toMatch(/^rgba\(/);
108
+ // The hairline border-style/width live in the self-injected stylesheet.
109
+ const css = document.getElementById('bloom-frosted-icon-button-web-css')?.textContent ?? '';
110
+ expect(css).toMatch(/border-style:\s*solid/);
111
+ expect(css).toMatch(/border-width:\s*1px/);
112
+ // Shadow is NOT killed in dark mode.
113
+ expect(btn.style.boxShadow).not.toBe('');
114
+ expect(btn.style.boxShadow).toMatch(/rgba?\(/);
115
+ });
116
+
117
+ // jsdom's CSS engine drops `backdrop-filter`, so assert it via SSR style
118
+ // serialization — which emits EVERY style key exactly as the browser receives
119
+ // it (react-dom writes the same inline style on a real DOM <button>).
120
+ it('emits a real CSS backdrop-filter blur when frosted (and drops it when active)', () => {
121
+ const frosted = renderToStaticMarkup(
122
+ <BloomThemeProvider mode="dark" colorPreset="blue">
123
+ <FrostedIconButton accessibilityLabel="Back" icon={<span>x</span>} />
124
+ </BloomThemeProvider>,
125
+ );
126
+ expect(frosted).toMatch(/backdrop-filter:\s*blur\(\d+px\)/);
127
+
128
+ const active = renderToStaticMarkup(
129
+ <BloomThemeProvider mode="dark" colorPreset="blue">
130
+ <FrostedIconButton accessibilityLabel="Back" active icon={<span>x</span>} />
131
+ </BloomThemeProvider>,
132
+ );
133
+ // Active is solid: no blur (either absent or explicitly "none").
134
+ expect(active).not.toMatch(/backdrop-filter:\s*blur\(/);
135
+ });
136
+ });
137
+
138
+ describe('active (solid "on") state', () => {
139
+ it('fills opaque (no rgba alpha), drops the blur, and sets aria-pressed', () => {
140
+ const c = mount(<FrostedIconButton accessibilityLabel="Mute" active icon={<span>x</span>} />);
141
+ const btn = getByRole(c, 'button');
142
+ // Opaque solid fill — an `rgb(...)` (not translucent `rgba`).
143
+ expect(btn.style.backgroundColor).toMatch(/^rgb\(/);
144
+ expect(btn.style.backgroundColor).not.toMatch(/^rgba\(/);
145
+ expect(btn).toHaveAttribute('aria-pressed', 'true');
146
+ expect(btn).toHaveAttribute('data-active', 'true');
147
+ const bf = btn.style.getPropertyValue('backdrop-filter');
148
+ expect(bf === '' || bf === 'none').toBe(true);
149
+ });
150
+
151
+ it('reports aria-pressed=false when not active', () => {
152
+ const c = mount(<FrostedIconButton accessibilityLabel="Mute" icon={<span>x</span>} />);
153
+ expect(getByRole(c, 'button')).toHaveAttribute('aria-pressed', 'false');
154
+ });
155
+ });
156
+
157
+ describe('icon color', () => {
158
+ it('injects the theme icon color as a fallback fill on a bare icon', () => {
159
+ const c = mount(
160
+ <FrostedIconButton accessibilityLabel="Back" icon={<svg data-testid="ic" />} />,
161
+ );
162
+ const icon = c.querySelector('[data-testid="ic"]');
163
+ expect(icon?.getAttribute('fill')).toMatch(/^rgb/);
164
+ });
165
+
166
+ it('never overrides an explicit fill on the icon', () => {
167
+ const c = mount(
168
+ <FrostedIconButton
169
+ accessibilityLabel="Back"
170
+ icon={<svg data-testid="ic" fill="rgb(1, 2, 3)" />}
171
+ />,
172
+ );
173
+ expect(c.querySelector('[data-testid="ic"]')?.getAttribute('fill')).toBe('rgb(1, 2, 3)');
174
+ });
175
+ });
176
+
177
+ describe('light theme', () => {
178
+ it('uses a subtle DARK tint (base near-black) distinguishable from a white page', () => {
179
+ const c = mount(
180
+ <FrostedIconButton accessibilityLabel="Back" icon={<span>x</span>} />,
181
+ 'light',
182
+ );
183
+ const surface = getByRole(c, 'button').style.backgroundColor;
184
+ expect(surface).toMatch(/^rgba\(/);
185
+ const base = parseRgb(surface);
186
+ if (!base) throw new Error('unreachable');
187
+ expect(base.r).toBeLessThanOrEqual(60);
188
+ expect(base.g).toBeLessThanOrEqual(60);
189
+ expect(base.b).toBeLessThanOrEqual(60);
190
+ });
191
+ });
192
+
193
+ describe('behavior', () => {
194
+ it('fires both onClick and onPress on click', () => {
195
+ const onClick = jest.fn();
196
+ const onPress = jest.fn();
197
+ const c = mount(
198
+ <FrostedIconButton
199
+ accessibilityLabel="Back"
200
+ onClick={onClick}
201
+ onPress={onPress}
202
+ icon={<span>x</span>}
203
+ />,
204
+ );
205
+ act(() => {
206
+ fireEvent.click(getByRole(c, 'button'));
207
+ });
208
+ expect(onClick).toHaveBeenCalledTimes(1);
209
+ expect(onPress).toHaveBeenCalledTimes(1);
210
+ });
211
+
212
+ it('does not fire handlers when disabled', () => {
213
+ const onPress = jest.fn();
214
+ const c = mount(
215
+ <FrostedIconButton accessibilityLabel="Back" disabled onPress={onPress} icon={<span>x</span>} />,
216
+ );
217
+ const btn = getByRole(c, 'button');
218
+ expect(btn).toBeDisabled();
219
+ act(() => {
220
+ fireEvent.click(btn);
221
+ });
222
+ expect(onPress).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it('applies preset sizes (md = 36px, sm = 32px)', () => {
226
+ const c = mount(<FrostedIconButton accessibilityLabel="Back" size="md" icon={<span>x</span>} />);
227
+ expect(getByRole(c, 'button').style.width).toBe('36px');
228
+ const c2 = mount(<FrostedIconButton accessibilityLabel="Back" size="sm" icon={<span>x</span>} />);
229
+ expect(getByRole(c2, 'button').style.width).toBe('32px');
230
+ });
231
+
232
+ it('does not crash on an array-form style prop (StyleProp flattening)', () => {
233
+ const showBorder = false;
234
+ const c = mount(
235
+ <FrostedIconButton
236
+ accessibilityLabel="Back"
237
+ icon={<span>x</span>}
238
+ style={[{ marginTop: 8 }, showBorder && { borderColor: 'rgb(9, 9, 9)' }]}
239
+ />,
240
+ );
241
+ const btn = getByRole(c, 'button');
242
+ expect(btn.style.marginTop).toBe('8px');
243
+ expect(btn.getAttribute('style') ?? '').not.toMatch(/(^|;)\s*0\s*:/);
244
+ });
245
+ });
246
+ });
@@ -11,11 +11,12 @@
11
11
  * scroll, no sticky positioning, and no bleed to mask, so the panel is simply a
12
12
  * surface wrapping its content. The `framed` prop still drives whether that
13
13
  * surface is rounded + bordered, tri-state and resolved with pure NativeWind
14
- * (`md:` evaluates against the window width on native too — no JS breakpoint
15
- * hook needed):
14
+ * (the breakpoint's `min-*:` variant evaluates against the window width on
15
+ * native too — no JS breakpoint hook needed):
16
16
  *
17
- * - `undefined` (DEFAULT) → responsive: full-bleed below `md`, rounded +
18
- * bordered at `md`+ (`md:rounded-radius-28 md:border md:border-border`).
17
+ * - `undefined` (DEFAULT) → responsive: full-bleed below the `framedFrom`
18
+ * breakpoint (default `768` / Tailwind `md`), rounded + bordered at/above it
19
+ * (e.g. `md:rounded-radius-28 md:border md:border-border`).
19
20
  * - `false` → never framed (plain full-bleed at every size).
20
21
  * - `true` → always rounded + bordered.
21
22
  *
@@ -47,17 +48,35 @@ export const PANEL_TOP_INSET = 8;
47
48
  /** Bottom sticky inset (px) of the framed panel chrome. */
48
49
  export const PANEL_BOTTOM_INSET = 8;
49
50
 
51
+ /**
52
+ * Viewport width (px) at which a RESPONSIVE `ContentPanel` (`framed` undefined)
53
+ * switches from full-bleed to framed. Each value maps to a PRE-SHIPPED literal
54
+ * Tailwind class bundle — `768` (default) uses the named `md:` screen
55
+ * (byte-identical to prior behavior), `640` uses `sm:`, `1024` uses `lg:`, and
56
+ * `500` uses the arbitrary `min-[500px]:` variant (there is no named screen at
57
+ * 500px). The breakpoint tokens are never built at runtime, so a consumer's
58
+ * Tailwind content-scan over `src/**`/`lib/**` resolves them verbatim.
59
+ */
60
+ export type ContentPanelFramedBreakpoint = 500 | 640 | 768 | 1024;
61
+
50
62
  export interface ContentPanelProps {
51
63
  children: React.ReactNode;
52
64
  /**
53
65
  * Framing mode. Tri-state, resolved purely with NativeWind — no consumer
54
66
  * breakpoint hook needed:
55
- * - `undefined` (DEFAULT) → responsive: full-bleed below `md`, rounded +
56
- * bordered at `md`+.
67
+ * - `undefined` (DEFAULT) → responsive: full-bleed below the `framedFrom`
68
+ * breakpoint, rounded + bordered at/above it.
57
69
  * - `false` → never framed (plain full-bleed at every size).
58
70
  * - `true` → always rounded + bordered.
59
71
  */
60
72
  framed?: boolean;
73
+ /**
74
+ * Viewport width at which the RESPONSIVE panel switches from full-bleed to
75
+ * framed. Only meaningful when `framed` is `undefined` (responsive) — ignored
76
+ * when `framed` is `true` (always framed) or `false` (never framed). Defaults
77
+ * to `768` (Tailwind `md`), reproducing the prior fixed behavior exactly.
78
+ */
79
+ framedFrom?: ContentPanelFramedBreakpoint;
61
80
  /** Override the surface background utility (defaults to `bg-card`). */
62
81
  surfaceClassName?: string;
63
82
  surfaceStyle?: StyleProp<ViewStyle>;
@@ -76,9 +95,24 @@ export interface ContentPanelProps {
76
95
  maskColor?: string;
77
96
  }
78
97
 
98
+ /**
99
+ * Per-breakpoint literal Tailwind surface class bundle for the NATIVE responsive
100
+ * mode. Each value is a WHOLE literal string (never assembled from parts at
101
+ * runtime) so the Tailwind content-scan over `src/**`/`lib/**` picks up every
102
+ * `min-*`/`sm:`/`md:`/`lg:` token verbatim. Selecting a bundle by `framedFrom`
103
+ * value is the only runtime decision.
104
+ */
105
+ const RESPONSIVE_SURFACE: Record<ContentPanelFramedBreakpoint, string> = {
106
+ 500: 'min-[500px]:overflow-hidden min-[500px]:rounded-radius-28 min-[500px]:border min-[500px]:border-border',
107
+ 640: 'sm:overflow-hidden sm:rounded-radius-28 sm:border sm:border-border',
108
+ 768: 'md:overflow-hidden md:rounded-radius-28 md:border md:border-border',
109
+ 1024: 'lg:overflow-hidden lg:rounded-radius-28 lg:border lg:border-border',
110
+ };
111
+
79
112
  const ContentPanelComponent: React.FC<ContentPanelProps> = ({
80
113
  children,
81
114
  framed,
115
+ framedFrom = 768,
82
116
  surfaceClassName,
83
117
  surfaceStyle,
84
118
  contentClassName,
@@ -87,13 +121,15 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
87
121
  // Dev-only invariant — a ContentPanel must never be nested inside another.
88
122
  useContentPanelNestingGuard();
89
123
 
90
- // Tri-state: `undefined` → responsive (md:-gated), `true` → always framed,
91
- // `false` → never framed (plain full-bleed). Whole literal class strings are
92
- // selected per mode so the Tailwind content-scan over `src/**` picks up every
93
- // `md:` / `rounded-radius-28` token verbatim (no dynamic concatenation).
124
+ // Tri-state: `undefined` → responsive (breakpoint-gated), `true` → always
125
+ // framed, `false` → never framed (plain full-bleed). Whole literal class
126
+ // strings are selected per mode so the Tailwind content-scan over `src/**`
127
+ // picks up every `min-*`/`sm:`/`md:`/`lg:` / `rounded-radius-28` token verbatim
128
+ // (the breakpoint bundle lives whole in `RESPONSIVE_SURFACE`; `framed ===
129
+ // true`/`false` ignore `framedFrom` for fixed always/never framing).
94
130
  const surfaceBase =
95
131
  framed === undefined
96
- ? 'flex-1 md:overflow-hidden md:rounded-radius-28 md:border md:border-border'
132
+ ? `flex-1 ${RESPONSIVE_SURFACE[framedFrom]}`
97
133
  : framed
98
134
  ? 'flex-1 overflow-hidden rounded-radius-28 border border-border'
99
135
  : 'flex-1';
@@ -26,18 +26,22 @@
26
26
  * Framing is tri-state via the `framed` prop:
27
27
  *
28
28
  * - `undefined` (DEFAULT) → RESPONSIVE, driven purely by NativeWind: full-bleed
29
- * below `md`, framed at `md`+. The rounding is `md:`-gated and both overlays
30
- * are always rendered but carry `max-md:hidden` (`display:none` below `md`),
31
- * so the breakpoint is decided in CSS no consumer JS breakpoint hook.
29
+ * below the `framedFrom` breakpoint, framed at/above it. The breakpoint is
30
+ * configurable per consumer (`framedFrom`, default `768` / Tailwind `md`); the
31
+ * rounding is gated by that breakpoint's `min-*:` variant and both overlays
32
+ * are always rendered but carry the matching `max-*:hidden` literal
33
+ * (`display:none` below the breakpoint), so the breakpoint is decided entirely
34
+ * in CSS — no consumer JS breakpoint hook.
32
35
  * - `false` → NEVER framed (full-bleed at every size): a single flat surface,
33
36
  * no rounding, no overlays.
34
37
  * - `true` → ALWAYS framed (rounded + overlays at every size).
35
38
  *
36
- * The overlays are gated by VISIBILITY (a `max-md:hidden` literal) rather than
37
- * by conditional mounting for the responsive case, so crossing the breakpoint
38
- * never remounts them. The content wrapper likewise keeps a stable `key` so
39
- * `{children}` reconciles in place across the breakpoint instead of remounting
40
- * (which would reset feed scroll / the virtualizer + refetch).
39
+ * The overlays are gated by VISIBILITY (a `max-*:hidden` literal for the chosen
40
+ * breakpoint) rather than by conditional mounting for the responsive case, so
41
+ * crossing the breakpoint never remounts them. The content wrapper likewise
42
+ * keeps a stable `key` so `{children}` reconciles in place across the breakpoint
43
+ * instead of remounting (which would reset feed scroll / the virtualizer +
44
+ * refetch).
41
45
  *
42
46
  * Native bundlers use `./index.tsx` (a plain rounded surface); web bundlers
43
47
  * select this file via the `"browser"` export condition in `package.json`.
@@ -63,17 +67,37 @@ export const PANEL_TOP_INSET = 8;
63
67
  /** Bottom sticky inset (px) of the framed panel chrome. */
64
68
  export const PANEL_BOTTOM_INSET = 8;
65
69
 
70
+ /**
71
+ * Viewport width (px) at which a RESPONSIVE `ContentPanel` (`framed` undefined)
72
+ * switches from full-bleed to framed. Each value maps to a PRE-SHIPPED literal
73
+ * Tailwind class bundle — `768` (default) uses the named `md:`/`max-md:` screens
74
+ * (byte-identical to prior behavior), `640` uses `sm:`/`max-sm:`, `1024` uses
75
+ * `lg:`/`max-lg:`, and `500` uses the arbitrary `min-[500px]:`/`max-[500px]:`
76
+ * variants (there is no named screen at 500px). The breakpoint tokens are never
77
+ * built at runtime, so a consumer's Tailwind content-scan over `lib/**` resolves
78
+ * them verbatim.
79
+ */
80
+ export type ContentPanelFramedBreakpoint = 500 | 640 | 768 | 1024;
81
+
66
82
  export interface ContentPanelProps {
67
83
  children: React.ReactNode;
68
84
  /**
69
85
  * Framing mode. Tri-state, resolved purely with NativeWind — no consumer
70
86
  * breakpoint hook needed:
71
- * - `undefined` (DEFAULT) → responsive: full-bleed below `md`, framed at
72
- * `md`+ (rounded corners + sticky bleed-mask/border overlays).
87
+ * - `undefined` (DEFAULT) → responsive: full-bleed below the `framedFrom`
88
+ * breakpoint, framed at/above it (rounded corners + sticky bleed-mask/border
89
+ * overlays).
73
90
  * - `false` → never framed (full-bleed at every size).
74
91
  * - `true` → always framed (rounded + overlays at every size).
75
92
  */
76
93
  framed?: boolean;
94
+ /**
95
+ * Viewport width at which the RESPONSIVE panel switches from full-bleed to
96
+ * framed. Only meaningful when `framed` is `undefined` (responsive) — ignored
97
+ * when `framed` is `true` (always framed) or `false` (never framed). Defaults
98
+ * to `768` (Tailwind `md`), reproducing the prior fixed behavior exactly.
99
+ */
100
+ framedFrom?: ContentPanelFramedBreakpoint;
77
101
  /** Override the surface background utility (defaults to `bg-card`). */
78
102
  surfaceClassName?: string;
79
103
  surfaceStyle?: StyleProp<ViewStyle>;
@@ -93,9 +117,50 @@ export interface ContentPanelProps {
93
117
  maskColor?: string;
94
118
  }
95
119
 
120
+ /**
121
+ * Per-breakpoint literal Tailwind variant tokens for the WEB responsive mode.
122
+ * Each field is a WHOLE literal class string (never assembled from parts at
123
+ * runtime) so the consumer's content-scan over `lib/**` picks up every
124
+ * arbitrary/`min-*`/`max-*`/`web:` token verbatim. Selecting a bundle by
125
+ * `framedFrom` value is the only runtime decision:
126
+ * - `surface` → framing rounding on the surface at/above the breakpoint.
127
+ * - `content` → the same rounding + a web-only horizontal clip on the
128
+ * inner content wrapper (`web:<bp>:overflow-x-clip`, platform-first-then-
129
+ * breakpoint — matching the consumer convention `web:sm:flex` in `@mercaria/ui`).
130
+ * - `overlayHidden` → `display:none` for the sticky overlays BELOW the breakpoint.
131
+ * `min-[500px]:` (≥500) and `max-[500px]:` (<500) are exactly complementary
132
+ * (meet at 500, no overlap/gap), mirroring the named `md:`/`max-md:` pair.
133
+ */
134
+ const RESPONSIVE_WEB: Record<
135
+ ContentPanelFramedBreakpoint,
136
+ { surface: string; content: string; overlayHidden: string }
137
+ > = {
138
+ 500: {
139
+ surface: 'min-[500px]:rounded-radius-28',
140
+ content: 'min-[500px]:rounded-radius-28 web:min-[500px]:overflow-x-clip',
141
+ overlayHidden: 'max-[500px]:hidden',
142
+ },
143
+ 640: {
144
+ surface: 'sm:rounded-radius-28',
145
+ content: 'sm:rounded-radius-28 web:sm:overflow-x-clip',
146
+ overlayHidden: 'max-sm:hidden',
147
+ },
148
+ 768: {
149
+ surface: 'md:rounded-radius-28',
150
+ content: 'md:rounded-radius-28 web:md:overflow-x-clip',
151
+ overlayHidden: 'max-md:hidden',
152
+ },
153
+ 1024: {
154
+ surface: 'lg:rounded-radius-28',
155
+ content: 'lg:rounded-radius-28 web:lg:overflow-x-clip',
156
+ overlayHidden: 'max-lg:hidden',
157
+ },
158
+ };
159
+
96
160
  const ContentPanelComponent: React.FC<ContentPanelProps> = ({
97
161
  children,
98
162
  framed,
163
+ framedFrom = 768,
99
164
  surfaceClassName,
100
165
  surfaceStyle,
101
166
  contentClassName,
@@ -112,18 +177,21 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
112
177
  // `false` → never framed (full-bleed).
113
178
  const responsive = framed === undefined;
114
179
  const showOverlays = framed !== false;
180
+ // Responsive mode selects a pre-shipped literal breakpoint bundle by value;
181
+ // `framed === true`/`false` ignore `framedFrom` (fixed always/never framing).
182
+ const bp = RESPONSIVE_WEB[framedFrom];
115
183
 
116
184
  // Whole literal class strings selected per mode (the Tailwind content-scan
117
- // over `lib/**` requires each arbitrary/`md:`/`web:` token stay literal).
118
- // `web:md:overflow-x-clip` is platform-first-then-breakpoint, matching the
119
- // consumer convention (`web:sm:flex` in `@mercaria/ui`).
185
+ // over `lib/**` requires each arbitrary/`min-*`/`max-*`/`web:` token stay
186
+ // literal — the breakpoint tokens live whole in `RESPONSIVE_WEB` above and are
187
+ // only spliced in, never assembled from parts).
120
188
  const surfaceBase = responsive
121
- ? 'flex-1 md:rounded-radius-28'
189
+ ? `flex-1 ${bp.surface}`
122
190
  : framed
123
191
  ? 'flex-1 rounded-radius-28'
124
192
  : 'flex-1';
125
193
  const contentBase = responsive
126
- ? 'flex-1 md:rounded-radius-28 web:md:overflow-x-clip'
194
+ ? `flex-1 ${bp.content}`
127
195
  : framed
128
196
  ? 'flex-1 rounded-radius-28 web:overflow-x-clip'
129
197
  : 'flex-1';
@@ -145,7 +213,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
145
213
  pointerEvents="none"
146
214
  {...({
147
215
  className: responsive
148
- ? 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 max-md:hidden web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]'
216
+ ? `web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]`
149
217
  : 'web:sticky web:top-2 z-30 h-[calc(100dvh-16px)] w-full rounded-radius-28 web:[margin-bottom:calc(-100dvh+16px)] web:[clip-path:inset(-12px)]',
150
218
  } as Record<string, string>)}
151
219
  style={{ boxShadow: `0 0 0 ${GUTTER_MASK_SPREAD}px ${maskColor ?? colors.background}` }}
@@ -159,7 +227,7 @@ const ContentPanelComponent: React.FC<ContentPanelProps> = ({
159
227
  pointerEvents="none"
160
228
  {...({
161
229
  className: responsive
162
- ? 'web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border max-md:hidden web:[margin-bottom:calc(-100dvh+16px)]'
230
+ ? `web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border ${bp.overlayHidden} web:[margin-bottom:calc(-100dvh+16px)]`
163
231
  : 'web:sticky web:top-2 z-[120] h-[calc(100dvh-16px)] w-full rounded-radius-28 border border-border web:[margin-bottom:calc(-100dvh+16px)]',
164
232
  } as Record<string, string>)}
165
233
  />
@@ -0,0 +1,65 @@
1
+ import React from 'react';
2
+ import { View, ImageBackground } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { FrostedIconButton } from './FrostedIconButton';
6
+ import * as Icons from '../icons';
7
+
8
+ const meta: Meta<typeof FrostedIconButton> = {
9
+ title: 'Components/FrostedIconButton',
10
+ component: FrostedIconButton,
11
+ args: {
12
+ accessibilityLabel: 'Back',
13
+ icon: <Icons.ChevronLeft_Stroke2_Corner0_Rounded size="md" />,
14
+ onPress: () => {},
15
+ },
16
+ argTypes: {
17
+ size: { control: 'select', options: ['sm', 'md'] },
18
+ active: { control: 'boolean' },
19
+ disabled: { control: 'boolean' },
20
+ },
21
+ };
22
+
23
+ export default meta;
24
+
25
+ type Story = StoryObj<typeof FrostedIconButton>;
26
+
27
+ /** The frosted chip on a SOLID dark background — must read as a distinct button. */
28
+ export const OnSolidDark: Story = {
29
+ render: (args) => (
30
+ <View style={{ padding: 40, backgroundColor: 'rgb(11, 11, 15)' }}>
31
+ <FrostedIconButton {...args} />
32
+ </View>
33
+ ),
34
+ };
35
+
36
+ /** The solid "on" state for toggles (no blur, brand fill). */
37
+ export const Active: Story = {
38
+ render: (args) => (
39
+ <View style={{ padding: 40, backgroundColor: 'rgb(11, 11, 15)' }}>
40
+ <FrostedIconButton {...args} active />
41
+ </View>
42
+ ),
43
+ };
44
+
45
+ /** Frosting over an image (the reason it exists) — blur shows the banner behind. */
46
+ export const OverImage: Story = {
47
+ render: (args) => (
48
+ <ImageBackground
49
+ source={{ uri: 'https://picsum.photos/600/300' }}
50
+ style={{ width: 320, height: 180, padding: 16, justifyContent: 'flex-start' }}
51
+ >
52
+ <FrostedIconButton {...args} />
53
+ </ImageBackground>
54
+ ),
55
+ };
56
+
57
+ /** Both sizes side by side. */
58
+ export const Sizes: Story = {
59
+ render: (args) => (
60
+ <View style={{ flexDirection: 'row', gap: 12, padding: 40, backgroundColor: 'rgb(11, 11, 15)' }}>
61
+ <FrostedIconButton {...args} size="sm" />
62
+ <FrostedIconButton {...args} size="md" />
63
+ </View>
64
+ ),
65
+ };