@djangocfg/layouts 2.1.567 → 2.1.570

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.570",
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.570",
94
+ "@djangocfg/api": "^2.1.570",
95
+ "@djangocfg/devtools": "^2.1.570",
96
+ "@djangocfg/i18n": "^2.1.570",
97
+ "@djangocfg/ui-core": "^2.1.570",
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.570",
128
+ "@djangocfg/api": "^2.1.570",
129
+ "@djangocfg/devtools": "^2.1.570",
130
+ "@djangocfg/i18n": "^2.1.570",
131
+ "@djangocfg/typescript-config": "^2.1.570",
132
+ "@djangocfg/ui-core": "^2.1.570",
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,57 @@ 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
+ **The mark is a link to `/`** — the way out. A sign-in screen is otherwise a
44
+ dead end: arrive by a direct link, or get bounced here by an expired session,
45
+ and there is no route back to the site (Back has nowhere to return to either).
46
+ There is no prop for it: every app has a root, and a localized app resolves `/`
47
+ through next-intl's own `Link`, so `/ru/auth` returns to `/ru`. The href lives
48
+ in `constants.ts` as `AUTH.HOME_URL`.
49
+
50
+ The link carries `aria-label` from `layouts.navigation.home` rather than
51
+ relying on its contents: `logo` is an opaque node, and a brand that is a bare
52
+ SVG would otherwise announce as an unnamed link.
53
+
54
+ `logo` is a **`ReactNode`** — an inline-SVG brand component, an `<img>`, a
55
+ horizontal lock-up. **The slot fits the artwork, not the reverse:** it caps the
56
+ height (44px in the form header, 2.5rem on the brand panel) and lets the width
57
+ follow, so a square mark and a wide lock-up both keep their aspect ratio. Use a
58
+ `currentColor` SVG and it inherits the surrounding text color — no per-theme
59
+ asset swap.
60
+
61
+ > Until this was fixed the slot was a hard 56×56 square with `width: 100%` on
62
+ > the child, which silently assumed every brand is square. A horizontal lock-up
63
+ > (mark plus two lines of type) was squeezed into the square and rendered as a
64
+ > scramble — visible in carapis, invisible in mls, whose mark happens to be
65
+ > square.
24
66
 
25
67
  ```tsx
26
68
  <AuthLayout logo={<BrandMark className="text-foreground" />} … />
@@ -98,12 +140,12 @@ import { AuthLayout } from '@djangocfg/layouts';
98
140
  | `variant` | `'centered' \| 'split' \| 'fullsplit'` | `'fullsplit'` | Shell layout variant |
99
141
  | `mediaSide` | `'left' \| 'right'` | `'left'` | Which side the image half sits on (`fullsplit` only) |
100
142
  | `background` | `AuthBackgroundConfig` | — | Background image/gradient/overlay/blur |
101
- | `sidebar` | `ReactNode` | — | Right column content (split variant only) |
143
+ | `sidebar` | `ReactNode` | — | Media/side column content. On `fullsplit`, omitting it with `logo` set yields the default `AuthBrandPanel` |
102
144
  | `sourceUrl` | `string` | — | App URL for analytics/tracking |
103
145
  | `redirectUrl` | `string` | `/private` | Where to redirect after auth |
104
146
  | `enableGithubAuth` | `boolean` | `false` | Show GitHub OAuth button |
105
147
  | `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 |
