diff --git a/Taskfile.yml b/Taskfile.yml index 0b51555..025f48a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -66,6 +66,13 @@ tasks: cmds: - bun run vitest run + visual:check: + desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual// + dir: web + deps: [web:install] + cmds: + - bun visual/check.ts {{.CLI_ARGS}} + lint: desc: gofmt check, go vet, eslint (--fix) and vue-tsc cmds: diff --git a/docs/development.md b/docs/development.md index b2a7382..f6afa2e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -57,6 +57,8 @@ task --list # everything available task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v task cover # coverage of the use cases → tmp/coverage.out task mutate # gremlins mutation testing → tmp/gremlins.json +task test:web # SPA component tests (Vitest) +task visual:check -- log-in # screenshot design vs SPA for a screen → tmp/visual/log-in/ task lint # gofmt check, go vet, eslint --fix, vue-tsc task fmt # gofmt -w + prettier task run # build the production binary and run it (SPA embedded) on :8080 @@ -67,7 +69,23 @@ task clean # remove bin/, tmp/ and the built SPA - All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network. - HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler. -- The frontend has no test runner yet. `task lint:web` runs eslint and a type check. +- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check. + +## Visual check against the design + +`task visual:check -- [state]` compares the SPA with the Claude Design boards in [`docs/design/source`](design/source). The screen is a file name from [`docs/design/screens`](design/screens) (`log-in`, `home`, …), and the state is one of its headings. + +- It builds the SPA into `tmp/visual/dist` and serves both the build and the boards locally, so no API or dev server is needed. +- For every state it screenshots: + - the board's frame for that state; + - the SPA in the same state, at 1440×900 and 390×844, in light and dark. +- It saves `design-*.png`, `app-*.png` and `diff-*.png` (differing pixels in red) to `tmp/visual///`, and a summary to `tmp/visual//report.md`. +- [`web/visual/states.ts`](../web/visual/states.ts) says how to reach each state in the SPA: the route, the mocked `/v1` responses (keyed `'METHOD /v1/path'`) and any user steps. A state without an entry is captured on the design side only, and marked "design only" in the report. +- Narrow a run with `--viewport=desktop|mobile` and `--theme=light|dark`. +- Lucide icons are served from the local `@iconify-json/lucide` set, for both the boards and the SPA. api.iconify.design rate-limits repeated runs. +- The first time on a machine, run `bunx playwright install chromium` in `web/`. + +The pixel percentage only points at where to look, because font anti-aliasing always differs a little. Judge by comparing the image pairs. ## Data diff --git a/features/auth_shell_login_conformance.feature b/features/auth_shell_login_conformance.feature new file mode 100644 index 0000000..53da1bb --- /dev/null +++ b/features/auth_shell_login_conformance.feature @@ -0,0 +1,94 @@ +Feature: AuthShell and log-in screen conform to the design spec + + As the maintainer of RenewCMS + I want AuthShell and LoginView built from the exact primitives docs/design/components.md + specifies — AppLogo, UPageCard's own title/description styling, the muted page background, + UForm with "Forgot password?" in the Password field's #hint slot, and the neutral-by-default, + red-on-error input border + So that the log-in screen (and every other AuthShell-based screen) matches the visual spec of + record instead of ad hoc markup that happens to look similar + + Background: + Given AuthShell is the layout for every public route, per docs/design/routes.md + And AuthShell composes a UPageCard variant="outline" as the centered card, with AppLogo + rendered as the card's first element — left-aligned above the title/description block, + sharing the card's own 24px gap — not as a separate element centered outside/above the + card, per docs/design/components.md's AuthShell section and + docs/design/source/AuthCard.dc.html + And AppLogo is a reusable component (props include size) rendering the brand mark from + docs/design/source/brand/favicon.svg as inline SVG, not an , so it can theme-switch + And UPageCard's title renders through its own title slot as an h1 (tabindex="-1", so route + changes can move focus to it, per AuthShell's a11y spec), and its description renders + through UPageCard's description prop — neither is freeform markup in the default slot + And from the sm breakpoint up, the centered
sits on the muted page background + (--ui-bg-muted), the same canvas the rest of the app uses; below sm it sits on --ui-bg + instead, per docs/design/components.md's AuthShell "Responsive" line + And LoginView's form is a UForm wrapping the Username and Password UFormFields and the + submit button + And the Password UFormField's "Forgot password?" link renders through the field's #hint + slot, in the position the Auth board draws it — right-aligned on the label row — per + docs/design/source/RenewCMS Screens Auth.dc.html, group "Log in" + And every UInput on the page uses the neutral control-edge border (--ui-border-accented) by + default, and turns to the error edge (--ui-error) only when its field has an error, per + docs/design/components.md's Input section (":highlight bound to the field error") and + foundations.md's Borders and elevation + + Scenario: AppLogo renders the brand mark as inline SVG, and theme-switches + Given a public screen renders inside AuthShell + When AuthShell mounts + Then an AppLogo renders as UPageCard's first element, inside the card, left-aligned, sharing + the card's 24px gap with the title/description block below it — not centered above the card + And the logo is inline SVG sourced from docs/design/source/brand/favicon.svg, at 32px, not as + an tag + And the logo carries alt="RenewCMS" (role="img" aria-label="RenewCMS" on the SVG) + And the logo is not wrapped in a link, since there is nothing public to go back to + And in light mode the black square has no edge stroke + And in dark mode the black square has a 1px edge in --color-chalk-600 (#444444), so it + doesn't disappear into the dark background + + Scenario: UPageCard's title is the page h1, and its description uses UPageCard's own styling + Given AuthShell renders with title "Log in" and description + "Welcome back. Log in to manage your articles." + When AuthShell mounts + Then the card is a UPageCard with variant="outline" + And UPageCard's title slot renders an h1 with tabindex="-1" containing "Log in" + And UPageCard's description prop is "Welcome back. Log in to manage your articles." + And neither the title nor the description is rendered as separate freeform markup inside + UPageCard's default slot + + Scenario: The page sits on the muted background from sm up, and on the default background below sm + Given AuthShell renders any public screen at a viewport of sm or wider + When AuthShell mounts + Then the centered
uses the muted background token (--ui-bg-muted) + And the card keeps its border, is capped at 400px, and its top sits 12vh from the top + + Scenario: Below sm, AuthShell drops the muted background, the card border and the card padding + Given AuthShell renders any public screen at a viewport below sm + When AuthShell mounts + Then the centered
uses the default background token (--ui-bg), not the muted one + And the card has no border and fills the screen with 20px padding + And the fields and buttons on the page render at the xl size + + Scenario: The login form is a UForm + Given the user is on /login + When the login form renders + Then the form is a UForm, not a plain HTML
, wrapping the Username UFormField, the + Password UFormField and the submit button + + Scenario: "Forgot password?" renders in the Password field's hint slot + Given the user is on /login + When the login form renders + Then the Password UFormField's "Forgot password?" link is rendered through the field's + #hint slot + And the Password field's label reads only "Password", with no link inside it + + Scenario: Inputs are neutral by default and turn to the error edge only when their field has an error + Given the user is on /login with no validation errors yet + When the Username and Password UInput fields render + Then both use the neutral control-edge border (--ui-border-accented), with :highlight false + When the user submits with Username "ma" and Password empty (the Validation errors state, + per docs/design/screens/log-in.md) + Then the Username UInput's edge turns to the error color (--ui-error), with :highlight true, + alongside the message "Use at least 3 characters." + And the Password UInput's edge turns to the error color (--ui-error), with :highlight true, + alongside the message "Enter your password." diff --git a/features/auth_shell_login_conformance.qa.md b/features/auth_shell_login_conformance.qa.md new file mode 100644 index 0000000..7b91093 --- /dev/null +++ b/features/auth_shell_login_conformance.qa.md @@ -0,0 +1,87 @@ +# QA: AuthShell and log-in screen conform to the design spec + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView directly (LoginView +already needs `useApi`/`GET /v1/auth/registration` mocked per features/login_screen.qa.md's setup, +reused here only as scaffolding, not as the thing under test). + +1. **AppLogo renders inline SVG and theme-switches** — mount AuthShell with title "Log in"; + assert an inline `` (not ``) renders as UPageCard's first child element, left-aligned + (not centered) above the title/description block, sharing the card's 24px gap, at 32px, with + `role="img" aria-label="RenewCMS"`, and is not inside an ``; assert its outer square has no + stroke in light mode; switch to dark mode (toggle the `.dark` class the way + `nuxt_ui_shell_foundation`'s color-mode tests do) and assert a 1px stroke using + `--color-chalk-600` appears. +2. **UPageCard title is an h1 with tabindex="-1"** — mount AuthShell with title "Log in" and + description "Welcome back. Log in to manage your articles."; assert + `wrapper.find('h1').attributes('tabindex')` is `"-1"` and its text is "Log in"; assert the + description text "Welcome back. Log in to manage your articles." renders via UPageCard's + `description` prop (not as a sibling `

