@oxy.so/bloom 4.34.0 → 4.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/docs/mail-list.mdx +22 -22
  2. package/docs/settings-modal.mdx +1 -1
  3. package/lib/commonjs/mail-list/MailList.js +2 -0
  4. package/lib/commonjs/mail-list/MailList.js.map +1 -1
  5. package/lib/commonjs/mail-list/MailRow.js +26 -29
  6. package/lib/commonjs/mail-list/MailRow.js.map +1 -1
  7. package/lib/commonjs/mail-list/shared.js +26 -10
  8. package/lib/commonjs/mail-list/shared.js.map +1 -1
  9. package/lib/commonjs/settings-modal/SettingsModal.js +12 -18
  10. package/lib/commonjs/settings-modal/SettingsModal.js.map +1 -1
  11. package/lib/module/mail-list/MailList.js +2 -0
  12. package/lib/module/mail-list/MailList.js.map +1 -1
  13. package/lib/module/mail-list/MailRow.js +26 -29
  14. package/lib/module/mail-list/MailRow.js.map +1 -1
  15. package/lib/module/mail-list/shared.js +26 -10
  16. package/lib/module/mail-list/shared.js.map +1 -1
  17. package/lib/module/settings-modal/SettingsModal.js +12 -18
  18. package/lib/module/settings-modal/SettingsModal.js.map +1 -1
  19. package/lib/typescript/commonjs/mail-list/MailList.d.ts +1 -1
  20. package/lib/typescript/commonjs/mail-list/MailList.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/mail-list/MailRow.d.ts +5 -20
  22. package/lib/typescript/commonjs/mail-list/MailRow.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/mail-list/shared.d.ts +2 -9
  24. package/lib/typescript/commonjs/mail-list/shared.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/mail-list/types.d.ts +8 -6
  26. package/lib/typescript/commonjs/mail-list/types.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/settings-modal/SettingsModal.d.ts.map +1 -1
  28. package/lib/typescript/module/mail-list/MailList.d.ts +1 -1
  29. package/lib/typescript/module/mail-list/MailList.d.ts.map +1 -1
  30. package/lib/typescript/module/mail-list/MailRow.d.ts +5 -20
  31. package/lib/typescript/module/mail-list/MailRow.d.ts.map +1 -1
  32. package/lib/typescript/module/mail-list/shared.d.ts +2 -9
  33. package/lib/typescript/module/mail-list/shared.d.ts.map +1 -1
  34. package/lib/typescript/module/mail-list/types.d.ts +8 -6
  35. package/lib/typescript/module/mail-list/types.d.ts.map +1 -1
  36. package/lib/typescript/module/settings-modal/SettingsModal.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__tests__/support/adoption-matrix.ts +708 -0
  39. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  40. package/src/__tests__/support/commerce-harness.tsx +97 -0
  41. package/src/__tests__/support/composite.ts +68 -0
  42. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  43. package/src/__tests__/support/press-host.ts +30 -0
  44. package/src/__tests__/support/rendered-style.ts +99 -0
  45. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  46. package/src/__tests__/support/worklet-capture-fixture.tsx +28 -0
  47. package/src/mail-list/MailList.tsx +2 -0
  48. package/src/mail-list/MailRow.tsx +24 -33
  49. package/src/mail-list/shared.ts +27 -10
  50. package/src/mail-list/types.ts +8 -6
  51. package/src/settings-modal/SettingsModal.tsx +12 -15
  52. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +9346 -0
  53. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +69 -0
  54. 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
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Positive control for `worklet-captures.test.ts`. Never rendered: the gate
3
+ * builds a program over this file and reads its worklets.
4
+ *
5
+ * `history` carries a `Date` and `slot` a React element — the two shapes the
6
+ * native worklet runtime cannot copy — and each is captured whole by a mapper.
7
+ * `hasHistory` is the fix: the primitive derived OUTSIDE the worklet, which the
8
+ * gate must leave alone.
9
+ */
10
+ import type { ReactNode } from 'react';
11
+ import { Gesture } from 'react-native-gesture-handler';
12
+ import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
13
+
14
+ interface Conversation {
15
+ title: string;
16
+ createdAt: Date;
17
+ }
18
+
19
+ export function useWorkletCaptureFixture(history: Conversation[] | undefined, slot: ReactNode) {
20
+ const progress = useSharedValue(0);
21
+ const hasHistory = Boolean(history);
22
+ const whole = useAnimatedStyle(() => ({ opacity: history ? progress.value : 1 }), [progress, history]);
23
+ const derived = useAnimatedStyle(() => ({ opacity: hasHistory ? progress.value : 1 }), [progress, hasHistory]);
24
+ const pan = Gesture.Pan().onUpdate(() => {
25
+ progress.value = slot ? 1 : 0;
26
+ });
27
+ return { whole, derived, pan };
28
+ }
@@ -52,6 +52,7 @@ function MailListComponent({
52
52
  sections,
53
53
  mails,
54
54
  density = 'comfortable',
55
+ showAvatar = true,
55
56
  selectedId,
56
57
  checkedIds,
57
58
  onCheckedIdsChange,
@@ -162,6 +163,7 @@ function MailListComponent({
162
163
  actionsPlacement={mail.actionsPlacement ?? actionsPlacement}
163
164
  swipeEnabled={mail.swipeEnabled ?? swipeEnabled}
164
165
  density={density}
166
+ showAvatar={mail.showAvatar ?? showAvatar}
165
167
  strings={mail.strings ?? strings}
166
168
  selected={selectedId === id}
167
169
  checked={checked.has(id)}
@@ -39,26 +39,11 @@ import type { MailAction, MailRowProps } from './types';
39
39
  /**
40
40
  * One message thread in the inbox.
41
41
  *
42
- * comfortable 72 tall · 16 padding · 48 avatar · sender and time on the
43
- * first line, subject and snippet on the second, the star on
44
- * the right where it is a control
45
- * compact 40 tall · 12 padding · 24 avatar · sender, then subject and
46
- * snippet on ONE baseline, then the states and the time
42
+ * Compact uses one baseline; comfortable and cozy stack sender above subject
43
+ * and snippet. All three share a geometry table, content, paint and actions.
47
44
  *
48
- * ONE component, two rungs of a geometry table. There is no second row
49
- * component for the desktop: everything a compact row draws, a comfortable row
50
- * draws too, and a density that forked into two files is a density that drifts.
51
- *
52
- * TWO LINES, NOT THREE, AND NOTHING DRAWN AT REST THAT IS NOT INFORMATION.
53
- * The phone row is the same 72/48 as a conversation row, so an inbox and a
54
- * messages screen read as one library. What paid for the third line: the
55
- * subject and the snippet share line two, the labels collapse to a chip and
56
- * dots, and the star is drawn only where it is a control.
57
- *
58
- * THE SUBJECT AND THE SNIPPET ARE ONE `Text`, at both densities. Two
59
- * neighbouring flex children each want to be as wide as their content, and the
60
- * result of a long one is a row that pushes sideways or a subject clipped to
61
- * nothing. One string with a nested span truncates where the line ends.
45
+ * The subject and snippet share ONE Text at every density, so a long snippet
46
+ * cannot claim its own flex width and push the subject out of the row.
62
47
  *
63
48
  * UNREAD IS THE WHOLE ROW, NOT A DOT. The sender and the subject go semibold
64
49
  * and every rung of text moves up to the primary colour; a read row sits on the
@@ -95,6 +80,7 @@ function MailRowComponent({
95
80
  checked = false,
96
81
  onCheckedChange,
97
82
  density = 'comfortable',
83
+ showAvatar = true,
98
84
  onPress,
99
85
  onLongPress,
100
86
  href,
@@ -207,6 +193,7 @@ function MailRowComponent({
207
193
 
208
194
  const paperclip = hasAttachment ? (
209
195
  <View
196
+ pointerEvents="none"
210
197
  aria-hidden
211
198
  importantForAccessibility="no-hide-descendants"
212
199
  style={{ flexShrink: 0 }}
@@ -219,6 +206,7 @@ function MailRowComponent({
219
206
  const threadBadge =
220
207
  (threadCount ?? 0) > 1 ? (
221
208
  <View
209
+ pointerEvents="none"
222
210
  aria-hidden
223
211
  importantForAccessibility="no-hide-descendants"
224
212
  style={{ flexShrink: 0 }}
@@ -230,20 +218,22 @@ function MailRowComponent({
230
218
 
231
219
  const timeText =
232
220
  time === undefined ? null : (
233
- <Text
234
- variant={geo.timeVariant}
235
- numberOfLines={1}
236
- style={{ color: timeColor, flexShrink: 0 }}
237
- testID={testID ? `${testID}-time` : undefined}
238
- >
239
- {time}
240
- </Text>
221
+ <View pointerEvents="none" style={{ flexShrink: 0 }}>
222
+ <Text
223
+ variant={geo.timeVariant}
224
+ numberOfLines={1}
225
+ style={{ color: timeColor, flexShrink: 0 }}
226
+ testID={testID ? `${testID}-time` : undefined}
227
+ >
228
+ {time}
229
+ </Text>
230
+ </View>
241
231
  );
242
232
 
243
233
  // --- the leading slot: a checkbox when the list can multi-select ----------
244
234
  const leading =
245
235
  onCheckedChange !== undefined ? (
246
- <View style={{ width: geo.avatar, alignItems: 'center', flexShrink: 0 }}>
236
+ <View pointerEvents="box-none" style={{ width: geo.avatar, alignItems: 'center', flexShrink: 0 }}>
247
237
  <Checkbox
248
238
  checked={checked}
249
239
  onCheckedChange={onCheckedChange}
@@ -252,7 +242,7 @@ function MailRowComponent({
252
242
  testID={testID ? `${testID}-checkbox` : undefined}
253
243
  />
254
244
  </View>
255
- ) : (
245
+ ) : showAvatar ? (
256
246
  <View
257
247
  pointerEvents="none"
258
248
  aria-hidden
@@ -266,7 +256,7 @@ function MailRowComponent({
266
256
  testID={testID ? `${testID}-avatar` : undefined}
267
257
  />
268
258
  </View>
269
- );
259
+ ) : null;
270
260
 
271
261
  // --- the text column -----------------------------------------------------
272
262
  const column = compact ? (
@@ -280,7 +270,7 @@ function MailRowComponent({
280
270
  flexDirection: 'row',
281
271
  alignItems: 'center',
282
272
  gap: geo.gap,
283
- marginLeft: geo.gap,
273
+ marginLeft: leading === null ? 0 : geo.gap,
284
274
  }}
285
275
  >
286
276
  <Text
@@ -315,7 +305,7 @@ function MailRowComponent({
315
305
  pointerEvents="none"
316
306
  aria-hidden
317
307
  importantForAccessibility="no-hide-descendants"
318
- style={{ flex: 1, minWidth: 0, marginLeft: geo.gap, gap: geo.lineGap }}
308
+ style={{ flex: 1, minWidth: 0, marginLeft: leading === null ? 0 : geo.gap, gap: geo.lineGap }}
319
309
  >
320
310
  <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, minWidth: 0 }}>
321
311
  <Text
@@ -371,7 +361,7 @@ function MailRowComponent({
371
361
  // and every star press would open the thread. Everything else in here is
372
362
  // hidden from assistive technology; the row's composed name already says it.
373
363
  const states = paperclip || threadBadge || onStarredChange !== undefined ? (
374
- <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
364
+ <View pointerEvents="box-none" style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
375
365
  {paperclip}
376
366
  {threadBadge}
377
367
  {onStarredChange === undefined ? (starred ? star : null) : star}
@@ -444,6 +434,7 @@ function MailRowComponent({
444
434
  testID={testID ? `${testID}-link` : undefined}
445
435
  />
446
436
  <View
437
+ pointerEvents="box-none"
447
438
  style={{
448
439
  flexDirection: 'row',
449
440
  alignItems: 'center',
@@ -1,5 +1,7 @@
1
1
  import { Platform } from 'react-native';
2
2
 
3
+ import { space } from '../styles/tokens';
4
+
3
5
  import { RiInbox2Line } from '../icons/remix/RiInbox2Line';
4
6
  import {
5
7
  hairlineOn,
@@ -28,7 +30,7 @@ export const IS_WEB = Platform.OS === 'web';
28
30
  export const MAIL_EMPTY_ICON = RiInbox2Line;
29
31
 
30
32
  // ---------------------------------------------------------------------------
31
- // Geometry — the whole difference between the two densities
33
+ // Geometry — the whole difference between the densities
32
34
  // ---------------------------------------------------------------------------
33
35
 
34
36
  export interface MailRowGeometry {
@@ -82,15 +84,8 @@ export interface MailRowGeometry {
82
84
  }
83
85
 
84
86
  /**
85
- * 64 tall with two lines on a phone, 40 tall with one on a desktop. The avatar
86
- * is centred in both, so a row with no snippet is the same height as one with a
87
- * snippet and a label.
88
- *
89
- * The phone rung is the same 72/48 as a `chat-list` row on purpose: a mail row
90
- * and a conversation row are the same kind of object, and an inbox that stands
91
- * 16px taller per row than the messages screen reads as a different library.
92
- * Two lines is what pays for it — the sender and the time share the first, the
93
- * subject and the snippet the second.
87
+ * Two stacked lines at comfortable/cozy, one baseline at compact. Stacked rows
88
+ * grow with their content; cozy gives the same facts more breathing room.
94
89
  */
95
90
  export const MAIL_ROW_GEOMETRY: Record<MailDensity, MailRowGeometry> = {
96
91
  comfortable: {
@@ -118,6 +113,27 @@ export const MAIL_ROW_GEOMETRY: Record<MailDensity, MailRowGeometry> = {
118
113
  actionGlyph: 18,
119
114
  starControl: 24,
120
115
  },
116
+ cozy: {
117
+ minHeight: 80,
118
+ paddingVertical: space.lg,
119
+ paddingHorizontal: space.lg,
120
+ avatar: 48,
121
+ gap: space.md,
122
+ lineGap: space._2xs,
123
+ glyph: space.lg,
124
+ senderWidth: 0,
125
+ senderMinWidth: 0,
126
+ senderMaxWidth: '100%',
127
+ senderVariant: 'headline-regular',
128
+ senderUnreadVariant: 'headline-semibold',
129
+ subjectVariant: 'body-regular',
130
+ subjectUnreadVariant: 'body-semibold',
131
+ snippetVariant: 'body-regular',
132
+ timeVariant: 'caption-1-regular',
133
+ action: 44,
134
+ actionGlyph: 18,
135
+ starControl: space._2xl,
136
+ },
121
137
  compact: {
122
138
  minHeight: 40,
123
139
  height: 40,
@@ -161,6 +177,7 @@ export const MAIL_LABEL_DOT = 8;
161
177
  /** The selection bar's height, per density. */
162
178
  export const MAIL_SELECTION_BAR_HEIGHT: Record<MailDensity, number> = {
163
179
  comfortable: 56,
180
+ cozy: 56,
164
181
  compact: 44,
165
182
  };
166
183