@djangocfg/layouts 2.1.585 → 2.1.590

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.585",
3
+ "version": "2.1.590",
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.585",
94
- "@djangocfg/api": "^2.1.585",
95
- "@djangocfg/devtools": "^2.1.585",
96
- "@djangocfg/i18n": "^2.1.585",
97
- "@djangocfg/ui-core": "^2.1.585",
93
+ "@djangocfg/analytics": "^2.1.590",
94
+ "@djangocfg/api": "^2.1.590",
95
+ "@djangocfg/devtools": "^2.1.590",
96
+ "@djangocfg/i18n": "^2.1.590",
97
+ "@djangocfg/ui-core": "^2.1.590",
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.585",
128
- "@djangocfg/api": "^2.1.585",
129
- "@djangocfg/devtools": "^2.1.585",
130
- "@djangocfg/i18n": "^2.1.585",
131
- "@djangocfg/typescript-config": "^2.1.585",
132
- "@djangocfg/ui-core": "^2.1.585",
127
+ "@djangocfg/analytics": "^2.1.590",
128
+ "@djangocfg/api": "^2.1.590",
129
+ "@djangocfg/devtools": "^2.1.590",
130
+ "@djangocfg/i18n": "^2.1.590",
131
+ "@djangocfg/typescript-config": "^2.1.590",
132
+ "@djangocfg/ui-core": "^2.1.590",
133
133
  "@storybook/react-vite": "^10.5.0",
134
134
  "@types/node": "^25.9.5",
135
135
  "@types/react": "19.2.15",
@@ -21,7 +21,7 @@ import { useAppT } from '@djangocfg/i18n';
21
21
 
22
22
  import { Suspense } from '../../components';
23
23
  import { TwoFactorChallengeDialog } from '../../snippets/TwoFactor';
24
- import { AuthBrandPanel } from './components/shared';
24
+ import { AuthBrandPanel, AuthTopBar } from './components/shared';
25
25
  import { OAuthCallback } from './components/oauth';
26
26
  import { IdentifierStep, OTPStep } from './components/steps';
27
27
  import { AUTH } from './constants';
@@ -113,6 +113,10 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
113
113
  [hideHeader, hasBrandPanel],
114
114
  );
115
115
 
