@oxyhq/bloom 0.79.0 → 0.79.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +3 -3
  2. package/lib/commonjs/provider/index.js +11 -7
  3. package/lib/commonjs/provider/index.js.map +1 -1
  4. package/lib/commonjs/scroll/context.js +96 -0
  5. package/lib/commonjs/scroll/context.js.map +1 -0
  6. package/lib/commonjs/scroll/expo-router/index.js +133 -0
  7. package/lib/commonjs/scroll/expo-router/index.js.map +1 -0
  8. package/lib/commonjs/scroll/index.js +85 -21
  9. package/lib/commonjs/scroll/index.js.map +1 -1
  10. package/lib/commonjs/scroll/index.web.js +184 -118
  11. package/lib/commonjs/scroll/index.web.js.map +1 -1
  12. package/lib/commonjs/scroll/scrollable.native.js +58 -0
  13. package/lib/commonjs/scroll/scrollable.native.js.map +1 -0
  14. package/lib/commonjs/scroll/store.js +47 -25
  15. package/lib/commonjs/scroll/store.js.map +1 -1
  16. package/lib/commonjs/theme/color-policy.js +11 -10
  17. package/lib/commonjs/theme/color-policy.js.map +1 -1
  18. package/lib/module/provider/index.js +10 -6
  19. package/lib/module/provider/index.js.map +1 -1
  20. package/lib/module/scroll/context.js +90 -0
  21. package/lib/module/scroll/context.js.map +1 -0
  22. package/lib/module/scroll/expo-router/index.js +128 -0
  23. package/lib/module/scroll/expo-router/index.js.map +1 -0
  24. package/lib/module/scroll/index.js +80 -21
  25. package/lib/module/scroll/index.js.map +1 -1
  26. package/lib/module/scroll/index.web.js +181 -119
  27. package/lib/module/scroll/index.web.js.map +1 -1
  28. package/lib/module/scroll/scrollable.native.js +54 -0
  29. package/lib/module/scroll/scrollable.native.js.map +1 -0
  30. package/lib/module/scroll/store.js +47 -25
  31. package/lib/module/scroll/store.js.map +1 -1
  32. package/lib/module/theme/color-policy.js +11 -10
  33. package/lib/module/theme/color-policy.js.map +1 -1
  34. package/lib/typescript/commonjs/provider/index.d.ts +7 -5
  35. package/lib/typescript/commonjs/provider/index.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/scroll/context.d.ts +14 -0
  37. package/lib/typescript/commonjs/scroll/context.d.ts.map +1 -0
  38. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts +7 -0
  39. package/lib/typescript/commonjs/scroll/expo-router/index.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/scroll/index.d.ts +14 -23
  41. package/lib/typescript/commonjs/scroll/index.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/scroll/index.web.d.ts +17 -19
  43. package/lib/typescript/commonjs/scroll/index.web.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts +23 -0
  45. package/lib/typescript/commonjs/scroll/scrollable.native.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/scroll/store.d.ts +41 -22
  47. package/lib/typescript/commonjs/scroll/store.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/scroll/types.d.ts +82 -12
  49. package/lib/typescript/commonjs/scroll/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/theme/color-policy.d.ts.map +1 -1
  51. package/lib/typescript/module/provider/index.d.ts +7 -5
  52. package/lib/typescript/module/provider/index.d.ts.map +1 -1
  53. package/lib/typescript/module/scroll/context.d.ts +14 -0
  54. package/lib/typescript/module/scroll/context.d.ts.map +1 -0
  55. package/lib/typescript/module/scroll/expo-router/index.d.ts +7 -0
  56. package/lib/typescript/module/scroll/expo-router/index.d.ts.map +1 -0
  57. package/lib/typescript/module/scroll/index.d.ts +14 -23
  58. package/lib/typescript/module/scroll/index.d.ts.map +1 -1
  59. package/lib/typescript/module/scroll/index.web.d.ts +17 -19
  60. package/lib/typescript/module/scroll/index.web.d.ts.map +1 -1
  61. package/lib/typescript/module/scroll/scrollable.native.d.ts +23 -0
  62. package/lib/typescript/module/scroll/scrollable.native.d.ts.map +1 -0
  63. package/lib/typescript/module/scroll/store.d.ts +41 -22
  64. package/lib/typescript/module/scroll/store.d.ts.map +1 -1
  65. package/lib/typescript/module/scroll/types.d.ts +82 -12
  66. package/lib/typescript/module/scroll/types.d.ts.map +1 -1
  67. package/lib/typescript/module/theme/color-policy.d.ts.map +1 -1
  68. package/package.json +15 -1
  69. package/src/__tests__/BloomProvider.web.test.tsx +8 -15
  70. package/src/__tests__/optional-peer-imports.test.ts +2 -2
  71. package/src/__tests__/scroll-expo-router-adapter.test.tsx +164 -0
  72. package/src/__tests__/scroll-native.test.tsx +465 -0
  73. package/src/__tests__/scroll-store.test.ts +41 -17
  74. package/src/__tests__/scroll-web.test.tsx +547 -111
  75. package/src/provider/index.tsx +10 -7
  76. package/src/scroll/context.tsx +107 -0
  77. package/src/scroll/expo-router/index.ts +140 -0
  78. package/src/scroll/index.ts +92 -25
  79. package/src/scroll/index.web.tsx +219 -162
  80. package/src/scroll/scrollable.native.ts +64 -0
  81. package/src/scroll/store.ts +47 -25
  82. package/src/scroll/types.ts +82 -12
  83. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +5 -5
  84. package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +11 -11
  85. package/src/theme/__tests__/policy-legibility.test.ts +10 -7
  86. package/src/theme/color-policy.ts +11 -10
  87. package/lib/commonjs/provider/scroll-provider.js +0 -13
  88. package/lib/commonjs/provider/scroll-provider.js.map +0 -1
  89. package/lib/commonjs/provider/scroll-provider.web.js +0 -13
  90. package/lib/commonjs/provider/scroll-provider.web.js.map +0 -1
  91. package/lib/module/provider/scroll-provider.js +0 -16
  92. package/lib/module/provider/scroll-provider.js.map +0 -1
  93. package/lib/module/provider/scroll-provider.web.js +0 -5
  94. package/lib/module/provider/scroll-provider.web.js.map +0 -1
  95. package/lib/typescript/commonjs/provider/scroll-provider.d.ts +0 -14
  96. package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +0 -1
  97. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +0 -3
  98. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +0 -1
  99. package/lib/typescript/module/provider/scroll-provider.d.ts +0 -14
  100. package/lib/typescript/module/provider/scroll-provider.d.ts.map +0 -1
  101. package/lib/typescript/module/provider/scroll-provider.web.d.ts +0 -3
  102. package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +0 -1
  103. package/src/__tests__/scroll-native.test.ts +0 -25
  104. package/src/provider/scroll-provider.ts +0 -13
  105. package/src/provider/scroll-provider.web.ts +0 -2
