@oxy.so/bloom 4.1.0 → 4.3.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 (93) hide show
  1. package/docs/adoption-matrix.mdx +7 -7
  2. package/docs/ai-chat.mdx +8 -0
  3. package/docs/app-shell.mdx +7 -0
  4. package/docs/bottom-bar.mdx +6 -0
  5. package/docs/settings-modal.mdx +14 -5
  6. package/docs/sidebar.mdx +11 -0
  7. package/lib/commonjs/ai-chat/AiChatContainer.js +8 -8
  8. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  9. package/lib/commonjs/app-shell/AppShellBars.js +8 -2
  10. package/lib/commonjs/app-shell/AppShellBars.js.map +1 -1
  11. package/lib/commonjs/app-shell/AppShellEngine.js +29 -6
  12. package/lib/commonjs/app-shell/AppShellEngine.js.map +1 -1
  13. package/lib/commonjs/bottom-bar/BottomBarBase.js +10 -2
  14. package/lib/commonjs/bottom-bar/BottomBarBase.js.map +1 -1
  15. package/lib/commonjs/layout/bottom-bar-slot.js +10 -0
  16. package/lib/commonjs/layout/bottom-bar-slot.js.map +1 -0
  17. package/lib/commonjs/settings-modal/SettingsModal.js +153 -230
  18. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  19. package/lib/commonjs/sidebar/Sidebar.js +35 -5
  20. package/lib/commonjs/sidebar/Sidebar.js.map +1 -1
  21. package/lib/module/ai-chat/AiChatContainer.js +8 -8
  22. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  23. package/lib/module/app-shell/AppShellBars.js +8 -2
  24. package/lib/module/app-shell/AppShellBars.js.map +1 -1
  25. package/lib/module/app-shell/AppShellEngine.js +30 -7
  26. package/lib/module/app-shell/AppShellEngine.js.map +1 -1
  27. package/lib/module/bottom-bar/BottomBarBase.js +11 -3
  28. package/lib/module/bottom-bar/BottomBarBase.js.map +1 -1
  29. package/lib/module/layout/bottom-bar-slot.js +7 -0
  30. package/lib/module/layout/bottom-bar-slot.js.map +1 -0
  31. package/lib/module/settings-modal/SettingsModal.js +150 -227
  32. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  33. package/lib/module/sidebar/Sidebar.js +35 -5
  34. package/lib/module/sidebar/Sidebar.js.map +1 -1
  35. package/lib/typescript/commonjs/ai-chat/types.d.ts +2 -2
  36. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/app-shell/AppShellBars.d.ts +1 -0
  38. package/lib/typescript/commonjs/app-shell/AppShellBars.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/app-shell/AppShellEngine.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/bottom-bar/BottomBarBase.d.ts +1 -1
  41. package/lib/typescript/commonjs/bottom-bar/BottomBarBase.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/bottom-bar/types.d.ts +4 -0
  43. package/lib/typescript/commonjs/bottom-bar/types.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/layout/bottom-bar-slot.d.ts +3 -0
  45. package/lib/typescript/commonjs/layout/bottom-bar-slot.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts +1 -1
  47. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/settings-modal/types.d.ts +7 -2
  49. package/lib/typescript/commonjs/settings-modal/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/sidebar/Sidebar.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/sidebar/types.d.ts +12 -1
  52. package/lib/typescript/commonjs/sidebar/types.d.ts.map +1 -1
  53. package/lib/typescript/module/ai-chat/types.d.ts +2 -2
  54. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  55. package/lib/typescript/module/app-shell/AppShellBars.d.ts +1 -0
  56. package/lib/typescript/module/app-shell/AppShellBars.d.ts.map +1 -1
  57. package/lib/typescript/module/app-shell/AppShellEngine.d.ts.map +1 -1
  58. package/lib/typescript/module/bottom-bar/BottomBarBase.d.ts +1 -1
  59. package/lib/typescript/module/bottom-bar/BottomBarBase.d.ts.map +1 -1
  60. package/lib/typescript/module/bottom-bar/types.d.ts +4 -0
  61. package/lib/typescript/module/bottom-bar/types.d.ts.map +1 -1
  62. package/lib/typescript/module/layout/bottom-bar-slot.d.ts +3 -0
  63. package/lib/typescript/module/layout/bottom-bar-slot.d.ts.map +1 -0
  64. package/lib/typescript/module/settings-modal/SettingsModal.d.ts +1 -1
  65. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  66. package/lib/typescript/module/settings-modal/types.d.ts +7 -2
  67. package/lib/typescript/module/settings-modal/types.d.ts.map +1 -1
  68. package/lib/typescript/module/sidebar/Sidebar.d.ts.map +1 -1
  69. package/lib/typescript/module/sidebar/types.d.ts +12 -1
  70. package/lib/typescript/module/sidebar/types.d.ts.map +1 -1
  71. package/package.json +1 -1
  72. package/src/__tests__/support/adoption-matrix.ts +703 -0
  73. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  74. package/src/__tests__/support/commerce-harness.tsx +97 -0
  75. package/src/__tests__/support/composite.ts +68 -0
  76. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  77. package/src/__tests__/support/press-host.ts +30 -0
  78. package/src/__tests__/support/rendered-style.ts +99 -0
  79. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  80. package/src/ai-chat/AiChatContainer.tsx +7 -7
  81. package/src/ai-chat/types.ts +2 -2
  82. package/src/app-shell/AppShellBars.tsx +5 -3
  83. package/src/app-shell/AppShellEngine.tsx +23 -8
  84. package/src/bottom-bar/BottomBarBase.tsx +7 -4
  85. package/src/bottom-bar/types.ts +4 -0
  86. package/src/layout/bottom-bar-slot.ts +4 -0
  87. package/src/settings-modal/SettingsModal.tsx +180 -250
  88. package/src/settings-modal/types.ts +7 -2
  89. package/src/sidebar/Sidebar.tsx +26 -5
  90. package/src/sidebar/types.ts +12 -1
  91. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
  92. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
  93. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A barrel that DELIBERATELY offers one name from two declarations, so that