` hand-authored in AuthShell's template). This + replaces the older assertion in `AuthShell.spec.ts` that only checked for any `

`; keep that + assertion passing (h1 text still equals the title) while adding the tabindex check. +3. **Muted background at sm and up** — mount AuthShell at a `>= 640px` viewport (mock + `useBreakpoints`/matchMedia the way other shell tests do); assert `
`'s class list + includes the muted background utility (e.g. `bg-muted`); assert the card renders with a + border and is capped at 400px width, offset 12vh from the top. +4. **Default background, borderless card, 20px padding, xl controls below sm** — mount AuthShell + at a `< 640px` viewport; assert `
`'s class list includes the default background utility + (`bg-default`) and not the muted one; assert the card has no border class; assert the card's + padding is 20px; assert the Username/Password inputs and submit button render at `size="xl"`. +5. **Login form is a UForm** — mount LoginView at /login; assert the form wrapping the fields is + a UForm component instance, not a bare `` element with only `@submit.prevent` wired by + hand. +6. **Forgot password in the #hint slot** — mount LoginView at /login; assert the Password + UFormField is passed content (or renders content) in its `hint` slot containing a + "Forgot password?" link to /register/reset/request; assert the field's rendered label text is + exactly "Password" with no link inside it. +7. **Inputs default to neutral, then highlight red on error** — mount LoginView at /login; assert + the Username and Password `UInput` components render with `highlight` falsy (or absent) and + the theme's default ring class (`ring-(--ui-border-accented)`); then trigger the Validation + errors state (type "ma" into Username, submit with Password empty); assert both `UInput`s now + receive `:highlight="true"` (or the field's error is otherwise truthy) so the edge renders in + `--ui-error`, alongside "Use at least 3 characters." and "Enter your password." respectively. + +### Implementation finding for coder +`web/src/views/LoginView.vue`'s Username and Password `UInput`s currently receive no `:highlight` +prop at all — `UFormField` gets `:error="usernameError"` / `:error="passwordError"`, but that value +is never forwarded to the inner `UInput`. Today the edge never turns red in the Validation errors +state; this isn't a stale/inverted binding, it's a missing one. Wiring `:highlight="!!usernameError"` +/ `:highlight="!!passwordError"` (or equivalent) onto each `UInput` is the fix scenario 7 above +pins down. + +## Visual (manual verification — desktop and mobile, light and dark) + +Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", plus +`docs/design/source/RenewCMS Components Product.dc.html`'s AuthShell drawing and +`docs/design/source/RenewCMS Components Primitives.dc.html`'s Input drawing. + +- [ ] AppLogo renders inside UPageCard as its first element, left-aligned above the title (not + centered above the card), sharing the card's 24px gap; the mark matches the favicon.svg + drawing exactly (white R on a black square), desktop and mobile; in dark mode the 1px + chalk-600 edge is visible around the square, in light mode it is absent. +- [ ] UPageCard title ("Log in") renders at the card-title size/weight from the AuthShell drawing + and receives focus (visible focus ring) on route change; description text matches + "Welcome back. Log in to manage your articles." styling, desktop and mobile, light and dark. +- [ ] At sm and up: 400px card with its border, 12vh top offset, on the muted page canvas, light + and dark. +- [ ] Below sm: card fills the screen borderless with 20px padding on the default (not muted) + background; fields and the submit button render at the touch (xl, 44px) size, light and + dark. +- [ ] The Password field's "Forgot password?" link sits right-aligned on the label row (the hint + position), matching the Auth board, desktop and mobile, light and dark. +- [ ] Username and Password fields show the neutral control-edge border by default; in the + Validation errors state (short username, empty password) both fields' edges turn to the + error color alongside their messages, in both light and dark. +- [ ] Keyboard/focus: Tab order remains Username → Password → show/hide toggle → "Forgot + password?" → submit; UPageCard's h1 is reachable/focusable via tabindex="-1" on route + change, matching AuthShell's existing a11y spec. + +## Manual verification + +- **AppLogo reused in DashboardShell** — out of scope for this slice (AuthShell/login only). + `DashboardShell.vue`'s sidebar header is currently a plain ` + RenewCMS` (not AppLogo), and its footer is a bare `Log out` instead of the + UserMenu component components.md specifies. Both need their own "Built from" conformance pass in + a later DashboardShell slice; AppLogo (built here with a `size` prop) will be ready to drop in + at that point without changes. diff --git a/web/bun.lock b/web/bun.lock index fbc2ec5..d325f85 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -13,6 +13,8 @@ "vue-router": "^4.6.3", }, "devDependencies": { + "@iconify-json/lucide": "^1.2.136", + "@iconify/utils": "^3.1.7", "@tsconfig/node24": "^24.0.3", "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.2", @@ -25,6 +27,7 @@ "jiti": "^2.6.1", "jsdom": "25", "npm-run-all2": "^8.0.4", + "playwright": "^1.63.0", "prettier": "3.6.2", "typescript": "~5.9.0", "vite": "^7.2.4", @@ -199,6 +202,8 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@iconify-json/lucide": ["@iconify-json/lucide@1.2.136", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-Wk+OeDVKkw+1LELZqRR3gXWIy5jNx5XjNMvVc9KoygJYmGqclv36FYqseRuJuxBIKDggdoZWNBxNRrOkiRbtwQ=="], + "@iconify/collections": ["@iconify/collections@1.0.741", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-AUzucHF84kVFMzUQUgBsjmoy53U2ecvDZksFRsEOjfSBvJ5Hy+j8GeJ3dDSvBzNgLZ5BSxR6Zvh38VwrHdFbeg=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], @@ -1015,6 +1020,10 @@ "pkg-types": ["pkg-types@2.3.3", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], diff --git a/web/package.json b/web/package.json index 500454f..ad2f280 100644 --- a/web/package.json +++ b/web/package.json @@ -24,6 +24,8 @@ "vue-router": "^4.6.3" }, "devDependencies": { + "@iconify-json/lucide": "^1.2.136", + "@iconify/utils": "^3.1.7", "@tsconfig/node24": "^24.0.3", "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.2", @@ -36,6 +38,7 @@ "jiti": "^2.6.1", "jsdom": "25", "npm-run-all2": "^8.0.4", + "playwright": "^1.63.0", "prettier": "3.6.2", "typescript": "~5.9.0", "vite": "^7.2.4", diff --git a/web/src/components/AppLogo.vue b/web/src/components/AppLogo.vue new file mode 100644 index 0000000..6404452 --- /dev/null +++ b/web/src/components/AppLogo.vue @@ -0,0 +1,32 @@ + + + diff --git a/web/src/components/AuthShell.vue b/web/src/components/AuthShell.vue index e027eeb..2d384a7 100644 --- a/web/src/components/AuthShell.vue +++ b/web/src/components/AuthShell.vue @@ -1,5 +1,6 @@ - - - - +