@@ -3,16 +3,12 @@
3
3
  *
4
4
  * This file deliberately contains NO React and NO DOM/native imports so the
5
5
  * logic can be unit-tested in isolation and shared verbatim by the web and
6
- * native barrels. The web barrel drives it with real scroll offsets; the
7
- * native barrel never instantiates it (native-stack already restores scroll).
6
+ * native barrels.
8
7
  */
9
-
10
8
  /**
11
- * Separator between the navigation route key and an optional caller-supplied
12
- * sub-key. A route may host more than one independently-scrolling list (e.g.
13
- * a tabbed profile screen), so each list contributes its own sub-key.
9
+ * Separator between the two components of a storage key.
14
10
  *
15
- * `\0` (NUL) can never appear in a React Navigation route key or in a
11
+ * `\0` (NUL) can never appear in a router-derived content id or in a
16
12
  * developer-authored sub-key, so it is collision-free as a delimiter. It is
17
13
  * written as an escape sequence (not a literal byte) so the source stays
18
14
  * text-diffable.
@@ -20,31 +16,51 @@
20
16
  const COMPOSITE_KEY_SEPARATOR = '\0';
21
17
 
22
18
  /**
23
- * Derive the storage key for a scrollable from its owning route key and an
24
- * optional caller sub-key.
19
+ * Derive the storage key for a scrollable from WHAT its screen is showing plus
20
+ * an optional caller sub-key.
21
+ *
22
+ * The key is CONTENT identity, deliberately not the navigation entry. An offset
23
+ * belongs to what the user was looking at, so returning to it restores however
24
+ * they got there — browser Back or Forward, a tab press, an in-app link — and
25
+ * only content never seen this session opens at the top. Keying on the entry
26
+ * would defeat that: a tab press and `router.replace` both mint a fresh entry,
27
+ * so both would open at the top even though the user has been there.
28
+ *
29
+ * The trade, chosen rather than overlooked: the same content occupying two LIVE
30
+ * entries now shares one offset. Pushing `/@alice` on top of `/@alice` in a
31
+ * native stack restores the first one's position instead of opening at the top,
32
+ * and while both are mounted each writes to the same key. That is the price of
33
+ * a tab press restoring, which is the behaviour that was asked for.
25
34
  *
26
- * - `routeKey` is React Navigation's stable per-route `route.key`.
27
- * - `subKey` distinguishes multiple scrollables that share a single route.
35
+ * The key is always the same two-field composite, with an absent sub-key
36
+ * written as empty, so two different identities can never collapse onto one key
37
+ * by omission.
28
38
  *
29
- * Returns `null` when there is no route key to anchor against (the scrollable
30
- * is not inside a navigator), which the caller treats as "do not persist".
39
+ * Returns `null` when there is no content id to anchor against (the scrollable
40
+ * is not inside a navigator, or the adapter cannot answer), which every caller
41
+ * treats as "do not persist and do not restore".
31
42
  */