148
+ | `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
149
  | `termsUrl` | `string` | — | Terms link — rendered in the passive consent line (opens in a new tab) |
108
150
  | `privacyUrl` | `string` | — | Privacy link — rendered in the passive consent line (opens in a new tab) |
109
151
  | `supportUrl` | `string` | — | Support page link |
@@ -139,7 +181,7 @@ The sign-in flow walks these steps:
139
181
  | `identifier` | Email or phone input |
140
182
  | `otp` | 4-digit email OTP code entry |
141
183
  | `2fa` | TOTP or backup code verification (only if the account already has 2FA) |
142
- | `success` | Full-screen success overlay → redirect |
184
+ | `success` | Full-screen confirmation → redirect (see below) |
143
185
 
144
186
  `children` (custom header) are only rendered on the `identifier` step — hidden on all others.
145
187
 
@@ -147,6 +189,63 @@ The sign-in flow walks these steps:
147
189
  > transitions to it. That value backs the standalone setup UI (`SetupStep` /
148
190
  > `SetupStepStandalone`) which **ProfileLayout** reuses for configuring 2FA.
149
191
 
192
+ ## The success screen
193
+
194
+ A checkmark, the outcome, and what the wait is for:
195
+
196
+ ```text
197
+ ✓
198
+ Signed in successfully
199
+ Taking you in…
200
+ ```
201
+
202
+ The mark is **always** the checkmark, never the brand. Rendering the logo
203
+ instead (the old behaviour when `logo` was set) produced a screen that never
204
+ actually said the sign-in worked — it read as a splash screen, which is what a
205
+ sign-in has just finished not being. The second line names the redirect, so the
206
+ pause before navigation reads as intent rather than a stall.
207
+
208
+ The overlay is one `role="status"` live region, so a screen reader hears the
209
+ confirmation and the redirect notice together. Timings live in `constants.ts`
210
+ (`REDIRECT_DELAY`, `ANIMATION_START_DELAY`).
211
+
212
+ The markup is exported separately as **`AuthSuccessScreen`** — no timer, no
213
+ router — so it can be rendered and reviewed on its own:
214
+
215
+ ```tsx
216
+ <AuthSuccessScreen message="Signed in successfully" redirecting="Taking you in…" />
217
+ ```
218
+
219
+ | Prop | Type | Description |
220
+ |---|---|---|
221
+ | `message` | `string` | The outcome |
222
+ | `redirecting` | `string?` | What the pause is for; omitted renders nothing |
223
+ | `visible` | `boolean?` | Drives the group fade. Default `true` |
224
+
225
+ That split exists because the screen previously had **no story at all**: the
226
+ only component that could draw it also navigated away 1.5s later, so nobody
227
+ could sit and look at it — which is how it kept showing the brand instead of a
228
+ confirmation. See `Success` in `AuthFlow.stories.tsx`.
229
+
230
+ ## Errors
231
+
232
+ The form never shows a raw transport string. `@djangocfg/api`'s
233
+ `resolveAuthError` (`auth/utils/errors.ts`) decides the copy in one place:
234
+
235
+ 1. **Text the server wrote** (`error` / `detail` / `message`) — it knows the
236
+ specific reason a status code cannot express ("This code has expired").
237
+ 2. **Copy chosen from the status code**, translated, via keys that already
238
+ exist in all 17 locales (`api.errors.*`, `ui.errors.*`).
239
+ 3. A generic sentence.
240
+
241
+ `HTTP <code>:` strings are rejected on the way through: that is `APIError`
242
+ restating its own status line, and under HTTP/2 `statusText` is empty — which
243
+ is how a bare `HTTP 404:` once rendered under a sign-in field. The code still
244
+ reaches the console through `logAuthFailure`, where diagnosis belongs.
245
+
246
+ The message renders directly beneath the input it concerns, not at the foot of
247
+ the form.
248
+
150
249
  ## Architecture
151
250
 
152
251
  AuthLayout is built on a **shell pattern** (inspired by PublicLayout's navbar/footer slots):
@@ -162,6 +261,7 @@ AuthLayout/
162
261
  │ └── types.ts — AuthShellVariant, AuthBackgroundConfig
163
262
  ├── components/steps/ — Auth step components (IdentifierStep, OTPStep, ...)
164
263
  ├── components/shared/ — Reusable UI primitives (AuthButton, AuthConsent, ...)
264
+ │ incl. AuthBrandPanel — the default `fullsplit` media content
165
265
  ├── styles/
166
266
  │ ├── auth.css — Shared form/button/input styles
167
267
  │ ├── centered-shell.css — Centered variant layout
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import React, { memo } from 'react';
4
+
5
+ import { useAppT } from '@djangocfg/i18n';
6
+ import { Link } from '@djangocfg/ui-core/components';
7
+
8
+ import { AUTH } from '../../constants';
9
+
10
+ export interface AuthBrandPanelProps {
11
+ /** The brand node the app already passes to `AuthLayout` as `logo`. */
12
+ logo: React.ReactNode;
13
+ /** One line under the mark. Omitted by default — the photo carries the mood. */
14
+ tagline?: React.ReactNode;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * AuthBrandPanel — what the `fullsplit` media half shows when the app gives it
20
+ * nothing.
21
+ *
22
+ * WHY A DEFAULT EXISTS
23
+ *
24
+ * `fullsplit` is the default variant and its left half is a full-height photo.
25
+ * The content over that photo is the `sidebar` slot, which the package left
26
+ * entirely to the consumer — so an app passing `logo` + `background` and no
27
+ * `sidebar` rendered a large photograph with nothing on it, while the brand
28
+ * crowded the form column opposite. Four of the nine call sites were in exactly
29
+ * that state; Storybook never showed it because the story harness supplied a
30
+ * sidebar of its own, so the broken configuration had no story.
31
+ *
32
+ * The brand belongs here rather than above the form: the photo half is the
33
+ * half with room, and a sign-in form reads fastest when it carries only the
34
+ * task. An app that wants something richer (a quote, a testimonial) still
35
+ * passes its own `sidebar` and this never renders.
36
+ *
37
+ * Text is light because it sits on the scrim `fullsplit-shell.css` paints over
38
+ * the photo, not on a theme surface — so it does not follow `--foreground`.
39
+ *
40
+ * The mark is the way OUT. A sign-in screen is otherwise a dead end: arrive by
41
+ * a direct link or get bounced here by an expired session and there is no
42
+ * route back to the site — Back has nowhere to go either. A brand mark in the
43
+ * corner is where people already click to leave, so it is the control to make
44
+ * real rather than a second one to add. It always links to `AUTH.HOME_URL`;
45
+ * nothing to configure.
46
+ */
47
+ function AuthBrandPanelRaw({ logo, tagline, className = '' }: AuthBrandPanelProps) {
48
+ /*
49
+ * The label is read from the shared navigation catalogue rather than taken
50
+ * as a prop: it is the same word in all 17 locales, and a `logo` is an
51
+ * opaque node this component cannot read a name out of — a brand that is a
52
+ * bare SVG would otherwise announce as an unnamed link.
53
+ */
54
+ const t = useAppT();
55
+
56
+ return (
57
+ <div className={`auth-brand-panel ${className}`}>
58
+ <Link
59
+ href={AUTH.HOME_URL}
60
+ className="auth-brand-panel__mark"
61
+ aria-label={t('layouts.navigation.home')}
62
+ >
63
+ {logo}
64
+ </Link>
65
+ {tagline && <p className="auth-brand-panel__tagline">{tagline}</p>}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export const AuthBrandPanel = memo(AuthBrandPanelRaw);
@@ -2,11 +2,24 @@
2
2
 
3
3
  import React, { memo } from 'react';
4
4
 
5
+ import { useAppT } from '@djangocfg/i18n';
6
+ import { Link } from '@djangocfg/ui-core/components';
7
+
8
+ import { AUTH } from '../../constants';
9
+
5
10
  export interface AuthHeaderProps {
6
11
  logo?: React.ReactNode;
7
12
  title: string;
8
13
  subtitle?: string;
9
14
  identifier?: string;
15
+ /**
16
+ * The brand is already rendered elsewhere on this screen (the `fullsplit`
17
+ * media half). The logo stays in the markup and CSS hides it at the width
18
+ * where that other half is actually visible — see `.auth-header` in
19
+ * `auth.css`. Rendering it conditionally in JS instead would need a width
20
+ * the server does not have, and the brand would flicker on hydration.
21
+ */
22
+ brandElsewhere?: boolean;
10
23
  className?: string;
11
24
  }
12
25
 
@@ -15,21 +28,38 @@ export interface AuthHeaderProps {
15
28
  *
16
29
  * `logo` is any React node (inline-SVG brand component, `<img>`, …), rendered
17
30
  * inside an `.auth-logo` wrapper so the existing logo sizing/spacing applies.
31
+ * The slot fits the artwork rather than the reverse: a square mark and a
32
+ * horizontal lock-up both keep their aspect ratio.
18
33
  */
19
34
  function AuthHeaderRaw({
20
35
  logo,
21
36
  title,
22
37
  subtitle,
23
38
  identifier,
39
+ brandElsewhere = false,
24
40
  className = '',
25
41
  }: AuthHeaderProps) {
42
+ const t = useAppT();
43
+ /*
44
+ * A link, not decoration — so no `aria-hidden`: it is the one way off this
45
+ * screen and must be reachable by keyboard and named for a screen reader.
46
+ * The name comes from the shared catalogue because `logo` is an opaque node
47
+ * this component cannot read a name out of; a brand that is a bare SVG
48
+ * would otherwise announce as an unnamed link.
49
+ */
50
+ const mark = logo ? (
51
+ <Link
52
+ href={AUTH.HOME_URL}
53
+ className="auth-logo"
54
+ aria-label={t('layouts.navigation.home')}
55
+ >
56
+ {logo}
57
+ </Link>
58
+ ) : null;
59
+
26
60
  return (
27
- <div className={`auth-header ${className}`}>
28
- {logo && (
29
- <span className="auth-logo" aria-hidden="true">
30
- {logo}
31
- </span>
32
- )}
61
+ <div className={`auth-header ${className}`} data-brand-elsewhere={brandElsewhere}>
62
+ {mark}
33
63
  <h1 className="auth-title">{title}</h1>
34
64
  {subtitle && (
35
65
  <p className="auth-subtitle" aria-live="polite">
@@ -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}
@@ -16,6 +16,19 @@ export const AUTH = {
16
16
  BACKUP_CODE_MAX_LENGTH: AUTH_CONSTANTS.BACKUP_CODE_MAX_LENGTH,
17
17
 
18
18
  // Timing (ms)
19
+ /**
20
+ * Where the brand mark links to — the way OUT of the sign-in screen.
21
+ *
22
+ * Sign-in is otherwise a dead end: a visitor who arrived by a direct link,
23
+ * or who was bounced here by an expired session, has no in-page route back
24
+ * and the browser's Back button has nowhere to return to.
25
+ *
26
+ * Not configurable, and the site ROOT rather than `redirectUrl` (which is
27
+ * where a *successful* sign-in lands — often a private page that would
28
+ * bounce a signed-out visitor straight back here). A localized app resolves
29
+ * this through next-intl's `Link`, so `/ru/auth` returns to `/ru`.
30
+ */
31
+ HOME_URL: '/',
19
32
  ANIMATION_DURATION: 400,
20
33
  AUTO_SUBMIT_DELAY: 100,
21
34
  COPY_FEEDBACK_DURATION: 2000,
@@ -39,6 +39,7 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
39
39
  const requireTermsAcceptance = Boolean(termsUrl || privacyUrl);
40
40
 
41
41
  // Use the auth form hook
42
+
42
43
  const authForm = useAuthForm({
43
44
  onIdentifierSuccess,
44
45
  onOTPSuccess,
@@ -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,32 +74,65 @@
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;
86
+ }
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
+ }
71
95
  }
72
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
 
119
+ /* As a link home it is a control, so it needs a visible focus ring and a
120
+ pointer state; as a decorative mark it stays inert. */
121
+ a.auth-logo {
122
+ color: inherit;
123
+ border-radius: var(--auth-radius-sm);
124
+ transition: opacity 0.15s var(--spring-snappy);
125
+ }
126
+
127
+ a.auth-logo:hover {
128
+ opacity: 0.82;
129
+ }
130
+
131
+ a.auth-logo:focus-visible {
132
+ outline: 2px solid var(--ring);
133
+ outline-offset: 4px;
134
+ }
135
+
91
136
  @keyframes authLogoIn {
92
137
  from {
93
138
  opacity: 0;
@@ -125,7 +170,7 @@
125
170
  .auth-form-group {
126
171
  display: flex;
127
172
  flex-direction: column;
128
- gap: 0.875rem;
173
+ gap: var(--auth-gap);
129
174
  }
130
175
 
131
176
  .auth-input {
@@ -333,10 +378,17 @@
333
378
 
334
379
  /* ===== ERROR ===== */
335
380
 
381
+ /* Sits directly under the input it concerns, so it reads as that field's
382
+ answer rather than a notice floating in the form. `align-items: flex-start`
383
+ keeps the icon on the first line when the message wraps.
384
+
385
+ The negative top margin pulls it inside the field's own gap: an error is
386
+ part of the control, not the next block down. */
336
387
  .auth-error {
337
388
  display: flex;
338
- align-items: center;
389
+ align-items: flex-start;
339
390
  gap: 0.375rem;
391
+ margin-top: calc(var(--auth-gap) * -0.5);
340
392
  font-size: 0.8125rem;
341
393
  line-height: 1.4;
342
394
  color: var(--destructive);
@@ -384,10 +436,21 @@
384
436
 
385
437
  /* ===== CONSENT (passive line under the submit button) ===== */
386
438
 
439
+ /* Quieter than the opt-in row above it, deliberately. Both were 0.75rem in
440
+ `--muted-foreground`, so a checkbox the visitor must decide about and a
441
+ legal line they need not read carried identical weight — with an error
442
+ between them the block read as three equal notes.
443
+
444
+ The step down is SIZE, not contrast. Fading the colour instead
445
+ (`--muted-foreground` at 70%) dropped it to 3.88:1 against the dark surface
446
+ and the a11y contract gate rejected it: `--muted-foreground` is already the
447
+ dimmest text token that clears AA, so anything mixed toward transparent
448
+ fails by construction. Legal copy is exactly the text that must stay
449
+ readable. */
387
450
  .auth-consent {
388
- margin: 0;
451
+ margin: calc(var(--auth-gap) * -0.25) 0 0;
389
452
  text-align: center;
390
- font-size: 0.75rem;
453
+ font-size: 0.6875rem;
391
454
  line-height: 1.5;
392
455
  color: var(--muted-foreground);
393
456
  }
@@ -580,39 +643,36 @@
580
643
  to { opacity: 1; }
581
644
  }
582
645
 
646
+ /* One fade owns the whole group, via `data-visible`. Each element used to
647
+ carry its own inline `style={{opacity}}`, which put presentation in the
648
+ component and meant the mark, the message and the hint could drift apart. */
583
649
  .auth-success-content {
584
650
  display: flex;
585
651
  flex-direction: column;
586
652
  align-items: center;
587
653
  gap: 1.25rem;
654
+ opacity: 0;
655
+ transition: opacity 0.35s var(--spring-smooth);
588
656
  }
589
657
 
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;
658
+ .auth-success-content[data-visible="true"] {
659
+ opacity: 1;
605
660
  }
606
661
 
607
662
  .auth-success-check {
608
- width: 72px;
609
- height: 72px;
663
+ width: 64px;
664
+ height: 64px;
610
665
  display: flex;
611
666
  align-items: center;
612
667
  justify-content: center;
613
- background: hsl(142 76% 36% / 0.1);
668
+ /* The semantic token, not a hardcoded green: the old literal
669
+ `hsl(142 76% 36%)` was the one colour on this screen that could not be
670
+ re-themed and did not move between light and dark. `--success` is defined
671
+ in both themes (ui-core `theme/light.css`, `theme/dark.css`), so no
672
+ fallback — a missing token should surface, not be papered over. */
673
+ background: color-mix(in oklab, var(--success) 12%, transparent);
614
674
  border-radius: 50%;
615
- color: hsl(142 76% 36%);
675
+ color: var(--success);
616
676
  animation: authSuccessIn 0.5s var(--spring-bounce) both;
617
677
  }
618
678
 
@@ -628,15 +688,32 @@
628
688
  }
629
689
 
630
690
  .auth-success-check svg {
631
- width: 36px;
632
- height: 36px;
691
+ width: 32px;
692
+ height: 32px;
693
+ }
694
+
695
+ /* Outcome first, then what the wait is for — two weights, one block, so the
696
+ pair reads as a single statement rather than two floating lines. */
697
+ .auth-success-copy {
698
+ display: flex;
699
+ flex-direction: column;
700
+ align-items: center;
701
+ gap: 0.25rem;
702
+ text-align: center;
633
703
  }
634
704
 
635
705
  .auth-success-text {
636
- font-size: 0.9375rem;
637
- font-weight: 500;
706
+ margin: 0;
707
+ font-size: 1.0625rem;
708
+ font-weight: 600;
709
+ letter-spacing: -0.01em;
710
+ color: var(--foreground);
711
+ }
712
+
713
+ .auth-success-hint {
714
+ margin: 0;
715
+ font-size: 0.8125rem;
638
716
  color: var(--muted-foreground);
639
- animation: authFadeIn 0.4s 0.15s var(--spring-smooth) both;
640
717
  }
641
718
 
642
719
  /* ===== 2FA BOX ===== */
@@ -85,6 +85,67 @@
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
+ /* `align-self` keeps the hit area the width of the artwork: the panel is a
114
+ flex column, so a link would otherwise stretch the full column width and
115
+ swallow clicks well past the mark. */
116
+ align-self: flex-start;
117
+ color: inherit;
118
+ border-radius: var(--auth-radius-sm);
119
+ transition: opacity 0.15s var(--spring-snappy);
120
+ }
121
+
122
+ /* Only the link form reacts — a plain mark is not a control. */
123
+ a.auth-brand-panel__mark:hover {
124
+ opacity: 0.82;
125
+ }
126
+
127
+ a.auth-brand-panel__mark:focus-visible {
128
+ outline: 2px solid #fff;
129
+ outline-offset: 6px;
130
+ }
131
+
132
+ /* Same contract as `.auth-logo`: cap the height, let the width follow the
133
+ artwork, so a horizontal lock-up is not squeezed into a square. */
134
+ .auth-brand-panel__mark > * {
135
+ width: auto;
136
+ height: 100%;
137
+ max-width: 100%;
138
+ object-fit: contain;
139
+ }
140
+
141
+ .auth-brand-panel__tagline {
142
+ margin: 0;
143
+ max-width: 24ch;
144
+ font-size: 1.125rem;
145
+ line-height: 1.45;
146
+ color: hsl(0 0% 100% / 0.82);
147
+ }
148
+
88
149
  /* ── Form half (right) ─────────────────────────────────────────────────── */
89
150
  .auth-shell-fullsplit__form {
90
151
  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,