Skip to content

Commit 0ed09a9

Browse files
committed
feat(theme): theme-aware images and Datum logo for dark mode
Add a <ThemedImage> primitive + darkAssetUrl helper (".dark" suffix convention) that swap source via the .dark class on <html> — flash-free and SSR-safe (not prefers-color-scheme, which would ignore the in-app ThemeToggle). The dark source is optimistic: it degrades to light on load error, so a dark asset auto-activates when it ships and never shows broken before then. Wire it across the auth surfaces: - BrandLogo: Datum mark via ThemedLogo.Flat; org logo uses branding.darkLogoUrl - split.layout: signature gets dark:invert; illustrations routed via ThemedImage - IdP marks (idp-icon, idp-button-list) swap to <provider>.dark.png variants Ship github.dark.png and datum-sso.dark.png dark marks.
1 parent 9191613 commit 0ed09a9

10 files changed

Lines changed: 164 additions & 56 deletions

File tree

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { AuthFormFields } from '@/components/auth-form/auth-form-fields';
22
import { LastUsedBadge } from '@/components/auth-form/last-used-badge';
3+
import { ThemedImage } from '@/components/themed-image/themed-image';
34
import type { IdProvider } from '@/modules/auth/types';
4-
import { assetUrl } from '@/utils/asset-url';
5+
import { assetUrl, darkAssetUrl } from '@/utils/asset-url';
56
import { Button } from '@datum-cloud/datum-ui/button';
67
import { cn } from '@datum-cloud/datum-ui/utils';
78
import { Trans } from '@lingui/react/macro';
@@ -33,35 +34,44 @@ export function IdpButtonList({
3334
}: IdpButtonListProps): React.JSX.Element {
3435
return (
3536
<div className="flex flex-col gap-3">
36-
{idps.map((idp) => (
37-
<RRForm key={idp.id} method="post">
38-
<AuthFormFields csrf={csrf} requestId={requestId} organization={organization} />
39-
<input type="hidden" name="intent" value="idp" />
40-
<input type="hidden" name="idpId" value={idp.id} />
41-
<Button
42-
size="large"
43-
className={cn(relative && 'relative', 'h-13 gap-3')}
44-
type="quaternary"
45-
theme="outline"
46-
block
47-
htmlType="submit"
48-
loading={submittingIdpId === idp.id}
49-
iconPosition="left"
50-
icon={
51-
<img
52-
src={assetUrl(`/images/idps/${idp.name.toLowerCase().replace(/\s+/g, '-')}.png`)}
53-
alt={idp.name}
54-
aria-hidden="true"
55-
className="size-4 object-contain"
56-
/>
57-
}>
58-
<Trans>{idp.name}</Trans>
59-
{lastUsedLogin !== undefined && (
60-
<LastUsedBadge active={lastUsedLogin === `idp:${idp.id}`} />
61-
)}
62-
</Button>
63-
</RRForm>
64-
))}
37+
{idps.map((idp) => {
38+
const mark = `/images/idps/${idp.name.toLowerCase().replace(/\s+/g, '-')}.png`;
39+
return (
40+
<RRForm key={idp.id} method="post">
41+
<AuthFormFields csrf={csrf} requestId={requestId} organization={organization} />
42+
<input type="hidden" name="intent" value="idp" />
43+
<input type="hidden" name="idpId" value={idp.id} />
44+
<Button
45+
size="large"
46+
className={cn(relative && 'relative', 'h-13 gap-3')}
47+
type="quaternary"
48+
theme="outline"
49+
block
50+
htmlType="submit"
51+
loading={submittingIdpId === idp.id}
52+
iconPosition="left"
53+
icon={
54+
// Span wrapper keeps the Button's `icon` slot a single element; the optimistic
55+
// `dark` source (<provider>.dark.png) auto-activates when it ships, else falls
56+
// back to the light mark.
57+
<span className="flex size-4 shrink-0 items-center justify-center">
58+
<ThemedImage
59+
light={assetUrl(mark)}
60+
dark={darkAssetUrl(mark)}
61+
alt={idp.name}
62+
aria-hidden="true"
63+
className="size-4 object-contain"
64+
/>
65+
</span>
66+
}>
67+
<Trans>{idp.name}</Trans>
68+
{lastUsedLogin !== undefined && (
69+
<LastUsedBadge active={lastUsedLogin === `idp:${idp.id}`} />
70+
)}
71+
</Button>
72+
</RRForm>
73+
);
74+
})}
6575
</div>
6676
);
6777
}