32
43
  export function deriveScrollKey(
33
- routeKey: string | undefined,
44
+ contentId: string | null,
34
45
  subKey?: string,
35
46
  ): string | null {
36
- if (!routeKey) return null;
37
- if (subKey === undefined || subKey === '') return routeKey;
38
- return `${routeKey}${COMPOSITE_KEY_SEPARATOR}${subKey}`;
47
+ if (!contentId) return null;
48
+ return [contentId, subKey ?? ''].join(COMPOSITE_KEY_SEPARATOR);
39
49
  }
40
50
 
41
51
  /**
42
52
  * In-memory map of `scrollKey -> last-known scroll offset`.
43
53
  *
44
- * Mirrors the semantics of Bluesky's `Map<screenKey, scrollY>`: offsets live
45
- * only for the lifetime of the document/session. We never persist them — a
46
- * full reload should start at the top and we never auto-evict, because a
47
- * route can be revisited via browser Forward/Back long after it blurred.
54
+ * Offsets live only for the lifetime of the document/session. We never persist
55
+ * them a full reload should start at the top and we never auto-evict,
56
+ * because an entry can be revisited via browser Forward/Back long after it
57
+ * blurred.
58
+ *
59
+ * Deliberately an instance held in React context rather than a module-level
60
+ * singleton: a dependency tree can legitimately contain two copies of Bloom
61
+ * (a hoisted one plus a nested one under another Oxy package), and a
62
+ * module-global would then exist twice with no way for either half to see the
63
+ * other's offsets.
48
64
  */
