Plan 71 R1 - SekiroKenjii/Quizzivy GitHub Wiki
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
/adminand/approute 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 thesm/md/lgradii that differ from today apply only inside an element carryingdata-scale="deck"(Tailwind v4'sin-data-*variant, and--radiusredefined 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, defaultlight, the deck'sstartTheme) 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 isbrandin utilities (bg-brand,text-brand-ink) because shadcn'saccentalready 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--inputborder (#8a9395light, 3.1:1;#5d6769dark, 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_windowsis created by a migration and written only throughcmd/maintenanceunderMAINTENANCE_DATABASE_URL; the app role getsSELECTand nothing else. There is noMAINTENANCE_MODEenvironment 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_idNULL). 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 theLockForDraftUse/LockForVersionUseprecedent, 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
deadlineAtonly 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 returnsdeadlineAtfrom every autosave and refuses atimer_expiredsubmit 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/confirmredirects, so printed QR codes and old links keep working. -
Password rules are a schema constraint.
newPasswordgainspattern: '[\p{N}\p{P}\p{S}]'(a number, punctuation or symbol character; a Vietnamese letter is a letter), enforced once byhttpx.ValidateRequests. "Different from the temporary password" becomesPASSWORD_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 addsVITE_ORG_EMAILandVITE_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.
ABCDEFGHJKLMNPQRSTUVWXYZ23456789containsL; the spec's "(no1/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
availabilitymodule: the window query, the status endpoint and the start guard. R5'sorganizationmodule may absorb it; nothing else depends on the name.
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:
-
:rootcarries the deck's light set under the deck's own names, copied from the:rootrule ofdocs/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|infoeach with-softand-ink,--overlay. The two shadows are--qz-shadowand--qz-shadow-lg(Tailwind owns--shadow-*). -
html.darkcarries the deck's:root.darkset;@custom-variant darkbecomes(&:where(.dark, .dark *));color-schemefollows the theme. The deck'sdata-qz-themeattribute is not carried. -
:root[data-accent="mono"] { --accent-c: var(--fg) }exists at token level; nothing in R1 sets the attribute (the deck'saccent: monois a designer prop). - Colours the deck hard-codes become tokens (DG-34):
--accent-fg(#1b2123, text on lime, both themes),--switch-thumb,--danger-solidand--danger-solid-fg,--paperand--paper-fg(content keeps a light paper in dark mode, DG-35),--qr-bgand--qr-fg,--font-serif. - Accessibility tokens (DG-30, DG-31):
--focus: var(--muted-fg);--input#8a9395light,#5d6769dark;--danger-solid: oklch(0.55 0.2 27)in both themes. -
@theme inlineexposes the deck names as utilities (bg-sidebar,bg-hover,text-fg,text-muted-fg,bg-overlay,border-input), the lime asbrand,brand-soft,brand-ink,brand-fg, every semantic-soft/-ink, andshadow-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-foregroundandwarning-foregroundstay as aliases, marked for removal in R5. - Type scale:
xs,sm,base,lg,xlunchanged (already the deck's 12, 13, 14, 17, 20). Added:3xs10.5,2xs11,caption11.5,meta12.5,ui13.5,body14.5 (lh 1.6),md15,title16,stat-sm18,stat22,h124 (600, -0.02em, lh 1.25),h1-student26,kpi-sm28,kpi30,display34. Landing-only sizes wait for R11. - Radii:
--radius-2xl14px,--radius-3xl16px, new--radius-seg7px and--radius-ctl9px, all global (no current code usesrounded-2xl/3xl).--radiusis redefined to 8px only under[data-scale="deck"], giving the deck'ssm6 /md8 /lg10 there while old screens keep 4 / 6 / 8. - Z-scale:
--z-sheet70,--z-alert80,--z-dialog85,--z-toast90,--z-popover100 (menus and pickers above dialogs, asqz-controls.jsdoes). - Breakpoint
--breakpoint-auth: 56.25rem(900px, the Sign in brand panel). -
bodyhas an explicitvar(--bg)background and-webkit-font-smoothing: antialiased. The.student-surface44px rule, a floor on buttons below 1024px, stays unchanged. R3 keeps it: the class sits on the student shell's<main>and onFocusLayout, and a control drawn to the deck's size opts out of it (AGENTS.md, "Design"). -
tokens.test.tsrewritten at the same path. It parses hex,rgba()andoklch()in both blocks (compositingrgbaover--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;--primaryhas 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, andinfois the only family allowed in the blue band; contrast in both themes ≥ 4.5 forfgandmuted-fgonbg,card,sidebar,muted, for each-inkon its-soft, forprimary-fgonprimary, foraccent-fgonaccent-c, and fordanger-solid-fgondanger-solid; ≥ 3 forfocusonbg,card,sidebar,mutedand forinputonbgandcard. The existing "@themereferences only variables" case is kept. -
no-raw-colours.test.ts: no hex,rgb(),rgba()oroklch()literal and no Tailwind palette utility (zinc-,slate-,red-, …) inweb/src/**/*.{ts,tsx}outsidefeatures/auth/components/GoogleMark.tsx; hits found today are fixed in the same PR. -
pnpm buildgreen; every existing unit test green, except assertions of colour class names, each listed in the PR.
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'inweb/public/_headersforbids Google Fonts, and the package pins versioned OFL files withunicode-rangesubsets. Vendoring woff2 files by hand was the alternative considered. - Imported:
latinandvietnamesesubsets × 400/500/600/700,font-display: swap;latin-extleft 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 nofonts.googleapis.comorfonts.gstatic.comstring appears anywhere indist/. -
_headersCSP 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).
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 | systeminlocalStorage['quizzivy.theme']; absent or invalid meanslight. 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 toviin bothboot.js(html.lang="vi") andlib/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.jsis an external classic script (CSPscript-src 'self'allows no inline script) loaded blocking in<head>. It readsquizzivy.themeandquizzivy.locale, setshtml.dark,html.style.colorSchemeandhtml.lang, and sets<meta name="theme-color">to the resolved--bg(#ffffff/#0e1213; two metas withmediainsystemmode). Nothing else; under 1 KB;_headersgives/boot.jsCache-Control: no-cache. -
lib/theme.ts:readThemePreference,writeThemePreference,useResolvedTheme()(systemfollowsprefers-color-schemelive through amatchMedialistener, read withuseSyncExternalStore), anduseForcedLightTheme(), which applies light while its component is mounted and restores the preference on unmount. -
AdminLayout,StudentLayoutandFocusLayoutcalluseForcedLightTheme(). 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.tsxgainstheme="auto", swapping the colour and on-dark logo and mark by the resolved theme;onDarkstays for always-dark surfaces. - Unit tests: default, invalid value, storage throwing,
systemfollowing an emulated media change, forced light restoring the preference. -
theme.spec.ts: withquizzivy.theme=dark,/login's document hashtml.darkand a dark computed background before React mounts (no light flash); withsystemandpage.emulateMedia({ colorScheme: "dark" })the page follows live;/adminrenders light under a dark preference.
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(totranslateX(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). Underprefers-reduced-motioneach 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. -
MarqueeTextanimates only when its text overflows (measured with a ResizeObserver, not the deck's 600 ms polling), duplicates the text with the copyaria-hidden, exposes the full text once to assistive tech and intitle, pauses on hover and on focus-within (WCAG 2.2.2, DG-33), and renders an ellipsis under reduced motion. -
LiveDotis a 7px--successdot usingqz-pulse,aria-hidden(the text beside it carries the meaning), static under reduced motion. -
lib/icons.tsmaps 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 isicons[<PascalName>]from lucide-react's canonicaliconsexport, 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.
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, hovermuted),secondary,ghost(hoverhover),destructive(--danger-solid/--danger-solid-fg, replacingtext-white),link. Deck geometry for existing keys:xs26/r6,sm30/r7,default36/r8/px-3.5/13.5/500,lg42/r10/14/600; new keysmd38/r9 andxl46/r10/15/600 (auth); icon buttons 24/28/34/36 and newicon-xl40. 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-invalidgives 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.5danger-ink; errors show after the first submit. - Select: the
qz-controls.jspopover — 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: theqzc-callook (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. TheAPP_TIME_ZONEpinning is unchanged;select-and-date.test.tsxgains keyboard cases. - Switch
md38×22 with an 18px knob (default),sm36×20,lg42×24, knob--switch-thumb. Checkbox 16/r4 with the--inputborder, 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-cardand 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
mutedandself(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 onqz-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.
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-visiblestyle: 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 vendoredfocus-visible:ring-[3px] ring-ring/50classes are replaced. Pointer focus shows nothing.focus-visible.test.tsxcovers button, input, select trigger, checkbox, switch, tab, menu item and dialog close. - Dialog:
--overlaybackdrop;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-inkitem, 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-inkcolour; 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.tsexposes the four tones; the plaintoast()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.
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 withdata-scale="deck"on--bg; brand panelflex: 0 0 44%, max 620,--sidebarbackground, 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 (authbreakpoint) 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 incurrentColor, 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.tsexportsorgName(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) andappVersion(web/package.jsonversion). An unset value hides its line..env.exampledocuments them as public;deploy.ymlpasses them from repositoryvars. These three are the onlyVITE_ORG_*values R1 defines; R11 extends this file withVITE_ORG_EMAILandVITE_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.
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:
-
/loginper#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 18pxGoogleMarkand a busy state (aria-busy, spinner) — still our PKCE flow, never the GIS SDK; the "or with email" divider; onerole="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 whosearia-labelswitches; submitxl"Sign in" / "Signing in…" witharia-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_CREDENTIALSmessage 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 whenAccept-Languageprefers it, using thelanguage.Matcherapproach ofimports/http/errors.go. It stays one message for an unknown email, a wrong password and a disabled account. A 429 shows the server'sRATE_LIMITEDmessage (DG-40). -
/forgot-password(new, lazy, panel from 900): 44pxkey-roundtile 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 (phoneicon, "Front desk · {phone} · {hours}", hidden when unset, DG-41); outlinexl"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
/loginthat keeps the join context (DG-40); the student-home preload stays (callback-preload.test.tsxgreen). - 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
#loginand#forgotat 360, 768, 1024, 1280, 1440, light and dark.
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:
-
/joinand/join/:coderender onAuthLayout, 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",maxlength9) with a placeholder from the §6 alphabet,K7QM-2PXA, not the deck'sABCD-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/:codepre-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,
staleTime30 s). A unit test counts requests: each distinct complete code costs at most onePOST /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
joinCodeand the callback'senrolledClass; password sign-in callsPOST /app/classes/join;ALREADY_ENROLLEDcounts 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-passwordfirst 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/confirmredirects to/join/:code.JoinConfirmPage,JoinCodeFormandPublicLayoutare deleted (nothing else uses them); the PR says so. - Tests:
code.test.tsgains the invalid-format cases;join-page.test.tsxcovers debounce, one request per code, abort, the collapsed error, 429 and the non-student note;joined.test.tsxcovers success, already enrolled and the failure variant. E2E 3 and E2E 4 injoin.spec.tsare 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:livegreen. - Compared with
#joinand#joinedat the five widths, light and dark.
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'snewPasswordkeepsminLength: 8,maxLength: 512and gainspattern: '[\p{N}\p{P}\p{S}]'; the description states the rules. A violation is400 VALIDATION_FAILEDfromhttpx.ValidateRequests, with a message that states the rule (vi, or en when the request prefers it; #189); no hand-written check is added.make genoutput committed as its own commit. - New
ErrorCodePASSWORD_UNCHANGED(400, message vi then en): whilemustChangePasswordis set, the new password is verified against the current (temporary) hash and refused if it matches; otherwise it is refused when equal to the verifiedcurrentPassword. - Existing passwords are never re-validated and sign-in is unchanged. A test proves
PasswordManager.Temporary()'s output satisfies the pattern (word-word-NNin R1,word-word-word-NNsince 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--dangerat ≤ 1,--warningat 2,--successat ≥ 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.5danger-ink). - "Save and continue" (48px) is
aria-disableduntil 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_UNCHANGEDon both paths. Web tests: meter scores, checklist, mismatch,aria-disabledreveal, the server error shown. - Compared with
#changeat the five widths, light and dark.
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:
-
performRefreshdistinguishes three outcomes: refreshed; refused (401 or 403 from/auth/refresh, today's session-lost path); unavailable (networkTypeError, 503, other 5xx), which rejects every waiting request with a retryable error and never callsonSessionLost. The refresh stays single-flight;client.refresh.test.tsis untouched and green. - A 503 whose envelope code is
MAINTENANCEcalls a newsetMaintenanceHandlerwith{ startsAt, endsAt }before the error is thrown, from bothapi()anduploadFile(). - Every request sends
Accept-Languageset to the app locale (vioren), 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 phaseoffline, the session untouched;MAINTENANCE→ overlaymaintenance; any other error → the unexpected-error page with itsrequestId. An offline cold load no longer lands on/login. - Tests: a network failure or a 503
MAINTENANCEduring refresh does not callonSessionLost; a refused refresh does; five concurrent 401s with an unavailable refresh make one refresh call and all reject retryably;Accept-Languageis sent; the bootstrap outcomes.
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 noCONCURRENTLY):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)withCHECK (ends_at > starts_at),CHECK (ends_at - starts_at <= interval '12 hours')andEXCLUDE USING gist (tstzrange(starts_at, ends_at) WITH &&) WHERE (cancelled_at IS NULL)(a range-only exclusion, nobtree_gist).created_byis the database role that scheduled it, not an app user. The file thenREVOKE INSERT, UPDATE, DELETE ON app.maintenance_windows FROM quizzivy_app, narrowing the default privileges00009grants. Down:DROP TABLE app.maintenance_windows. Reviewed against the Neon skill; CI's up/down/up round trip passes. -
cmd/maintenancetakesmaintenance <command> [flags]. Todaymain.gocalls the globalflag.Parse()before readingflag.Arg(0), so a flag written after the command is never parsed (window-schedule -start X -end Ywould ignore both). Parsing moves tocore/maintenance/cli.go, where each command owns aflag.FlagSet;main.goonly reads the environment, opens the pool and runs what the parser returns.retain-integrityandanonymize-studenttake the same order, and today'smaintenance [-apply] [-batch N] [-student UUID] <command>keeps working as an alias (flags before the command are parsed as today). Every command gains-timeout <duration>(default1m, today's value) in place of the hard-coded one-minute context; a command may derive it from its own budget flag (R5'sretention-sweep -budget). R2'srekey-join-codesand R5'sretention-sweepregister in the same parser. -
cmd/maintenancegainswindow-schedule -start <RFC3339> -end <RFC3339>,window-list,window-cancel -window <uuid>andwindow-end -window <uuid>, with the existing dry-run default (-applyto write) and the existingMAINTENANCE_DATABASE_URLrequirement. 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 parsesretain-integrityandanonymize-student; an unknown command or flag fails with the usage line;-timeoutreaches the context. -
window-scheduleruns in one transaction that first takespg_advisory_xact_lock(73819, 40)(the start guard in T-R1.13 takes it shared), then one statement of data-modifying CTEs (PG18RETURNING id, old.deadline_at, new.deadline_atandRETURNING id, old.closes_at, new.closes_at, per AGENTS,idfeeding the audit row'sentity_id; no wildcard projection, so the audit diff carries only the moved column): insert the window (start no earlier than one minute ago); addends_at - starts_attodeadline_atof everyin_progressattempt whosedeadline_at > starts_at; add the same tocloses_atof every assignment withclosed_at IS NULLandcloses_atin[starts_at, ends_at); insert audit rows withactor_user_idNULL (System):attempt.extendedandassignment.extendedwith{deadline_at|closes_at: {old, new}, reason: "maintenance", windowId, databaseRole}, and onemaintenance.scheduled. The dry run reports how many attempts and assignments would move. -
20-data-model.mdgains an advisory-lock registry for the73819namespace: 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-cancelsetscancelled_aton a window that has not ended;window-endsetsends_at = now()on an active window; both keep the extensions already granted and writemaintenance.cancelled/maintenance.ended.window-listprints 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_apphasSELECTand noINSERT,UPDATEorDELETEonapp.maintenance_windows. -
docs/setup/operations.mdgains 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 becomemaintenance <command> [flags], naming the old order as a still-accepted alias, and-timeoutis documented.
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, tagpublic,security: []; R2 gives it nox-permission, as an open operation). ResponsePublicStatus { 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.AssertPublicRoutesLimitedpasses;public_routes_test.gocovers 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
theOpenSeveninauth_middleware_test.go, with the one-line justification forGET /public/status. - The
availabilitymodule: the domainWindow, the queryCurrentWindow, a repository with noSELECT *, 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 logsMAINTENANCE_STATUS_UNAVAILABLE. Wired inwiring/availability.go;core/tests/architecture_test.gopasses. - The gate (
httpx.Maintenance) is an outer handler between CORS and the router —CORS(Maintenance(handler))inrouter.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:503with the error envelope, codeMAINTENANCE,details { startsAt, endsAt },Retry-Afterin seconds toendsAt, 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 (routedOnlyinrouter.go), so a path no route serves gets the mux's 404 without a snapshot read. - Contract:
ErrorCodegainsMAINTENANCEandMAINTENANCE_SCHEDULED;components/responses/Maintenanceis defined and referenced from/auth/me,/auth/refresh,/auth/loginandsaveAnswers(the responses the web branches on);info.descriptionsays every operation except the three exempt routes may answer it. - Start guard:
availability/repositories.GuardAttemptStart(ctx, q, now, deadline)takespg_advisory_xact_lock_shared(73819, 40)(the registry's maintenance-windows key, T-R1.12) and returns the first non-cancelled window withstarts_at < deadlineandends_at > now. Attempts never imports it:core/tests/architecture_test.gorefuses a repository that imports another module's non-domain layer. Following theQuestionLocks/LockForDraftUsepattern oftests/repositories/store.go, the attempts repository declares anAttemptStartGuardinterface;core/adapters/availability.goimplements it by callingavailability/repositories.GuardAttemptStart, andwiring/attempts.gopasses it in. The repository'sCreateruns in a transaction that calls the guard before the insert; a hit answers409 MAINTENANCE_SCHEDULEDwithdetails { startsAt, endsAt }(added tostartOrResumeAttempt's 409). Resume never checks it. The old student intro page shows the server message it already displays for a 409. -
guard_test.gocovers the guard's query (overlapping, adjacent, cancelled and past windows).TestWindowScheduleSerialisesAgainstAttemptStart, incore/maintenance/tests/windows_test.gobecause onlycoremay import both sides, drives a start and awindow-scheduleconcurrently and fails within two attempts without the locks (the pattern ofTestLockForDraftUseSerialisesAgainstDelete).create_test.gocovers 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 afterends_at; the gate's position is pinned beside the middleware-order test inrouter_test.go.
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:
-
saveAnswers200 gains requireddeadlineAt(the row's currentdeadline_at, already read byautosave.go). Additive: an open v0.6 tab ignores it. -
submitAttemptwith reasontimer_expiredwhile the server deadline is more than 5 s away answers409 DEADLINE_NOT_REACHEDwithdetails { deadlineAt }(newErrorCode).manualandauto_submitare unchanged; atimer_expiredwithin 5 s of the deadline, or after it, behaves as today. - The store adopts the server's deadline from either response:
deadlineAtupdates, the existing subscription re-arms the single deadline timer, a refused submit returnssubmitStateto 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) andpnpm 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 noisCorrect,sampleAnswer,acceptedAnswersortranscript. - Server tests: autosave returns the extended deadline after an Extend; an early
timer_expiredis 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_REACHEDreturns to idle and re-arms at the new time; no double submit.
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 intoerrorArt.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
(
requestIdfirst, else four random bytes asxxxx-xxxx,user-select: all) withCopyButton: "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 (clockinwarning-ink, "Started {time}", "about {n} min";circle-checkinsuccess-ink, "Answers and results saved"), primary "Check again", footnote. Fed by the 503 details orGET /public/status(T-R1.16 wires it).
- 404: h1, body, the path chip with the real
-
NotFoundPage.tsxreplacesErrorBoundary.tsx'sNotFoundexport:router.tsx's*route andErrorBoundary'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 existingNotFoundArtbecomes the deck's 404 SVG rather than gaining a second 404 illustration. - "Home" is the caller's console,
homePathFor(user), and/loginwhen signed out (DG-43). - The four pages and
SystemFrameare in the entry chunk (they must render when a lazy chunk cannot load);router-chunks.test.tsgains that assertion and removes none. - Keys
notFound.*,forbidden.*,error.*,maintenance.*rewritten vi first, en from the deck, listed in the PR. -
screens.test.tsxrewritten: home per role and signed out, the signed-out 403, copy success and failure,requestIdpreference, maintenance times. - Compared with
#404,#403,#error,#maintenanceat the five widths, light and dark.
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.htmlputs the 64px mark (theme-correct throughhtml.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 gainshandle: { 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.tsxasserts the switcher's and theme button's labels do not render. - Skeleton hand-off:
handoff.tsmaps 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; theonlineevent 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" readsGET /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;_headersgives/version.jsonCache-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 hashandle.focus; avite:preloadErrorshows 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. -
useIntegrityMonitorrecords 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.tsassertsapp/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 503MAINTENANCEon/auth/meshows the maintenance page, which closes when/public/statusreports no window; a changed/version.jsonshows 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.
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
/adminroute and every/approute, including the engine on the three-part paper fromseed/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.
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 containsL); §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"); §13maintenance_windows; §15GET /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 withtheOpenSeven; the middleware section notes the maintenance gate sits outside the router, between CORS and the mux; Design names--focusandno-raw-colours.test.ts. -
70-redesign-overview.md§4.2 already carries the whole open-operation schedule (six at v0.6.0, R1'sgetPublicStatusmaking 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: amaintenance_windowssection, its inventory row and a deviation (the app role reads only; the operator writes throughcmd/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.
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-r1withdevelopmerged 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-deckon 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 withgetBoundingClientRect; the table (screen × width × theme → match / fixed / DG id) is in the release PR. - A local maintenance rehearsal: a student mid-attempt,
window-schedule -applytwo minutes ahead; a second student's start is refused; the window opens and both tabs show the overlay; afterwindow-endthe 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.
- Every Done-when box above is ticked on
work/redesign-r1; the R0 PRs (T-R0.1 … T-R0.9) are ondevelop;developis merged in and T-R1.19 is green. - Every PR's new vi keys reviewed by Thuong (D11); the answers applied.
- Security: the leak review for
GET /public/statusis in its PR; its rate-limit test passes;theOpenSevenis the only change to the open list; E2E 9 green after thesaveAnswerschange. - Migration rehearsed on a Neon branch of production: goose up, then
maintenance window-scheduleas a dry run against the branch (it reports counts and writes nothing); branch deleted. - Production prerequisites: repository variables
VITE_ORG_NAME,VITE_ORG_FRONT_DESK_PHONEandVITE_ORG_FRONT_DESK_HOURSset (optional; unset lines are hidden);web/package.jsonversionset to0.7.0(the splash label). No new secret. - Deploy outside exam windows: no assignment open with an attempt in progress (the release
changes
saveAnswersandsubmitAttempt). -
work/redesign-r1→develop(--no-ff);release/0.7.0cut fromdevelopwith the release notes, the spec version confirmed and the NFR registry reviewed. - 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). -
release/0.7.0→mainwith Thuong's go (--no-ff); Deploy runs the API (release_commandmigrates) then Pages. - Smoke on production:
/livez;/healthzwithdatabase: ok; the worker group running;GET /public/statusanswers{"maintenance":null};/loginin 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/adminin the old console with the new palette and font; a student signs in;maintenance window-listruns against production. - Tag
v0.7.0; back-mergemain→developby PR; close the R1 epic issue. Rollback is roll forward (70 §8).
- 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.