@oxyhq/bloom 0.87.0 → 0.88.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 (105) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +1 -1
  3. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +7 -12
  4. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  5. package/lib/commonjs/button/Button.web.js +2 -6
  6. package/lib/commonjs/button/Button.web.js.map +1 -1
  7. package/lib/commonjs/connection-dots/ConnectionDots.web.js +2 -6
  8. package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -1
  9. package/lib/commonjs/design-tokens/index.js +19 -0
  10. package/lib/commonjs/design-tokens/index.js.map +1 -1
  11. package/lib/commonjs/design-tokens/tokens-json.js +149 -0
  12. package/lib/commonjs/design-tokens/tokens-json.js.map +1 -0
  13. package/lib/commonjs/design-tokens/tokens.json +6681 -0
  14. package/lib/commonjs/dialog/Dialog.web.js +2 -6
  15. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  16. package/lib/commonjs/fab/Fab.web.js +2 -6
  17. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  18. package/lib/commonjs/fonts/apply-font-faces.web.js +8 -10
  19. package/lib/commonjs/fonts/apply-font-faces.web.js.map +1 -1
  20. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +2 -6
  21. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
  22. package/lib/commonjs/loading/SpinnerIcon.web.js +2 -6
  23. package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
  24. package/lib/commonjs/overlay/index.js +102 -24
  25. package/lib/commonjs/overlay/index.js.map +1 -1
  26. package/lib/commonjs/styles/adopt-style-sheet.js +126 -0
  27. package/lib/commonjs/styles/adopt-style-sheet.js.map +1 -0
  28. package/lib/module/bottom-sheet/BottomSheetBase.js +7 -12
  29. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  30. package/lib/module/button/Button.web.js +2 -6
  31. package/lib/module/button/Button.web.js.map +1 -1
  32. package/lib/module/connection-dots/ConnectionDots.web.js +2 -6
  33. package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
  34. package/lib/module/design-tokens/index.js +5 -0
  35. package/lib/module/design-tokens/index.js.map +1 -1
  36. package/lib/module/design-tokens/tokens-json.js +144 -0
  37. package/lib/module/design-tokens/tokens-json.js.map +1 -0
  38. package/lib/module/design-tokens/tokens.json +6681 -0
  39. package/lib/module/dialog/Dialog.web.js +2 -6
  40. package/lib/module/dialog/Dialog.web.js.map +1 -1
  41. package/lib/module/fab/Fab.web.js +2 -6
  42. package/lib/module/fab/Fab.web.js.map +1 -1
  43. package/lib/module/fonts/apply-font-faces.web.js +8 -10
  44. package/lib/module/fonts/apply-font-faces.web.js.map +1 -1
  45. package/lib/module/frosted-icon-button/FrostedIconButton.web.js +2 -6
  46. package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
  47. package/lib/module/loading/SpinnerIcon.web.js +2 -6
  48. package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
  49. package/lib/module/overlay/index.js +103 -25
  50. package/lib/module/overlay/index.js.map +1 -1
  51. package/lib/module/styles/adopt-style-sheet.js +121 -0
  52. package/lib/module/styles/adopt-style-sheet.js.map +1 -0
  53. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/design-tokens/index.d.ts +2 -0
  57. package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +85 -0
  59. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts +5 -3
  63. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/overlay/index.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts +22 -0
  68. package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts.map +1 -0
  69. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  70. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  71. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  72. package/lib/typescript/module/design-tokens/index.d.ts +2 -0
  73. package/lib/typescript/module/design-tokens/index.d.ts.map +1 -1
  74. package/lib/typescript/module/design-tokens/tokens-json.d.ts +85 -0
  75. package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -0
  76. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  77. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  78. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts +5 -3
  79. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts.map +1 -1
  80. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
  81. package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
  82. package/lib/typescript/module/overlay/index.d.ts.map +1 -1
  83. package/lib/typescript/module/styles/adopt-style-sheet.d.ts +22 -0
  84. package/lib/typescript/module/styles/adopt-style-sheet.d.ts.map +1 -0
  85. package/package.json +4 -2
  86. package/src/__tests__/adopt-style-sheet.node.test.ts +21 -0
  87. package/src/__tests__/adopt-style-sheet.test.ts +165 -0
  88. package/src/__tests__/apply-font-faces.test.ts +32 -0
  89. package/src/__tests__/design-tokens-json.test.ts +112 -0
  90. package/src/__tests__/exports-map-contract.test.ts +5 -1
  91. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  92. package/src/bottom-sheet/BottomSheetBase.tsx +10 -14
  93. package/src/button/Button.web.tsx +2 -6
  94. package/src/connection-dots/ConnectionDots.web.tsx +2 -6
  95. package/src/design-tokens/index.ts +16 -0
  96. package/src/design-tokens/tokens-json.ts +214 -0
  97. package/src/design-tokens/tokens.json +6681 -0
  98. package/src/dialog/Dialog.stories.tsx +37 -0
  99. package/src/dialog/Dialog.web.tsx +2 -6
  100. package/src/fab/Fab.web.tsx +2 -6
  101. package/src/fonts/apply-font-faces.web.ts +11 -11
  102. package/src/frosted-icon-button/FrostedIconButton.web.tsx +2 -6
  103. package/src/loading/SpinnerIcon.web.tsx +2 -6
  104. package/src/overlay/index.tsx +123 -24
  105. package/src/styles/adopt-style-sheet.ts +131 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { adoptStyleSheet, dropStyleSheet } from '../styles/adopt-style-sheet';
