Hold the one-verb rule in AGENTS.md instead of a unit test
This commit was merged in pull request #49.
This commit is contained in:
@@ -277,11 +277,12 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
|||||||
in words; handlers pass `ctx.t`.
|
in words; handlers pass `ctx.t`.
|
||||||
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
|
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
|
||||||
"log out" or "sign up", inflections included — a second spelling for one button reads as a second
|
"log out" or "sign up", inflections included — a second spelling for one button reads as a second
|
||||||
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. Guarded over the
|
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and
|
||||||
shipped core catalog by `src/i18n/load.test.ts` (a test file cannot live in `locales/`, which loads
|
every other locale follow the same rule in their own language. An unmapped Kratos id renders
|
||||||
every `.ts` in it as a catalog); a plugin's catalog and every other locale follow the same rule in
|
Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the
|
||||||
their own language, unguarded. An unmapped Kratos id still renders Kratos' own wording — map it to
|
UI grows, slightly different wording is often the right call, and a check that fails the build on
|
||||||
hold the line.
|
a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped
|
||||||
|
with the rule.
|
||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
- 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"
|
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||||
and not "ids=x,y".
|
and not "ids=x,y".
|
||||||
|
|||||||
+1
-10
@@ -18,21 +18,12 @@ async function fixture(files: Record<string, string>): Promise<{ localesDir: str
|
|||||||
return { localesDir: join(root, "locales"), pluginsDir: join(root, "plugins") };
|
return { localesDir: join(root, "locales"), pluginsDir: join(root, "plugins") };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("the shipped core catalogs load, agree key for key, and use one verb per action", async () => {
|
test("the shipped core catalogs load and agree key for key", async () => {
|
||||||
const loaded = await loadI18n(); // no args ⇒ the real src/i18n/locales + plugins/
|
const loaded = await loadI18n(); // no args ⇒ the real src/i18n/locales + plugins/
|
||||||
assert.ok(loaded.available.includes("en-US"));
|
assert.ok(loaded.available.includes("en-US"));
|
||||||
assert.ok(loaded.available.includes("sv-SE"));
|
assert.ok(loaded.available.includes("sv-SE"));
|
||||||
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
|
assert.deepEqual([...loaded.available].sort(), loaded.available); // sorted, so "sv" resolves deterministically
|
||||||
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
|
assert.ok(Object.keys(loaded.core.get("en-US") ?? {}).length > 20);
|
||||||
|
|
||||||
// One verb per action; inflected too, and the noun ("a sign-in error") is fine. AGENTS.md → Rules.
|
|
||||||
// The lookbehind spares a path (/login) and a word ending in one (blog in…).
|
|
||||||
const competing = /(?<![/\w])(log(?:ged|ging)?[\s-]?(?:in|out)|sign(?:ed|ing)?[\s-]?up)s?\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 () => {
|
test("a plugin's catalogs load under its id and may cover fewer locales than the host", async () => {
|
||||||
|
|||||||
@@ -45,7 +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] 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] 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] 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] 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, and the admin example's email hint says "the sign-in identifier". The rule is recorded in AGENTS.md → Rules and held by the author: a unit test asserting the verb shipped first and was dropped on the maintainer's call, since a build that fails on a word removes the judgment a growing UI needs. 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`.)
|
- [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)
|
### Architectural review findings (2026-07-02)
|
||||||
|
|||||||
Reference in New Issue
Block a user