3
+ * `barrel-name-collisions.test.ts` can prove its detector fires. It reproduces
4
+ * the shipped `Item` shape: a star export carrying `Item` from `item/`, and an
5
+ * explicit re-export binding that same name to a different component.
6
+ *
7
+ * Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
8
+ * `files: ["src", …]` does ship it inside the tarball's `src/` like the other
9
+ * test files there — but no entry point reaches it, so no bundler links it, and
10
+ * `package.json#exports` never names it.
11
+ *
12
+ * The `Card` lines below are the other half of the
13
+ * control: one name offered twice from ONE declaration, which the detector must
14
+ * stay quiet about — otherwise its cheapest fix would be deleting a legitimate
15
+ * redundant re-export.
16
+ */
17
+ export * from '../../item';
18
+ export { Card as Item } from '../../card';
19
+ export { Card } from '../../card';
20
+ export * from '../../card';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The mount harness the four commerce suites share.
3
+ *
4
+ * Every one of them asserts EMITTED DOM — an `aria-*` attribute, a computed
5
+ * colour, whether a node exists at all — rather than the props it just passed,
6
+ * so each renders through the real react-native-web. The mock has to be
7
+ * installed by the SUITE (a `jest.mock` call is hoisted to the top of the file
8
+ * it is written in and does not travel through an import), which is why this
9
+ * module exports the harness and not the mock.
10
+ */
11
+ import React from 'react';
12
+ import { act } from 'react';
13
+ import { createRoot, type Root } from 'react-dom/client';
14
+
15
+ import { BloomThemeProvider } from '../../theme/BloomThemeProvider';
16
+ import type { Theme } from '../../theme/types';
17
+ import { useTheme } from '../../theme/use-theme';
18
+
19
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ let container: HTMLDivElement;
22
+ let root: Root;
23
+ let lastTheme: Theme | null = null;
24
+
25
+ function ThemeProbe() {
26
+ lastTheme = useTheme();
27
+ return null;
28
+ }
29
+
30
+ export function setupHarness(): void {
31
+ beforeEach(() => {
32
+ container = document.createElement('div');
33
+ document.body.appendChild(container);
34
+ root = createRoot(container);
35
+ });
36
+ afterEach(() => {
37
+ act(() => root.unmount());
38
+ container.remove();
39
+ lastTheme = null;
40
+ });
41
+ }
42
+
43
+ export function mount(ui: React.ReactElement, mode: 'light' | 'dark' = 'light'): HTMLElement {
44
+ act(() => {
45
+ root.render(
46
+ <BloomThemeProvider mode={mode} colorPreset="teal">
47
+ <ThemeProbe />
48
+ {ui}
49
+ </BloomThemeProvider>,
50
+ );
51
+ });
52
+ return container;
53
+ }
54
+
55
+ export function theme(): Theme {
56
+ if (!lastTheme) throw new Error('theme not captured');
57
+ return lastTheme;
58
+ }
59
+
60
+ export function root$(): HTMLElement {
61
+ return container;
62
+ }
63
+
64
+ export function byTestId(id: string): HTMLElement {
65
+ const el = document.querySelector(`[data-testid="${id}"]`);
66
+ if (!(el instanceof HTMLElement)) throw new Error(`No element for testID "${id}"`);
67
+ return el;
68
+ }
69
+
70
+ export function queryTestId(id: string): HTMLElement | null {
71
+ return document.querySelector(`[data-testid="${id}"]`);
72
+ }
73
+
74
+ export function byLabel(label: string): HTMLElement {
75
+ const el = document.querySelector(`[aria-label="${label}"]`);
76
+ if (!(el instanceof HTMLElement)) throw new Error(`No element labelled "${label}"`);
77
+ return el;
78
+ }
79
+
80
+ export function allByRole(role: string): HTMLElement[] {
81
+ return Array.from(document.querySelectorAll(`[role="${role}"]`)).filter(
82
+ (el): el is HTMLElement => el instanceof HTMLElement,
83
+ );
84
+ }
85
+
86
+ export function click(el: HTMLElement): void {
87
+ act(() => {
88
+ el.click();
89
+ });
90
+ }
91
+
92
+ /** A colour as the DOM serialises it, for comparing against a computed style. */
93
+ export function css(color: string): string {
94
+ const probe = document.createElement('div');
95
+ probe.style.color = color;
96
+ return probe.style.color;
97
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Compositing and WCAG maths for suites that measure a TRANSLUCENT surface.
3
+ *
4
+ * A pane you can see through has no colour of its own to read off a style prop:
5
+ * what a label lands on is the fill flattened onto whatever is behind it. Every
6
+ * gate on a glass material therefore needs the same four functions, and they
7
+ * are here so a new one does not start by copying them — which is how two
8
+ * copies of a luminance formula end up disagreeing about the sRGB knee.
9
+ *
10
+ * `theme/__tests__/glass-colors.test.ts` keeps its OWN copies, deliberately and
11
+ * with its own reasons written down; this is for everything since.
12
+ */
13
+
14
+ export interface Rgba {
15
+ r: number;
16
+ g: number;
17
+ b: number;
18
+ a: number;
19
+ }
20
+
21
+ /** Parse `#rgb`, `#rrggbb`, `rgb(...)` or `rgba(...)`. Throws on anything else. */
22
+ export function parseColor(value: string): Rgba {
23
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
24
+ if (hex?.[1] !== undefined) {
25
+ const h = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];
26
+ return {
27
+ r: parseInt(h.slice(0, 2), 16),
28
+ g: parseInt(h.slice(2, 4), 16),
29
+ b: parseInt(h.slice(4, 6), 16),
30
+ a: 1,
31
+ };
32
+ }
33
+ const fn = /^rgba?\(([^)]*)\)$/.exec(value.trim());
34
+ if (!fn?.[1]) throw new Error(`unparseable colour: ${JSON.stringify(value)}`);
35
+ const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map(Number);
36
+ if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) {
37
+ throw new Error(`unparseable colour: ${JSON.stringify(value)}`);
38
+ }
39
+ return { r: parts[0] ?? 0, g: parts[1] ?? 0, b: parts[2] ?? 0, a: parts[3] ?? 1 };
40
+ }
41
+
42
+ /** Flatten a translucent colour onto an opaque one — source-over. */
43
+ export function over(top: Rgba, bottom: Rgba): Rgba {
44
+ return {
45
+ r: top.r * top.a + bottom.r * (1 - top.a),
46
+ g: top.g * top.a + bottom.g * (1 - top.a),
47
+ b: top.b * top.a + bottom.b * (1 - top.a),
48
+ a: 1,
49
+ };
50
+ }
51
+
52
+ /** WCAG relative luminance. */
53
+ export function luminance({ r, g, b }: Rgba): number {
54
+ const channel = (v: number): number => {
55
+ const c = v / 255;
56
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
57
+ };
58
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
59
+ }
60
+
61
+ /** WCAG contrast ratio, order-independent. */
62
+ export function contrastRatio(a: Rgba, b: Rgba): number {
63
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
64
+ return (hi + 0.05) / (lo + 0.05);
65
+ }
66
+
67
+ export const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
68
+ export const BLACK: Rgba = { r: 0, g: 0, b: 0, a: 1 };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
3
+ *
4
+ * jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
5
+ * `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
6
+ * fallback. That makes the whole CSP-safe path — the reason
7
+ * `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
8
+ * installs the API itself. Without this, a mutation that deleted the adoption
9
+ * branch entirely would leave the suite green.
10
+ *
11
+ * Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
12
+ */
13
+
14
+ export interface FakeStyleSheet {
15
+ /** The CSS most recently handed to `replaceSync`. */
16
+ cssText: string;
17
+ /** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
18
+ replaceSyncCalls: number;
19
+ replaceSync(css: string): void;
20
+ }
21
+
22
+ export interface ConstructedStyleSheetsHarness {
23
+ /** The document's adopted sheets, read live (the code reassigns the array). */
24
+ adopted(): readonly FakeStyleSheet[];
25
+ /** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
26
+ uninstall(): void;
27
+ }
28
+
29
+ export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
30
+ class FakeCSSStyleSheet implements FakeStyleSheet {
31
+ cssText = '';
32
+ replaceSyncCalls = 0;
33
+
34
+ replaceSync(css: string): void {
35
+ this.cssText = css;
36
+ this.replaceSyncCalls += 1;
37
+ }
38
+ }
39
+
40
+ const previousConstructor = Object.getOwnPropertyDescriptor(
41
+ globalThis,
42
+ 'CSSStyleSheet',
43
+ );
44
+
45
+ Object.defineProperty(globalThis, 'CSSStyleSheet', {
46
+ value: FakeCSSStyleSheet,
47
+ writable: true,
48
+ configurable: true,
49
+ });
50
+ Object.defineProperty(document, 'adoptedStyleSheets', {
51
+ value: [],
52
+ writable: true,
53
+ configurable: true,
54
+ });
55
+
56
+ return {
57
+ adopted: () =>
58
+ document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
59
+ uninstall: () => {
60
+ Reflect.deleteProperty(document, 'adoptedStyleSheets');
61
+ if (previousConstructor) {
62
+ Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
63
+ } else {
64
+ Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
65
+ }
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pressing a component's OWN host node, and proving the component is what
3
+ * installed the handler being measured.
4
+ *
5
+ * `fireEvent.press` walks UP from the element it is handed to the nearest
6
+ * ancestor carrying an `onPress` prop, and it does not stop at host nodes — a
7
+ * COMPOSITE counts, because `getEventHandler` only reads `element.props`. So
8
+ * `<Thing onPress={fn}>` written in a test's own JSX catches the press itself.
9
+ * Measured on a (since removed) card component: with the handler deleted from the
10
+ * component the card rendered as an inert `View`, and the test still reported exactly one
11
+ * call. Green, and measuring nothing. Nine suites had the same shape.
12
+ *
13
+ * Asserting the host node's own `onPress` FIRST closes both halves:
14
+ *
15
+ * - a component that installs no handler at all fails here, and
16
+ * - a component that installs one which drops the caller's fails on the call
17
+ * count, because `fireEvent` now finds a handler AT the node and never walks
18
+ * past it to the test's own JSX.
19
+ *
20
+ * `host` must therefore be the node the component itself made pressable — the
21
+ * one carrying its `testID` / `accessibilityLabel` — not a `Text` deep inside
22
+ * it, or the walk-up is back and so is the hole.
23
+ */
24
+ import { fireEvent } from '@testing-library/react-native';
25
+ import type { ReactTestInstance } from 'react-test-renderer';
26
+
27
+ export function pressHost(host: ReactTestInstance): void {
28
+ expect(typeof host.props.onPress).toBe('function');
29
+ fireEvent.press(host);
30
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reading what actually landed on a rendered node — shared by every suite that
3
+ * asserts the "className lands on the node the parent lays out" rule.
4
+ *
5
+ * Three components had the same two-node defect (`Button`, then `Fab` and
6
+ * `FrostedIconButton`), and asserting it needs the same two things each time:
7
+ * the HOST tree (composites do not appear in `toJSON()`, so a wrapper is only
8
+ * visible there) and a deep flatten of the `style` prop, because react-native-css
9
+ * merges a caller's class in as a `{ $$css: true, className }` descriptor
10
+ * ALONGSIDE the style objects rather than in a prop of its own.
11
+ */
12
+ import type { ReactTestRendererJSON } from 'react-test-renderer';
13
+
14
+ export type StyleEntry = Record<string, unknown>;
15
+
16
+ export interface HostNode {
17
+ type: string;
18
+ props: Record<string, unknown>;
19
+ children: Array<HostNode | string> | null;
20
+ }
21
+
22
+ /**
23
+ * Deep-flatten an RN `style` prop (nested arrays, `false`/`null` holes) into the
24
+ * list of style objects that actually reached the node.
25
+ */
26
+ export function styleEntries(style: unknown, out: StyleEntry[] = []): StyleEntry[] {
27
+ if (!style || typeof style !== 'object') return out;
28
+ if (Array.isArray(style)) {
29
+ for (const entry of style) styleEntries(entry, out);
30
+ return out;
31
+ }
32
+ out.push(style as StyleEntry);
33
+ return out;
34
+ }
35
+
36
+ /** Every style key/value that landed on a node, later entries winning. */
37
+ export function resolvedStyle(style: unknown): StyleEntry {
38
+ return Object.assign({}, ...styleEntries(style)) as StyleEntry;
39
+ }
40
+
41
+ /** The class tokens react-native-css accepted for a node, in arrival order. */
42
+ export function classNamesOn(style: unknown): string[] {
43
+ return styleEntries(style)
44
+ .filter((entry) => entry.$$css === true && typeof entry.className === 'string')
45
+ .map((entry) => String(entry.className));
46
+ }
47
+
48
+ export function isHostNode(value: unknown): value is HostNode {
49
+ return (
50
+ typeof value === 'object' && value !== null && typeof (value as HostNode).type === 'string'
51
+ );
52
+ }
53
+
54
+ /** Walk the HOST tree (composites are absent from `toJSON()`) for a testID. */
55
+ export function findHost(
56
+ node: ReactTestRendererJSON | ReactTestRendererJSON[] | unknown,
57
+ testID: string,
58
+ ): HostNode | null {
59
+ if (Array.isArray(node)) {
60
+ for (const child of node) {
61
+ const hit = findHost(child, testID);
62
+ if (hit) return hit;
63
+ }
64
+ return null;
65
+ }
66
+ if (!isHostNode(node)) return null;
67
+ if (node.props.testID === testID) return node;
68
+ return findHost(node.children, testID);
69
+ }
70
+
71
+ /**
72
+ * Every host node in a rendered tree, in document order.
73
+ *
74
+ * The reason to walk `toJSON()` rather than `UNSAFE_root.findAll` is that the
75
+ * latter yields COMPOSITE instances too, so a `memo(fn)` component contributes
76
+ * two matches for one rendered element and every count is silently doubled —
77
+ * which reads as "the component rendered the thing twice", not as an artefact
78
+ * of the query.
79
+ */
80
+ export function hostNodes(tree: unknown, out: HostNode[] = []): HostNode[] {
81
+ if (Array.isArray(tree)) {
82
+ for (const child of tree) hostNodes(child, out);
83
+ return out;
84
+ }
85
+ if (!isHostNode(tree)) return out;
86
+ out.push(tree);
87
+ return hostNodes(tree.children, out);
88
+ }
89
+
90
+ /**
91
+ * The host nodes a parent laid out. Callers assert the LENGTH themselves —
92
+ * "exactly one" is the property, and a helper that returned only the first would
93
+ * hide the second.
94
+ */
95
+ export function renderedChildren(tree: unknown, hostTestID: string): HostNode[] {
96
+ const host = findHost(tree, hostTestID);
97
+ if (host === null) throw new Error(`no host rendered for testID "${hostTestID}"`);
98
+ return (host.children ?? []).filter(isHostNode);
99
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A module that DELIBERATELY throws away one of the values a hook hands back, so
3
+ * `hook-subscriptions-are-read.test.ts` can prove its detector fires. It carries
4
+ * both halves of the control: `unreadFlag` is bound and never read, which the
5
+ * detector must report, and `readFlag` is bound and read, which it must stay
6
+ * quiet about — otherwise the cheapest way to green the gate would be deleting a
7
+ * binding that is doing its job.
8
+ *
9
+ * Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
10
+ * `files: ["src", …]` does ship it inside the tarball's `src/` like the other
11
+ * 137 test files there — but no entry point reaches it, so no bundler links it.
12
+ * It is a plain `.ts` with no React import because the
13
+ * detector reads source, never types: a call named `use…` bound to a name is all
14
+ * the shape there is.
15
+ *
16
+ * The destructuring RENAMES, which is not decoration — it reproduces the shipped
17
+ * shape (`const { state: pressed, onIn: onPressIn } = useInteractionState()`) and
18
+ * it is what keeps the control honest. A hook declared in the same file as its
19
+ * caller mentions its own property names in its return type and its return
20
+ * object, and the detector counts every occurrence of a spelling as a possible
21
+ * read, so an unrenamed fixture would be silently unreportable for a reason no
22
+ * real call site has.
23
+ */
24
+
25
+ /** Stands in for a real subscription — a hook by name, with two return values. */
26
+ function useFixtureInteractionState(): { first: boolean; second: boolean } {
27
+ return { first: false, second: false };
28
+ }
29
+
30
+ export function fixtureComponentBody(): boolean {
31
+ const { first: readFlag, second: unreadFlag } = useFixtureInteractionState();
32
+ return readFlag;
33
+ }
@@ -82,7 +82,7 @@ export function AiChatContainer({
82
82
  </View>
83
83
  ) : null}
84
84
  {header}
85
- <View
85
+ {title != null || project != null || onShare || onMore ? <View
86
86
  style={{
87
87
  width: '100%',
88
88
  flexDirection: 'row',
@@ -94,20 +94,20 @@ export function AiChatContainer({
94
94
  paddingTop: 16,
95
95
  }}>
96
96
  <View style={{ minWidth: 0, flex: 1 }}>
97
- <Breadcrumb accessibilityLabel={l.breadcrumb}>
97
+ {title != null || project != null ? <Breadcrumb accessibilityLabel={l.breadcrumb}>
98
98
  {project === undefined ? null : (
99
99
  <BreadcrumbItem icon={projectIcon} onPress={onProjectPress}>
100
100
  {project}
101
101
  </BreadcrumbItem>
102
102
  )}
103
- <BreadcrumbItem current>{title}</BreadcrumbItem>
104
- </Breadcrumb>
103
+ {title == null ? null : <BreadcrumbItem current>{title}</BreadcrumbItem>}
104
+ </Breadcrumb> : null}
105
105
  </View>
106
106
  <View style={{ flexShrink: 0, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
107
- <GlyphAction icon={RiShare2Line} label={l.share} onPress={onShare} palette={palette} />
108
- <GlyphAction icon={RiMoreFill} label={l.more} onPress={onMore} palette={palette} />
107
+ {onShare ? <GlyphAction icon={RiShare2Line} label={l.share} onPress={onShare} palette={palette} /> : null}
108
+ {onMore ? <GlyphAction icon={RiMoreFill} label={l.more} onPress={onMore} palette={palette} /> : null}
109
109
  </View>
110
- </View>
110
+ </View> : null}
111
111
 
112
112
  {children}
113
113
 
@@ -283,8 +283,8 @@ export interface AiChatContainerProps {
283
283
  * and no folder glyph.
284
284
  */
285
285
  project?: string;
286
- /** The current chat crumb. */
287
- title: string;
286
+ /** The optional current chat crumb. Omit with project to use only your header slot. */
287
+ title?: string;
288
288
  /** The project crumb's glyph. Default `RiFolderLine`. */
289
289
  projectIcon?: BloomIconComponent;
290
290
  onProjectPress?: () => void;
@@ -31,6 +31,7 @@ import { View, type LayoutChangeEvent } from 'react-native';
31
31
  import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
32
32
 
33
33
  import { WEB_POSITION_FIXED, WEB_POSITION_STICKY, type WebCssStyle } from '../styles/web-view-style';
34
+ import { BottomBarSlotContext } from '../layout/bottom-bar-slot';
34
35
  import { useClaimBottomEdge } from '../layout/bottom-edge';
35
36
  import { Z_INDEX } from '../styles/z-index';
36
37
 
@@ -98,7 +99,7 @@ const AppShellBottomBarComponent: React.FC<AppShellBottomBarProps> = ({
98
99
  onLayout={onLayout}
99
100
  style={[anchorBottom(doc), { paddingBottom: insets.bottom }]}
100
101
  >
101
- {children}
102
+ <BottomBarSlotContext.Provider value={insets.bottom}>{children}</BottomBarSlotContext.Provider>
102
103
  </View>
103
104
  );
104
105
  };
@@ -152,6 +153,7 @@ AppShellFloatingAction.displayName = 'AppShellFloatingAction';
152
153
 
153
154
  export interface AppShellTopBarProps {
154
155
  children: React.ReactNode;
156
+ onHeightChange?: (height: number) => void;
155
157
  doc: boolean;
156
158
  testID?: string;
157
159
  }
@@ -165,11 +167,11 @@ export interface AppShellTopBarProps {
165
167
  * only as far as `top: 0`); in `container`/`fixed` and on native it is simply
166
168
  * outside the scroller, which pins it by construction.
167
169
  */
168
- const AppShellTopBarComponent: React.FC<AppShellTopBarProps> = ({ children, doc, testID }) => {
170
+ const AppShellTopBarComponent: React.FC<AppShellTopBarProps> = ({ children, doc, testID, onHeightChange }) => {
169
171
  const insets = useShellInsets();
170
172
  const pinned: WebCssStyle = doc ? { position: WEB_POSITION_STICKY, top: 0 } : {};
171
173
  return (
172
- <View testID={testID} style={[{ zIndex: Z_INDEX.floating, paddingTop: insets.top }, pinned]}>
174
+ <View testID={testID} onLayout={event => onHeightChange?.(event.nativeEvent.layout.height)} style={[{ zIndex: Z_INDEX.floating, paddingTop: insets.top }, pinned]}>
173
175
  {children}
174
176
  </View>
175
177
  );
@@ -17,7 +17,7 @@ import { Backdrop, OverlayRoot } from '../overlay';
17
17
  import { Portal } from '../portal';
18
18
  import { Sidebar } from '../sidebar';
19
19
  import { BREAKPOINTS } from '../styles/breakpoints';
20
- import { WEB_OVERFLOW_CLIP, WEB_POSITION_FIXED, WEB_POSITION_STICKY, type WebCssStyle } from '../styles/web-view-style';
20
+ import { webViewportHeightMinus, WEB_OVERFLOW_CLIP, WEB_POSITION_FIXED, WEB_POSITION_STICKY, type WebCssStyle } from '../styles/web-view-style';
21
21
  import { useTheme } from '../theme/use-theme';
22
22
  import { AppShellBottomBar, AppShellFloatingAction, AppShellTopBar, useShellInsets } from './AppShellBars';
23
23
  import { AppShellHeader } from './AppShellHeader';
@@ -221,7 +221,8 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
221
221
  return () => document.removeEventListener('keydown', dismiss);
222
222
  }, [isOpen, drawerStyle, setOpen]);
223
223
 
224
- const feedRevealAvailable = variant === 'feed' && drawerStyle === 'reveal' && drawerAvailable;
224
+ const revealAvailable = drawerStyle === 'reveal' && drawerAvailable;
225
+ const feedRevealAvailable = variant === 'feed' && revealAvailable;
225
226
  const feedRevealed = feedRevealAvailable && isOpen;
226
227
 
227
228
  const shell = useMemo(
@@ -271,6 +272,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
271
272
  const showTopBar = topBar != null && barActive(topBarVisibility);
272
273
  const showBottomBar = bottomBar != null && barActive(bottomBarVisibility);
273
274
  const showFloatingAction = floatingAction != null;
275
+ const [topBarHeight, setTopBarHeight] = useState(0);
274
276
  const [bottomBarHeight, setBottomBarHeight] = useState(0);
275
277
  const [floatingActionHeight, setFloatingActionHeight] = useState(0);
276
278
  // Both slots are MEASURED rather than assumed: a bar's height is its
@@ -285,7 +287,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
285
287
  <SurfaceLevelProvider level={compactPanel ? 1 : 0} fill={compactPanel ? theme.colors.card : background}>
286
288
  {showBottomBar ? (
287
289
  <AppShellBottomBar
288
- doc={doc && !feedRevealAvailable}
290
+ doc={doc && !revealAvailable}
289
291
  onHeightChange={setBottomBarHeight}
290
292
  testID={testID ? `${testID}-bottom-bar` : undefined}
291
293
  >
@@ -294,7 +296,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
294
296
  ) : null}
295
297
  {showFloatingAction ? (
296
298
  <AppShellFloatingAction
297
- doc={doc && !feedRevealAvailable}
299
+ doc={doc && !revealAvailable}
298
300
  offset={bottomEdge + gutter}
299
301
  gutter={gutter}
300
302
  placement={floatingActionPlacement}
@@ -308,7 +310,7 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
308
310
  );
309
311
 
310
312
  const topBarNode = showTopBar ? (
311
- <AppShellTopBar doc={doc} testID={testID ? `${testID}-top-bar` : undefined}>
313
+ <AppShellTopBar doc={doc} onHeightChange={setTopBarHeight} testID={testID ? `${testID}-top-bar` : undefined}>
312
314
  {topBar}
313
315
  </AppShellTopBar>
314
316
  ) : null;
@@ -492,7 +494,15 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
492
494
  ) : null}
493
495
  </Animated.View>
494
496
  {overlay}
495
- {bars}
497
+ {drawerAvailable ? (
498
+ <Animated.View
499
+ testID={testID ? `${testID}-reveal-bars` : undefined}
500
+ pointerEvents={isOpen ? 'none' : 'box-none'}
501
+ style={[{ position: doc ? WEB_POSITION_FIXED : 'absolute', top: 0, bottom: 0, left: 0, right: 0, overflow: 'hidden' }, pageStyle]}
502
+ >
503
+ {bars}
504
+ </Animated.View>
505
+ ) : bars}
496
506
  </View>
497
507
  </AppShellProvider>
498
508
  );
@@ -512,6 +522,8 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
512
522
  /** `feed` / `focus`: a fixed reading column, centred in what the nav leaves. */
513
523
  const contentAligned = navigationAlign === 'content' && centred;
514
524
  // Plain navigation belongs to the viewport; only the reading surfaces are inset.
525
+ const documentPanelInset = feedRevealed ? 12 : compactPanel ? 0 : gutter;
526
+ const externalTopHeight = showTopBar ? topBarHeight : 0;
515
527
  const plainDocumentFrame = doc && contentAligned && flowSidebar?.surface === 'plain' && !topBarNode;
516
528
  const readingGap = Math.max(0, asideGap ?? columnGap);
517
529
  const readingGroupWidth = contentWidth + (asideBeside ? asideWidth + readingGap : 0);
@@ -592,10 +604,13 @@ const AppShellComponent: React.FC<AppShellEngineProps> = ({
592
604
  // exactly where the panel starts. Left at the 8px default it
593
605
  // would sit 8px above the panel's real top edge and snap down on
594
606
  // the first scroll — the panel appearing to breathe.
595
- overlayInset={feedRevealed ? 12 : gutter}
607
+ overlayInset={{ top: externalTopHeight + documentPanelInset, bottom: documentPanelInset }}
596
608
  // The panel's own `flex-1` (basis 0) would collapse to nothing in
597
609
  // a document-flow column, which has no free space to distribute.
598
- surfaceStyle={{ flexGrow: 1, flexShrink: 1, flexBasis: 'auto' }}
610
+ surfaceStyle={{ flexGrow: 1, flexShrink: 1, flexBasis: 'auto',
611
+ // The viewport frame must have a painted surface even when the
612
+ // page is short. A minimum still lets long documents grow.
613
+ minHeight: webViewportHeightMinus(externalTopHeight + documentPanelInset * 2) }}
599
614
  contentStyle={{ flexGrow: 1, flexShrink: 1, flexBasis: 'auto' }}
600
615
  >
601
616
  {/* Header chrome spans the panel. Its own title/action insets
@@ -1,8 +1,9 @@
1
- import { useRef, useState, type ComponentType } from 'react';
1
+ import { useContext, useRef, useState, type ComponentType } from 'react';
2
2
  import { View, ScrollView, Keyboard, Platform } from 'react-native';
3
3
  import { useEffect } from 'react';
4
4
  import Animated, { useAnimatedReaction, useAnimatedStyle, interpolate, runOnJS, useReducedMotion } from 'react-native-reanimated';
5
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
+ import { BottomBarSlotContext } from '../layout/bottom-bar-slot';
6
7
  import { useClaimBottomEdge } from '../layout/bottom-edge';
7
8
  import { FAB_METRICS } from '../fab/constants';
8
9
  import { windowEdgeGap } from '../layout/edge';
@@ -17,7 +18,7 @@ interface Props extends BottomBarProps {
17
18
  Item: ComponentType<TabBarButtonProps>;
18
19
  Blur: ComponentType<ProgressiveBlurProps>;
19
20
  }
20
- export function BottomBarBase({ Navigation, Item, Blur, items, value, onValueChange, action,
21
+ export function BottomBarBase({ Navigation, Item, Blur, items, value, onValueChange, activeProgress, onValueLongPress, action,
21
22
  actionPlacement = 'auto', actionBehavior = 'hide', material = 'translucent', minimizeProgress, blur = true, maxWidth = 560, style, testID }: Props) {
22
23
  const insets = useSafeAreaInsets();
23
24
  const minimize = useMinimizeState();
@@ -45,14 +46,15 @@ export function BottomBarBase({ Navigation, Item, Blur, items, value, onValueCha
45
46
  node.inert = actionHidden;
46
47
  }, [actionHidden]);
47
48
  const [keyboardVisible, setKeyboardVisible] = useState(false);
48
- const bottom = windowEdgeGap(insets.bottom);
49
+ const suppliedBottomInset = useContext(BottomBarSlotContext);
50
+ const bottom = Math.max(0, windowEdgeGap(insets.bottom) - (suppliedBottomInset ?? 0));
49
51
  useEffect(() => {
50
52
  if (Platform.OS === 'web') return;
51
53
  const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
52
54
  const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
53
55
  return () => { show.remove(); hide.remove(); };
54
56
  }, []);
55
- useClaimBottomEdge(keyboardVisible ? 0 : bottom + footprint);
57
+ useClaimBottomEdge(keyboardVisible || suppliedBottomInset !== undefined ? 0 : bottom + footprint);
56
58
  const rowStyle = useAnimatedStyle(() => ({
57
59
  height: interpolate(reducedMotion ? Number(progress.value > 0.01) : progress.value, [0, 1], [EXPANDED_HEIGHT, MINIMIZED_HEIGHT], 'clamp'),
58
60
  }), [progress, reducedMotion]);
@@ -70,6 +72,7 @@ export function BottomBarBase({ Navigation, Item, Blur, items, value, onValueCha
70
72
  {blur && material === 'translucent' && <Blur direction="bottom" style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: bottom + footprint + BLUR_BLEED }} />}
71
73
  <Animated.View testID={testID ? `${testID}-row` : undefined} onLayout={event => setRowWidth(event.nativeEvent.layout.width)} pointerEvents="box-none" style={[{ justifyContent: items.length ? 'center' : 'flex-end', alignSelf: 'center', width: '100%', maxWidth, paddingLeft: 12, paddingRight: 12, marginBottom: bottom, flexDirection: 'row', alignItems: 'center' }, rowStyle]}>
72
74
  {items.length > 0 && <View testID={testID ? `${testID}-navigation` : undefined} style={{ width: navigationWidth, minWidth: 0 }}><ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ minWidth: '100%' }}><View style={{ width: Math.max(navigationWidth, items.length * 44 + 8) }}><Navigation scrollable={navigationWidth > 0 && navigationWidth < items.length * 44 + 8} embedded blur={false} material={material} minimizeProgress={progress}
75
+ activeProgress={activeProgress} onIndexLongPress={onValueLongPress ? index => { if (items[index]) onValueLongPress(items[index].name); } : undefined}
73
76
  activeIndex={items.findIndex(item => item.name === value)} onIndexChange={index => { if (items[index]) onValueChange(items[index].name); }}>
74
77
  {items.map((item, index) => <Item key={item.name} item={item} index={index} />)}
75
78
  </Navigation></View></ScrollView></View>}
@@ -7,6 +7,10 @@ export interface BottomBarProps {
7
7
  items: TabBarItem[];
8
8
  value: string;
9
9
  onValueChange: (value: string) => void;
10
+ /** Optional continuous selection supplied by a swipe pager, in item-index space. */
11
+ activeProgress?: SharedValue<number>;
12
+ /** Long press on a destination (for example an account switcher). */
13
+ onValueLongPress?: (value: string) => void;
10
14
  /** Static action sibling; outside the navigation scrub detector. */
11
15
  action?: ReactNode;
12
16
  /** Auto places the action above navigation when side-by-side targets would be cramped. */
@@ -0,0 +1,4 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** Bottom safe area already supplied by the measured app-shell slot. */
4
+ export const BottomBarSlotContext = createContext<number | undefined>(undefined);