@spunto/design-system 0.17.0 → 0.18.1

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
@@ -102,6 +102,21 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
102
102
  highlighted, so a query with no result still inserts a line break. The query either
103
103
  comes from the caller (`query`, the "/" case) or from a `CommandMenuInput` rendered
104
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)).
105
120
  - _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
106
121
  xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
107
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.17.0",
3
+ "version": "0.18.1",
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"
@@ -0,0 +1,81 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { cn } from "../../utils"
4
+ import { buttonVariants } from "../button-variants"
5
+ import { GitHubIcon, GoogleIcon } from "./oauth-icons"
6
+ import type { AuthLinkRender } from "./types"
7
+
8
+ /* ─── OAuthButton ──────────────────────────────────────────────────────────
9
+ "Continue with <provider>", full width, above the separator.
10
+
11
+ It's the design system's `Button` in its `outline` register, only taller and
12
+ rounder than any of its sizes — an auth screen's two big targets (this one and
13
+ the submit) are 44 px pills, not 32 px toolbar buttons. That's a `className`
14
+ over `buttonVariants`, deliberately, rather than a new `size` on the primitive:
15
+ the shape belongs to this screen, not to buttons in general.
16
+
17
+ Going through `buttonVariants` (and not the `Button` component) is also what
18
+ makes the link case honest: an OAuth "button" is nearly always an `<a href>`
19
+ to the API's redirect route, and both cases must come out pixel-identical. */
20
+
21
+ export type OAuthProvider = "google" | "github"
22
+
23
+ const providers: Record<OAuthProvider, { label: string; icon: ReactNode }> = {
24
+ google: { label: "Continue with Google", icon: <GoogleIcon /> },
25
+ github: { label: "Continue with GitHub", icon: <GitHubIcon /> },
26
+ }
27
+
28
+ export interface OAuthButtonProps {
29
+ /** Picks the shipped icon and the default label. */
30
+ provider?: OAuthProvider
31
+ /** Overrides the provider's icon (or supplies one for a provider we don't ship). */
32
+ icon?: ReactNode
33
+ /** Overrides the label. Defaults to "Continue with <provider>". */
34
+ children?: ReactNode
35
+ /**
36
+ * Where the provider dance starts — an API route, not a client-side one
37
+ * (`/api/auth/login`). Set it and the button renders as an `<a>`.
38
+ */
39
+ href?: string
40
+ /** Alternative to `href` when the app opens the flow itself (a popup, a SDK call). */
41
+ onClick?: () => void
42
+ disabled?: boolean
43
+ /** `link` renders the anchor (a router link…); without it, a plain `<a>`. */
44
+ render?: { link?: AuthLinkRender }
45
+ className?: string
46
+ }
47
+
48
+ export function OAuthButton({
49
+ provider = "google",
50
+ icon,
51
+ children,
52
+ href,
53
+ onClick,
54
+ disabled,
55
+ render,
56
+ className,
57
+ }: OAuthButtonProps) {
58
+ const preset = providers[provider]
59
+ const classes = cn(
60
+ buttonVariants({ variant: "outline" }),
61
+ "h-auto w-full gap-3 rounded-xl bg-card px-4 py-3 text-sm font-medium text-foreground",
62
+ className
63
+ )
64
+ const content = (
65
+ <>
66
+ {icon ?? preset?.icon}
67
+ {children ?? preset?.label}
68
+ </>
69
+ )
70
+
71
+ if (href && !disabled) {
72
+ const link = render?.link ?? ((props) => <a {...props} />)
73
+ return link({ href, className: classes, children: content })
74
+ }
75
+
76
+ return (
77
+ <button type="button" className={classes} onClick={onClick} disabled={disabled}>
78
+ {content}
79
+ </button>
80
+ )
81
+ }
@@ -0,0 +1,53 @@
1
+ import type { ComponentProps } from "react"
2
+
3
+ import { cn } from "../../utils"
4
+
5
+ /* The two marks that actually appear on an auth screen, shipped with the package
6
+ for the same reason `/devcontainer` ships its stack marks: a login button that
7
+ has to fetch its provider logo from a CDN is a login button that renders wrong
8
+ on a cold cache. Google's is drawn in its brand colors (its guidelines don't
9
+ allow recoloring it); GitHub's inherits `currentColor` like every other glyph. */
10
+
11
+ export function GoogleIcon({ className, ...props }: ComponentProps<"svg">) {
12
+ return (
13
+ <svg
14
+ xmlns="http://www.w3.org/2000/svg"
15
+ viewBox="0 0 24 24"
16
+ aria-hidden="true"
17
+ className={cn("h-4 w-4 shrink-0", className)}
18
+ {...props}
19
+ >
20
+ <path
21
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
22
+ fill="#4285F4"
23
+ />
24
+ <path
25
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
26
+ fill="#34A853"
27
+ />
28
+ <path
29
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
30
+ fill="#FBBC05"
31
+ />
32
+ <path
33
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
34
+ fill="#EA4335"
35
+ />
36
+ </svg>
37
+ )
38
+ }
39
+
40
+ export function GitHubIcon({ className, ...props }: ComponentProps<"svg">) {
41
+ return (
42
+ <svg
43
+ xmlns="http://www.w3.org/2000/svg"
44
+ viewBox="0 0 24 24"
45
+ fill="currentColor"
46
+ aria-hidden="true"
47
+ className={cn("h-4 w-4 shrink-0", className)}
48
+ {...props}
49
+ >
50
+ <path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.58.1.79-.25.79-.56v-2.17c-3.2.7-3.88-1.37-3.88-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.79 0c2.2-1.5 3.17-1.18 3.17-1.18.63 1.59.24 2.76.12 3.05.74.81 1.19 1.84 1.19 3.1 0 4.43-2.7 5.4-5.26 5.69.41.36.78 1.06.78 2.14v3.17c0 .31.2.67.8.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5z" />
51
+ </svg>
52
+ )
53
+ }
@@ -0,0 +1,29 @@
1
+ import type { ComponentProps, ReactElement } from "react"
2
+
3
+ /** The two faces of one screen: signing in, or opening an account. */
4
+ export type AuthMode = "login" | "register"
5
+
6
+ /**
7
+ * What `AuthForm` hands back on submit. `name` only exists in `register` mode
8
+ * (and only when the display-name field is shown and filled).
9
+ *
10
+ * The package **never calls an API**: it collects, validates natively (`required`,
11
+ * `type="email"`, `minLength`) and gives the values back. Which endpoint they go
12
+ * to, what a session cookie is, and where to redirect afterwards are the app's.
13
+ */
14
+ export interface AuthSubmitValues {
15
+ mode: AuthMode
16
+ email: string
17
+ password: string
18
+ name?: string
19
+ }
20
+
21
+ /**
22
+ * Props handed to the app's link component — spread them all.
23
+ *
24
+ * Same slot motif as `CommandPaletteLinkRender` and `WorkerCard`'s `render.link`:
25
+ * the package never imports `next/link`, so anything that navigates takes an
26
+ * `href` plus an optional renderer. Without one, it falls back to a plain `<a>`.
27
+ */
28
+ export type AuthLinkProps = ComponentProps<"a"> & { href: string }
29
+ export type AuthLinkRender = (props: AuthLinkProps) => ReactElement
package/src/index.ts CHANGED
@@ -177,6 +177,37 @@ export type {
177
177
  CommandMenuLinkRender,
178
178
  } from "./components/command-menu"
