fix(signup): stop an unsolved anti-spam check clearing the form, and say what the check is #171

Sammanfogat
supernaut sammanfogade 2 incheckningar från fix/signup-captcha-ux in i main 2026-08-02 19:47:47 +00:00
Ägare

Closes #162, closes #163.

#162 — an unsolved anti-spam check no longer clears the form.

The captcha was the only control on the form without a native constraint. Every other field has
required, pattern or type="email", so the browser already blocks an incomplete submit; the
captcha did not, which is why it was the one that appeared to wipe everything. /api/signup answers
every outcome with a 303 to /signup?status=… and the re-rendered form echoes nothing, so the
response was a fresh empty page.

cap-widget is a form-associated custom element and already implements this: given required it
sets valueMissing validity and scrolls itself into view on invalid. So the form never leaves the
page and there is nothing to restore. Because the validity anchor lives in the widget's shadow root,
an always-rendered role="alert" message is unhidden from an invalid listener — a colour flash
alone is not accessible. It is re-hidden on solve so a stale complaint cannot sit under a solved
widget.

Verified in a real browser: with username, email and terms filled and only the captcha missing,
formValid: false, the URL never gains a ?status= parameter — so no request reached the endpoint
— and every field retains its value.

#163 — the check now explains itself, in both languages.

A visitor who has met the usual third-party image puzzles has no way to know this one is
self-hosted, image-free and does no tracking. Adds a permanent description to both forms, wired
through aria-describedby on the wrapper role="group" — not on <cap-widget>, which has no ARIA
role of its own. The description id is rendered unconditionally, so the dangling-id shape fixed in
#146 is not reintroduced.

Also adds a <noscript> line: the token field is created at runtime and the solver is WebAssembly,
so with JavaScript off sign-up cannot succeed at all — and today the only way to discover that is to
submit and be told the check failed.

docs/content-style.md gains the Swedish house term "arbetsbevis (proof of work)" so it stops
being decided per writer.

Not in scope

Preserving typed values after a server-side rejection (taken, ratelimited, error, nomail)
is #164 and needs a privacy-policy decision first. No draft cookie is added here.

Verification

pnpm lint, pnpm check, pnpm lang-check, pnpm test (206), pnpm test:e2e and pnpm mdlint
all clean.

Closes #162, closes #163. **#162 — an unsolved anti-spam check no longer clears the form.** The captcha was the only control on the form without a native constraint. Every other field has `required`, `pattern` or `type="email"`, so the browser already blocks an incomplete submit; the captcha did not, which is why it was the one that appeared to wipe everything. `/api/signup` answers every outcome with a 303 to `/signup?status=…` and the re-rendered form echoes nothing, so the response was a fresh empty page. `cap-widget` is a form-associated custom element and already implements this: given `required` it sets `valueMissing` validity and scrolls itself into view on `invalid`. So the form never leaves the page and there is nothing to restore. Because the validity anchor lives in the widget's shadow root, an always-rendered `role="alert"` message is unhidden from an `invalid` listener — a colour flash alone is not accessible. It is re-hidden on `solve` so a stale complaint cannot sit under a solved widget. Verified in a real browser: with username, email and terms filled and only the captcha missing, `formValid: false`, the URL never gains a `?status=` parameter — so no request reached the endpoint — and every field retains its value. **#163 — the check now explains itself, in both languages.** A visitor who has met the usual third-party image puzzles has no way to know this one is self-hosted, image-free and does no tracking. Adds a permanent description to both forms, wired through `aria-describedby` on the wrapper `role="group"` — not on `<cap-widget>`, which has no ARIA role of its own. The description id is rendered unconditionally, so the dangling-id shape fixed in #146 is not reintroduced. Also adds a `<noscript>` line: the token field is created at runtime and the solver is WebAssembly, so with JavaScript off sign-up cannot succeed at all — and today the only way to discover that is to submit and be told the check failed. `docs/content-style.md` gains the Swedish house term **"arbetsbevis (proof of work)"** so it stops being decided per writer. ### Not in scope Preserving typed values after a *server-side* rejection (`taken`, `ratelimited`, `error`, `nomail`) is #164 and needs a privacy-policy decision first. No draft cookie is added here. ### Verification `pnpm lint`, `pnpm check`, `pnpm lang-check`, `pnpm test` (206), `pnpm test:e2e` and `pnpm mdlint` all clean.
supernaut lade till 2 incheckningar 2026-08-02 15:53:21 +00:00
Submitting the sign-up form without solving the anti-spam check posted the
form, was rejected by the server, and returned an empty page: username, email,
account type and the accepted-terms tick all had to be retyped. The captcha was
the only control on either form without a native constraint, so it was the only
one that could get that far.