6
+
7
+ /* Centralising the injection also centralised the `typeof document` guard that
8
+ * used to sit in each fork. It has to stay: `adoptStyleSheet` is reached from a
9
+ * platform-neutral component (`BottomSheetBase`) and from every server render,
10
+ * where throwing would take the whole page down rather than lose a stylesheet.
11
+ * Needs its own file — jest picks the environment per file. */
12
+ describe('adoptStyleSheet with no document (native / SSR)', () => {
13
+ it('the environment under test genuinely has no document', () => {
14
+ expect(typeof document).toBe('undefined');
15
+ });
16
+
17
+ it('is a no-op rather than a throw', () => {
18
+ expect(() => adoptStyleSheet('bloom-test', '.x {}')).not.toThrow();
19
+ expect(() => dropStyleSheet('bloom-test')).not.toThrow();
20
+ });
21
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import {
6
+ installConstructedStyleSheets,
7
+ type ConstructedStyleSheetsHarness,
8
+ } from './support/constructed-style-sheets';
9
+
10
+ type AdoptStyleSheetModule = typeof import('../styles/adopt-style-sheet');
11
+
12
+ /**
13
+ * A fresh copy of the module per test. It keeps the sheets it has constructed in
14
+ * a module-scope registry, so without this a later test would see the previous
15
+ * one's sheet and the idempotence cases would prove nothing.
16
+ */
17
+ function freshModule(): AdoptStyleSheetModule {
18
+ let loaded: AdoptStyleSheetModule | undefined;
19
+ jest.isolateModules(() => {
20
+ loaded = require('../styles/adopt-style-sheet') as AdoptStyleSheetModule;
21
+ });
22
+ if (!loaded) throw new Error('adopt-style-sheet failed to load');
23
+ return loaded;
24
+ }
25
+
26
+ describe('adoptStyleSheet — constructed stylesheets (the CSP-safe path)', () => {
27
+ let harness: ConstructedStyleSheetsHarness;
28
+
29
+ beforeEach(() => {
30
+ document.head.innerHTML = '';
31
+ harness = installConstructedStyleSheets();
32
+ });
33
+
34
+ afterEach(() => {
35
+ harness.uninstall();
36
+ });
37
+
38
+ /* THE point of the whole change. A `<style>` element carrying text content is
39
+ * what `style-src 'self'` blocks, silently — so "no style element" is the
40
+ * assertion that fails if the adoption branch is ever removed, and it is the
41
+ * only one a CSP can tell apart. */
42
+ it('applies the CSS through an adopted sheet, creating no <style> element', () => {
43
+ const { adoptStyleSheet } = freshModule();
44
+
45
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
46
+
47
+ expect(document.querySelectorAll('style')).toHaveLength(0);
48
+ expect(harness.adopted()).toHaveLength(1);
49
+ expect(harness.adopted()[0]?.cssText).toBe('.x { color: red; }');
50
+ });
51
+
52
+ it('repeat calls with the same CSS re-parse nothing and adopt nothing twice', () => {
53
+ const { adoptStyleSheet } = freshModule();
54
+
55
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
56
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
57
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
58
+
59
+ expect(harness.adopted()).toHaveLength(1);
60
+ expect(harness.adopted()[0]?.replaceSyncCalls).toBe(1);
61
+ });
62
+
63
+ it('new CSS under the same id replaces the sheet content in place', () => {
64
+ const { adoptStyleSheet } = freshModule();
65
+
66
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
67
+ const first = harness.adopted()[0];
68
+ adoptStyleSheet('bloom-test', '.x { color: blue; }');
69
+
70
+ expect(harness.adopted()).toHaveLength(1);
71
+ expect(harness.adopted()[0]).toBe(first);
72
+ expect(harness.adopted()[0]?.cssText).toBe('.x { color: blue; }');
73
+ });
74
+
75
+ it('keeps one sheet per id', () => {
76
+ const { adoptStyleSheet } = freshModule();
77
+
78
+ adoptStyleSheet('bloom-a', '.a {}');
79
+ adoptStyleSheet('bloom-b', '.b {}');
80
+
81
+ expect(harness.adopted()).toHaveLength(2);
82
+ });
83
+
84
+ /* A consumer that owns `document.adoptedStyleSheets` can reassign it — a
85
+ * framework swapping its own sheets, a test resetting the document. Bloom's
86
+ * CSS has to come back, and without re-parsing. */
87
+ it('re-adopts the same sheet after the document list is reset', () => {
88
+ const { adoptStyleSheet } = freshModule();
89
+
90
+ adoptStyleSheet('bloom-test', '.x {}');
91
+ const first = harness.adopted()[0];
92
+ document.adoptedStyleSheets = [];
93
+ adoptStyleSheet('bloom-test', '.x {}');
94
+
95
+ expect(harness.adopted()).toHaveLength(1);
96
+ expect(harness.adopted()[0]).toBe(first);
97
+ expect(harness.adopted()[0]?.replaceSyncCalls).toBe(1);
98
+ });
99
+
100
+ it('dropStyleSheet un-adopts only its own sheet', () => {
101
+ const { adoptStyleSheet, dropStyleSheet } = freshModule();
102
+
103
+ adoptStyleSheet('bloom-a', '.a {}');
104
+ adoptStyleSheet('bloom-b', '.b {}');
105
+ dropStyleSheet('bloom-a');
106
+
107
+ expect(harness.adopted()).toHaveLength(1);
108
+ expect(harness.adopted()[0]?.cssText).toBe('.b {}');
109
+ });
110
+
111
+ it('a dropped id can be adopted again', () => {
112
+ const { adoptStyleSheet, dropStyleSheet } = freshModule();
113
+
114
+ adoptStyleSheet('bloom-test', '.x {}');
115
+ dropStyleSheet('bloom-test');
116
+ adoptStyleSheet('bloom-test', '.y {}');
117
+
118
+ expect(harness.adopted()).toHaveLength(1);
119
+ expect(harness.adopted()[0]?.cssText).toBe('.y {}');
120
+ });
121
+ });
122
+
123
+ describe('adoptStyleSheet — <style> fallback', () => {
124
+ beforeEach(() => {
125
+ document.head.innerHTML = '';
126
+ });
127
+
128
+ /* Plain jsdom: no `adoptedStyleSheets`, no `replaceSync`. Same for a browser
129
+ * older than Chrome 73 / Safari 16.4 / Firefox 101. The CSS still has to
130
+ * apply — under a permissive CSP, which is what those pages have. */
131
+ it('the environment under test genuinely lacks the constructed API', () => {
132
+ expect('adoptedStyleSheets' in document).toBe(false);
133
+ });
134
+
135
+ it('creates one <style> element carrying the CSS', () => {
136
+ const { adoptStyleSheet } = freshModule();
137
+
138
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
139
+
140
+ const element = document.getElementById('bloom-test');
141
+ expect(element?.tagName).toBe('STYLE');
142
+ expect(element?.textContent).toBe('.x { color: red; }');
143
+ });
144
+
145
+ it('does not duplicate the element across calls, and updates its content', () => {
146
+ const { adoptStyleSheet } = freshModule();
147
+
148
+ adoptStyleSheet('bloom-test', '.x { color: red; }');
149
+ adoptStyleSheet('bloom-test', '.x { color: blue; }');
150
+
151
+ expect(document.querySelectorAll('style#bloom-test')).toHaveLength(1);
152
+ expect(document.getElementById('bloom-test')?.textContent).toBe(
153
+ '.x { color: blue; }',
154
+ );
155
+ });
156
+
157
+ it('dropStyleSheet removes the element', () => {
158
+ const { adoptStyleSheet, dropStyleSheet } = freshModule();
159
+
160
+ adoptStyleSheet('bloom-test', '.x {}');
161
+ dropStyleSheet('bloom-test');
162
+
163
+ expect(document.getElementById('bloom-test')).toBeNull();
164
+ });
165
+ });
@@ -9,7 +9,12 @@
9
9
  // resolution; here we reach for it explicitly so the test exercises the
10
10
  // actual implementation under jsdom.
11
11
  import { applyFontFaces } from '../fonts/apply-font-faces.web';
12
+ import { installConstructedStyleSheets } from './support/constructed-style-sheets';
12
13
 
14
+ /* jsdom has no constructed-stylesheet API, so everything in this block
15
+ * exercises `adoptStyleSheet`'s `<style>` fallback. The CSP-safe path a real
16
+ * browser takes is asserted in the last block, and in
17
+ * `adopt-style-sheet.test.ts`. */
13
18
  describe('applyFontFaces (web)', () => {
14
19
  beforeEach(() => {
15
20
  document.head.innerHTML = '';
@@ -71,6 +76,33 @@ describe('applyFontFaces (web)', () => {
71
76
  });
72
77
  });
73
78
 
79
+ /* The font faces are the one injection `BloomThemeProvider` performs by
80
+ * default, so they are the first thing a page under `style-src 'self'` loses —
81
+ * and it loses them silently, as text rendered in the fallback family with no
82
+ * console output. Everything else Bloom injects is per-component; this is
83
+ * every consumer, every page. */
84
+ describe('applyFontFaces under a page that forbids inline styles', () => {
85
+ it('adopts a constructed sheet and creates no <style> element', () => {
86
+ document.head.innerHTML = '';
87
+ const harness = installConstructedStyleSheets();
88
+ try {
89
+ let fonts: typeof import('../fonts/apply-font-faces.web') | undefined;
90
+ jest.isolateModules(() => {
91
+ fonts = require('../fonts/apply-font-faces.web') as typeof fonts;
92
+ });
93
+ fonts?.applyFontFaces();
94
+
95
+ expect(document.querySelectorAll('style')).toHaveLength(0);
96
+ expect(harness.adopted()).toHaveLength(1);
97
+ const css = harness.adopted()[0]?.cssText ?? '';
98
+ expect(css.match(/@font-face\s*{/g) ?? []).toHaveLength(4);
99
+ expect(css).toMatch(/--bloom-font-sans:/);
100
+ } finally {
101
+ harness.uninstall();
102
+ }
103
+ });
104
+ });
105
+
74
106
  describe('applyFontFaces (native stub)', () => {
75
107
  beforeEach(() => {
76
108
  document.head.innerHTML = '';
@@ -0,0 +1,112 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import {
5
+ bloomDesignTokens,
6
+ renderBloomTokensJson,
7
+ resolvedColorToHex,
8
+ type PresetGroup,
9
+ } from '../design-tokens/tokens-json';
10
+ import { APP_COLOR_NAMES, APP_COLOR_PRESETS } from '../theme/color-presets';
11
+ import { CANONICAL_TOKENS, getResolvedTokens } from '../theme/token-registry';
12
+
13
+ const TOKENS_PATH = join(__dirname, '..', 'design-tokens', 'tokens.json');
14
+
15
+ describe('the shipped tokens.json is generated, not maintained', () => {
16
+ it('is byte-identical to a fresh render', () => {
17
+ // The whole guarantee: a token changed in the engine and not regenerated
18
+ // fails here rather than shipping a JSON that describes a palette Bloom no
19
+ // longer paints.
20
+ expect(readFileSync(TOKENS_PATH, 'utf8')).toBe(renderBloomTokensJson());
21
+ });
22
+
23
+ it('says so in its own $description', () => {
24
+ expect(bloomDesignTokens().$description).toContain('AUTO-GENERATED');
25
+ });
26
+ });
27
+
28
+ describe('tokens.json shape contract', () => {
29
+ /* Astro codegens C++ SkColor tables from these paths, so the shape is a
30
+ * published interface: new groups may be added, existing paths may not move. */
31
+ const tokens = bloomDesignTokens();
32
+
33
+ it('covers every preset Bloom ships, and nothing else', () => {
34
+ const presets = Object.keys(tokens.color).filter((key) => !key.startsWith('$'));
35
+ expect(presets.sort()).toEqual([...APP_COLOR_NAMES].sort());
36
+ });
37
+
38
+ it('carries both schemes and every canonical token, for every preset', () => {
39
+ for (const name of APP_COLOR_NAMES) {
40
+ const preset = tokens.color[name] as PresetGroup;
41
+ for (const scheme of ['light', 'dark'] as const) {
42
+ expect(Object.keys(preset[scheme]).sort()).toEqual([...CANONICAL_TOKENS].sort());
43
+ }
44
+ }
45
+ });
46
+
47
+ it('names tokens as the CSS custom property without the leading --', () => {
48
+ // What makes the mapping mechanical for a consumer. A token renamed on one
49
+ // side only would break it silently.
50
+ const preset = tokens.color.oxy as PresetGroup;
51
+ for (const token of Object.keys(preset.light)) {
52
+ expect(token.startsWith('--')).toBe(false);
53
+ expect(getResolvedTokens('oxy', 'light')[`--${token}`]).toBeDefined();
54
+ }
55
+ });
56
+
57
+ it('resolves every colour to an sRGB hex string', () => {
58
+ for (const name of APP_COLOR_NAMES) {
59
+ const preset = tokens.color[name] as PresetGroup;
60
+ for (const scheme of ['light', 'dark'] as const) {
61
+ for (const [token, entry] of Object.entries(preset[scheme])) {
62
+ expect(`${name}.${scheme}.${token} = ${String(entry.$value)}`).toMatch(
63
+ /= #[0-9a-f]{6}([0-9a-f]{2})?$/,
64
+ );
65
+ }
66
+ }
67
+ }
68
+ });
69
+
70
+ it('records each preset’s seed and variant', () => {
71
+ for (const name of APP_COLOR_NAMES) {
72
+ const preset = tokens.color[name] as PresetGroup;
73
+ expect(preset.$extensions['so.oxy.bloom']).toMatchObject({
74
+ seed: APP_COLOR_PRESETS[name].hex,
75
+ variant: APP_COLOR_PRESETS[name].variant,
76
+ });
77
+ }
78
+ });
79
+
80
+ it('emits the numeric scales as px dimensions', () => {
81
+ expect(tokens.spacing['space-8']).toEqual({ $value: '8px' });
82
+ expect(tokens.radius['radius-max']).toEqual({ $value: '9999px' });
83
+ expect(tokens.borderWidth['hairline']).toEqual({ $value: '0.5px' });
84
+ expect(tokens.typography['body']).toMatchObject({
85
+ fontSize: { $value: '15px' },
86
+ fontWeight: { $value: 400 },
87
+ });
88
+ });
89
+ });
90
+
91
+ describe('resolvedColorToHex', () => {
92
+ it('converts the engine’s two output forms', () => {
93
+ expect(resolvedColorToHex('rgb(255 239 253)')).toBe('#ffeffd');
94
+ expect(resolvedColorToHex('rgb(0 0 0)')).toBe('#000000');
95
+ // 0.13 × 255 = 33.15 → 33 → 0x21. An 8-bit alpha is exactly what an SkColor
96
+ // holds, so nothing is lost downstream.
97
+ expect(resolvedColorToHex('rgba(213, 86, 255, 0.13)')).toBe('#d556ff21');
98
+ });
99
+
100
+ it('drops a fully opaque alpha rather than emitting #rrggbbff', () => {
101
+ expect(resolvedColorToHex('rgba(1, 2, 3, 1)')).toBe('#010203');
102
+ });
103
+
104
+ /* The engine emitting a third form — an oklch(), a bare hex, a named colour —
105
+ * would otherwise reach a C++ colour table as an unparseable string. Failing
106
+ * at generation time is the only place anyone would notice. */
107
+ it('throws on anything that is not an rgb()/rgba() colour', () => {
108
+ expect(() => resolvedColorToHex('oklch(0.7 0.1 300)')).toThrow(/not an rgb/);
109
+ expect(() => resolvedColorToHex('#ffeffd')).toThrow(/not an rgb/);
110
+ expect(() => resolvedColorToHex('0.5rem')).toThrow(/not an rgb/);
111
+ });
112
+ });
@@ -37,7 +37,11 @@ const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8')) as {
37
37
  const exportsMap = pkg.exports;
38
38
 
39
39
  /** Non-subpath entries that are legitimately plain strings (static assets). */
40
- const STRING_ENTRIES = ['./design-tokens/theme.css', './package.json'];
40
+ const STRING_ENTRIES = [
41
+ './design-tokens/theme.css',
42
+ './design-tokens/tokens.json',
43
+ './package.json',
44
+ ];
41
45
 
42
46
  /**
43
47
  * Vacuity floor. `SUBPATHS` in the generator is 86 entries today; a traversal
@@ -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
+ }
@@ -11,6 +11,7 @@ import {
11
11
  type StyleProp,
12
12
  } from 'react-native';
13
13
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
14
+ import { adoptStyleSheet, dropStyleSheet } from '../styles/adopt-style-sheet';
14
15
  import type { WebCssStyle } from '../styles/web-view-style';
15
16
  import { Z_INDEX } from '../styles/z-index';
16
17
  import Animated, {
@@ -921,15 +922,7 @@ const styles = StyleSheet.create({
921
922
 
922
923
  // Create web scrollbar styles dynamically based on theme
923
924
  const createWebScrollbarStyle = (borderColor: string) => {
924
- if (Platform.OS !== 'web' || typeof document === 'undefined') return;
925
-
926
- let styleElement = document.getElementById(SCROLLBAR_STYLE_ID) as HTMLStyleElement | null;
927
-
928
- if (!styleElement) {
929
- styleElement = document.createElement('style');
930
- styleElement.id = SCROLLBAR_STYLE_ID;
931
- document.head.appendChild(styleElement);
932
- }
925
+ if (Platform.OS !== 'web') return;
933
926
 
934
927
  // Derive a slightly darker scrollbar hover color from the border color
935
928
  const scrollbarColor = borderColor;
@@ -937,7 +930,9 @@ const createWebScrollbarStyle = (borderColor: string) => {
937
930
  ? borderColor.replace(/\)$/, ' / 0.7)') // add alpha for hover
938
931
  : '#888';
939
932
 
940
- styleElement.textContent = `
933
+ adoptStyleSheet(
934
+ SCROLLBAR_STYLE_ID,
935
+ `
941
936
  .bottom-sheet-scrollview::-webkit-scrollbar {
942
937
  width: 6px;
943
938
  }
@@ -952,13 +947,14 @@ const createWebScrollbarStyle = (borderColor: string) => {
952
947
  .bottom-sheet-scrollview::-webkit-scrollbar-thumb:hover {
953
948
  background: ${scrollbarHoverColor};
954
949
  }
955
- `;
950
+ `,
951
+ );
956
952
  };
957
953
 
958
954
  const SCROLLBAR_STYLE_ID = 'bottom-sheet-scrollbar-style';
959
955
 
960
- /** Remove the injected scrollbar <style> tag on unmount. */
956
+ /** Detach the scrollbar rules on unmount. */
961
957
  const removeWebScrollbarStyle = () => {
962
- if (Platform.OS !== 'web' || typeof document === 'undefined') return;
963
- document.getElementById(SCROLLBAR_STYLE_ID)?.remove();
958
+ if (Platform.OS !== 'web') return;
959
+ dropStyleSheet(SCROLLBAR_STYLE_ID);
964
960
  };
@@ -12,6 +12,7 @@ import React, {
12
12
  import { useTheme } from '../theme/use-theme';
13
13
  import type { Theme } from '../theme/types';
14
14
  import { SpinnerIcon } from '../loading/SpinnerIcon.web';
15
+ import { adoptStyleSheet } from '../styles/adopt-style-sheet';
15
16
  import { flattenWebStyle } from '../styles/flatten-web-style';
16
17
  import type { ButtonProps, ButtonSize, ButtonVariant } from './types';
17
18
 
@@ -128,12 +129,7 @@ const BLOOM_BUTTON_CSS = `
128
129
 
129
130
  function useButtonCss(): void {
130
131
  useEffect(() => {
131
- if (typeof document === 'undefined') return;
132
- if (document.getElementById(STYLE_ID)) return;
133
- const style = document.createElement('style');
134
- style.id = STYLE_ID;
135
- style.textContent = BLOOM_BUTTON_CSS;
136
- document.head.appendChild(style);
132
+ adoptStyleSheet(STYLE_ID, BLOOM_BUTTON_CSS);
137
133
  }, []);
138
134
  }
139
135
 
@@ -1,6 +1,7 @@
1
1
  import React, { memo, useEffect, useState } from 'react';
2
2
  import { View, type ViewStyle } from 'react-native';
3
3
 
4
+ import { adoptStyleSheet } from '../styles/adopt-style-sheet';
4
5
  import type { WebCssStyle } from '../styles/web-view-style';
5
6
  import { useTheme } from '../theme/use-theme';
6
7
  import { ConnectionDotsBase } from './ConnectionDotsBase';
@@ -31,12 +32,7 @@ export const BLOOM_CONNECTION_DOTS_CSS = `
31
32
 
32
33
  function useKeyframes(): void {
33
34
  useEffect(() => {
34
- if (typeof document === 'undefined') return;
35
- if (document.getElementById(KEYFRAMES_ID)) return;
36
- const style = document.createElement('style');
37
- style.id = KEYFRAMES_ID;
38
- style.textContent = BLOOM_CONNECTION_DOTS_CSS;
39
- document.head.appendChild(style);
35
+ adoptStyleSheet(KEYFRAMES_ID, BLOOM_CONNECTION_DOTS_CSS);
40
36
  }, []);
41
37
  }
42
38
 
@@ -27,6 +27,22 @@ export type {
27
27
 
28
28
  export { bloomThemeCss, bloomThemeBlock } from './theme-css';
29
29
 
30
+ /* The same tokens as a data structure rather than a stylesheet, for a consumer
31
+ * that is not a browser (`@oxyhq/bloom/design-tokens/tokens.json` is the
32
+ * generated artifact; these rebuild it in memory). Both come from the colour
33
+ * engine, so the JSON and the CSS cannot disagree. */
34
+ export {
35
+ bloomDesignTokens,
36
+ renderBloomTokensJson,
37
+ resolvedColorToHex,
38
+ } from './tokens-json';
39
+ export type {
40
+ BloomDesignTokens,
41
+ DesignToken,
42
+ PresetExtensions,
43
+ PresetGroup,
44
+ } from './tokens-json';
45
+
30
46
  /* The resolved counterpart of `bloomThemeCss()`. That emits the alias layer
31
47
  * (`--color-x: var(--x)`); these resolve `--x` itself, so a build script can
32
48
  * write a static `:root` / `.dark` block and a prerendered page paints the real