Skip to content

Commit 155b617

Browse files
committed
design(auth): make the backup sign-in methods a plain list, one choice at a time
The disclosure hid three methods behind a summary, then swapped the whole card when one was chosen and offered no way back. It read as a pile of links. - Backup methods are now a grouped list: leading symbol, succinct title, one line of detail, chevron. Apple's "Lists and tables" guidance asks for a disclosure indicator on a row that opens a view, and for a clear press state; both are there. - Choosing a row opens that method alone, with "All sign-in options" to step back. No screen shows two ways in at once. - With no provider enabled, the same list is the landing instead of dropping people straight into code entry. - The heading explains why to sign in rather than how identifiers work. Refs #45
1 parent 162106e commit 155b617

5 files changed

Lines changed: 253 additions & 111 deletions

File tree

docs/design.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,12 @@ applies.
122122
`#747775` border (light) or a `#131314` button with a `#8e918f` border
123123
(dark).
124124

125-
Backup methods live under one **Other ways to sign in** disclosure, so the
126-
first screen stays two decisions wide.
125+
Backup methods are a grouped list under **Other ways to sign in**: a leading
126+
symbol, a succinct title, one line of detail, and a chevron, following the
127+
["Lists and tables"](https://developer.apple.com/design/human-interface-guidelines/lists-and-tables)
128+
rule that a row which opens a view carries a disclosure indicator. Choosing a
129+
row opens that method alone, with **All sign-in options** to step back, so no
130+
screen ever presents two ways in at once.
127131

128132
---
129133

src/components/auth/AuthScreen.tsx

Lines changed: 123 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type AuthProvider,
77
} from "../../lib/supabaseClient";
88
import { CollectBrand } from "../CollectBrand";
9-
import { Icon } from "../Icon";
9+
import { Icon, type IconName } from "../Icon";
1010
import { isAppleMobileBrowser, isStandaloneApp } from "../../lib/platform";
1111
import { CodeSignIn } from "./CodeSignIn";
1212
import { EmailLinkForm } from "./EmailLinkForm";
@@ -23,22 +23,42 @@ interface AuthScreenProps {
2323
onPasswordSet?: () => void;
2424
}
2525

26-
/** Sign-in methods, in the order the screen offers them. */
27-
type EntryMode = "provider" | "link" | "password" | "code";
26+
/** The backup methods, in the order the list offers them. */
27+
type BackupMethod = "link" | "password" | "code";
2828

29-
const modeTitle: Record<EntryMode, string> = {
30-
provider: "Sign in.",
31-
link: "Sign in with a link.",
32-
password: "Sign in with a password.",
33-
code: "Sign in with a code.",
34-
};
35-
36-
const modeAction: Record<EntryMode, string> = {
37-
provider: "Continue with Google or Apple",
38-
link: "Email me a sign-in link",
39-
password: "Use an email address and password",
40-
code: "Use a code from your administrator or another device",
41-
};
29+
const backupMethods: {
30+
id: BackupMethod;
31+
icon: IconName;
32+
title: string;
33+
detail: string;
34+
heading: string;
35+
lede: string;
36+
}[] = [
37+
{
38+
id: "link",
39+
icon: "send",
40+
title: "Email me a sign-in link",
41+
detail: "Opens collect from your inbox.",
42+
heading: "Sign in with a link.",
43+
lede: "We send a one-time link to the address on your account.",
44+
},
45+
{
46+
id: "password",
47+
icon: "key",
48+
title: "Sign in with a password",
49+
detail: "For an account that already has one.",
50+
heading: "Sign in with a password.",
51+
lede: "Use the email address and password on your account.",
52+
},
53+
{
54+
id: "code",
55+
icon: "phone",
56+
title: "Sign in with a code",
57+
detail: "Eight characters, from your administrator or a signed-in device.",
58+
heading: "Sign in with a code.",
59+
lede: "Enter the code you were given, or request a fresh one below.",
60+
},
61+
];
4262