49
65
  export class ScrollOffsetStore {
50
66
  private readonly offsets = new Map<string, number>();
@@ -55,19 +71,25 @@ export class ScrollOffsetStore {
55
71
  }
56
72
 
57
73
  /**
58
- * Read the saved offset for a key. Returns `0` when nothing was saved, so
59
- * callers can restore unconditionally (an unseen list restores to the top).
74
+ * Read the saved offset for a key. Returns `0` when nothing was saved, which
75
+ * is the same value a caller writes when {@link has} is false — an unseen
76
+ * list restores to the top.
60
77
  */
61
78
  read(key: string): number {
62
79
  return this.offsets.get(key) ?? 0;
63
80
  }
64
81
 
65
- /** Whether an offset was ever saved for this key. */
82
+ /**
83
+ * Whether an offset was ever saved for this key. This — not `read() > 0` —
84
+ * is what separates "restore" from "reset": a key that was never seen and a
85
+ * key deliberately saved at the top both read 0, and only the store knows
86
+ * which is which.
87
+ */
66
88
  has(key: string): boolean {
67
89
  return this.offsets.has(key);
68
90
  }
69
91
 
70
- /** Drop a saved offset (e.g. when a route is permanently removed). */
92
+ /** Drop a saved offset (e.g. when an entry is permanently removed). */
71
93
  forget(key: string): void {
72
94
  this.offsets.delete(key);
73
95
  }
@@ -1,21 +1,25 @@
1
1
  import type { ReactNode, RefObject } from 'react';
2
+ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
2
3
 
3
4
  /**
4
5
  * Anything `useScrollRestoration` knows how to read/write a vertical scroll
5
6
  * offset from. The hook accepts a ref to one of these:
6
7
  *
7
- * - a React Native `ScrollView` / `FlatList` ref (which on web is backed by a
8
- * DOM node and exposes `getScrollableNode()`),
8
+ * - a React Native `ScrollView` / `FlatList` / `FlashList` ref (which on web is
9
+ * backed by a DOM node and exposes `getScrollableNode()`),
9
10
  * - a raw DOM element ref (when a component renders its own scroll container),
10
- * - the literal `'window'` sentinel, for the rare layout where the list IS the
11
- * window scroller (matches Bluesky's default).
11
+ * - the literal `'window'` sentinel, for the layout where the list IS the
12
+ * document scroller.
12
13
  *
13
- * Native never reads any of these — the native hook is a no-op — so the type is
14
- * intentionally permissive rather than coupled to a specific RN class.
14
+ * The two imperative methods are the native write path; web writes `scrollTop`
15
+ * on the resolved DOM node instead. Both are optional because a given ref only
16
+ * ever exposes one of them (list vs. scroll view).
15
17
  */
16
18
  export interface ScrollableHandle {
17
- /** Imperative scroll API exposed by RN scrollables. */
19
+ /** `ScrollView`'s imperative scroll API. */
18
20
  scrollTo?: (options: { x?: number; y?: number; animated?: boolean }) => void;
21
+ /** `FlatList`/`FlashList`'s imperative scroll API. */
22
+ scrollToOffset?: (options: { offset: number; animated?: boolean }) => void;
19
23
  /** Web/RNW path: returns the underlying DOM node. */
20
24
  getScrollableNode?: () => unknown;
21
25
  }
@@ -28,21 +32,87 @@ export type ScrollRestorationTarget =
28
32
  | RefObject<unknown>
29
33
  | 'window';
30
34
 
35
+ /** The effect shape `useScreenFocusEffect` runs — mirrors `useEffect`'s. */
36
+ export type ScreenFocusEffect = () => (() => void) | void;
37
+
38
+ /**
39
+ * The router bindings the scroll core needs, and the ONLY thing it knows about
40
+ * routing. Keeping this to two hooks is what lets the core ship without a
41
+ * router import, so a Vite/SPA consumer can supply its own adapter (or none).
42
+ *
43
+ * Both members are HOOKS: the core calls them from `useScrollRestoration`, so
44
+ * the adapter value must be referentially stable for the lifetime of the
45
+ * provider (a module-level constant, which is what every adapter Bloom ships
46
+ * is). Swapping in a different adapter at runtime would change the hook order
47
+ * of every consumer below it.
48
+ */
49
+ export interface ScrollRouterAdapter {
50
+ /**
51
+ * Identify WHAT the calling screen is showing — its route and params, not the
52
+ * history slot it occupies. Offsets are remembered per content, so returning
53
+ * to something already seen restores it however the user got there.
54
+ *
55
+ * It must describe the screen the CALLER is rendered inside, never the
56
+ * globally focused one: a background screen has to keep reporting its own
57
+ * content, or it would save its offset under the foreground screen's key.
58
+ *
59
+ * `null` when the adapter cannot answer (a scrollable rendered outside any
60
+ * navigator), which makes the hook inert rather than guessing.
61
+ */
62
+ useScreenContentId(): string | null;
63
+ /**
64
+ * Run `effect` while the calling screen is focused, and its cleanup on blur.
65
+ * Re-runs when `effect`'s identity changes AND the screen is focused — that
66
+ * is what makes an in-screen key change (a tab or folder swap) visible to a
67
+ * hook that never blurred.
68
+ */
69
+ useScreenFocusEffect(effect: ScreenFocusEffect): void;
70
+ }
71
+
31
72
  export interface UseScrollRestorationOptions {
32
73
  /**
33
- * Sub-key to disambiguate multiple scrollables that live on the same route
34
- * (e.g. the tabs of a profile screen). Combined with the active route key to
35
- * form the storage key. Omit when a route has a single scrollable.
74
+ * Sub-key distinguishing scrollables that share one screen AND one content
75
+ * id the tabs of a profile, the folders of a saved-items screen. Changing
76
+ * it while the screen stays focused saves the outgoing list's offset and
77
+ * restores (or resets) the incoming one.
36
78
  */
37
79
  key?: string;
38
80
  /**
39
- * When `false`, the hook is inert (saves and restores are skipped). Useful to
40
- * gate restoration behind a feature flag without changing call sites.
81
+ * When `false` the hook is completely inert: no save, no restore, no reset.
82
+ *
83
+ * On NATIVE this is load-bearing rather than a feature flag. Restoring into a
84
+ * list whose rows have not been laid out yet lands at the wrong place (or is
85
+ * clamped away), and native has no equivalent of the web path's re-apply
86
+ * loop — so the caller gates on its own content being present, e.g.
87
+ * `enabled: rows.length > 0`. Flipping it from `false` to `true` is what
88
+ * triggers the restore.
89
+ *
41
90
  * Defaults to `true`.
42
91
  */
43
92
  enabled?: boolean;
44
93
  }
45
94
 
95
+ /**
96
+ * What `useScrollRestoration` hands back. Identical on both platforms so call
97
+ * sites are written once.
98
+ */
99
+ export interface ScrollRestorationBinding {
100
+ /**
101
+ * Wire onto the list being restored: `<FlashList onScroll={scroll.onScroll}>`.
102
+ *
103
+ * Native has no way to observe a list's offset from outside it, so this is
104
+ * how offsets are recorded there. On web it is a stable no-op — the web path
105
+ * subscribes to the resolved DOM node's own `scroll` event, which needs
106
+ * nothing from the caller — and passing it costs nothing.
107
+ */
108
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
109
+ }
110
+
46
111
  export interface ScrollRestorationProviderProps {
47
112
  children: ReactNode;
113
+ /**
114
+ * Binds the store to a router. Must be referentially stable — see
115
+ * {@link ScrollRouterAdapter}.
116
+ */
117
+ adapter: ScrollRouterAdapter;
48
118
  }
@@ -366,7 +366,7 @@
366
366
  "--surface-foreground": "rgb(36 26 14)",
367
367
  "--popover": "rgb(255 234 215)",
368
368
  "--popover-foreground": "rgb(36 26 14)",
369
- "--primary": "rgb(157 99 0)",
369
+ "--primary": "rgb(150 94 0)",
370
370
  "--primary-foreground": "rgb(255 255 255)",
371
371
  "--secondary": "rgb(0 117 141)",
372
372
  "--secondary-foreground": "rgb(255 255 255)",
@@ -486,8 +486,8 @@
486
486
  "--surface-foreground": "rgb(33 27 13)",
487
487
  "--popover": "rgb(250 236 213)",
488
488
  "--popover-foreground": "rgb(33 27 13)",
489
- "--primary": "rgb(255 195 0)",
490
- "--primary-foreground": "rgb(0 0 0)",
489
+ "--primary": "rgb(135 102 0)",
490
+ "--primary-foreground": "rgb(255 255 255)",
491
491
  "--secondary": "rgb(0 114 158)",
492
492
  "--secondary-foreground": "rgb(255 255 255)",
493
493
  "--tertiary": "rgb(117 66 250)",
@@ -1446,8 +1446,8 @@
1446
1446
  "--surface-foreground": "rgb(24 29 18)",
1447
1447
  "--popover": "rgb(234 240 222)",
1448
1448
  "--popover-foreground": "rgb(24 29 18)",
1449
- "--primary": "rgb(139 255 0)",
1450
- "--primary-foreground": "rgb(0 0 0)",
1449
+ "--primary": "rgb(63 120 0)",
1450
+ "--primary-foreground": "rgb(255 255 255)",
1451
1451
  "--secondary": "rgb(79 79 255)",
1452
1452
  "--secondary-foreground": "rgb(255 255 255)",
1453
1453
  "--tertiary": "rgb(190 36 152)",
@@ -47,14 +47,14 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
47
47
  "contrast50": "rgb(249 229 209)",
48
48
  "error": "rgb(203 41 46)",
49
49
  "icon": "rgb(84 68 50)",
50
- "iconActive": "rgb(157 99 0)",
50
+ "iconActive": "rgb(150 94 0)",
51
51
  "info": "rgb(5 102 217)",
52
52
  "negative": "rgb(186 26 26)",
53
53
  "negativeForeground": "rgb(255 255 255)",
54
54
  "negativeSubtle": "rgba(239, 68, 68, 0.13)",
55
55
  "negativeSubtleForeground": "rgb(167 55 53)",
56
56
  "overlay": "rgba(0, 0, 0, 0.5)",
57
- "primary": "rgb(157 99 0)",
57
+ "primary": "rgb(150 94 0)",
58
58
  "primaryDark": "rgb(255 241 229)",
59
59
  "primaryForeground": "rgb(255 255 255)",
60
60
  "primaryLight": "rgb(255 241 229)",
@@ -69,7 +69,7 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
69
69
  "text": "rgb(36 26 14)",
70
70
  "textSecondary": "rgb(84 68 50)",
71
71
  "textTertiary": "rgb(135 116 96)",
72
- "tint": "rgb(157 99 0)",
72
+ "tint": "rgb(150 94 0)",
73
73
  "warning": "rgb(150 94 0)",
74
74
  },
