@oxyhq/bloom 0.87.1 → 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 (95) hide show
  1. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +7 -12
  2. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  3. package/lib/commonjs/button/Button.web.js +2 -6
  4. package/lib/commonjs/button/Button.web.js.map +1 -1
  5. package/lib/commonjs/connection-dots/ConnectionDots.web.js +2 -6
  6. package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -1
  7. package/lib/commonjs/design-tokens/index.js +19 -0
  8. package/lib/commonjs/design-tokens/index.js.map +1 -1
  9. package/lib/commonjs/design-tokens/tokens-json.js +149 -0
  10. package/lib/commonjs/design-tokens/tokens-json.js.map +1 -0
  11. package/lib/commonjs/design-tokens/tokens.json +6681 -0
  12. package/lib/commonjs/dialog/Dialog.web.js +2 -6
  13. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  14. package/lib/commonjs/fab/Fab.web.js +2 -6
  15. package/lib/commonjs/fab/Fab.web.js.map +1 -1
  16. package/lib/commonjs/fonts/apply-font-faces.web.js +8 -10
  17. package/lib/commonjs/fonts/apply-font-faces.web.js.map +1 -1
  18. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +2 -6
  19. package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
  20. package/lib/commonjs/loading/SpinnerIcon.web.js +2 -6
  21. package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
  22. package/lib/commonjs/styles/adopt-style-sheet.js +126 -0
  23. package/lib/commonjs/styles/adopt-style-sheet.js.map +1 -0
  24. package/lib/module/bottom-sheet/BottomSheetBase.js +7 -12
  25. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  26. package/lib/module/button/Button.web.js +2 -6
  27. package/lib/module/button/Button.web.js.map +1 -1
  28. package/lib/module/connection-dots/ConnectionDots.web.js +2 -6
  29. package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
  30. package/lib/module/design-tokens/index.js +5 -0
  31. package/lib/module/design-tokens/index.js.map +1 -1
  32. package/lib/module/design-tokens/tokens-json.js +144 -0
  33. package/lib/module/design-tokens/tokens-json.js.map +1 -0
  34. package/lib/module/design-tokens/tokens.json +6681 -0
  35. package/lib/module/dialog/Dialog.web.js +2 -6
  36. package/lib/module/dialog/Dialog.web.js.map +1 -1
  37. package/lib/module/fab/Fab.web.js +2 -6
  38. package/lib/module/fab/Fab.web.js.map +1 -1
  39. package/lib/module/fonts/apply-font-faces.web.js +8 -10
  40. package/lib/module/fonts/apply-font-faces.web.js.map +1 -1
  41. package/lib/module/frosted-icon-button/FrostedIconButton.web.js +2 -6
  42. package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
  43. package/lib/module/loading/SpinnerIcon.web.js +2 -6
  44. package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
  45. package/lib/module/styles/adopt-style-sheet.js +121 -0
  46. package/lib/module/styles/adopt-style-sheet.js.map +1 -0
  47. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/design-tokens/index.d.ts +2 -0
  51. package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +85 -0
  53. package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts +5 -3
  57. package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts +22 -0
  61. package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts.map +1 -0
  62. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  63. package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
  64. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
  65. package/lib/typescript/module/design-tokens/index.d.ts +2 -0
  66. package/lib/typescript/module/design-tokens/index.d.ts.map +1 -1
  67. package/lib/typescript/module/design-tokens/tokens-json.d.ts +85 -0
  68. package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -0
  69. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  70. package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
  71. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts +5 -3
  72. package/lib/typescript/module/fonts/apply-font-faces.web.d.ts.map +1 -1
  73. package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
  74. package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
  75. package/lib/typescript/module/styles/adopt-style-sheet.d.ts +22 -0
  76. package/lib/typescript/module/styles/adopt-style-sheet.d.ts.map +1 -0
  77. package/package.json +4 -2
  78. package/src/__tests__/adopt-style-sheet.node.test.ts +21 -0
  79. package/src/__tests__/adopt-style-sheet.test.ts +165 -0
  80. package/src/__tests__/apply-font-faces.test.ts +32 -0
  81. package/src/__tests__/design-tokens-json.test.ts +112 -0
  82. package/src/__tests__/exports-map-contract.test.ts +5 -1
  83. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  84. package/src/bottom-sheet/BottomSheetBase.tsx +10 -14
  85. package/src/button/Button.web.tsx +2 -6
  86. package/src/connection-dots/ConnectionDots.web.tsx +2 -6
  87. package/src/design-tokens/index.ts +16 -0
  88. package/src/design-tokens/tokens-json.ts +214 -0
  89. package/src/design-tokens/tokens.json +6681 -0
  90. package/src/dialog/Dialog.web.tsx +2 -6
  91. package/src/fab/Fab.web.tsx +2 -6
  92. package/src/fonts/apply-font-faces.web.ts +11 -11
  93. package/src/frosted-icon-button/FrostedIconButton.web.tsx +2 -6
  94. package/src/loading/SpinnerIcon.web.tsx +2 -6
  95. package/src/styles/adopt-style-sheet.ts +131 -0
