Skip to content

Plan 73 R3

github-actions[bot] edited this page Oct 14, 2026 · 1 revision

R3 — Student console (v0.9.0)

The student half of the redesign. Every /app screen and the take-test engine are rebuilt to docs/design/deck/Quizzivy Student.dc.html. This file also carries the contract half of R2's expand steps: the legacy role enum goes, the ownership constraints are validated, and the /admin path alias is removed. No teacher or admin screen changes. Where the deck is silent, docs/design/gaps.md says what to build: DG-80 (engine frames), DG-81 (URLs) and DG-82 (badge, bell, notification switches).

Two versions (decided 2026-10-04). v0.9.0 ships the student console, T-R3.4 to T-R3.14, without T-R3.1, T-R3.2 and T-R3.3. Those three are R2's contract steps, and each needs v0.8.0 to have been in production for seven days. v0.8.0 was released on 2026-10-03, so they merge no earlier than 2026-10-10 and ship as v0.9.1. Until then the code keeps users.role, app.user_role and the sync trigger, the NOT VALID owner constraints with their fill triggers, and the /admin alias.

Migration numbers (2026-10-08). work/redesign-r4 merged 00080 to 00091 before these three could merge, and production's cmd/migrate runs goose.Up without allowing missing versions, so neither numbering works for a v0.9.1 from develop. Numbered from 00080, its files collide with R4's versions where the branches meet, and goose refuses the duplicate in CI. Numbered after 00091 and deployed first, it makes production refuse R4's 00080 to 00091 as missing. Until Thuong decides otherwise, the three ship inside R4 as T-R4.49, numbered on work/redesign-r4, and this file's release checklist runs inside v0.10.0's. A separate v0.9.1 from develop would first renumber R4's merged migrations after its own (74-r4.md T-R4.49). Where a document says "until v0.9.1", read "until T-R4.49 ships"; the v0.9.1 checklist's docs sweep rewrites those lines.

Two constraints shape the file.

The first is risk. The engine is the highest-risk code in the product (70 §9 PR-3; 30-risks.md R-01, R-03, R-05, R-06), so it is rebuilt in four sub-PRs. Each sub-PR runs the five canaries on its branch point and again on its last commit, together with pnpm test:unit tests/units/take-test tests/units/integrity tests/units/media. The five canaries are:

  • server/internal/modules/tests/application/tests/publish_snapshot_test.go
  • web/tests/units/media/audio-player.test.tsx
  • server/internal/modules/attempts/application/tests/events_test.go
  • web/tests/units/api/client.refresh.test.ts
  • web/tests/integration/router-chunks.test.ts

The second is order. R3 and R4 are independent (70 §2 rule 4). If R4 ships before v0.9.1, T-R3.1 to T-R3.3 move to R4 unchanged (74-r4.md T-R4.49). If it ships before v0.9.0, the student bell and notification settings, which this file gates on R4's module, are built here instead.

Branch work/redesign-r3 is cut from develop. Feature branches are feature/t-r3-<nn>-<slug>.

Deliverable: the /app tree (Home, Classes with the Join dialog, Test intro, Result, Settings) under a rebuilt StudentLayout. The engine is rebuilt in FocusLayout with split passage and question panes, a footer number strip, a phone question sheet, flags, the focus dialog, the copy-and-paste toast and the red timer. It keeps autosave and local drafts, integrity capture, same-tick audio, section grouping and single-flight refresh. Learn, Grades, Messages, This week, Practice and the bell stay absent until their modules ship. The R2 contract steps follow as v0.9.1.

Exit criteria:

  • Every /app route matches the deck at 360, 768, 1024, 1280 and 1440, in light and dark.
  • The five canaries pass untouched before and after every engine PR.
  • Live E2E 2 and 5–9 pass, with E2E 9's key list unchanged.
  • The student console ships to production as v0.9.0.
  • v0.9.1: users.role and app.user_role are gone, and no NOT VALID constraint is left in app outside the pinned list.