116
+ // The way out and the locale, pinned to the top of the form column. Owned by
117
+ // the layout so it does not depend on `logo` or `sidebar`, which are optional.
118
+ const topBar = useMemo(() => <AuthTopBar brandPanel={hasBrandPanel} />, [hasBrandPanel]);
119
+
116
120
  const oauthCallback = useMemo(() => {
117
121
  if (!enableGithubAuth) return null;
118
122
  return (
@@ -135,7 +139,14 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
135
139
  <AuthFunnelTracker />
136
140
  <AuthTwoFactorChallenge onVerified={onOTPSuccess} />
137
141
 
138
- <AuthShell variant={variant} mediaSide={mediaSide} background={background} sidebar={resolvedSidebar} className={className}>
142
+ <AuthShell
143
+ variant={variant}
144
+ mediaSide={mediaSide}
145
+ background={background}
146
+ sidebar={resolvedSidebar}
147
+ topBar={topBar}
148
+ className={className}
149
+ >
139
150
  {/* Handle OAuth callback when GitHub auth is enabled */}
140
151
  {oauthCallback}
141
152
 
@@ -51,6 +51,38 @@ The link carries `aria-label` from `layouts.navigation.home` rather than
51
51
  relying on its contents: `logo` is an opaque node, and a brand that is a bare
52
52
  SVG would otherwise announce as an unnamed link.
53
53
 
54
+ ## The top bar: the way out and the language
55
+
56
+ Every variant pins one bar to the top of the form column: **`‹ Home`** on the
57
+ left, the **locale picker** on the right. The layout owns it (`AuthTopBar`,
58
+ passed to the shell as `topBar`), so it does not depend on `logo` or `sidebar`.
59
+
60
+ > It exists because the brand link above was the only exit, and the brand is
61
+ > optional. An app that passed its own `sidebar` and no `logo` (the Kino sign-in)
62
+ > left a phone visitor with no route off the screen: the media half is hidden
63
+ > below 1024px and the form header had nothing to render. The locale, meanwhile,
64
+ > floated above the title inside the centered stack.
65
+
66
+ - The link is a real `<a href="/">` (`AUTH.HOME_URL`) with a visible label, the
67
+ way an iOS back button names its destination. A bare arrow reads as browser
68
+ Back, and Back has nowhere to go after a direct link. The label is
69
+ `layouts.navigation.home`, translated in all 17 locales.
70
+ - Both controls are at least 44px tall. The bar clears the notch with
71
+ `env(safe-area-inset-top)`.
72
+ - When the layout's own `AuthBrandPanel` is on the photo half (`fullsplit`, `logo`
73
+ and no `sidebar`) the home link stands down from 1024px, since the panel links
74
+ home already. With a custom `sidebar` the bar's link stays: the layout cannot
75
+ know the sidebar links anywhere.
76
+ - The locale picker mounts whenever `LayoutI18nProvider` does (`BaseApp` does it
77
+ when the host passes `i18n`). It moved out of `AuthContainer`.
78
+
79
+ **Height.** Every shell is `min-height: 100dvh` with a `100vh` fallback, so the
80
+ mobile address bar does not leave a gap. The form centers itself in the space the
81
+ bar leaves with `margin: auto` on its own block, not `justify-content: center` on
82
+ the column: with the keyboard open the form scrolls from its top instead of
83
+ having its first field pushed out of reach. It sits slightly above the true
84
+ center (`padding-bottom: clamp(0rem, 8dvh, 4rem)`), the optical center of a form.
85
+
54
86
  `logo` is a **`ReactNode`** — an inline-SVG brand component, an `<img>`, a
55
87
  horizontal lock-up. **The slot fits the artwork, not the reverse:** it caps the
56
88
  height (44px in the form header, 2.5rem on the brand panel) and lets the width
@@ -2,8 +2,6 @@
2
2
 
3
3
  import React, { memo, useEffect, useState } from 'react';
4
4
 
5
- import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
6
- import { useLayoutI18nOptional } from '../../../AppLayout/LayoutI18nProvider';
7
5
  import { AUTH } from '../../constants';
8
6
  import type { AuthStep } from '../../types';
9
7
 
@@ -44,32 +42,12 @@ function AuthContainerRaw({
44
42
  }
45
43
  }, [isEntering]);
46
44
 
47
- // Locale picker is mounted as the first child so it sits at the very
48
- // top of the form column — visually paired with the form chrome (not
49
- // floating in a viewport corner). Renders automatically when
50
- // LayoutI18nProvider is mounted (BaseApp does this when the host app
51
- // passes `i18n`); no prop gating needed.
52
- const i18n = useLayoutI18nOptional();
53
-
54
45
  return (
55
46
  <div
56
47
  className={`auth-container ${className}`}
57
48
  data-entering={isEntering}
58
49
  data-step={step}
59
50
  >
60
- {i18n ? (
61
- <div className="auth-container__meta">
62
- <LocaleSwitcher
63
- variant="dropdown"
64
- buttonVariant="ghost"
65
- size="sm"
66
- showFlag
67
- showCode={false}
68
- showTriggerLabel
69
- className="auth-locale-trigger"
70
- />
71
- </div>
72
- ) : null}
73
51
  {children}
74
52
  </div>
75
53
  );
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import React, { memo } from 'react';
4
+
5
+ import { ChevronLeft } from 'lucide-react';
6
+
7
+ import { useAppT } from '@djangocfg/i18n';
8
+ import { Link } from '@djangocfg/ui-core/components';
9
+
10
+ import { LocaleSwitcher } from '../../../_components/LocaleSwitcher';
11
+ import { useLayoutI18nOptional } from '../../../AppLayout/LayoutI18nProvider';
12
+ import { AUTH } from '../../constants';
13
+
14
+ export interface AuthTopBarProps {
15
+ /**
16
+ * The brand already sits on the media half, which links home itself, so the
17
+ * bar stands its own link down where that half is visible (>= 1024px). CSS
18
+ * owns the breakpoint — see `.auth-topbar` in `auth.css`.
19
+ */
20
+ brandPanel?: boolean;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * AuthTopBar — the one place a sign-in screen keeps its way out and its language.
26
+ *
27
+ * Home on the left, locale on the right, pinned to the top of the viewport and
28
+ * independent of what the app passes. Both used to ride inside the centered
29
+ * form stack: the locale floated above the title, and the way out existed only
30
+ * as the `logo` — an optional prop, hidden with the media half on a phone. An
31
+ * app that passed a custom `sidebar` and no `logo` (the Kino sign-in) left a
32
+ * phone visitor with no route off the screen at all.
33
+ *
34
+ * The link is a real `<a>` to `AUTH.HOME_URL` with a visible label, the way an
35
+ * iOS back button names its destination: an arrow alone reads as browser Back,
36
+ * and Back has nowhere to go when the page was opened by a direct link.
37
+ */
38
+ function AuthTopBarRaw({ brandPanel = false, className = '' }: AuthTopBarProps) {
39
+ const t = useAppT();
40
+ const i18n = useLayoutI18nOptional();
41
+
42
+ return (
43
+ <div className={`auth-topbar ${className}`} data-brand-panel={brandPanel}>
44
+ <Link href={AUTH.HOME_URL} className="auth-topbar__home">
45
+ <ChevronLeft aria-hidden="true" className="auth-topbar__icon" />
46
+ <span>{t('layouts.navigation.home')}</span>
47
+ </Link>
48
+ {/* Mounted whenever LayoutI18nProvider is — BaseApp does it when the host
49
+ passes `i18n` — so there is no prop to gate it on. */}
50
+ {i18n ? (
51
+ <LocaleSwitcher
52
+ variant="dropdown"
53
+ buttonVariant="ghost"
54
+ size="sm"
55
+ showFlag
56
+ showCode={false}
57
+ showTriggerLabel
58
+ className="auth-locale-trigger"
59
+ />
60
+ ) : null}
61
+ </div>
62
+ );
63
+ }
64
+
65
+ export const AuthTopBar = memo(AuthTopBarRaw);
@@ -1,5 +1,6 @@
1
1
  export { AuthBrandPanel } from './AuthBrandPanel';
2
2
  export { AuthContainer } from './AuthContainer';
3
+ export { AuthTopBar } from './AuthTopBar';
3
4
  export { AuthHeader } from './AuthHeader';
4
5
  export { AuthFooter } from './AuthFooter';
5
6
  export { AuthDivider } from './AuthDivider';
@@ -12,6 +13,7 @@ export { AuthConsent } from './AuthConsent';
12
13
 
13
14
  export type { AuthBrandPanelProps } from './AuthBrandPanel';
14
15
  export type { AuthContainerProps } from './AuthContainer';
16
+ export type { AuthTopBarProps } from './AuthTopBar';
15
17
  export type { AuthHeaderProps } from './AuthHeader';
16
18
  export type { AuthFooterProps } from './AuthFooter';
17
19
  export type { AuthDividerProps } from './AuthDivider';
@@ -31,6 +31,7 @@ function AuthShellRaw({
31
31
  mediaSide = 'left',
32
32
  background,
33
33
  sidebar,
34
+ topBar,
34
35
  className,
35
36
  }: AuthShellProps) {
36
37
  const { isLoaded: bgLoaded, hasError: bgError } = useImageLoader(background?.imageUrl);
@@ -73,6 +74,7 @@ function AuthShellRaw({
73
74
 
74
75
  const commonShellProps = {
75
76
  children,
77
+ topBar,
76
78
  className,
77
79
  bgStyle,
78
80
  overlayStyle,
@@ -13,6 +13,7 @@ import type { ShellRenderProps } from './types';
13
13
  */
14
14
  function CenteredShellRaw({
15
15
  children,
16
+ topBar,
16
17
  className,
17
18
  bgStyle,
18
19
  overlayStyle,
@@ -30,6 +31,8 @@ function CenteredShellRaw({
30
31
  </>
31
32
  )}
32
33
 
34
+ {topBar && <div className="auth-shell-centered__topbar">{topBar}</div>}
35
+
33
36
  <div className="auth-shell-centered__content">
34
37
  {children}
35
38
  </div>
@@ -25,6 +25,7 @@ interface FullSplitShellProps extends ShellRenderProps {
25
25
  */
26
26
  function FullSplitShellRaw({
27
27
  children,
28
+ topBar,
28
29
  className,
29
30
  bgStyle,
30
31
  overlayStyle,
@@ -64,6 +65,7 @@ function FullSplitShellRaw({
64
65
 
65
66
  {/* Right: solid panel with the centered form */}
66
67
  <div className="auth-shell-fullsplit__form">
68
+ {topBar}
67
69
  <div className="auth-shell-fullsplit__form-inner">{children}</div>
68
70
  </div>
69
71
  </div>
@@ -23,6 +23,7 @@ interface SplitShellProps extends ShellRenderProps {
23
23
  */
24
24
  function SplitShellRaw({
25
25
  children,
26
+ topBar,
26
27
  className,
27
28
  bgStyle,
28
29
  overlayStyle,
@@ -50,6 +51,7 @@ function SplitShellRaw({
50
51
  <div className="auth-shell-split__card">
51
52
  {/* Left: form */}
52
53
  <div className="auth-shell-split__form">
54
+ {topBar}
53
55
  <div className="auth-shell-split__form-inner">
54
56
  {children}
55
57
  </div>
@@ -50,12 +50,20 @@ export interface AuthShellProps {
50
50
  background?: AuthBackgroundConfig;
51
51
  /** Slot for the right column in split variant (testimonial, branding, etc.). */
52
52
  sidebar?: ReactNode;
53
+ /**
54
+ * Pinned to the top of the form column, above the vertically centered form:
55
+ * the way out and the locale (see `AuthTopBar`). Optional so a harness that
56
+ * mounts a shell alone still renders.
57
+ */
58
+ topBar?: ReactNode;
53
59
  className?: string;
54
60
  }
55
61
 
56
62
  /** Internal props passed from AuthShell orchestrator to variant shells. */
57
63
  export interface ShellRenderProps {
58
64
  children: ReactNode;
65
+ /** See `AuthShellProps.topBar`. */
66
+ topBar?: ReactNode;
59
67
  className?: string;
60
68
  bgStyle?: CSSProperties;
61
69
  overlayStyle?: CSSProperties;
@@ -32,22 +32,65 @@
32
32
  gap: var(--auth-gap);
33
33
  }
34
34
 
35
- /* Meta row at the top of the form — currently hosts the locale picker.
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. */
41
- .auth-container__meta {
35
+ /* Top bar: the way out (left) and the locale (right), pinned to the top of the
36
+ form column by the shell. The locale used to ride above the title inside the
37
+ centered stack; it is a utility, so it lives on the screen's edge, not in the
38
+ reading order. Contrast stays at `--muted-foreground`, the dimmest token that
39
+ clears AA: fading it further fails the contract gate. */
40
+ .auth-topbar {
42
41
  display: flex;
43
- justify-content: flex-end;
44
- /* Lifted clear of the column's own gap — a utility chip, not a first row. */
45
- margin-bottom: calc(var(--auth-gap) * -1);
42
+ align-items: center;
43
+ justify-content: space-between;
44
+ gap: 0.5rem;
45
+ width: 100%;
46
+ /* A touch target, not a text row: 44px is the floor for a thumb. */
47
+ min-height: 2.75rem;
48
+ /* Clear the notch / status bar when the page runs edge to edge. */
49
+ padding-top: env(safe-area-inset-top, 0);
50
+ }
51
+
52
+ .auth-topbar__home {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 0.125rem;
56
+ min-height: 2.75rem;
57
+ /* Pull the chevron onto the column's left edge; the padding stays as hit area. */
58
+ margin-inline-start: -0.5rem;
59
+ padding-inline: 0.5rem 0.75rem;
60
+ border-radius: var(--auth-radius-sm);
61
+ font-size: 0.875rem;
62
+ color: var(--muted-foreground);
63
+ transition: color 0.15s var(--spring-snappy);
64
+ }
65
+ .auth-topbar__home:hover {
66
+ color: var(--foreground);
67
+ }
68
+ .auth-topbar__home:focus-visible {
69
+ outline: 2px solid var(--ring);
70
+ outline-offset: 2px;
71
+ }
72
+
73
+ .auth-topbar__icon {
74
+ width: 1.25rem;
75
+ height: 1.25rem;
76
+ }
77
+
78
+ /* The locale stays at the right edge even when the link is hidden. */
79
+ .auth-topbar > :last-child {
80
+ margin-inline-start: auto;
81
+ }
82
+
83
+ /* The brand panel on the media half already links home, and that half exists
84
+ from 1024px — the same breakpoint `fullsplit-shell.css` reveals it at. */
85
+ @media (min-width: 1024px) {
86
+ .auth-topbar[data-brand-panel="true"] .auth-topbar__home {
87
+ display: none;
88
+ }
46
89
  }
47
90
 
48
91
  .auth-locale-trigger {
49
- height: 1.75rem;
50
- padding-inline: 0.5rem;
92
+ height: 2.75rem;
93
+ padding-inline: 0.75rem;
51
94
  font-size: 0.75rem;
52
95
  color: var(--muted-foreground);
53
96
  transition: color 0.15s var(--spring-snappy);
@@ -12,9 +12,10 @@
12
12
  display: flex;
13
13
  flex-direction: column;
14
14
  align-items: center;
15
- justify-content: center;
15
+ /* The top bar pins to the top; the content centers itself in what is left
16
+ (see `__content`), so a short viewport never pushes the form out of reach. */
16
17
  /* Responsive padding: 24px mobile → 40px desktop */
17
- padding: 1.5rem clamp(1.5rem, 5vw, 2.5rem);
18
+ padding: 0.5rem clamp(1.5rem, 5vw, 2.5rem) 1.5rem;
18
19
  padding-bottom: max(1.5rem, env(safe-area-inset-bottom, 1rem));
19
20
  }
20
21
 
@@ -39,11 +40,19 @@
39
40
  pointer-events: none;
40
41
  }
41
42
 
43
+ .auth-shell-centered__topbar {
44
+ position: relative;
45
+ z-index: 1;
46
+ width: 100%;
47
+ }
48
+
42
49
  /* Content sits above background */
43
50
  .auth-shell-centered__content {
44
51
  position: relative;
45
52
  z-index: 1;
46
53
  width: 100%;
54
+ margin-block: auto;
55
+ padding-bottom: clamp(0rem, 8dvh, 4rem);
47
56
  display: flex;
48
57
  flex-direction: column;
49
58
  align-items: center;
@@ -147,18 +147,26 @@ a.auth-brand-panel__mark:focus-visible {
147
147
  }
148
148
 
149
149
  /* ── Form half (right) ─────────────────────────────────────────────────── */
150
+ /* A column: the top bar sits at the top of the viewport, and the form takes the
151
+ rest and centers itself in it. `margin: auto` on the inner block (rather than
152
+ `justify-content: center` on the column) is what keeps the form reachable when
153
+ the keyboard or a short viewport leaves it less room than it needs — centering
154
+ by the container would push the top of it out of reach. */
150
155
  .auth-shell-fullsplit__form {
151
156
  flex: 1;
152
157
  display: flex;
153
- align-items: center;
154
- justify-content: center;
155
- padding: 1.5rem clamp(1.5rem, 5vw, 2.5rem);
158
+ flex-direction: column;
159
+ padding: 0.5rem clamp(1.5rem, 5vw, 2.5rem);
156
160
  padding-bottom: max(1.5rem, env(safe-area-inset-bottom, 1rem));
157
161
  }
158
162
 
159
163
  .auth-shell-fullsplit__form-inner {
160
164
  width: 100%;
161
165
  max-width: 360px;
166
+ margin: auto;
167
+ /* Sits a little above the true center — the optical center of a form — and
168
+ the top bar's height keeps it from looking low. */
169
+ padding-bottom: clamp(0rem, 8dvh, 4rem);
162
170
  }
163
171
 
164
172
  /* ── Desktop: reveal the image half, split 50/50 edge-to-edge ──────────── */
@@ -215,7 +223,7 @@ a.auth-brand-panel__mark:focus-visible {
215
223
 
216
224
  .auth-shell-fullsplit__form {
217
225
  flex: 1;
218
- padding: 2.5rem;
226
+ padding: 1.5rem 2.5rem 2.5rem;
219
227
  }
220
228
 
221
229
  .auth-shell-fullsplit__form-inner {
@@ -13,9 +13,11 @@
13
13
  min-height: 100vh;
14
14
  min-height: 100dvh;
15
15
  display: flex;
16
- align-items: center;
16
+ /* Mobile: the card fills the viewport so the top bar lands at its top edge;
17
+ desktop re-centers the floating card below. */
18
+ align-items: stretch;
17
19
  justify-content: center;
18
- padding: 1rem;
20
+ padding: 0.5rem 1rem 1rem;
19
21
  }
20
22
 
21
23
  /* Background layers — hidden on mobile (plain form), shown on desktop. */
@@ -47,14 +49,15 @@
47
49
  .auth-shell-split__form {
48
50
  flex: 1;
49
51
  display: flex;
50
- align-items: center;
51
- justify-content: center;
52
- padding: 1rem 0.5rem;
52
+ flex-direction: column;
53
+ padding: 0 0.5rem 1rem;
53
54
  }
54
55
 
55
56
  .auth-shell-split__form-inner {
56
57
  width: 100%;
57
58
  max-width: 360px;
59
+ /* Centers in what the top bar leaves, and stays reachable when it is short. */
60
+ margin: auto;
58
61
  }
59
62
 
60
63
  /* Sidebar column — hidden on mobile, shown on desktop. */
@@ -64,6 +67,11 @@
64
67
 
65
68
  /* ── Desktop: frosted-glass two-column card ──────────────────────────────── */
66
69
  @media (min-width: 1280px) {
70
+ .auth-shell-split {
71
+ align-items: center;
72
+ padding: 1rem;
73
+ }
74
+
67
75
  .auth-shell-split__bg,
68
76
  .auth-shell-split__overlay {
69
77
  display: block;
@@ -91,7 +99,7 @@
91
99
  }
92
100
 
93
101
  .auth-shell-split__form {
94
- padding: 2.75rem;
102
+ padding: 1.25rem 2.75rem 2.75rem;
95
103
  }
96
104
 
97
105
  .auth-shell-split__sidebar {
@@ -27,7 +27,7 @@ export { AuthLayoutProvider, AuthSuccessScreen } from '../layouts/AuthLayout/Aut
27
27
  // The layout's own default media-half content. Stories mount `AuthShell`
28
28
  // directly, so they must supply this themselves to preview what an app that
29
29
  // passes no `sidebar` actually gets.
30
- export { AuthBrandPanel } from '../layouts/AuthLayout/components/shared';
30
+ export { AuthBrandPanel, AuthTopBar } from '../layouts/AuthLayout/components/shared';
31
31
  export {
32
32
  IdentifierStep,
33
33
  OTPStep,