opendesk-react-native 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -2
- package/lib/api.d.ts +43 -0
- package/lib/api.js +114 -0
- package/lib/client.d.ts +120 -0
- package/lib/client.js +505 -0
- package/lib/errors.d.ts +8 -0
- package/lib/errors.js +17 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +29 -0
- package/lib/react.d.ts +33 -0
- package/lib/react.js +72 -0
- package/lib/realtime.d.ts +51 -0
- package/lib/realtime.js +124 -0
- package/lib/storage.d.ts +48 -0
- package/lib/storage.js +63 -0
- package/lib/types.d.ts +203 -0
- package/lib/types.js +6 -0
- package/lib/ui/BotFace.d.ts +13 -0
- package/lib/ui/BotFace.js +103 -0
- package/lib/ui/OpenDeskChat.d.ts +27 -0
- package/lib/ui/OpenDeskChat.js +636 -0
- package/lib/ui/OpenDeskLauncher.d.ts +12 -0
- package/lib/ui/OpenDeskLauncher.js +46 -0
- package/lib/ui/icons.d.ts +23 -0
- package/lib/ui/icons.js +95 -0
- package/lib/ui/strings.d.ts +53 -0
- package/lib/ui/strings.js +58 -0
- package/lib/ui/theme.d.ts +51 -0
- package/lib/ui/theme.js +89 -0
- package/package.json +43 -4
- package/src/api.ts +172 -0
- package/src/client.ts +659 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +25 -0
- package/src/react.tsx +109 -0
- package/src/realtime.ts +166 -0
- package/src/storage.ts +93 -0
- package/src/types.ts +200 -0
- package/src/ui/BotFace.tsx +163 -0
- package/src/ui/OpenDeskChat.tsx +1318 -0
- package/src/ui/OpenDeskLauncher.tsx +69 -0
- package/src/ui/icons.tsx +162 -0
- package/src/ui/strings.ts +57 -0
- package/src/ui/theme.ts +127 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Pressable, StyleSheet, Text, useColorScheme, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useOpenDeskState, useUnreadCount } from '../react';
|
|
4
|
+
import { ChatIcon, CloseIcon } from './icons';
|
|
5
|
+
import { makeTheme } from './theme';
|
|
6
|
+
|
|
7
|
+
export interface OpenDeskLauncherProps {
|
|
8
|
+
onPress: () => void;
|
|
9
|
+
/** Shows the close icon instead of the chat bubble. */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
accentColor?: string;
|
|
12
|
+
accessibilityLabel?: string;
|
|
13
|
+
/** Position it yourself, e.g. `{ position: 'absolute', right: 20, bottom: 20 }`. */
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The web widget's launcher button: brand colour, rounded square, unread badge. */
|
|
18
|
+
export function OpenDeskLauncher({ onPress, open = false, accentColor, accessibilityLabel, style }: OpenDeskLauncherProps) {
|
|
19
|
+
const config = useOpenDeskState((state) => state.config);
|
|
20
|
+
const unread = useUnreadCount();
|
|
21
|
+
const scheme = useColorScheme() === 'dark' ? 'dark' : 'light';
|
|
22
|
+
const theme = useMemo(() => makeTheme(config, config?.theme === 'light' || config?.theme === 'dark' ? config.theme : scheme, accentColor), [accentColor, config, scheme]);
|
|
23
|
+
const radius = { square: 10, soft: 16, round: 27 }[config?.radius ?? 'soft'] ?? 16;
|
|
24
|
+
const label = accessibilityLabel ?? (open ? 'Close chat' : unread > 0 ? `Open chat, ${unread} new` : 'Open chat');
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Pressable
|
|
28
|
+
accessibilityRole="button"
|
|
29
|
+
accessibilityLabel={label}
|
|
30
|
+
onPress={onPress}
|
|
31
|
+
style={({ pressed }) => [styles.launcher, { backgroundColor: theme.accent, borderRadius: radius }, pressed && styles.pressed, style]}
|
|
32
|
+
>
|
|
33
|
+
{open ? <CloseIcon color={theme.accentFg} size={22} /> : <ChatIcon color={theme.accentFg} size={24} />}
|
|
34
|
+
{!open && unread > 0 ? (
|
|
35
|
+
<View style={[styles.badge, { backgroundColor: theme.danger, borderColor: theme.bg }]}>
|
|
36
|
+
<Text style={styles.badgeText}>{unread > 99 ? '99+' : unread}</Text>
|
|
37
|
+
</View>
|
|
38
|
+
) : null}
|
|
39
|
+
</Pressable>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const styles = StyleSheet.create({
|
|
44
|
+
launcher: {
|
|
45
|
+
width: 54,
|
|
46
|
+
height: 54,
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
shadowColor: '#000000',
|
|
50
|
+
shadowOpacity: 0.3,
|
|
51
|
+
shadowRadius: 10,
|
|
52
|
+
shadowOffset: { width: 0, height: 8 },
|
|
53
|
+
elevation: 6,
|
|
54
|
+
},
|
|
55
|
+
pressed: { transform: [{ scale: 0.96 }] },
|
|
56
|
+
badge: {
|
|
57
|
+
position: 'absolute',
|
|
58
|
+
top: -5,
|
|
59
|
+
right: -5,
|
|
60
|
+
minWidth: 20,
|
|
61
|
+
height: 20,
|
|
62
|
+
paddingHorizontal: 6,
|
|
63
|
+
borderRadius: 10,
|
|
64
|
+
borderWidth: 2,
|
|
65
|
+
alignItems: 'center',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
},
|
|
68
|
+
badgeText: { color: '#ffffff', fontSize: 11, fontWeight: '600' },
|
|
69
|
+
});
|
package/src/ui/icons.tsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { View, type ViewStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The widget's line icons drawn with plain Views, so the SDK needs no react-native-svg.
|
|
5
|
+
* Each fits a square of `size` with 1.6px strokes, like the web widget's 1.6 stroke SVGs.
|
|
6
|
+
*/
|
|
7
|
+
interface IconProps {
|
|
8
|
+
color: string;
|
|
9
|
+
size?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const stroke = 1.6;
|
|
13
|
+
|
|
14
|
+
function Box({ size, children }: { size: number; children: React.ReactNode }) {
|
|
15
|
+
return <View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>{children}</View>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function Line({ length, color, style }: { length: number; color: string; style?: ViewStyle }) {
|
|
19
|
+
return (
|
|
20
|
+
<View
|
|
21
|
+
style={[{ position: 'absolute', width: length, height: stroke, borderRadius: stroke, backgroundColor: color }, style]}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function CloseIcon({ color, size = 16 }: IconProps) {
|
|
27
|
+
const length = size * 0.78;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Box size={size}>
|
|
31
|
+
<Line length={length} color={color} style={{ transform: [{ rotate: '45deg' }] }} />
|
|
32
|
+
<Line length={length} color={color} style={{ transform: [{ rotate: '-45deg' }] }} />
|
|
33
|
+
</Box>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A chevron; `direction` is where it points. */
|
|
38
|
+
export function ChevronIcon({ color, size = 16, direction = 'left' }: IconProps & { direction?: 'left' | 'right' }) {
|
|
39
|
+
const arm = size * 0.42;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box size={size}>
|
|
43
|
+
<View
|
|
44
|
+
style={{
|
|
45
|
+
width: arm,
|
|
46
|
+
height: arm,
|
|
47
|
+
borderLeftWidth: stroke,
|
|
48
|
+
borderBottomWidth: stroke,
|
|
49
|
+
borderColor: color,
|
|
50
|
+
borderBottomLeftRadius: 1,
|
|
51
|
+
transform: [{ translateX: direction === 'left' ? arm * 0.2 : -arm * 0.2 }, { rotate: direction === 'left' ? '45deg' : '-135deg' }],
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An arrow; `angle` is the clockwise rotation from pointing right. */
|
|
59
|
+
export function ArrowIcon({ color, size = 16, angle = 0 }: IconProps & { angle?: number }) {
|
|
60
|
+
const shaft = size * 0.7;
|
|
61
|
+
const head = size * 0.36;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box size={size}>
|
|
65
|
+
<View style={{ width: shaft, height: shaft, alignItems: 'center', justifyContent: 'center', transform: [{ rotate: `${angle}deg` }] }}>
|
|
66
|
+
<Line length={shaft} color={color} />
|
|
67
|
+
<View
|
|
68
|
+
style={{
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
right: stroke / 2,
|
|
71
|
+
width: head,
|
|
72
|
+
height: head,
|
|
73
|
+
borderTopWidth: stroke,
|
|
74
|
+
borderRightWidth: stroke,
|
|
75
|
+
borderColor: color,
|
|
76
|
+
borderTopRightRadius: 1,
|
|
77
|
+
transform: [{ rotate: '45deg' }],
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</View>
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function PaperclipIcon({ color, size = 16 }: IconProps) {
|
|
86
|
+
const width = size * 0.42;
|
|
87
|
+
const height = size * 0.86;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Box size={size}>
|
|
91
|
+
<View style={{ transform: [{ rotate: '40deg' }] }}>
|
|
92
|
+
<View
|
|
93
|
+
style={{ width, height, borderWidth: stroke, borderColor: color, borderRadius: width / 2, borderBottomColor: color }}
|
|
94
|
+
/>
|
|
95
|
+
<View
|
|
96
|
+
style={{
|
|
97
|
+
position: 'absolute',
|
|
98
|
+
left: width / 2 - stroke / 2,
|
|
99
|
+
top: height * 0.22,
|
|
100
|
+
width: stroke,
|
|
101
|
+
height: height * 0.5,
|
|
102
|
+
borderRadius: stroke,
|
|
103
|
+
backgroundColor: color,
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
</View>
|
|
107
|
+
</Box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A speech bubble, used on the launcher. */
|
|
112
|
+
export function ChatIcon({ color, size = 22 }: IconProps) {
|
|
113
|
+
const width = size * 0.86;
|
|
114
|
+
const height = size * 0.66;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Box size={size}>
|
|
118
|
+
<View style={{ width, height, borderWidth: 2, borderColor: color, borderRadius: height * 0.38 }} />
|
|
119
|
+
<View
|
|
120
|
+
style={{
|
|
121
|
+
position: 'absolute',
|
|
122
|
+
left: size * 0.2,
|
|
123
|
+
bottom: size * 0.06,
|
|
124
|
+
width: size * 0.22,
|
|
125
|
+
height: size * 0.22,
|
|
126
|
+
borderLeftWidth: 2,
|
|
127
|
+
borderBottomWidth: 2,
|
|
128
|
+
borderColor: color,
|
|
129
|
+
borderBottomLeftRadius: 2,
|
|
130
|
+
transform: [{ skewY: '-20deg' }],
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function PersonIcon({ color, size = 16 }: IconProps) {
|
|
138
|
+
return (
|
|
139
|
+
<Box size={size}>
|
|
140
|
+
<View style={{ width: size * 0.38, height: size * 0.38, borderRadius: size, borderWidth: stroke, borderColor: color, marginBottom: 1 }} />
|
|
141
|
+
<View
|
|
142
|
+
style={{
|
|
143
|
+
width: size * 0.66,
|
|
144
|
+
height: size * 0.3,
|
|
145
|
+
borderTopLeftRadius: size,
|
|
146
|
+
borderTopRightRadius: size,
|
|
147
|
+
borderWidth: stroke,
|
|
148
|
+
borderBottomWidth: 0,
|
|
149
|
+
borderColor: color,
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
</Box>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function SparkIcon({ color, size = 10 }: IconProps) {
|
|
157
|
+
return (
|
|
158
|
+
<Box size={size}>
|
|
159
|
+
<View style={{ width: size * 0.5, height: size * 0.5, backgroundColor: color, borderRadius: 1, transform: [{ rotate: '45deg' }] }} />
|
|
160
|
+
</Box>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** The web widget's English copy, so both channels read the same. Override any key via `strings`. */
|
|
2
|
+
export const defaultStrings = {
|
|
3
|
+
support: 'Support',
|
|
4
|
+
closeChat: 'Close chat',
|
|
5
|
+
greeting: 'Hi there. How can we help?',
|
|
6
|
+
replyTime: 'We usually reply within a few hours.',
|
|
7
|
+
replyTimeAi: '{name} answers right away, the team within a few hours.',
|
|
8
|
+
unavailable: 'Chat is unavailable',
|
|
9
|
+
unavailableHint: 'Check your connection and try again later.',
|
|
10
|
+
sendMessage: 'Send us a message',
|
|
11
|
+
yourConversations: 'Your conversations',
|
|
12
|
+
conversation: 'Conversation',
|
|
13
|
+
typing: '{name} is typing…',
|
|
14
|
+
botTyping: '{name} is writing…',
|
|
15
|
+
searchHelp: 'Search the help center',
|
|
16
|
+
poweredBy: 'Powered by opendesk',
|
|
17
|
+
back: 'Back to conversations',
|
|
18
|
+
emptyHint: "Ask us anything. We'll reply here, and by email if you leave one.",
|
|
19
|
+
notSent: 'Not sent.',
|
|
20
|
+
sending: 'Sending…',
|
|
21
|
+
uploading: 'Uploading…',
|
|
22
|
+
removeFile: 'Remove {name}',
|
|
23
|
+
fileTypeRefused: "{name} can't be sent here. Try another file type.",
|
|
24
|
+
fileTooLarge: '{name} is larger than 25 MB.',
|
|
25
|
+
uploadFailed: "Couldn't upload {name}. Try again.",
|
|
26
|
+
placeholder: 'Write a message…',
|
|
27
|
+
attach: 'Attach files',
|
|
28
|
+
send: 'Send',
|
|
29
|
+
ai: 'AI',
|
|
30
|
+
talkToPerson: 'Talk to a person',
|
|
31
|
+
handoffFailed: "Couldn't reach the team. Try again.",
|
|
32
|
+
handedOff: 'A person will reply here',
|
|
33
|
+
handedOffHint: "You'll see their reply in this conversation, and by email if you left one.",
|
|
34
|
+
checking: 'Checking {tool}…',
|
|
35
|
+
checked: 'Checked {tool}',
|
|
36
|
+
toolFailed: "Couldn't check {tool}",
|
|
37
|
+
actionPending: 'Needs your confirmation',
|
|
38
|
+
confirm: 'Confirm',
|
|
39
|
+
cancel: 'Cancel',
|
|
40
|
+
actionConfirmed: 'Confirmed',
|
|
41
|
+
actionDeclined: 'Cancelled',
|
|
42
|
+
actionExpired: 'Expired',
|
|
43
|
+
actionFailed: "Didn't go through",
|
|
44
|
+
topicsTitle: 'What can we help with?',
|
|
45
|
+
topicsHint: 'Pick a topic so your message reaches the right people.',
|
|
46
|
+
placeholderTopic: 'Pick a topic to start',
|
|
47
|
+
changeTopic: 'Change topic',
|
|
48
|
+
offlineDefault: "We're away right now. Leave a message and we'll reply as soon as we're back.",
|
|
49
|
+
today: 'Today',
|
|
50
|
+
yesterday: 'Yesterday',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ChatStrings = typeof defaultStrings;
|
|
54
|
+
|
|
55
|
+
export function format(template: string, values: Record<string, string | number> = {}): string {
|
|
56
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in values ? String(values[key]) : match));
|
|
57
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import type { WidgetConfig } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Colour tokens from openDesk's web widget (`.root` and `.root[data-theme="dark"]`). */
|
|
5
|
+
export interface Palette {
|
|
6
|
+
bg: string;
|
|
7
|
+
bg2: string;
|
|
8
|
+
bg3: string;
|
|
9
|
+
line: string;
|
|
10
|
+
line2: string;
|
|
11
|
+
fg: string;
|
|
12
|
+
fg2: string;
|
|
13
|
+
fg3: string;
|
|
14
|
+
fg4: string;
|
|
15
|
+
ai: string;
|
|
16
|
+
aiSoft: string;
|
|
17
|
+
aiLine: string;
|
|
18
|
+
danger: string;
|
|
19
|
+
dangerSoft: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const light: Palette = {
|
|
23
|
+
bg: '#ffffff',
|
|
24
|
+
bg2: '#f4f4f5',
|
|
25
|
+
bg3: '#fafafa',
|
|
26
|
+
line: '#e7e7e9',
|
|
27
|
+
line2: '#d6d6d9',
|
|
28
|
+
fg: '#0a0a0a',
|
|
29
|
+
fg2: '#525252',
|
|
30
|
+
fg3: '#737373',
|
|
31
|
+
fg4: '#a3a3a3',
|
|
32
|
+
ai: '#6d4bd8',
|
|
33
|
+
aiSoft: '#f4f0ff',
|
|
34
|
+
aiLine: '#e4dcff',
|
|
35
|
+
danger: '#dc2626',
|
|
36
|
+
dangerSoft: '#fef2f2',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const dark: Palette = {
|
|
40
|
+
bg: '#161616',
|
|
41
|
+
bg2: '#242426',
|
|
42
|
+
bg3: '#1c1c1d',
|
|
43
|
+
line: '#2c2c2f',
|
|
44
|
+
line2: '#3a3a3e',
|
|
45
|
+
fg: '#fafafa',
|
|
46
|
+
fg2: '#c4c4c6',
|
|
47
|
+
fg3: '#8d8d92',
|
|
48
|
+
fg4: '#5e5e63',
|
|
49
|
+
ai: '#b9a3ff',
|
|
50
|
+
aiSoft: '#231d33',
|
|
51
|
+
aiLine: '#372d52',
|
|
52
|
+
danger: '#f87171',
|
|
53
|
+
dangerSoft: '#2a1515',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The widget's `radius` setting, in the same steps as the web widget. */
|
|
57
|
+
const radii = {
|
|
58
|
+
square: { bubble: 6, ctl: 4, card: 4, bar: 4, av: 4 },
|
|
59
|
+
soft: { bubble: 14, ctl: 8, card: 10, bar: 10, av: 6 },
|
|
60
|
+
round: { bubble: 20, ctl: 999, card: 16, bar: 22, av: 999 },
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
export interface ChatTheme extends Palette {
|
|
64
|
+
accent: string;
|
|
65
|
+
accentFg: string;
|
|
66
|
+
headBg: string;
|
|
67
|
+
radius: (typeof radii)[keyof typeof radii];
|
|
68
|
+
mono: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function makeTheme(config: WidgetConfig | null, scheme: 'light' | 'dark', accentOverride?: string): ChatTheme {
|
|
72
|
+
const palette = scheme === 'dark' ? dark : light;
|
|
73
|
+
const brand = normalizeHex(accentOverride ?? config?.brand_color ?? config?.brand?.color);
|
|
74
|
+
const accent = brand ?? palette.fg;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...palette,
|
|
78
|
+
accent,
|
|
79
|
+
accentFg: brand ? readableOn(brand) : palette.bg,
|
|
80
|
+
headBg: mix(accent, palette.bg3, 0.035),
|
|
81
|
+
radius: radii[config?.radius ?? 'soft'] ?? radii.soft,
|
|
82
|
+
mono: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'ui-monospace, SFMono-Regular, Menlo, monospace' }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeHex(value: string | undefined | null): string | null {
|
|
87
|
+
if (!value) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const hex = value.trim().replace(/^#/, '');
|
|
91
|
+
|
|
92
|
+
return /^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex) ? `#${hex}` : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rgb(hex: string): [number, number, number] {
|
|
96
|
+
let h = hex.replace('#', '');
|
|
97
|
+
if (h.length === 3) {
|
|
98
|
+
h = h
|
|
99
|
+
.split('')
|
|
100
|
+
.map((c) => c + c)
|
|
101
|
+
.join('');
|
|
102
|
+
}
|
|
103
|
+
const n = parseInt(h, 16);
|
|
104
|
+
|
|
105
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `color-mix(in srgb, a weight, b)`. */
|
|
109
|
+
function mix(a: string, b: string, weight: number): string {
|
|
110
|
+
const [ar, ag, ab] = rgb(a);
|
|
111
|
+
const [br, bg, bb] = rgb(b);
|
|
112
|
+
const channel = (x: number, y: number) => Math.round(x * weight + y * (1 - weight)).toString(16).padStart(2, '0');
|
|
113
|
+
|
|
114
|
+
return `#${channel(ar, br)}${channel(ag, bg)}${channel(ab, bb)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** White or near-black text, whichever reads better on the brand colour. */
|
|
118
|
+
function readableOn(hex: string): string {
|
|
119
|
+
const [r, g, b] = rgb(hex).map((c) => {
|
|
120
|
+
const v = c / 255;
|
|
121
|
+
|
|
122
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
123
|
+
}) as [number, number, number];
|
|
124
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
125
|
+
|
|
126
|
+
return luminance > 0.45 ? '#0a0a0a' : '#ffffff';
|
|
127
|
+
}
|