@spunto/design-system 0.16.0 → 0.18.0

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/README.md CHANGED
@@ -63,9 +63,20 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
63
63
  `Alert` (+`AlertTitle`/`AlertDescription`, +`alertVariants`) — an **inline, persistent,
64
64
  declarative** status banner, the counterpart to the ephemeral imperative `toast()`.
65
65
  - _Overlays_ (plug into the `SpuntoProvider` umbrella) — `Dialog`, `AlertDialog`
66
- (a confirmation variant, non-dismissible), `Tooltip`. Their portals render into
67
- the provider's overlay container; `Tooltip`'s shared delay group is mounted by the
68
- provider too.
66
+ (a confirmation variant, non-dismissible), `Sheet`, `Tooltip`. Their portals render
67
+ into the provider's overlay container; `Tooltip`'s shared delay group is mounted by
68
+ the provider too.
69
+ - _Sheet_ — `Sheet` (+`SheetTrigger`/`SheetClose`/`SheetContent`/`SheetHeader`/
70
+ `SheetFooter`/`SheetTitle`/`SheetDescription`), the same Base UI dialog entering from
71
+ an edge. `side` (4) decides the edge, `size` (`sm`…`full`) how far it comes in — two
72
+ props rather than one bundle of `data-[side=right]:`-prefixed classes, precisely so
73
+ `className` stays the layout escape hatch (an app widens a panel or lays it out in two
74
+ columns without `!important`).
75
+ - _Table_ — `Table` (+`TableHeader`/`TableBody`/`TableFooter`/`TableRow`/`TableHead`/
76
+ `TableCell`/`TableCaption`). **Presentational only**: no sorting, no pagination, no
77
+ column model — apps already own that logic and disagree on it; what they were
78
+ duplicating is the markup and the token choices. `containerClassName` reaches the
79
+ scroll container (max-height, border, sticky-header context).
69
80
  - _CommandPalette_ — `CommandPalette` (+`CommandPaletteInput`/`List`/`Group`/`Item`/
70
81
  `Empty`/`Loading`/`Separator`/`Footer`/`Trigger`), the ⌘K palette: an input, groups,
71
82
  items, and a jump. **Domain-free**, hence its place in the root entry — it knows
@@ -80,6 +91,32 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
80
91
  `value`/`onValueChange` + `filter={null}`. Navigation is `href` + `render.link`, never
81
92
  `next/link` — same rule as `WorkerCard`. Not virtualized: a few hundred items stay
82
93
  fluid (a non-matching item renders nothing), beyond that filter server-side.
