Plan 71 R1 - SekiroKenjii/Quizzivy GitHub Wiki

R1 — Foundations and front door (v0.7.0)

The first release of Phase R (70-redesign-overview.md). It lays the deck's visual foundation under the whole app and rebuilds everything a person sees before they reach a console: sign in, forgot password, join, change password, the four system pages and the splash. It also adds the one maintenance mechanism the programme uses. R0's groundwork (deck import, docs rewrite, gap register) rides to production in the same version.

Nothing inside a console is rebuilt. The old teacher and student consoles take the new palette, font and focus ring and keep their layout and geometry, because the primitives are additive (ordering rule 1, D7). Screens are compared with docs/design/deck/Quizzivy Sign in.dc.html (#login, #forgot, #join, #joined, #change), Quizzivy System pages.dc.html (#404, #403, #error, #maintenance) and Quizzivy Splash.dc.html (#loading, #skeleton, #slow, #offline, #update, #expired). Design gaps are cited by DG id from docs/design/gaps.md.

Deliverable: deck tokens (light and dark), Be Vietnam Pro, motion, icons and additive primitives; the rebuilt front door, system pages and splash; maintenance windows end to end (operator command, public status, 503 gate, deadline extensions a running attempt picks up). Exit criteria:

  • every front-door, system and splash screen matches the deck at 360, 768, 1024, 1280 and 1440, light and dark, measured in the browser;
  • every old /admin and /app route keeps its layout at 1280 and 360, in light, with the new palette and font;
  • a maintenance window rehearsed locally end to end: scheduled with cmd/maintenance, the SPA shows the maintenance page, an attempt in progress is extended and its timer shows the new deadline after the window, a start that would overlap is refused;
  • every CI step green locally and in CI, E2E chromium and live green;
  • released to production as v0.7.0.

Decisions taken in this file, worth a second look.

  • Deck geometry is scoped by data-scale="deck", not by call site. Colours, font and the focus ring change globally. Control heights, paddings and the sm/md/lg radii that differ from today apply only inside an element carrying data-scale="deck" (Tailwind v4's in-data-* variant, and --radius redefined in that scope). Rebuilt surfaces set it on their root: the front door and system pages in R1, each console as it is rebuilt. This is what "additive" means in practice: an old screen cannot shift because no class it renders changes meaning. R5 deletes the scope and makes the deck geometry the default (the "primitive defaults flip").
  • No theme control ships in R1. The preference (light | dark | system, default light, the deck's startTheme) and the boot script exist, and the front door honours them, but the only surfaces the deck gives a toggle are the console top bars, account menus and Settings › Appearance, all rebuilt in R3–R5. Old consoles force light while mounted, so a dark preference set later in the student app (R3) cannot paint the old teacher console half dark (D7). Dark is verified in R1 through the test harness.
  • Deck token names are kept verbatim on :root, so deck CSS diffs against ours. The lime is brand in utilities (bg-brand, text-brand-ink) because shadcn's accent already means the hover surface. The legacy shadcn and semantic names (destructive-ink, *-foreground) stay as aliases until R5 rather than sweeping old screens now.
  • Three tokens go beyond the deck, for accessibility (DG-30, DG-31): --focus (the focus ring, set to --muted-fg: 5.4:1 on white, 7.1:1 on the dark background), a darker --input border (#8a9395 light, 3.1:1; #5d6769 dark, 3.2:1) and --danger-solid (oklch(0.55 0.2 27), about 5.4:1 under white text) for destructive buttons. The deck's --ring (2.6:1) and --border (1.25:1) stay for what the deck uses them for. The token test measures all three, so a design answer that changes them fails loudly rather than silently.
  • One maintenance mechanism, written only by the operator. app.maintenance_windows is created by a migration and written only through cmd/maintenance under MAINTENANCE_DATABASE_URL; the app role gets SELECT and nothing else. There is no MAINTENANCE_MODE environment switch and no admin endpoint: the deck draws no control (DG-42), and a table the API cannot write cannot be turned on by a compromised session.
  • Extensions are applied when the window is scheduled, in the same statement that inserts it, audited as System (actor_user_id NULL). Every in-progress attempt whose deadline falls after the window's start gains the window's length; every assignment that closes inside the window gains the same. Doing it at scheduling time needs no timer in the API and cannot be skipped by a process that was asleep at the start. Cancelling or ending a window early keeps the extensions: giving time back is harmless, taking it back mid-test is not.
  • No new attempt may overlap a window. Starting one whose deadline would pass a scheduled window's start answers 409 MAINTENANCE_SCHEDULED; resuming is never refused. The start and the scheduling serialise on one advisory lock, following the LockForDraftUse / LockForVersionUse precedent, so no attempt slips between them.
  • The 503 gate exempts no role and covers the integrity beacon. An admin exemption needs the principal, which exists only after auth, and a role model R2 replaces; the operator ends a window early instead. Events the beacon would send during a window are dropped, and the client pauses integrity capture while the maintenance or session-expired overlay shows, so a student is never flagged for looking away from a page that told them to wait.
  • A running attempt learns a moved deadline. Today the client takes deadlineAt only from the session payload, so an extension (a teacher's Extend, or maintenance) reaches an open tab only after a reload, and its local timer submits early. R1 returns deadlineAt from every autosave and refuses a timer_expired submit that arrives more than five seconds before the server's deadline (409 DEADLINE_NOT_REACHED); the store adopts the server's deadline from either. Without this, maintenance extensions would be granted on the server and lost in the browser.
  • Unavailable is not signed out. A refresh that fails because the network is down or the API answers 503 no longer signs the user out; only a refused refresh does. Today an offline cold load lands on /login, and maintenance would do the same to every open tab.
  • App states are overlays over a mounted route, not routes. Maintenance, session expired and update render above the current page (made inert), so an attempt stays mounted beneath with its local draft. The deck's "Check again" reloads the page; ours re-reads the status and closes the overlay, for the same reason. The update prompt waits for the next navigation and never appears on the take-test route.
  • Joined is a state of /join/:code, not a route. 70-redesign-overview.md §5 lists the front-door routes and has no /joined; the success step lives in the join route's location state (it survives a reload). /join/:code/confirm redirects, so printed QR codes and old links keep working.
  • Password rules are a schema constraint. newPassword gains pattern: '[\p{N}\p{P}\p{S}]' (a number, punctuation or symbol character; a Vietnamese letter is a letter), enforced once by httpx.ValidateRequests. "Different from the temporary password" becomes PASSWORD_UNCHANGED, applied to every change (for an ordinary change it is a comparison with the verified current password). Existing passwords are never re-validated.
  • Organisation details come from build-time config. The org name (brand panel, system-page caption) and the front-desk phone and hours (Forgot password) are VITE_ORG_* values, each line hidden when unset (DG-41). The build values stay after R5: R5's Organization settings take precedence on the front door, which falls back to these values when a setting is empty or the API is unreachable, and they are the Landing's contact source (R11). R11 adds VITE_ORG_EMAIL and VITE_ORG_ADDRESS (T-R11.4b); no release removes them.
  • The join page sends at most one preview per distinct complete code, as today's confirm page does. The re-budget of the join and login limits for a class behind one school NAT stays in R2, as the approved plan places it; R1 does not raise request volume, and a unit test pins that.
  • The §6 alphabet string is authoritative. ABCDEFGHJKLMNPQRSTUVWXYZ23456789 contains L; the spec's "(no 1/I/L)" and DG-02's "no … L" are wrong, and both the client (features/join/code.ts) and the server (classes/domain/joincode.go) use the string. The R1 docs task corrects the prose.
  • The read side lives in a new availability module: the window query, the status endpoint and the start guard. R5's organization module may absorb it; nothing else depends on the name.

T-R1.1 — Tokens and the token test

Depends on: T-R0.1 Touches: web/src/index.css, web/tests/units/styles/tokens.test.ts, web/tests/units/styles/no-raw-colours.test.ts (new) Size: M Done when:

  • :root carries the deck's light set under the deck's own names, copied from the :root rule of docs/design/deck/Quizzivy Teacher.dc.html (all seven pages share it): --bg, --sidebar, --card, --muted, --hover, --fg, --muted-fg, --border, --primary, --primary-fg, --ring, --accent-c, --accent-soft, --accent-ink, --success|warning|danger|info each with -soft and -ink, --overlay. The two shadows are --qz-shadow and --qz-shadow-lg (Tailwind owns --shadow-*).
  • html.dark carries the deck's :root.dark set; @custom-variant dark becomes (&:where(.dark, .dark *)); color-scheme follows the theme. The deck's data-qz-theme attribute is not carried.
  • :root[data-accent="mono"] { --accent-c: var(--fg) } exists at token level; nothing in R1 sets the attribute (the deck's accent: mono is a designer prop).
  • Colours the deck hard-codes become tokens (DG-34): --accent-fg (#1b2123, text on lime, both themes), --switch-thumb, --danger-solid and --danger-solid-fg, --paper and --paper-fg (content keeps a light paper in dark mode, DG-35), --qr-bg and --qr-fg, --font-serif.
  • Accessibility tokens (DG-30, DG-31): --focus: var(--muted-fg); --input #8a9395 light, #5d6769 dark; --danger-solid: oklch(0.55 0.2 27) in both themes.
  • @theme inline exposes the deck names as utilities (bg-sidebar, bg-hover, text-fg, text-muted-fg, bg-overlay, border-input), the lime as brand, brand-soft, brand-ink, brand-fg, every semantic -soft/-ink, and shadow-card / shadow-float. The shadcn names the vendored files use (background, foreground, card-foreground, popover, popover-foreground, primary-foreground, secondary, secondary-foreground, muted-foreground, accent = hover surface, accent-foreground, destructive, input, ring) point at deck tokens. destructive-ink, destructive-foreground, success-foreground and warning-foreground stay as aliases, marked for removal in R5.
  • Type scale: xs, sm, base, lg, xl unchanged (already the deck's 12, 13, 14, 17, 20). Added: 3xs 10.5, 2xs 11, caption 11.5, meta 12.5, ui 13.5, body 14.5 (lh 1.6), md 15, title 16, stat-sm 18, stat 22, h1 24 (600, -0.02em, lh 1.25), h1-student 26, kpi-sm 28, kpi 30, display 34. Landing-only sizes wait for R11.
  • Radii: --radius-2xl 14px, --radius-3xl 16px, new --radius-seg 7px and --radius-ctl 9px, all global (no current code uses rounded-2xl/3xl). --radius is redefined to 8px only under [data-scale="deck"], giving the deck's sm 6 / md 8 / lg 10 there while old screens keep 4 / 6 / 8.
  • Z-scale: --z-sheet 70, --z-alert 80, --z-dialog 85, --z-toast 90, --z-popover 100 (menus and pickers above dialogs, as qz-controls.js does).
  • Breakpoint --breakpoint-auth: 56.25rem (900px, the Sign in brand panel).
  • body has an explicit var(--bg) background and -webkit-font-smoothing: antialiased. The .student-surface 44px rule, a floor on buttons below 1024px, stays unchanged. R3 keeps it: the class sits on the student shell's <main> and on FocusLayout, and a control drawn to the deck's size opts out of it (AGENTS.md, "Design").
  • tokens.test.ts rewritten at the same path. It parses hex, rgba() and oklch() in both blocks (compositing rgba over --bg) and asserts: every themed light token has a dark override except the shared solids (accent-c, success, warning, danger, info); the neutrals (bg … ring, primary) have oklch chroma < 0.03; --primary has L < 0.35 light and > 0.85 dark; hue bands brand 110–135, success 140–170, warning 55–90, danger 15–35, info 220–240, and info is the only family allowed in the blue band; contrast in both themes ≥ 4.5 for fg and muted-fg on bg, card, sidebar, muted, for each -ink on its -soft, for primary-fg on primary, for accent-fg on accent-c, and for danger-solid-fg on danger-solid; ≥ 3 for focus on bg, card, sidebar, muted and for input on bg and card. The existing "@theme references only variables" case is kept.
  • no-raw-colours.test.ts: no hex, rgb(), rgba() or oklch() literal and no Tailwind palette utility (zinc-, slate-, red-, …) in web/src/**/*.{ts,tsx} outside features/auth/components/GoogleMark.tsx; hits found today are fixed in the same PR.
  • pnpm build green; every existing unit test green, except assertions of colour class names, each listed in the PR.

T-R1.2 — Be Vietnam Pro, self-hosted

Depends on: T-R1.1 Touches: web/package.json, web/pnpm-lock.yaml, web/src/index.css, web/tests/integration/fonts.test.ts (new) Size: S Done when:

  • New dependency @fontsource/be-vietnam-pro, reason stated in the PR: the deck mandates the family, font-src 'self' in web/public/_headers forbids Google Fonts, and the package pins versioned OFL files with unicode-range subsets. Vendoring woff2 files by hand was the alternative considered.
  • Imported: latin and vietnamese subsets × 400/500/600/700, font-display: swap; latin-ext left out.
  • --font-sans: "Be Vietnam Pro", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; Inter removed.
  • fonts.test.ts (real build): the CSS references fonts only under /assets/*.woff2, the Vietnamese subset is emitted, and no fonts.googleapis.com or fonts.gstatic.com string appears anywhere in dist/.
  • _headers CSP unchanged; checked in Chromium that "Đặng Thu Hà · Nguyễn Gia Bảo · Ươ" renders in Be Vietnam Pro with no fallback glyph (DevTools "Rendered fonts", noted in PR).

T-R1.3 — Theme preference and the boot script

Depends on: T-R1.1 Touches: web/index.html, web/public/boot.js (new), web/public/_headers, web/public/brand/quizzivy-mark-on-dark.svg (new, copied from the deck), web/src/lib/theme.ts (new), web/src/components/shared/Brand.tsx, web/src/layouts/{AdminLayout,StudentLayout,FocusLayout}.tsx, web/tests/units/lib/theme.test.ts (new), web/tests/units/i18n/default-locale.test.ts (new), web/tests/e2e/theme.spec.ts (new) Size: M Done when:

  • The preference is light | dark | system in localStorage['quizzivy.theme']; absent or invalid means light. Every storage access is in try/catch and the app renders without storage (70 §6 "Browser storage").
  • Vietnamese is the default language (DG-20): an absent or invalid quizzivy.locale, or storage that throws, resolves to vi in both boot.js (html.lang="vi") and lib/i18n (DEFAULT_LOCALE), never to the deck's English default. web/tests/units/i18n/default-locale.test.ts (new) covers the three cases for both.
  • web/public/boot.js is an external classic script (CSP script-src 'self' allows no inline script) loaded blocking in <head>. It reads quizzivy.theme and quizzivy.locale, sets html.dark, html.style.colorScheme and html.lang, and sets <meta name="theme-color"> to the resolved --bg (#ffffff / #0e1213; two metas with media in system mode). Nothing else; under 1 KB; _headers gives /boot.js Cache-Control: no-cache.
  • lib/theme.ts: readThemePreference, writeThemePreference, useResolvedTheme() (system follows prefers-color-scheme live through a matchMedia listener, read with useSyncExternalStore), and useForcedLightTheme(), which applies light while its component is mounted and restores the preference on unmount.
  • AdminLayout, StudentLayout and FocusLayout call useForcedLightTheme(). The front door and system pages follow the preference. No user-facing toggle ships; the server copy of the preference (users.preferences) arrives in R4.
  • Brand.tsx gains theme="auto", swapping the colour and on-dark logo and mark by the resolved theme; onDark stays for always-dark surfaces.
  • Unit tests: default, invalid value, storage throwing, system following an emulated media change, forced light restoring the preference.
  • theme.spec.ts: with quizzivy.theme=dark, /login's document has html.dark and a dark computed background before React mounts (no light flash); with system and page.emulateMedia({ colorScheme: "dark" }) the page follows live; /admin renders light under a dark preference.

T-R1.4 — Motion and icons

Depends on: T-R1.1 Touches: web/src/index.css, web/src/components/shared/{MarqueeText,LiveDot}.tsx (new), web/src/lib/icons.ts (new), web/tests/units/shared/{marquee-text,live-dot}.test.tsx (new), web/tests/units/lib/icons.test.ts (new) Size: S Done when:

  • Keyframes from the deck: qz-marquee (to translateX(calc(-50% - 16px))), qz-pulse (opacity 1 → .35, 1.6 s), qz-spin, qz-shimmer (1.4 s linear), qz-breath (1.8 s), qz-indet (1.2 s). Under prefers-reduced-motion each has a static state, not a shortened one: marquee → ellipsis, pulse → solid dot, shimmer → flat --muted, breath → still mark, indeterminate → a fixed 40% bar. The existing global reduced-motion block stays.
  • MarqueeText animates only when its text overflows (measured with a ResizeObserver, not the deck's 600 ms polling), duplicates the text with the copy aria-hidden, exposes the full text once to assistive tech and in title, pauses on hover and on focus-within (WCAG 2.2.2, DG-33), and renders an ellipsis under reduced motion.
  • LiveDot is a 7px --success dot using qz-pulse, aria-hidden (the text beside it carries the meaning), static under reduced motion.
  • lib/icons.ts maps the deck's kebab names to canonical lucide-react components for icons chosen by data: the role icons the Admin page offers (shield, graduation-cap, hand-helping, user, key-round, crown, briefcase, book-open, users, clipboard-check, headset, star) and the toast tones. Statically chosen icons keep direct imports.
  • icons.test.ts: every mapped component is icons[<PascalName>] from lucide-react's canonical icons export, so no deprecated alias can enter the map; pnpm lint (sonarjs deprecation) clean.
  • Unit tests: overflow detection with a stubbed ResizeObserver, pause on focus, ellipsis under reduced motion, accessible name spoken once; LiveDot static under reduced motion.

T-R1.5 — Primitives: controls, additive

Depends on: T-R1.1, T-R1.2, T-R1.4 As built (#163, #165): button, input, textarea, label, field, badge, avatar, card, skeleton, kbd and separator carry the deck geometry. Select, the date/time picker, switch, checkbox, segmented, tabs, table, toggle and toggle group had no consumer in R1 and moved to the release of their first consumer (T-R3.5, T-R4.1, T-R4.2), together with the deck-scale context their portaled popovers need. Touches: web/src/components/ui/{button,input,textarea,label,field,select,calendar,switch,checkbox,segmented,tabs,badge,avatar,card,skeleton,table,toggle,toggle-group,kbd,separator}.tsx, web/src/components/shared/{DateTimeField,PasswordInput,StatusBadge}.tsx, web/tests/units/ui/{icon-scale,avatar,select-and-date}.test.tsx, web/tests/units/ui/deck-scale.test.tsx (new) Size: L Done when:

  • Every size and radius that differs from today is written as an in-data-[scale=deck]: class or as a new size key; a control outside [data-scale="deck"] renders exactly today's classes. Colours come from T-R1.1 everywhere.
  • Button: variants default (primary, hover opacity .9), outline (card, border, hover muted), secondary, ghost (hover hover), destructive (--danger-solid / --danger-solid-fg, replacing text-white), link. Deck geometry for existing keys: xs 26/r6, sm 30/r7, default 36/r8/px-3.5/13.5/500, lg 42/r10/14/600; new keys md 38/r9 and xl 46/r10/15/600 (auth); icon buttons 24/28/34/36 and new icon-xl 40. Icons 15px in 36px buttons, 14px at 30px and below.
  • Input and textarea: deck 38/r8/13.5 by default, size="lg" 42/r9/14.5, size="xl" 46/r10/15 (auth); border --input, focus border --ring; aria-invalid gives the danger border; text stays 16px on coarse pointers (--text-input) so iOS does not zoom.
  • Label and field: label 13/500, gap 6, hint 12.5 muted-fg, error 12.5 danger-ink; errors show after the first submit.
  • Select: the qz-controls.js popover — card, r10, shadow-float, p4, 32px options with the check on the left, group labels 11.5/600, separators, type-ahead, Home/End, --z-popover.
  • Calendar and DateTimeField: the qzc-cal look (268 wide, Monday first, 34px days r7, today as an inset ring, selected in primary, out-of-month days at .55); one 38px trigger; time −/+ in 1 h and 5 min steps; Today/Now and Done; arrow keys move the day; Esc closes. The APP_TIME_ZONE pinning is unchanged; select-and-date.test.tsx gains keyboard cases.
  • Switch md 38×22 with an 18px knob (default), sm 36×20, lg 42×24, knob --switch-thumb. Checkbox 16/r4 with the --input border, checked in primary with an 11px check, indeterminate minus, size="lg" 18/r5.
  • Segmented: track muted, p3, r9, gap 2; items 30/r7/13/500; active = card, shadow-card and a 1px border ring; optional count pill. Tabs: underline variant 13.5/500, pb 10, inset underline on the active tab, 1px row border.
  • Badge becomes the pill family: status 22/r-full/12/500 with a 6px dot; count 18–20 at 11–11.5/600 (a lime count uses brand / brand-fg); role outline 22/r6.
  • Avatar: sizes 26–56, circle or square (r8), tones muted and self (brand-soft / brand-ink). Initials become given name then family name (DG-22): "Hoàng Thương" → TH, "Phan Minh Đức" → ĐP, "Nguyễn Gia Bảo" → BN. avatar.test.tsx, which pins family-first today, is rewritten to the new rule; old screens show the new initials, which the PR states.
  • Card r12 with shadow-card; skeleton on qz-shimmer, static under reduced motion; table r12 border; kbd 11.5/r4.
  • icon-scale.test.tsx: the existing assertions stay unchanged; deck-scale assertions are added beside them.
  • deck-scale.test.tsx: each control rendered inside and outside [data-scale="deck"] carries the deck classes only inside.
  • Every existing unit test passes unchanged.

T-R1.6 — Primitives: overlays, feedback and focus

Depends on: T-R1.5 Touches: web/src/components/ui/{dialog,dropdown-menu,popover,sonner,alert}.tsx, web/src/components/ui/sheet.tsx (new), web/src/lib/toast.ts (new), web/src/index.css, web/tests/units/ui/{toast-tones,sheet,focus-visible}.test.tsx (new) Size: M Done when:

  • One global :focus-visible style: 2px solid --focus, offset 2px, on every interactive element in both themes and both scales (DG-30). Inputs add it to the border change. The vendored focus-visible:ring-[3px] ring-ring/50 classes are replaced. Pointer focus shows nothing. focus-visible.test.tsx covers button, input, select trigger, checkbox, switch, tab, menu item and dialog close.
  • Dialog: --overlay backdrop; variant="console" at top 8vh, 480 wide by default (420/500/520/560/720 options), r12, max-height 86vh; variant="student" 440 wide at 18% (12% on phones), r14. Focus is trapped, Esc closes, focus returns to the trigger. (The student variant was not built in R1; T-R3.7's Join dialog carries the frame as a class recipe, centred from 768 and 12% from the top below it, see 73-r3.md.)
  • Sheet (new, on Radix Dialog from the installed radix-ui): right drawer 380/420/440 (min(…, 100%)) and bottom sheet (r16 top), same focus rules; --z-sheet.
  • Dropdown menu 220 default, r10, p5, shadow-float; items r6 13px with icon or check, meta, danger-ink item, separator, disabled at .45, optional title, max-height 60vh; roving focus. Popover r10, shadow-float, p4.
  • Toast tones (DG-32): success, error, warning, info, each with its own icon (circle-check, circle-alert, triangle-alert, info) in its -ink colour; card r10; one at a time; success and info last 2.4 s as in the deck, error and warning 6 s with a close button; errors announced assertively, the rest politely. lib/toast.ts exposes the four tones; the plain toast() calls old screens make keep working in a neutral tone.
  • Alert tones muted, info, warning, danger: soft background, ink text, 16px icon, r10, padding 10/12.
  • Tests: each toast tone's icon and live-region politeness; sheet focus trap and Esc; dialog variant classes.

T-R1.7 — The brand frame for the front door and system pages

Depends on: T-R1.3, T-R1.5 Touches: web/src/components/shared/BrandFrame.tsx (new), web/src/features/auth/AuthLayout.tsx, web/src/app/pages/SystemFrame.tsx (new), web/src/app/pages/ErrorScreen.tsx (removed), web/src/lib/config.ts (new), .env.example, .github/workflows/deploy.yml, web/tests/units/auth/auth-layout.test.tsx (new) Size: S Done when:

  • BrandFrame: root with data-scale="deck" on --bg; brand panel flex: 0 0 44%, max 620, --sidebar background, right border, padding 40/48, column with space-between (32px theme-aware horizontal logo, a middle slot, a bottom caption); main column centred, padding 48 wide and 32/20 narrow; narrow shows a 28px logo above the content with 28px below; content max 380, gap 20.
  • AuthLayout: the panel appears from 900px (auth breakpoint) on login, forgot-password and change-password only; join (and Joined) is always one column. Panel text: headline 30/600/-0.02em/lh 1.2 balanced ("Tests, lessons and practice for your English classes."), the org name at 15 muted, and at the bottom 12.5 muted "Need help signing in? Ask your teacher or the front desk." — vi written first.
  • SystemFrame: the same frame with the art panel from 1024 (lg): a 300px SVG in currentColor, the org name as caption; content h1 24/600, body 14.5/1.6, a detail slot, full-width stacked actions (38 wide / 44 narrow, r9), an optional divider slot, footnote 13 muted centred.
  • lib/config.ts exports orgName (VITE_ORG_NAME), frontDesk (VITE_ORG_FRONT_DESK_PHONE, VITE_ORG_FRONT_DESK_HOURS, shown verbatim in both locales as the centre's own wording) and appVersion (web/package.json version). An unset value hides its line. .env.example documents them as public; deploy.yml passes them from repository vars. These three are the only VITE_ORG_* values R1 defines; R11 extends this file with VITE_ORG_EMAIL and VITE_ORG_ADDRESS, and none of them is removed later (R5 falls back to them).
  • Prototype chrome never ships: no switcher pills, canvas theme button, demo-accounts box or "Try …" hint; a test asserts none of their strings render.
  • Unit tests: panel present at 900 and absent at 899 on /login, never on join; org lines hidden when unset.

T-R1.8 — Sign in, Forgot password and the Google callback

As built (#169, #172): the client maps INVALID_CREDENTIALS and PASSWORD_UNCHANGED to its own keys (login.invalidCredentials, changePassword.errors.unchanged) while the server also localises both; every other code shows the server's message. The web app owns these two, in the deck's wording, and the contract's ErrorCode description states the exception (#183, decided 2026-10-04). Depends on: T-R1.6, T-R1.7 Touches: web/src/features/auth/pages/{LoginPage,GoogleCallbackPage}.tsx, web/src/features/auth/pages/ForgotPasswordPage.tsx (new), web/src/features/auth/loginSchema.ts, web/src/app/router.tsx, web/src/lib/i18n/locales/{vi,en}.json, server/internal/modules/identity/http/auth.go, web/tests/units/auth/{login,login-without-google,callback-preload}.test.tsx, web/tests/units/auth/forgot-password.test.tsx (new), web/tests/e2e/smoke.spec.ts Size: M Done when:

  • /login per #login: h1 "Sign in"; subtitle "Welcome back.", or "Sign in to join {className}." when a join context is present (T-R1.9); Google button 46/r10 full width with the 18px GoogleMark and a busy state (aria-busy, spinner) — still our PKCE flow, never the GIS SDK; the "or with email" divider; one role="alert" (danger-soft, danger-ink, circle-alert) for "Enter your email and password." (client) or the server's message, cleared on the next edit; email 46 (autocomplete="username", placeholder "[email protected]"); password with "Forgot password?" in the label row and a 36px show/hide button whose aria-label switches; submit xl "Sign in" / "Signing in…" with aria-busy, never a dead disabled button; footer "Have a class code? Join a class".
  • Enter submits; ?next= and the must-change redirect are unchanged (destinationAfterSignIn); in a join context a successful sign-in continues the join.
  • The server's INVALID_CREDENTIALS message becomes the deck's sentence, vi first ("Email và mật khẩu không khớp. Hãy kiểm tra lỗi gõ, hoặc nhờ giáo viên đặt lại mật khẩu."), English when Accept-Language prefers it, using the language.Matcher approach of imports/http/errors.go. It stays one message for an unknown email, a wrong password and a disabled account. A 429 shows the server's RATE_LIMITED message (DG-40).
  • /forgot-password (new, lazy, panel from 900): 44px key-round tile r12; h1 "Forgot your password?"; the body; an info card with the Google row ("Signed up with Google? You do not need a password. Use Continue with Google.") and the front-desk row (phone icon, "Front desk · {phone} · {hours}", hidden when unset, DG-41); outline xl "Back to sign in". It makes no request.
  • The Google callback has a loading state ("Signing you in…") and an error state on the auth layout with a way back to /login that keeps the join context (DG-40); the student-home preload stays (callback-preload.test.tsx green).
  • New and changed keys under login.*, forgot.*, callback.*: vi written first, en verbatim from the deck, listed in the PR.
  • Unit tests rewritten with the screen: fields and autocomplete, toggle labels, the alert clearing, the join subtitle, the busy state, the forgot page with and without front-desk config, the callback states. smoke.spec.ts "lands on /login" and live E2E 2 (answers-persist.live.spec.ts, password sign-in) green.
  • Compared with #login and #forgot at 360, 768, 1024, 1280, 1440, light and dark.

T-R1.9 — Join and Joined

Depends on: T-R1.7, T-R1.8 Touches: web/src/features/join/{code,api}.ts, web/src/features/join/context.ts (new), web/src/features/join/pages/JoinPage.tsx, web/src/features/join/components/{JoinCodeField,ClassPreviewCard,JoinedState}.tsx (new), web/src/features/join/pages/JoinConfirmPage.tsx (removed), web/src/features/join/components/JoinCodeForm.tsx (removed), web/src/layouts/PublicLayout.tsx (removed), web/src/app/router.tsx, web/src/features/auth/pages/{LoginPage,GoogleCallbackPage}.tsx, web/tests/units/join/code.test.ts, web/tests/units/join/{join-page,joined}.test.tsx (new, replacing confirm.test.tsx), web/tests/e2e/join.spec.ts Size: M Done when:

  • /join and /join/:code render on AuthLayout, one column: h1 "Join a class", subtitle "Enter the 8-character code from your teacher.", the code field (58px, r12, 1.5px border, mono 22/600, letter-spacing .14em, centred, aria-label "Class code", autocomplete="off", maxlength 9) with a placeholder from the §6 alphabet, K7QM-2PXA, not the deck's ABCD-1234 (DG-02).
  • Typing uppercases, drops spaces and dashes, caps at 8 characters and shows a dash after 4. A character the §6 alphabet never uses (0, O, 1, I) puts the field in an invalid-format state (danger border, "Codes never use 0, O, 1 or I.") and sends nothing (DG-02). /join/:code pre-fills and looks up.
  • At 8 valid characters one lookup runs after a 250 ms debounce; an edit aborts it; results are cached per code (TanStack Query, staleTime 30 s). A unit test counts requests: each distinct complete code costs at most one POST /join/preview. (T-R3.7 took the abort out: the server has already counted the request, and abandoning it made a retyped code cost a second one; see 73-r3.md.)
  • Found: success border and the preview card (r14, shadow-card, p16, gap 12): "Class found" pill, class name 17/600, the teacher's avatar initials and name. No student count (DG-02). No schedule line: the preview carries schedule and room from R8.
  • Not found: danger border and one role="alert" line, "No class uses this code. Check it with your teacher.", for every failure code (invalid, expired, exhausted, revoked); a 429 shows the server's message. Nothing about the class reaches the DOM.
  • "Join {className}" (48px): signed out, it saves the join context in sessionStorage['quizzivy.join'] (code, class name, teacher name; 30-minute expiry; try/catch) and goes to /login; a signed-in student joins at once (POST /app/classes/join); a signed-in account that is not a student sees "Joining a class is for student accounts." and "Go to my home" (DG-40). Caption "You will sign in or create an account next."; footer "Already in a class? Sign in", which clears the context.
  • After sign-in in a join context: Google uses the existing PKCE flow with joinCode and the callback's enrolledClass; password sign-in calls POST /app/classes/join; ALREADY_ENROLLED counts as joined; any other enrolment failure shows the Joined failure variant ("We couldn't add you to {className}", the server's message, "Go to my classes") (DG-40). A must-change-password account goes through /change-password first and the join continues after it.
  • Joined is the success state of /join/:code, held in router location state: 56px success-soft tile r16 with a 28px check, h1 "You're in {className}", "{teacherName} will see you in the class list.", full-width 48px "Go to my classes" → /app/classes. The join context is cleared.
  • /join/:code/confirm redirects to /join/:code. JoinConfirmPage, JoinCodeForm and PublicLayout are deleted (nothing else uses them); the PR says so.
  • Tests: code.test.ts gains the invalid-format cases; join-page.test.tsx covers debounce, one request per code, abort, the collapsed error, 429 and the non-student note; joined.test.tsx covers success, already enrolled and the failure variant. E2E 3 and E2E 4 in join.spec.ts are rewritten to the inline flow (3: deep link → card → Google, stubbed → Joined → "Go to my classes" lands on /app/classes; 4: an expired code shows the one message, calls neither Google nor /app/classes/join, and no class name renders). pnpm e2e:live green.
  • Compared with #join and #joined at the five widths, light and dark.

T-R1.10 — Change password with server-enforced rules

As built (#172): the meter scores 0–4, not 0–3: one for any password (the browser cannot see the temporary password, so it cannot fail that rule), one for each client-checkable rule, and one at twelve characters or more (web/src/lib/password.ts, passwordStrength). Depends on: T-R1.7 Touches: api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/identity/{domain/errors.go,application/command/change_password.go,http/account.go}, server/internal/platform/httpx/errors.go, web/src/features/auth/changePasswordSchema.ts, web/src/features/auth/pages/ChangePasswordPage.tsx, web/src/components/shared/PasswordRules.tsx (new), web/src/features/auth/components/SettingsSections.tsx, server/internal/modules/identity/application/tests/change_password_test.go, server/internal/core/router/tests/validate_test.go, web/tests/units/auth/change-password.test.tsx Size: M Done when:

  • Contract: changePassword's newPassword keeps minLength: 8, maxLength: 512 and gains pattern: '[\p{N}\p{P}\p{S}]'; the description states the rules. A violation is 400 VALIDATION_FAILED from httpx.ValidateRequests, with a message that states the rule (vi, or en when the request prefers it; #189); no hand-written check is added. make gen output committed as its own commit.
  • New ErrorCode PASSWORD_UNCHANGED (400, message vi then en): while mustChangePassword is set, the new password is verified against the current (temporary) hash and refused if it matches; otherwise it is refused when equal to the verified currentPassword.
  • Existing passwords are never re-validated and sign-in is unchanged. A test proves PasswordManager.Temporary()'s output satisfies the pattern (word-word-NN in R1, word-word-word-NN since F-53).
  • The zod schema carries the same two rules, shared by the page and the Settings password section (whose layout waits for R3/R4); the settings section shows the same messages.
  • The page per #change, panel from 900: h1 "Choose your password", the body, "New password" (46px, autocomplete="new-password"), the strength meter (4 bars, 4px, r4, gap 4; score = rules 1–2 met plus one at 12 characters or more; filled bars --danger at ≤ 1, --warning at 2, --success at ≥ 3, the rest --muted), the checklist ("At least 8 characters", "A number or symbol", "Different from the temporary password" — the third stays a neutral stated rule, since the browser cannot see the temporary password, and turns into the server's message if refused), "Type it again" with "The two passwords are different." (danger border, 12.5 danger-ink).
  • "Save and continue" (48px) is aria-disabled until rules 1–2 pass and the fields match; a click before then reveals the errors. After success: destinationAfterSignIn, or the pending join (T-R1.9).
  • Server tests: "matkhau1" and "mật khẩu!" accepted, "mậtkhẩuđẹp" (letters only) refused, PASSWORD_UNCHANGED on both paths. Web tests: meter scores, checklist, mismatch, aria-disabled reveal, the server error shown.
  • Compared with #change at the five widths, light and dark.

T-R1.11 — The API client: unavailable is not signed out

Depends on: — Touches: web/src/lib/api/{client,errors}.ts, web/src/features/auth/useSession.ts, web/src/stores/appState.ts (new), web/tests/units/api/client.unavailable.test.ts (new), web/tests/units/auth/bootstrap.test.tsx Size: S Done when:

  • performRefresh distinguishes three outcomes: refreshed; refused (401 or 403 from /auth/refresh, today's session-lost path); unavailable (network TypeError, 503, other 5xx), which rejects every waiting request with a retryable error and never calls onSessionLost. The refresh stays single-flight; client.refresh.test.ts is untouched and green.
  • A 503 whose envelope code is MAINTENANCE calls a new setMaintenanceHandler with { startsAt, endsAt } before the error is thrown, from both api() and uploadFile().
  • Every request sends Accept-Language set to the app locale (vi or en), so server messages match the UI rather than the browser.
  • stores/appState.ts (zustand) holds the boot phase and the overlay (none, maintenance, expired, update) with the window for maintenance.
  • useBootstrapSession: 401 → signed out (as today); network failure → boot phase offline, the session untouched; MAINTENANCE → overlay maintenance; any other error → the unexpected-error page with its requestId. An offline cold load no longer lands on /login.
  • Tests: a network failure or a 503 MAINTENANCE during refresh does not call onSessionLost; a refused refresh does; five concurrent 401s with an unavailable refresh make one refresh call and all reject retryably; Accept-Language is sent; the bootstrap outcomes.

T-R1.12 — Maintenance windows: schema and the operator command

Depends on: — Touches: migrations/NNNNN_create_maintenance_windows.sql (new), server/internal/core/maintenance/windows.go (new), server/internal/core/maintenance/maintenance.go (package comment), server/internal/core/maintenance/cli.go (new), server/cmd/maintenance/main.go, server/internal/core/maintenance/tests/{windows_test.go,cli_test.go} (new), server/internal/platform/db/tests/privileges_test.go, docs/setup/operations.md, docs/plan/20-data-model.md (the advisory-lock registry) Size: M Done when:

  • NNNNN_create_maintenance_windows.sql (one concern; in a transaction — the table is new and empty, so no CONCURRENTLY): app.maintenance_windows (id uuid PK DEFAULT uuidv7(), starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), created_by text NOT NULL DEFAULT current_user, cancelled_at timestamptz) with CHECK (ends_at > starts_at), CHECK (ends_at - starts_at <= interval '12 hours') and EXCLUDE USING gist (tstzrange(starts_at, ends_at) WITH &&) WHERE (cancelled_at IS NULL) (a range-only exclusion, no btree_gist). created_by is the database role that scheduled it, not an app user. The file then REVOKE INSERT, UPDATE, DELETE ON app.maintenance_windows FROM quizzivy_app, narrowing the default privileges 00009 grants. Down: DROP TABLE app.maintenance_windows. Reviewed against the Neon skill; CI's up/down/up round trip passes.
  • cmd/maintenance takes maintenance <command> [flags]. Today main.go calls the global flag.Parse() before reading flag.Arg(0), so a flag written after the command is never parsed (window-schedule -start X -end Y would ignore both). Parsing moves to core/maintenance/cli.go, where each command owns a flag.FlagSet; main.go only reads the environment, opens the pool and runs what the parser returns. retain-integrity and anonymize-student take the same order, and today's maintenance [-apply] [-batch N] [-student UUID] <command> keeps working as an alias (flags before the command are parsed as today). Every command gains -timeout <duration> (default 1m, today's value) in place of the hard-coded one-minute context; a command may derive it from its own budget flag (R5's retention-sweep -budget). R2's rekey-join-codes and R5's retention-sweep register in the same parser.
  • cmd/maintenance gains window-schedule -start <RFC3339> -end <RFC3339>, window-list, window-cancel -window <uuid> and window-end -window <uuid>, with the existing dry-run default (-apply to write) and the existing MAINTENANCE_DATABASE_URL requirement. Output is JSON like the other commands.
  • cli_test.go: flags after the command are parsed for every command; the old flags-first order still parses retain-integrity and anonymize-student; an unknown command or flag fails with the usage line; -timeout reaches the context.
  • window-schedule runs in one transaction that first takes pg_advisory_xact_lock(73819, 40) (the start guard in T-R1.13 takes it shared), then one statement of data-modifying CTEs (PG18 RETURNING id, old.deadline_at, new.deadline_at and RETURNING id, old.closes_at, new.closes_at, per AGENTS, id feeding the audit row's entity_id; no wildcard projection, so the audit diff carries only the moved column): insert the window (start no earlier than one minute ago); add ends_at - starts_at to deadline_at of every in_progress attempt whose deadline_at > starts_at; add the same to closes_at of every assignment with closed_at IS NULL and closes_at in [starts_at, ends_at); insert audit rows with actor_user_id NULL (System): attempt.extended and assignment.extended with {deadline_at|closes_at: {old, new}, reason: "maintenance", windowId, databaseRole}, and one maintenance.scheduled. The dry run reports how many attempts and assignments would move.
  • 20-data-model.md gains an advisory-lock registry for the 73819 namespace: 10 and 11 imports (existing), 40 maintenance windows (this task and T-R1.13), 41 R4's legacy join-code rotation and 42 R7's notify scheduler (both reserved, each release confirming its row). A new key is added there before it is used.
  • window-cancel sets cancelled_at on a window that has not ended; window-end sets ends_at = now() on an active window; both keep the extensions already granted and write maintenance.cancelled / maintenance.ended. window-list prints upcoming and active windows.
  • Integration tests (windows_test.go): an attempt whose deadline passes the start is extended and one ending before it is not; an assignment closing inside the window is extended and one closing after it is not; the audit rows carry a NULL actor; an overlapping window is refused with a readable error; the dry run changes nothing; cancel and end keep the extensions.
  • privileges_test.go: quizzivy_app has SELECT and no INSERT, UPDATE or DELETE on app.maintenance_windows.
  • docs/setup/operations.md gains the runbook: when to schedule, what moves, how to end or cancel, that a window takes effect within 30 s of being written (T-R1.13's snapshot), and that the import worker keeps running (Open items). Its "Flags must precede the command" note and examples become maintenance <command> [flags], naming the old order as a still-accepted alias, and -timeout is documented.

T-R1.13 — Maintenance windows in the API: status, the 503 gate, the start guard

As built (#178): the attempts port is StartGuard and its adapter adapters.AttemptStartGuard. The gate's position is pinned in maintenance_gate_test.go (a preflight is answered by CORS; the limiter never counts a gated request), since the middleware order is pinned by validate_test.go and hardening_test.go, not router_test.go. Depends on: T-R1.12 Touches: api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/availability/{domain,application/query,repositories,http}/ (new), server/internal/core/wiring/{availability.go (new),attempts.go}, server/internal/core/adapters/availability.go (new), server/internal/core/router/{router.go,ratelimits.go,modules.go,server.go}, server/internal/platform/httpx/maintenance.go (new), server/internal/modules/attempts/{repositories/store.go,domain/errors.go}, server/internal/core/router/tests/maintenance_gate_test.go (new), server/internal/core/router/tests/{auth_middleware_test.go,public_routes_test.go,router_test.go}, server/internal/modules/availability/repositories/tests/guard_test.go (new), server/internal/core/maintenance/tests/windows_test.go, server/internal/modules/attempts/application/tests/create_test.go Size: M — high-risk area (attempts) Done when:

  • Before and after: the five canaries (as listed in T-R1.14) and the attempts module's unit and integration tests run on the branch point and on the last commit, both runs in the PR.
  • New operation GET /public/status (getPublicStatus, tag public, security: []; R2 gives it no x-permission, as an open operation). Response PublicStatus { maintenance: MaintenanceWindow | null }, MaintenanceWindow { startsAt, endsAt, active: boolean } — only the earliest non-cancelled window whose end is in the future. Cache-Control: public, max-age=30.
  • Rate limit in RateLimits(): 120 per minute and 2,000 per hour per IP — a class of 40 tabs re-checking once a minute behind one school NAT fits. AssertPublicRoutesLimited passes; public_routes_test.go covers the 429.
  • Leak review in the PR description: the answer is identical for every caller and never depends on a token; it carries no id, creator, count or free text; it is served from the snapshot below, so its timing reveals nothing.
  • The open-operation pin becomes theOpenSeven in auth_middleware_test.go, with the one-line justification for GET /public/status.
  • The availability module: the domain Window, the query CurrentWindow, a repository with no SELECT *, and an in-process snapshot refreshed at most every 30 s and only when a request arrives (single-flight), so an idle API never queries Neon for it. A read failure fails open and logs MAINTENANCE_STATUS_UNAVAILABLE. Wired in wiring/availability.go; core/tests/architecture_test.go passes.
  • The gate (httpx.Maintenance) is an outer handler between CORS and the router — CORS(Maintenance(handler)) in router.New — so it runs before rate limiting and auth, covers every contract route and /docs, and its 503 carries CORS headers the SPA can read. Exempt: GET /livez, GET /healthz, GET /public/status (preflights are answered by CORS first). During an active window: 503 with the error envelope, code MAINTENANCE, details { startsAt, endsAt }, Retry-After in seconds to endsAt, message vi then en. No role is exempt; the integrity beacon is covered. As built (v0.7.0 release branch): the gate runs only when the path matches a route under some method (routedOnly in router.go), so a path no route serves gets the mux's 404 without a snapshot read.
  • Contract: ErrorCode gains MAINTENANCE and MAINTENANCE_SCHEDULED; components/responses/Maintenance is defined and referenced from /auth/me, /auth/refresh, /auth/login and saveAnswers (the responses the web branches on); info.description says every operation except the three exempt routes may answer it.
  • Start guard: availability/repositories.GuardAttemptStart(ctx, q, now, deadline) takes pg_advisory_xact_lock_shared(73819, 40) (the registry's maintenance-windows key, T-R1.12) and returns the first non-cancelled window with starts_at < deadline and ends_at > now. Attempts never imports it: core/tests/architecture_test.go refuses a repository that imports another module's non-domain layer. Following the QuestionLocks / LockForDraftUse pattern of tests/repositories/store.go, the attempts repository declares an AttemptStartGuard interface; core/adapters/availability.go implements it by calling availability/repositories.GuardAttemptStart, and wiring/attempts.go passes it in. The repository's Create runs in a transaction that calls the guard before the insert; a hit answers 409 MAINTENANCE_SCHEDULED with details { startsAt, endsAt } (added to startOrResumeAttempt's 409). Resume never checks it. The old student intro page shows the server message it already displays for a 409.
  • guard_test.go covers the guard's query (overlapping, adjacent, cancelled and past windows). TestWindowScheduleSerialisesAgainstAttemptStart, in core/maintenance/tests/windows_test.go because only core may import both sides, drives a start and a window-schedule concurrently and fails within two attempts without the locks (the pattern of TestLockForDraftUseSerialisesAgainstDelete). create_test.go covers a refused overlapping start and an allowed start that ends before the window.
  • maintenance_gate_test.go: 503 with CORS headers for an allowed origin; the exempt routes answer; an expired token during a window gets 503, not 401 (so the SPA never refreshes-then-logs-out); the same request passes after ends_at; the gate's position is pinned beside the middleware-order test in router_test.go.

T-R1.14 — A running attempt adopts a moved deadline

Depends on: — Touches: api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/attempts/{repositories/autosave.go,repositories/submit.go,application/command/save.go,http/attempts_handlers.go,domain/errors.go}, web/src/features/take-test/{store.ts,groupPlayback.ts,draft.ts}, server/internal/modules/attempts/application/tests/{autosave_test.go,submit_test.go}, web/tests/units/take-test/{deadline,group-playback}.test.ts Size: M — high-risk area (features/take-test/, attempts) Done when:

  • saveAnswers 200 gains required deadlineAt (the row's current deadline_at, already read by autosave.go). Additive: an open v0.6 tab ignores it.
  • submitAttempt with reason timer_expired while the server deadline is more than 5 s away answers 409 DEADLINE_NOT_REACHED with details { deadlineAt } (new ErrorCode). manual and auto_submit are unchanged; a timer_expired within 5 s of the deadline, or after it, behaves as today.
  • The store adopts the server's deadline from either response: deadlineAt updates, the existing subscription re-arms the single deadline timer, a refused submit returns submitState to idle, the group playback lock and the local draft carry the new deadline. A teacher's Extend now reaches an open attempt on its next autosave instead of on reload.
  • Before and after the PR: the canaries (publish_snapshot_test.go, audio-player.test.tsx, the integrity events canary, client.refresh.test.ts, router-chunks.test.ts) and pnpm test:unit tests/units/take-test tests/units/integrity; live E2E 5–8 (timer-expiry, integrity-away, session-takeover, audio-plays) and E2E 9 (payload-leak.live.spec.ts) green, the key walk still finding no isCorrect, sampleAnswer, acceptedAnswers or transcript.
  • Server tests: autosave returns the extended deadline after an Extend; an early timer_expired is refused with the deadline; the 5 s boundary is accepted. Web tests (fake timers): a later deadline from autosave re-arms the timer; DEADLINE_NOT_REACHED returns to idle and re-arms at the new time; no double submit.

T-R1.15 — System pages

Depends on: T-R1.7 Touches: web/src/app/pages/{ForbiddenPage,errorArt}.tsx, web/src/app/pages/{NotFoundPage,MaintenancePage}.tsx (new), web/src/app/ErrorBoundary.tsx, web/src/app/router.tsx, web/src/components/shared/CopyButton.tsx (new), web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/errors/screens.test.tsx, web/tests/integration/router-chunks.test.ts Size: M Done when:

  • Four pages on SystemFrame, the deck's four SVGs copied verbatim into errorArt.tsx (currentColor, aria-hidden), correct in both themes:
    • 404: h1, body, the path chip with the real location.pathname, "Go to home", "Go back" (history.back() when there is history, else home), footnote.
    • 403: h1, body, the identity card (30px avatar with DG-22 initials, full name, "Signed in"), "Go to my home", "Sign in with another account" (signs out, then /login). Signed out: no card and a primary "Sign in".
    • Unexpected error: h1, the body with its promise ("…your saved answers are safe", pinned literally in the test), "Reload the page", "Go to home", a divider, the Error ID row (requestId first, else four random bytes as xxxx-xxxx, user-select: all) with CopyButton: "Copy" → "Copied" for 2 s announced politely; a clipboard failure says "Couldn't copy. Select the ID and copy it." instead.
    • Maintenance: h1 "Quizzivy is being updated", "We'll be back by {time}. Tests that close during the update are extended." (times in APP_TIME_ZONE, the date added when not today), the status list (clock in warning-ink, "Started {time}", "about {n} min"; circle-check in success-ink, "Answers and results saved"), primary "Check again", footnote. Fed by the 503 details or GET /public/status (T-R1.16 wires it).
  • NotFoundPage.tsx replaces ErrorBoundary.tsx's NotFound export: router.tsx's * route and ErrorBoundary's router-404 branch both render it, and the old export is deleted in the same PR, so there is one 404. errorArt.tsx's existing NotFoundArt becomes the deck's 404 SVG rather than gaining a second 404 illustration.
  • "Home" is the caller's console, homePathFor(user), and /login when signed out (DG-43).
  • The four pages and SystemFrame are in the entry chunk (they must render when a lazy chunk cannot load); router-chunks.test.ts gains that assertion and removes none.
  • Keys notFound.*, forbidden.*, error.*, maintenance.* rewritten vi first, en from the deck, listed in the PR.
  • screens.test.tsx rewritten: home per role and signed out, the signed-out 403, copy success and failure, requestId preference, maintenance times.
  • Compared with #404, #403, #error, #maintenance at the five widths, light and dark.

T-R1.16 — Splash and app states

As built (#180): AppFrame renders beside RouterProvider, not as the root route's element: React Router 7.18 renders the root's HydrateFallback, not its element, while a deep link's lazy routes load, and a failed lazy import replaces the root with the ErrorBoundary. The root route has HydrateFallback: () => null. A refused session keeps a signed-in user (auth.expired, token dropped) so the page stays mounted under the sign-in-again overlay, and a 401 while expired skips the refresh. The build id is QUIZZIVY_BUILD, which deploy.yml sets to the resolved commit, not git rev-parse; any other build gets an id of its own. The overlays render light over the old consoles, which force light until R3 and R4 rebuild them. Depends on: T-R1.4, T-R1.11, T-R1.13, T-R1.15 Touches: web/index.html, web/src/app/AppFrame.tsx (new), web/src/app/boot/{BootSplash,AppStateLayer}.tsx (new), web/src/app/boot/{handoff,version,status}.ts (new), web/src/app/{router,providers}.tsx, web/src/app/guards/{RequireSession,HomeRedirect}.tsx, web/src/features/integrity/useIntegrityMonitor.ts, web/vite.config.ts, web/public/_headers, web/tests/units/boot/{splash,app-state,version}.test.tsx (new), web/tests/units/integrity/overlay-pause.test.ts (new), web/tests/e2e/splash.spec.ts (new), web/tests/integration/router-chunks.test.ts Size: L Done when:

  • First paint: index.html puts the 64px mark (theme-correct through html.dark) and the 180×3px track inside #root, styled by a small inline <style> on the tokens (style-src 'unsafe-inline' allows it); React replaces it.
  • The root route renders AppFrame (outlet + BootSplash + AppStateLayer); the take-test route gains handle: { focus: true }. The guards' "Đang tải…" text goes; the splash covers boot.
  • BootSplash, copy verbatim from the deck's vi/en dictionary: step 1 "Checking your session…" (refresh and /auth/me), step 2 "Loading your classes…" (until the router's first navigation settles, i.e. the console's lazy chunks), step 3 "Getting things ready…"; the bar at 12/46/78/100%, the mark breathing (still under reduced motion); a .35 s fade (none under reduced motion); no artificial minimum duration; "Quizzivy · v{appVersion}".
  • Prototype chrome never ships: the splash has no state-switcher pill bar and no theme button, and the deck's hard-coded "v2.8.1" is never rendered (the label is appVersion). splash.test.tsx asserts the switcher's and theme button's labels do not render.
  • Skeleton hand-off: handoff.ts maps a shell (teacher, admin, student) to its skeleton. R1 registers none, so the splash fades straight onto the old consoles; R3, R4 and R5 each register their deck skeleton with their shell.
  • Slow: no step change for 8 s (the deck is silent; this is our default) → indeterminate bar, "Taking longer than usual", outline 36px "Reload".
  • Offline: a network failure during boot → greyed mark, the "No connection" pill (warning-soft), title and body, "Try again", and "Trying again in {n}s" counting down from 10 before an automatic retry; the online event retries at once.
  • Maintenance: from boot or any later request, the maintenance page (T-R1.15) shows as a full-viewport overlay above the mounted route, which becomes inert. "Check again" reads GET /public/status; the overlay also re-checks every 60 s while the tab is visible and the user has touched the page in the last 10 minutes (polling stops when idle, not only when hidden). When no window is active it closes and invalidates the query cache.
  • Update: a Vite plugin emits dist/version.json ({ build, version }, build = the commit SHA) and compiles the same id in; _headers gives /version.json Cache-Control: no-store. It is fetched when the tab becomes visible and every 10 minutes while visible and not idle. A changed id shows "Quizzivy has been updated" / "Reload now" at the next navigation, never while the current route has handle.focus; a vite:preloadError shows it at once, since that route cannot load otherwise.
  • Expired: the session lost while a user was signed in (refresh refused, or REFRESH_TOKEN_REUSED) → "Please sign in again" / "You were signed out for your security." as an overlay; "Sign in" clears the session and goes to /login?next=<current path>. On the take-test route the engine stays mounted beneath, its local draft (written on every change) intact, and resumes from it after sign-in.
  • useIntegrityMonitor records no focus change while the maintenance or expired overlay is showing (overlay-pause.test.ts); the integrity unit suite runs before and after.
  • router-chunks.test.ts asserts app/boot/ is in the entry chunk and still asserts the admin, student and focus trees are not.
  • Unit tests per state (fake timers for slow and the countdown); splash.spec.ts: an offline cold load (API routes aborted) shows the countdown and recovers when the API returns; a 503 MAINTENANCE on /auth/me shows the maintenance page, which closes when /public/status reports no window; a changed /version.json shows the update card at the next navigation and not on /app/attempts/:id.
  • Compared with Quizzivy Splash.dc.html (#loading, #slow, #offline, #update, #expired) at 360, 768 and 1280, light and dark.

T-R1.17 — Old consoles under the new palette and font

As built (#181): a throwaway Playwright harness measured every /app route, the engine included, and every /admin route except the import detail, import review and question-group editor pages, at 1280 and 360, on the seeded data plus five bank questions created for the run (the seed has no bank questions, imports or question groups), and diffed the result with the same run against develop: contrast of every text node, overflow, clipping, truncation, wrapping, and a focused-versus-blurred style check at each of the first 40 Tab stops. Two fixes: the old consoles' success badge takes --success-ink (4.28:1 before), and the grading bar's details line yields before the student's name. The teacher console's 768px layout at 360 is as on develop; R4 builds its phone layout. Depends on: T-R1.1 … T-R1.6 Touches: token-level fixes in web/src/** only; no layout change Size: M Done when:

  • Every /admin route and every /app route, including the engine on the three-part paper from seed/05-dev-sections.sql, is checked at 1280 and 360 in (forced) light, with data shaped like production's.
  • Contrast and overflow regressions from the palette and font are fixed by token mapping or by replacing a legacy class, never by layout. Be Vietnam Pro is wider than Inter: the longest Vietnamese strings in sidebars, table headers and buttons are checked, and a new wrap or truncation is fixed with the existing min-width and ellipsis patterns.
  • Keyboard focus is visible on every control of the old consoles (NFR-F02).
  • Existing unit and E2E tests green unchanged, except assertions of colour class names (listed in the PR). Anything not fixed becomes an issue.

T-R1.18 — Docs for R1

Depends on: T-R1.1 … T-R1.16, T-R0.3, T-R0.4 Touches: docs/quizzivy-spec-v0.3.md, AGENTS.md, docs/plan/70-redesign-overview.md, docs/plan/20-data-model.md, docs/plan/50-nfr-registry.md, docs/design/gaps.md Size: S Done when:

  • Spec, version bumped per §18: the "Availability: R1" markers T-R0.3 placed are removed from what shipped; §4 org details from build-time config (R5's settings later take precedence, these values staying as the fallback); §5.4 the password rules and PASSWORD_UNCHANGED; §6.1 the alphabet prose corrected (the string contains L); §6.2 the inline preview, Joined and the password path; §9 /forgot-password, the system pages, the splash states and the overlays; §10 integrity paused under the maintenance and expired overlays, and an extension reaching an open attempt on its next autosave; §12 the tokens as built (the three accessibility tokens, data-scale="deck"); §13 maintenance_windows; §15 GET /public/status, the 503 envelope, MAINTENANCE_SCHEDULED, DEADLINE_NOT_REACHED, PASSWORD_UNCHANGED, saveAnswers.deadlineAt.
  • AGENTS.md: the "Redesign in progress" R1 bullet replaced by what now holds (deck geometry only under data-scale="deck" until R5; old consoles force light; boot.js); seven open operations with theOpenSeven; the middleware section notes the maintenance gate sits outside the router, between CORS and the mux; Design names --focus and no-raw-colours.test.ts.
  • 70-redesign-overview.md §4.2 already carries the whole open-operation schedule (six at v0.6.0, R1's getPublicStatus making seven, then R5, R7 and R11). Checked, not rewritten: R1's entry matches what shipped (GET /public/status, theOpenSeven); any difference is corrected there.
  • 20-data-model.md: a maintenance_windows section, its inventory row and a deviation (the app role reads only; the operator writes through cmd/maintenance; extensions are audited as System).
  • NFR registry: F02 (visible focus everywhere) ✅ with evidence; F04 rewritten to the deck's motion list with reduced-motion statics; E05's evidence names the entry-chunk assertions.
  • gaps.md: DG-02's alphabet wording fixed; DG-20's Vietnamese-default rule, DG-22, DG-30 … DG-35, DG-40, DG-42 and DG-43 "Meanwhile" rules marked as built in R1, naming the tokens and components. DG-20's glossary review is sent to the design team with the list of R1's new and changed keys.

T-R1.19 — Verification

Depends on: T-R1.1 … T-R1.18 Touches: — Size: S Done when:

  • 70 §8 step 1 run locally in order, judged by exit code, on work/redesign-r1 with develop merged in: make lint, make gen-check, make test-api (docker compose up -d db minio), goose up/down/up, pnpm lint, pnpm typecheck, pnpm format:check, pnpm test:unit, pnpm test:integration, pnpm build, pnpm e2e, pnpm e2e:content, pnpm e2e:live (auth, join and take-test changed), node scripts/check-design-deck.mjs.
  • The app beside the deck (design-deck on 5175, the app on 5173 with the API), dev server confirmed fresh: every Sign in, System and Splash state at 360, 768, 1024, 1280 and 1440, light and dark, measured with getBoundingClientRect; the table (screen × width × theme → match / fixed / DG id) is in the release PR.
  • A local maintenance rehearsal: a student mid-attempt, window-schedule -apply two minutes ahead; a second student's start is refused; the window opens and both tabs show the overlay; after window-end the overlay closes, the first student's timer shows the extended deadline, and the attempt submits normally.
  • Vite, the API and the deck server stopped; only the compose services used were started.

Release checklist

  1. Every Done-when box above is ticked on work/redesign-r1; the R0 PRs (T-R0.1 … T-R0.9) are on develop; develop is merged in and T-R1.19 is green.
  2. Every PR's new vi keys reviewed by Thuong (D11); the answers applied.
  3. Security: the leak review for GET /public/status is in its PR; its rate-limit test passes; theOpenSeven is the only change to the open list; E2E 9 green after the saveAnswers change.
  4. Migration rehearsed on a Neon branch of production: goose up, then maintenance window-schedule as a dry run against the branch (it reports counts and writes nothing); branch deleted.
  5. Production prerequisites: repository variables VITE_ORG_NAME, VITE_ORG_FRONT_DESK_PHONE and VITE_ORG_FRONT_DESK_HOURS set (optional; unset lines are hidden); web/package.json version set to 0.7.0 (the splash label). No new secret.
  6. Deploy outside exam windows: no assignment open with an attempt in progress (the release changes saveAnswers and submitAttempt).
  7. work/redesign-r1 → develop (--no-ff); release/0.7.0 cut from develop with the release notes, the spec version confirmed and the NFR registry reviewed.
  8. Release notes say: the new look (palette, Be Vietnam Pro, visible keyboard focus) with the consoles' layout unchanged; the new sign in, the forgot-password information page, the join page that shows the class before sign-in and confirms the join; new passwords need at least 8 characters with a number or symbol and must differ from the temporary one, while existing passwords keep working; the new error pages; the loading screen, which now retries when offline instead of signing out and offers a reload after an update; maintenance windows (the runbook in docs/setup/operations.md), with tests closing during maintenance extended; an open test now picks up a teacher's extension without a reload; a Cloudflare Pages rollback loses no data, but not every API change is additive, so the old web meets new refusals (the password rule among them, #189).
  9. release/0.7.0 → main with Thuong's go (--no-ff); Deploy runs the API (release_command migrates) then Pages.
  10. Smoke on production: /livez; /healthz with database: ok; the worker group running; GET /public/status answers {"maintenance":null}; /login in the new design; a join deep link with a known code shows the inline card; an unknown path shows the new 404; the owner signs in and lands on /admin in the old console with the new palette and font; a student signs in; maintenance window-list runs against production.
  11. Tag v0.7.0; back-merge main → develop by PR; close the R1 epic issue. Rollback is roll forward (70 §8).

Open items

  • O-R1.1 — Does a maintenance window pause the import worker? Default: no. The window gates HTTP only and the worker keeps processing imports it already holds; the runbook says so. If a window is ever needed for work that must not race imports, the worker gains the same snapshot check. Needed before the first window scheduled for database work.
  • DG-30, DG-31 — the interim accessibility tokens (--focus, --input, --danger-solid) ship with the values above until the design team answers; the token test fails on any answer below the thresholds.
  • DG-40 — auth states the deck does not draw (Google callback loading and error, 429 copy, a non-student opening a join link, enrolment failing after sign-in) are built from deck primitives and listed for the design team's sign-off.
  • DG-41 — front-desk phone and hours come from build-time config. From R5 the Organization settings take precedence and the build values remain the fallback.
  • DG-42 — no Admin control for maintenance windows; the command line is the only way to schedule one until the deck draws a control.

Generated from docs/plan/71-r1.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️