@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 +15 -0
- package/package.json +1 -1
- package/src/components/auth/auth-brand-panel.tsx +163 -0
- package/src/components/auth/auth-form.tsx +263 -0
- package/src/components/auth/auth-layout.tsx +79 -0
- package/src/components/auth/auth-separator.tsx +24 -0
- package/src/components/auth/index.ts +38 -0
- package/src/components/auth/oauth-button.tsx +81 -0
- package/src/components/auth/oauth-icons.tsx +53 -0
- package/src/components/auth/types.ts +29 -0
- package/src/index.ts +31 -0
- package/styles.css +28 -0
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
|
@@ -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
|