179
179
 
180
+ // Auth — the login/signup screen in bricks (layout, brand panel, form, OAuth
181
+ // button, separator). Domain-free like the palette, hence its place in the root:
182
+ // every word of it is a prop, and it neither fetches nor redirects. Assembling
183
+ // them: the showcase's "Créer sa page de login" guide.
184
+ export {
185
+ AuthLayout,
186
+ AuthBrandPanel,
187
+ AuthBrandMark,
188
+ AuthForm,
189
+ authFormCopy,
190
+ OAuthButton,
191
+ AuthSeparator,
192
+ GoogleIcon,
193
+ GitHubIcon,
194
+ } from "./components/auth"
195
+ export type {
196
+ AuthLayoutProps,
197
+ AuthBrandPanelProps,
198
+ AuthBrandMarkProps,
199
+ AuthFormProps,
200
+ AuthFormCopy,
201
+ AuthModeCopy,
202
+ AuthFieldCopy,
203
+ AuthMode,
204
+ AuthSubmitValues,
205
+ AuthLinkProps,
206
+ AuthLinkRender,
207
+ OAuthButtonProps,
208
+ OAuthProvider,
209
+ } from "./components/auth"
210
+
180
211
  // Kbd — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K). Used by the
181
212
  // palette, exported on its own for anywhere else a shortcut needs drawing.
182
213
  export { Kbd } from "./components/kbd"
package/styles.css CHANGED
@@ -351,6 +351,34 @@
351
351
  ::-webkit-scrollbar-thumb:hover { background: color-mix(in oklch, var(--muted-foreground) 30%, transparent); }
352
352
  }
353
353
 
354
+ /* ─── Touch devices — no zoom-on-focus ───────────────────────────────────────
355
+ * iOS Safari (and other coarse-pointer browsers) zoom the whole page in the
356
+ * moment a form control smaller than 16px takes focus, then leaves the user
357
+ * to zoom back out by hand. Individual components already set `text-base`
358
+ * for this (Input, Textarea, the command palette's search field), but any
359
+ * caller passing its own `text-sm`/`text-xs` override — very common, since
360
+ * that's how a form matches its surrounding density on desktop — clobbers it
361
+ * through `tailwind-merge`. Deliberately unlayered (outside `@layer`): Tailwind
362
+ * utilities all live inside named layers, and an unlayered rule always wins
363
+ * the cascade over a layered one regardless of source order or specificity —
364
+ * so this holds no matter what class a consumer adds. */
365
+ @media (any-pointer: coarse) {
366
+ input:not(
367
+ [type="checkbox"],
368
+ [type="radio"],
369
+ [type="range"],
370
+ [type="color"],
371
+ [type="file"],
372
+ [type="button"],
373
+ [type="submit"],
374
+ [type="reset"]
375
+ ),
376
+ textarea,
377
+ select {
378
+ font-size: 16px;
379
+ }
380
+ }
381
+
354
382
  /* ─── Terminal — xterm.js scrollbar ──────────────────────────────────────────
355
383
  * Restyle xterm's own custom scrollbar (14px, square corners) to match the
356
384
  * thin/rounded one above. Its width/position are set inline by xterm's JS on