75
75
  "blue/dark": {
@@ -187,16 +187,16 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
187
187
  "contrast50": "rgb(228 235 216)",
188
188
  "error": "rgb(203 41 46)",
189
189
  "icon": "rgb(65 73 57)",
190
- "iconActive": "rgb(139 255 0)",
190
+ "iconActive": "rgb(63 120 0)",
191
191
  "info": "rgb(5 102 217)",
192
192
  "negative": "rgb(186 26 26)",
193
193
  "negativeForeground": "rgb(255 255 255)",
194
194
  "negativeSubtle": "rgba(239, 68, 68, 0.13)",
195
195
  "negativeSubtleForeground": "rgb(167 55 53)",
196
196
  "overlay": "rgba(0, 0, 0, 0.5)",
197
- "primary": "rgb(139 255 0)",
197
+ "primary": "rgb(63 120 0)",
198
198
  "primaryDark": "rgb(240 246 227)",
199
- "primaryForeground": "rgb(0 0 0)",
199
+ "primaryForeground": "rgb(255 255 255)",
200
200
  "primaryLight": "rgb(240 246 227)",
201
201
  "primarySubtle": "rgba(87, 163, 0, 0.13)",
202
202
  "primarySubtleForeground": "rgb(40 80 0)",
@@ -209,7 +209,7 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
209
209
  "text": "rgb(24 29 18)",
210
210
  "textSecondary": "rgb(65 73 57)",
211
211
  "textTertiary": "rgb(114 122 103)",
212
- "tint": "rgb(139 255 0)",
212
+ "tint": "rgb(63 120 0)",
213
213
  "warning": "rgb(150 94 0)",
214
214
  },
