Use one verb per action in the English UI: sign in, sign out, create account #49

Merged
lilleman merged 3 commits from uniform-signin-wording into main 2026-08-05 00:57:02 +02:00
7 changed files with 25 additions and 9 deletions
Showing only changes of commit 34b668d49f - Show all commits
+5
View File
@@ -275,6 +275,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`.
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
"log out" or "sign up" — a second spelling for one button reads as a second thing. Guarded by
`src/i18n/load.test.ts` (a test file cannot live in `locales/`, which loads every `.ts` in it as a
catalog); the noun ("a sign-in error") is unaffected. A plugin's catalog
and every other locale follow the same one-verb-per-action rule in their own language.
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
and not "ids=x,y".
+2 -2
View File
@@ -26,9 +26,9 @@ async function signIn(page: import("@playwright/test").Page): Promise<void> {
test("seeded admin logs in from the advertised URL (http://localhost:3000) and reaches the dashboard", async ({ page }) => {
test.setTimeout(90_000);
// Open the app at the URL the first-run banner prints, then follow its "Log in" call to action.
// Open the app at the URL the first-run banner prints, then follow the landing's "Sign in" action.
await page.goto("/");
await page.getByRole("link", { name: "Log in" }).click();
await page.locator("#main-content").getByRole("link", { name: "Sign in" }).click();
await signIn(page);
// Signed in on the app — NOT dumped on the Kratos /error "Page not found" page.
+3 -2
View File
@@ -102,8 +102,9 @@ test("the public landing at / is ungated and links to sign in + register", async
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.locator('use[href="#i-gear"]')).toHaveCount(0); // no settings cog to offer a signed-out visitor
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
// Scoped to the landing itself: the anonymous sidebar offers a "Sign in" link of its own.
await expect(page.locator("#main-content").getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login");
await expect(page.locator("#main-content").getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await shot(page, "live-05-public-landing");
});
+1 -1
View File
@@ -122,7 +122,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
const rule = "─".repeat(58);
return [
`${rule}`,
`│ Plainpages is ready — log in at ${opts.appUrl}`,
`│ Plainpages is ready — sign in at ${opts.appUrl}`,
`│ email: ${opts.email}`,
`│ password: ${opts.password}`,
`│ ⚠ Demo admin credentials — change them before production.`,
+10
View File
@@ -24,6 +24,16 @@ test("the shipped core catalogs load and agree key for key", async () => {
assert.ok(loaded.available.includes("sv-SE"));
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
// One verb per action: sign in / sign out / create account. Two spellings for one button ("Log in"
// on the landing, "Sign in" in the sidebar) read as two different things. Nouns ("a sign-in error")
// are fine — only the competing verbs are out. AGENTS.md → Rules.
const competing = /\b(log[\s-]?in|log[\s-]?out|sign[\s-]?up)\b/i;
const offenders = Object.entries(loaded.core.get("en-US") ?? {})
.map(([key, message]) => [key, typeof message === "string" ? message : Object.values(message).join(" ")] as const)
.filter(([, text]) => competing.test(text))
.map(([key, text]) => `${key}: ${text}`);
assert.deepEqual(offenders, []);
});
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
+3 -3
View File
@@ -108,7 +108,7 @@ const messages = {
// "Email" on the login form and "First name" on a registration form with that trait, so it can
// only be translated per field (auth.field.* above), never per id.
"kratos.1010022": "Sign in with password",
"kratos.1040001": "Sign up",
"kratos.1040001": "Create account",
"kratos.1060003":
"An email containing a recovery code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and make sure to use the address you registered with.",
"kratos.1070008": "Resend code",
@@ -127,7 +127,7 @@ const messages = {
"landing.lead":
"{{brand}} is a self-hostable foundation for admin and operational UIs — sign-in, a config-driven menu, and a server-rendered, zero-JS design system. You add the domain-specific screens by dropping in plugin folders.",
"landing.register": "Create account",
"landing.signIn": "Log in",
"landing.signIn": "Sign in",
"landing.title": "Operational web apps, without the boilerplate.",
"locale.label": "Language",
@@ -137,7 +137,7 @@ const messages = {
"oauth.consentExpired": "This authorization request has expired. Please start again from the application you were signing in to.",
"oauth.loginExpired": "This sign-in request has expired. Please start again from the application you were signing in to.",
"oauth.logoutExpired": "This logout request has expired. Please start again from the application you were signing out of.",
"oauth.logoutExpired": "This sign-out request has expired. Please start again from the application you were signing out of.",
"pagination.go": "Go",
"pagination.label": "Pagination",
+1 -1
View File
@@ -2,7 +2,6 @@
## Unfinnished work
- [ ] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language.
- [ ] When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it.
- [ ] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something?
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
@@ -46,6 +45,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE``Accept-Language``en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
- [x] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language. (English now says **sign in / sign out / create account** everywhere; Swedish was already uniform. Three outliers went: the landing's `landing.signIn` "Log in", the registration submit `kratos.1040001` "Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — and `oauth.logoutExpired`'s "This logout request", whose sign-in twin said "sign-in request". The first-run banner says "sign in at" too. Guarded in `src/i18n/load.test.ts`, which fails on a competing verb in any shipped core English value — it lives there because `locales/` loads every `.ts` in it as a catalog — and recorded in AGENTS.md → Rules; the two e2e specs that clicked "Log in" now scope to `#main-content`, since the anonymous sidebar carries a "Sign in" link of its own.)
- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.)
### Architectural review findings (2026-07-02)