@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.1.567",
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.567",
94
- "@djangocfg/api": "^2.1.567",
95
- "@djangocfg/devtools": "^2.1.567",
96
- "@djangocfg/i18n": "^2.1.567",
97
- "@djangocfg/ui-core": "^2.1.567",
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.567",
128
- "@djangocfg/api": "^2.1.567",
129
- "@djangocfg/devtools": "^2.1.567",
130
- "@djangocfg/i18n": "^2.1.567",
131
- "@djangocfg/typescript-config": "^2.1.567",
132
- "@djangocfg/ui-core": "^2.1.567",
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 two variants:
5
- * - `centered` (default) — Apple-style frameless, centered, glow background
6
- * - `split` — Two-column on desktop (form + sidebar), single-column on mobile
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>({ hideHeader: false });
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
- const layoutContextValue = useMemo(() => ({ hideHeader }), [hideHeader]);
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={sidebar} className={className}>
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, logo, redirectUrl } = useAuthFormContext();
196
+ const { step, redirectUrl } = useAuthFormContext();
150
197
 
151
198
  if (step !== 'success') {
152
199
  return null;
153
200
  }
154
201
 
155
- return <AuthSuccess logo={logo} redirectUrl={redirectUrl} />;
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 successMessage = React.useMemo(() => t('layouts.auth.success.message'), [t]);
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
- <div className="auth-success-overlay">
194
- <div className="auth-success-content">
195
- {logo ? (
196
- <span
197
- className="auth-success-logo"
198
- style={{ opacity: isVisible ? 1 : 0 }}
199
- >
200
- {logo}
201
- </span>
202
- ) : (
203
- <div
204
- className="auth-success-check"
205
- style={{ opacity: isVisible ? 1 : 0 }}
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
- <p
223
- className="auth-success-text"
224
- style={{ opacity: isVisible ? 1 : 0 }}
225
- >
226
- {successMessage}
227
- </p>
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
- </div>
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
- The logo (when `logo` is set) always renders above the form across all
16
- variants; the `sidebar` slot should carry the quote/illustration, not a second
17
- brand mark.
15
+ ## Where the brand goes
18
16
 
19
- `logo` is a **`ReactNode`** — pass an inline-SVG brand component, an `<img>`,
20
- whatever. The layout owns the size: the node is dropped into a fixed slot
21
- (`.auth-logo`, 56px; `.auth-success-logo`, 72px) and stretched to fill it
22
- (`width/height: 100%`), so a `currentColor` SVG inherits the slot's size and
23
- the surrounding text color — no per-theme asset swap needed.
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` | — | Right column content (split variant only) |
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` | — | Logo node shown above the form and on the success screen. Fills its slot (the layout sizes it); use a `currentColor` SVG for theme adaptivity |
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 success overlay → redirect |
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 (hidden in the split variant — sidebar carries the brand)
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 && <AuthHeader logo={logo} title={content.title} subtitle={content.subtitle.email} />}
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: 1.25rem;
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
- Right-aligned, tight, not eating much vertical space; feels like a
31
- tab/utility chip rather than a floating control. */
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
- margin-bottom: -0.5rem;
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
- width: 56px;
79
- height: 56px;
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: 100%;
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: 0.875rem;
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: center;
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.75rem;
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-logo {
591
- /* The slot owns the size; the logo node fills it. */
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: 72px;
609
- height: 72px;
646
+ width: 64px;
647
+ height: 64px;
610
648
  display: flex;
611
649
  align-items: center;
612
650
  justify-content: center;
613
- background: hsl(142 76% 36% / 0.1);
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: hsl(142 76% 36%);
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: 36px;
632
- height: 36px;
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
- font-size: 0.9375rem;
637
- font-weight: 500;
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;
@@ -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,