Decisions taken in this file, worth a second look.

  • The shell branches at 768px through a media query; pages use container queries. The deck measures its root element with a ResizeObserver. The student shell is the viewport, so useMediaQuery("(min-width: 768px)") gives the same number and keeps tests/support/viewport.ts working. Thresholds inside a page (Home's two columns at 820) are CSS container queries on the shell's root, the container named student, which is exactly as wide as the viewport, as the deck's measured root is. They need no JavaScript. The delivery draft's useContainerWidth hook is not added.
  • The deck's Submit dialog replaces the full-page review screen. The deck draws no review page. The dialog carries the same facts: answered, flagged, time left, and a "Go to" chip per unanswered question. The submit path is unchanged: it flushes, then submits, and stays idempotent against a racing auto-submit. Only the surface moves.
  • On phones, the save state appears only when a save has failed or the device is offline. The deck shows "All answers saved" from 768 up and nothing on phones. A phone whose saves are failing must say so. A one-line strip under the header appears when a save has failed, or the device is offline, while an answer is unsaved, and goes away once the answer is saved. Where the assignment sets a limit on leaving, the strip is there from the start for the strike count, and only the failed or offline line comes and goes in it (T-R3.9a, As built). A save that is merely on its way draws nothing (amended in T-R3.9a's review): a strip for "Saving…" came and went on every answer, and either moved the paper under the student's thumb or cost a permanent empty row the deck does not draw. The strip is in the layout flow, so the paper moves once when it appears, which is acceptable for a state the student must not miss. From 768 the header's save line is unchanged. The strip is listed for DG-80 sign-off.
  • An unknown question type renders a "reload" block, never a choice list. Today the engine's default branch renders any unknown type as options and writes a choice answer. R6 adds three types, and a tab that loaded this engine earlier would mis-answer them. The block names the question, writes nothing, and offers to reload. R1's update prompt stays out of FocusLayout.
  • Keys are the deck's a–e plus the existing F for flag. The deck binds a–e. The engine binds F to flag today, so a key for a sixth option would collide with it. Options after E are reached with Tab. Arrow keys and Esc work as the deck draws them.
  • "Larger text in tests" is stored locally until R4's users.preferences exists. It lives in localStorage['quizzivy.testText'], read inside try/catch. R4 moves it to preferences.largerTestText, migrating the local value once (74-r4.md); after that the server value wins. This follows the theme rule in 70 §6.
  • The resume card's answered count applies the engine's rule in SQL. "3 of 8 answered" must agree with the navigator's squares, which follow take-test/answered.ts: a fill-in counts only when every blank is filled. The student assignment list counts saved answers by the same rule, pinned by an integration test that runs the unit test's cases. An answer that exists only in the local draft is not counted.
  • Part tiles are summed on the client from sections. The result gains sections and ResultQuestion.sectionId, both safe for students, and the page sums earned per part. A server-side parts[] would repeat the review-policy gate in a second place.
  • R3 has no attempt-level comment callout. The deck's "Comment" callout has no model. attempts.teacher_note is the teacher's private note and must never reach a student. Per-answer grader comments stay on their review items. R6 handles the one case the deck draws, a single hand-graded essay.
  • The bottom tab bar shows only destinations that exist. In v0.9.0 those are Home, Classes and Me. The deck's five tabs return one by one as R9 (Grades) and R10 (Learn) ship (70 §2 rule 5). Nav items, the Messages button, the bell and the page controls read one availability map.

T-R3.1 — Retire the legacy role (contract step)

Depends on: no binary older than v0.8.0 (R2) in production for at least seven days (72-r2.md release checklist item 9). v0.9.0 counts: it carries every R2 expand step and none of T-R3.1 to T-R3.3. Ships in: v0.10.0 as T-R4.49 by default, or v0.9.1; no earlier than 2026-10-10 ("Two versions" and "Migration numbers" above). v0.9.0 is then the binary running while this deploys: where the bullets say the v0.8.0 tag or a v0.8.0 token, read v0.9.0. The migration takes the next number on the branch it merges into ("Migration numbers" above). Touches: migrations/00105_drop_users_legacy_role.sql (new), api/openapi.yaml (User.role, CurrentUser.role, Role), server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/platform/httpx/auth.go, server/internal/modules/identity/application/token/token.go, server/internal/modules/identity/repositories/store.go, seed/01-dev.sql, seed/03-dev-students.sql, server/internal/core/tests/legacy_role_test.go (new), server/internal/platform/db/tests/contract_r3_test.go (new), docs/plan/20-data-model.md Size: M Done when:

  • Before the first commit, a grep of the v0.8.0 tag finds no query, seed or test that names users.role, u.role or app.user_role outside migrations/. The output goes in the PR. v0.8.0 is the binary still running during the deploy window, so this check is what makes dropping the column safe.
  • The migration runs in one transaction, using the names 72-r2.md created: - ALTER TABLE app.users VALIDATE CONSTRAINT users_role_id_not_null - drop the trigger users_sync_legacy_role and its function - drop users_role_active_idx - drop users.role - DROP TYPE app.user_role
  • Down restores the legacy role and passes goose up/down/up in CI: - it recreates app.user_role and grants USAGE to quizzivy_app - it adds role as nullable and backfills it through the view R2 made the single strict student predicate, which survives R3: CASE WHEN role_id IN (SELECT id FROM app.student_like_roles) THEN 'student' ELSE 'admin' END. A grants-only rule would classify the Admin, which stores at most its "Take tests" cell, as a student. - it sets NOT NULL DEFAULT 'student' - it recreates the index and the sync trigger
  • A Down test in contract_r3_test.go (integration) seeds an Admin whose role has learning.take_tests granted, a Teacher and a Student, runs the Down, and asserts role is 'admin', 'admin' and 'student'.
  • legacy_role_test.go walks server/, seed/ and docker/, skipping migrations/ and server/gen/. It fails on app.user_role, users.role, role = 'admin' or role = 'student'.
  • Access tokens no longer carry the legacy role claim. The parser ignores the claim if present, so a v0.8.0 token presented during the deploy window still authenticates. A unit test uses a fixture token.
  • User.role/CurrentUser.role and the Role schema, which R2 T-R2.6 kept, leave api/openapi.yaml: - make gen is its own commit - any remaining web call site is fixed in the same PR, and pnpm typecheck passes - GET /auth/me has a response test
  • Integration test: inserting a user without role_id fails with the not-null violation.
  • The users section of 20-data-model.md and its §12 deviation entry record that the enum is gone, and spec §13's users table matches.
  • Rehearsed on a Neon branch of production (Thuong, 2026-10-11): goose up from 79 to 106 in 7.04 s, 00105 in 469 ms; r4-legacy-role.sql A3 is 0, section A prints identically before and after, B1 to B5 print what they must and B6 is empty. Both machines run v25 (v0.9.0 plus hotfix #431), and nothing older than v0.8.0 has run since 2026-10-04. Evidence: https://github.com/SekiroKenjii/Quizzivy/pull/486#issuecomment-6109429331

T-R3.2 — Validate ownership and drop the fill triggers (contract step)

Depends on: no binary older than v0.8.0 (R2) in production for at least seven days (72-r2.md release checklist item 9). v0.9.0 counts: it carries every R2 expand step and none of T-R3.1 to T-R3.3. Ships in: v0.10.0 as T-R4.49 by default, or v0.9.1; no earlier than 2026-10-10 ("Two versions" and "Migration numbers" above). v0.9.0 is then the previous binary: where the bullets say v0.8.0, read v0.9.0. The migration takes the next number after T-R3.1's. Touches: migrations/00106_validate_ownership.sql (new), server/internal/platform/db/tests/constraints_test.go, server/internal/platform/db/tests/contract_r3_ownership_test.go (new; a file of its own beside T-R3.1's contract_r3_test.go, so the two pull requests add no file in common), server/internal/platform/db/tests/expand_r2_test.go, the fixtures and seed/02-dev-assignments.sql and seed/05-dev-sections.sql that insert without an owner, docs/setup/rehearsals/r4-ownership.sql (new), docs/setup/deploy.md, docs/plan/20-data-model.md Size: S Done when:

  • The migration covers every owner column that R2 constrained NOT NULL … NOT VALID (72-r2.md T-R2.9). It validates, by name, classes_teacher_id_not_null, tests_owner_id_not_null, questions_owner_id_not_null, question_groups_owner_id_not_null and media_assets_owner_id_not_null. For each one: - a DO block raises, naming the table, if any row is still NULL - then VALIDATE CONSTRAINT runs, under SHARE UPDATE EXCLUSIVE, so reads and writes continue
  • The fill triggers and their functions are dropped, using the names T-R2.9 created and no IF EXISTS, so a wrong name fails the migration instead of leaving a trigger behind: - the BEFORE INSERT triggers on tests, questions and question_groups, then app.fill_owner_from_created_by() - the BEFORE INSERT trigger on media_assets, then app.fill_owner_from_uploaded_by() - the trigger classes_fill_teacher on classes, then its function
  • The previous binary (v0.8.0) sets every owner itself. contract_r3_ownership_test.go asserts that neither fill function, the classes_fill_teacher trigger nor its function exists.
  • Before the triggers go, every Go test fixture and seed/*.sql insert into tests, questions, question_groups and media_assets names owner_id (classes already name teacher_id since T-R2.9). About twenty test files rely on the fill triggers today; grep INSERT INTO app\.(tests|questions|question_groups|media_assets) in *_test.go.
  • Down recreates the functions and triggers. The constraints stay validated, because Down never weakens a valid NOT NULL.
  • constraints_test.go pins the not-validated constraints in app to an explicit list, empty after this release: - a later expand step adds its constraint to the list, and its contract step removes it - a forgotten NOT VALID fails CI - an insert that omits an owner fails
  • Rehearsed on a Neon branch of production (Thuong, 2026-10-11, in the run of T-R3.1's): goose up from 79 to 106, 00106 in 798 ms. r4-ownership.sql P1 is 0 in every table (classes 1 row, media_assets 2, question_groups 2, questions 137, tests 3); the scan behind each VALIDATE is one sequential scan of 1 to 9 buffers in 0.03 to 0.08 ms; section A prints identically before and after, B1 to B5 print what they must, and no triggers or functions are left. Both machines run v25, and nothing older than v0.8.0 has run since 2026-10-04. Evidence: https://github.com/SekiroKenjii/Quizzivy/pull/491#issuecomment-6109430067
  • 20-data-model.md records the contract half of each ownership deviation.

T-R3.3 — Remove the /admin legacy alias (contract step)

Depends on: no binary older than v0.8.0 (R2) in production for at least seven days (72-r2.md release checklist item 9). v0.9.0 counts: it carries every R2 expand step and none of T-R3.1 to T-R3.3. Ships in: v0.10.0 as T-R4.49 by default, or v0.9.1; no earlier than 2026-10-10 ("Two versions" and "Migration numbers" above). Moved to R5 (v0.12.0) as T-R5.36 (the owner, 2026-10-12): v0.10.0 shipped T-R3.1 and T-R3.2 without it (74-r4.md T-R4.49). Touches: server/internal/core/router/legacy_admin.go (removed; the outer alias handler 72-r2.md T-R2.8 added), server/internal/core/router/legacy_admin_paths.go (removed), server/internal/core/router/tests/legacy_admin_test.go (removed), server/internal/core/router/tests/unrouted_test.go (TestTheLegacyAliasKeepsTheSameAnswers removed: it pins HEAD and a wrong method on an alias path), server/internal/core/router/router.go (the alias wiring), server/internal/core/router/ratelimits.go, server/internal/core/router/tests/router_test.go, AGENTS.md Size: S Done when:

  • The PR records the number of legacy_admin_path log lines in production over the seven days before merge, and the paths seen. Traffic that is not a stale browser tab, such as a script or a bookmarked API path, is named and dealt with before the alias goes.
  • The handler (legacy_admin.go), its pinned path list (legacy_admin_paths.go) and its test (legacy_admin_test.go) are removed together, and router.go no longer wraps the mux with it. The PR says why the test goes: the behaviour it pinned is removed on schedule, and the test is passing, not failing.
  • A former teaching path under /admin/ now gets the same 404 as any path the contract does not declare. router_test.go covers it.
  • RateLimits() holds no key for an aliased path. The test that every credential-minting operation has a limit still passes.
  • The R2 bullet in AGENTS.md's "Redesign in progress" no longer mentions an alias, and the v0.9.1 bullet beside it leaves with the last of T-R3.1 to T-R3.3.

T-R3.4 — Student contract: resume progress and result parts

Depends on: — Touches: api/openapi.yaml (StudentAssignmentCard, ResultQuestion, getAttemptResult), server/gen/openapi/, web/src/lib/api/schema.d.ts, server/internal/modules/assignments/{domain/student_view.go,repositories/student.go,http/student.go}, server/internal/modules/assignments/repositories/tests/student_test.go, server/internal/modules/attempts/{repositories/result.go,http/result.go}, server/internal/modules/attempts/application/tests/result_test.go, web/tests/support/{fixtures,handlers}.ts, docs/quizzivy-spec-v0.3.md §15 Size: M As built: liveAnsweredCount is omitted, not null, when there is no live attempt (omitempty, as liveDeadlineAt is); the schema allows null and does not require the field, and both descriptions now say "absent". StudentDetail computes the count too, and StudentAssignmentDetail does not carry it. "Blank" is what JavaScript's trim() leaves empty: the SQL trims the same 25 code points. student_test.go runs every case of answered.test.ts and four the browser cannot produce, which count as nothing: an answer of an unknown type, one with no type, a true/false whose value is null and a choice whose options are not a list. web/tests/support/{fixtures,handlers}.ts hold no card or result and were not touched; also touched are attempts/domain/review.go, attempts/http/attempts.go, attempts/http/tests/parts_test.go (new), server/tests/join_and_take_flow_test.go, web/tests/units/take-test/answered.test.ts, web/tests/units/results/result.test.tsx and web/tests/e2e/{group-review,student-layout}.spec.ts. The result page counted a saved row as answered (#234) until T-R3.10 rebuilt it. The teacher's monitor counted that way until #234's fix moved the rule to shared/answered, which both counts now splice. Done when:

  • StudentAssignmentCard.liveAnsweredCount: integer | null counts the live attempt's saved answers that say something. It is absent exactly when hasLiveAttempt is false (As built: omitted, not null).

  • The count applies the rule in web/src/features/take-test/answered.ts, in SQL: - choice: optionIds is non-empty - true/false: value is a boolean - text: the value is non-blank - fill-in: every blank of the frozen question is non-blank

    `student_test.go` runs the same cases as `web/tests/units/take-test/answered.test.ts`.
    
  • The result gains sections: StudentSection[] and ResultQuestion.sectionId. Both appear under every review policy: a part's title and membership reveal nothing the paper did not already show.

  • Neither /app/* operation changes its x-permission, and no operation is added.

  • The PR's leak review says neither field is key-shaped. web/tests/units/contract/openapi-contract.test.ts and server/internal/modules/attempts/application/tests/payload_test.go pass unchanged.

  • make gen is its own commit. MSW fixtures validate with ajv, and pnpm typecheck passes.

  • Spec §15 documents both fields.


T-R3.5 — Student shell

Depends on: R1 (tokens, primitives, theme, splash hand-off) Touches: web/src/layouts/StudentLayout.tsx, web/src/layouts/detailShell.ts, web/src/layouts/slots.ts, web/src/app/router.tsx, web/src/app/modules.ts (new unless an earlier release added it), web/src/features/auth/AccountMenu.tsx, web/src/features/auth/SignOutButton.tsx, web/src/components/shared/Brand.tsx, web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/student/shell.test.tsx (new, replaces desktop.test.tsx), web/tests/e2e/student-layout.spec.ts Size: L As built:

  • Touched beyond the list: components/ui/{deck-scale (new),select,switch,segmented,dropdown-menu,sonner}.tsx, features/assignments/dueSoon.ts (new), layouts/StudentSkeleton.tsx (new), lib/theme.ts, index.css, one line each in the Home, Classes, Intro and Result pages, tests/support/viewport.ts, tests/e2e/{settings,group-review}.spec.ts and nine unit test files. detailShell.ts, slots.ts and SignOutButton.tsx needed no change.

  • The deck surface. DeckScale is the shell's root: it sets data-scale="deck" and tells a select's list and a menu, which portal out of it, to carry the attribute. It is also the container named student, exactly viewport-wide, for the pages' container queries (@min-[820px]/student:); a query on the padded <main> would flip later than the shell does.

  • The shell's 768 is a pixel value everywhere (useMediaQuery("(min-width: 768px)") and min-[768px]:), never Tailwind's md:, which is 48rem and moves with the browser's font size.

  • <main> scrolls inside a fixed-height root, and returns to the top when the route changes, not when Settings moves between its sections or Home changes a filter. On a new route it also takes focus, unless the page has already put focus inside it, so the keyboard scrolls the page and Tab starts at its first control. It draws no ring: it is a region, not a control.

  • Intro and Result draw their own back link from 768, where the shell stops drawing the detail header; they asked at 1024 before, which left 768 to 1023 with no way back.

  • A list that has loaded stays on screen when a later refetch fails. Home and Classes report a failure only when there is nothing to show; the badge's refetch on mount and on focus would otherwise blank Home whenever the connection drops.

  • The theme item works where the browser refuses storage: the choice then holds for as long as the page lives.

  • Home's class filter already draws the deck's 32px list rows, because its select is on a deck surface; T-R3.6 removes the filter.

  • The 44px floor (.student-surface) moved from the root to <main>: the chrome uses the deck's 36px and 40px controls, and each page drops the floor when it is rebuilt.

  • The Result page still names the paper in the phone header through setTitle; T-R3.10 removes it for the deck's "Result".

  • The badge counts the cards the server lists as due now or upcoming that close within 7 × 24 hours of the fetch: a paper with an attempt left counts, and a paper being taken closes at its attempt's deadline. It is absent at 0, while loading and on an error. The shell's reading of the lists refetches when it mounts, which is when the student returns from a test, and when the tab becomes visible; nothing is on a timer.

  • Messages and the bell have flags and no markup: R7 and R4 add the buttons with their data.

  • Primitives. Segmented 30px on a deck surface with size="lg" at 32px; Switch md 38x22 on a deck surface, new sm 36x20 and lg 42x24; Select trigger 38px on a deck surface with lg 42px. The segmented control's count and sideways scroll stay with T-R4.2.

  • The h1 belongs to each page. The recipe for T-R3.6 onwards is text-h1-student from 768 and text-stat font-semibold tracking-[-0.02em] (22px) below.

  • Departures recorded: the toast's position (DG-87), the name as text beside the mark (DG-88), and the Me tab (DG-89). The account menu opens 46.5px below the button's top, half a pixel more than the deck's 46.

  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, on Home and on Settings (the phone detail header): header, logo, nav items, badge, avatar button, main padding, tab bar, back control and the account menu match. The pages inside are today's until T-R3.6 to T-R3.11. Done when:

  • There is one StudentLayout outlet. useMediaQuery("(min-width: 768px)") selects only the chrome, and a form's state survives crossing 768 (test).

  • The primitives T-R1.5 deferred that the student screens use first (select, switch, segmented) get their deck geometry here, with a deck-scale context that their portaled popovers read, so a popover opened from a deck surface keeps the deck's sizes (#165). Checkbox and the underline tabs appear only in the teacher and admin decks and stay with T-R4.2.

  • The top bar is 60px high. It holds: - the mark (colour, or on-dark by theme) and the wordmark, linking to /app - from 768, the nav: Home (house) and Classes (users) - Learn (R10) and Grades (R9), only once modules.ts says their module has shipped

  • Home carries the DG-82 badge: the number of assignments not yet completed that are open, or will open, and close within seven days.

  • Messages (R7) and the bell (R4) render only when their modules are on. Whichever of R3 and R4 ships second wires the student bell per DG-82: a dot only when something is unread, and "Mark all read". R3 ships first, with flags and no markup: T-R4.45 adds the bell.

  • The avatar button shows initials from given and family name (DG-22). It opens the 240px account menu: - name and email - Settings - Dark mode or Light mode, using R1's theme - Sign out, in danger ink under a divider

    The menu follows the WAI-ARIA pattern: roving focus, and Esc returns focus to the button
    (DG-36).
    
  • Below 768 the top nav is hidden and a bottom tab bar shows: - the root destinations that exist: Home, Classes and Me (→ /app/settings) in v0.9.0; Learn and Grades once shipped - 52px tabs with the icon in a 48×28 pill; the active pill is --accent-soft - the bar is hidden on detail routes, and toasts sit above it

  • Detail routes declare handle.detail = { titleKey, back }, all going back to /app: assignments/:id (Test), attempts/:attemptId/result (Result) and settings/:section? (Settings). Below 768, the logo becomes a 36px back button with the title. From 768 the nav stays, and the intro and the result draw their own back link; Settings draws its heading.

  • Main area: - padding 32/24/48, or 18/16/28 below 768 - h1 26px, or 22px below 768 - scroll resets on navigation - each page sets its own maximum width (T-R3.6 to T-R3.11)

  • No PageAside, PageAsideSlot or SideColumn renders under StudentLayout.

  • No "Coming next" route. The deck's unreachable placeholder is prototype chrome; an unshipped module is hidden instead (70 §2 rule 5).

  • The shell polls nothing. The badge and the assignment lists refresh when the shell mounts, on focus and after mutations (70 §9 PR-7); the class list refreshes after a join. The intro reads a scheduled test again each minute and at its opening (T-R3.8).

  • The student skeleton is registered with R1's splash hand-off.

  • Dark mode opens on the student side. StudentLayout no longer calls R1's useForcedLightTheme(), and the mark uses Brand theme="auto". Light and dark both render from tokens.

  • Unit tests cover: - the nav items match the available modules (flipping a flag in the test adds the item) - the tab bar at viewport("phone") - the detail header on a phone - the account menu's keyboard behaviour and Esc - the badge count from a fixture

  • Compared with the deck's Home frame at the five widths, light and dark. The PR lists the new vi and en keys.


T-R3.6 — Home

Depends on: T-R3.4, T-R3.5 Touches: web/src/features/assignments/pages/StudentHomePage.tsx, web/src/features/assignments/components/StudentPanel.tsx (removed), web/src/features/assignments/components/{ResumeCard,ComingUp,RecentResults}.tsx (new), web/src/features/assignments/{status,studentTime}.ts, locales, web/tests/units/student/home.test.tsx Size: M As built:

  • Touched, against the list above: StudentPanel.tsx is the teacher's roster panel and stays; status.ts is the teacher's and is not touched. New: features/assignments/studentHome.ts, components/{HomeSkeleton.tsx,homeStyles.ts}, tests/units/student/home-view.test.ts. Also lib/i18n/datetime.ts (seven helpers), hooks/useTick.ts (useMinute), layouts/StudentLayout.tsx (one doc line), tests/units/i18n/datetime.test.ts, tests/units/student/shell.test.tsx, tests/e2e/student-layout.spec.ts and tests/e2e/audio-plays.live.spec.ts.

  • The view is a pure function. homeView(lists, now) picks the resume card, the rows with their pills and tile dates, the results and the line under the greeting. Its boundaries (24 hours, the calendar day, the opening, the closed window, one minute) are tested there to the millisecond with an explicit clock. What "Opens" and "Next up" say (the time today, a weekday for one to six days, a date from seven) is decided in ComingUp.tsx and StudentHomePage.tsx and tested in the rendered file at 0, 1, 3, 6 and 7 days; the rendered tests keep a minute's margin, because fake timers that advance cannot hold a boundary.

  • Greeting. Morning from 05:00, afternoon from 12:00, evening from 18:00, read in the app's zone. The deck writes one static greeting (DG-102).

  • The page has a clock. It repaints each minute through useMinute, so a tab left open overnight does not keep yesterday's greeting, pills and sentence; a refetch that returns the same lists would not repaint it. Nothing is fetched to do so.

  • The line under it, first match wins: the live attempt ("…It closes at {time}", the attempt's deadline, with the date when it is another day); the count of papers closing today, open or still to open (a classroom test whose window lies inside today is due today); the next row ("Next up: {title} on {weekday}", a date from seven days out, and "opening at {time} today" for a paper that opens later today); "Nothing due today." when only results are left; nothing at all for a student with nothing assigned. The deck draws the first and the third.

  • Pills. An open paper closing within 24 hours reads "Due today" or "Due tomorrow" by the calendar day it closes on in the app's zone; the plan's single "Due tomorrow" would say tomorrow of a paper closing in four hours. The boundary is inclusive at exactly 24 hours. Open-ness comes from the card's status, not its dates, except that a scheduled paper whose opening has passed on this clock reads as open. "Opens" takes the time today, the short weekday for one to six days, a date beyond. A paper whose window has closed on this clock leaves Coming up, as it leaves the badge. The tile is amber exactly when the pill is.

  • Coming up has every paper still to do except the resume card's, ordered by the date on its tile, uncapped. A paper with an attempt left stays there after a first attempt. Its count is plain text and excludes the resume card's paper, so it can differ from the badge.

  • Every result is listed until Grades ships. The deck shows three, and Grades holds the rest. Grades is R9's, and the old Home linked to every finished paper's result, so cutting to three now would leave an older result with no link on any student screen for six releases. Home lists them all, newest first, while modules.grades is off, and the deck's three once it is on; R9's task turns the flag on and needs no change here (DG-102).

  • Recent results read more than completed. They are the papers submitted last wherever the server lists them: a paper with a retake left stays in dueNow, and reading completed alone would hide a result just submitted, and "Just now" with it. Such a paper shows twice, as still to do and as a result. A submitted paper the teacher has since moved to open later is in upcoming, and its result shows too. The server orders by closing time, so Home sorts by submission. An attempt that ran out of time and that the server has not closed yet has no date and comes first: it is the last one sat, and opening its result is what closes it.

  • "Being graded" needs the score to be shown: pendingManual lives inside score, so a paper whose score is hidden reads "Submitted" while it waits.

  • The minutes never read 0, and the card stays after the deadline passes on this clock: Home has no server clock offset, and a fast device must not hide an attempt the server calls live. It repaints through useTick; nothing is fetched to do so. Continue names the attempt it resumes (resume), so it can never start a new one (#237). When the server refuses to resume, the lists are read again, so a card for an attempt that has ended does not stay. The server refuses only when the attempt is over, so the card always goes: the reason is a toast, which outlives it, and the card draws no error of its own.

  • Emptiness is what Home would draw, not what the server sent. A student whose only paper has closed on this clock gets the "nothing assigned" state, as a fresh fetch would give.

  • "Join a class" is still the link to /join; T-R3.7 swaps it for the dialog. It shows only for a student in no class with nothing assigned, and Home waits for the classes before choosing between that and "nothing assigned".

  • The filter is gone, with its URL parameters. The Classes page's link to /app?classId=… lands on the whole of Home until T-R3.7 rebuilds those cards.

  • Rows are links, so they open in a new tab and take the keyboard's Enter. The list card does not clip, so a row's focus ring shows; the first and last rows round their own corners.

  • The 44px floor needed no change: nothing the deck draws on Home is under 44px. T-R3.7 is the first page with a deck control under it.

  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's own fixtures: every box, type size, colour and gap matches, with three differences. Recent results take the full width, as the plan asks until Practice exists. The resume pill is 6px narrower: the deck's template splits its sentence into three flex items and the pill's gap falls between them. The row divider sits on the list item, not on the row. Done when:

  • The column is at most 960 wide. The greeting follows the time of day and uses the given name, the last word of the full name. The line under it is derived from the lists, never fixed copy: the live attempt's closing time, "Nothing due today.", "Next up: …" and the other forms in As built.

  • A resume card shows the live attempt that closes soonest: - an info pill, "In progress · n min left" - the title - class · "a of q answered" (liveAnsweredCount) · closing time - a 6px --accent-c bar at a/q - "Continue test", 46px high and full width below 768

    Any other live attempt stays in Coming up with an "In progress" pill.
    
  • Coming up shows a count in its header. Each row has: - a 44px date tile - the title - class · minutes · questions - a pill: Due today or Due tomorrow (warning, only when closing within 24 hours; see As built), Open now (success), or Opens {weekday} (muted)

    A row opens the intro.
    
  • Recent results show at most three entries, the papers submitted last (every result until R9's Grades exists, see As built): - "8 / 10" only when score is present - "Being graded" (warning, hourglass) while pendingManual > 0 - otherwise "Submitted" - the newest reads "Just now" for a minute after submitting

  • The Practice card is absent until R10. Until then recent results take the full width. The 1.4fr / 1fr split at a container width of 820 or more applies only once Practice exists.

  • States (DG-83): - a loading skeleton - no classes: one sentence and "Join a class", which opens T-R3.7's dialog - nothing assigned - an error with retry

  • Unit tests cover every state and each pill boundary. Compared with the deck at the five widths, light and dark.


T-R3.7 — Classes and the Join dialog

Depends on: T-R3.5 Touches: web/src/features/classes/pages/StudentClassesPage.tsx, web/src/features/assignments/pages/StudentHomePage.tsx (its "Join a class" opens the dialog), web/src/features/join/components/JoinDialog.tsx (new), web/src/features/join/components/{JoinCodeField,ClassPreviewCard}.tsx (from R1), web/src/features/join/{code,api}.ts, web/src/features/classes/api.ts, locales, web/tests/units/student/classes.test.tsx, web/tests/units/student/{home,shell}.test.tsx and web/tests/e2e/settings.spec.ts (they pin the /join link the dialog replaces), web/tests/units/join/join-dialog.test.tsx (new), web/tests/e2e/join.spec.ts Size: M As built:

  • Touched beyond the list: features/join/useJoinLookup.ts (new: R1's debounce, preview query and failure line, moved out of JoinPage.tsx), features/join/pages/JoinPage.tsx, components/ui/dialog.tsx (the deck scale through the portal), features/assignments/studentHome.ts (nextByClass), tests/units/join/{join-page,joined}.test.tsx, tests/units/student/home-view.test.ts, tests/units/ui/deck-scale.test.tsx, tests/units/student/support.tsx, tests/e2e/{settings,student-layout}.spec.ts. features/join/code.ts needed no change.

  • No contract change. The preview already carries classId, and it never knows the caller, so "already a member" is the preview's classId found in the student's own /app/classes. The server checks the code's state before membership, so a member who types a revoked, expired or full code reads "No class uses this code", not "You are already in this class".

  • One preview per distinct complete code, refused codes included. A refusal was an error, and an error is never fresh: retyping it sent it again. lookupJoinCode turns a 404 into remembered data for the same 30 seconds as a found class. A 429, a 500 or no network is still sent again when the code is retyped. This changes the public Join page too. The remembered answer outlives the dialog, so closing and reopening costs nothing.

  • An edit no longer abandons a lookup already out. R1 aborted it (71-r1.md). The server's limiter counts a request before the handler runs, so the abort saved no budget and threw the answer away: retyping a code while its first lookup was still out sent it again, four times for three retypes. The lookup now finishes and is remembered. A retyped code rests its 250 ms again, and the preview is not sent again by itself when the connection comes back.

  • The dialog's frame is a class recipe on JoinDialog, not a variant of the primitive: T-R1.6 planned variant="student" and did not build it. 440px or the width less 24px, 22px padding, 14px radius, --card, the float shadow, no close button. Cancel is flat: the deck draws no shadow under a dialog's secondary button. T-R3.8 lifts the frame and that button into a shared component when it adds the second dialog (its Done-when says so).

  • On a phone the panel sits 12% from the top, centred from 768 (DG-103). The field takes focus on open, and a centred panel would put the found class and both buttons under the keyboard.

  • The found class is the deck's flat row: a tick, the name, the teacher. The schedule joins the teacher's line in R8 (78-r8.md). The words "Class found" are there for a screen reader only.

  • "Join class" is aria-disabled, not disabled, as the deck's half-opacity button: it keeps its place in the tab order and does nothing. Enter in the field joins when a class is found.

  • Already a member is drawn as the deck draws it: the danger border and the line, no class. While the class list is still loading the dialog says it is looking; if the list could not be read, joining is allowed, because the server's join is idempotent.

  • A 429 gives the server's sentence and "Try again in n seconds." from Retry-After, or the sentence alone without the header. No countdown and no automatic retry: a retry spends the per-address budget R2 sized for a class joining together.

  • A join that fails: not found (the code died after the preview) gives the one line and clears the remembered preview, so the class does not stay above the refusal; a 429 keeps the class; anything else says "Could not join the class" and keeps it. The server's own 404 sentence is never shown: it tells an expired code from one that never existed.

  • On success the dialog waits for the class list to come back, for at most three seconds, then says "You joined {name}" (the join response's name) and closes, so the new card is on screen under the toast. Without the limit, a phone that lost its signal right after joining kept the spinner until it was online again. Focus returns to what opened it, or to <main> when that control has gone, as on Home.

  • A join's state lives as long as the dialog's content. It is forgotten on every close, so a failed join's line does not greet the next opening, and retyping the code just joined reads "You are already in this class." A join still on its way when the dialog is closed finishes, refreshes the lists and says so, and does not close a dialog opened since.

  • A found class is announced: the status line reads "Class found: {name}, {teacher}" when the lookup ends, because the row itself is not in a live region.

  • An account that is not only a student sees the class and "Joining a class is for student accounts", and cannot join (R2's rule for /join).

  • The typed code is forgotten when the dialog closes, as the deck clears it on open.

  • "Next" is Home's order, through nextByClass: the attempt on the resume card, then every other paper still to do by the date on its tile. It reads, in Home's words: "{title} · in progress", "{title} closes today at {time}", "{title} · due {date}", "{title} · opens today at {time}", "{title} · opens {date}", or "Nothing assigned yet". The deck gives two samples and no rule (DG-104). The page repaints each minute.

  • A paper shared by two of the student's classes is next on both cards, through classIds on the card (#240).

  • The line under a class's name is its description until R8 brings the timetable. The deck draws the timetable there and the plan's card has no description, but today the description is the only place a student reads when the class meets (DG-104). "Joined {date}" and the open and submitted counts leave with the old cards.

  • States the deck does not draw: two card shapes while loading; the error card with retry only when the list never loaded; one sentence for a student in no class, with the header's button as the page's one action; a grey bar for "Next" while the papers load, and "Could not load this class's tests" in the footer when they could not be read. A class with no teacher name has no teacher row.

  • The 44px floor: the header's "Join a class" opts out and is the deck's 40px at every width. The dialog portals out of <main>, so it has the deck's 42px buttons and 52px field. The empty and error states' buttons keep the floor.

  • /join keeps its place: a link or a QR code still lands there. Three things changed on it, all through the shared lookup or its enrol: a refused code is remembered for 30 seconds, an edit no longer abandons a lookup already out (below), and its enrol marks the student's lists stale; before, "Go to my classes" showed the list without the class just joined.

  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's fixtures: every box, type size, colour and gap of the page, the cards, the dialog, the field, the found row and the buttons matches, with these differences. The h1 is the house recipe's 1.25 line, 6.5px shorter than this screen's drawing from 768 and 5.5px on a phone (the deck's Home sets 1.25, its Classes inherits 1.5). The dialog's place on a phone (DG-103). "This week", the message button and the schedule in the found row are absent until their releases. Done when:

  • The column is at most 960 wide, with the h1, the line under it and a primary "Join a class" button. "This week" is absent until R8.

  • Cards sit in an auto-fill grid with a 280px minimum. Each card shows: - the class name - the timetable line, absent until R8 - the teacher row: initials, teacherName (the class's teacher_id since R2) and the caption "Teacher"; the message button is absent until R7 - a "Next" footer: the soonest open or upcoming assignment with this classId, or "Nothing assigned yet"

    Cards are not links, and there is no way to leave a class (deck).
    
  • The dialog is 440px wide. It reuses R1's JoinCodeField and ClassPreviewCard (71-r1.md), sized to the dialog's 52px input, and keeps their rules: - uppercase, with the dash after four characters, capped at eight - the invalid-format state for characters outside the §6 alphabet - the placeholder K7QM-2PXA (DG-02)

  • The dialog sends at most one POST /join/preview per distinct complete code, after R1's debounce, and none per keystroke; a test counts the requests. It must not spend the per-IP budget R2 sized for a class joining together behind one school address.

  • Preview outcomes: - found: the preview card, with the class name and teacher; schedule and room only from R8 - already a member (the preview's classId is in /app/classes): "You are already in this class.", and Join is disabled - any failure code: R1's single line, "No class uses this code. Check it with your teacher.", and nothing about any class reaches the DOM - 429: the server's message, with the Retry-After wait (DG-40)

  • Join calls POST /app/classes/join and shows the toast "You joined {name}". The classes and assignments queries refetch, and focus returns to the trigger. Esc closes the dialog.

  • States: loading, no classes (the dialog is the call to action), and an error with retry. Compared with the deck.


T-R3.8 — Test intro

Depends on: T-R3.5 Touches: web/src/features/assignments/pages/AssignmentIntroPage.tsx, web/src/features/assignments/studentRules.ts, web/src/features/assignments/components/StudentRulesPreview.tsx, web/src/features/join/components/JoinDialog.tsx (its frame moves to a shared component), locales, web/tests/units/student/intro.test.tsx, web/tests/units/assignments/student-rules.test.ts Size: M As built:

  • Touched beyond the list: components/shared/DeckDialog.tsx (new: the frame, the button row and the flat secondary button, lifted out of JoinDialog.tsx in a commit of its own), tests/units/assignments/rules-preview.test.tsx and tests/units/shared/deck-dialog.test.tsx (new), tests/units/student/{shell.test.tsx,support.tsx}, tests/units/join/join-dialog.test.tsx, tests/e2e/support/live.ts and tests/e2e/audio-plays.live.spec.ts (the live suite starts an attempt through the dialog now), docs/plan/74-r4.md, docs/design/gaps.md (DG-105).
  • One generator, two decks. The Student deck draws five fixed sentences; the Teacher deck's "What students will read" generates others for the same switches. studentRules uses the Student deck's words where it has them and the Teacher deck's for the cases it lacks, in the Student deck's order with fullscreen before copy and paste and audio before the score. Each sentence has a stable id and a kind; the page picks the icon from the kind. The teacher's preview words the date line for the teacher's day, and has none for a window that is not filled in, is reversed or has passed; its caption says the date line is worded for the day the student reads it, not "exactly these sentences".
  • Leaving, by maxFocusLoss: 0 reads "Leaving the test is recorded." This bullet said "nothing for 0"; the Teacher deck prints that line, spec §10.2 wants every rule announced, and the server records every absence whatever the limit. −1 reads "Leaving the test is not allowed." and then "Your teacher will be told.", "You will get a warning." or "Your test will be submitted." A limit reads "You can leave the test n times. After that, your teacher is told." (D8, the Student deck's tense), "you will get a warning." or "your test is submitted." A warn sentence never names the teacher, a −1 sentence has no number, and no sentence says "violation" or "cheating"; tests pin all three.
  • Availability carries times. "Available until today 21:00.", "Available until Thu 24 Sep, 08:00.", "Opens Mon 28 Sep, 08:00 – Wed 30 Sep, 21:00.", "Opens Mon 28 Sep, 08:00 – 21:00." when both fall on one day, and "Opens today, 19:00 – 21:00." when that day is today. The deck's upcoming line has dates only. A closed test has no availability line. No date carries a year, as nowhere else in the app, so a date a year or more away reads as this year's.
  • The score line has four forms (score; score and correct answers; score and explanations; all three) and is absent when the score is hidden. The Teacher deck's "Your teacher will share results later." is not used: nothing in R3 does that. The old "After submitting" block, with its crosses for what is hidden and its transcript line, is gone with the deck's layout. With the score hidden, the correct-answers and explanations switches are not announced either, although the result page shows what they release; DG-105 asks whether they should be.
  • Not said: "the best score counts". The Student deck does not say it and neither the spec nor the contract gives the student a best-score rule; attempts appear only in the facts strip. Nor the honest-limits sentence the old list added under a limit ("Bài làm chỉ ghi nhận việc trang này mất tập trung…"): neither deck has it here. Spec §10.5's text stays on the teacher's form and, past the limit, in the engine's dialog.
  • Fullscreen on a browser that has none (an iPhone) reads "This test asks for fullscreen, but this browser has none. You can take it as normal." and not a promise the engine cannot keep.
  • Audio keeps its sentences from before the redesign: the strictest play limit, or unlimited, and the shared-allowance sentence. The teacher's preview does not know the paper's audio, so it has no audio line; that was so before.
  • The facts strip is a <dl>: time limit, the question count alone (the points left with the old layout), and attempts as "used of max". An attempt in progress counts as used, and the value never exceeds the maximum. Below 360 the cells' side padding is 12px, not 16px, so "Thời lượng" and "45 phút" stay on one line at 320.
  • "Continue test" is the Start button with "This attempt ends at 17:38." under it (the date too when it ends on another day). It resumes in its own click with no question: the timer is already running, which the timer sentence says. It names the attempt it resumes, so a Continue whose attempt has ended is refused (ATTEMPT_CLOSED) and starts nothing (#237). A live attempt wins over a closed window and over "no attempts left", as on Home.
  • Closed and used up are one sentence in the button's place, in the inert button's muted box so nothing shows through it: "You have used all your attempts." (which wins when both hold) or "This test is closed." It is not a control.
  • Not open yet: the button reads "Opens today at 19:00", "Opens Monday" inside the coming week, or "Opens Sun 6 Sep" from seven days out. It is aria-disabled, keeps the deck's arrow, and does nothing. Under it: "You can start once it opens." The deck's "We will notify you when it opens." waits for R4's notifications (T-R4.10b carries the change). While the page waits it reads the assignment again each minute, at the opening, and when its tab is returned to, so Start appears without a reload. A device whose clock runs ahead asks again after 5 seconds, then less and less often, up to the minute.
  • "Start now?" is centred at every width, as the deck draws it: it has no field, so no keyboard covers it (the Join dialog's 12% is DG-103's). The body is the deck's sentence, or "The test closes at 21:00, so you have 20 minutes. …" when less than the time limit is left, because the attempt's deadline is the earlier of the two. Minutes are rounded down from the click, never read less than 1, and are counted again each minute while the dialog stays open. The sentence stays in the dialog while it fades out.
  • Start is the gesture. enterFullscreen() is the first call in the click handler of the dialog's Start, and of Continue, before the request; a test fires the click and asserts the fullscreen request in the click's own tick, before anything is awaited. A refused or missing fullscreen never blocks the start. A second click while the request is out sends nothing, and the dialog cannot be dismissed meanwhile; it stays up, busy, until the engine's chunk has loaded. A start the server refuses leaves the browser in fullscreen, as before the redesign: the app does not leave fullscreen itself here. After a submit it does, since T-R3.9d.
  • A refused start closes the dialog and says why above the button, in the reader's language by the error's code (ATTEMPT_LIMIT_REACHED, ASSIGNMENT_NOT_OPEN) or its status (the not-found sentence for a 403 or 404), the server's sentence for any other code, and "Could not start. Please try again." with no answer at all. The line has the page's ground, so the rules do not show through it, and it describes the Start button, which takes the focus back. The assignment is read again, so the page then shows the state the server refused for; when that state is "no attempts left" or "closed", its own sentence replaces the refusal as a new alert, so the same words are not on screen twice, and focus moves to the page.
  • The page never trusts the cache. The app's 30-second default would let a student who just submitted reopen the intro and read "0 of 1" with a Start button. staleTime: 0 here; an open test is read once more when its close passes (the server says whether it closed, never this device's clock); and the cached copy is dropped when an attempt starts, after the engine has replaced the page: dropped before, the page still mounted fetched it again.
  • States the deck does not draw (DG-105): the page's shape as a skeleton; the error card with retry; for a 403 or 404, on the first read or a later one, "This test was not found, or it was not assigned to you." with a link Home and no retry.
  • The back link is "Home" from 768, as drawn; below it the shell's header has the arrow (T-R3.5). The class label has no teacher: the deck names the class only.
  • The teacher's note is not built here. The contract has no studentNote for the page to read; T-R4.11 adds the field and draws the note (its Touches and Done-when say so).
  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's fixtures (the open test, the "Start now?" dialog and the upcoming test): every box, type size, weight, colour, radius, shadow and gap of the column, the back link, the label and h1, the strip and its cells, the card, its rows, tiles and icons, the action bar, the button, its line and the dialog matches. The differences: the upcoming test's date line carries times, so it is one line longer at 360; the line under "Opens Monday" (above). The list showed through the 28px under the sticky action on a short screen, as it does in the deck, until T-R3.13 covered the shell's bottom padding beneath the action. The states the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280: nothing overflows. The shared error card's request-id row is cramped at 360 and 5px too wide at 320, on every student page that uses it (issue #244). Done when:
  • The column is at most 720 wide: back link "Home" from 768, then the class label and the h1.
  • A facts strip has three cells: time limit, questions, and attempts ("used of max").
  • "Before you start" is generated only from the stored policy and dates. vi is written first; en uses the deck's wording. The rules cover: - availability: available until a date, or the opening range - the timer does not pause - copy and paste are off, when blockCopyPaste is set - leaving, per maxFocusLoss and onLimitExceeded: "is recorded" for 0 (the Teacher deck's line; see As built), "not allowed" for −1, "n times, after that your teacher is told" for flag (D8), and the warn and auto_submit variants - fullscreen, when required - audio plays, from audioMaxPlays and shared audio - the score line, only when review.showScore; R4's release timing joins it in T-R4.27b, once T-R4.11 has added review.release
  • student-rules.test.ts runs every combination of integrity and review policy and asserts that each sentence matches the policy it came from. The teacher's StudentRulesPreview uses the same generator.
  • The teacher's note is not drawn: the student assignment detail has no studentNote until R4 adds assignments.student_note, and T-R4.11 draws the note with it (74-r4.md; DG-71). Nothing renders in its place.
  • A sticky action sits over a fade from transparent to var(--bg): - "Start test", with "Your answers save automatically." - "Continue test" for a live attempt - "Opens Monday", inert, for a scheduled assignment - blocked states (closed, no attempts left) stated in words
  • The "Start now?" dialog and T-R3.7's Join dialog share one frame component: the FRAME recipe and the flat secondary button leave JoinDialog.tsx.
  • The "Start now?" dialog offers Not yet and Start. Start enters fullscreen in the same gesture when the policy requires it (test).
  • Compared with the deck, including the upcoming variant.

T-R3.9a — Engine: focus shell, header, timer and save state

Depends on: R1 Touches: web/src/layouts/FocusLayout.tsx, web/src/features/take-test/pages/TakeTestPage.tsx, web/src/features/take-test/components/{EngineHeader,Clock,SaveState}.tsx, web/src/features/take-test/store.ts (read only), locales, web/tests/units/take-test/{timer,breakpoint,load-failure,resume,deadline}.test.ts(x), web/tests/units/take-test/draft-compat.test.ts (new) Size: M As built:

  • Touched beyond the list: features/take-test/{saveStatus,useLeave}.ts and components/LeaveDialog.tsx (new), components/shared/DeckDialog.tsx (T-R3.8's frame, taken unchanged from its branch), one class or prop each in components/{Navigator,QuestionCard,QuestionBody,ReviewScreen}.tsx, tests/support/viewport.ts, tests/units/take-test/{header,leave,focus-layout}.test.tsx and deckSession.ts (new), tests/units/take-test/navigator.test.tsx, tests/units/boot/app-state.test.tsx. store.ts, draft.ts and {timer,resume,deadline}.test.ts needed no change.
  • The boundary is 768 for the whole engine. wide asks for 768, and three class gates written for 1024 follow it, or the 768 to 1023 band would break: the rail is gated by wide alone (it was also display:none below lg, where the footer that replaces it is no longer rendered), and the worth line under an answer hides from 768 (it was drawn twice). The 44px sizes that pair with the .student-surface floor stay on 1024, where the floor ends.
  • A shared passage between 768 and 1023 is the old wide pane without its height cap, until T-R3.9b. GroupContext takes wide, so the band has no collapse button and a move inside a group no longer scrolls to the answer, while the card's sticky height cap is still gated on lg: the passage is drawn at full height above the question. The rail (256px) and the wide padding leave the paper 320px narrower than the viewport, so its two columns (a paper of 800px) start at 1120, where the phone layout had them from 832. T-R3.9b's panes from 768 replace all of it; T-R3.9a must not ship without them.
  • The shell. FocusLayout is a DeckScale root, the container named student, with no forced light theme. It keeps .student-surface: the panes, the navigator and the review rely on the 44px floor until T-R3.9b to 9d rebuild them. The header's ✕ (36px) and Submit (38px) and the Leave dialog's 42px buttons are the deck's sizes at every width.
  • The header is one component in every view: the leading control (the ✕ on the paper, "Back to the test" on the review, nothing once submitted), the title over the save line from 768, the timer, Submit. The question counter and the progress bar are gone; the deck draws neither. Until T-R3.9b's question header lands, a phone shows the question's number only in the navigator sheet.
  • Submit opens the review screen, as "Review & submit" does; T-R3.9d swaps in the dialog. The review's header has no Submit, because that page has its own, and keeps "Back to the test". A paper that is closed or was taken over by another device has no Submit: the review's own is disabled there.
  • The save line is read from the store, with no new state. Nothing dirty is "All answers saved", even while integrity events alone are being sent. A dirty answer is "Saving…" from the keystroke, through the debounce and the request, as the deck shows it. Failed is the span in which the store's retryDelayMs is past its first value, read against getInitialState(): from a failed save until one succeeds. A retry in flight still reads failed, so the line does not flicker. Offline (navigator.onLine) wins over both while something is unsaved.
  • Frames the deck does not draw (DG-80): the failed line, "Could not save. Trying again…", and the offline line, "You are offline. Saving when you reconnect.", in the danger ink with cloud-alert and cloud-off; the phone save strip, one 12px line under the header that is there only when a save has failed or the device is offline while an answer is unsaved, and never for a save on its way (a phone says nothing about a routine save, as the deck draws it); the Leave dialog's pending and failed states (below). A polite live region says the failed or offline line once, at every width; routine saves are not announced.
  • The strike count keeps its place (integrity is unchanged): after the save line from 768, at the far end of the phone strip. So on a phone a paper that counts departures keeps a one-line strip for the count, which the failed or offline line joins without moving the paper. The deck draws no count; T-R3.9d decides where it lives.
  • The timer keeps remainingMs and the shared countdown format, so minutes are zero-padded ("04:59") where the deck's prototype writes "4:59", and an hour reads "1:30:00". It is a role="timer" named "Time left". The danger tones start below 5:00, not at it. The live region says the minutes left when the time passes 5:00 and 1:00, again if a later deadline lets it pass them again, and never for a mark the paper was opened below. It holds the line for the minute it is true of and is empty otherwise, so it never reads "5 minutes left" with 2:30 on the timer. The deck asks for tabular figures, but Be Vietnam Pro has none (in the deck's pill "11:11" is 29px wide, "00:00" 45px and "44:44" 49px), so the deck's centred pill changes width and moves every second. Each digit here sits in a cell one zero wide: the pill is a constant 96px, which is 4px wider than the deck's at the deck's own 38:12, 16px wider at "11:11" and 4px narrower at "44:44" (measured by T-R3.13). Listed for DG-80.
  • Leaving is one flow (useLeave). The ✕ asks. Leave sends what is unsaved, twice if an answer changed while a save was out, then goes to /app. With nothing unsaved it waits on nothing and goes at once. As T-R3.9a built it, integrity events still in the buffer were dropped with the session, as "Exit" dropped them; since #331 the engine's unmount hands them to the beacon and keeps the sequence in sessionStorage, and forgets the buffer only once the attempt has ended (releaseSession). While an answer is unsaved the dialog says it "will be saved before you leave", not the deck's "are saved". The back button's blocker is taken as "leave": it saves and goes where it was going, as before, now in the same dialog. Stay, Esc and the backdrop cancel, also while the save is out.
  • A failed save never leaves silently. The dialog becomes "Your answers have not been saved", with Stay and "Try saving again" and no way to leave without them, as before; the answers stay in the local draft. An alert inside the dialog says the new title: the words change in place, and the modal hides the header's live regions. If the store's own retry lands first, the dialog goes back to asking.
  • A locked paper has no save line and is not asked about. The lock strip, unchanged, says why at every width. Closed, out of time or taken over by another device, the ✕ leaves at once, as "Exit" did, and a dialog that is open when the lock arrives closes for good: "Your answers are saved" would be false for an answer the lock rejected, and the sentence about the timer false for a paper that has ended. A paper that has ended has no timer either (#319); one taken over by another device, or one out of time, keeps it.
  • Tests run at 1280 and at viewport("phone"). Removed: "distinguishes restored server answers from a new empty attempt"; the "Saved answers restored" and "Nothing to save yet" lines left with the old save state. The navigator tests read the current question from the paper's landmark, not the header's counter. E2E 5's "Thoát" locator still matches the ✕ ("Thoát khỏi bài làm"), and the live specs' /^Đã lưu / matches "Đã lưu tất cả câu trả lời", which shows only when nothing is dirty.
  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark (the header and the Leave dialog; the deck's phone layout is reached through Home's "Continue test"): every box, type size, weight, colour, radius and gap matches. Two differences: the timer's fixed digit cells (above), and the save line's icon, which the deck's own icon font does not have (cloud-check), so its drawing shows the words alone. Done when:
  • The five canaries and the take-test, integrity and media unit suites pass on the branch point and on the last commit. Both runs go in the PR.
  • The header is 60px high: - ✕ (36px, "Leave test") - from 768, the title with the save state under it: "All answers saved", "Saving…", offline or failed, read from the store rather than timed - the timer pill, centred - Submit (primary)
  • The timer runs from the server's deadlineAt and serverTime, as today, and deadline.test.ts is unchanged. Under 5:00 it takes the danger tones (D8). A polite live region announces 5:00 and 1:00.
  • Below 768, a one-line save strip appears only when a save has failed or the device is offline while an answer is unsaved, and goes away once it is saved. Nothing is drawn while everything is saved or a save is on its way (decision above; DG-80). A paper that counts departures keeps the strip for the strike count (As built).
  • The Leave dialog reads "Leave the test?" and offers Stay and Leave. Pending answers flush before navigating to /app.
  • R1's app-state overlays keep working over the rebuilt engine (tests): - maintenance and session-expired render above the engine, which stays mounted and writes its local draft on every change - the store adopts the server's extended deadline, as R1 made it - useIntegrityMonitor records no focus change while an overlay shows
  • FocusLayout no longer calls useForcedLightTheme(): the engine follows the theme preference.
  • The take-test route keeps handle.focus, so R1's update prompt never renders inside FocusLayout (test).
  • draft-compat.test.ts: - a quizzivy.answer-draft.<attemptId> entry in the v0.8.0 format restores its answers - quizzivy.flags.<attemptId> restores its flags - neither key nor schema changes
  • Unit tests run at 1280 and at viewport("phone").

T-R3.9b — Engine: passage and question panes

Depends on: T-R3.9a Touches: web/src/features/take-test/components/{QuestionCard,QuestionBody,GroupContext,QuestionAudio,SectionInstructions,Note}.tsx, web/src/features/take-test/components/blankInputs.ts, web/src/features/take-test/components/UnknownType.tsx (new), web/src/features/take-test/{sections,worth}.ts, web/src/features/media/components/AudioPlayer.tsx (styling only), web/src/features/tests/components/StudentPreview.tsx, locales, web/tests/units/take-test/{renderers,group-context}.test.tsx Size: L As built:

  • Touched beyond the list: features/take-test/{questionType,panes}.ts and components/{QuestionSheet,PassageBody}.tsx (new; the two the preview shares, free of the store), components/shared/content/{GroupMaterials.tsx,paperSurface.ts} (the second new), features/tests/components/PreviewGroupContext.tsx, PreviewImage.tsx (removed), lib/testText.ts (T-R3.11's reader, byte for byte, in its own commit), pages/TakeTestPage.tsx (Paper rebuilt as the two panes, with PaneSwitch, PaneTab and useLanding new, and PreviousButton, NextButton, Shortcuts and gapLanding lifted out of it unchanged in their own commit; in the top-level component two guards in the key handler and the count of jumps), tests/units/take-test/{panes (new),header,breakpoint,navigator,keyboard,submit-dialog}.test.tsx and deckSession.ts, tests/units/builder/group-preview.test.tsx, tests/e2e/group-attempt.spec.ts. sections.ts and blankInputs.ts needed no change.

  • The panes. A group with something to read (stimuli) gets the passage pane; every other question has the question pane alone. Each pane scrolls by itself. A new question starts at the top of its pane, reached from a gap in the passage as much as by a move, and takes focus there; the passage keeps its place while the student moves inside the group, and another group's passage starts at its top. The passage is a tab stop, so the keyboard can scroll it.

  • The rail and the old step buttons stay until T-R3.9c. From 768 the 256px rail still stands beside the panes, so each pane is 128px narrower than the deck draws and a tablet at 768 is cramped. Previous, Next and the shortcut hint sit under the answer, and a phone keeps its footer. T-R3.9c's footer strip replaces all three; T-R3.9b must not ship without it.

  • Below 768 the switcher is two toggle buttons, "Ngữ liệu" and "Câu n", not ARIA tabs: the arrow keys already move between questions. The question shows first, and again after every move, back to a question whose passage was open included; a gap in the passage goes to its question and shows it. A hidden pane stays mounted and keeps its scroll position. The footer shows only with the question, where the deck draws it, and the keys that answer or flag do nothing while the passage hides the question. The phone's collapse button and sessionStorage['quizzivy.material-collapsed.<id>'] are gone. With T-R3.9d's Submit dialog (merged into this branch): the dialog stays over a mounted Paper, and "Go to n" for the question already open does not change the index. The top-level jump therefore counts its calls and hands Paper the count (jumps, the one prop added from the top level): every jump shows the question, as the deck's unansweredBtns does with pane: 'q', and lands on it, at the top of its pane with the focus there, the question already open included. T-R3.9d's case for the chip follows the focus to the question's panel, where this task puts it, from the <main> around it.

  • Shared recordings sit in the question pane at every width, so a phone can listen while it answers and the player is not remounted when the window crosses 768. A group with recordings and nothing to read has no passage pane: its title and instructions lead the question pane.

  • The passage's parts. The eyebrow is the part's title, the title the group's, then the group's instructions (14px, muted) and each material. A material named like its group draws no second heading (GroupMaterials.omitTitle, opt-in; the teacher console's cards are unchanged). "Shared material · Questions 2–3" left the engine and is the eyebrow in the preview.

  • The question line is "Câu 4 trên 8 · {type}": Chọn một đáp án, Chọn nhiều đáp án (the deck's "Choose two" needs a count the contract does not carry), Đúng hay sai, Điền vào chỗ trống, Trả lời ngắn. The flag toggle's accessible name is its visible text, "Đánh dấu xem lại" or "Đã đánh dấu", with aria-pressed. In a paper of several parts, a question with no passage carries its part as an eyebrow; with a passage the part is named there.

  • Worth is said once, under the answer, at every width (12.5px, muted; beside the word count for a short answer): the deck's line has no place for points, and the old meta line is gone. English counts "1 point". Multiple choice shows its existing instruction sentence as the deck's hint line; single choice keeps its sentence for a screen reader only. A fill-in's case rule is the hint line under the sentence, no longer a boxed note. The deck's own hints and word limits have no field in the contract.

  • The answer field stays a <textarea>, because an <input> would drop the line breaks of answers already saved. It is one row at 52px that grows with its content (field-sizing: content; 96px and resizable where the browser lacks it). Fields are 16px below 1024, not the deck's 15px, so iOS does not zoom on focus; 15px from 1024 and 17px with larger text. Blanks are 44px high below 1024 and 40px from it. A chosen option keeps its primary border under the pointer; the deck's hover turns every border to --ring, the chosen one included.

  • Unknown type. QuestionSheet draws UnknownType in place of the prompt and the answer: one sentence that names the question, and "Tải lại trang", a full reload, which brings the new renderer. It has no worth line and takes no answer, from the options or from the keys a–d.

  • Images and the paper surface. A question's own image is drawn; the engine dropped it while the preview drew it. Its text alternative is the fixed "Hình ảnh của câu hỏi", as the audio's label is: the contract carries no alt for it, and the upload's file name can name the answer. Images and rich tables keep a light surface in dark mode through one recipe, PAPER_SURFACE in components/shared/content/paperSurface.ts, the same classes as T-R3.10's; blanks inside a rich table take the paper tokens too.

  • The audio player takes the answer rows' frame on every deck surface (the card colour, a 1.5px border, an 11px radius, 12.5px time and hint text, a 40px button from 1024): the engine, the preview, and the Result page too (a question's recording and ReviewGroup's shared ones), which T-R3.10 compared with the old frame; T-R3.13 looks at that page's audio item with the new one. The teacher console's other players do not move. Its handlers are untouched.

  • The preview is a deck surface that draws PassageBody and QuestionSheet, one under the other and read-only: every control disabled, no flag. preview.writtenAnswer left with the old placeholder box.

  • Frames the deck does not draw (DG-80): question audio with plays left and the extra-plays line; shared recordings with their scope and sync lines; a listening group with no passage; an image; a fill-in with its blanks, rich blanks in a table, and the case-rule line; material gaps, tables, lists and audio labels in the passage; the part eyebrow and a part's instructions (a note with headphones for a listening part, one muted line otherwise); the worth line; the unknown-type block; the answer field as it grows; a locked paper's disabled rows, fields and flag; a material or image that did not load, with its retry.

  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's fixtures and T-R3.9d merged in, the rail hidden while measuring: question 1 with its answer, question 4 (flag off and on, an option chosen, another under the pointer), question 5 (empty, then typed in with the focus in it), the passage and, at 360, the switcher and the passage tab. Every box, type size, weight, line height, colour, radius, border, padding and gap of the switcher, the eyebrow, the title, the paragraphs, the question line, the flag toggle, the prompt, the hint, the rows, their markers and the field matches; question 1, the passage and the switcher differ in nothing. The differences are the departures above: question 4 reads "Choose one or more" under the product's longer instruction, so the hint takes two lines up to 1024 (19.5px) and, at 360 with the flag on, the line still takes two where the deck's fits on one (7px); question 5 has no hint and no word limit, so its field sits 29.5px higher, counts "0 words" and says its worth at the far end; the field is a textarea, 16px below 1024, in the same 52px box, and shows the house focus ring with the deck's primary border. With the rail back each pane is 128px narrower from 768, and at 768 the question line and the options break word by word; T-R3.9c's footer ends that. Fixed on the way: a ring round the whole question after every keyboard move (the unlayered :focus-visible rule beats outline-none, so the landing panel takes outline-none!, as the student shell's <main> does); the shortcut hint pushing the question pane sideways at 768; a gap's label on a phone sitting 25px above its underline. The frames the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280, light and dark: nothing scrolls sideways, overlaps or is clipped, larger text measures 18, 19 and 17px, and the Submit dialog's chip for the open question closes the passage tab. The preview was looked at in the teacher console, which is light only.

  • Replaced in D4 (74d-d4-deck-update.md): T-D4.7 replaced the "Ngữ liệu | Câu n" switcher with the phone drawer, T-D4.5 the first-question section instructions with a section head above every question, and T-D4.8 moved the shared recordings from the question pane to their audio node in the stimulus pane; T-D4.4 replaced the footer strip (T-R3.9c). Done when:

  • From 768, a question whose group has stimuli splits into two panes: - passage: at most 640 wide, with the part eyebrow, the title and paragraphs at 16px, line-height 1.75, not selectable - question: at most 600 wide, on the --sidebar background

    A question with no passage uses the question pane alone, centred.
    
  • Below 768, a "Passage | Question n" switcher sits under the header when a passage exists. The question shows first.

  • The question header reads "Question 4 of 8 · Choose one", with the label for each type. Beside it is the flag toggle, "Flag for review" or "Flagged", in warning tones.

  • The prompt is 17px. Options are at least 52px high with letter markers (round for single choice, square for multiple), and a chosen option gets a primary border and a check. The short-answer field is at least 52px high; a fill-in's blanks are 44px high, 40px from 1024. Paste is blocked when the policy blocks it.

  • Every existing type and context renders in deck primitives: - single choice, multiple choice and true/false; true/false keeps writing the choice answer it writes today (see 76-r6.md, open items) - fill-in, with rich blanks and the case-rule line - short answer - images, question audio and group recordings - cloze groups - section instructions on the first question of a section

    In dark mode, images and rich tables keep a light paper surface (DG-35). The PR lists the
    frames the deck does not draw, for DG-80 sign-off.
    
  • An unknown type renders UnknownType and writes no answer. A unit test uses a fabricated type.

  • "Larger text in tests" sets passage, prompt and options to 18, 19 and 17px.

  • audio-player.test.tsx passes untouched: .play() stays in the click's synchronous tick.

  • Order is unchanged: sections.test.ts passes, and options keep the server's order.

  • The teacher's draft preview renders the same panes.


T-R3.9c — Engine: number strip, question sheet, flags and keys

Depends on: T-R3.9b Touches: web/src/features/take-test/components/Navigator.tsx (rewritten), web/src/features/take-test/pages/TakeTestPage.tsx, web/src/hooks/useColumnWidth.ts, locales, web/tests/units/take-test/{navigator,keyboard,flags}.test.ts(x), web/tests/units/shared/side-column.test.tsx Size: M As built:

  • Touched beyond the list: tests/units/take-test/{submit-dialog,panes,group-context,header,breakpoint}.test.tsx (where they looked for the old footer, the rail or the hint), tests/units/student/side-column.test.ts (new: the walk over the router) and tests/e2e/student-interactions.spec.ts. flags.test.ts needed no change. components/shared/PageAside.tsx is not touched: its widthKey prop has no caller left.

  • The footer belongs to the question pane, as the deck draws it: under the question's scroller, inside the question's section. Beside a passage it spans the question pane only, and on a phone it hides with the question while the passage shows. It is a <nav> named "Danh sách câu" ("Questions"). The data-columns wrapper round <main> left with the rail.

  • Previous, Next, Finish are 44px with a 10px radius. Previous is disabled on the first question at the deck's 40% opacity. The two step buttons read "Trước" and "Sau" and are named "Câu trước" and "Câu sau" ("Previous question", "Next question"); Previous shows its label from 768. The longer "Câu trước" and "Câu sau" left the strip one square a row at 768 beside a passage. Finish reads "Hoàn tất" and opens the Submit dialog through the handler the header's Submit uses. The step buttons and the shortcut hint under the answer are gone: the deck draws no hint for the keys.

  • The strip wraps, as drawn. Past four rows (151px) it scrolls inside itself and keeps the open question's square in view: at 768 beside a passage the deck's own eight squares take four rows of two, in the deck and here, in both languages, and a paper of forty would push the question off the screen. Its squares are 34px from 768, under the 44px floor .student-surface keeps below 1024.

  • A square is filled primary when answered, has the --accent-c border when it is the question on screen (over the fill when that one is answered) and the amber dot when flagged. Its name is "Câu 4", then the part on a paper of several parts, then "đang xem", "đã trả lời", "đã đánh dấu"; the deck's is "Question 4, answered". The one on screen carries aria-current.

  • Parts. On a paper of several parts the strip puts 12px more before each part's first square (17px in all), the sheet heads each part's grid in the passage eyebrow's type, and each square's name has its part. A paper of one part shows none of it, as its question pane shows no part line. A question whose section the payload did not list keeps its square, under no heading.

  • The phone button reads "4 / 8 · đã trả lời 3". A screen reader hears "Danh sách câu: 4 / 8 · đã trả lời 3", and it says it opens a dialog. Where the line does not fit (in Vietnamese at 320 with two-digit counts or beside Finish) it breaks after the dot into two lines inside the same 44px.

  • The question sheet is the dialog primitive pinned to the bottom edge; components/ui/sheet.tsx does not exist. It has the grabber, "Danh sách câu" with "Đã trả lời 3 trên 8", six columns of 44px squares and the legend, and on a long paper the squares scroll inside it. It opens with the focus on the open question's square, traps it, and closes on Esc, on the backdrop and from any square. Focus then returns to the button, unless the paper took it: after a square it is on the question gone to. It has no close button and no Submit, as drawn, and keeps the dialog's fade and zoom: the deck draws no motion.

  • Keys. A to E choose; a sixth option has no key, because F flags. The arrows move and stop at either end. The right arrow on the last question no longer opens the Submit dialog: the deck's stops there, and the release decision is that the arrows work as drawn. Esc closes the sheet and the dialogs. None acts in a text field, with Ctrl, Alt or the command key held, while a dialog or the sheet is open, or on a locked paper.

  • A sealed paper (superseded or closed) has no Finish on its last question, as it has no Submit in the header: the footer is then Previous and the squares. A paper whose time is up keeps both.

  • The rail's width. studentNavigator left ColumnRole, and layout.resize.studentNavigator the locales. The stored width is deleted when the engine's footer mounts, not in useColumnWidth.ts: no student route loads that module now, so a deletion there would never run in a student's browser.

  • No /app route can draw a side column. The walk loads every lazy route under app in the real router and checks that none loads SideColumn, directly or through PageAside. The teacher's layout, walked the same way, does.

  • Frames the deck does not draw (DG-80): the gap between parts in the strip, the part headings in the sheet and the part in a square's name; the strip scrolling past four rows; the sheet scrolling on a long paper; the count button on two lines; a sealed paper's footer, with no Finish; the focus ring on each control.

  • Strings. New: takeTest.{finish,previousQuestion,nextQuestion,navPosition,navAnswered,navCount}. Changed: previous and next in Vietnamese ("Trước", "Sau"), navTitle in English ("Questions"), legendAnswered and legendFlagged (capitals, "Đã đánh dấu"). Removed: takeTest.{questionList,reviewAndSubmit,close,legendUnanswered,railAnswered,railFlagged,railCount,shortcuts,shortcutPick,shortcutMove,shortcutFlag}.

  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's fixtures: question 4 with three answered, the flag on, an option chosen, the last question (Finish), the first (Previous at 40%) and, at 360, the sheet open. Every box, type size, weight, colour, radius, border, padding and gap of the footer, Previous, the squares and their dot, the count button, Next and Finish, the overlay, the sheet, its grabber, title, count, grid, squares and legend matches. With the rail gone the whole engine was measured for the first time: the header, the two panes (416.5 and 351.5 wide at 768, to 752.5 and 687.5 at 1440), their paddings and columns and the footer have the deck's boxes at every width. The differences: at 360 the deck's count button cannot shrink and pushes Next 5px past the bar's padding (12px with Finish), where the app's keeps the 14px; the footer's buttons keep the default cursor, as the app's button primitive does; the sheet fades in; and the departures of T-R3.9a and T-R3.9b (the timer's digit cells, the strike count's strip on a phone, question 4's wording). The frames the deck does not draw were looked at in Vietnamese at 320, 360, 768, 820, 1024 and 1280, light and dark (134 frames, each probed for sideways scroll): none. On a paper of several parts beside a passage at 768 a row that starts a part sits 6px off the rows around it, because the gap is that square's margin.

  • Replaced in D4 (74d-d4-deck-update.md): T-D4.4 replaced the strip with the sections rail (from 900px, or 1180px beside shared content) and the grid button and its sheet everywhere else, and T-D4.10a made a cloze's run one stop; T-D4.7 replaced the switcher, T-D4.5 the first-question instructions and T-D4.8 the shared recordings' place (T-R3.9b). Done when:

  • From 768, the footer holds: - Previous - a wrapping strip of 34px squares: answered is filled primary, the current question has an --accent-c border, and a flagged one has an amber corner dot - Next, or Finish on the last question, which opens T-R3.9d's Submit dialog

  • Parts stay visible, following AGENTS' within-section deal. The strip has a gap between parts, and each square's accessible name includes its part title. The sheet shows part headings.

  • Below 768, the footer holds Previous (icon only), a "4 / 8 · 3 answered" button and Next. The button opens a bottom sheet containing: - "Questions" and the answered count - a six-column grid of 44px squares under part headings - the legend: Answered, Flagged

    The sheet traps focus, closes on Esc or the backdrop, and returns focus.
    
  • Flags keep sessionStorage['quizzivy.flags.<attemptId>'], and flags.test.ts is unchanged.

  • Keys: ←/→ move, a–e choose, F flags, Esc closes. None fires in a text field or while a dialog is open (keyboard.test.tsx extended).

  • On a paper that was taken over or has already ended (lock is superseded or closed), the last question offers no Finish, as the header offers no Submit, and nothing on the page opens the Submit dialog. A unit test pins both locks.

  • The studentNavigator role leaves useColumnWidth, and localStorage['quizzivy.column.studentNavigator'] is deleted once, inside try/catch. No /app route renders SideColumn, checked by a test over the router's student tree.


T-R3.9d — Engine: integrity dialogs, submit and end states

Depends on: T-R3.9a Touches: web/src/features/integrity/components/{StrikeDialog,StrikeIndicator,FullscreenBar,AutoSubmitNotice}.tsx, web/src/features/take-test/components/ReviewScreen.tsx (removed), web/src/features/take-test/components/SubmitDialog.tsx (new), web/src/features/take-test/components/SubmittedScreen.tsx, locales, web/tests/units/integrity/{dialog,auto-submit,fullscreen}.test.tsx, web/tests/units/take-test/submit.test.ts, web/tests/e2e/{timer-expiry,integrity-away,session-takeover,audio-plays}.live.spec.ts Size: M As built:

  • Touched beyond the list: features/integrity/{fullscreen.ts,useClipboardNotice.ts (new)}, features/take-test/components/{NoticeBar,EndState}.tsx (new) and SaveState.tsx (the lock strip), pages/TakeTestPage.tsx (the top-level component, not Paper), components/shared/DeckDialog.tsx (when it restores focus on close), tests/units/take-test/{submit-dialog,end-states}.test.tsx and deck-fixtures.test.ts (new), tests/units/take-test/{header,navigator,leave}.test.tsx, tests/units/boot/app-state.test.tsx (the focus dialog by role), tests/e2e/support/live.ts, tests/e2e/student-interactions.spec.ts and tests/e2e/admin-change-requests.live.spec.ts. The timer, takeover and audio live specs needed no change.

  • The Submit dialog is the only way to hand in. The header's Submit and Finish on the last question open it (amended in T-R3.9c: the rail and the sheet's button are gone, and → stops on the last question). It is one step: "Submit test" runs the store's submit, untouched, with no second confirmation. While a submission is out it cannot be dismissed and its controls are inert. One that fails says "Could not submit. Please try again." inside the dialog, which stays open. When the timer or an auto-submit is already submitting, it sends nothing of its own. After Keep working, focus goes back to what opened it; after "Go to" it is on the question gone to: DeckDialog restores focus only when the close left it nowhere, so it does not take it back from the paper. Opened from the phone sheet's button, which goes with the sheet, Keep working leaves focus on <body>; T-R3.9c's sheet has no such button.

  • Left with the review page (the release decision above): chips for flagged questions (the deck has the count only), the "n further attempts" note, the header's "Back to the test", the rail beside the review and the second "Submit?" confirmation.

  • The dialog's words where the deck is silent. Minutes are whole and rounded down. Under one minute: "You have less than a minute left." On a paper whose time has passed and still has to be handed in: "Time is up." Time left is the header's format ("04:59") and takes the danger ink under 5:00, with the header. The Vietnamese tile labels are short ("Đã trả lời", "Đánh dấu", "Còn lại") so three tiles hold at 360.

  • A long paper's chips scroll in a box of their own (160px), so both buttons stay on screen at 320 with 80 unanswered. The deck draws eight.

  • A lock closes the dialog. Taken over by another device, or already ended, it closes for good and the bar under the header says why. On such a paper the "Review & submit" buttons that T-R3.9c replaces open nothing; T-R3.9c's Done-when has its Finish absent there, as the header's Submit is.

  • What is recorded while the dialog is open names the question behind it. The review page recorded none; the question is on screen now (§10.4). The submitted screen after the student's own submit names it too, as it already did after a timer or an auto-submit. Apart from that and the fullscreen record noted under "Leaving fullscreen", nothing that is counted, recorded, sent or submitted changed.

  • The focus dialog is the deck's alertdialog: 420px, the tile, the title, one body, one 46px button. Under flag it is the deck's two bodies. On the same frame (DG-80), saying only what the server does: warn past the allowance "…more than the m allowed. Your answers are safe. Stay on this page until you submit.", never naming the teacher; no absence allowed (−1) "Leaving the test is not allowed. …", with no number; auto_submit inside the allowance "…After that, your test is submitted." and at the last one "…If you leave again, your test is submitted."; no limit "Leaving the test is recorded. …", once a sitting, as before. An allowance of one reads "1 of 1 time allowed".

  • Gone from the dialog: the seconds away, "The clock is still running." and, past the limit, the honest-limits sentence. The deck's body is the whole text; §10.5's text stays on the teacher's form. E2E 6's body assertion follows, as the Done-when names.

  • The strike count stays where T-R3.9a left it, after the save line from 768 and at the far end of the phone strip: §10.2 asks for it and the deck draws none. Past the allowance it takes the warning ink and, under flag, reads "Your teacher has been told" (D8).

  • Copy, cut and paste under blockCopyPaste raise R1's error toast with the ban icon in --danger, one toast however often. useClipboardNotice only tells: the monitor, untouched, records the event and stops it. The toast shows wherever the monitor blocks, the submitted screen included. The hook has its own copy, cut and paste listeners on document, a second set beside §10.6's "one hook owns all listeners", because the monitor is not edited here; T-R3.14 amends §10.6 or moves them into the monitor.

  • Leaving fullscreen (the lead's decision, §10). When the attempt is submitted on an assignment that requires fullscreen, by the student, the timer or an auto-submit, the page calls the new exitFullscreen(); not on a lock without a submit. That exit is the app's, so it is not recorded: once the attempt has ended the page hands the monitor, untouched, a policy without requireFullscreen, and no fullscreen_enter or fullscreen_exit is noted from then on. That includes the student's own on the submitted screen, which was recorded before.

  • Bars under the header (NoticeBar, DG-80): return to fullscreen, in the warning tone with the deck's small bordered button, 32px from 1024 and on the 44px floor below it (the deck does not draw this bar, so §12's floor holds), or one muted sentence on a browser with no fullscreen; and the lock, with an icon each: taken over (read-only, no Submit, no focus dialog), time is up (Submit stays), ended. The lock bar is not a live region, as before.

  • End states (EndState, DG-80): the submitted screen, on a success tile for the student's submit, the timer for a timeout and the focus dialog's tile for an auto-submit, with "Back to home" and "View submitted paper"; and the auto-submit notice, "Your test is being submitted". No sentence says "violation". At zero the timer reads 00:00 in the danger tones and the paper goes in without a click. Offline is T-R3.9a's save line and the dialog's failure line; session expiry is R1's overlay over the mounted engine. Neither changed.

  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's fixtures: the Submit dialog (five unanswered; all answered; one flag under five minutes), the focus dialog within and past the allowance, and the toast. Every box, type size, weight, colour, radius, shadow and gap of the two panels, the overlay, the tile and its icon, the titles and bodies, the three figures, the "Go to" row and its chips and the buttons matches. The differences: Time left reads "04:50" where the deck writes "4:50" (T-R3.9a's timer); the toast is R1's, at the bottom right 16px from the edges (DG-87) with a close button, so 50px high for the deck's 46, and on a phone it spans the width where the deck's centring wraps its own at half of it. The deck goes from Submit straight to the result, so the submitted screen has no frame to measure. The frames the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280: nothing scrolls sideways, overlaps or is clipped. In Chromium, focus after the dialog and the unrecorded fullscreen exit behave as the bullets above say. Done when:

  • Focus loss shows an alertdialog, "You left the test", with the eye-off icon on a warning tile: - within the allowance: "That counts as n of m times allowed…" - past it under flag: "…Your teacher has been told. Your answers are safe." (D8) - the warn, −1 and auto_submit variants use the same frame; auto_submit keeps T-5.1's semantics

    The counting logic in `integrity/strikes.ts` is not touched.
    
  • Copy or paste under blockCopyPaste shows the danger toast "Copy and paste are turned off in this test", with R1's error tone and a ban icon. The event is recorded as today.

  • The return-to-fullscreen bar stays, in deck primitives.

  • The Submit dialog shows: - the title "Submit with n unanswered?" or "Submit your test?" - Answered, Flagged and Time left - a "Go to" chip per unanswered question - Keep working and Submit test

    It flushes before submitting and stays idempotent against a racing auto-submit
    (`submit.test.ts`).
    
  • Timer at zero, offline, session takeover (read-only), session expiry and the submitted screen keep today's behaviour in deck primitives. Each frame is listed for DG-80.

  • events_test.go, beacon.test.ts and buffer.test.ts pass untouched.

  • pnpm e2e:live passes E2E 5, 6, 7 and 8 with their assertions unchanged. Only locators follow the new UI, with one exception: the deck's dialog body has no "clock is still running" sentence, so E2E 6 asserts "counts as 1 of 1 allowed" (in Vietnamese) instead.


T-R3.10 — Result

Depends on: T-R3.4, T-R3.5 Touches: web/src/features/results/pages/ResultPage.tsx, web/src/features/results/components/{ScoreRing,ResultTiles,ReviewItem}.tsx (new), web/src/features/results/api.ts, locales, web/tests/units/results/result.test.tsx Size: M As built:

  • Touched beyond the list: features/results/resultView.ts (new, the page's logic), components/ResultStates.tsx (new, the skeleton and the failures), tests/units/results/{result-view.test.ts,fixtures.ts} (new), tests/e2e/{student-layout.spec.ts,load-error.spec.ts,answers-persist.live.spec.ts}. The first follows the back link's new name and now also checks that the phone header reads "Kết quả"; the second has a case for the failed result's request-id row at 320 and 360; both pass. The live one, not run here, asserts "trên 2, tạm tính" and "2 / 2" where it asserted "2/4": T-R3.12 confirms it. group-review.spec.ts and features/results/api.ts needed no change. Left for whoever merges last: components/shared/BackLink.tsx and student.myAssignments have no reader now that T-R3.8 has landed, and the shell's setTitle (layouts/detailShell.ts) has no caller.
  • The view is a pure function. resultView(data) gives the ring, the sentence's state, the tiles, the filter's choices and the lock line; given and correctKey give an item's two lines. Every boundary (80%, 60%, 70%, one minute, a started minute) is tested there with explicit numbers.
  • Four states, three of them the deck's. Fully marked: the score "of n" and the sentence "You answered 8 of 10 correctly." (with "Correct answers are shown below." only when an answer below draws one: never on a paper with every answer right). Partly automatic: "3 of 6 so far" in the accent, with the three tiles. Pending: "…" and "grading", no tiles, when nothing was marked automatically. Withheld, which the deck does not draw: a lock in the ring, no tiles, and "You answered 7 of 8 questions. Your teacher has not released the score."
  • "So far" is every answer not waiting: the server's score.earned over the points of those answers. "Marked automatically" counts only the types the server marks itself (everything but the short answer), so an essay the teacher has already graded is in the ring and not in that tile. "Time used" is submission less start, a started minute counting as one; its bar is measured against the attempt's own allowance (deadline less start). The tiles' columns are set in code from the shell's 768px query, two below it and one per tile up to four from it, not by a container query: the threshold is the shell's own, and viewport("phone") can pin it.
  • An answer shows its own mark whenever scores are shown, whatever the key's policy: the server sends earned with the total, and the Wrong filter needs it. The deck's just-submitted paper draws a dash on every auto-marked answer; here the dash is for a hidden score only (DG-106).
  • Partly right, which the deck does not draw: a lime circle with a percent sign and "0.5 / 1". It is listed under Wrong, is not counted as correct, and is not struck through. Only an answer marked zero is struck through, and "No answer" never is.
  • "Correct answer" needs showCorrectAnswers and the key on the question, and is left out for an answer marked correct, as the deck does. With the score hidden it shows for every question and nothing says which answers were right. No mark is ever worked out from the key.
  • The lock line names the policy, in seven forms from "Your teacher has not released the score for this test." to "Your teacher is not showing the score, correct answers or explanations for this test." The deck's three lock texts need a release time (R4) or the band variant (R6).
  • The filter is R1's segmented control with the deck's labels and no counts. It is absent when "All" would stand alone, falls back to "All" when its choice is no longer offered, and an empty "Wrong" says so with "View all questions". The 44px floor does not apply to it (the deck's 30px).
  • The line above the title takes the class from the assignment lists the shell already holds, so the contract is unchanged, and the page asks for them only when nothing holds them. It is the date alone while they load, or for a paper shared by two of the student's classes (#240). "Submitted just now" lasts a minute, as on Home, and the one-second tick behind it is taken by that line alone, so the ring, the tiles and the answers are not drawn again while it runs. "attempt 1 of 2" is added only when the paper allows a retake.
  • The teacher is "your teacher". The result carries no name, and a Vietnamese full name without its title reads badly. The pending sentence ends "Your score appears here when grading is finished." and becomes the deck's "You will get a notification…" by itself when modules.notifications turns on (R4).
  • Answered is the engine's rule (#234): the count in the withheld sentence and "No answer" on an item both ask take-test/answered.ts, so a cleared answer is not answered. A fill-in with some blanks filled is not answered by that rule and still lists what was typed, with a dash for each empty blank, because those blanks may have earned points. A blank in a prompt is drawn "___". An item's number floats at the start of its prompt, so a wrapped line starts under the number as the deck's single run does; a prompt that opens with a list would draw its first marker over the number (the deck draws no such prompt).
  • The title is the page's h1, inside the card at the deck's 20px; the phone header reads "Result".
  • The paper surface (DG-35) is one class recipe on the answers section, from the tokens, with no theme branch: [&_img]:bg-paper, and for .content-table-scroll bg-paper text-paper-fg rounded-md with cell borders and header cells mixed from --paper-fg into --paper (14% and 5%), marked important because content.css is unlayered. T-R3.9b's recipe and this one should become one.
  • States the deck does not draw (DG-106): the page's shape as a skeleton; "The result isn't ready yet." with the server's sentence for a 409; "This result was not found, or it is not yours." for a 403 or 404, with a way Home and no retry; any other failure says the paper is safe, with retry and the request id, whose row wraps on a phone as the shared error card's does (#244: the id under its label and Copy); a result that has loaded stays when a later read fails. Shared material keeps the teacher console's card (ReviewGroup, untouched); a question's recording, its transcript and the grader's comment sit under the answer. Known differences from the deck's frames: no tiles on a one-part paper (topic tiles are R6's) and no comment callout (DG-84); the deck leaves 36px between the card and the answers where its empty tile grid is, the page 18px.
  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's three papers (graded, just submitted and pending) and the Wrong and Waiting filters: every box, type size, weight, colour, radius, shadow and gap of the column, the back link, the phone header, the card, the ring and its arc, the three lines beside it, the tiles and their bars, the heading, the filter, the lock line and each answer (circle, number, prompt, points, both lines and the explanation) matches. One fix came of it: the gap after an answer's number was a 4px margin, 1px wider than the deck's, and is now the typeface's own space. The differences are the ones above. The graded paper's filter has two choices where the deck always draws three, so it is 76px narrower in English from the same right edge, and all ten answers are listed where the deck's fixture has five. The just-submitted paper's answers carry their marks and points (DG-106), which at 360 takes its fourth prompt to three lines where the deck has two. "Your teacher" stands for "Ms Thương", and the lock line and the pending sentence are worded as above. The pending paper lists its two answers, 18px under the card. The states the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280, light and dark: nothing scrolls sideways, overlaps or is clipped, and focus rings show. Two fixes: a word longer than its line now breaks inside its answer's box (it ran out of the explanation's at 320), and the ring's caption is balanced ("trên 6," over "tạm tính"). Done when:
  • The column is at most 820 wide: a back link from 768, the detail header below 768.
  • The summary card has: - a 112px ring: success from 80%, --accent-c from 60%, otherwise warning - the value with "of n" when the score is shown, or "…" while grading - class · submitted time, and the title - one sentence derived from the state: partly automatic, fully automatic, or pending
  • Tiles: - while answers wait: Marked automatically (earned of auto points), Waiting for teacher (a count) and Time used - once graded, with showScore, on a paper of two or more parts: part tiles summed from sections and sectionId, two columns below 768 and up to four above - nothing when the score is withheld
  • The filter offers All, Wrong and Waiting. Wrong appears only when scores are shown, and Waiting only when something waits. A lock line names the policy that hides whatever is hidden.
  • Each review item shows: - a status circle, the number and the prompt, and the points - "You answered X", struck through in danger only when correctness is shown and wrong - "Correct answer Y", only with showCorrectAnswers - the explanation, only with showExplanations - the transcript, by its own rule - the grader's comment
  • Variants: just submitted, fully automatic, and pending. The band variant is R6's. The deck's topic tiles (Result variant 2, a one-part grammar paper) are not built here; they have no model, and 76-r6.md T-R6.12 decides them (gap entry "Topic tiles have no model").
  • Wherever the page says how many questions were answered, or marks one unanswered, it asks take-test/answered.ts, as the navigator and the resume card do. The page it replaced counted a saved row, so a cleared answer read as answered (#234).
  • Group images and rich tables keep the light paper surface in dark mode (DG-35), as T-R3.9b builds it.
  • Nothing renders from a field the policy removed. result.test.tsx covers every policy combination. Compared with the deck.

T-R3.11 — Settings

Depends on: T-R3.5 Touches: web/src/features/auth/pages/StudentSettingsPage.tsx, web/src/features/auth/components/SettingsSections.tsx, web/src/features/auth/{profileSchema,changePasswordSchema}.ts, web/src/features/auth/google/useGoogleSignIn.ts (reused), locales, web/tests/units/auth/settings-sections.test.tsx, web/tests/e2e/{settings,settings-navigation}.spec.ts Size: M As built:

  • Touched, against the list above: new features/auth/components/StudentSettingsSections.tsx, lib/testText.ts, tests/units/student/settings.test.tsx and tests/units/lib/test-text.test.ts. Also lib/theme.ts (useThemePreference), index.css (two swatch tokens and the switch thumb's shadow), components/shared/PasswordInput.tsx (the 42px field), components/ui/switch.tsx (the deck's thumb shadow under the deck scale), tests/units/{student/shell,lib/theme,ui/deck-scale}.test.* and tests/e2e/student-layout.spec.ts. SettingsSections.tsx, SettingsPage.tsx, the two schemas, useGoogleSignIn.ts, settings-sections.test.tsx and settings-navigation.spec.ts are the teacher's and did not change. T-R5.4's Touches in 75-r5.md now names the student's file and test for its "Changed …" line.
  • The teacher's settings are the files they were. The student page no longer renders the shared SettingsPage; it shares the schemas, the API calls and the PKCE hook, not the markup. R4 rebuilds the teacher's (T-R4.43), whose PasswordSection still reports a changed password as failed when the re-read after it fails: the fix made here for the student is owed there.
  • Sections and slugs. profile (also the bare /app/settings), sign-in and appearance. The switcher is R1's segmented control, a group of aria-pressed buttons (DG-17). A choice of another section is a navigation; a press on the one that is on does nothing, so Back is never padded. Old slugs redirect with a replace: security to sign-in, and preferences to Profile, where its one control, the language, went. Any other slug goes to Profile.
  • Notifications is not built. There is no preferences endpoint and no notification exists in v0.9.0, and a switch that saves nowhere would lie. The section, its switcher item and its strings arrive with R4's module; until then notifications is one of the slugs that go to Profile.
  • The name is saved through the Teacher deck's unsaved-changes bar (DG-107). The Student deck draws the field and nothing that saves it. The bar ("You have unsaved changes.", Discard, Save changes) shows only while the name differs from the saved one, so the settled card is the deck's. A name the rules refuse says so in place of the hint; a save the server refuses says so in the bar, which stays for a second try, and the reason leaves once the name is edited. The field is read-only while the request runs, so nothing typed meanwhile is thrown away by the saved value. R4's DirtyBar (T-R4.2) can lift it.
  • Language applies at once through R1's locale and is never an unsaved change. The list has Vietnamese first (DG-20).
  • Left out with the old page: the summary card (name, email, role, "account since"), which the deck replaces with the initials, and the Sign out button at the foot. The account menu signs out at every width (T-R3.5). "Change photo" waits for R4.
  • The meter is R1's passwordStrength, capped at two bars while a rule the server enforces is unmet, so a password that will be refused never reads "Strong". The hint is "At least 8 characters." until it is long enough, then the strength, with the deck's advice only while there is no number or symbol. The deck's "Weak" is never shown and has no string: a password long enough to be named already scores two. "Update password" is aria-disabled and does nothing under 8 characters, as the deck's; Enter does the same. R1's schema and the server decide, and their reasons show under the meter. After a change the form closes, focus returns to Change, and the toast adds "Other devices have been signed out.", which the server does. The user is read again once; a re-read that fails does not turn the change into a failure.
  • Both password fields keep the show and hide button, which the deck does not draw (student-layout.spec.ts pins it). PasswordInput gained the lg geometry for it. Its right padding is declared under the deck scale as well, or Input's scoped px-3 undoes it and the text runs under the button. The login's xl field has that flaw from R1 and is not fixed here.
  • The Google row reads "Linked", with no address. CurrentUser carries the providers, not the Google address, and the account's own email can be a different one. A Google-only account has no password row and keeps Unlink in the tab order, switched off, beside the reason, as before. A row whose text is a sentence (not linked, Google only) wraps it, and on a phone its button drops under the sentence, to the right; the deck cuts the sentence with an ellipsis. The linked row is the deck's one line. Linking leaves the page through the PKCE redirect, so there is no "Google linked" toast; unlinking has the deck's.
  • Appearance. The cards are a group of aria-pressed buttons on R1's preference. They follow the account menu's theme item through useThemePreference. The previews use two new tokens, --swatch-light and --swatch-dark, and Device is two halves, not a gradient (DG-34).
  • "Larger text in tests" is lib/testText.ts: readLargerTestText(), writeLargerTestText(larger) and useLargerTestText(). It writes large or default to localStorage['quizzivy.testText'], follows a change made in another tab, and holds a choice the browser refuses to store for as long as the page lives. T-R3.9b reads it.
  • The 44px floor is off for the whole page: every control on it is the deck's (32, 34 and 38px buttons, the 42×24 switch).
  • The h1 is the house recipe from 768. Below it the shell's header carries the title, as the deck draws, and the page's own heading is for screen readers.
  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with the deck's fixtures, in seven states (Profile; Sign-in closed, open, with Abcdefg1 typed, and after Unlink; Appearance with the switch off and on): every box, type size, weight, colour, radius, shadow and gap of the column, the switcher, the cards, the avatar, the fields and their hints, the rows, tiles and icons, the meter, the buttons, the theme cards and the switch matches. Two things were wrong and are fixed: the switch thumb had R1's shadow, not the deck's, and the Language chevron sat 1px left. The differences: the h1 is the house recipe's 1.25 line, 6.5px shorter than this screen's drawing from 768 (the deck's Settings inherits 1.5); no Notifications item, so the switcher is 253px wide, not 365; no "Change photo"; no "Changed …" line, so the password row is 64px, not 67.75, and what is under it sits 3.75px higher; "Linked" without the address; field text at 16px below 1024 and the darker field border, both R1's (DG-31); the show and hide buttons; the hint under a strong password ("Strong." here, one line shorter at 360); the not-linked row on a phone (above); the meter on a password of letters only (abcdefgh is two warning bars and "Okay" here, one danger bar and "Weak" in the deck). The states the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280, light and dark: nothing overflows, nothing is clipped, and every control shows its focus ring. Done when:
  • /app/settings/:section has three sections in v0.9.0: Profile, Sign-in and Appearance. Notifications joins with R4's module (T-R4.45). Old section slugs redirect. The column is at most 760 wide, and no nav item is lit.
  • Profile: - an initials avatar; "Change photo" only once R4's avatar exists - full name through PATCH /auth/me, with "Your teacher sees this name." - email disabled, with "Ask your teacher to change your email." - Language (Tiếng Việt / English) through R1's locale, stored on the server once R4's preferences exist
  • Sign-in: - the password row has no "Changed …" line yet; R5 T-R5.4 adds passwordChangedAt to CurrentUser (self only) and the line to this section, with its unit test - Change opens current and new password fields with a four-bar strength meter; the meter advises, R1's server rules decide - the Google row shows "Linked" (no address: see As built) with Unlink (only when hasPassword), or "Link Google" through the existing PKCE flow, never the GIS SDK
  • Notifications, when shown: only the deck's switches whose events exist, with the deck's defaults. R4 brings "Test due soon" and "Result ready", R7 "Messages from teachers", R10 "Daily practice reminder" (74-r4.md). Each switch covers both channels (DG-82). Not shown in v0.9.0: T-R4.45 builds the section with its module (As built).
  • Appearance: Light, Dark and Device cards on R1's theme, and "Larger text in tests" (decision above).
  • A form's state survives switching section and resizing across 768. Dirty and error states use R1 primitives. Compared with the deck.

T-R3.12 — Student suites and the phone project

Depends on: T-R3.6 … T-R3.11 Touches: web/playwright.config.ts (a mobile-chromium project, Pixel 5), web/package.json (the e2e script), web/tests/e2e/{smoke,student-layout,student-interactions,group-attempt,group-review}.spec.ts, web/tests/e2e/{answers-persist,payload-leak}.live.spec.ts, web/tests/units/ui/page-aside.test.tsx, web/tests/integration/router-chunks.test.ts Size: M As built:

  • Touched beyond the list: tests/e2e/{student,student-engine}.phone.spec.ts (new), tests/e2e/support/{student,engine}.ts (new: the fixtures and helpers of student-layout, student-interactions and group-attempt, lifted unchanged in a commit of their own, so the phone specs reuse them), tests/e2e/{load-error,settings}.spec.ts, tests/units/student/imports.test.ts (new). smoke, group-review and the two live specs needed no change. Nothing under web/src changed.
  • The phone project runs the files named *.phone.spec.ts, and only those, on Pixel 5 (viewport, touch, user agent); chromium ignores them. The bullet below says "limited to the student specs": the existing student specs set their own widths, many of them desktop widths, so a second run under a phone device would repeat the desktop run or fail for the emulation's reasons. A phone spec never sets a viewport. *.phone.spec.ts and *.live.spec.ts cannot name the same file. No WebKit project: CI installs Chromium only, and the mobile-safari stub is gone.
  • What the phone specs pin (14 cases, with tap): the tab bar at the foot with Home, Classes and Me, no top navigation, no button in the header but the avatar's, and each tab current on its own screen; Me as a detail screen with signing out behind the avatar; the detail header on the intro and the result (back arrow, title, no logo, no tab bar, no back link of the page's own) and back to Home; the count button's line ("4 / 8 · đã trả lời 3"); the sheet with every question and the open one marked; a square going to its question and closing the sheet; the backdrop closing it; Finish opening the Submit dialog; the pane switcher, with the footer hidden behind the passage and the answer kept; no sideways scroll on Home, Classes, the intro, the result and the engine; the 44px floor.
  • The deck agrees with what the phone run shows: its script hides the bottom navigation on the intro, the result and Settings, sends their back arrow Home, and closes the sheet from a square and from the backdrop. Nothing was added to gaps.md.
  • The 44px floor had no test. Shrinking the .student-surface rule left every suite green: the tabs and the footer's buttons carry their own height. The phone spec measures three controls that only the floor holds at 44px: the recording's play button, Return to fullscreen and, for the rule's link-button arm, the way Home on an intro that is not found. student-interactions pins the boundary on Return to fullscreen: at least 44px at 1023, under 44px at 1024. The rule's third arm, [data-slot="select-item"], matches nothing (select items are portalled to body), so it has no test.
  • Four overflow assertions could no longer fail. The engine's panes scroll by themselves, so content wider than its pane scrolls inside it and document.scrollWidth never grows: student-interactions and group-attempt stayed green with the question column at 150% of its pane. They use engineFits now, which measures every scroller inside <main> (a rich table's own scroll box excepted), and go red on that change at every width. It compares the document to the pixel, as those checks did, and allows one pixel elsewhere. It also measures an open dialog and the scrollers inside it, which is how the phone case sees the question sheet: the sheet is fixed, so it never widens the document.
  • 768 joined two width loops that were written when the engine branched at 1024: student-interactions (320, 360, 768, 1024, 1440) and group-attempt (320, 768, 1440), which now checks the passage beside the question from 768.
  • One phone case moved, not copied: settings.spec.ts "on a phone › settings is the Me tab…" is the phone file's "Me opens settings as a detail screen, and signing out stays behind the avatar". The 320px cases stay where they are: they pin a width narrower than the device.
  • page-aside.test.tsx: the one case written for the student's navigator rail ("Danh sách câu", hidden below lg) names the teacher's version history, which is who uses it now. The teacher's cases are untouched. imports.test.ts pins the rule from the sources: every route the router declares under /app is in studentTree or takeTestTree (a third tree, or a route written inline, fails), and from those no module reached by a static, type-only or dynamic import is PageAside, SideColumn or useColumnWidth; the teacher's routes, walked the same way, reach all three. T-R3.9c's side-column.test.ts stays: it proves the same by loading every route.
  • The chunk canary. STUDENT named the shell and Home (two modules matched). It names seven now, the same two and Classes, the intro, the result, Settings and the Join dialog, and everything under features/results/ (13 matched); FOCUS is unchanged. Added: the student tree and FocusLayout are in no chunk the entry loads statically; nothing under features/take-test/ or features/integrity/ loads with the entry except draft.ts and groupPlaybackDraft.ts, which features/auth/useSession.ts imports to clear local drafts at sign-out (so since before R3; they are named, and a third module fails); every named page, FocusLayout and TakeTestPage is built in a non-entry chunk, so a renamed module cannot leave a pattern matching nothing. Not covered: Home's rows in features/assignments/components/, a directory the teacher's pages share.
  • Counts, v0.8.0 to the tip (vitest list, playwright test --list): unit 1261 to 2147, integration 29 to 32, E2E chromium 61 to 75, mobile-chromium 0 to 14, live 11 to 11. This task's share: 5 unit, 3 integration, 3 chromium (two widths and the floor's two boundary cases added, one case moved out) and the 14 phone cases.
  • The register. 85 test names present at v0.8.0 are absent at the tip (80 unit, 5 E2E): 59 renamed, 5 moved, 21 removed with the behaviour they pinned (the review screen, the navigator column and the old engine chrome, the old Home, Classes, Settings and Result layouts, the intro's and the focus dialog's old sentences, the aborted lookup). Each of the 21 has a note in the task that removed it; none was a silent loss, and nothing was written back. No test in web/tests is skipped, focused or marked to do.
  • Not run here: the live suite (it needs the API and Docker; CI's "E2E (live API)" job is the proof). Read instead: E2E 9's key list is byte for byte v0.8.0's (the spec's only change since is how it waits for the response), and each string E2E 2 asserts on the result is one the page composes ("trên 2, tạm tính", the two-sentence summary, "2 / 2", "lượt n/50", "Bạn trả lời").
  • Mutants: 46 changes to the app. Of the first 35 (25 against the phone project, 10 against the unit and canary cases), 33 failed a case of this task at once; the floor rule alone got the case above, and the footer's own h-11 alone is covered by the floor below 1024 and pinned by navigator.test.tsx. The review added 11: the sheet's grid and its title row at 28rem, the header one pixel too wide, the floor at 48rem, at 80rem and without its link-button arm, a third /app tree, the same route inline, a template-literal import, and ScoreRing and the integrity monitor imported by AppFrame. Eight of them were run against the suites as first built and passed every case; each of the 11 fails a named case now.
  • Found on the way: stubbed E2E 3 (join.spec.ts) fails now and then (1 run in 5 here, 2 of 24 under eight workers). On the Google callback the boot's 401 for the visitor can be handled after the code exchange has signed the student in, and it then expires or clears the new session (reproduced in jsdom with the real hook and client). It is older than R3 and is §5, so it is reported, not changed here. Done when:
  • mobile-chromium runs the student specs at phone width: the bottom tabs, the detail header, the engine's sheet and the pane switcher. - its testMatch is limited to the student specs, and it has testIgnore: /\.live\.spec\.ts$/, as chromium does - web/package.json's e2e becomes playwright test --project=chromium --project=mobile-chromium, so CI and local runs of pnpm e2e both execute it; the PR shows its tests in the run's output
  • Every rewritten assertion states the same behaviour as before. The PR names each removed test, the behaviour it pinned, and why that behaviour left (the review screen, the navigator column). None is skipped.
  • router-chunks.test.ts still shows the student tree and FocusLayout as lazy chunks outside the entry chunk. Its patterns are updated, not loosened.
  • pnpm e2e:live passes E2E 2 and 9. E2E 9's key list is unchanged, because R3 adds no key-shaped field.
  • The PR states the unit and E2E test counts before and after, and explains the difference.

T-R3.13 — Browser verification against the deck

Depends on: T-R3.12 Touches: — Size: M As built:

  • Touched: features/assignments/pages/AssignmentIntroPage.tsx, with tests/units/student/intro.test.tsx and tests/units/student/shell.test.tsx (the one defect, below); layouts/FocusLayout.tsx (its comment: every part of the engine is rebuilt, and the 44px floor is what is left on the buttons the deck does not draw); T-R3.8's note and the timer sentence of T-R3.9a's note above. docs/design/gaps.md is not changed here: T-R3.14's branch rewrites DG-80 and opens DG-119, and a row added here would collide with it. So the fourth bullet below is met through this note, and the register's entries for what was measured here (the pill's figures in DG-80, the skeleton's line) are T-R3.14's to write. T-R3.14 wrote them: DG-80 carries the figures, and DG-118 the skeleton's line and the intro's cover.
  • Two decisions about the method. Signed-in flows use the live suite's signInAsStudent (tests/e2e/support/live.ts), the path CI runs every day, not a route proxy with an API token; nothing was typed into the Browser pane. The deck was opened from the worktree's file (file://…/docs/design/deck/Quizzivy Student.dc.html), which is the bytes a server on 5175 would send. The build was confirmed fresh: the browser wrapper stops any preview and each run rebuilds, and one run read /version.json and the entry chunk from 4173 and found both in web/dist, written 67 seconds earlier.
  • The data is a fresh database, migrated and seeded, read through the real API as the seed student. Four things the seed lacks were made through the API as the seed admin and the seed teacher, none by SQL: the deck's reading paper (one group with the deck's four paragraphs, a real mp3 uploaded through POST /teacher/media, so the sniff and the probe ran, bound as the group's recording with two plays, and the deck's eight questions; published and assigned with the review released); an assignment that opens in two days; one closed early; a spare class with a join code.
  • Every /app route was measured at 360, 768, 1024, 1280 and 1440, light and dark, in Vietnamese, beside the deck's same screen: Home with and without an attempt in progress, Classes, the Join dialog, the intro (startable, in progress, attempts spent, not open yet, closed early, not the student's) with "Start now?", the result (review withheld, review released, three parts, not found) and Settings' three sections. Equal everywhere: the 60px top bar and its 24px and 14px side padding, the 65px tab bar with 52px tabs, the page padding (32 24 48, and 18 16 28 on a phone), the columns (960, 960, 720, 820, 760), the 112px ring, and, for the cards and controls both draw, the radius, border, padding and shadow and the height. The h1 is 26px and 22px, 600, -0.02em in both on Home, Classes and the intro, and on Settings from 768 (below it the shell's header carries the title). The result's title is its card's 20px h1 (T-R3.10) and was not compared here: the deck's is not an h1, and the harness looks for one. The differences are the modules not shipped (three tabs for the deck's five, no Learn card on Home, no "This week" and no message button on Classes, no Notifications item and no "Change photo" in Settings) and the departures T-R3.7 and T-R3.11 state, seen again: the h1's 1.25 line on Classes and Settings (27.5px and 32.5px for the deck's 33px and 39px) and field text at 16px below 1024. Appearance's theme tiles are 98.4px high for the deck's 98px from 768, inside the 0.55px the comparisons allow: the label's line is 16.4px (leading-[1.265]) and the deck's normal comes to 16px. At 360 in Vietnamese they are 114.9px, because "Theo thiết bị" takes two lines.
  • The engine on the deck's own paper, in English so the text boxes compare, at the five widths and both themes, on questions 1, 4 and 5 and the fill-in: the 60px header, the 36px ✕, the 36px pill, the 38px Submit, the panes (640 and 600 columns, 28 32 and 18 16 padding), the 17px prompt, the 52px options with 26px markers, the 52px field, the 32px flag toggle, the 44px steps, the 34px squares 5px apart, the footer (65px on a phone, 172px at 768 in four rows, 94px at 1024 in two, 65px from 1280 in one), the phone's 51px switcher with 34px tabs, the sheet's six columns of 44px squares, and the Submit and Leave dialogs all equal the deck's. One difference: the written answer's field is 16px below 1024 (T-R3.9b).
  • The timer pill, measured: the app's pill is 96px at any time (45px of digits: four 10px cells and a 5px colon). The deck's follows its digits: 92px at its own 38:12, 80px at "11:11", 96px at "00:00" and 100px at "44:44". So the app's is 4px wider at 38:12, 16px wider at "11:11" and 4px narrower at "44:44"; Chromium gives the first two the same at one and at 2.75 device pixels to the px. T-R3.9a's note above now states these; DG-80's sentence is T-R3.14's to correct.
  • The three-part paper: the parts are dealt in order; the strip leaves 17px between parts and 5px inside one; the sheet has the three part headings; a square's name carries its part; A to E choose, F flags, the arrows move and stop at the first question. After a reload the answers and the flags are back; the open question is not (below).
  • The passage group with a recording: two panes from 768; the passage keeps its scroll from question to question by square, by arrow and, on a phone, through the switcher; the player starts from one click and from one tap, counts "2 plays left", "1 play left", "Played 2/2 times" from the server, and keeps playing while the student moves inside the group.
  • A phone run at the Pixel 5 size with touch went from Home through the intro, the engine (the count button, the sheet, a square, the switcher, the player, Finish, a "Go to" chip, the Submit dialog) to the result, then Classes, the Join dialog with a wrong code and with the spare class's code (previewed, not joined) and Settings. Nothing scrolls sideways or is clipped; the controls under 44px are the ones each task's note names.
  • Integrity on the real server: the focus dialog at "1 of 1", the over-limit dialog and "Giáo viên đã được báo" in the header and in the phone's strip, still there after a reload; the pill in --danger-soft and --danger-ink in both themes inside the last five minutes, then the paper handing itself in; a second session taking the attempt, and the first one read-only under its warning strip.
  • The student skeleton against Quizzivy Splash.dc.html#skeleton (student is the deck's default destination) at 360 and 1280, light and dark: every box and block is equal, and the shimmer runs --muted to --hover over 1.4s. It was held by keeping the splash's 350ms fade timer from firing: delaying the API holds the splash, because the skeleton is drawn only while the splash leaves. One difference: the deck's 320px line overhangs its 304px column at 360 and the app's stops at the column.
  • Fixed: on the intro of a paper with seven sentences, a rule showed under "Câu trả lời được lưu tự động." at 360, through the shell's bottom padding below the sticky action. The action now covers that padding (28px below 768, 48px from it) in each of its four states (start, continue, not open yet, nothing to start); no measured box moves. A test holds the cover to the shell's padding, so one cannot change without the other.
  • Looked at: no sideways scroll of the page, <main>, the panes, the footer or a dialog from 360 to 1440 in both themes, and at 320, in one theme or both, on Home, Classes and the Join dialog, the intro, Settings' three sections, the fill-in question, the ended and the taken-over paper and the result. A ring on every Tab stop, one full cycle at 360 and at 1280 in light and in dark (452 stops): Home, Classes, four states of the intro, the result with and without its review, Settings' three sections, the Join and "Start now?" dialogs, and the engine with its Submit and Leave dialogs and the phone's sheet. 338 stops draw the house 2px --focus outline (an option's is on its label); the 114 on R1's Button, segmented control and switch draw the primitive's ring, 3px or 2px in --ring at half strength. Measured again on 2026-10-04 (#322): that reading is the first frame of a 150 ms transition on those three controls, which settle on the same 2px --focus outline as the rest, at 4.86:1 or more in light and 6.19:1 or more in dark. The only pure white in dark and near black in light are Appearance's theme swatches, by --swatch-light and --swatch-dark.
  • The leak check: 312 JSON bodies were saved from the student's pages; none has a key named isCorrect, sampleAnswer, acceptedAnswers or transcript. getAttemptResult carries correctOptionIds, correctAnswers and explanation only for the paper whose review was released, and an empty transcripts map there.
  • Reported, not changed (engine and media behaviour; each is written up as an issue in the PR): a reload opens the paper on question 1, not on the question that was open; a submitted attempt opened at its engine address shows the timer still counting down beside "Bài làm này đã kết thúc."; a recording's length reads "0:09" in the engine and "0:10" on the result. Done when:
  • Every /app route and the engine are measured as the seed student at 360, 768, 1024, 1280 and 1440, in light and dark, beside Quizzivy Student.dc.html served on 5175, with a dev server confirmed fresh. getBoundingClientRect measures the 60px bars, the 52px options, the 34px squares, the 112px ring and each column's maximum width.
  • The engine runs on the three-part paper (seed/05-dev-sections.sql) and on a passage group with a recording, and a phone run goes through the sheet and the pane switcher.
  • The registered student skeleton is compared with Quizzivy Splash.dc.html#skeleton at the destination prop student, at 360 and 1280, light and dark. The teacher and admin destinations are compared by the releases that register them (T-R4.51, T-R5.33).
  • Each deviation is fixed in this PR or recorded against DG-80 or DG-21 with its reason.
  • Signed-in flows go through the Playwright route proxy with an API token. No password is typed into the pane.

T-R3.14 — Docs

Depends on: T-R3.13 Touches: AGENTS.md, docs/quizzivy-spec-v0.3.md (§6, §9, §10, §12, §15; version bumped per §18), docs/plan/92-student-ux.md, docs/plan/30-risks.md, docs/plan/70-redesign-overview.md §6, docs/design/gaps.md Size: S As built:

  • Touched beyond the list: this file (the two versions, the ticks), 70-redesign-overview.md §2 and §9 beside §6, and 20-data-model.md and 74-r4.md, where ten sentences named R3 for the contract steps. 71-r1.md, where a T-R1.1 bullet said R3 removes the .student-surface rule. Spec §7 and §13.3 for the contract steps too, and §7.3 and §10.1. In §15 only the alias sentence changed: T-R3.4 had documented its two fields.
  • T-R3.13's results are in. DG-80's timer figures are the browser's (the pill is 4px wider than drawn at 38:12, 16px at "11:11" and 4px narrower at "44:44"), its list gained no frame, and DG-118 holds the two deck defects the run found and the app does not copy.
  • Two versions. Every "until R3" about the alias, the legacy role, its trigger and the fill triggers reads "until v0.9.1" in AGENTS.md, the spec, 70 and 20-data-model.md; the spec's v0.45 changelog keeps "until R3" and adds "(v0.9.1, since v0.47)".
  • Five pages, four widths: Home and Classes 960, Test intro 720, Result 820, Settings 760.
  • Nothing queries the container student yet. Both layout roots declare it. The thresholds inside pages beside the shell's 768 are viewport queries or a component's own container: min-[360px]: on the intro's facts strip, lg: (1024, where the floor ends) on the engine's blanks and answer field and on the audio seek track, and the load-error card's container, load-error. AGENTS.md states the rule for new thresholds and lists these.
  • The spec is v0.47. T-R3.4 made it v0.46 for the contract; the screens are v0.47. It has no "Availability: R3" marker: its forward references to R3 were the "until R3" sentences and those on the rebuild, which read "since R3" now.
  • Spec corrections beyond the bullets, each read from the code: §7.3's learner material (one pane at a time on a phone, no collapse state), §9's keys (A to E, and the right arrow stops on the last question) and its review page, §10.1's clipboard listeners (on document, as before R3), and §12's grids at 768 and 1536 and "restored answers say they were loaded".
  • The 44px rule is a floor with exceptions, not "every phone control": .student-surface sets it below 1024, and a control the deck draws keeps the deck's size.
  • The phone save strip is stated as the code draws it: the save line only for a failed or offline save, and the strike count where the assignment sets a limit.
  • Storage. 70 §6 lists the nine keys a student's browser holds and the two that are gone. The second, quizzivy.material-collapsed.<groupId>, is not in the bullets below.
  • The comment callout is DG-84, in the register since R0. It is extended with what T-R3.10 built, not duplicated, and the open item below names it.
  • DG-118 and DG-119 are new. DG-118: the skeleton's overhanging line and the intro's list showing under its sticky action, both the deck's. DG-119: the Classes and Settings headings keep a 1.25 line-height where the deck's inherit 1.5. T-R3.12 needed no row.
  • Ticks. T-R3.4 to T-R3.11 are ticked where the code, a test in the tree or the task's As-built record bears the bullet out. Twelve bullets were first reworded to what was built: one each in T-R3.4, T-R3.9a, T-R3.9b and T-R3.10, two in T-R3.6, and three each in T-R3.5 and T-R3.11. Three of this task's own bullets below are reworded the same way (the page widths, the spec's markers, the comment callout) and left unticked.
  • Left alone, outside this task's files: ALREADY_ENROLLED stays in the contract's ErrorCode and in a branch of JoinPage.tsx that nothing reaches (issue 315). The alias sentences of docs/setup/deploy.md and 72-r2.md name v0.9.1 now, and AGENTS.md's map names the answered kernel package. Done when:
  • In AGENTS.md, the shipped rules replace the "Until R3" student paragraph: - the shell branches at 768 through a media query, with bottom tabs below it - detail routes declare handle.detail - page widths are 960 (Home, Classes), 720 (Test intro), 820 (Result) and 760 (Settings); a new threshold inside a page is a container query on student, which nothing queries yet - the student side has no PageAside or SideColumn - the engine lives in FocusLayout: split panes from 768, the number strip and the phone sheet, with no navigator column and no width preference
  • The "R3" bullet leaves "Redesign in progress" in AGENTS.md.
  • AGENTS.md's repository map names answered among the kernel packages (it holds the rule the monitor's and the resume card's counts splice, #234).
  • In the spec, every forward reference to R3 is settled, and anything built differently is corrected. It has no "Availability: R3" marker: what the student rebuild shipped reads "since R3", and the sentences about the alias, the legacy role and the fill triggers name v0.9.1. The version is bumped.
  • 92-student-ux.md is marked superseded by the deck and this file.
  • 30-risks.md R-01 names the rebuilt engine's guards: the draft-compatibility test, the phone save strip and the unknown-type block.
  • 70 §6 records that the student quizzivy.column.* keys were removed.
  • The result's attempt-level comment callout is in docs/design/gaps.md, so the design team receives it: DG-84, extended with what T-R3.10 built (open items). DG-118 and DG-119 are the entries this task adds.
  • DG-80 in docs/design/gaps.md loses "review before submit": T-R3.9d removed the page.
  • §10.6's "one hook owns all listeners" follows what T-R3.9d built: useClipboardNotice has its own clipboard listeners for the toast, unless they have moved into the monitor.
  • §10.2 follows what T-R3.9d built: the engine leaves fullscreen once the attempt is submitted and records no fullscreen change after that; the focus dialog's body is the deck's, without the seconds away or "the timer keeps running".
  • In the spec, §6.2 describes the student's Join dialog and loses "ALREADY_ENROLLED counts as joined" (no code sends it; a member's join answers 200); §6.5's "never IDs" is corrected to the contract, whose preview carries classId; §9's /app/classes row and §12's grid, 44px and "entering a class" sentences follow what T-R3.7 built (a class card is not a link).

Release checklist

v0.9.0, the student console

  • Every task but T-R3.1 to T-R3.3 is ticked on work/redesign-r3. develop is merged into it, and verification is re-run after that merge. - Three syncs brought develop in (#273, #310, #313), and CI ran on the merge of the branch into develop (#326).
  • Every CI step runs, in order, judged by exit code (70 §8.1). For this release they ran in GitHub CI, on #326 and on the release pull request, and were not repeated locally: since 2026-10-04 CI is the full gate, by the owner's decision on pace. One local run was made: the web's unit and integration suites on the tip of work/redesign-r3 (207 files, 2194 tests, all passing). - make lint, make gen-check - make test-api, and goose up/down/up - pnpm lint, pnpm typecheck, pnpm format:check - pnpm test:unit, pnpm test:integration, pnpm build - pnpm e2e (chromium and mobile-chromium), pnpm e2e:content - pnpm e2e:live, required because take-test, join and auth changed - node scripts/check-design-deck.mjs
  • The canaries pass, and their files are as they were at v0.8.0, but for router-chunks.test.ts: T-R3.12 widened its patterns to the student tree as built and added three assertions.
  • Security: R2's isolation suite and escalation tests pass, E2E 9 passes, and no public operation was added: the contract has 104 operations at v0.8.0 and now, and the open list is the same seven.
  • A read-only review of everything since v0.8.0, by three reviewers (the deploy and the data; a student in the middle of a test and the answer key; access and the contract), found no blocker and no major defect. Its two findings that are older than this release are issues 330 and 331.
  • Exam window: a query of production assignments open during the planned window returns none, or Thuong picks another window. Attempts in progress at deploy keep their answers: local drafts are read unchanged, and answers are keyed by question id. - Not queried for this release: production reads are closed to the agent that ran it. The owner gave the go for this window on 2026-10-04.
  • Deploy and smoke: - Thuong's go, then release/0.9.0 → main - deploy the API (the release command migrates), then Pages - smoke /healthz, /livez, and sign-in as an admin and as a student - a student takes a short test on production in the owner's test class, once on a phone and once on a laptop - back-merge to develop, and tag v0.9.0 - Done on 2026-10-04: release/0.9.0 merged into main at 049617bd on the owner's go (#332). The deploy finished at 07:57:50Z: the release command, the API and the worker machines, then Pages. /healthz answers database: ok, /livez answers 200, and /version.json reads version 0.9.0 on that commit. The tag v0.9.0 is pushed, and main is merged back (#334). - Still the owner's, which is why the box is open: the sign-in as an admin and as a student, the short test on a phone and on a laptop, and the TEMPORARY check of docs/setup/operations.md (#194).
  • web/package.json carries the version the splash shows, set on the release branch.
  • Release notes, vi first (in the release pull request): - a new student app that works at every screen size - tests in progress keep their answers - the check-before-submit page is now a dialog - nothing changes for teachers - old tabs are asked to reload
  • T-R3.14's docs are in the release.

v0.9.1, R2's contract steps, no earlier than 2026-10-10

  • T-R3.1 to T-R3.3 are ticked.
  • drop_users_legacy_role and validate_ownership are rehearsed on a Neon branch of production. The timings and the up/down/up run go in the release PR.
  • The legacy_admin_path count is recorded (T-R3.3).
  • web/package.json carries the version the splash shows, set on the release branch.
  • The v0.9.0 items on the CI steps, the canaries, security, the exam window and deploy and smoke are run again, as release/0.9.1 and the tag v0.9.1.
  • The documents change with the code (70 §3). Beyond what T-R3.1 to T-R3.3 name, no sentence still describes the /admin alias, users.role, its sync trigger or the fill triggers as present: - AGENTS.md: the core/router row of "The composition root", the /admin/* alias bullet of "Authentication and authorization", and "Access and ownership" under "High-risk areas" - the spec: §7 (Role), §13.3 (the role_id and Ownership paragraphs) and §15 (Prefixes), with the version bumped per §18 - 70 §6 (the Tokens row reads done) and §9 (PR-5) - docs/setup/deploy.md ("Rolling out v0.8.0"), which today says "R3 removes" the alias - the spec's changelog and this file's "Two versions" note keep their wording

Open items

  • DG-80. These engine frames ship extrapolated from deck primitives and need the design team's sign-off: audio, image, fill-in, groups, parts, the timer at zero, the warn and auto_submit dialogs, fullscreen, offline, takeover, expiry, the unknown-type block and the phone save strip. DG-80's row in docs/design/gaps.md has them frame by frame.
  • DG-82. The Home badge (due within seven days) and the bell rules are interim.
  • DG-21. Label widths in the number strip and the pills are sized to content with a minimum until the design team answers.
  • The result's comment callout has no model (DG-84). A student-visible comment for the whole attempt would need a column and a grading control. Until the deck says which, only per-answer comments show (R6 covers the single-essay case).
  • DG-119. The Classes and Settings headings keep a 1.25 line-height where the deck's two inherit 1.5.
  • Assignment note to students. The deck's Test intro draws a teacher note, and no release had a field for it. R4 builds it (assignments.student_note, studentNote) and T-R4.11 draws it on the intro: T-R3.8 could not, with no field in the contract to read. If Thuong decides not to build it, T-R4.11's bullet is deleted and the element is recorded as not built.
  • Release order of R3 and R4 decides where the student bell is wired. The default is R3 first (70 §2).

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

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally