@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.
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js +7 -12
- package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/commonjs/button/Button.web.js +2 -6
- package/lib/commonjs/button/Button.web.js.map +1 -1
- package/lib/commonjs/connection-dots/ConnectionDots.web.js +2 -6
- package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -1
- package/lib/commonjs/design-tokens/index.js +19 -0
- package/lib/commonjs/design-tokens/index.js.map +1 -1
- package/lib/commonjs/design-tokens/tokens-json.js +149 -0
- package/lib/commonjs/design-tokens/tokens-json.js.map +1 -0
- package/lib/commonjs/design-tokens/tokens.json +6681 -0
- package/lib/commonjs/dialog/Dialog.web.js +2 -6
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/fab/Fab.web.js +2 -6
- package/lib/commonjs/fab/Fab.web.js.map +1 -1
- package/lib/commonjs/fonts/apply-font-faces.web.js +8 -10
- package/lib/commonjs/fonts/apply-font-faces.web.js.map +1 -1
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js +2 -6
- package/lib/commonjs/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
- package/lib/commonjs/loading/SpinnerIcon.web.js +2 -6
- package/lib/commonjs/loading/SpinnerIcon.web.js.map +1 -1
- package/lib/commonjs/styles/adopt-style-sheet.js +126 -0
- package/lib/commonjs/styles/adopt-style-sheet.js.map +1 -0
- package/lib/module/bottom-sheet/BottomSheetBase.js +7 -12
- package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
- package/lib/module/button/Button.web.js +2 -6
- package/lib/module/button/Button.web.js.map +1 -1
- package/lib/module/connection-dots/ConnectionDots.web.js +2 -6
- package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -1
- package/lib/module/design-tokens/index.js +5 -0
- package/lib/module/design-tokens/index.js.map +1 -1
- package/lib/module/design-tokens/tokens-json.js +144 -0
- package/lib/module/design-tokens/tokens-json.js.map +1 -0
- package/lib/module/design-tokens/tokens.json +6681 -0
- package/lib/module/dialog/Dialog.web.js +2 -6
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/fab/Fab.web.js +2 -6
- package/lib/module/fab/Fab.web.js.map +1 -1
- package/lib/module/fonts/apply-font-faces.web.js +8 -10
- package/lib/module/fonts/apply-font-faces.web.js.map +1 -1
- package/lib/module/frosted-icon-button/FrostedIconButton.web.js +2 -6
- package/lib/module/frosted-icon-button/FrostedIconButton.web.js.map +1 -1
- package/lib/module/loading/SpinnerIcon.web.js +2 -6
- package/lib/module/loading/SpinnerIcon.web.js.map +1 -1
- package/lib/module/styles/adopt-style-sheet.js +121 -0
- package/lib/module/styles/adopt-style-sheet.js.map +1 -0
- package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/button/Button.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/index.d.ts +2 -0
- package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts +85 -0
- package/lib/typescript/commonjs/design-tokens/tokens-json.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts +5 -3
- package/lib/typescript/commonjs/fonts/apply-font-faces.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/loading/SpinnerIcon.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts +22 -0
- package/lib/typescript/commonjs/styles/adopt-style-sheet.d.ts.map +1 -0
- package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
- package/lib/typescript/module/button/Button.web.d.ts.map +1 -1
- package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/index.d.ts +2 -0
- package/lib/typescript/module/design-tokens/index.d.ts.map +1 -1
- package/lib/typescript/module/design-tokens/tokens-json.d.ts +85 -0
- package/lib/typescript/module/design-tokens/tokens-json.d.ts.map +1 -0
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/module/fonts/apply-font-faces.web.d.ts +5 -3
- package/lib/typescript/module/fonts/apply-font-faces.web.d.ts.map +1 -1
- package/lib/typescript/module/frosted-icon-button/FrostedIconButton.web.d.ts.map +1 -1
- package/lib/typescript/module/loading/SpinnerIcon.web.d.ts.map +1 -1
- package/lib/typescript/module/styles/adopt-style-sheet.d.ts +22 -0
- package/lib/typescript/module/styles/adopt-style-sheet.d.ts.map +1 -0
- package/package.json +4 -2
- package/src/__tests__/adopt-style-sheet.node.test.ts +21 -0
- package/src/__tests__/adopt-style-sheet.test.ts +165 -0
- package/src/__tests__/apply-font-faces.test.ts +32 -0
- package/src/__tests__/design-tokens-json.test.ts +112 -0
- package/src/__tests__/exports-map-contract.test.ts +5 -1
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/bottom-sheet/BottomSheetBase.tsx +10 -14
- package/src/button/Button.web.tsx +2 -6
- package/src/connection-dots/ConnectionDots.web.tsx +2 -6
- package/src/design-tokens/index.ts +16 -0
- package/src/design-tokens/tokens-json.ts +214 -0
- package/src/design-tokens/tokens.json +6681 -0
- package/src/dialog/Dialog.web.tsx +2 -6
- package/src/fab/Fab.web.tsx +2 -6
- package/src/fonts/apply-font-faces.web.ts +11 -11
- package/src/frosted-icon-button/FrostedIconButton.web.tsx +2 -6
- package/src/loading/SpinnerIcon.web.tsx +2 -6
- 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 = [
|
|
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'
|
|
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
|
-
|
|
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
|
-
/**
|
|
956
|
+
/** Detach the scrollbar rules on unmount. */
|
|
961
957
|
const removeWebScrollbarStyle = () => {
|
|
962
|
-
if (Platform.OS !== 'web'
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|