@dbx-tools/ui 0.0.0-stage → 0.9.61
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 +202 -0
- package/README.md +64 -2
- package/index.ts +39 -0
- package/package.json +75 -4
- package/src/auth/react/auth-client.ts +21 -0
- package/src/auth/react/auth-gate.tsx +281 -0
- package/src/auth/react/index.ts +4 -0
- package/src/auth/react/passkey-manager.tsx +154 -0
- package/src/branding/brand-bridge.css +53 -0
- package/src/branding/browser.ts +70 -0
- package/src/branding/generated/assets.ts +18 -0
- package/src/branding/generated/icon-dark.svg +11 -0
- package/src/branding/generated/icon-light.svg +11 -0
- package/src/branding/generated/logo-dark.svg +14 -0
- package/src/branding/generated/logo-light.svg +14 -0
- package/src/branding/react/brand.tsx +92 -0
- package/src/branding/react/index.ts +9 -0
- package/src/branding/styles.css +12 -0
- package/src/email/react/email-approval-card.tsx +169 -0
- package/src/email/react/email-body.tsx +23 -0
- package/src/email/react/email-compose.tsx +346 -0
- package/src/email/react/fields.ts +33 -0
- package/src/email/react/index.ts +17 -0
- package/src/email/styles.css +20 -0
- package/src/react/appkit-ui.ts +9 -0
- package/src/react/brand-picker.tsx +285 -0
- package/src/react/index.ts +2 -0
- package/src/search/react/index.ts +16 -0
- package/src/search/react/search-box.tsx +153 -0
- package/src/search/react/search-results.tsx +76 -0
- package/src/search/react/use-search.ts +188 -0
- package/src/search/styles.css +145 -0
- package/src/styles.css +26 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { net } from "@dbx-tools/shared-core";
|
|
2
|
+
import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
beginPasskeyEnrollment,
|
|
6
|
+
beginPasskeySignIn,
|
|
7
|
+
getAuthStatus,
|
|
8
|
+
requestEmailOtp,
|
|
9
|
+
type PasskeyOperation,
|
|
10
|
+
verifyEmailOtp,
|
|
11
|
+
} from "./auth-client.ts";
|
|
12
|
+
import { BrandIcon, useBrand } from "../../branding/react/index.ts";
|
|
13
|
+
import { Button, Input } from "../../react/index.ts";
|
|
14
|
+
|
|
15
|
+
type Phase = "loading" | "email" | "code" | "enroll" | "authed" | "open";
|
|
16
|
+
|
|
17
|
+
/** Protected content and optional sign-in copy rendered by {@link AuthGate}. */
|
|
18
|
+
export interface AuthGateProps {
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Email OTP gate with conditional passkey sign-in and first-login enrollment. */
|
|
25
|
+
export function AuthGate({ children, title, description }: AuthGateProps): ReactNode {
|
|
26
|
+
const { context: brand } = useBrand();
|
|
27
|
+
const [phase, setPhase] = useState<Phase>("loading");
|
|
28
|
+
const [email, setEmail] = useState("");
|
|
29
|
+
const [code, setCode] = useState("");
|
|
30
|
+
const [busy, setBusy] = useState(false);
|
|
31
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
32
|
+
const [passkeysEnabled, setPasskeysEnabled] = useState(false);
|
|
33
|
+
const passkeyOperation = useRef<PasskeyOperation | undefined>(undefined);
|
|
34
|
+
|
|
35
|
+
const cancelPasskey = useCallback(() => {
|
|
36
|
+
const operation = passkeyOperation.current;
|
|
37
|
+
passkeyOperation.current = undefined;
|
|
38
|
+
operation?.cancel();
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useEffect(() => cancelPasskey, [cancelPasskey]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let cancelled = false;
|
|
45
|
+
void getAuthStatus()
|
|
46
|
+
.then((status) => {
|
|
47
|
+
if (cancelled) return;
|
|
48
|
+
setPasskeysEnabled(status.passkeysEnabled === true);
|
|
49
|
+
setPhase(status.enabled ? (status.authenticated ? "authed" : "email") : "open");
|
|
50
|
+
})
|
|
51
|
+
.catch(() => {
|
|
52
|
+
if (!cancelled) setPhase("email");
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!passkeysEnabled || phase !== "email") {
|
|
61
|
+
cancelPasskey();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
typeof PublicKeyCredential === "undefined" ||
|
|
66
|
+
typeof PublicKeyCredential.isConditionalMediationAvailable !== "function"
|
|
67
|
+
) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let cancelled = false;
|
|
71
|
+
let ownedOperation: PasskeyOperation | undefined;
|
|
72
|
+
// Conditional mediation cannot reveal whether a credential exists. It lets
|
|
73
|
+
// the browser offer one immediately on the focused `webauthn` input without
|
|
74
|
+
// showing an empty modal to users who have no passkey.
|
|
75
|
+
void PublicKeyCredential.isConditionalMediationAvailable()
|
|
76
|
+
.then(async (available) => {
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
if (!available) return;
|
|
79
|
+
cancelPasskey();
|
|
80
|
+
const operation = beginPasskeySignIn(true);
|
|
81
|
+
ownedOperation = operation;
|
|
82
|
+
passkeyOperation.current = operation;
|
|
83
|
+
const authenticated = await operation.result;
|
|
84
|
+
if (passkeyOperation.current === operation) {
|
|
85
|
+
passkeyOperation.current = undefined;
|
|
86
|
+
if (!cancelled && authenticated) setPhase("authed");
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
.catch(() => undefined);
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
ownedOperation?.cancel();
|
|
93
|
+
if (passkeyOperation.current === ownedOperation) passkeyOperation.current = undefined;
|
|
94
|
+
};
|
|
95
|
+
}, [cancelPasskey, passkeysEnabled, phase]);
|
|
96
|
+
|
|
97
|
+
const requestCode = useCallback(
|
|
98
|
+
async (event: FormEvent) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
if (busy) return;
|
|
101
|
+
const addresses = net.parseEmails(email);
|
|
102
|
+
const address = addresses[0];
|
|
103
|
+
if (addresses.length !== 1 || !address || !net.isEmail(address)) {
|
|
104
|
+
setNotice("Enter a valid email address.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
cancelPasskey();
|
|
108
|
+
setBusy(true);
|
|
109
|
+
setNotice(null);
|
|
110
|
+
setEmail(address);
|
|
111
|
+
try {
|
|
112
|
+
if (!(await requestEmailOtp(address))) throw new Error("OTP request failed");
|
|
113
|
+
setNotice("If the address is authorized, a verification code is on its way.");
|
|
114
|
+
setPhase("code");
|
|
115
|
+
} catch {
|
|
116
|
+
setNotice("Unable to request a verification code. Try again.");
|
|
117
|
+
} finally {
|
|
118
|
+
setBusy(false);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[busy, cancelPasskey, email],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const verifyCode = useCallback(
|
|
125
|
+
async (event: FormEvent) => {
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
if (!code.trim() || busy) return;
|
|
128
|
+
setBusy(true);
|
|
129
|
+
setNotice(null);
|
|
130
|
+
try {
|
|
131
|
+
if (!(await verifyEmailOtp(email, code.trim(), email.split("@")[0] || "User"))) {
|
|
132
|
+
throw new Error("OTP verification failed");
|
|
133
|
+
}
|
|
134
|
+
setPhase(passkeysEnabled ? "enroll" : "authed");
|
|
135
|
+
} catch {
|
|
136
|
+
setNotice("That verification code is incorrect or has expired.");
|
|
137
|
+
} finally {
|
|
138
|
+
setBusy(false);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[busy, code, email, passkeysEnabled],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const usePasskey = useCallback(async () => {
|
|
145
|
+
cancelPasskey();
|
|
146
|
+
const operation = beginPasskeySignIn();
|
|
147
|
+
passkeyOperation.current = operation;
|
|
148
|
+
setBusy(true);
|
|
149
|
+
setNotice(null);
|
|
150
|
+
try {
|
|
151
|
+
if (!(await operation.result)) throw new Error("Passkey authentication failed");
|
|
152
|
+
if (passkeyOperation.current !== operation) return;
|
|
153
|
+
passkeyOperation.current = undefined;
|
|
154
|
+
setPhase("authed");
|
|
155
|
+
} catch {
|
|
156
|
+
if (passkeyOperation.current === operation) {
|
|
157
|
+
setNotice("Unable to sign in with a passkey. Use email recovery instead.");
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
if (passkeyOperation.current === operation) {
|
|
161
|
+
passkeyOperation.current = undefined;
|
|
162
|
+
setBusy(false);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}, [cancelPasskey]);
|
|
166
|
+
|
|
167
|
+
const enroll = useCallback(async () => {
|
|
168
|
+
cancelPasskey();
|
|
169
|
+
const operation = beginPasskeyEnrollment("Primary passkey");
|
|
170
|
+
passkeyOperation.current = operation;
|
|
171
|
+
setBusy(true);
|
|
172
|
+
setNotice(null);
|
|
173
|
+
try {
|
|
174
|
+
if (!(await operation.result)) throw new Error("Passkey enrollment failed");
|
|
175
|
+
if (passkeyOperation.current !== operation) return;
|
|
176
|
+
passkeyOperation.current = undefined;
|
|
177
|
+
setPhase("authed");
|
|
178
|
+
} catch {
|
|
179
|
+
if (passkeyOperation.current === operation) {
|
|
180
|
+
setNotice("Unable to create a passkey. You can continue with email recovery.");
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
if (passkeyOperation.current === operation) {
|
|
184
|
+
passkeyOperation.current = undefined;
|
|
185
|
+
setBusy(false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}, [cancelPasskey]);
|
|
189
|
+
|
|
190
|
+
if (phase === "authed" || phase === "open") return <>{children}</>;
|
|
191
|
+
if (phase === "loading") return null;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
|
195
|
+
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-sm">
|
|
196
|
+
<div className="mb-4 flex items-center gap-2 text-foreground">
|
|
197
|
+
<BrandIcon className="size-5" alt="" aria-hidden />
|
|
198
|
+
<h1 className="text-lg font-semibold">{title ?? `Sign in to ${brand.name}`}</h1>
|
|
199
|
+
</div>
|
|
200
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
201
|
+
{description ??
|
|
202
|
+
(phase === "code"
|
|
203
|
+
? "Enter the 6-digit verification code sent to your email address."
|
|
204
|
+
: phase === "enroll"
|
|
205
|
+
? "Create a passkey for faster, phishing-resistant sign-in next time."
|
|
206
|
+
: "Enter your email for a one-time code, or use a passkey.")}
|
|
207
|
+
</p>
|
|
208
|
+
|
|
209
|
+
{phase === "email" ? (
|
|
210
|
+
<form noValidate onSubmit={requestCode} className="space-y-3">
|
|
211
|
+
<Input
|
|
212
|
+
type="email"
|
|
213
|
+
autoComplete="email webauthn"
|
|
214
|
+
autoFocus
|
|
215
|
+
aria-label="Email address"
|
|
216
|
+
placeholder="you@example.com"
|
|
217
|
+
value={email}
|
|
218
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
219
|
+
required
|
|
220
|
+
/>
|
|
221
|
+
<Button type="submit" disabled={busy} className="w-full">
|
|
222
|
+
Sign in with email OTP
|
|
223
|
+
</Button>
|
|
224
|
+
{passkeysEnabled ? (
|
|
225
|
+
<Button
|
|
226
|
+
type="button"
|
|
227
|
+
variant="outline"
|
|
228
|
+
disabled={busy}
|
|
229
|
+
className="w-full"
|
|
230
|
+
onClick={usePasskey}
|
|
231
|
+
>
|
|
232
|
+
Sign in with a passkey
|
|
233
|
+
</Button>
|
|
234
|
+
) : null}
|
|
235
|
+
</form>
|
|
236
|
+
) : phase === "code" ? (
|
|
237
|
+
<form onSubmit={verifyCode} className="space-y-3">
|
|
238
|
+
<Input
|
|
239
|
+
type="text"
|
|
240
|
+
inputMode="numeric"
|
|
241
|
+
autoComplete="one-time-code"
|
|
242
|
+
aria-label="Verification code"
|
|
243
|
+
placeholder="6-digit verification code"
|
|
244
|
+
maxLength={6}
|
|
245
|
+
value={code}
|
|
246
|
+
onChange={(event) => setCode(event.target.value)}
|
|
247
|
+
required
|
|
248
|
+
/>
|
|
249
|
+
<Button type="submit" disabled={busy} className="w-full">
|
|
250
|
+
Continue
|
|
251
|
+
</Button>
|
|
252
|
+
</form>
|
|
253
|
+
) : (
|
|
254
|
+
<div className="space-y-3">
|
|
255
|
+
<Button type="button" disabled={busy} className="w-full" onClick={enroll}>
|
|
256
|
+
Create a passkey
|
|
257
|
+
</Button>
|
|
258
|
+
<Button
|
|
259
|
+
type="button"
|
|
260
|
+
variant="ghost"
|
|
261
|
+
className="w-full"
|
|
262
|
+
onClick={() => {
|
|
263
|
+
cancelPasskey();
|
|
264
|
+
setBusy(false);
|
|
265
|
+
setPhase("authed");
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
Not now
|
|
269
|
+
</Button>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{notice ? (
|
|
274
|
+
<p role="status" aria-live="polite" className="mt-3 text-xs text-muted-foreground">
|
|
275
|
+
{notice}
|
|
276
|
+
</p>
|
|
277
|
+
) : null}
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { AUTH_BASE_PATH, getAuthStatus, logout } from "./auth-client.ts";
|
|
2
|
+
export type { Passkey } from "./auth-client.ts";
|
|
3
|
+
export { AuthGate, type AuthGateProps } from "./auth-gate.tsx";
|
|
4
|
+
export { PasskeyManager, type PasskeyManagerProps } from "./passkey-manager.tsx";
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
beginPasskeyEnrollment,
|
|
5
|
+
listPasskeys,
|
|
6
|
+
type PasskeyOperation,
|
|
7
|
+
type Passkey,
|
|
8
|
+
removePasskey,
|
|
9
|
+
renamePasskey,
|
|
10
|
+
} from "./auth-client.ts";
|
|
11
|
+
import { Button, Input } from "../../react/index.ts";
|
|
12
|
+
|
|
13
|
+
/** Styling options for the authenticated passkey management surface. */
|
|
14
|
+
export interface PasskeyManagerProps {
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Authenticated management surface for a user's Better Auth passkeys. */
|
|
19
|
+
export function PasskeyManager({ className }: PasskeyManagerProps): ReactNode {
|
|
20
|
+
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
|
|
21
|
+
const [names, setNames] = useState<Record<string, string>>({});
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const [loaded, setLoaded] = useState(false);
|
|
24
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
25
|
+
const enrollment = useRef<PasskeyOperation | undefined>(undefined);
|
|
26
|
+
|
|
27
|
+
const cancelEnrollment = useCallback(() => {
|
|
28
|
+
const operation = enrollment.current;
|
|
29
|
+
enrollment.current = undefined;
|
|
30
|
+
operation?.cancel();
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
useEffect(() => cancelEnrollment, [cancelEnrollment]);
|
|
34
|
+
|
|
35
|
+
const refresh = useCallback(async () => {
|
|
36
|
+
const values = await listPasskeys();
|
|
37
|
+
setPasskeys(values);
|
|
38
|
+
setNames(Object.fromEntries(values.map((passkey) => [passkey.id, passkey.name ?? "Passkey"])));
|
|
39
|
+
setLoaded(true);
|
|
40
|
+
setNotice(null);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
void refresh().catch(() => setNotice("Unable to load passkeys."));
|
|
45
|
+
}, [refresh]);
|
|
46
|
+
|
|
47
|
+
const add = useCallback(async () => {
|
|
48
|
+
cancelEnrollment();
|
|
49
|
+
const operation = beginPasskeyEnrollment();
|
|
50
|
+
enrollment.current = operation;
|
|
51
|
+
setBusy(true);
|
|
52
|
+
setNotice(null);
|
|
53
|
+
try {
|
|
54
|
+
if (!(await operation.result)) throw new Error("Passkey enrollment failed");
|
|
55
|
+
if (enrollment.current !== operation) return;
|
|
56
|
+
await refresh();
|
|
57
|
+
} catch {
|
|
58
|
+
if (enrollment.current === operation) setNotice("Unable to add a passkey.");
|
|
59
|
+
} finally {
|
|
60
|
+
if (enrollment.current === operation) {
|
|
61
|
+
enrollment.current = undefined;
|
|
62
|
+
setBusy(false);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}, [cancelEnrollment, refresh]);
|
|
66
|
+
|
|
67
|
+
const rename = useCallback(
|
|
68
|
+
async (id: string) => {
|
|
69
|
+
const name = names[id]?.trim();
|
|
70
|
+
if (!name) return;
|
|
71
|
+
setBusy(true);
|
|
72
|
+
try {
|
|
73
|
+
if (!(await renamePasskey(id, name))) throw new Error("Passkey rename failed");
|
|
74
|
+
await refresh();
|
|
75
|
+
} catch {
|
|
76
|
+
setNotice("Unable to rename the passkey.");
|
|
77
|
+
} finally {
|
|
78
|
+
setBusy(false);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[names, refresh],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const remove = useCallback(
|
|
85
|
+
async (id: string) => {
|
|
86
|
+
setBusy(true);
|
|
87
|
+
try {
|
|
88
|
+
if (!(await removePasskey(id))) throw new Error("Passkey removal failed");
|
|
89
|
+
await refresh();
|
|
90
|
+
} catch {
|
|
91
|
+
setNotice("Unable to remove the passkey.");
|
|
92
|
+
} finally {
|
|
93
|
+
setBusy(false);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[refresh],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<section className={className} aria-label="Passkeys">
|
|
101
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
102
|
+
<div>
|
|
103
|
+
<h2 className="font-medium text-foreground">Passkeys</h2>
|
|
104
|
+
<p className="text-sm text-muted-foreground">
|
|
105
|
+
Add more than one device so email remains recovery, not your daily sign-in.
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
<Button type="button" onClick={add} disabled={busy}>
|
|
109
|
+
Add passkey
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="space-y-2">
|
|
113
|
+
{passkeys.map((passkey) => (
|
|
114
|
+
<div key={passkey.id} className="flex items-center gap-2 rounded-md border p-2">
|
|
115
|
+
<Input
|
|
116
|
+
aria-label="Passkey name"
|
|
117
|
+
value={names[passkey.id] ?? ""}
|
|
118
|
+
onChange={(event) =>
|
|
119
|
+
setNames((current) => ({ ...current, [passkey.id]: event.target.value }))
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
122
|
+
<Button
|
|
123
|
+
type="button"
|
|
124
|
+
variant="outline"
|
|
125
|
+
disabled={busy}
|
|
126
|
+
onClick={() => rename(passkey.id)}
|
|
127
|
+
>
|
|
128
|
+
Save
|
|
129
|
+
</Button>
|
|
130
|
+
<Button
|
|
131
|
+
type="button"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
disabled={busy}
|
|
134
|
+
onClick={() => remove(passkey.id)}
|
|
135
|
+
>
|
|
136
|
+
Remove
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
{loaded && passkeys.length === 0 ? (
|
|
141
|
+
<p className="text-sm text-muted-foreground">No passkeys enrolled.</p>
|
|
142
|
+
) : null}
|
|
143
|
+
{!loaded && !notice ? (
|
|
144
|
+
<p className="text-sm text-muted-foreground">Loading passkeys.</p>
|
|
145
|
+
) : null}
|
|
146
|
+
</div>
|
|
147
|
+
{notice ? (
|
|
148
|
+
<p role="status" className="mt-2 text-sm text-muted-foreground">
|
|
149
|
+
{notice}
|
|
150
|
+
</p>
|
|
151
|
+
) : null}
|
|
152
|
+
</section>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Brand -> AppKit token bridge.
|
|
3
|
+
*
|
|
4
|
+
* Remaps the AppKit / shadcn semantic tokens the dbx-tools UI components
|
|
5
|
+
* actually style off (`--primary`, `--ring`, `--accent`, `--sidebar-*`,
|
|
6
|
+
* `--font-*`) onto the portable `--brand-color-*` / `--brand-font-*` custom
|
|
7
|
+
* properties that `applyBrandContext()` writes. This is the "tag selector":
|
|
8
|
+
* scoped to `:root[data-brand]`, it is completely INERT until a brand is
|
|
9
|
+
* applied to the document (which sets the `data-brand` attribute), so
|
|
10
|
+
* importing it never disturbs default AppKit.
|
|
11
|
+
*
|
|
12
|
+
* Import once via `@dbx-tools/ui/styles.css` (the shared UI base every
|
|
13
|
+
* feature UI package pulls in), so the bridge travels with every dbx-tools UI
|
|
14
|
+
* component automatically.
|
|
15
|
+
*
|
|
16
|
+
* Scope note: `:root[data-brand]` has higher specificity (0,2,0) than AppKit's
|
|
17
|
+
* `:root` and `.dark` (both 0,1,0), so these win regardless of source order.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately identity-only: it does NOT remap neutrals (`--background`,
|
|
20
|
+
* `--foreground`, `--card`, `--muted`, `--border`). The brand context carries a
|
|
21
|
+
* single light palette, so remapping neutrals would break dark mode; leaving
|
|
22
|
+
* them to AppKit keeps light AND dark themes working while the brand's identity
|
|
23
|
+
* colors + fonts come through.
|
|
24
|
+
*/
|
|
25
|
+
:root[data-brand] {
|
|
26
|
+
--primary: var(--brand-color-primary);
|
|
27
|
+
--primary-foreground: var(--brand-color-background);
|
|
28
|
+
--ring: var(--brand-color-primary);
|
|
29
|
+
/*
|
|
30
|
+
* `--accent` is a SURFACE, not an identity color. In AppKit / shadcn it is the
|
|
31
|
+
* subtle hover + selected background (light theme `oklch(0.967 0.001 286)`,
|
|
32
|
+
* i.e. near-white) paired with a near-black `--accent-foreground`. Assigning
|
|
33
|
+
* the brand accent to it at full strength therefore put dark text on saturated
|
|
34
|
+
* `#00A972` in every hover and every selected row - the selected conversation
|
|
35
|
+
* in `ThreadSidebar`, which styles off `bg-accent text-accent-foreground`, was
|
|
36
|
+
* the worst of it.
|
|
37
|
+
*
|
|
38
|
+
* Menus, selected rows, and tabs are navigation, not success or identity
|
|
39
|
+
* signals. Tint them with the cooler primary-hover color rather than the
|
|
40
|
+
* identity accent so the default brand produces a quiet blue wash instead of
|
|
41
|
+
* coral/red or success green. Mixing into the CURRENT `--background` follows
|
|
42
|
+
* the active theme on its own - pale in light mode and deep in dark mode -
|
|
43
|
+
* while AppKit's per-theme `--accent-foreground` remains readable. That is
|
|
44
|
+
* also why `--accent-foreground` is NOT set here: pinning it would break
|
|
45
|
+
* whichever theme it was not chosen for.
|
|
46
|
+
*/
|
|
47
|
+
--accent: color-mix(in oklab, var(--brand-color-primary-hover) 12%, var(--background));
|
|
48
|
+
--sidebar-primary: var(--brand-color-primary);
|
|
49
|
+
--sidebar-primary-foreground: var(--brand-color-background);
|
|
50
|
+
--sidebar-ring: var(--brand-color-primary);
|
|
51
|
+
--font-sans: var(--brand-font-sans);
|
|
52
|
+
--font-mono: var(--brand-font-mono);
|
|
53
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { brandUtils } from "@dbx-tools/shared-core";
|
|
2
|
+
import { dbxToolsAssetDataUrls } from "./generated/assets.ts";
|
|
3
|
+
|
|
4
|
+
const builtInAssets = new Map<string, string>([
|
|
5
|
+
[brandUtils.DEFAULT_BRAND_ASSETS.icon.light, dbxToolsAssetDataUrls.iconLight],
|
|
6
|
+
[brandUtils.DEFAULT_BRAND_ASSETS.icon.dark, dbxToolsAssetDataUrls.iconDark],
|
|
7
|
+
[brandUtils.DEFAULT_BRAND_ASSETS.logo.light, dbxToolsAssetDataUrls.logoLight],
|
|
8
|
+
[brandUtils.DEFAULT_BRAND_ASSETS.logo.dark, dbxToolsAssetDataUrls.logoDark],
|
|
9
|
+
[brandUtils.DEFAULT_BRAND_ASSETS.favicon, dbxToolsAssetDataUrls.iconLight],
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
/** Resolve a brand asset identifier to a browser-loadable URL. */
|
|
13
|
+
export type BrandAssetResolver = (source: string) => string;
|
|
14
|
+
|
|
15
|
+
/** Resolve built-in package asset ids to portable data URLs. */
|
|
16
|
+
export const resolveBrandAsset: BrandAssetResolver = (source) =>
|
|
17
|
+
builtInAssets.get(source) ?? source;
|
|
18
|
+
|
|
19
|
+
/** Convert a validated context to CSS custom properties. */
|
|
20
|
+
export function brandCssVariables(context: brandUtils.BrandContext): Record<string, string> {
|
|
21
|
+
return {
|
|
22
|
+
"--brand-color-primary": context.colors.primary,
|
|
23
|
+
"--brand-color-primary-hover": context.colors.primaryHover,
|
|
24
|
+
"--brand-color-accent": context.colors.accent,
|
|
25
|
+
"--brand-color-foreground": context.colors.foreground,
|
|
26
|
+
"--brand-color-background": context.colors.background,
|
|
27
|
+
"--brand-color-surface": context.colors.surface,
|
|
28
|
+
"--brand-color-muted": context.colors.muted,
|
|
29
|
+
"--brand-color-border": context.colors.border,
|
|
30
|
+
"--brand-font-sans": context.typography.sans,
|
|
31
|
+
"--brand-font-mono": context.typography.mono,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Document, root element, asset, title, and favicon behavior for applying a brand. */
|
|
36
|
+
export interface ApplyBrandContextOptions {
|
|
37
|
+
root?: HTMLElement;
|
|
38
|
+
document?: Document;
|
|
39
|
+
resolveAsset?: BrandAssetResolver;
|
|
40
|
+
updateTitle?: boolean;
|
|
41
|
+
updateFavicon?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Apply tokens and optional page metadata to a browser document. */
|
|
45
|
+
export function applyBrandContext(
|
|
46
|
+
context: brandUtils.BrandContext,
|
|
47
|
+
options: ApplyBrandContextOptions = {},
|
|
48
|
+
): void {
|
|
49
|
+
const documentRef = options.document ?? globalThis.document;
|
|
50
|
+
const root = options.root ?? documentRef?.documentElement;
|
|
51
|
+
for (const [name, value] of Object.entries(brandCssVariables(context))) {
|
|
52
|
+
root?.style.setProperty(name, value);
|
|
53
|
+
}
|
|
54
|
+
// Detection flag for the `brand-bridge.css` tag selector: setting
|
|
55
|
+
// `data-brand` activates the `:root[data-brand]` token remap. Until a
|
|
56
|
+
// brand is applied the bridge is inert, so default AppKit is untouched.
|
|
57
|
+
root?.setAttribute("data-brand", context.name);
|
|
58
|
+
|
|
59
|
+
if (documentRef && options.updateTitle !== false) documentRef.title = context.name;
|
|
60
|
+
if (!documentRef || options.updateFavicon === false) return;
|
|
61
|
+
|
|
62
|
+
const resolveAsset = options.resolveAsset ?? resolveBrandAsset;
|
|
63
|
+
let favicon = documentRef.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
64
|
+
if (!favicon) {
|
|
65
|
+
favicon = documentRef.createElement("link");
|
|
66
|
+
favicon.rel = "icon";
|
|
67
|
+
documentRef.head.append(favicon);
|
|
68
|
+
}
|
|
69
|
+
favicon.href = resolveAsset(context.assets.favicon);
|
|
70
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// GENERATED by branding/generate-package-assets.mjs - DO NOT EDIT.
|
|
2
|
+
// Regenerated from branding/assets.
|
|
3
|
+
// Hand edits are overwritten during synthesis; this file is read-only.
|
|
4
|
+
/* eslint-disable prettier/prettier */
|
|
5
|
+
|
|
6
|
+
export const dbxToolsAssetSvg = {
|
|
7
|
+
iconLight: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" role=\"img\" aria-labelledby=\"title description\">\n <title id=\"title\">dbx tools icon</title>\n <desc id=\"description\">Pixel tool mark in Databricks lava red on a light surface.</desc>\n <rect width=\"64\" height=\"64\" rx=\"12\" fill=\"#F9F7F4\"/>\n <g shape-rendering=\"crispEdges\">\n <path d=\"M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z\" fill=\"#FF3621\"/>\n <path d=\"M4 28h8v8H4zm52 0h4v8h-4z\" fill=\"#98102A\"/>\n <path d=\"M28 28h8v8h-8z\" fill=\"#FF5F46\"/>\n </g>\n</svg>\n",
|
|
8
|
+
iconDark: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\" role=\"img\" aria-labelledby=\"title description\">\n <title id=\"title\">dbx tools icon</title>\n <desc id=\"description\">Pixel tool mark in Databricks lava red on a dark surface.</desc>\n <rect width=\"64\" height=\"64\" rx=\"12\" fill=\"#1B3139\"/>\n <g shape-rendering=\"crispEdges\">\n <path d=\"M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z\" fill=\"#FF3621\"/>\n <path d=\"M4 28h8v8H4zm52 0h4v8h-4z\" fill=\"#FF5F46\"/>\n <path d=\"M28 28h8v8h-8z\" fill=\"#FF8974\"/>\n </g>\n</svg>\n",
|
|
9
|
+
logoLight: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 72\" role=\"img\" aria-labelledby=\"title description\">\n <title id=\"title\">dbx tools</title>\n <desc id=\"description\">dbx tools logo for light surfaces.</desc>\n <g transform=\"translate(4 4)\">\n <rect width=\"64\" height=\"64\" rx=\"12\" fill=\"#F9F7F4\"/>\n <g shape-rendering=\"crispEdges\">\n <path d=\"M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z\" fill=\"#FF3621\"/>\n <path d=\"M4 28h8v8H4zm52 0h4v8h-4z\" fill=\"#98102A\"/>\n <path d=\"M28 28h8v8h-8z\" fill=\"#FF5F46\"/>\n </g>\n </g>\n <text x=\"84\" y=\"47\" font-family=\"'DM Sans', ui-sans-serif, system-ui, sans-serif\" font-size=\"32\" font-weight=\"700\" letter-spacing=\"-0.5\" fill=\"#1B3139\">dbx <tspan fill=\"#FF3621\">tools</tspan></text>\n</svg>\n",
|
|
10
|
+
logoDark: "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 72\" role=\"img\" aria-labelledby=\"title description\">\n <title id=\"title\">dbx tools</title>\n <desc id=\"description\">dbx tools logo for dark surfaces.</desc>\n <g transform=\"translate(4 4)\">\n <rect width=\"64\" height=\"64\" rx=\"12\" fill=\"#1B3139\"/>\n <g shape-rendering=\"crispEdges\">\n <path d=\"M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z\" fill=\"#FF3621\"/>\n <path d=\"M4 28h8v8H4zm52 0h4v8h-4z\" fill=\"#FF5F46\"/>\n <path d=\"M28 28h8v8h-8z\" fill=\"#FF8974\"/>\n </g>\n </g>\n <text x=\"84\" y=\"47\" font-family=\"'DM Sans', ui-sans-serif, system-ui, sans-serif\" font-size=\"32\" font-weight=\"700\" letter-spacing=\"-0.5\" fill=\"#F9F7F4\">dbx <tspan fill=\"#FF5F46\">tools</tspan></text>\n</svg>\n",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export const dbxToolsAssetDataUrls = {
|
|
14
|
+
iconLight: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsbGVkYnk9InRpdGxlIGRlc2NyaXB0aW9uIj4KICA8dGl0bGUgaWQ9InRpdGxlIj5kYnggdG9vbHMgaWNvbjwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2NyaXB0aW9uIj5QaXhlbCB0b29sIG1hcmsgaW4gRGF0YWJyaWNrcyBsYXZhIHJlZCBvbiBhIGxpZ2h0IHN1cmZhY2UuPC9kZXNjPgogIDxyZWN0IHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgcng9IjEyIiBmaWxsPSIjRjlGN0Y0Ii8+CiAgPGcgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj4KICAgIDxwYXRoIGQ9Ik00IDI4aDh2OEg0em04IDBoOHYxNmgtOHptOC04aDh2MTZoLTh6bTggOGg4djhoLTh6bTgtOGg4djE2aC04em04IDhoOHYxNmgtOHptOCAwaDR2OGgtNHoiIGZpbGw9IiNGRjM2MjEiLz4KICAgIDxwYXRoIGQ9Ik00IDI4aDh2OEg0em01MiAwaDR2OGgtNHoiIGZpbGw9IiM5ODEwMkEiLz4KICAgIDxwYXRoIGQ9Ik0yOCAyOGg4djhoLTh6IiBmaWxsPSIjRkY1RjQ2Ii8+CiAgPC9nPgo8L3N2Zz4K",
|
|
15
|
+
iconDark: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgcm9sZT0iaW1nIiBhcmlhLWxhYmVsbGVkYnk9InRpdGxlIGRlc2NyaXB0aW9uIj4KICA8dGl0bGUgaWQ9InRpdGxlIj5kYnggdG9vbHMgaWNvbjwvdGl0bGU+CiAgPGRlc2MgaWQ9ImRlc2NyaXB0aW9uIj5QaXhlbCB0b29sIG1hcmsgaW4gRGF0YWJyaWNrcyBsYXZhIHJlZCBvbiBhIGRhcmsgc3VyZmFjZS48L2Rlc2M+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxQjMxMzkiLz4KICA8ZyBzaGFwZS1yZW5kZXJpbmc9ImNyaXNwRWRnZXMiPgogICAgPHBhdGggZD0iTTQgMjhoOHY4SDR6bTggMGg4djE2aC04em04LThoOHYxNmgtOHptOCA4aDh2OGgtOHptOC04aDh2MTZoLTh6bTggOGg4djE2aC04em04IDBoNHY4aC00eiIgZmlsbD0iI0ZGMzYyMSIvPgogICAgPHBhdGggZD0iTTQgMjhoOHY4SDR6bTUyIDBoNHY4aC00eiIgZmlsbD0iI0ZGNUY0NiIvPgogICAgPHBhdGggZD0iTTI4IDI4aDh2OGgtOHoiIGZpbGw9IiNGRjg5NzQiLz4KICA8L2c+Cjwvc3ZnPgo=",
|
|
16
|
+
logoLight: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMjAgNzIiIHJvbGU9ImltZyIgYXJpYS1sYWJlbGxlZGJ5PSJ0aXRsZSBkZXNjcmlwdGlvbiI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+ZGJ4IHRvb2xzPC90aXRsZT4KICA8ZGVzYyBpZD0iZGVzY3JpcHRpb24iPmRieCB0b29scyBsb2dvIGZvciBsaWdodCBzdXJmYWNlcy48L2Rlc2M+CiAgPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNCA0KSI+CiAgICA8cmVjdCB3aWR0aD0iNjQiIGhlaWdodD0iNjQiIHJ4PSIxMiIgZmlsbD0iI0Y5RjdGNCIvPgogICAgPGcgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj4KICAgICAgPHBhdGggZD0iTTQgMjhoOHY4SDR6bTggMGg4djE2aC04em04LThoOHYxNmgtOHptOCA4aDh2OGgtOHptOC04aDh2MTZoLTh6bTggOGg4djE2aC04em04IDBoNHY4aC00eiIgZmlsbD0iI0ZGMzYyMSIvPgogICAgICA8cGF0aCBkPSJNNCAyOGg4djhINHptNTIgMGg0djhoLTR6IiBmaWxsPSIjOTgxMDJBIi8+CiAgICAgIDxwYXRoIGQ9Ik0yOCAyOGg4djhoLTh6IiBmaWxsPSIjRkY1RjQ2Ii8+CiAgICA8L2c+CiAgPC9nPgogIDx0ZXh0IHg9Ijg0IiB5PSI0NyIgZm9udC1mYW1pbHk9IidETSBTYW5zJywgdWktc2Fucy1zZXJpZiwgc3lzdGVtLXVpLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjMyIiBmb250LXdlaWdodD0iNzAwIiBsZXR0ZXItc3BhY2luZz0iLTAuNSIgZmlsbD0iIzFCMzEzOSI+ZGJ4IDx0c3BhbiBmaWxsPSIjRkYzNjIxIj50b29sczwvdHNwYW4+PC90ZXh0Pgo8L3N2Zz4K",
|
|
17
|
+
logoDark: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMjAgNzIiIHJvbGU9ImltZyIgYXJpYS1sYWJlbGxlZGJ5PSJ0aXRsZSBkZXNjcmlwdGlvbiI+CiAgPHRpdGxlIGlkPSJ0aXRsZSI+ZGJ4IHRvb2xzPC90aXRsZT4KICA8ZGVzYyBpZD0iZGVzY3JpcHRpb24iPmRieCB0b29scyBsb2dvIGZvciBkYXJrIHN1cmZhY2VzLjwvZGVzYz4KICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg0IDQpIj4KICAgIDxyZWN0IHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgcng9IjEyIiBmaWxsPSIjMUIzMTM5Ii8+CiAgICA8ZyBzaGFwZS1yZW5kZXJpbmc9ImNyaXNwRWRnZXMiPgogICAgICA8cGF0aCBkPSJNNCAyOGg4djhINHptOCAwaDh2MTZoLTh6bTgtOGg4djE2aC04em04IDhoOHY4aC04em04LThoOHYxNmgtOHptOCA4aDh2MTZoLTh6bTggMGg0djhoLTR6IiBmaWxsPSIjRkYzNjIxIi8+CiAgICAgIDxwYXRoIGQ9Ik00IDI4aDh2OEg0em01MiAwaDR2OGgtNHoiIGZpbGw9IiNGRjVGNDYiLz4KICAgICAgPHBhdGggZD0iTTI4IDI4aDh2OGgtOHoiIGZpbGw9IiNGRjg5NzQiLz4KICAgIDwvZz4KICA8L2c+CiAgPHRleHQgeD0iODQiIHk9IjQ3IiBmb250LWZhbWlseT0iJ0RNIFNhbnMnLCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzIiIGZvbnQtd2VpZ2h0PSI3MDAiIGxldHRlci1zcGFjaW5nPSItMC41IiBmaWxsPSIjRjlGN0Y0Ij5kYnggPHRzcGFuIGZpbGw9IiNGRjVGNDYiPnRvb2xzPC90c3Bhbj48L3RleHQ+Cjwvc3ZnPgo=",
|
|
18
|
+
} as const;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- GENERATED by branding/generate-package-assets.mjs - DO NOT EDIT. -->
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title description">
|
|
3
|
+
<title id="title">dbx tools icon</title>
|
|
4
|
+
<desc id="description">Pixel tool mark in Databricks lava red on a dark surface.</desc>
|
|
5
|
+
<rect width="64" height="64" rx="12" fill="#1B3139"/>
|
|
6
|
+
<g shape-rendering="crispEdges">
|
|
7
|
+
<path d="M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z" fill="#FF3621"/>
|
|
8
|
+
<path d="M4 28h8v8H4zm52 0h4v8h-4z" fill="#FF5F46"/>
|
|
9
|
+
<path d="M28 28h8v8h-8z" fill="#FF8974"/>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!-- GENERATED by branding/generate-package-assets.mjs - DO NOT EDIT. -->
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title description">
|
|
3
|
+
<title id="title">dbx tools icon</title>
|
|
4
|
+
<desc id="description">Pixel tool mark in Databricks lava red on a light surface.</desc>
|
|
5
|
+
<rect width="64" height="64" rx="12" fill="#F9F7F4"/>
|
|
6
|
+
<g shape-rendering="crispEdges">
|
|
7
|
+
<path d="M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z" fill="#FF3621"/>
|
|
8
|
+
<path d="M4 28h8v8H4zm52 0h4v8h-4z" fill="#98102A"/>
|
|
9
|
+
<path d="M28 28h8v8h-8z" fill="#FF5F46"/>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!-- GENERATED by branding/generate-package-assets.mjs - DO NOT EDIT. -->
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72" role="img" aria-labelledby="title description">
|
|
3
|
+
<title id="title">dbx tools</title>
|
|
4
|
+
<desc id="description">dbx tools logo for dark surfaces.</desc>
|
|
5
|
+
<g transform="translate(4 4)">
|
|
6
|
+
<rect width="64" height="64" rx="12" fill="#1B3139"/>
|
|
7
|
+
<g shape-rendering="crispEdges">
|
|
8
|
+
<path d="M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z" fill="#FF3621"/>
|
|
9
|
+
<path d="M4 28h8v8H4zm52 0h4v8h-4z" fill="#FF5F46"/>
|
|
10
|
+
<path d="M28 28h8v8h-8z" fill="#FF8974"/>
|
|
11
|
+
</g>
|
|
12
|
+
</g>
|
|
13
|
+
<text x="84" y="47" font-family="'DM Sans', ui-sans-serif, system-ui, sans-serif" font-size="32" font-weight="700" letter-spacing="-0.5" fill="#F9F7F4">dbx <tspan fill="#FF5F46">tools</tspan></text>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!-- GENERATED by branding/generate-package-assets.mjs - DO NOT EDIT. -->
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72" role="img" aria-labelledby="title description">
|
|
3
|
+
<title id="title">dbx tools</title>
|
|
4
|
+
<desc id="description">dbx tools logo for light surfaces.</desc>
|
|
5
|
+
<g transform="translate(4 4)">
|
|
6
|
+
<rect width="64" height="64" rx="12" fill="#F9F7F4"/>
|
|
7
|
+
<g shape-rendering="crispEdges">
|
|
8
|
+
<path d="M4 28h8v8H4zm8 0h8v16h-8zm8-8h8v16h-8zm8 8h8v8h-8zm8-8h8v16h-8zm8 8h8v16h-8zm8 0h4v8h-4z" fill="#FF3621"/>
|
|
9
|
+
<path d="M4 28h8v8H4zm52 0h4v8h-4z" fill="#98102A"/>
|
|
10
|
+
<path d="M28 28h8v8h-8z" fill="#FF5F46"/>
|
|
11
|
+
</g>
|
|
12
|
+
</g>
|
|
13
|
+
<text x="84" y="47" font-family="'DM Sans', ui-sans-serif, system-ui, sans-serif" font-size="32" font-weight="700" letter-spacing="-0.5" fill="#1B3139">dbx <tspan fill="#FF3621">tools</tspan></text>
|
|
14
|
+
</svg>
|