cap-widget is a form-associated custom element and already implements this:
given `required` it reports `valueMissing`, so the browser blocks the submit and
the form never leaves the page. Nothing has to be restored because nothing is
lost.

Its validity anchor lives in the widget's shadow root, so the native message
bubble is not guaranteed and the widget's own reaction is a visual flash that
says nothing. So an always-rendered `role="alert"` notice sits in the wrapper,
unhidden from an `invalid` listener and hidden again on `solve`, and its id is
appended to the wrapper's `aria-describedby` — composed the way the username
field is, so the conditional error id is kept rather than replaced, and rendered
unconditionally so the id always resolves.

Applies to both the sign-up form and the re-issue form.

Verified in Firefox: with every other field filled, `form.checkValidity()` is
false, clicking submit leaves the URL at /signup with no status parameter, the
typed values are still in the inputs, and the notice is visible in the page
language.

Preserving typed values after a server-side rejection (taken / ratelimited /
error / nomail) is deliberately not addressed here.

Closes #162.
feat(signup): say what the anti-spam check is, in both languages
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m15s
5d71aacab7
The sign-up form showed a widget labelled "Anti-spam check" and nothing else. A
visitor who has met the usual third-party image puzzles had no way to know that
this one is self-hosted, image-free and tracks nothing — a point in our favour we
were keeping to ourselves.

Adds a permanent description between the label and the widget on both the
sign-up and the re-issue form, and a noscript line: the hidden cap-token field is
created by the widget at runtime and the solver is WebAssembly, so with
JavaScript off sign-up cannot succeed at all, and until now the only way to find
that out was to submit and be told the check failed.

The description is wired through aria-describedby on the wrapper div, not on
cap-widget — the custom element has no ARIA role of its own and its control lives
in the shadow root. Its id is rendered unconditionally and composed the way the
username field's is, so the description is added to the existing ids rather than
replacing them and never points at an id that does not exist (#146). It is
static text, so deliberately not aria-live; only the error notices announce
themselves.

The Swedish house term for proof of work is "arbetsbevis (proof of work)" — the
Swedish word with the English gloss in parentheses on first use, per the
introduce-a-term rule. It is not in the Forgejo sv-SE glossary, so the style
guide gains a section for terms decided here, and the next writer does not have
to re-decide it.

Verified in Firefox on both forms: the description renders, is visible, and every
id in the wrapper's aria-describedby resolves to an element.

Closes #163.
supernaut tvångsskickade fix/signup-captcha-ux från 5d71aacab7
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m15s
till 6b3db94a07
Alla kontroller lyckades
ci / ci (pull_request) Successful in 1m21s
2026-08-02 19:38:48 +00:00
Jämför
supernaut sammanfogade incheckning 1740eff052 till main 2026-08-02 19:47:47 +00:00
supernaut tog bort grenen fix/signup-captcha-ux 2026-08-02 19:47:47 +00:00
Logga in för att delta i denna konversation.
Inga granskare
Ingen milstolpe
Inget projekt
Inga tilldelade
1 deltagare
Notiser
Förfallodatum
Förfallodatumet är ogiltigt eller utanför gränserna. Använd formatet "åååå-mm-dd".

Inget förfallodatum satt.

Beroenden

Inga beroenden satta

Referens
bitborg/bitborg-web!171
Ingen beskrivning angiven.