94
+ - _CommandMenu_ — `CommandMenu` (+`CommandMenuInput`/`List`/`Group`/`Item`/`Empty`/
95
+ `Loading`/`Separator`), the palette's vocabulary **inline**: a Base UI `Popover`
96
+ anchored to an element or a **virtual element** (a caret rect), which never moves
97
+ focus (`initialFocus`/`finalFocus` off, no backdrop, non-modal) and claims ↑ ↓ ↵ Esc
98
+ on `document` in the **capture** phase, before the field underneath. That's what a
99
+ "/" menu inside a block editor needs and what the dialog-based palette can't do
100
+ without losing the caret — hence a separate component rather than a `modal={false}`
101
+ flag: the contract is the opposite one. Enter is only claimed when an item is
102
+ highlighted, so a query with no result still inserts a line break. The query either
103
+ comes from the caller (`query`, the "/" case) or from a `CommandMenuInput` rendered
104
+ inside the popup (a filter dropdown on a button; `autoFocus` is opt-in).
105
+ - _Auth_ — `AuthLayout`, `AuthBrandPanel` (+`AuthBrandMark`), `AuthForm`
106
+ (+`authFormCopy`), `OAuthButton` (+`GoogleIcon`/`GitHubIcon`) and `AuthSeparator`:
107
+ the login/signup screen **in bricks**, not one `<AuthScreen />`. In the root entry
108
+ for the same reason as the palette — nothing in it knows a Spunto concept: every
109
+ word of the brand panel is a prop, and the form authenticates nothing. **No
110
+ framework, no network**: no `next/navigation`, no `fetch`; `AuthForm` hands back
111
+ `onSubmit({ mode, email, password, name })`, `error`/`loading` come back down as
112
+ props, and anything that navigates takes an `href` (+ `render.link` on
113
+ `OAuthButton`) — the route, the endpoints and the redirect stay in the app. Both
114
+ controlled (`mode`, for `/login` + `/signup`) and uncontrolled (`initialMode` + the
115
+ in-place toggle). The fields are the package's own `Input`/`Label`/`Button`, in this
116
+ screen's taller register through `className` — the shape belongs to the page, not to
117
+ inputs in general. Assembling the whole thing:
118
+ [Créer sa page de login](https://design.spunto.net/#/auth-guide)
119
+ ([markdown](https://design.spunto.net/auth-guide.md)).
83
120
  - _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
84
121
  xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
85
122
  bytes come from (WebSocket, SSE, a static string) — feed it via the `write`/`writeln`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,163 @@
1
+ import type { ReactNode } from "react"
2
+ import { CheckIcon } from "lucide-react"
3
+
4
+ import { cn } from "../../utils"
5
+
6
+ /* ─── AuthBrandPanel ───────────────────────────────────────────────────────
7
+ What fills the dark half of `AuthLayout`: the mark at the top, the promise
8
+ and its arguments in the middle, a quote at the bottom.
9
+
10
+ **Nothing about Spunto is written in here.** The headline, the four lines
11
+ that sell the product and the quote all come down as props — the component is
12
+ the *shape* of that panel (three blocks pushed apart by `justify-between`, a
13
+ flame check per argument, a hairline card for the quote), not its copy. */
14
+
15
+ export interface AuthBrandMarkProps {
16
+ /**
17
+ * The logo, as a node — an inline SVG, an `<img>`, whatever the app has. It
18
+ * sits above the halo, so give it its own size and radius. Use `logoSrc`
19
+ * instead to get a plain, correctly-sized `<img>` for free.
20
+ */
21
+ logo?: ReactNode
22
+ /** Convenience over `logo`: an image URL the package draws itself. */
23
+ logoSrc?: string
24
+ /** Wordmark beside (or under) the logo. */
25
+ name?: ReactNode
26
+ /**
27
+ * `panel` — horizontal, white text, for the dark brand panel.
28
+ * `compact` — stacked and centered, themed text, for the top of the form
29
+ * column on small screens (where the brand panel is hidden).
30
+ * @default "panel"
31
+ */
32
+ variant?: "panel" | "compact"
33
+ className?: string
34
+ }
35
+
36
+ /** Logo + wordmark, haloed. The one brand element the auth screen repeats. */
37
+ export function AuthBrandMark({
38
+ logo,
39
+ logoSrc,
40
+ name,
41
+ variant = "panel",
42
+ className,
43
+ }: AuthBrandMarkProps) {
44
+ const panel = variant === "panel"
45
+ const alt = typeof name === "string" ? name : ""
46
+
47
+ const mark = logoSrc ? (
48
+ <img
49
+ src={logoSrc}
50
+ alt={alt}
51
+ className={cn(
52
+ "relative object-cover",
53
+ panel ? "h-10 w-10 rounded-xl ring-1 ring-white/15" : "h-12 w-12 rounded-2xl shadow-lg ring-1 ring-black/5"
54
+ )}
55
+ />
56
+ ) : (
57
+ <span className="relative block">{logo}</span>
58
+ )
59
+
60
+ return (
61
+ <div
62
+ className={cn(
63
+ "relative flex",
64
+ panel ? "items-center gap-3" : "flex-col items-center gap-2",
65
+ className
66
+ )}
67
+ >
68
+ {(logo || logoSrc) && (
69
+ <div className="relative">
70
+ <div
71
+ className={cn(
72
+ "absolute inset-0 rounded-md",
73
+ panel ? "bg-primary/40 blur-md" : "scale-110 bg-primary/25 blur-lg"
74
+ )}
75
+ />
76
+ {mark}
77
+ </div>
78
+ )}
79
+ {name && (
80
+ <span
81
+ className={cn(
82
+ "font-display font-bold tracking-tight",
83
+ panel ? "text-xl text-white" : "text-lg text-foreground"
84
+ )}
85
+ >
86
+ {name}
87
+ </span>
88
+ )}
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export interface AuthBrandPanelProps extends Omit<AuthBrandMarkProps, "variant" | "className"> {
94
+ /** The promise, big. Takes nodes, so a `<br />` or a gradient span is yours to place. */
95
+ title?: ReactNode
96
+ /** The paragraph under it. */
97
+ description?: ReactNode
98
+ /** The selling points, one flame check each. */
99
+ items?: ReactNode[]
100
+ /** A testimonial in the bottom card. Omit both quote props to drop the card. */
101
+ quote?: ReactNode
102
+ /** Attribution under the quote — write the dash yourself ("— Platform Lead"). */
103
+ quoteAuthor?: ReactNode
104
+ /** Replaces the quote card entirely (a logo strip, a status line…). */
105
+ footer?: ReactNode
106
+ className?: string
107
+ /** Reaches the mark at the top (or hand it `null` to drop it). */
108
+ markClassName?: string
109
+ }
110
+
111
+ export function AuthBrandPanel({
112
+ logo,
113
+ logoSrc,
114
+ name,
115
+ title,
116
+ description,
117
+ items,
118
+ quote,
119
+ quoteAuthor,
120
+ footer,
121
+ className,
122
+ markClassName,
123
+ }: AuthBrandPanelProps) {
124
+ const hasQuote = quote !== undefined || quoteAuthor !== undefined
125
+
126
+ return (
127
+ // `h-full` + `justify-between` rather than the layout's own flex column, so
128
+ // the panel keeps its three-block rhythm wherever it's mounted.
129
+ <div className={cn("relative flex h-full flex-col justify-between", className)}>
130
+ <AuthBrandMark logo={logo} logoSrc={logoSrc} name={name} className={markClassName} />
131
+
132
+ <div className="space-y-8">
133
+ {(title || description) && (
134
+ <div>
135
+ {title && <h1 className="mb-3 text-3xl leading-snug font-bold text-white">{title}</h1>}
136
+ {description && <p className="leading-relaxed text-zinc-400">{description}</p>}
137
+ </div>
138
+ )}
139
+
140
+ {items && items.length > 0 && (
141
+ <ul className="space-y-4">
142
+ {items.map((item, i) => (
143
+ <li key={i} className="flex items-start gap-3 text-sm text-zinc-300">
144
+ <div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-primary/30 bg-primary/20">
145
+ <CheckIcon className="h-3 w-3 text-primary" />
146
+ </div>
147
+ {item}
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ )}
152
+ </div>
153
+
154
+ {footer ??
155
+ (hasQuote && (
156
+ <div className="rounded-xl border border-white/10 bg-white/3 p-5">
157
+ {quote && <p className="text-sm leading-relaxed text-zinc-300 italic">{quote}</p>}
158
+ {quoteAuthor && <p className="mt-3 text-xs text-zinc-500">{quoteAuthor}</p>}
159
+ </div>
160
+ ))}
161
+ </div>
162
+ )
163
+ }
@@ -0,0 +1,263 @@
1
+ "use client"
2
+
3
+ import { useId, useState, type FormEvent, type ReactNode } from "react"
4
+
5
+ import { cn } from "../../utils"
6
+ import { Button } from "../button"
7
+ import { Input } from "../input"
8
+ import { Label } from "../label"
9
+ import { Spinner } from "../spinner"
10
+ import type { AuthMode, AuthSubmitValues } from "./types"
11
+
12
+ /* ─── AuthForm ─────────────────────────────────────────────────────────────
13
+ One form, two modes. `/login` opens on the sign-in form and `/signup` on the
14
+ account form — a home page whose call to action says "Sign up for free" has to
15
+ land on a form that says "Create an account", not on "Welcome back". The
16
+ toggle at the bottom still switches between the two *without a navigation*, so
17
+ nobody is stuck on the wrong one.
18
+
19
+ Hence both a `mode` (controlled — two routes, each pinning its own) and an
20
+ `initialMode` (uncontrolled — one route, the toggle switches in place).
21
+
22
+ **It does not authenticate anything.** No `fetch`, no session, no redirect:
23
+ `onSubmit` hands back `{ mode, email, password, name }` and the app decides
24
+ which endpoint that goes to and where the user lands afterwards. `error` and
25
+ `loading` come back down as props — the request is the app's, so its two
26
+ states are the app's too (`loading` is optional: left out, the form spins by
27
+ itself while an async `onSubmit` is pending). */
28
+
29
+ export interface AuthModeCopy {
30
+ title: ReactNode
31
+ subtitle: ReactNode
32
+ /** The submit button's label. */
33
+ submit: ReactNode
34
+ /** The question at the bottom ("No account?"). */
35
+ switchPrompt: ReactNode
36
+ /** The link that flips the mode ("Create one"). */
37
+ switchAction: ReactNode
38
+ }
39
+
40
+ export interface AuthFieldCopy {
41
+ name: string
42
+ email: string
43
+ password: string
44
+ }
45
+
46
+ export interface AuthFormCopy {
47
+ login: AuthModeCopy
48
+ register: AuthModeCopy
49
+ fields: AuthFieldCopy
50
+ }
51
+
52
+ /** English defaults — every string is overridable through `copy`. */
53
+ export const authFormCopy: AuthFormCopy = {
54
+ login: {
55
+ title: "Welcome back",
56
+ subtitle: "Sign in to access your environments",
57
+ submit: "Sign in",
58
+ switchPrompt: "No account?",
59
+ switchAction: "Create one",
60
+ },
61
+ register: {
62
+ title: "Create an account",
63
+ subtitle: "Set up your dev account",
64
+ submit: "Create account",
65
+ switchPrompt: "Already have an account?",
66
+ switchAction: "Sign in",
67
+ },
68
+ fields: {
69
+ name: "Display name (optional)",
70
+ email: "Email",
71
+ password: "Password",
72
+ },
73
+ }
74
+
75
+ /* The auth register of the `Input` primitive: taller, rounder, on the card
76
+ surface — the same 44 px pill as `OAuthButton`, so the provider button and the
77
+ fields under it line up. Kept here rather than added as a `size` on `Input`:
78
+ it is this screen's shape, not a general one. */
79
+ const fieldClass =
80
+ "h-auto rounded-xl border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground dark:bg-card"
81
+
82
+ export interface AuthFormProps {
83
+ /** Controlled mode — set it when the route decides (`/login` vs `/signup`). */
84
+ mode?: AuthMode
85
+ /** Uncontrolled starting mode. @default "login" */
86
+ initialMode?: AuthMode
87
+ /** Fires on both the controlled and the uncontrolled path (clear your `error` here). */
88
+ onModeChange?: (mode: AuthMode) => void
89
+ /** Where the app takes over: call your API, then redirect. */
90
+ onSubmit?: (values: AuthSubmitValues) => void | Promise<void>
91
+ /** Controlled busy state. Omitted, the form is busy while `onSubmit` is pending. */
92
+ loading?: boolean
93
+ /** The failure, under the fields. `null`/`undefined` shows nothing. */
94
+ error?: ReactNode
95
+ /** Pre-filled values — dev credentials, an invited address… */
96
+ defaultEmail?: string
97
+ defaultPassword?: string
98
+ defaultName?: string
99
+ /** Show the display-name field. @default true in `register`, false in `login` */
100
+ showNameField?: boolean
101
+ /** Native `minLength` on the password. @default 8 in `register`, 1 in `login` */
102
+ passwordMinLength?: number
103
+ /** Hides the bottom "No account? Create one" line (two dedicated routes, no toggle). */
104
+ hideModeSwitch?: boolean
105
+ /** Every string on the screen. Merged over the defaults, per mode. */
106
+ copy?: {
107
+ login?: Partial<AuthModeCopy>
108
+ register?: Partial<AuthModeCopy>
109
+ fields?: Partial<AuthFieldCopy>
110
+ }
111
+ /** Slot between the heading and the fields — the OAuth buttons and their separator. */
112
+ children?: ReactNode
113
+ /** Slot under the mode switch — terms, a link back to the site. */
114
+ footer?: ReactNode
115
+ className?: string
116
+ }
117
+
118
+ export function AuthForm({
119
+ mode: modeProp,
120
+ initialMode = "login",
121
+ onModeChange,
122
+ onSubmit,
123
+ loading,
124
+ error,
125
+ defaultEmail = "",
126
+ defaultPassword = "",
127
+ defaultName = "",
128
+ showNameField,
129
+ passwordMinLength,
130
+ hideModeSwitch,
131
+ copy,
132
+ children,
133
+ footer,
134
+ className,
135
+ }: AuthFormProps) {
136
+ const id = useId()
137
+ const [uncontrolledMode, setUncontrolledMode] = useState<AuthMode>(initialMode)
138
+ const mode = modeProp ?? uncontrolledMode
139
+
140
+ const [email, setEmail] = useState(defaultEmail)
141
+ const [password, setPassword] = useState(defaultPassword)
142
+ const [name, setName] = useState(defaultName)
143
+ const [pending, setPending] = useState(false)
144
+
145
+ const busy = loading ?? pending
146
+ const register = mode === "register"
147
+ const withName = showNameField ?? register
148
+ const fields = { ...authFormCopy.fields, ...copy?.fields }
149
+ const text = { ...authFormCopy[mode], ...copy?.[mode] }
150
+
151
+ function switchMode() {
152
+ const next: AuthMode = register ? "login" : "register"
153
+ if (modeProp === undefined) setUncontrolledMode(next)
154
+ onModeChange?.(next)
155
+ }
156
+
157
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
158
+ event.preventDefault()
159
+ if (busy) return
160
+ const values: AuthSubmitValues = { mode, email, password }
161
+ if (register && withName && name) values.name = name
162
+ try {
163
+ setPending(true)
164
+ await onSubmit?.(values)
165
+ } finally {
166
+ setPending(false)
167
+ }
168
+ }
169
+
170
+ return (
171
+ <div className={cn("space-y-6", className)}>
172
+ <div className="text-center">
173
+ <h2 className="text-2xl font-bold tracking-tight text-foreground">{text.title}</h2>
174
+ <p className="mt-2 text-sm text-muted-foreground">{text.subtitle}</p>
175
+ </div>
176
+
177
+ {children}
178
+
179
+ <form onSubmit={handleSubmit} className="space-y-3">
180
+ {/* The screen is placeholder-only by design (three fields, no room for a
181
+ column of labels), so each field still carries a real `Label` — just
182
+ visually hidden. A placeholder is not an accessible name. */}
183
+ {register && withName && (
184
+ <div>
185
+ <Label htmlFor={`${id}-name`} className="sr-only">
186
+ {fields.name}
187
+ </Label>
188
+ <Input
189
+ id={`${id}-name`}
190
+ type="text"
191
+ autoComplete="name"
192
+ placeholder={fields.name}
193
+ value={name}
194
+ onChange={(e) => setName(e.target.value)}
195
+ className={fieldClass}
196
+ />
197
+ </div>
198
+ )}
199
+
200
+ <div>
201
+ <Label htmlFor={`${id}-email`} className="sr-only">
202
+ {fields.email}
203
+ </Label>
204
+ <Input
205
+ id={`${id}-email`}
206
+ type="email"
207
+ autoComplete="email"
208
+ required
209
+ placeholder={fields.email}
210
+ value={email}
211
+ onChange={(e) => setEmail(e.target.value)}
212
+ className={fieldClass}
213
+ />
214
+ </div>
215
+
216
+ <div>
217
+ <Label htmlFor={`${id}-password`} className="sr-only">
218
+ {fields.password}
219
+ </Label>
220
+ <Input
221
+ id={`${id}-password`}
222
+ type="password"
223
+ autoComplete={register ? "new-password" : "current-password"}
224
+ required
225
+ minLength={passwordMinLength ?? (register ? 8 : 1)}
226
+ placeholder={fields.password}
227
+ value={password}
228
+ onChange={(e) => setPassword(e.target.value)}
229
+ className={fieldClass}
230
+ />
231
+ </div>
232
+
233
+ {error && <p className="text-center text-xs text-destructive">{error}</p>}
234
+
235
+ <Button
236
+ type="submit"
237
+ disabled={busy}
238
+ className="h-auto w-full gap-2 rounded-xl px-4 py-2.5 text-sm"
239
+ >
240
+ {busy && <Spinner className="size-3.5" />}
241
+ {text.submit}
242
+ </Button>
243
+ </form>
244
+
245
+ {!hideModeSwitch && (
246
+ // A plain <button> and not `Button variant="link"`: this one sits *inside*
247
+ // a sentence, and the primitive is an inline-flex box with a height.
248
+ <p className="text-center text-xs text-muted-foreground">
249
+ {text.switchPrompt}{" "}
250
+ <button
251
+ type="button"
252
+ onClick={switchMode}
253
+ className="font-medium text-primary underline-offset-4 hover:underline"
254
+ >
255
+ {text.switchAction}
256
+ </button>
257
+ </p>
258
+ )}
259
+
260
+ {footer}
261
+ </div>
262
+ )
263
+ }
@@ -0,0 +1,79 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { cn } from "../../utils"
4
+
5
+ /* ─── AuthLayout ───────────────────────────────────────────────────────────
6
+ The page an auth screen sits in: a fixed dark brand panel on the left (gone
7
+ below `lg`), the form column on the right, and the two backgrounds that make
8
+ the left side look like something — the flame halo bleeding out of the bottom
9
+ corner and the dot grid over it.
10
+
11
+ The layout owns the *chrome* (the split, the panel, its backgrounds, the
12
+ `max-w-sm` column the form lives in); `AuthBrandPanel` owns the content that
13
+ goes in it. That seam is what lets an app keep the screen and change every
14
+ word on it, or drop the brand panel entirely (`brand` omitted → the form
15
+ centers on the whole page). */
16
+
17
+ export interface AuthLayoutProps {
18
+ /** Left panel content — usually an `AuthBrandPanel`. Hidden below `lg`. */
19
+ brand?: ReactNode
20
+ /**
21
+ * Shown *above* the form below `lg`, where the brand panel is gone — usually
22
+ * an `AuthBrandMark variant="compact"`. Without it, small screens open
23
+ * straight on the form.
24
+ */
25
+ brandCompact?: ReactNode
26
+ /** The form column: `AuthForm`, or anything else you want centered there. */
27
+ children: ReactNode
28
+ className?: string
29
+ /** Reaches the dark panel (a different surface, another glow color…). */
30
+ brandClassName?: string
31
+ /** Reaches the form column (a wider container, a different background…). */
32
+ contentClassName?: string
33
+ }
34
+
35
+ export function AuthLayout({
36
+ brand,
37
+ brandCompact,
38
+ children,
39
+ className,
40
+ brandClassName,
41
+ contentClassName,
42
+ }: AuthLayoutProps) {
43
+ return (
44
+ <div className={cn("flex min-h-screen font-sans antialiased", className)}>
45
+ {brand && (
46
+ <div
47
+ className={cn(
48
+ "relative hidden w-1/2 overflow-hidden border-r border-white/8 bg-zinc-950 p-12 lg:block",
49
+ brandClassName
50
+ )}
51
+ >
52
+ {/* Flame halo, bottom-left, bleeding out of the corner. */}
53
+ <div className="pointer-events-none absolute bottom-0 left-0 h-[500px] w-[500px] bg-primary/20 blur-[120px]" />
54
+ {/* Not the package's `dot-grid` utility: that one is warm and follows
55
+ the theme, and this panel is a *fixed* dark surface (same rule as
56
+ `--night` in /marketing) — it needs a white grid in both themes. */}
57
+ <div
58
+ className="pointer-events-none absolute inset-0"
59
+ style={{
60
+ backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px)",
61
+ backgroundSize: "32px 32px",
62
+ }}
63
+ />
64
+ {brand}
65
+ </div>
66
+ )}
67
+
68
+ <div
69
+ className={cn(
70
+ "flex flex-1 flex-col items-center justify-center bg-background px-6 py-12",
71
+ contentClassName
72
+ )}
73
+ >
74
+ {brandCompact && <div className="mb-10 lg:hidden">{brandCompact}</div>}
75
+ <div className="w-full max-w-sm space-y-6">{children}</div>
76
+ </div>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { cn } from "../../utils"
4
+
5
+ /* The "or" between the provider buttons and the password form: two hairlines and
6
+ a word. Not the package's `Separator` — that one is a single `role="separator"`
7
+ rule, and this is a *labelled* divider, so it's a small composition of its own
8
+ rather than a variant that would have to bend `Separator`'s ARIA role. */
9
+
10
+ export interface AuthSeparatorProps {
11
+ /** The word in the middle. @default "or" */
12
+ children?: ReactNode
13
+ className?: string
14
+ }
15
+
16
+ export function AuthSeparator({ children = "or", className }: AuthSeparatorProps) {
17
+ return (
18
+ <div className={cn("flex items-center gap-3 text-xs text-muted-foreground", className)}>
19
+ <div className="h-px flex-1 bg-border" />
20
+ {children}
21
+ <div className="h-px flex-1 bg-border" />
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,38 @@
1
+ // The auth screen, in bricks.
2
+ //
3
+ // Re-exported from the package **root**, not from a sub-entry: nothing in here
4
+ // knows a Spunto concept (the brand panel's every word is a prop, the form posts
5
+ // nowhere), so it follows `CommandPalette` rather than `/workers` — see the
6
+ // README, § "Domain components".
7
+ //
8
+ // Five bricks rather than one `<AuthScreen />`, because the parts are what
9
+ // differ between apps: a product with three OAuth providers and no password,
10
+ // an internal tool with a password and no panel, and a marketing signup that
11
+ // wants its own headline all assemble the same pieces differently.
12
+ //
13
+ // AuthLayout — the split screen: dark brand panel, form column, backgrounds.
14
+ // AuthBrandPanel — what fills the dark half (+ `AuthBrandMark`, its logo lockup).
15
+ // AuthForm — email / password / display name, login ⇄ register, error, loading.
16
+ // OAuthButton — "Continue with <provider>" (+ the `GoogleIcon`/`GitHubIcon` marks).
17
+ // AuthSeparator — the labelled "or" between the two.
18
+ //
19
+ // Assembling them end to end: the showcase's "Créer sa page de login" guide.
20
+
21
+ export { AuthLayout } from "./auth-layout"
22
+ export type { AuthLayoutProps } from "./auth-layout"
23
+
24
+ export { AuthBrandPanel, AuthBrandMark } from "./auth-brand-panel"
25
+ export type { AuthBrandPanelProps, AuthBrandMarkProps } from "./auth-brand-panel"
26
+
27
+ export { AuthForm, authFormCopy } from "./auth-form"
28
+ export type { AuthFormProps, AuthFormCopy, AuthModeCopy, AuthFieldCopy } from "./auth-form"
29
+
30
+ export { OAuthButton } from "./oauth-button"
31
+ export type { OAuthButtonProps, OAuthProvider } from "./oauth-button"
32
+
33
+ export { GoogleIcon, GitHubIcon } from "./oauth-icons"
34
+
35
+ export { AuthSeparator } from "./auth-separator"
36
+ export type { AuthSeparatorProps } from "./auth-separator"
37
+
38
+ export type { AuthMode, AuthSubmitValues, AuthLinkProps, AuthLinkRender } from "./types"