4363
function isLocalDevelopmentOrigin(): boolean {
4464
if (typeof window === "undefined") return false;
@@ -51,11 +71,10 @@ function isLocalDevelopmentOrigin(): boolean {
5171
/**
5272
* The single sign-in surface for both installed apps.
5373
*
54-
* Providers come first: they need no email, so a deployment never depends on a
55-
* mail quota to admit people. Every other method stays available underneath,
56-
* each named after the authentication it performs, as Apple's "Managing
57-
* accounts" guidance requires. Only methods this deployment actually offers
58-
* are shown.
74+
* One decision at a time. Providers come first: they need no email, so a
75+
* deployment never depends on a mail quota to admit people. Everything else
76+
* sits in one list of named methods, and choosing one opens that method alone
77+
* with an explicit way back — no screen ever shows two ways in at once.
5978
*/
6079
export function AuthScreen({
6180
configured,
@@ -71,7 +90,7 @@ export function AuthScreen({
7190
configured ? knownAuthProviders() : [],
7291
);
7392
const [providersChecked, setProvidersChecked] = useState(false);
74-
const [mode, setMode] = useState<EntryMode | null>(null);
93+
const [method, setMethod] = useState<BackupMethod | null>(null);
7594
const [callbackIssue, setCallbackIssue] = useState<string | null>(() =>
7695
authCallbackError(),
7796
);
@@ -92,18 +111,7 @@ export function AuthScreen({
92111
};
93112
}, [configured]);
94113

95-
// Before the deployment answers, keep the screen quiet rather than guessing
96-
// a method that may not exist here.
97-
const entryMode: EntryMode =
98-
mode ??
99-
(providers.length ? "provider" : role === "admin" ? "link" : "code");
100-
const alternatives = (
101-
["provider", "link", "password", "code"] as const
102-
).filter(
103-
(candidate) =>
104-
candidate !== entryMode &&
105-
(candidate !== "provider" || providers.length > 0),
106-
);
114+
const chosen = backupMethods.find((candidate) => candidate.id === method);
107115

108116
if (requirePasswordSetup) {
109117
return (
@@ -122,19 +130,34 @@ export function AuthScreen({
122130
<CollectBrand />
123131
</div>
124132
<section className="auth-card" aria-labelledby="auth-title">
133+
{chosen && (
134+
<button
135+
type="button"
136+
className="text-button auth-back"
137+
onClick={() => {
138+
setMethod(null);
139+
setCallbackIssue(null);
140+
}}
141+
>
142+
<Icon name="chevron-left" size={16} /> All sign-in options
143+
</button>
144+
)}
145+
125146
<h1 id="auth-title">
126-
{role === "admin" && entryMode === "provider"
127-
? "Admin sign in."
128-
: modeTitle[entryMode]}
147+
{chosen
148+
? chosen.heading
149+
: role === "admin"
150+
? "Admin sign in."
151+
: "Sign in."}
129152
</h1>
130153
<p>
131154
{!configured
132155
? "Authentication is not configured for this deployment."
133-
: entryMode === "provider"
134-
? "Sign in to reach the projects you contribute to. Your email address stays your identifier."
135-
: entryMode === "code"
136-
? "Enter the code your administrator issued, or request a new one below."
137-
: "Use the email address your invitation was sent to."}
156+
: chosen
157+
? chosen.lede
158+
: providers.length
159+
? "Sign in to reach the projects you contribute to."
160+
: "Choose how to sign in."}
138161
</p>
139162

140163
{configured && (
@@ -145,48 +168,64 @@ export function AuthScreen({
145168
</p>
146169
)}
147170

148-
{entryMode === "provider" && (
149-
<ProviderSignIn
150-
providers={providers}
151-
surface={role}
152-
onFailure={() => setCallbackIssue(null)}
153-
/>
154-
)}
155-
{entryMode === "link" && (
156-
<EmailLinkForm showLocalRedirectHint={showLocalRedirectHint} />
157-
)}
158-
{entryMode === "password" && <PasswordForm />}
159-
{entryMode === "code" && <CodeSignIn autoFocus={mode === "code"} />}
160-
161-
{entryMode === "provider" && standalone && (
162-
<p className="auth-config-note">
163-
<Icon name="info" size={16} />
164-
<span>
165-
The provider opens in the browser. If it does not return to
166-
this app, sign in there and use a code from the signed-in
167-
browser.
168-
</span>
169-
</p>
170-
)}
171+
{chosen ? (
172+
<>
173+
{chosen.id === "link" && (
174+
<EmailLinkForm
175+
showLocalRedirectHint={showLocalRedirectHint}
176+
/>
177+
)}
178+
{chosen.id === "password" && <PasswordForm />}
179+
{chosen.id === "code" && <CodeSignIn autoFocus />}
180+
</>
181+
) : (
182+
<>
183+
<ProviderSignIn
184+
providers={providers}
185+
surface={role}
186+
onFailure={() => setCallbackIssue(null)}
187+
/>
188+
189+
{providers.length > 0 && standalone && (
190+
<p className="auth-config-note">
191+
<Icon name="info" size={16} />
192+
<span>
193+
The provider opens in the browser. If it does not return
194+
to this app, sign in there and use a code from the
195+
signed-in browser.
196+
</span>
197+
</p>
198+
)}
171199

172-
{providersChecked && alternatives.length > 0 && (
173-
<details className="auth-alternatives">
174-
<summary>Other ways to sign in</summary>
175-
{alternatives.map((candidate) => (
176-
<button
177-
key={candidate}
178-
type="button"
179-
className="text-button"
180-
onClick={() => {
181-
setMode(candidate);
182-
setCallbackIssue(null);
183-
}}
184-
>
185-
{modeAction[candidate]}{" "}
186-
<Icon name="arrow-right" size={15} />
187-
</button>
188-
))}
189-
</details>
200+
{providersChecked && (
201+
<>
202+
{providers.length > 0 && (
203+
<p className="auth-list-heading">Other ways to sign in</p>
204+
)}
205+
<ul className="auth-method-list">
206+
{backupMethods.map((candidate) => (
207+
<li key={candidate.id}>
208+
<button
209+
type="button"
210+
className="auth-method"
211+
onClick={() => {
212+
setMethod(candidate.id);
213+
setCallbackIssue(null);
214+
}}
215+
>
216+
<Icon name={candidate.icon} size={19} />
217+
<span className="auth-method-copy">
218+
<strong>{candidate.title}</strong>
219+
<span>{candidate.detail}</span>
220+
</span>
221+
<Icon name="chevron-right" size={17} />
222+
</button>
223+
</li>
224+
))}
225+
</ul>
226+
</>
227+
)}
228+
</>
190229
)}
191230
</>
192231
)}
@@ -197,7 +236,7 @@ export function AuthScreen({
197236
</button>
198237
)}
199238

200-
{showInstallHint && (
239+
{showInstallHint && !chosen && (
201240
<details className="auth-install-help">
202241
<summary>
203242
<Icon name="plus" size={16} /> Add collect to Home Screen

src/styles/foundation.css

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,6 +1956,78 @@ p {
19561956
.auth-sent .text-button {
19571957
margin-top: 20px;
19581958
}
1959+
/* Backup methods.
1960+
A grouped list in the platform idiom: a leading symbol, a succinct title
1961+
with one line of detail, and a chevron for the row that opens a view.
1962+
Apple HIG "Lists and tables": use a disclosure indicator for rows that
1963+
drill in, and give every row a clear press state. */
1964+
.auth-list-heading {
1965+
margin: 26px 0 8px;
1966+
color: var(--secondary);
1967+
font-size: 0.75rem;
1968+
font-weight: 650;
1969+
letter-spacing: 0.05em;
1970+
text-transform: uppercase;
1971+
}
1972+
.auth-method-list {
1973+
margin: 14px 0 0;
1974+
padding: 0;
1975+
border: 1px solid var(--separator-light);
1976+
border-radius: 14px;
1977+
list-style: none;
1978+
overflow: hidden;
1979+
}
1980+
.auth-method-list li + li {
1981+
border-top: 1px solid var(--separator-light);
1982+
}
1983+
.auth-method {
1984+
width: 100%;
1985+
min-height: 60px;
1986+
display: flex;
1987+
align-items: center;
1988+
gap: 12px;
1989+
padding: 12px 14px;
1990+
border: 0;
1991+
background: transparent;
1992+
color: var(--text);
1993+
text-align: left;
1994+
transition: background 0.16s ease;
1995+
}
1996+
.auth-method:hover {
1997+
background: var(--grouped);
1998+
}
1999+
.auth-method:active {
2000+
background: var(--grouped-strong);
2001+
}
2002+
.auth-method > svg:first-child {
2003+
flex: 0 0 auto;
2004+
color: var(--secondary);
2005+
}
2006+
.auth-method > svg:last-child {
2007+
flex: 0 0 auto;
2008+
margin-left: auto;
2009+
color: var(--tertiary);
2010+
}
2011+
.auth-method-copy {
2012+
min-width: 0;
2013+
display: flex;
2014+
flex-direction: column;
2015+
gap: 2px;
2016+
}
2017+
.auth-method-copy strong {
2018+
font-size: 0.9375rem;
2019+
font-weight: 600;
2020+
}
2021+
.auth-method-copy span {
2022+
color: var(--secondary);
2023+
font-size: 0.8125rem;
2024+
line-height: 1.35;
2025+
}
2026+
.auth-back {
2027+
margin: -6px 0 6px;
2028+
color: var(--secondary);
2029+
}
2030+
19592031
.auth-install-help,
19602032
.auth-alternatives {
19612033
margin-top: 22px;

0 commit comments

Comments
 (0)