@@ -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
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The machine-readable form of every Bloom design token, in W3C Design Tokens
3
+ * (DTCG) format.
4
+ *
5
+ * The CSS artifacts next to this file (`theme.css`, `bloomThemeCss()`) are the
6
+ * ALIAS layer and only make sense to a browser. A consumer that is not a
7
+ * browser — Astro codegens `SkColor` tables for Chromium's native UI so the
8
+ * toolbar and the WebUI pages cannot disagree about what "primary" is — needs
9
+ * the RESOLVED values, for every preset and both schemes, in a format it can
10
+ * read without running Bloom's colour engine. That is this file.
11
+ *
12
+ * Same single source as everything else: `getResolvedTokens`, i.e. the colour
13
+ * engine. `tokens.json` is generated from it by
14
+ * `scripts/generate-design-tokens-json.ts`, and
15
+ * `src/__tests__/design-tokens-json.test.ts` fails if the checked-in file is
16
+ * not byte-identical to a fresh render — so the JSON, the CSS and the runtime
17
+ * palette cannot drift from each other.
18
+ */
19
+
20
+ import {
21
+ APP_COLOR_NAMES,
22
+ APP_COLOR_PRESETS,
23
+ type AppColorName,
24
+ } from '../theme/color-presets';
25
+ import { getResolvedTokens } from '../theme/token-registry';
26
+ import {
27
+ BORDER_WIDTH,
28
+ RADIUS,
29
+ SPACING,
30
+ TYPOGRAPHY,
31
+ type TypeRoleName,
32
+ } from './scales';
33
+
34
+ /** One DTCG token. `$type` is inherited from the nearest group that declares it. */
35
+ export interface DesignToken {
36
+ $value: string | number;
37
+ $type?: string;
38
+ $description?: string;
39
+ }
40
+
41
+ /** Bloom-specific metadata on a preset group, namespaced per the DTCG `$extensions` rule. */
42
+ export interface PresetExtensions {
43
+ 'so.oxy.bloom': {
44
+ /** The brand seed the engine derived the whole palette from. */
45
+ seed: string;
46
+ /** The tonal scheme variant used with that seed. */
47
+ variant: string;
48
+ /** Who may pick the preset; absent means everyone. */
49
+ gate?: string;
50
+ /** Present when the preset forces a white label in both schemes. */
51
+ label?: string;
52
+ };
53
+ }
54
+
55
+ export interface PresetGroup {
56
+ $description: string;
57
+ $extensions: PresetExtensions;
58
+ light: Record<string, DesignToken>;
59
+ dark: Record<string, DesignToken>;
60
+ }
61
+
62
+ export interface BloomDesignTokens {
63
+ $description: string;
64
+ color: { $type: 'color'; $description: string } & Record<string, unknown>;
65
+ spacing: { $type: 'dimension'; $description: string } & Record<string, unknown>;
66
+ radius: { $type: 'dimension'; $description: string } & Record<string, unknown>;
67
+ borderWidth: { $type: 'dimension'; $description: string } & Record<string, unknown>;
68
+ typography: { $description: string } & Record<string, unknown>;
69
+ }
70
+
71
+ const FILE_DESCRIPTION = [
72
+ 'Bloom design tokens, resolved. W3C Design Tokens Community Group format.',
73
+ 'SHAPE CONTRACT (consumers codegen against this — additions are additive, existing paths do not move):',
74
+ 'color.<preset>.<scheme>.<token> — <preset> is a Bloom AppColorName, <scheme> is "light" or "dark",',
75
+ 'and <token> is the CSS custom property Bloom writes on the document root with its leading "--" removed,',
76
+ 'so color.oxy.dark.card carries the value of --card under the oxy preset in dark.',
77
+ 'Every colour $value is an sRGB hex string: "#rrggbb", or "#rrggbbaa" when the token carries alpha',
78
+ '(the -subtle family does). spacing/radius/borderWidth are DTCG dimensions with a px unit in the string.',
79
+ 'typography.<role> is a group of fontSize (dimension), lineHeight (dimension) and fontWeight (number);',
80
+ 'font FAMILIES are deliberately absent — they resolve to CSS variables a non-browser consumer cannot use.',
81
+ 'Shadows are absent for the same reason: Bloom ships them as a platform-forked style object (bloomShadowStyle),',
82
+ 'not as a value.',
83
+ 'AUTO-GENERATED by scripts/generate-design-tokens-json.ts from Bloom’s colour engine — do not edit.',
84
+ ].join(' ');
85
+
86
+ /**
87
+ * Turn one resolved token value into an sRGB hex string.
88
+ *
89
+ * The engine emits exactly two forms — `rgb(r g b)` for opaque tokens and
90
+ * `rgba(r, g, b, a)` for the translucent `-subtle` family. Anything else is a
91
+ * change in the engine's output that this file has not been taught about, and
92
+ * throwing at generation time is the only way that surfaces: a silently
93
+ * passed-through CSS string would end up in a C++ colour table as garbage.
94
+ */
95
+ export function resolvedColorToHex(value: string): string {
96
+ const channels = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(
97
+ value,
98
+ );
99
+ if (!channels) {
100
+ throw new Error(
101
+ `Bloom token value is not an rgb()/rgba() colour: ${JSON.stringify(value)}`,
102
+ );
103
+ }
104
+
105
+ const [, red = '0', green = '0', blue = '0', alpha] = channels;
106
+ const byte = (channel: string): string =>
107
+ Math.round(Number(channel)).toString(16).padStart(2, '0');
108
+ const hex = `#${byte(red)}${byte(green)}${byte(blue)}`;
109
+
110
+ if (alpha === undefined) return hex;
111
+ const fraction = alpha.endsWith('%')
112
+ ? Number(alpha.slice(0, -1)) / 100
113
+ : Number(alpha);
114
+ if (fraction >= 1) return hex;
115
+ return `${hex}${byte(String(fraction * 255))}`;
116
+ }
117
+
118
+ function schemeTokens(
119
+ preset: AppColorName,
120
+ scheme: 'light' | 'dark',
121
+ ): Record<string, DesignToken> {
122
+ const tokens: Record<string, DesignToken> = {};
123
+ for (const [property, value] of Object.entries(getResolvedTokens(preset, scheme))) {
124
+ tokens[property.replace(/^--/, '')] = { $value: resolvedColorToHex(value) };
125
+ }
126
+ return tokens;
127
+ }
128
+
129
+ function colorGroup(): BloomDesignTokens['color'] {
130
+ const group: Record<string, unknown> = {
131
+ $type: 'color',
132
+ $description:
133
+ 'One group per Bloom colour preset, each with a light and a dark scheme. Token names match the CSS custom properties without the leading "--".',
134
+ };
135
+
136
+ for (const name of APP_COLOR_NAMES) {
137
+ const preset = APP_COLOR_PRESETS[name];
138
+ const extensions: PresetExtensions = {
139
+ 'so.oxy.bloom': { seed: preset.hex, variant: preset.variant },
140
+ };
141
+ if (preset.gate) extensions['so.oxy.bloom'].gate = preset.gate;
142
+ if (preset.label) extensions['so.oxy.bloom'].label = preset.label;
143
+
144
+ group[name] = {
145
+ $description: `Derived by Bloom’s colour engine from seed ${preset.hex} (${preset.variant}).`,
146
+ $extensions: extensions,
147
+ light: schemeTokens(name, 'light'),
148
+ dark: schemeTokens(name, 'dark'),
149
+ } satisfies PresetGroup;
150
+ }
151
+
152
+ return group as BloomDesignTokens['color'];
153
+ }
154
+
155
+ function dimensionGroup(
156
+ description: string,
157
+ scale: Record<string, number>,
158
+ ): BloomDesignTokens['spacing'] {
159
+ const group: Record<string, unknown> = {
160
+ $type: 'dimension',
161
+ $description: description,
162
+ };
163
+ for (const [key, value] of Object.entries(scale)) {
164
+ group[key] = { $value: `${value}px` } satisfies DesignToken;
165
+ }
166
+ return group as BloomDesignTokens['spacing'];
167
+ }
168
+
169
+ function typographyGroup(): BloomDesignTokens['typography'] {
170
+ const group: Record<string, unknown> = {
171
+ $description:
172
+ 'One group per type role. Sizes and line-heights are px dimensions; weights are DTCG numeric font weights.',
173
+ };
174
+ for (const name of Object.keys(TYPOGRAPHY) as TypeRoleName[]) {
175
+ const role = TYPOGRAPHY[name];
176
+ group[name] = {
177
+ fontSize: { $type: 'dimension', $value: `${role.size}px` },
178
+ lineHeight: { $type: 'dimension', $value: `${role.lineHeight}px` },
179
+ fontWeight: { $type: 'fontWeight', $value: Number(role.weight) },
180
+ };
181
+ }
182
+ return group as BloomDesignTokens['typography'];
183
+ }
184
+
185
+ /** The whole token file, as a plain object. */
186
+ export function bloomDesignTokens(): BloomDesignTokens {
187
+ return {
188
+ $description: FILE_DESCRIPTION,
189
+ color: colorGroup(),
190
+ spacing: dimensionGroup(
191
+ 'The Oxy spacing scale. Same keys as the Tailwind/NativeWind utilities (p-space-8, gap-space-20, px-screen-margin).',
192
+ SPACING,
193
+ ),
194
+ radius: dimensionGroup(
195
+ 'The corner-radius scale. Same keys as the rounded-radius-* utilities; radius-max is the pill/circle sentinel.',
196
+ RADIUS,
197
+ ),
198
+ borderWidth: dimensionGroup(
199
+ 'Border widths. hairline is the 0.5px rule that pairs with the border-image colour role.',
200
+ BORDER_WIDTH,
201
+ ),
202
+ typography: typographyGroup(),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * The exact bytes of `design-tokens/tokens.json`.
208
+ *
209
+ * Both the generator script and the drift test call this, so neither can be
210
+ * right about the file while the other is wrong.
211
+ */
212
+ export function renderBloomTokensJson(): string {
213
+ return `${JSON.stringify(bloomDesignTokens(), null, 2)}\n`;
214
+ }