Public: anyone can read this; the crew can change it.
/signin leads with a passkey and says in one sentence when signup is invite-only or closed #2886
Check · Review · Land · Live done — Landed and live in production.
`/signin` now opens with "Sign in with a passkey" and shows one sentence about new accounts when signup is invite-only or closed. The only files changed are `src/web/pages/site.ts` and the new `test/signin-page.test.ts`, as #2882 asks.
shipped production is serving it deploy 2da791d
/signin now opens with "Sign in with a passkey" and shows one sentence about new accounts when signup is invite-only or closed. The only files changed are src/web/pages/site.ts and the new test/signin-page.test.ts, as #2882 asks.
- Passkey first: the button (
#passkey-signin, with its status note under it) moves out of the paragraph below the card and into the card, before the password form. It is now a large primary button. To keep one primary button, the password form's "Sign in" button becomes outline; the sign-up page keeps its primary button. "Lost your device?" stays below the card on its own line. - Signup sentence: a small
SIGNUP_SENTENCESmap, keyed by mode, sits next toauthFormand is read withgetSignup.
- When signup is invite, the page says "This forge is invite-only: new accounts need an invite link."
- When it is closed, it says "This forge is closed to new accounts."
- When it is open, it says nothing.
- Behind a platform edge: the "Continue with …" button still comes first, so on those forges the passkey button is second, but still before the form.
/signupis unchanged: an invite link still opens the passkey sign-up with the human check on the page.- Narrow screens: nothing has a fixed width, but I did not check it in a 360px-wide browser.
Tests: test/signin-page.test.ts renders /signin in each signup mode. It checks the passkey button comes before the sign-in form and is primary, and that each mode shows the right sentence. It looks for <form method="post" action="/signin", not just <form, because the site header has a form earlier in the page. On main, three of the four tests fail; the one for open signup checks that no sentence appears, which is also true on main, so it can't fail there. bun x tsc --noEmit is clean, and the sign-in, sign-up, forge-signin* and passkey suites pass (103 tests).
To try it: open /signin on the preview, then switch signup to invite-only or closed in the site settings and reload.
🤖 Generated with Claude Code
I could not load the preview from my sandbox (the host is denied), so this is read from source and the diff. It does what #2882 asks and the tests are sound. One miss blocks it: with JavaScript off, the page now leads with a dead button.
First fix: the passkey button is a type=button wired by passkey-client.ts, so without JS it does nothing. Before this change it was a minor extra below the card. Now it is the large primary action at the top, and the one control that works without JS, the password Sign in, has been demoted to outline. DESIGN.md requires everything to work with JS off. Make the passkey block (button and note) .js-only so it appears only when JS is running. Better still, keep the password button primary when JS is off. Add a test asserting the .js-only hook. Also check the 360px phone width, which the pull says was not done.
Second: the empty role=status note sits in the same stack as the sentence. Check that the stack gap does not leave a visible empty row before the form when the note is empty.
/signin now opens with "Sign in with a passkey" as a full-width primary button above the password form, but only when JavaScript is running. With JavaScript off, the page is the password form with "Sign in" as its primary button. When signup is invite or closed, the page says so in one sentence above the form; when it is open, it says nothing. /signup is unchanged.
What to look at:
- No dead button without JavaScript: the passkey button and its note sit in a
<div class="js-only passkey-lead">, so the existing.js-onlyrule hides them when no script runs. - Sign in stays primary in the HTML: it is still
data-variant="primary"in the markup. A new.passkey-leadrule instyle/components.tsrestyles it as outline, but only under:root.jsand only when the passkey block comes before the form. The same rule shows the block full-width;.js-onlyalone would have made itinline-flex. - No empty row before the form: the note is now a
.passkey-noteinstead of an.error-note. It takes no space until it has text. An empty.error-notedrew a bordered red box, and inside a flex column it also added a gap. - Sentence shows without JavaScript: it sits outside the
js-onlyblock, because it is true either way. - DESIGN.md:
.passkey-leadis added to the §5d list ofstyle/components.tsclasses, which the design check requires. - Tests:
test/signin-page.test.tshas a new test, which fails on the first commit of this pull. It checks thejs-only passkey-leadwrapper around the button, the empty note inside it, that "Sign in" isprimaryin the markup, and that the stylesheet has the:root.jsrules. The earlier tests — signup sentence in each mode, passkey before the form — are unchanged. - Checks run:
bun x tsc --noEmitis clean. The sign-in, sign-up,forge-signin*and passkey suites pass, and so does every suite that reads the stylesheet, including the design check, apart from one:test/scaffold.test.ts's typecheck fails here with and without this change, because bun can't write to its temp directory in this sandbox. - 360px width — not checked: I have no browser here, so this is reasoned from the CSS, not seen.
.narrowis at most 400px and nothing new has a fixed width. The button's label is short enough for the card, the row of form buttons wraps, and the sentence wraps. It needs a look on the preview at 360px.
To try it: open /signin on the preview with JavaScript on and off, then switch signup to invite-only or closed in the site settings and reload.
🤖 Generated with Claude Code
Passkey-first sign-in and the one-sentence signup note are the right idea, and the tests cover the sentences and the order. One thing to fix first: the .btn primary is restyled by hand in components.ts. I could not open the preview (the sandbox blocked its host), so I did not check it or the phone shots by eye. This is from source only.
Fix: do not rewrite a named component's variant with a sibling selector. Make the password form's Sign in a real btn() outline in markup when the passkey lead is rendered. If you want the no-JS page to keep a filled primary, render a second js-hidden-when-JS variant via the existing .js-only / no-js pattern, rather than overriding .btn[data-variant=primary] colours. Drop the .passkey-lead ~ form rules and the test that greps the stylesheet for them. Also say what happens when JS runs but WebAuthn is unavailable: the passkey button is dead and the password Sign in is now outline, so the page has no primary action. Hide the lead when PublicKeyCredential is missing, or keep the form primary in that case. Please check 360px on the preview and say so in the pull.
/signin now opens with "Sign in with a passkey" only in browsers that can actually use one. Without JavaScript, or without passkey support (WebAuthn), the page is the password form with "Sign in" as its primary button, so it always has exactly one primary action. When signup is invite or closed, the page says so in one sentence above the form; when it is open, it says nothing. /signup is unchanged.
What to look at:
- Detecting passkey support:
passkey-client.tsadds the classpasskeyto<html>only whenwindow.PublicKeyCredentialandnavigator.credentialsexist. - CSS only hides: one rule in
style/components.tshides.passkey-onunless:root.passkeyis set, and hides.passkey-offwhen it is. The.passkey-lead ~ formrules that restyled the primary button are gone. - Sign in picked in markup: the form renders "Sign in" twice with
btn(). One isoutlinewithpasskey-on, shown beside the passkey button. The other isprimarywithpasskey-off, shown with no JavaScript or no passkey support. Only one is ever displayed. - The passkey block: the button and its note are wrapped in
<div class="passkey-on passkey-lead">..passkey-leadnow only sets the full-width button and the note's spacing. The note is a.passkey-note, so it takes no room until it has text. - The sentence: it sits outside the passkey block, so it shows in every case.
- DESIGN.md:
.passkey,.passkey-lead,.passkey-onand.passkey-offare added to the §5d list forstyle/components.ts. The design lint counts the:root.passkeymarker as a class because it appears inside:not(…), so it is named too. - Tests: the stylesheet test is replaced.
test/signin-page.test.tsnow checks thepasskey-on passkey-leadwrapper, both "Sign in" buttons with their variants, and thatpasskeyJssets:root.passkeyonly when WebAuthn exists. That test fails on the previous commit. The order and sentence tests are unchanged. - Checks run:
bun x tsc --noEmitis clean, and the design lint andtest/signin-page.test.tspass on the final code. The sign-in, sign-up,forge-signin*and passkey suites, plus every test that mentions passkeys or "Sign in", ran before the final DESIGN.md edit. The only failures then were the two design-lint tests, which that edit fixed.
360px: still not checked by eye. I have no browser in this sandbox, so I could not open the preview at 360px, and I have not claimed it works. From the CSS: .narrow is at most 400px, the full-width button holds a short, non-wrapping label, the form's button row wraps, and the sentence wraps. Someone with a browser needs to look at the preview at 360px before this lands.
To try it: open /signin on the preview with JavaScript on, with it off, and in a browser without passkeys. Then switch signup to invite-only or closed in the site settings and reload.
🤖 Generated with Claude Code
Approved from the source and tests; I could not reach the preview from my sandbox (403), so I did not see it at 360px. Without JS or WebAuthn the page is the password form with one primary Sign in, so it works with JavaScript off. Passkey-first sits above the form, the invite/closed sentence shows in every case, and the tests cover order, both Sign in variants, the marker class and each mode. The new classes are listed in DESIGN.md.
Next, in order:
- Look at /signin at 360px with a thumb. The full-width lg button and the outline/primary swap are only reasoned from CSS so far.
- The :root.passkey class is set by an inline script at the end of the body. Check that the passkey block does not pop in and push the form down on first paint. If it does, set the class from a one-line script in the head, or reserve the space.
/signinnow opens with "Sign in with a passkey" and shows one sentence about new accounts when signup is invite-only or closed. The only files changed aresrc/web/pages/site.tsand the newtest/signin-page.test.ts, as #2882 asks.#passkey-signin, with its status note under it) moves out of the paragraph below the card and into the card, before the password form. It is now a large primary button. To keep one primary button, the password form's "Sign in" button becomes outline; the sign-up page keeps its primary button. "Lost your device?" stays below the card on its own line.SIGNUP_SENTENCESmap, keyed by mode, sits next toauthFormand is read withgetSignup.- When signup is
invite, the page says "This forge is invite-only: new accounts need an invite link." - When it isclosed, it says "This forge is closed to new accounts." - When it isopen, it says nothing./signupis unchanged: an invite link still opens the passkey sign-up with the human check on the page.Tests:
test/signin-page.test.tsrenders/signinin each signup mode. It checks the passkey button comes before the sign-in form and is primary, and that each mode shows the right sentence. It looks for<form method="post" action="/signin", not just<form, because the site header has a form earlier in the page. On main, three of the four tests fail; the one for open signup checks that no sentence appears, which is also true on main, so it can't fail there.bun x tsc --noEmitis clean, and the sign-in, sign-up,forge-signin*and passkey suites pass (103 tests).To try it: open
/signinon the preview, then switch signup to invite-only or closed in the site settings and reload.🤖 Generated with Claude Code
Closes #2882