app/components/brand-logo/brand-logo.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
1+
import { ThemedImage } from '@/components/themed-image/themed-image';
12
import type { BrandingTheme } from '@/modules/auth/types';
2-
import { Logo } from '@datum-cloud/datum-ui/logo';
3+
import { ThemedLogo } from '@datum-cloud/datum-ui/logo/themed';
34
import { Link } from 'react-router';
45

56
// The home-link + logo swap shared by the auth layouts: the org's branding logo when
6-
// present, else the Datum flat mark. Each layout keeps its own wrapper div + className;
7-
// this owns only the <Link> + img/Logo.Flat conditional so the two stay byte-identical.
7+
// present, else the Datum mark. Each layout keeps its own wrapper div + className;
8+
// this owns only the <Link> + logo conditional so the two stay byte-identical.
9+
//
10+
// Both branches are theme-aware:
11+
// - org logo → <ThemedImage>: branding.darkLogoUrl (from Zitadel's darkTheme) on dark,
12+
// falling back to logoUrl when no dark variant is configured.
13+
// - Datum mark → <ThemedLogo.Flat>: datum-ui resolves `brand` on light / `mono-light`
14+
// on dark via useTheme (SSR-safe, brand fallback before hydration).
815
export function BrandLogo({ branding }: { branding?: BrandingTheme | null }): React.JSX.Element {
916
return (
1017
<Link to="/">
1118
{branding?.logoUrl ? (
12-
<img src={branding.logoUrl} alt="" aria-hidden="true" className="h-6 w-auto" />
19+
<ThemedImage
20+
light={branding.logoUrl}
21+
dark={branding.darkLogoUrl}
22+
alt=""
23+
aria-hidden="true"
24+
className="h-6 w-auto"
25+
/>
1326
) : (
14-
<Logo.Flat aria-label="Datum" className="h-6 w-auto" tone="brand" />
27+
<ThemedLogo.Flat aria-label="Datum" className="h-6 w-auto" />
1528
)}
1629
</Link>
1730
);

app/components/idp-icon/idp-icon.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { assetUrl } from '@/utils/asset-url';
1+
import { ThemedImage } from '@/components/themed-image/themed-image';
2+
import { assetUrl, darkAssetUrl } from '@/utils/asset-url';
23
import { Icon } from '@datum-cloud/datum-ui/icons';
34
import { MailIcon } from 'lucide-react';
45

@@ -10,6 +11,9 @@ import { MailIcon } from 'lucide-react';
1011
* accessible name inside an enclosing interactive control — avoids the
1112
* nested-interactive axe violation when rendered inside the account-switch button
1213
* or the SSO unlink row. Shared by /id/sso and /id/accounts.
14+
*
15+
* Bundled marks pass a `dark` source via the `.dark` suffix convention: it's optimistic, so
16+
* `<provider>.dark.png` auto-activates when it ships and falls back to the light mark until then.
1317
*/
1418
export function IdpIcon({
1519
type,
@@ -23,8 +27,9 @@ export function IdpIcon({
2327
const t = (type ?? '').toUpperCase();
2428
if (t === 'GOOGLE') {
2529
return (
26-
<img
27-
src={assetUrl(`/images/idps/google.png`)}
30+
<ThemedImage
31+
light={assetUrl(`/images/idps/google.png`)}
32+
dark={darkAssetUrl(`/images/idps/google.png`)}
2833
alt="Google"
2934
aria-hidden
3035
width={20}
@@ -35,8 +40,9 @@ export function IdpIcon({
3540
}
3641
if (t === 'GITHUB' || t === 'GITHUB_ES') {
3742
return (
38-
<img
39-
src={assetUrl(`/images/idps/github.png`)}
43+
<ThemedImage
44+
light={assetUrl(`/images/idps/github.png`)}
45+
dark={darkAssetUrl(`/images/idps/github.png`)}
4046
alt="GitHub"
4147
aria-hidden
4248
width={20}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { cn } from '@datum-cloud/datum-ui/utils';
2+
3+
export interface ThemedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
4+
/** Light-theme source. Also the SSR + fallback source. */
5+
light: string;
6+
/**
7+
* Dark-theme source, used *optimistically*: shown on the dark theme, but it degrades to
8+
* {@link light} if it fails to load (e.g. the dark asset hasn't shipped yet). This makes
9+
* `dark` a true drop-in — wire it now and it auto-activates the moment the file lands, with
10+
* no broken image in the meantime. Omit (or pass the same value) to reuse `light`.
11+
*/
12+
dark?: string;
13+
}
14+
15+
/**
16+
* Theme-aware <img> that swaps its source via the `.dark` class on <html>.
17+
*
18+
* The class is set pre-paint by datum-ui's <ThemeScript>, so the swap is flash-free and
19+
* SSR-safe. We deliberately do NOT use a <picture> + `prefers-color-scheme` media query:
20+
* that follows the OS only and would desync the moment a user flips the in-app ThemeToggle.
21+
*
22+
* A distinct `dark` source renders a second <img>; CSS shows exactly one. The dark copy
23+
* degrades to `light` on load error, so a not-yet-shipped dark asset never shows broken.
24+
*
25+
* Keep usage decorative (alt=""): both copies share the same attributes.
26+
*/
27+
export function ThemedImage({
28+
light,
29+
dark,
30+
className,
31+
...rest
32+
}: ThemedImageProps): React.JSX.Element {
33+
if (!dark || dark === light) {
34+
return <img src={light} className={className} {...rest} />;
35+
}
36+
37+
// Degrade the dark copy to `light` exactly once on failure. The ref catches a load error
38+
// that fired during SSR (before React attached onError); onError catches client-side ones.
39+
// The data-flag both prevents a loop when `light` is also unavailable and avoids re-firing.
40+
const degradeToLight = (img: HTMLImageElement | null): void => {
41+
if (!img || img.dataset.themedFallback || !img.complete || img.naturalWidth > 0) return;
42+
img.dataset.themedFallback = 'true';
43+
img.src = light;
44+
};
45+
46+
return (
47+
<>
48+
<img src={light} className={cn(className, 'dark:hidden')} {...rest} />
49+
<img
50+
src={dark}
51+
{...rest}
52+
ref={degradeToLight}
53+
onError={(e) => {
54+
const img = e.currentTarget;
55+
if (img.dataset.themedFallback) return;
56+
img.dataset.themedFallback = 'true';
57+
img.src = light;
58+
}}
59+
className={cn(className, 'hidden dark:block')}
60+
/>
61+
</>
62+
);
63+
}

app/layouts/split.layout.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BrandLogo } from '@/components/brand-logo/brand-logo';
2+
import { ThemedImage } from '@/components/themed-image/themed-image';
23
import type { BrandingTheme } from '@/modules/auth/types';
34
import { assetUrl } from '@/utils/asset-url';
45
import { Avatar, AvatarFallback, AvatarImage } from '@datum-cloud/datum-ui/avatar';
@@ -73,9 +74,11 @@ export default function SplitLayout({
7374
the absolutely-positioned panel reserves space and never shifts layout (CLS).
7475
object-contain keeps the line-art crisp/uncropped while it scales down into the
7576
115px box. Design note (asset pending): replace with a vector (SVG) or AVIF source — a designer
76-
asset is still needed; this raster is the only source that currently exists. */}
77-
<img
78-
src={assetUrl('/images/illustration-2.svg')}
77+
asset is still needed; this raster is the only source that currently exists.
78+
Dark mode (#9C7979 rose line-art on the dark panel): the seam is wired — once
79+
/images/illustration-2.dark.svg exists, pass dark={darkAssetUrl('/images/illustration-2.svg')}. */}
80+
<ThemedImage
81+
light={assetUrl('/images/illustration-2.svg')}
7982
alt=""
8083
aria-hidden="true"
8184
width={232}
@@ -114,7 +117,8 @@ export default function SplitLayout({
114117
<Avatar className="mb-2 size-10 rounded-lg">
115118
{/* 80×80 source rendered into a 40px (size-10) slot — the existing source is
116119
already 2× DPR. The 2x density descriptor declares that intent so retina screens
117-
render the avatar crisply; width/height reserve the 40px box (CLS). */}
120+
render the avatar crisply; width/height reserve the 40px box (CLS). A photo, so it
121+
is theme-independent — no light/dark variant needed. */}
118122
<AvatarImage
119123
alt="Zac Smith"
120124
src={assetUrl('/images/zac-avatar.png')}
@@ -131,16 +135,18 @@ export default function SplitLayout({
131135
<span className="text-muted-foreground text-xs">Co-founder and CEO</span>
132136
</div>
133137

134-
{/* 95×38 signature raster at ~1× in a 96px-wide slot. width/height pin the box
135-
(CLS) and object-contain keeps the strokes crisp without stretching. Design note (asset pending): a
136-
vector (SVG) signature is still needed — only this low-res raster currently exists. */}
138+
{/* 95×38 signature raster (raster wrapped in SVG) at ~1× in a 96px-wide slot.
139+
width/height pin the box (CLS) and object-contain keeps the strokes crisp without
140+
stretching. dark:invert flips the dark ink to light so the signature reads on the
141+
dark panel — the right call here since it is a single-colour mark (no real dark
142+
asset needed). Design note (asset pending): a true vector signature is still wanted. */}
137143
<img
138144
src={assetUrl('/images/zac-sign.svg')}
139145
alt=""
140146
aria-hidden="true"
141147
width={95}
142148
height={38}
143-
className="h-[38px] w-24 object-contain"
149+
className="h-[38px] w-24 object-contain dark:invert"
144150
/>
145151
</div>
146152
</div>
@@ -150,9 +156,11 @@ export default function SplitLayout({
150156
so it currently upscales (soft). width/height pin the aspect ratio for CLS; object-contain
151157
avoids the crop/extra blur object-cover introduced. Design note (asset pending): replace with a vector
152158
(SVG) or higher-res AVIF source — a designer asset is still needed to render crisply at
153-
the 800px display width; the 707px raster is the only source that exists today. */}
154-
<img
155-
src={assetUrl('/images/illustration-1.svg')}
159+
the 800px display width; the 707px raster is the only source that exists today.
160+
Dark mode (#0C1D31 navy line-art on the dark panel): the seam is wired — once
161+
/images/illustration-1.dark.svg exists, pass dark={darkAssetUrl('/images/illustration-1.svg')}. */}
162+
<ThemedImage
163+
light={assetUrl('/images/illustration-1.svg')}
156164
alt=""
157165
aria-hidden="true"
158166
width={707}

app/modules/i18n/locales/en.po

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}"
1919

2020
#. placeholder {0}: idp.name
2121
#. placeholder {0}: r.label
22-
#: app/components/auth-form/idp-button-list.tsx:58
22+
#: app/components/auth-form/idp-button-list.tsx:67
2323
#: app/routes/setup/mfa.tsx:184
2424
msgid "{0}"
2525
msgstr "{0}"
@@ -126,7 +126,7 @@ msgstr "Back"
126126
msgid "Back to sign in"
127127
msgstr "Back to sign in"
128128

129-
#: app/layouts/split.layout.tsx:30
129+
#: app/layouts/split.layout.tsx:31
130130
msgid "By continuing, you agree to Datum's <0>Terms of Service</0> and <1>Privacy Policy</1>, and to receive periodic emails with updates."
131131
msgstr "By continuing, you agree to Datum's <0>Terms of Service</0> and <1>Privacy Policy</1>, and to receive periodic emails with updates."
132132

@@ -437,7 +437,7 @@ msgstr "Phone sign-in isn't available — use your email or username."
437437
msgid "Please check your input and try again."
438438
msgstr "Please check your input and try again."
439439

440-
#: app/layouts/split.layout.tsx:98
440+
#: app/layouts/split.layout.tsx:101
441441
msgid "Prefer a demo instead? Just <0>reach out</0>."
442442
msgstr "Prefer a demo instead? Just <0>reach out</0>."
443443

@@ -598,7 +598,7 @@ msgstr "Something went wrong. Please try again."
598598
msgid "Start over"
599599
msgstr "Start over"
600600

601-
#: app/layouts/split.layout.tsx:110
601+
#: app/layouts/split.layout.tsx:113
602602
msgid "Thanks,"
603603
msgstr "Thanks,"
604604

@@ -688,7 +688,7 @@ msgstr "Use your security key to verify your identity."
688688
msgid "Username"
689689
msgstr "Username"
690690

691-
#: app/layouts/split.layout.tsx:88
691+
#: app/layouts/split.layout.tsx:91
692692
msgid "Using Datum requires setting up a billing account, but to help you explore without cost, we add <0>$50 USD</0> in credit on signup."
693693
msgstr "Using Datum requires setting up a billing account, but to help you explore without cost, we add <0>$50 USD</0> in credit on signup."
694694

app/utils/asset-url.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,11 @@
66
*/
77
export const assetUrl = (path: string): string =>
88
`${import.meta.env.BASE_URL}${path.replace(/^\//, '')}`;
9+
10+
/**
11+
* Resolve the dark-theme variant of a public asset using the `.dark` suffix convention:
12+
* `/images/x.svg` → `<base>/images/x.dark.svg`. Pair with `<ThemedImage dark={...}>`.
13+
* Leaves the path unchanged (aside from the base prefix) when it has no file extension.
14+
*/
15+
export const darkAssetUrl = (path: string): string =>
16+
assetUrl(path.replace(/(\.[^./]+)$/, '.dark$1'));
2.62 KB
Loading

public/images/idps/github.dark.png

6.37 KB
Loading

public/images/idps/github.png

100644100755
-9.23 KB
Loading

0 commit comments

Comments
 (0)