@djangocfg/layouts 2.1.567 → 2.1.569
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/package.json +12 -12
- package/src/layouts/AuthLayout/AuthLayout.tsx +108 -36
- package/src/layouts/AuthLayout/README.md +100 -11
- package/src/layouts/AuthLayout/components/shared/AuthBrandPanel.tsx +44 -0
- package/src/layouts/AuthLayout/components/shared/AuthHeader.tsx +12 -1
- package/src/layouts/AuthLayout/components/shared/index.ts +2 -0
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +13 -6
- package/src/layouts/AuthLayout/index.ts +2 -1
- package/src/layouts/AuthLayout/styles/auth.css +98 -38
- package/src/layouts/AuthLayout/styles/fullsplit-shell.css +44 -0
- package/src/testing/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.569",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"check": "tsc --noEmit"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/analytics": "^2.1.
|
|
94
|
-
"@djangocfg/api": "^2.1.
|
|
95
|
-
"@djangocfg/devtools": "^2.1.
|
|
96
|
-
"@djangocfg/i18n": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
+
"@djangocfg/analytics": "^2.1.569",
|
|
94
|
+
"@djangocfg/api": "^2.1.569",
|
|
95
|
+
"@djangocfg/devtools": "^2.1.569",
|
|
96
|
+
"@djangocfg/i18n": "^2.1.569",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.569",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -124,12 +124,12 @@
|
|
|
124
124
|
"uuid": "^11.1.1"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
|
-
"@djangocfg/analytics": "^2.1.
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/devtools": "^2.1.
|
|
130
|
-
"@djangocfg/i18n": "^2.1.
|
|
131
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
132
|
-
"@djangocfg/ui-core": "^2.1.
|
|
127
|
+
"@djangocfg/analytics": "^2.1.569",
|
|
128
|
+
"@djangocfg/api": "^2.1.569",
|
|
129
|
+
"@djangocfg/devtools": "^2.1.569",
|
|
130
|
+
"@djangocfg/i18n": "^2.1.569",
|
|
131
|
+
"@djangocfg/typescript-config": "^2.1.569",
|
|
132
|
+
"@djangocfg/ui-core": "^2.1.569",
|
|
133
133
|
"@storybook/react-vite": "^10.5.0",
|
|
134
134
|
"@types/node": "^25.9.5",
|
|
135
135
|
"@types/react": "19.2.15",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auth Layout
|
|
3
3
|
*
|
|
4
|
-
* Shell-based authentication layout with
|
|
5
|
-
* - `
|
|
6
|
-
*
|
|
4
|
+
* Shell-based authentication layout with three variants:
|
|
5
|
+
* - `fullsplit` (default) — full-bleed 50/50: photo half carrying the brand,
|
|
6
|
+
* form on a solid panel opposite. Collapses to a plain centered form on mobile
|
|
7
|
+
* - `centered` — Apple-style frameless, centered, glow background
|
|
8
|
+
* - `split` — Two-column frosted card on desktop, single-column on mobile
|
|
7
9
|
*
|
|
8
10
|
* Supports: email/phone OTP, OAuth (GitHub), 2FA (TOTP + backup codes)
|
|
9
11
|
*/
|
|
@@ -17,6 +19,7 @@ import { useAnalytics } from '@djangocfg/analytics';
|
|
|
17
19
|
import { useAppT } from '@djangocfg/i18n';
|
|
18
20
|
|
|
19
21
|
import { Suspense } from '../../components';
|
|
22
|
+
import { AuthBrandPanel } from './components/shared';
|
|
20
23
|
import { OAuthCallback } from './components/oauth';
|
|
21
24
|
import { IdentifierStep, OTPStep, TwoFactorStep } from './components/steps';
|
|
22
25
|
import { AUTH } from './constants';
|
|
@@ -35,12 +38,34 @@ import type { AuthLayoutProps } from './types';
|
|
|
35
38
|
interface AuthLayoutContextValue {
|
|
36
39
|
/** True when the consumer passed custom children — step headers should be hidden */
|
|
37
40
|
hideHeader: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when the brand is already rendered on the media half, so the form
|
|
43
|
+
* header must not repeat it.
|
|
44
|
+
*
|
|
45
|
+
* A statement about the DESKTOP layout only: `fullsplit` drops the media half
|
|
46
|
+
* below 1024px, where the logo above the form is the only brand left. CSS
|
|
47
|
+
* owns that half of the decision on the same breakpoint — see
|
|
48
|
+
* `.auth-header[data-brand-elsewhere]` in `auth.css`.
|
|
49
|
+
*/
|
|
50
|
+
hasBrandPanel: boolean;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
const AuthLayoutContext = createContext<AuthLayoutContextValue>({
|
|
53
|
+
const AuthLayoutContext = createContext<AuthLayoutContextValue>({
|
|
54
|
+
hideHeader: false,
|
|
55
|
+
hasBrandPanel: false,
|
|
56
|
+
});
|
|
41
57
|
|
|
42
58
|
export const useAuthLayoutContext = (): AuthLayoutContextValue => useContext(AuthLayoutContext);
|
|
43
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The provider itself, for harnesses that mount the steps without `AuthLayout`
|
|
62
|
+
* (Storybook does, via `AuthShell`). Without it those previews always read the
|
|
63
|
+
* context defaults, so a story cannot show what a real screen renders — the
|
|
64
|
+
* brand appeared both on the photo and above the form, in the one story meant
|
|
65
|
+
* to prove it does not.
|
|
66
|
+
*/
|
|
67
|
+
export const AuthLayoutProvider = AuthLayoutContext.Provider;
|
|
68
|
+
|
|
44
69
|
// ─── Layout ──────────────────────────────────────────────────────────────────
|
|
45
70
|
|
|
46
71
|
export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
|
|
@@ -49,6 +74,7 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
|
|
|
49
74
|
mediaSide = 'left',
|
|
50
75
|
background,
|
|
51
76
|
sidebar,
|
|
77
|
+
logo,
|
|
52
78
|
enableGithubAuth,
|
|
53
79
|
// No default here — an unset prop falls through to the saved back-url /
|
|
54
80
|
// resolved routes.defaultCallback chain at navigation time (AuthSuccess).
|
|
@@ -61,7 +87,28 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
|
|
|
61
87
|
|
|
62
88
|
const hideHeader = Boolean(children);
|
|
63
89
|
|
|
64
|
-
|
|
90
|
+
/*
|
|
91
|
+
* The media half of `fullsplit` is a full-height photograph, and its content
|
|
92
|
+
* is the `sidebar` slot. Left to the consumer it was simply empty in four of
|
|
93
|
+
* the nine apps that mount this layout — a large photo carrying nothing while
|
|
94
|
+
* the brand crowded the form opposite. So when the app gives us a brand and
|
|
95
|
+
* no sidebar, the layout puts the brand where the room is.
|
|
96
|
+
*
|
|
97
|
+
* An explicit `sidebar` always wins; `centered` and `split` are untouched
|
|
98
|
+
* (neither has a photo half to fill).
|
|
99
|
+
*/
|
|
100
|
+
const resolvedSidebar = useMemo(() => {
|
|
101
|
+
if (sidebar) return sidebar;
|
|
102
|
+
if (variant !== 'fullsplit' || !logo) return undefined;
|
|
103
|
+
return <AuthBrandPanel logo={logo} />;
|
|
104
|
+
}, [sidebar, variant, logo]);
|
|
105
|
+
|
|
106
|
+
const hasBrandPanel = !sidebar && Boolean(resolvedSidebar);
|
|
107
|
+
|
|
108
|
+
const layoutContextValue = useMemo(
|
|
109
|
+
() => ({ hideHeader, hasBrandPanel }),
|
|
110
|
+
[hideHeader, hasBrandPanel],
|
|
111
|
+
);
|
|
65
112
|
|
|
66
113
|
const oauthCallback = useMemo(() => {
|
|
67
114
|
if (!enableGithubAuth) return null;
|
|
@@ -84,7 +131,7 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
|
|
|
84
131
|
<AuthSuccessOverlay />
|
|
85
132
|
<AuthFunnelTracker />
|
|
86
133
|
|
|
87
|
-
<AuthShell variant={variant} mediaSide={mediaSide} background={background} sidebar={
|
|
134
|
+
<AuthShell variant={variant} mediaSide={mediaSide} background={background} sidebar={resolvedSidebar} className={className}>
|
|
88
135
|
{/* Handle OAuth callback when GitHub auth is enabled */}
|
|
89
136
|
{oauthCallback}
|
|
90
137
|
|
|
@@ -146,24 +193,22 @@ const AuthContent: React.FC = memo(function AuthContent() {
|
|
|
146
193
|
});
|
|
147
194
|
|
|
148
195
|
const AuthSuccessOverlay: React.FC = memo(function AuthSuccessOverlay() {
|
|
149
|
-
const { step,
|
|
196
|
+
const { step, redirectUrl } = useAuthFormContext();
|
|
150
197
|
|
|
151
198
|
if (step !== 'success') {
|
|
152
199
|
return null;
|
|
153
200
|
}
|
|
154
201
|
|
|
155
|
-
return <AuthSuccess
|
|
202
|
+
return <AuthSuccess redirectUrl={redirectUrl} />;
|
|
156
203
|
});
|
|
157
204
|
|
|
158
205
|
// AuthSuccess component - Apple-style success screen
|
|
159
206
|
interface AuthSuccessInlineProps {
|
|
160
|
-
logo?: React.ReactNode;
|
|
161
207
|
redirectUrl?: string;
|
|
162
208
|
redirectDelay?: number;
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
const AuthSuccess: React.FC<AuthSuccessInlineProps> = memo(function AuthSuccess({
|
|
166
|
-
logo,
|
|
167
212
|
redirectUrl,
|
|
168
213
|
redirectDelay = AUTH.REDIRECT_DELAY,
|
|
169
214
|
}) {
|
|
@@ -172,7 +217,13 @@ const AuthSuccess: React.FC<AuthSuccessInlineProps> = memo(function AuthSuccess(
|
|
|
172
217
|
const t = useAppT();
|
|
173
218
|
const [isVisible, setIsVisible] = React.useState(false);
|
|
174
219
|
|
|
175
|
-
const
|
|
220
|
+
const content = React.useMemo(
|
|
221
|
+
() => ({
|
|
222
|
+
message: t('layouts.auth.success.message'),
|
|
223
|
+
redirecting: t('layouts.auth.success.redirecting'),
|
|
224
|
+
}),
|
|
225
|
+
[t],
|
|
226
|
+
);
|
|
176
227
|
|
|
177
228
|
React.useEffect(() => {
|
|
178
229
|
const animTimer = setTimeout(() => setIsVisible(true), AUTH.ANIMATION_START_DELAY);
|
|
@@ -189,21 +240,41 @@ const AuthSuccess: React.FC<AuthSuccessInlineProps> = memo(function AuthSuccess(
|
|
|
189
240
|
};
|
|
190
241
|
}, [redirectUrl, redirectDelay, router, routes.defaultCallback]);
|
|
191
242
|
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
243
|
+
return <AuthSuccessScreen visible={isVisible} {...content} />;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
export interface AuthSuccessScreenProps {
|
|
247
|
+
/** Drives the group fade. False on the first frame, then true. */
|
|
248
|
+
visible?: boolean;
|
|
249
|
+
/** The outcome. */
|
|
250
|
+
message: string;
|
|
251
|
+
/** What the pause before the redirect is for. */
|
|
252
|
+
redirecting?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The success screen's markup, with no timer and no router.
|
|
257
|
+
*
|
|
258
|
+
* Split out so it can be rendered on its own: the screen had no Storybook
|
|
259
|
+
* story at all, because the only thing that could draw it also navigated away
|
|
260
|
+
* 1.5s later. A screen nobody can look at is a screen nobody reviews.
|
|
261
|
+
*
|
|
262
|
+
* The checkmark is the confirmation, always — the brand is not a substitute
|
|
263
|
+
* for it. Showing the logo INSTEAD (the previous behaviour, whenever an app
|
|
264
|
+
* passed `logo`) produced a screen that never actually said the sign-in
|
|
265
|
+
* worked; it read as a splash screen, which is what a sign-in has just
|
|
266
|
+
* finished not being.
|
|
267
|
+
*
|
|
268
|
+
* `role="status"` rather than `alert`: this is the expected outcome, so it is
|
|
269
|
+
* announced politely, and the whole overlay is one live region so a screen
|
|
270
|
+
* reader hears the confirmation and the redirect notice together.
|
|
271
|
+
*/
|
|
272
|
+
export const AuthSuccessScreen: React.FC<AuthSuccessScreenProps> = memo(
|
|
273
|
+
function AuthSuccessScreen({ visible = true, message, redirecting }) {
|
|
274
|
+
return (
|
|
275
|
+
<div className="auth-success-overlay" role="status" aria-live="polite">
|
|
276
|
+
<div className="auth-success-content" data-visible={visible}>
|
|
277
|
+
<div className="auth-success-check">
|
|
207
278
|
<svg
|
|
208
279
|
fill="none"
|
|
209
280
|
stroke="currentColor"
|
|
@@ -218,14 +289,15 @@ const AuthSuccess: React.FC<AuthSuccessInlineProps> = memo(function AuthSuccess(
|
|
|
218
289
|
/>
|
|
219
290
|
</svg>
|
|
220
291
|
</div>
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
292
|
+
|
|
293
|
+
<div className="auth-success-copy">
|
|
294
|
+
<p className="auth-success-text">{message}</p>
|
|
295
|
+
{/* Says what the wait is for. Without it the screen states an
|
|
296
|
+
outcome and then moves on its own, which reads as a glitch. */}
|
|
297
|
+
{redirecting && <p className="auth-success-hint">{redirecting}</p>}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
228
300
|
</div>
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
301
|
+
);
|
|
302
|
+
},
|
|
303
|
+
);
|
|
@@ -12,15 +12,46 @@ Shell-based authentication layout with three visual variants:
|
|
|
12
12
|
floating over the `background` image; on mobile it collapses to a plain,
|
|
13
13
|
frameless centered form (no image, no glass, no sidebar).
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
variants; the `sidebar` slot should carry the quote/illustration, not a second
|
|
17
|
-
brand mark.
|
|
15
|
+
## Where the brand goes
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
**One brand per screen, placed where there is room for it.** Pass `logo`; the
|
|
18
|
+
layout decides.
|
|
19
|
+
|
|
20
|
+
| Variant | Desktop | Below 1024px |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `fullsplit` | on the photo half (`AuthBrandPanel`) | above the form |
|
|
23
|
+
| `centered`, `split` | above the form | above the form |
|
|
24
|
+
|
|
25
|
+
On `fullsplit` the media half is a full-height photograph, and its content is
|
|
26
|
+
the `sidebar` slot. When an app passes `logo` and **no** `sidebar`, the layout
|
|
27
|
+
supplies `AuthBrandPanel` itself and the form header stands its logo down — so
|
|
28
|
+
the photo carries the brand and the form carries only the task. An explicit
|
|
29
|
+
`sidebar` always wins and the logo stays above the form.
|
|
30
|
+
|
|
31
|
+
> This default exists because the slot was previously the consumer's problem
|
|
32
|
+
> alone: four of the nine apps mounting this layout passed `logo` + `background`
|
|
33
|
+
> and no `sidebar`, rendering a large photograph with nothing on it while the
|
|
34
|
+
> brand crowded the form opposite. Every Storybook story supplied a sidebar of
|
|
35
|
+
> its own, so the shipped configuration was the one never reviewed. See
|
|
36
|
+
> `FullSplitNoSidebar` in `AuthFlow.stories.tsx`.
|
|
37
|
+
|
|
38
|
+
Below 1024px `fullsplit` drops the media half, so the logo above the form is
|
|
39
|
+
the only brand left and it returns. CSS owns that half of the rule
|
|
40
|
+
(`.auth-header[data-brand-elsewhere]`) — a JS width branch would need a
|
|
41
|
+
viewport the server does not have and would flicker on hydration.
|
|
42
|
+
|
|
43
|
+
`logo` is a **`ReactNode`** — an inline-SVG brand component, an `<img>`, a
|
|
44
|
+
horizontal lock-up. **The slot fits the artwork, not the reverse:** it caps the
|
|
45
|
+
height (44px in the form header, 2.5rem on the brand panel) and lets the width
|
|
46
|
+
follow, so a square mark and a wide lock-up both keep their aspect ratio. Use a
|
|
47
|
+
`currentColor` SVG and it inherits the surrounding text color — no per-theme
|
|
48
|
+
asset swap.
|
|
49
|
+
|
|
50
|
+
> Until this was fixed the slot was a hard 56×56 square with `width: 100%` on
|
|
51
|
+
> the child, which silently assumed every brand is square. A horizontal lock-up
|
|
52
|
+
> (mark plus two lines of type) was squeezed into the square and rendered as a
|
|
53
|
+
> scramble — visible in carapis, invisible in mls, whose mark happens to be
|
|
54
|
+
> square.
|
|
24
55
|
|
|
25
56
|
```tsx
|
|
26
57
|
<AuthLayout logo={<BrandMark className="text-foreground" />} … />
|
|
@@ -98,12 +129,12 @@ import { AuthLayout } from '@djangocfg/layouts';
|
|
|
98
129
|
| `variant` | `'centered' \| 'split' \| 'fullsplit'` | `'fullsplit'` | Shell layout variant |
|
|
99
130
|
| `mediaSide` | `'left' \| 'right'` | `'left'` | Which side the image half sits on (`fullsplit` only) |
|
|
100
131
|
| `background` | `AuthBackgroundConfig` | — | Background image/gradient/overlay/blur |
|
|
101
|
-
| `sidebar` | `ReactNode` | — |
|
|
132
|
+
| `sidebar` | `ReactNode` | — | Media/side column content. On `fullsplit`, omitting it with `logo` set yields the default `AuthBrandPanel` |
|
|
102
133
|
| `sourceUrl` | `string` | — | App URL for analytics/tracking |
|
|
103
134
|
| `redirectUrl` | `string` | `/private` | Where to redirect after auth |
|
|
104
135
|
| `enableGithubAuth` | `boolean` | `false` | Show GitHub OAuth button |
|
|
105
136
|
| `enablePhoneAuth` | `boolean` | `false` | Allow phone number input |
|
|
106
|
-
| `logo` | `ReactNode` | — |
|
|
137
|
+
| `logo` | `ReactNode` | — | Brand node. Placed by variant (see [Where the brand goes](#where-the-brand-goes)); the slot caps its height and keeps its aspect ratio |
|
|
107
138
|
| `termsUrl` | `string` | — | Terms link — rendered in the passive consent line (opens in a new tab) |
|
|
108
139
|
| `privacyUrl` | `string` | — | Privacy link — rendered in the passive consent line (opens in a new tab) |
|
|
109
140
|
| `supportUrl` | `string` | — | Support page link |
|
|
@@ -139,7 +170,7 @@ The sign-in flow walks these steps:
|
|
|
139
170
|
| `identifier` | Email or phone input |
|
|
140
171
|
| `otp` | 4-digit email OTP code entry |
|
|
141
172
|
| `2fa` | TOTP or backup code verification (only if the account already has 2FA) |
|
|
142
|
-
| `success` | Full-screen
|
|
173
|
+
| `success` | Full-screen confirmation → redirect (see below) |
|
|
143
174
|
|
|
144
175
|
`children` (custom header) are only rendered on the `identifier` step — hidden on all others.
|
|
145
176
|
|
|
@@ -147,6 +178,63 @@ The sign-in flow walks these steps:
|
|
|
147
178
|
> transitions to it. That value backs the standalone setup UI (`SetupStep` /
|
|
148
179
|
> `SetupStepStandalone`) which **ProfileLayout** reuses for configuring 2FA.
|
|
149
180
|
|
|
181
|
+
## The success screen
|
|
182
|
+
|
|
183
|
+
A checkmark, the outcome, and what the wait is for:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
✓
|
|
187
|
+
Signed in successfully
|
|
188
|
+
Taking you in…
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The mark is **always** the checkmark, never the brand. Rendering the logo
|
|
192
|
+
instead (the old behaviour when `logo` was set) produced a screen that never
|
|
193
|
+
actually said the sign-in worked — it read as a splash screen, which is what a
|
|
194
|
+
sign-in has just finished not being. The second line names the redirect, so the
|
|
195
|
+
pause before navigation reads as intent rather than a stall.
|
|
196
|
+
|
|
197
|
+
The overlay is one `role="status"` live region, so a screen reader hears the
|
|
198
|
+
confirmation and the redirect notice together. Timings live in `constants.ts`
|
|
199
|
+
(`REDIRECT_DELAY`, `ANIMATION_START_DELAY`).
|
|
200
|
+
|
|
201
|
+
The markup is exported separately as **`AuthSuccessScreen`** — no timer, no
|
|
202
|
+
router — so it can be rendered and reviewed on its own:
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
<AuthSuccessScreen message="Signed in successfully" redirecting="Taking you in…" />
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
| Prop | Type | Description |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `message` | `string` | The outcome |
|
|
211
|
+
| `redirecting` | `string?` | What the pause is for; omitted renders nothing |
|
|
212
|
+
| `visible` | `boolean?` | Drives the group fade. Default `true` |
|
|
213
|
+
|
|
214
|
+
That split exists because the screen previously had **no story at all**: the
|
|
215
|
+
only component that could draw it also navigated away 1.5s later, so nobody
|
|
216
|
+
could sit and look at it — which is how it kept showing the brand instead of a
|
|
217
|
+
confirmation. See `Success` in `AuthFlow.stories.tsx`.
|
|
218
|
+
|
|
219
|
+
## Errors
|
|
220
|
+
|
|
221
|
+
The form never shows a raw transport string. `@djangocfg/api`'s
|
|
222
|
+
`resolveAuthError` (`auth/utils/errors.ts`) decides the copy in one place:
|
|
223
|
+
|
|
224
|
+
1. **Text the server wrote** (`error` / `detail` / `message`) — it knows the
|
|
225
|
+
specific reason a status code cannot express ("This code has expired").
|
|
226
|
+
2. **Copy chosen from the status code**, translated, via keys that already
|
|
227
|
+
exist in all 17 locales (`api.errors.*`, `ui.errors.*`).
|
|
228
|
+
3. A generic sentence.
|
|
229
|
+
|
|
230
|
+
`HTTP <code>:` strings are rejected on the way through: that is `APIError`
|
|
231
|
+
restating its own status line, and under HTTP/2 `statusText` is empty — which
|
|
232
|
+
is how a bare `HTTP 404:` once rendered under a sign-in field. The code still
|
|
233
|
+
reaches the console through `logAuthFailure`, where diagnosis belongs.
|
|
234
|
+
|
|
235
|
+
The message renders directly beneath the input it concerns, not at the foot of
|
|
236
|
+
the form.
|
|
237
|
+
|
|
150
238
|
## Architecture
|
|
151
239
|
|
|
152
240
|
AuthLayout is built on a **shell pattern** (inspired by PublicLayout's navbar/footer slots):
|
|
@@ -162,6 +250,7 @@ AuthLayout/
|
|
|
162
250
|
│ └── types.ts — AuthShellVariant, AuthBackgroundConfig
|
|
163
251
|
├── components/steps/ — Auth step components (IdentifierStep, OTPStep, ...)
|
|
164
252
|
├── components/shared/ — Reusable UI primitives (AuthButton, AuthConsent, ...)
|
|
253
|
+
│ incl. AuthBrandPanel — the default `fullsplit` media content
|
|
165
254
|
├── styles/
|
|
166
255
|
│ ├── auth.css — Shared form/button/input styles
|
|
167
256
|
│ ├── centered-shell.css — Centered variant layout
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { memo } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface AuthBrandPanelProps {
|
|
6
|
+
/** The brand node the app already passes to `AuthLayout` as `logo`. */
|
|
7
|
+
logo: React.ReactNode;
|
|
8
|
+
/** One line under the mark. Omitted by default — the photo carries the mood. */
|
|
9
|
+
tagline?: React.ReactNode;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AuthBrandPanel — what the `fullsplit` media half shows when the app gives it
|
|
15
|
+
* nothing.
|
|
16
|
+
*
|
|
17
|
+
* WHY A DEFAULT EXISTS
|
|
18
|
+
*
|
|
19
|
+
* `fullsplit` is the default variant and its left half is a full-height photo.
|
|
20
|
+
* The content over that photo is the `sidebar` slot, which the package left
|
|
21
|
+
* entirely to the consumer — so an app passing `logo` + `background` and no
|
|
22
|
+
* `sidebar` rendered a large photograph with nothing on it, while the brand
|
|
23
|
+
* crowded the form column opposite. Four of the nine call sites were in exactly
|
|
24
|
+
* that state; Storybook never showed it because the story harness supplied a
|
|
25
|
+
* sidebar of its own, so the broken configuration had no story.
|
|
26
|
+
*
|
|
27
|
+
* The brand belongs here rather than above the form: the photo half is the
|
|
28
|
+
* half with room, and a sign-in form reads fastest when it carries only the
|
|
29
|
+
* task. An app that wants something richer (a quote, a testimonial) still
|
|
30
|
+
* passes its own `sidebar` and this never renders.
|
|
31
|
+
*
|
|
32
|
+
* Text is light because it sits on the scrim `fullsplit-shell.css` paints over
|
|
33
|
+
* the photo, not on a theme surface — so it does not follow `--foreground`.
|
|
34
|
+
*/
|
|
35
|
+
function AuthBrandPanelRaw({ logo, tagline, className = '' }: AuthBrandPanelProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={`auth-brand-panel ${className}`}>
|
|
38
|
+
<span className="auth-brand-panel__mark">{logo}</span>
|
|
39
|
+
{tagline && <p className="auth-brand-panel__tagline">{tagline}</p>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const AuthBrandPanel = memo(AuthBrandPanelRaw);
|
|
@@ -7,6 +7,14 @@ export interface AuthHeaderProps {
|
|
|
7
7
|
title: string;
|
|
8
8
|
subtitle?: string;
|
|
9
9
|
identifier?: string;
|
|
10
|
+
/**
|
|
11
|
+
* The brand is already rendered elsewhere on this screen (the `fullsplit`
|
|
12
|
+
* media half). The logo stays in the markup and CSS hides it at the width
|
|
13
|
+
* where that other half is actually visible — see `.auth-header` in
|
|
14
|
+
* `auth.css`. Rendering it conditionally in JS instead would need a width
|
|
15
|
+
* the server does not have, and the brand would flicker on hydration.
|
|
16
|
+
*/
|
|
17
|
+
brandElsewhere?: boolean;
|
|
10
18
|
className?: string;
|
|
11
19
|
}
|
|
12
20
|
|
|
@@ -15,16 +23,19 @@ export interface AuthHeaderProps {
|
|
|
15
23
|
*
|
|
16
24
|
* `logo` is any React node (inline-SVG brand component, `<img>`, …), rendered
|
|
17
25
|
* inside an `.auth-logo` wrapper so the existing logo sizing/spacing applies.
|
|
26
|
+
* The slot fits the artwork rather than the reverse: a square mark and a
|
|
27
|
+
* horizontal lock-up both keep their aspect ratio.
|
|
18
28
|
*/
|
|
19
29
|
function AuthHeaderRaw({
|
|
20
30
|
logo,
|
|
21
31
|
title,
|
|
22
32
|
subtitle,
|
|
23
33
|
identifier,
|
|
34
|
+
brandElsewhere = false,
|
|
24
35
|
className = '',
|
|
25
36
|
}: AuthHeaderProps) {
|
|
26
37
|
return (
|
|
27
|
-
<div className={`auth-header ${className}`}>
|
|
38
|
+
<div className={`auth-header ${className}`} data-brand-elsewhere={brandElsewhere}>
|
|
28
39
|
{logo && (
|
|
29
40
|
<span className="auth-logo" aria-hidden="true">
|
|
30
41
|
{logo}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { AuthBrandPanel } from './AuthBrandPanel';
|
|
1
2
|
export { AuthContainer } from './AuthContainer';
|
|
2
3
|
export { AuthHeader } from './AuthHeader';
|
|
3
4
|
export { AuthFooter } from './AuthFooter';
|
|
@@ -9,6 +10,7 @@ export { AuthOTPInput } from './AuthOTPInput';
|
|
|
9
10
|
export { WebmailIcon } from './WebmailIcon';
|
|
10
11
|
export { AuthConsent } from './AuthConsent';
|
|
11
12
|
|
|
13
|
+
export type { AuthBrandPanelProps } from './AuthBrandPanel';
|
|
12
14
|
export type { AuthContainerProps } from './AuthContainer';
|
|
13
15
|
export type { AuthHeaderProps } from './AuthHeader';
|
|
14
16
|
export type { AuthFooterProps } from './AuthFooter';
|
|
@@ -22,8 +22,8 @@ import {
|
|
|
22
22
|
* IdentifierStep - Apple-style email input step.
|
|
23
23
|
*
|
|
24
24
|
* Clean, minimal design with:
|
|
25
|
-
* - Optional logo
|
|
26
|
-
* - Single email input field
|
|
25
|
+
* - Optional logo, stood down on desktop when the media half carries the brand
|
|
26
|
+
* - Single email input field, with its error directly beneath it
|
|
27
27
|
* - Passive consent line (continuing = consent; no opt-in checkbox)
|
|
28
28
|
* - OAuth options
|
|
29
29
|
*
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
* fields change.
|
|
35
35
|
*/
|
|
36
36
|
function IdentifierStepRaw() {
|
|
37
|
-
const { hideHeader } = useAuthLayoutContext();
|
|
37
|
+
const { hideHeader, hasBrandPanel } = useAuthLayoutContext();
|
|
38
38
|
|
|
39
39
|
const {
|
|
40
40
|
identifier,
|
|
@@ -109,7 +109,14 @@ function IdentifierStepRaw() {
|
|
|
109
109
|
|
|
110
110
|
return (
|
|
111
111
|
<AuthContainer step="identifier">
|
|
112
|
-
{!hideHeader &&
|
|
112
|
+
{!hideHeader && (
|
|
113
|
+
<AuthHeader
|
|
114
|
+
logo={logo}
|
|
115
|
+
brandElsewhere={hasBrandPanel}
|
|
116
|
+
title={content.title}
|
|
117
|
+
subtitle={content.subtitle.email}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
113
120
|
|
|
114
121
|
<form onSubmit={handleIdentifierSubmit} className="auth-form-group">
|
|
115
122
|
<Input
|
|
@@ -128,6 +135,8 @@ function IdentifierStepRaw() {
|
|
|
128
135
|
className="auth-input"
|
|
129
136
|
/>
|
|
130
137
|
|
|
138
|
+
<AuthError message={error} />
|
|
139
|
+
|
|
131
140
|
{/* Both opt-in rows share `auth-optin` — same control, same weight. */}
|
|
132
141
|
{consentEnabled && (
|
|
133
142
|
<label className="auth-optin">
|
|
@@ -161,8 +170,6 @@ function IdentifierStepRaw() {
|
|
|
161
170
|
</label>
|
|
162
171
|
)}
|
|
163
172
|
|
|
164
|
-
<AuthError message={error} />
|
|
165
|
-
|
|
166
173
|
<AuthButton
|
|
167
174
|
loading={isLoading}
|
|
168
175
|
disabled={!identifier || isRateLimited}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
// Main layout
|
|
6
|
-
export { AuthLayout, useAuthLayoutContext } from './AuthLayout';
|
|
6
|
+
export { AuthLayout, AuthSuccessScreen, useAuthLayoutContext } from './AuthLayout';
|
|
7
|
+
export type { AuthSuccessScreenProps } from './AuthLayout';
|
|
7
8
|
|
|
8
9
|
// Context and hooks
|
|
9
10
|
export { AuthFormProvider, useAuthFormContext } from './context';
|
|
@@ -14,25 +14,35 @@
|
|
|
14
14
|
--auth-radius: 0.75rem;
|
|
15
15
|
--auth-radius-sm: 0.5rem;
|
|
16
16
|
--auth-radius-xs: 0.375rem;
|
|
17
|
+
/* The single vertical step of the auth column. */
|
|
18
|
+
--auth-gap: 1rem;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/* ===== CONTAINER ===== */
|
|
20
22
|
|
|
23
|
+
/* One gap governs the column. Two competing scales (1.25rem between blocks,
|
|
24
|
+
0.875rem inside the form) made the stack pulse unevenly — the header sat
|
|
25
|
+
further from the field than the field sat from the button, so nothing read
|
|
26
|
+
as grouped. Blocks that want more room ask for it themselves, below. */
|
|
21
27
|
.auth-container {
|
|
22
28
|
width: 100%;
|
|
23
29
|
max-width: 380px;
|
|
24
30
|
display: flex;
|
|
25
31
|
flex-direction: column;
|
|
26
|
-
gap:
|
|
32
|
+
gap: var(--auth-gap);
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/* Meta row at the top of the form — currently hosts the locale picker.
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
It is the first thing in the column but the last thing anyone came here to
|
|
37
|
+
do, so it is pulled out of the reading order by SIZE and alignment. Not by
|
|
38
|
+
contrast: `--muted-foreground` is the dimmest token that still clears AA,
|
|
39
|
+
and fading it toward transparent fails the contract gate — which is what
|
|
40
|
+
happened to the consent line below. */
|
|
32
41
|
.auth-container__meta {
|
|
33
42
|
display: flex;
|
|
34
43
|
justify-content: flex-end;
|
|
35
|
-
|
|
44
|
+
/* Lifted clear of the column's own gap — a utility chip, not a first row. */
|
|
45
|
+
margin-bottom: calc(var(--auth-gap) * -1);
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
.auth-locale-trigger {
|
|
@@ -40,8 +50,10 @@
|
|
|
40
50
|
padding-inline: 0.5rem;
|
|
41
51
|
font-size: 0.75rem;
|
|
42
52
|
color: var(--muted-foreground);
|
|
53
|
+
transition: color 0.15s var(--spring-snappy);
|
|
43
54
|
}
|
|
44
|
-
.auth-locale-trigger:hover
|
|
55
|
+
.auth-locale-trigger:hover,
|
|
56
|
+
.auth-locale-trigger:focus-visible {
|
|
45
57
|
color: var(--foreground);
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -62,29 +74,45 @@
|
|
|
62
74
|
|
|
63
75
|
/* ===== HEADER ===== */
|
|
64
76
|
|
|
77
|
+
/* The header is the one block that earns extra space: it separates the brand
|
|
78
|
+
and the question from the controls that answer it. */
|
|
65
79
|
.auth-header {
|
|
66
80
|
text-align: center;
|
|
67
81
|
display: flex;
|
|
68
82
|
flex-direction: column;
|
|
69
83
|
align-items: center;
|
|
70
84
|
gap: 0.375rem;
|
|
85
|
+
margin-bottom: 0.5rem;
|
|
71
86
|
}
|
|
72
87
|
|
|
88
|
+
/* The brand is on the media half, which only exists from 1024px up — so this
|
|
89
|
+
is the same breakpoint `fullsplit-shell.css` reveals that half at. Below it
|
|
90
|
+
the logo above the form is the only brand on screen and stays. */
|
|
91
|
+
@media (min-width: 1024px) {
|
|
92
|
+
.auth-header[data-brand-elsewhere="true"] .auth-logo {
|
|
93
|
+
display: none;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* The slot caps the HEIGHT and lets the width follow the artwork.
|
|
98
|
+
It used to be a hard 56×56 square with `width: 100%` on the child, which
|
|
99
|
+
silently assumed every brand is square: a horizontal lock-up (mark + two
|
|
100
|
+
lines of type) was squeezed into the square and rendered as a scramble.
|
|
101
|
+
A square mark still comes out square — `max-width` only bounds it. */
|
|
73
102
|
.auth-logo {
|
|
74
|
-
/* The slot owns the size; the logo node (inline-SVG/<img>) fills it. */
|
|
75
103
|
display: inline-flex;
|
|
76
104
|
align-items: center;
|
|
77
105
|
justify-content: center;
|
|
78
|
-
|
|
79
|
-
|
|
106
|
+
height: 44px;
|
|
107
|
+
max-width: 100%;
|
|
80
108
|
margin-bottom: 0.75rem;
|
|
81
|
-
border-radius: 14px;
|
|
82
109
|
animation: authLogoIn 0.5s var(--spring-bounce) both;
|
|
83
110
|
}
|
|
84
111
|
|
|
85
112
|
.auth-logo > * {
|
|
86
|
-
width:
|
|
113
|
+
width: auto;
|
|
87
114
|
height: 100%;
|
|
115
|
+
max-width: 100%;
|
|
88
116
|
object-fit: contain;
|
|
89
117
|
}
|
|
90
118
|
|
|
@@ -125,7 +153,7 @@
|
|
|
125
153
|
.auth-form-group {
|
|
126
154
|
display: flex;
|
|
127
155
|
flex-direction: column;
|
|
128
|
-
gap:
|
|
156
|
+
gap: var(--auth-gap);
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
.auth-input {
|
|
@@ -333,10 +361,17 @@
|
|
|
333
361
|
|
|
334
362
|
/* ===== ERROR ===== */
|
|
335
363
|
|
|
364
|
+
/* Sits directly under the input it concerns, so it reads as that field's
|
|
365
|
+
answer rather than a notice floating in the form. `align-items: flex-start`
|
|
366
|
+
keeps the icon on the first line when the message wraps.
|
|
367
|
+
|
|
368
|
+
The negative top margin pulls it inside the field's own gap: an error is
|
|
369
|
+
part of the control, not the next block down. */
|
|
336
370
|
.auth-error {
|
|
337
371
|
display: flex;
|
|
338
|
-
align-items:
|
|
372
|
+
align-items: flex-start;
|
|
339
373
|
gap: 0.375rem;
|
|
374
|
+
margin-top: calc(var(--auth-gap) * -0.5);
|
|
340
375
|
font-size: 0.8125rem;
|
|
341
376
|
line-height: 1.4;
|
|
342
377
|
color: var(--destructive);
|
|
@@ -384,10 +419,21 @@
|
|
|
384
419
|
|
|
385
420
|
/* ===== CONSENT (passive line under the submit button) ===== */
|
|
386
421
|
|
|
422
|
+
/* Quieter than the opt-in row above it, deliberately. Both were 0.75rem in
|
|
423
|
+
`--muted-foreground`, so a checkbox the visitor must decide about and a
|
|
424
|
+
legal line they need not read carried identical weight — with an error
|
|
425
|
+
between them the block read as three equal notes.
|
|
426
|
+
|
|
427
|
+
The step down is SIZE, not contrast. Fading the colour instead
|
|
428
|
+
(`--muted-foreground` at 70%) dropped it to 3.88:1 against the dark surface
|
|
429
|
+
and the a11y contract gate rejected it: `--muted-foreground` is already the
|
|
430
|
+
dimmest text token that clears AA, so anything mixed toward transparent
|
|
431
|
+
fails by construction. Legal copy is exactly the text that must stay
|
|
432
|
+
readable. */
|
|
387
433
|
.auth-consent {
|
|
388
|
-
margin: 0;
|
|
434
|
+
margin: calc(var(--auth-gap) * -0.25) 0 0;
|
|
389
435
|
text-align: center;
|
|
390
|
-
font-size: 0.
|
|
436
|
+
font-size: 0.6875rem;
|
|
391
437
|
line-height: 1.5;
|
|
392
438
|
color: var(--muted-foreground);
|
|
393
439
|
}
|
|
@@ -580,39 +626,36 @@
|
|
|
580
626
|
to { opacity: 1; }
|
|
581
627
|
}
|
|
582
628
|
|
|
629
|
+
/* One fade owns the whole group, via `data-visible`. Each element used to
|
|
630
|
+
carry its own inline `style={{opacity}}`, which put presentation in the
|
|
631
|
+
component and meant the mark, the message and the hint could drift apart. */
|
|
583
632
|
.auth-success-content {
|
|
584
633
|
display: flex;
|
|
585
634
|
flex-direction: column;
|
|
586
635
|
align-items: center;
|
|
587
636
|
gap: 1.25rem;
|
|
637
|
+
opacity: 0;
|
|
638
|
+
transition: opacity 0.35s var(--spring-smooth);
|
|
588
639
|
}
|
|
589
640
|
|
|
590
|
-
.auth-success-
|
|
591
|
-
|
|
592
|
-
display: inline-flex;
|
|
593
|
-
align-items: center;
|
|
594
|
-
justify-content: center;
|
|
595
|
-
width: 72px;
|
|
596
|
-
height: 72px;
|
|
597
|
-
border-radius: 16px;
|
|
598
|
-
animation: authSuccessIn 0.5s var(--spring-bounce) both;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
.auth-success-logo > * {
|
|
602
|
-
width: 100%;
|
|
603
|
-
height: 100%;
|
|
604
|
-
object-fit: contain;
|
|
641
|
+
.auth-success-content[data-visible="true"] {
|
|
642
|
+
opacity: 1;
|
|
605
643
|
}
|
|
606
644
|
|
|
607
645
|
.auth-success-check {
|
|
608
|
-
width:
|
|
609
|
-
height:
|
|
646
|
+
width: 64px;
|
|
647
|
+
height: 64px;
|
|
610
648
|
display: flex;
|
|
611
649
|
align-items: center;
|
|
612
650
|
justify-content: center;
|
|
613
|
-
|
|
651
|
+
/* The semantic token, not a hardcoded green: the old literal
|
|
652
|
+
`hsl(142 76% 36%)` was the one colour on this screen that could not be
|
|
653
|
+
re-themed and did not move between light and dark. `--success` is defined
|
|
654
|
+
in both themes (ui-core `theme/light.css`, `theme/dark.css`), so no
|
|
655
|
+
fallback — a missing token should surface, not be papered over. */
|
|
656
|
+
background: color-mix(in oklab, var(--success) 12%, transparent);
|
|
614
657
|
border-radius: 50%;
|
|
615
|
-
color:
|
|
658
|
+
color: var(--success);
|
|
616
659
|
animation: authSuccessIn 0.5s var(--spring-bounce) both;
|
|
617
660
|
}
|
|
618
661
|
|
|
@@ -628,15 +671,32 @@
|
|
|
628
671
|
}
|
|
629
672
|
|
|
630
673
|
.auth-success-check svg {
|
|
631
|
-
width:
|
|
632
|
-
height:
|
|
674
|
+
width: 32px;
|
|
675
|
+
height: 32px;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* Outcome first, then what the wait is for — two weights, one block, so the
|
|
679
|
+
pair reads as a single statement rather than two floating lines. */
|
|
680
|
+
.auth-success-copy {
|
|
681
|
+
display: flex;
|
|
682
|
+
flex-direction: column;
|
|
683
|
+
align-items: center;
|
|
684
|
+
gap: 0.25rem;
|
|
685
|
+
text-align: center;
|
|
633
686
|
}
|
|
634
687
|
|
|
635
688
|
.auth-success-text {
|
|
636
|
-
|
|
637
|
-
font-
|
|
689
|
+
margin: 0;
|
|
690
|
+
font-size: 1.0625rem;
|
|
691
|
+
font-weight: 600;
|
|
692
|
+
letter-spacing: -0.01em;
|
|
693
|
+
color: var(--foreground);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.auth-success-hint {
|
|
697
|
+
margin: 0;
|
|
698
|
+
font-size: 0.8125rem;
|
|
638
699
|
color: var(--muted-foreground);
|
|
639
|
-
animation: authFadeIn 0.4s 0.15s var(--spring-smooth) both;
|
|
640
700
|
}
|
|
641
701
|
|
|
642
702
|
/* ===== 2FA BOX ===== */
|
|
@@ -85,6 +85,50 @@
|
|
|
85
85
|
color: #fff;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/* ── Default brand content over the photo ──────────────────────────────── */
|
|
89
|
+
|
|
90
|
+
/* The layout's own media-half content, used when the app passes `logo` but no
|
|
91
|
+
`sidebar` (see AuthBrandPanel). It occupies the TOP of the sidebar's
|
|
92
|
+
space-between column, leaving the bottom free for whatever an app adds
|
|
93
|
+
later; on its own it reads as a mark set on a photograph.
|
|
94
|
+
|
|
95
|
+
Colour is a literal, and deliberately so. Theme tokens answer "what colour
|
|
96
|
+
is text on OUR surface"; this text is on the consumer's photograph, which
|
|
97
|
+
the scrim below always darkens. `--foreground` would invert to near-black
|
|
98
|
+
in light mode and vanish into the picture. ui-core's styles README allows
|
|
99
|
+
raw values exactly here — branded assets and explicitly documented cases —
|
|
100
|
+
and there is no `--on-media` token to reach for. */
|
|
101
|
+
.auth-brand-panel {
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
gap: 0.75rem;
|
|
105
|
+
color: #fff;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.auth-brand-panel__mark {
|
|
109
|
+
display: inline-flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
height: 2.5rem;
|
|
112
|
+
max-width: 100%;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Same contract as `.auth-logo`: cap the height, let the width follow the
|
|
116
|
+
artwork, so a horizontal lock-up is not squeezed into a square. */
|
|
117
|
+
.auth-brand-panel__mark > * {
|
|
118
|
+
width: auto;
|
|
119
|
+
height: 100%;
|
|
120
|
+
max-width: 100%;
|
|
121
|
+
object-fit: contain;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.auth-brand-panel__tagline {
|
|
125
|
+
margin: 0;
|
|
126
|
+
max-width: 24ch;
|
|
127
|
+
font-size: 1.125rem;
|
|
128
|
+
line-height: 1.45;
|
|
129
|
+
color: hsl(0 0% 100% / 0.82);
|
|
130
|
+
}
|
|
131
|
+
|
|
88
132
|
/* ── Form half (right) ─────────────────────────────────────────────────── */
|
|
89
133
|
.auth-shell-fullsplit__form {
|
|
90
134
|
flex: 1;
|
package/src/testing/index.ts
CHANGED
|
@@ -20,6 +20,14 @@ export type { AuthStep } from '@djangocfg/api/auth';
|
|
|
20
20
|
// directly under MockAuthFormProvider without going through the full
|
|
21
21
|
// AuthLayout (which mounts its own real AuthFormProvider).
|
|
22
22
|
export { AuthShell } from '../layouts/AuthLayout/shells';
|
|
23
|
+
// Lets a story declare the same layout-level facts AuthLayout would (e.g. that
|
|
24
|
+
// the brand is already on the media half), instead of silently getting the
|
|
25
|
+
// context defaults.
|
|
26
|
+
export { AuthLayoutProvider, AuthSuccessScreen } from '../layouts/AuthLayout/AuthLayout';
|
|
27
|
+
// The layout's own default media-half content. Stories mount `AuthShell`
|
|
28
|
+
// directly, so they must supply this themselves to preview what an app that
|
|
29
|
+
// passes no `sidebar` actually gets.
|
|
30
|
+
export { AuthBrandPanel } from '../layouts/AuthLayout/components/shared';
|
|
23
31
|
export {
|
|
24
32
|
IdentifierStep,
|
|
25
33
|
OTPStep,
|