215
215
  "green/dark": {
@@ -887,16 +887,16 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
887
887
  "contrast50": "rgb(244 231 208)",
888
888
  "error": "rgb(203 41 46)",
889
889
  "icon": "rgb(79 70 49)",
890
- "iconActive": "rgb(255 195 0)",
890
+ "iconActive": "rgb(135 102 0)",
891
891
  "info": "rgb(5 102 217)",
892
892
  "negative": "rgb(186 26 26)",
893
893
  "negativeForeground": "rgb(255 255 255)",
894
894
  "negativeSubtle": "rgba(239, 68, 68, 0.13)",
895
895
  "negativeSubtleForeground": "rgb(167 55 53)",
896
896
  "overlay": "rgba(0, 0, 0, 0.5)",
897
- "primary": "rgb(255 195 0)",
897
+ "primary": "rgb(135 102 0)",
898
898
  "primaryDark": "rgb(255 242 221)",
899
- "primaryForeground": "rgb(0 0 0)",
899
+ "primaryForeground": "rgb(255 255 255)",
900
900
  "primaryLight": "rgb(255 242 221)",
901
901
  "primarySubtle": "rgba(182, 138, 0, 0.13)",
902
902
  "primarySubtleForeground": "rgb(90 67 0)",
@@ -909,7 +909,7 @@ exports[`theme.colors snapshot for all presets (catches unintended palette shift
909
909
  "text": "rgb(33 27 13)",
910
910
  "textSecondary": "rgb(79 70 49)",
911
911
  "textTertiary": "rgb(130 118 95)",
912
- "tint": "rgb(255 195 0)",
912
+ "tint": "rgb(135 102 0)",
913
913
  "warning": "rgb(150 94 0)",
914
914
  },
915
915
  }
@@ -117,11 +117,12 @@ describe('colour policy legibility', () => {
117
117
  });
118
118
 
119
119
 
120
- // The budget must apply to BOTH modes. Exempting dark so the fill could stay on
121
- // the brand hex left every Follow button, avatar and chat bubble with a black
122
- // label there while light had eleven white onesan asymmetry nobody chose,
123
- // and invisible to the suite because each half was individually legible.
124
- it('the brand fill carries the same label pattern in both modes', () => {
120
+ // The two modes want opposite things and the suite has to say which. Applying
121
+ // the budget in LIGHT let a light seed keep its own tone there, so faircoin
122
+ // rendered the same pale lime in both modesno theme at all. Skipping it in
123
+ // DARK left every Follow button, avatar and chat bubble with a black label.
124
+ // Each half was individually legible, so nothing else could catch either.
125
+ it('the brand fill keeps light exemption-free and dark budgeted', () => {
125
126
  const white = { light: 0, dark: 0 };
126
127
  for (const preset of APP_COLOR_NAMES) {
127
128
  for (const mode of ['light', 'dark'] as const) {
@@ -130,7 +131,9 @@ describe('colour policy legibility', () => {
130
131
  }
131
132
  }
132
133
  }
133
- expect(white.dark).toBe(white.light);
134
- expect(white.light).toBeGreaterThan(0);
134
+ // Light admits no exemption, so every preset carries white there. Dark keeps
135
+ // the budget, so the seeds that are already light keep their colour instead.
136
+ expect(white.light).toBe(APP_COLOR_NAMES.length);
137
+ expect(white.dark).toBeGreaterThan(APP_COLOR_NAMES.length - 4);
135
138
  });
136
139
  });
@@ -214,18 +214,19 @@ function vividHueNear(hue: number): number {
214
214
  * why 11 of the 13 presets carry white and exactly the two light ones do not.
215
215
  */
216
216
  function whiteLabelTone(palette: TonalPalette, seedTone: number, isDark: boolean): number {
217
- // ONE rule, both modes. The fill comes down to the tone a white label needs,
218
- // unless the descent costs more than the budget and destroys the colour then
219
- // it keeps its own tone and takes a black label. Exempting dark, so the fill
220
- // could stay on the brand hex, is what left every Follow button, avatar and
221
- // chat bubble with a black label there while light had eleven white ones: an
222
- // asymmetry nobody chose, visible in every component at once because they all
223
- // read the same token.
224
- const target = isDark ? DARK_FILL_TONE : LIGHT_FILL_TONE;
225
- const candidate = Math.max(target, seedTone - TONE_BUDGET);
217
+ // LIGHT: every fill comes down, no exemption. On a near-white page a bright
218
+ // colour has nothing to read against a lime at its own tone 90 is a pale
219
+ // smear there, indistinguishable from the same colour in dark, which is not a
220
+ // theme at all. Coming down is what makes faircoin a deep green in light and a
221
+ // bright lime in dark.
222
+ if (!isDark) return LIGHT_FILL_TONE;
223
+ // DARK: the budget applies. Most brands come down to where white fits, but a
224
+ // seed that is already light cannot without ceasing to be itself, so it keeps
225
+ // its own tone and takes a black label. That is the eleven-and-two pattern.
226
+ const candidate = Math.max(DARK_FILL_TONE, seedTone - TONE_BUDGET);
226
227
  return contrastOf(palette.tone(candidate), true) >= AA
227
228
  ? candidate
228
- : Math.max(seedTone, isDark ? DARK_SEED_FLOOR : 0);
229
+ : Math.max(seedTone, DARK_SEED_FLOOR);
229
230
  }
230
231
 
231
232
  /**
@@ -1,13 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- Object.defineProperty(exports, "ScrollRestorationProvider", {
7
- enumerable: true,
8
- get: function () {
9
- return _index.ScrollRestorationProvider;
10
- }
11
- });
12
- var _index = require("../scroll/index.js");
13
- //# sourceMappingURL=scroll-provider.js.map
@@ -1 +0,0 @@
1
- {"version":3,"names":["_index","require"],"sourceRoot":"../../../src","sources":["provider/scroll-provider.ts"],"mappings":";;;;;;;;;;;AAYA,IAAAA,MAAA,GAAAC,OAAA","ignoreList":[]}
@@ -1,13 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- Object.defineProperty(exports, "ScrollRestorationProvider", {
7
- enumerable: true,
8
- get: function () {
9
- return _indexWeb.ScrollRestorationProvider;
10
- }
11
- });
12
- var _indexWeb = require("../scroll/index.web.js");
13
- //# sourceMappingURL=scroll-provider.web.js.map
@@ -1 +0,0 @@
1
- {"version":3,"names":["_indexWeb","require"],"sourceRoot":"../../../src","sources":["provider/scroll-provider.web.ts"],"mappings":";;;;;;;;;;;AACA,IAAAA,SAAA,GAAAC,OAAA","ignoreList":[]}
@@ -1,16 +0,0 @@
1
- "use strict";
2
-
3
- /**
4
- * Native/default binding for the scroll-restoration provider used by
5
- * {@link BloomProvider}.
6
- *
7
- * `@oxyhq/bloom/scroll` is web-forked, so its `browser` export condition hands
8
- * web consumers the real implementation while native gets the no-op. A compiled
9
- * `lib/module/provider/index.js` cannot benefit from that condition (it imports
10
- * a relative path, not the package subpath), so the platform choice is made
11
- * here by FILENAME instead: Metro picks `scroll-provider.web.ts` on web, and
12
- * every web bundler that resolves `.web.js` picks the compiled sibling. Same
13
- * mechanism the toast engine uses for `ToastHost.native.tsx`.
14
- */
15
- export { ScrollRestorationProvider } from "../scroll/index.js";
16
- //# sourceMappingURL=scroll-provider.js.map
@@ -1 +0,0 @@
1
- {"version":3,"names":["ScrollRestorationProvider"],"sourceRoot":"../../../src","sources":["provider/scroll-provider.ts"],"mappings":";;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAASA,yBAAyB,QAAQ,oBAAW","ignoreList":[]}
@@ -1,5 +0,0 @@
1
- "use strict";
2
-
3
- /** Web binding for {@link BloomProvider}'s scroll-restoration provider — see `./scroll-provider.ts`. */
4
- export { ScrollRestorationProvider } from "../scroll/index.web.js";
5
- //# sourceMappingURL=scroll-provider.web.js.map
@@ -1 +0,0 @@
1
- {"version":3,"names":["ScrollRestorationProvider"],"sourceRoot":"../../../src","sources":["provider/scroll-provider.web.ts"],"mappings":";;AAAA;AACA,SAASA,yBAAyB,QAAQ,wBAAqB","ignoreList":[]}
@@ -1,14 +0,0 @@
1
- /**
2
- * Native/default binding for the scroll-restoration provider used by
3
- * {@link BloomProvider}.
4
- *
5
- * `@oxyhq/bloom/scroll` is web-forked, so its `browser` export condition hands
6
- * web consumers the real implementation while native gets the no-op. A compiled
7
- * `lib/module/provider/index.js` cannot benefit from that condition (it imports
8
- * a relative path, not the package subpath), so the platform choice is made
9
- * here by FILENAME instead: Metro picks `scroll-provider.web.ts` on web, and
10
- * every web bundler that resolves `.web.js` picks the compiled sibling. Same
11
- * mechanism the toast engine uses for `ToastHost.native.tsx`.
12
- */
13
- export { ScrollRestorationProvider } from '../scroll';
14
- //# sourceMappingURL=scroll-provider.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scroll-provider.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC"}
@@ -1,3 +0,0 @@
1
- /** Web binding for {@link BloomProvider}'s scroll-restoration provider — see `./scroll-provider.ts`. */
2
- export { ScrollRestorationProvider } from '../scroll/index.web';
3
- //# sourceMappingURL=scroll-provider.web.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scroll-provider.web.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.web.ts"],"names":[],"mappings":"AAAA,wGAAwG;AACxG,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -1,14 +0,0 @@
1
- /**
2
- * Native/default binding for the scroll-restoration provider used by
3
- * {@link BloomProvider}.
4
- *
5
- * `@oxyhq/bloom/scroll` is web-forked, so its `browser` export condition hands
6
- * web consumers the real implementation while native gets the no-op. A compiled
7
- * `lib/module/provider/index.js` cannot benefit from that condition (it imports
8
- * a relative path, not the package subpath), so the platform choice is made
9
- * here by FILENAME instead: Metro picks `scroll-provider.web.ts` on web, and
10
- * every web bundler that resolves `.web.js` picks the compiled sibling. Same
11
- * mechanism the toast engine uses for `ToastHost.native.tsx`.
12
- */
13
- export { ScrollRestorationProvider } from '../scroll';
14
- //# sourceMappingURL=scroll-provider.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scroll-provider.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC"}
@@ -1,3 +0,0 @@
1
- /** Web binding for {@link BloomProvider}'s scroll-restoration provider — see `./scroll-provider.ts`. */
2
- export { ScrollRestorationProvider } from '../scroll/index.web';
3
- //# sourceMappingURL=scroll-provider.web.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scroll-provider.web.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.web.ts"],"names":[],"mappings":"AAAA,wGAAwG;AACxG,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -1,25 +0,0 @@
1
- import {
2
- ScrollRestorationProvider,
3
- useScrollRestoration,
4
- } from '../scroll/index';
5
-
6
- // The native barrel is a deliberate no-op: native-stack already preserves
7
- // scroll. These tests pin that contract so a future refactor can't silently
8
- // turn the native path into something that touches the DOM or throws.
9
-
10
- describe('native scroll-restoration barrel', () => {
11
- it('ScrollRestorationProvider renders its children unchanged', () => {
12
- const child = { sentinel: true } as unknown as React.ReactElement;
13
- expect(ScrollRestorationProvider({ children: child })).toBe(child);
14
- });
15
-
16
- it('useScrollRestoration is a no-op for any target/options', () => {
17
- const ref = { current: null };
18
- expect(() => useScrollRestoration(ref)).not.toThrow();
19
- expect(() => useScrollRestoration('window')).not.toThrow();
20
- expect(() =>
21
- useScrollRestoration(ref, { key: 'feed', enabled: true }),
22
- ).not.toThrow();
23
- expect(useScrollRestoration(ref)).toBeUndefined();
24
- });
25
- });
@@ -1,13 +0,0 @@
1
- /**
2
- * Native/default binding for the scroll-restoration provider used by
3
- * {@link BloomProvider}.
4
- *
5
- * `@oxyhq/bloom/scroll` is web-forked, so its `browser` export condition hands
6
- * web consumers the real implementation while native gets the no-op. A compiled
7
- * `lib/module/provider/index.js` cannot benefit from that condition (it imports
8
- * a relative path, not the package subpath), so the platform choice is made
9
- * here by FILENAME instead: Metro picks `scroll-provider.web.ts` on web, and
10
- * every web bundler that resolves `.web.js` picks the compiled sibling. Same
11
- * mechanism the toast engine uses for `ToastHost.native.tsx`.
12
- */
13
- export { ScrollRestorationProvider } from '../scroll';
@@ -1,2 +0,0 @@
1
- /** Web binding for {@link BloomProvider}'s scroll-restoration provider — see `./scroll-provider.ts`. */
2
- export { ScrollRestorationProvider } from '../scroll/index.web';