Plan 74 R4 Part 5 - SekiroKenjii/Quizzivy GitHub Wiki

Part 5 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next

T-R4.33 — Question editor

Depends on: T-R4.32, T-R4.17a, T-R4.65, T-R4.66 Touches: web/src/features/question-bank/pages/teacher/QuestionEditorPage.tsx, web/src/features/question-bank/components/*, web/src/features/question-bank/questionSchema.ts, web/tests/units/question-bank/ Reuses: QuestionEditor with the answer areas and grading note of T-R4.66, QuestionProseField and ContentEditor (T-R4.63, T-R4.64), QuestionMediaField, AudioPolicyPanel and "Choose from Media" (T-R4.65), QuestionUsageRow Size: M

As built:

  • Frame. QuestionEditorPage is a deck surface, 1320px wide, whose routes carry the crumb Question bank › New question or Edit question. PageHead draws the back link and the title; the save area is the blocking message with its warning icon, the "…" menu on a saved question, and Save, enabled only when nothing blocks and something changed (or the question is new). A saved question shows QuestionUsageBanner: "Used in {n} tests. Saving changes future tests only." or "Not used in any test yet.", with up to three chips that open each test's builder.
  • Shared blocks. QuestionPromptAnswers (the prompt and the answer area, with "Insert gap" wired to the prompt's caret) and ExplanationField are the blocks the builder's BuilderQuestionEditor, QuestionEditor (still used by the group composer) and this page share; their placeholders moved to questionEditor.promptPlaceholder and questionEditor.explanationPlaceholder.
  • Type control. QuestionTypeControl is the deck's segmented control. From 768px it keeps one row and scrolls sideways in a narrow card, as the deck's does; below 768px it wraps and stretches its options: two rows at 360px in Vietnamese, three in English, whose labels are longer. While gaps bind answers only "Fill in the blank" stays available, with the existing line saying why.
  • Columns. The page is the container qe; from 900px of it the question card and a 300px aside sit side by side, which is the deck's rule (content width, so the sidebar's state counts). The aside holds SETTINGS (QuestionSettingsFields: Points with its error, Level and Skill with "Not set", Tags) and MEDIA (T-R4.65's block, unchanged).
  • Validation. blockingIssue names what blocks a save in the deck's order (the question, points above 0, a blank, an answer per blank, two options, every option filled, a correct answer), then any other rule as the parse's first issue. The seven messages take the deck's imperative wording in both languages.
  • "…" menu. Add to test (AddToTestDialog), Duplicate (duplicateQuestion, then opens the copy, through the guard), Delete question. A question a draft test still uses cannot be deleted, so Delete names those tests instead of asking (DG-146); otherwise it asks "Delete this question?" with "No test uses it. Published tests keep their own copy." and returns to the bank.
  • Unsaved changes. An edit marks the page dirty until it is saved; leaving for another page asks "Leave without saving?" (Stay, Leave), and reloading or closing the tab asks the browser's question. Saving a new question opens it at its own address without asking.
  • States. A skeleton of the frame, "This question was not found. It may have been deleted." with a link back to the bank on a 404, and the load error with Retry.

Done when:

  • Crumb Question bank › New question / Edit question; the save area (blocked message, "…", Save / Saving…); the usage banner "Used in {n} tests. Saving changes future tests only." or "Not used in any test yet." with up to three test chips
  • Type control: Single choice, Multiple choice, True / False, Fill in the blank, Short answer (two rows on phones). This screen was not redrawn on 2026-10-04, and the product has one QuestionEditor, so the form under the control is the builder's (DG-108): "Question" and "Explanation" are the content editor (T-R4.63, T-R4.64), not the deck's textareas, and the answer area and grading note are T-R4.66's: 2–8 options A–H, True / False as two cards, a row per gap with "Match case" and accepted-answer chips, "Sample answer". A prompt still stored as Markdown keeps its {{n}} placeholders and BlanksEditor, not the deck's [n]
  • SETTINGS: Points, Level, Skill, Tags. MEDIA: the question media block of T-R4.65, with DG-63's limits, "Plays" defaulting from the asset's default and otherwise Twice (DG-69; the question's value wins), "Allow skipping ahead", "Show transcript after submitting", "Transcript" and an image's alt text. The deck's "Add media" dialog and its "Plays allowed" select are not built (DG-108, DG-112)
  • Validation messages in the deck's order: write the question, points above 0, a blank, an answer per blank, two options, every option filled, a correct answer. The gap and multiple-choice lines are T-R4.66's inline sentences, and the builder shows the same set (DG-115)
  • Two columns from 900 content width; "…" menu Add to test, Duplicate, Delete (copy states the usage); an unsaved-change guard
  • States: skeleton, not found, error; deck check at the five widths, light and dark; keys vi first

Reopened in D4 (74d-d4-deck-update.md): T-D4.20 moved the editor's settings into a card in a sticky aside from @min-[900px]/qe; T-D4.35 added "Write with AI" on the Explanation and "Suggest wrong options" beside Add option.


T-R4.34 — Question groups, restyled (DG-68)

Depends on: T-R4.4, T-R4.5b, T-R4.1a, T-R4.2b, T-R4.63, T-R4.35 Touches: web/src/features/question-groups/** Reuses: every existing component and hook in the slice Size: S Done when:

  • GroupsListPage and GroupEditorPage use deck primitives only (page header, table or cards, FormDialog, Sheet); behaviour unchanged
  • group-authoring.spec.ts, group-authoring.live.spec.ts and builder-groups.live.spec.ts green
  • The group composer's starter question is created as rich text (content), not as the Markdown starter prompt features/question-groups/model.ts:68 writes today, so a new question in a group opens in Rich text (T-17 a)
  • Checked at 1280 and 360, light and dark; keys vi first
  • R35-8 (from T-R4.65): MaterialAssetDialog uses the shared FileInput and useMediaUpload; material-asset-limit.test.tsx stays green As built:
  • The two pages render in TeacherLayout. question-bank/groups and its :id route carry a handle (crumbs Question bank › Question groups › the group, width 1320), so they follow the theme; the editor names the last crumb with the group's title (PageHead's crumb).
  • The list is PageHead with New group, BankNavigation drawn as the deck's segmented track (links, so each half opens its page), the deck's search, an Active | Archived Segmented, the DataTable with selection, a row that opens the editor and a row menu (Duplicate, Archive or Restore and Delete permanently), the same BulkActions, and the deck's Pager. The row's quick Duplicate button folded into the menu. Points drop below 560px of table width, Updated below 680, and below 440 the question count moves under the title. "Just duplicated" sits under the title, outside the row's link. The list shows a skeleton, not the previous status's rows, while a status loads.
  • The editor is PageHead with a back link to the list, the save state under the title, Preview and Save; the conflict, archived and failure notes are Callouts (warning, neutral, danger and announced). GroupComposer is unchanged: it is built from primitives already and is also the builder's group pane (T-R4.31a).
  • The starter question is created with promptContent (newGroupQuestion), the same shape as the builder's (T-R4.31b), so it opens in Rich text and, through the composer's starter flag and RichProseEditor's starter, opens empty under the placeholder until it is edited.
  • R35-8, the dialog half. MaterialAssetDialog takes the shared FileInput for both kinds and uploads through useMediaUpload, shown by UploadStatus, on DialogShell. material-asset-limit.test.tsx keeps its three cases and finds the shared input by its name; the refusal it reads is the shared wording. groups.uploadFailed is removed (own commit).
  • Specs. group-authoring.spec.ts and group-authoring.live.spec.ts follow the back link, the row menu and the status Segmented, and read a rich prompt by its text; builder-groups.live.spec.ts needed no change. builder-group-diagnostics.test.tsx sets its member's prompt as Markdown, as the test means.

Reopened in D4 (74d-d4-deck-update.md): T-D4.2b replaced the gap binding's row with the export's (the "gap {n}" chip, a select whose first entry, "Choose a question…", replaces "Remove binding", and "Remove gap", which takes the gap out of the text); T-D4.15 added "Students can pause" to the group's recording.


T-R4.35 — Media library

Depends on: T-R4.4, T-R4.5b, T-R4.17a, T-R4.17b, T-R4.1c, T-R4.2b, T-R4.2c Touches: web/src/features/media/pages/teacher/MediaPage.tsx (from MediaLibraryPage.tsx), web/src/features/media/components/{MediaCard,UploadDialog,ReplaceDialog}.tsx (new; UploadPanel.tsx retired, its pre-check, probe and upload path moved to web/src/features/media/useMediaUpload.ts (new), which UploadDialog, QuestionMediaField and MaterialAssetDialog call), web/src/features/question-bank/components/QuestionMediaField.tsx and web/src/features/question-groups/components/MaterialAssetDialog.tsx (the upload call only), web/src/features/media/limits.ts, web/tests/units/media/ (library-blocked.test.tsx among them) Reuses: AudioPlayer semantics, Waveform, CardGrid, FileDrop, useFileDrop, probe.ts Size: M As built (T-R4.35: media library):

  • Touched beyond the list: components/{RenameDialog,DeleteMediaDialog,UploadStatus}.tsx (new), media/{api,format,probe}.ts, the media route line in app/router.tsx, which now declares a teacher handle (crumb media.title, width 1320) so the page renders in TeacherLayout and follows the theme, its row in tests/units/app/teacher-routes.test.ts, the media namespace of both locales, and tests/e2e/media-library.spec.ts (new, stubbed). router-chunks.test.ts is unedited: its admin pattern names all of features/media/, and the hook is not exported from features/media/index.ts.
  • useMediaUpload({ onUploaded, kind?, send? }) runs the pre-check (quickCheck: the kind's extensions, then its size; precheck adds an audio file's duration), the upload with its progress, and a refusal, failure or cancellation as a message. It draws nothing: UploadStatus draws the state (the old panel's checking line, progress row with cancel and alert with "Choose another file"), and each host keeps its own file input. start(file, { kind, send }) overrides the kind and the sender per upload, which is how the upload dialog sends defaultMaxPlays and the replace dialog replaceMedia. A new start supersedes the one in flight; unmounting aborts it, where UploadPanel let it finish into an unmounted host. Nothing retries.
  • The two hosts render <input aria-label="Chọn tệp từ máy"> and UploadStatus themselves, so the live suite's selector holds. MaterialAssetDialog's audio path moved to the hook; its image path, with its own size check and groups.imageLimit message, is unchanged. The upload's cancel button is now named "Huỷ tải lên" / "Cancel upload": "Huỷ" collided with the group dialog's own Cancel.
  • Image pre-checks (DG-63) are new: JPG, PNG or WebP (no GIF) up to 10 MB, with their own two messages. The upload and replace dialogs run quickCheck as field validation, then the full pre-check on submit.
  • The page: ?tab= (audio, image, unused) and ?q= live in the URL through useListFilters, a change resets the page, and the query sends kind or unused=true with the debounced q; the tab counts are the response's facets. 24 or 48 files a page (?size=) on the deck's data/Pager (QA-F1-2): the deck draws no pager on this screen but the API pages, and 24 or 48 cards fill the grid's two, three or four columns. The pager shows whenever total is above zero, so a page past the end still leads back. A window drop opens the upload dialog with the file chosen (its kind from the extension); the drop is disabled while any dialog is open. The storage line's sizes come from formatStorage, which writes gigabytes from 0.1 GB as the deck does and keeps each number with its unit; the line wraps instead of the deck's nowrap, for the longer vi copy.
  • The card: audio plays from its own <audio preload="none">; .play() runs in the click's tick and the page is told after, and a card that stops being the page's current one pauses, so one file plays at a time. The waveform is seeded by the asset id and fills with the playback position. A media error re-reads the library for a fresh signed URL. An image shows its thumbnail (object-cover) and falls back to the deck's icon and size when the URL fails; its meta line is "{w} × {h} · {size}", so the size in pixels stays visible under a thumbnail. The menu trigger is the deck's 26px button, named "File actions for {name}".
  • Replace (DG-09 copy) sends replaceMedia once; after any answer, failures included, it invalidates the media, question, group, test and preview queries, because a failed replace may have committed. Delete asks with the deck's copy ("No question uses this file." or "Used in {n} questions. Those questions will show a missing file."); a file with usageCount > 0, or a 409 that names versions, opens the notice "Used by a published version, so it can't be deleted" with links to those versions instead. Rename is updateMedia with the trimmed name.
  • Download is a link to the signed URL in a new tab (rel="noopener noreferrer"): a cross-origin URL cannot be named by download, so the file keeps its storage name. Follow-up (backend): a response-content-disposition on the signed URL would name it.
  • The upload dialog's play limit defaults to "2 plays" (decision T-14): DG-69 makes the card's value the default of new questions, and the builder's default is Twice. The deck's form would start at its first option, "Unlimited".
  • Abort on unmount is deliberate (REV-35): the old panel let an upload finish into an unmounted host, leaving an orphan file that the owner's quota counted.
  • Review round (REV-35):
    • R35-1: useFileDrop keeps its window listeners bound for its lifetime and always cancels a drop that carries files; enabled gates only the drag state and the callback. Before, a file dropped on a dialog's backdrop, with the page's drop off, navigated the tab to the file and killed the upload in flight. FileDrop's disabled test follows the new contract.
    • R35-3: a card pauses only an element that is playing. pause() on an unloaded element starts resource selection, so every card had fetched its recording on render; the e2e spec now pins that nothing is requested before a click.
    • R35-4: readDuration settles as unknown after 10 s (PROBE_TIMEOUT_MS) and revokes its object URL either way; cancel ends the check as well as the upload, and UploadStatus offers it during the check.
    • R35-6: a thumbnail that fails tells the page once per URL, as the audio does. The page re-reads the library only when its data is over a minute old, without cancelling a read in flight, so a file that fails on a fresh URL cannot loop the list.
    • R35-7: UploadStatus reads the deck tokens.
  • Verification round (VER-35):
    • QA-35-1: Rename, Replace and Delete open from an item of a menu that has already closed, so they had no trigger to return focus to and focus fell to <body>. Each card's menu trigger carries data-media-menu={id}; the page records it when an item opens a dialog and passes it as the dialog's returnFocus, so Esc, Cancel and a rename return to it. A delete points focus at the next card's trigger, else the previous card's, else the Upload button when the page empties. A replace returns to the old trigger on close and moves to the new file's trigger once the library has re-read, because the replacement is a new card.
    • QA-35-2: a cancel is its own cancelled state, drawn as the neutral status line "Đã huỷ tải lên." / "Upload cancelled." with "Choose another file", not under the refusal title.
    • QA-35-3: a size refusal goes through formatOverLimit, which gives the exact bytes, grouped for the language ("52.428.900 B"), when the usual figure would equal the limit ("50.0 MB" against 50 MB).
    • QA-35-4: tests for the four surviving mutants (a second start aborts the first, a 409 naming versions shows them, a drop under a dialog does not open Upload, a fresh URL that fails starts no fetch), each checked against its mutant.
  • Finding (QA-35-1, not fixed here): the same menu-item-to-dialog pattern, with no returnFocus, is on the rebuilt AssignmentDetailPage ("Close early" from its row menu, and the Reopen menu's choices) and on the not yet rebuilt TestsListPage, ClassesListPage, ClassDetailPage, GroupsListPage, QuestionBankPage and the builder's OutlineTree. Monitor already passes its menu trigger as returnFocus.
  • Follow-up (R35-5, the DG-09 copy owner): a 409 MEDIA_REFERENCED that names only groups (details.groups, no versions) falls to the generic error line in the delete dialog; the notice names published versions only. This predates the task and is not fixed here.
  • Retired: UploadPanel.tsx, the table, bulk delete and image viewer of the old page, and 27 media.* keys that nothing reads any more.
  • Tests: upload-panel.test.tsx became use-media-upload.test.tsx with its nine cases and their names, plus seven (image pre-checks, the result handed on, progress and cancel, abort on unmount). New: media-card-play.test.tsx (the second play site, same tick), media-page.test.tsx (15), upload-hosts.test.tsx (both hosts: upload, over-length, failure, cancel), format-storage.test.ts; library-blocked.test.tsx goes through the menu; limit-strings.test.ts covers the four new limit sentences. media-library.spec.ts checks the five widths in both themes and a real play in Chromium. Done when:
  • Header "Media" / "Audio and images used in questions." + Upload; storage card "{used} of {quota} · audio {a} · images {i}" with an accent and an info segment
  • Tabs All / Audio / Images / Not used with counts; a wired search. The counts are listMedia's facets (T-R4.17a), so they follow the search
  • Cards minmax(240px,1fr): audio play/pause through the AudioPlayer path (.play() in the click's tick) with the decorative waveform; image thumbnail and dimensions; name, meta, "Used in {n} questions" or "Not used" (warning), the play limit with a repeat icon
  • Menu: Rename, Replace file (copy per DG-09, vi first: bank questions and drafts get the new file, published versions keep theirs; limits per kind, DG-63), Download, Delete (refused with "Used by a published version, so it can't be deleted" when it is)
  • Upload dialog: Audio | Image, file, Play limit for audio; client pre-checks from limits.ts
  • useMediaUpload holds what UploadPanel did: the pre-check, the probe, the upload and its progress and failure. QuestionMediaField and MaterialAssetDialog upload through it with their behaviour unchanged, and upload-panel.test.tsx moves to the hook with no case dropped
  • audio-player.test.tsx (the AudioPlayer canary) green and untouched
  • States: skeleton; "No media yet" + Upload; error; deck check at the five widths, light and dark; keys vi first

T-R4.36 — Imports: history and upload

Depends on: T-R4.4, T-R4.5b, T-R4.1b, T-R4.2a, T-R4.2c, T-R4.3b; built on today's contract, and T-R4.21's and T-R4.55's pull requests wire the facets, the counts and the text format into it Touches: web/src/features/imports/pages/teacher/{ImportsListPage,NewImportPage}.tsx, web/src/features/imports/components/{SourceIntake,FileSlot,ImportStatusBadge}.tsx, web/src/features/imports/status.ts, web/src/features/imports/api.ts, web/tests/units/imports/ Reuses: ImportsGate, SourceIntake, useSourceIntake, FileSlot, the segmented control with counts (T-R4.2), Pager Size: M Implementation checkpoint: The current-contract presentation is accepted after independent source review, 136 imports unit tests, strict TypeScript, scoped lint and formatting, successful hosted CI, and 43 passing independent Chromium cases with no retries. History and upload were compared with the deck at 360, 768, 1024, 1280 and 1440 in both themes; history also covers both sidebar states and the real 15px main scrollbar (961px outer width at 1280 expanded). Native verification covers same-key polling failure and recovery, quiet upload cancellation, the actual main scroller and sticky footer, keyboard controls, skeleton hover/reduced motion and the 959/960px layout threshold. These are stubbed browser composition checks; real API import processing remains covered by its existing suites. The following deferred contract additions keep their own tasks and acceptance criteria. History has six URL-backed choices, response-backed pagination and one 960px outer container grid/card layout. Upload keeps its intake mounted across Source switches, preserving chosen files and title. T-R4.21 still wires facets and review counts, T-R4.55 wires text metadata, and T-R4.57 wires paste editing and intake. Until then no counts, text rows or paste calls are invented: Processing queries the literal current status, and paste mode says it is unavailable with Start disabled. Continuous title motion is absent on these deck surfaces. Correction (QA-51-2, QA-51-3, 2026-10-12): T-R4.51 found both screens off the deck, and they now follow it. History: the title "Imports" and the DG-73 sub and empty hint; the filters "All" and "Couldn't process"; the File cell holds the file name (the link to the import, named with its title) over the meta line, so a row is 62px; the Test column reads "—", and the card "Test: none yet"; the eight statuses take the deck's five pills (every status still being worked on, or waiting for files, takes Processing's look); the row action is the outline button, the column heads weight 500, the header buttons 36px and 13.5px; the row actions "See what failed" and "View". The card puts the pill and the chips on one wrapping line, so the pill is drawn in both the Status cell and the To review cell and the layout hides one. Upload: no back link (the crumb leads back; a page that cannot start keeps one in its empty state), the deck's sub in file mode, the Source switch sized to content with a 168px minimum, the "Files" card ("{formats} · up to {size} each", the limits note, each slot a drop tile with "Drag a file here, or choose one" and its hint, then the file row with Replace and an icon Remove), the "Test title" card with the recognition note, "How to read the content" as the deck's option card, and the footer's 38px buttons, Start in 600, starting at the left when it wraps. The h1 keeps DG-122's line-height, so text below it sits 6px higher than the deck's. Done when:

  • History header: title and the sub "Word and PDF files and pasted text you are turning into tests. Each import keeps its original content, its progress and your review, so you can come back at any time." (DG-73), then outline "Paste a test" (→ /teacher/imports/new?source=paste) and primary "Import from Word/PDF", both only while a new import can start
  • Filters: the segmented control with a count on every option, in the deck's order (All, Processing, Ready for review, Couldn't process, Draft created, Cancelled), on the left; the counts are T-R4.21's facets, so they follow the search. The search ("Search file or test name", 280px, 200px at least) sits on the right, and the row wraps. Status, q, page and size live in the URL; a status change resets the page. The sizes are 20, 30 and 50 in the deck's data/Pager (QA-F1-2), which replaced the history's own size select; the pager shows whenever total is above zero. The deck draws the pager as the card's footer, inside its border ("{n} of {n} imports", "Page 1 of 1", previous and next); the product's is the same footer (frame="card", QA-51-11) with the workspace pager's range, rows per page and first and last, as every other list in the deck draws it, because the API pages (DG-203). A page past the end, with no rows, draws the pager alone
  • The list is the page's own grid, not DataTable. From 960px of its own width (a CSS container query, not useContentWidthAtLeast and not the deck's viewport arithmetic; the query container is a wrapper around the card, outside its 1px border, so the width queried is main's content width, 976 at 1280 with the sidebar expanded) it is a grid with table roles and the deck's tracks, minmax(200px,2.4fr) 160px minmax(150px,1.4fr) minmax(110px,1fr) 150px 32px, the two fixed tracks taken as minimums (DG-21). Below it each import is a stacked card: tile, name, wrapping meta and the menu; the status pill and the To review chips; "Test: {link}" or "none yet" and the primary action. Nothing scrolls sideways at any width, and a row's controls exist once in the accessibility tree
  • Cells: File (tile, filename, "{creator} · {when} · with {key}"), Status (eight statuses mapped onto the deck's five pills), To review ("{n} need action" and "{n} to confirm" chips, or the deck's text), the Test link, the primary action per status, and a menu with Download original only (DG-67)
  • A pasted import (its exam source has format: text) shows the clipboard tile, the localized name "Pasted text" in place of the stored filename, and "{creator} · {when} · {n} characters". Its chips read as every import's do (see the decisions above). The search finds a pasted import by its title only, never by the label or the stored filename (T-R4.55)
  • Filtered empty "No imports match these filters" + Clear filters, and today's no-imports empty state (DG-67) with a hint that names both ways in and both header actions (DG-73); real pagination
  • Upload (max 860): the Source control "Upload a file | Paste text" under the header, the segmented control with an icon per option and a minimum width in place of the deck's fixed 168px (DG-21), with the Segmented's aria-pressed semantics, not the deck's role="tablist" (DG-17). The mode lives in ?source=paste, and anything else is the file mode. Switching replaces the search param and keeps the chosen files, the pasted text and a typed title. The paste side is T-R4.57's
  • File mode: Files card with Exam (required) and Answer key (optional) slots, the deck's preflight messages and the server's limits; Test title prefilled from the file name
  • "How to read the content" shows only automatic recognition; saved profiles, source conventions, the template link and the audio slot are absent until they have a backend (DG-67); the privacy callout, "Where your content goes.", names files and pasted text and says only what getWordImportCapabilities reports
  • Sticky footer with the deck's hints, Cancel and Start processing; upload progress and limits kept. The page has no leave guard today: leaving mid-upload stops the intake quietly, and that stays
  • Tests: history.test.tsx covers the six options and their counts from the facets, a pasted row's name and "{n} ký tự", both header links and their absence while processing is off, and the page reset on a status change; upload-flow.test.tsx covers the Source control read from the URL and a switch that keeps a chosen file and a typed title. The layout switch is checked in the browser, since jsdom has no container queries: cards at 360, 768 and 1024, the grid at 1280 and 1440, with the sidebar expanded and collapsed. 1280 expanded has 16px to spare, so it is also checked with a page that scrolls and with scrollbars shown (Playwright hides them when headless; launch with ignoreDefaultArgs: ['--hide-scrollbars']): Chromium's 15px scrollbar leaves 961 and the grid. A scrollbar wider than 16px gives cards there, which is DG-18 as decided
  • Deck check at the five widths, light and dark; keys vi first

Reopened in D4 (74d-d4-deck-update.md): T-D4.39 added the "Read with AI" card to the new import's Sources pane and "· Read with AI" to a history row's meta; the upload and the history are otherwise as built here.


T-R4.37 — Imports: processing

Depends on: T-R4.36 Touches: web/src/features/imports/pages/teacher/ImportDetailPage.tsx, web/src/features/imports/components/ProcessingPanel.tsx, web/tests/units/imports/ Reuses: ProcessingPanel, useReprocess, FilesRemovedNotice, useTick Size: S Done when:

  • Width 720; header tile, file name, "Started {when}" and "with {key}"
  • Running card (role=status): the deck's five rows mapped from run.stage — Check the files (source_validation), Read the content (normalization, extraction), Recognise the structure and Match the answers (recognition), Check the test (validation); elapsed time, attempt n of m and waiting-to-retry kept (DG-67); "You can leave this page." callout
  • Cancel processing (danger confirm stating the configured retention); Ready (the title "We found {q} questions with {a} answers in {s} sections" and the pills "{n} need action", "{n} to confirm" and "{n} notes", each shown above zero, from the review's summary and findings; pills fully rounded, the notes pill muted; "Start review →", Later); Failed (pill naming the stage, title and body per error code, the code in mono, Replace exam file when the import accepts a new source, else Start a new import); Cancelled (Back to imports; cancel stays final, DG-67); committing and committed kept
  • A pasted import (its exam source has format: text) shows the clipboard tile in the header, as its history row does, is named "Pasted text" there and wherever this page names its exam source, and its started line adds "· from pasted text". Waiting to start, Start processing works as for a file. Row 1 reads "Check the text" and, once done, "{n} characters checked" from the source's characters; row 2, once done, reads "Plain text · no formatting". The deck's "Formatting kept from …" lines are not built
  • A pasted import's end states: the failed pill names "Check the text"; a failed pasted import offers "Start a new import", which opens the paste mode, and this page has no paste field and no "Replace exam file" for it; the cancel confirm and the cancelled card say "the pasted text" where the deck says "the uploaded files". With nothing blocking and no answer missing, the Ready card's first pill is the success "Every question has an answer"; the deck's "need an answer" pill is not built (DG-14). detail-actions.test.tsx and processing-poll.test.tsx cover both bullets
  • The processing history is absent until run events are exposed (open item)
  • Deck check at the five widths, light and dark; keys vi first

As built (2026-10-07): The processing page keeps the real import lifecycle in one 720px deck frame. Five rows map the actual stages; elapsed time, retry attempts, waiting, revision/idempotency guards, cancellation and committing states remain. Ready uses the current review counts and findings, and source/download/remove gates retain actual source IDs. Text presentation follows the exam source's role/format, with completed character and plain-text receipts. Its failed headings and recovery copy do not guess which source failed. Restart opens the existing paste-mode placeholder; functional paste intake is T-R4.57, recognition conventions T-R4.56. No run-event history or prototype formatting-kept line is synthesized.

Affected public/file/locale/style checks pass (123 checks across nine suites), with strict typecheck, lint and formatting clean. Existing file-state native acceptance covers 32 cases and 25 reviewed images, followed by four bilingual breadcrumb checks. The pasted-text wave covers 36 cases and 28 individually reviewed images at 360/768/1024/1280/1440, vi/en, light/dark, plus Start/cancel/review/retention and spinner rotation, hover/focus pause and reduced-motion checks. Its file-only failure-heading defect was retained and corrected; a fresh 16-case production wave covers both error codes, both languages, companion present/absent, phone/light and wide/dark, with all 16 images independently reviewed. Older failing diagnostics stay preserved; unchanged geometry/motion evidence is reused only for unchanged behavior. This task's static source title has no marquee. Current-code required CI passed at 047f56893a9f29057b7f36f876d106f606cde75c; this completion record changes docs only.

Verification correction (2026-10-07): Prior application screenshots were reviewed against deck markup and script, without paired browser-rendered deck references. The visual gate remains open. Direct reference preparation identified the processing spinner using the shared 1-second default rather than the deck's 800ms linear loop. The duration is corrected only on this spinner; hover/focus pause and reduced-motion remain. Fresh rendered-reference and motion verification are required before closing the visual criterion. Earlier behavior evidence remains scoped to its actual revision.


T-R4.38 — Imports: review workspace

Depends on: T-R4.36, T-R4.3c, T-R4.63, and the sidebar override T-R4.4 exposes Touches: web/src/features/imports/pages/teacher/ImportReviewPage.tsx, web/src/features/imports/components/review/*, web/tests/units/imports/ Reuses: ReviewWorkspace, SourcePane, ExamPane, QuestionCard, QuestionEditor, FindingNotice, Provenance, useReviewSession, useReprocess, StaleNotice, ReprocessNotice, SplitPane, ContentEditor in the deck's frame and ContentView (T-R4.63) Size: M Done when:

  • One card calc(100vh − 128px), min 560: top bar with title, "{q} questions · {a} answers · {s} sections", save status (Saved / Saving… / "Couldn't save your last change" + Retry, role=status), severity pills, "Preview & finish →"
  • Toolbar: Section select; All / Needs action / To confirm / Info with counts; Source | Test below 980 content width; the navigator ("{i} of {n} open", previous, Next)
  • Split 30–65% (default 42) with arrow keys ±2; source pane with Exam | Answer key, Extracted | Page view, Download original and block tags; test pane with the whole-test card, findings and their radio-card resolutions, the Exclude dialog (reason required) and the Points dialog
  • A pasted import: the source pane has one source option, "Pasted text", and one view, "As pasted"; it names the source by that label, never by the stored filename; Download original hands out the .txt. An answer-key file added through the API keeps its own option. review-workspace.test.tsx covers the single option and the label
  • The open card's "Question" field is the content editor of T-R4.63 (64px, 14px, the placeholder "Write the question as students will read it"), under a header row with the provenance chip, "From the file" and then "Edited by you". It replaces today's "Edit" and "Done" buttons (imports.review.editPrompt). One card is open at a time, so one editor is mounted, and the other cards show the read view. It stores the prompt as content, as today; the prototype's plain-text save is not built (DG-114)
  • Its toolbar is T-R4.63's, in the profile the review uses today: a fill-in-the-blank item keeps "Insert gap", up to 20 gaps, with today's limit message, although the deck draws none here. There is no "Rich text | Markdown" switch, since import content is never Markdown, and no "Image" or "Audio" (DG-109, DG-114). "Preview content after pasting" opens here as in the builder; "Choose from Media" has no entry point in the review, and the notice for a pasted or dropped file names no "Question media" here
  • Where the review is read-only (a phone, a stale session, a committed import) and for an excluded question, the field is the read view, with no toolbar. Options keep today's rich option editing, and "Accepted answers" and the cloze group's "Shared passage" stay as they are (DG-68, DG-114)
  • review-workspace.test.tsx covers the live editor in the open card, the read view on a phone and for an excluded question, and that an edit stores content; review-blanks.test.tsx stays green with "Insert gap" in the new toolbar
  • Question actions: Exclude and Restore only; Move, Split and Merge are absent until they have a backend (DG-67)
  • Stale reload, "Use the new result", the leave guard and the lost-response commit copy kept (DG-67); phones read-only as today, with the deck's dismissible phone note; entering review collapses the sidebar and leaving restores the previous state (DG-67)
  • Deck check at the five widths, light and dark; keys vi first

As built (T-R4.38):

  • New files: components/review/{ReviewTopBar,ReviewToolbar,ReviewCard,ReviewDialogs}.tsx; QuestionCard.tsx is gone, its header now ReviewCard and its body QuestionEditor. The route handle collapses the sidebar for the visit (sidebar: "collapsed") and the page names the import in the breadcrumb.
  • Width: two panes from 980px of content width (useContentWidthAtLeast), the shared SplitPane (percent, 42 within 30 to 65, step 2, remembered under quizzivy.importReview.split); below it one pane, chosen by the toolbar's Source | Test, the other kept mounted and hidden so its state survives. A phone (below 768) is read-only.
  • Findings: the filter set gains Info (findings.ts, isOpenIn); the navigator walks the open findings the filter and section hold. Findings on no question live in the Whole test card, which opens first when the first open finding is one of them, beside the facts and "Review points…" (the Points dialog sets every included question's points). Section and group findings sit under their heading. Candidates are a radio group that applies at once.
  • Exclude: from the card's menu ("Loại khỏi đề…") or a finding's action, through a dialog that requires a reason of up to 500 characters; Restore from the menu or the excluded banner.
  • Source pane: the role switch names a pasted exam "Pasted text" and shows Answer key only when one exists; the view switch is "As pasted" for a paste and Extracted plus a disabled Page view otherwise; Download original, the note line and block tags ("Q{n}", "Excluded", "Not assigned").
  • Departures: DG-147. Locale keys: imports.review.* (the top bar, toolbar, cards, dialogs and phone note), imports.source.* (views, note, tags, download), and the imports.origin and imports.severity copy the deck uses; the keys the old layout used are removed in their own commit.
  • Tests: web/tests/units/imports/review-{workspace,findings,commit,autosave,blanks}.test.tsx and reviewHarness.tsx (card, cardHeader, isOpen).

Reopened in D4 (74d-d4-deck-update.md): T-D4.20 named this task, but the review workspace has no side column and was not changed; the sticky summary it meant is Preview and create's (T-R4.39).


T-R4.39 — Imports: preview and create

Depends on: T-R4.38, T-R4.30 Touches: web/src/features/imports/pages/teacher/ImportConfirmPage.tsx (new), web/src/features/imports/components/review/ReviewSummaryDialog.tsx (retired), web/src/features/imports/useImportCommit.ts, web/src/features/tests/index.ts (new, exporting StudentPreview), web/src/app/router.tsx, web/tests/units/imports/ Reuses: useImportCommit, StudentPreview through features/tests/index.ts with an adapter from the review draft Size: S Done when:

  • Route /teacher/imports/:id/confirm: header "Preview and create a draft", "← Back to review"; two columns minmax(0,1fr) 360px from 1000
  • Preview: Computer | Phone, answers hidden, "Showing {k} of {n} questions…"
  • Summary rows (answers included, sections and groups, answers set with Fix, points with Confirm, audio and images, still to resolve with Review), a linked value in danger ink
  • "Create draft test" (44px) with its hints and disabled states; the committed card (Open in test builder →, Back to imports); lost-response handling kept
  • Deck check at the five widths, light and dark; keys vi first

As built (T-R4.39):

  • Route: /teacher/imports/:id/confirm under ImportsGate, as /review is, so the same guard and permission apply; another teacher's import answers 404 and the page says it cannot be found. The crumb is the deck's Tests › Imports › {the import's file, or "Pasted text"}, as on the detail and review pages (QA-51-4; it named the import's title and added "Preview and create" until then). Both "Preview & finish" buttons navigate here; the review's leave guard saves first and keeps the teacher on the review with its dialog when the save fails. ReviewSummaryDialog and imports.summary.* are gone.
  • Files: pages/teacher/ImportConfirmPage.tsx, components/confirm/{ConfirmPreview, ConfirmSummary,CommittedCard}.tsx, previewAdapter.ts (draftPreview: the saved draft as the student's paper, excluded questions left out, nothing that grades carried), and features/tests/index.ts exporting StudentPreview.
  • Columns: minmax(0,1fr) 360px from 1000px by a container query on the page root (@container/confirm), which spans the content area.
  • Summary: counts from the review's summary; the sub-lines (exclusions and reasons, passages a group still keeps, where the points came from) from the draft. Only a row that blocks the draft is in danger ink, linked to /review?filter=blocking|all; a gap that does not block reads in normal ink and links to /review?question= (the review opens that card and focuses it) or to a filter that lists it (VER-39 round 2). The review opens on a filter's first open finding and focuses it once, at mount. With no included question, Points reads "No questions yet".
  • Commit: useImportCommit keeps its request per revision; it now takes the autosave flush as an optional argument and re-reads the review as well as the import on IMPORT_CONFLICT and on STALE_WRITE, where it also drops the request so the newer revision commits as a new one; a lost response still replays the same request. "Create draft test" is aria-disabled while off, stays focusable with its reason as its description, and keeps focus while pending; the created draft's card takes focus on its heading. A cancelled or failed import says so, with a link back to it. The created draft, and an import already committed, show the committed card; "Back to review" is hidden once retention removed the review.
  • Departures: DG-148. Tests: web/tests/units/imports/{confirm,preview-adapter, review-commit}.test.tsx.

Reopened in D4 (74d-d4-deck-update.md): T-D4.2c replaced "Showing {k} of {n} questions…" and the included count with the whole review draft in the bar's frame; T-D4.16 put the take-test engine in that frame; T-D4.20 made the summary a sticky column from @min-[1000px]/confirm.


T-R4.57 — Imports: paste a test

Depends on: T-R4.36, T-R4.55, T-R4.56 Touches: web/src/features/imports/pages/teacher/NewImportPage.tsx, web/src/features/imports/components/{SourceIntake,PasteField (new)}.tsx, web/src/features/imports/{paste.ts (new),useSourceIntake.ts,api.ts,findings.ts}, web/src/app/router.tsx, web/tests/units/imports/{paste-flow.test.tsx,paste-scan.test.ts} (new), web/tests/units/imports/{fixtures.ts,i18n.test.ts}, web/tests/e2e/paste-import.spec.ts (new), web/src/features/imports/pages/teacher/ImportsListPage.tsx (the history row, from T-R4.36) Reuses: SourceIntake, useSourceIntake, ImportsGate, CopyButton, ConfirmDialog, R1's Toaster, api/testdata/pasted-text-counts.json Size: M Done when:

  • The imports history draws a pasted import as T-R4.36 asks and has not yet built (QA-F1-1): the clipboard tile, the localized "Pasted text" name and "{creator} · {when} · {n} characters", with a unit test of the row
  • In paste mode the page reads "Paste a test" with the deck's sub, and its last crumb reads "Paste a test". The "Test content" card takes the place of the Files card, with no answer-key slot. The Test title card, "How to read the content", the privacy callout and the footer are shared with the file mode
  • The box is a plain textarea labelled "Test content", monospace, at least 340px high, with the deck's placeholder. The counter reads "{n} of {max} characters": max is ImportLimits.pasteMaxCharacters, and n counts code points of the NFC-normalised text, which is what the page sends. Over the limit the counter and the border turn danger
  • "Paste from clipboard" replaces the text and moves focus to the box. An empty clipboard and a refused or missing clipboard API show the deck's two toasts. "Clear" shows only with text. "Copy example" ships. "Use a sample" is prototype chrome and is not built
  • The kind pill has one state, "Plain text · mark answers with * or “Answer:”", shown while the box has text. Nothing reads the clipboard's HTML
  • "Found so far", shown while the box has text: Sections, Questions, With an answer and Need an answer (warning tone above zero); then the deck's notes (over the limit; no numbered question; which questions have no answer, six numbers then "and {k} more"; a number seen twice in one part; images and audio are not pasted) and its footnote. The duplicate note reads question labels, not counted questions: a line that starts with a question label whose number the current part has already used is named, whether or not the grammar takes it as a question. Under the server's sequence rule a repeated bare number other than 1 is not a question, so it leaves the count and the note is the only sign of it
  • The count is paste.ts, the server's grammar for text sources (T-R4.56), not the deck's WI_detect. paste-scan.test.ts runs every case of api/testdata/pasted-text-counts.json and expects the numbers the Go test expects; each rule of paste.ts is mutated once, and a case fails each time. The count runs on a deferred value, never in the keystroke path
  • "How to lay out pasted text" is open while the box is empty: the deck's four tips beside the example from a 900px viewport, stacked below it. The example is a case of the shared fixture with nothing missing, so it differs from the deck's, whose answer key names questions the example does not contain (DG-15)
  • Title: the first non-blank line when it reads as a title (at most 120 characters and, by paste.ts, not a part heading, a question or an option); a typed title wins; the hint is the deck's. A blank title starts as "Đề đã dán · {date}" / "Pasted test · {date}", the date in the user's zone
  • Start processing is disabled, as drawn, for an empty box, a text over the limit and a text with no numbered question; the footer shows the deck's hint for each. When ready it reads "{n} questions found.", with singular and plural forms, then the file flow's processing sentence until T-R4.51's measurement allows the deck's "Processing takes a few seconds."
  • Ctrl+Enter or Cmd+Enter in the box starts, never during an IME composition; the hint names the platform's key, as the top bar does (T-R4.4)
  • Start runs the file flow's steps with the text in place of the exam file: the capability re-check, createWordImport, pasteImportSource, processWordImport. A retry reuses the create and upload identities; an edited text takes a new uploadId; a 409 re-reads the import; a 429 and an intake refusal show the server's message. Leaving while a request is in flight stops quietly, as the file flow does
  • Leaving the page with text that has not started asks first (useBlocker and beforeunload). Switching the Source option does not ask, nor does the navigation after a start
  • findings.ts gives TEXT_MARKS_UNAVAILABLE its text
  • Tests: paste-flow.test.tsx covers the items above, with a refused and a missing clipboard API, the composition case, the dated fallback title and the absence of "Use a sample"; paste-import.spec.ts (stubbed) pastes with a real paste event and takes the clipboard-refused path in Chromium
  • Deck check (G-2: 360 and 1280, plus 899, 900, 1008 and 1009 for the tips), light and dark, empty and with text; keys vi first, en from the deck, listed in the PR and resolved by i18n.test.ts

As built (T-R4.57: paste a test):

  • Touched beyond the list: features/imports/usePastedText.ts (new: the text, its deferred count and title, and the re-check before sending), api/testdata/pasted-text-counts.json (74 cases added: the page's example and four rule cases, the tester's 42 texts from VER-57 and 27 aimed at one rule each; the Go parity test passes on all 120), tests/units/imports/history.test.tsx (the pasted row), finding-notice.test.tsx (TEXT_MARKS_UNAVAILABLE, whose text T-R4.56 had already written) and upload-flow.test.tsx (its deferred-paste case now asserts the box). router.tsx gives imports/new the deck's trail, Tests › Imports › the page (QA-57-3); in paste mode PageHead's crumb renames the last crumb. api.ts, findings.ts, fixtures.ts and i18n.test.ts are unedited: every new key is a literal t() call that i18n.test.ts already resolves.
  • paste.ts follows the server's text path (recognition/exam.go, keys.go, inline_keys.go and build.go; DG-13: the server's review stays the authority). It reads each line in the server's order: a one-line key, a key section, the title, another paper (which ends the count), a part heading (roman with or without a mark, or a named part), a question label the sequence admits (sub-labels under their parent, which becomes a group), the open question's next option, its answer line, then text, which can open an instruction or a passage group. Without a heading, parts are inferred where the instruction changes. Keys follow the server: entries start with a real label, several to a line, a following line continues the last value, and a label two parts share is ambiguous unless the key names the part. A question's answer counts only when every value resolves to the same answer (an option by letter, text or truth; a short answer; split blanks). A conflict, an ambiguous key and a letter with no options count as neither answered nor missing, so the count never claims an answer the server will not (QA-57-2). Not ported: numbered blanks inside a passage (open cloze) and a key reached only through a numbered blank. Mutation runs: 79 rules in paste.ts, each killed by a fixture case; five more mutants change no count and are not run (the "next number" clause the forward jump covers, the one-section guard on inference, consuming the title line, the title's instruction rule and passage groups, which matter only for open cloze). The page, the hook and the history row: 24 rules, each killed.
  • Departures: the tips go beside the example by a container query on the card (DG-142) at 594px, its inner width at a 900px viewport with the sidebar expanded, so that state matches the deck exactly (measured at 899 and 900). Where the card is wider than the deck's viewport rule expects, the two columns arrive early: from 624px with the sidebar as a drawer, and from 768px with it collapsed, where the deck still stacks them below 900. The clipboard refusal reads "Ctrl+V (Cmd+V on Mac)": FONT-1 keeps the command glyph out of locale strings. The ready hint uses the file flow's "Processing takes about a minute. You can leave this page." (DG-16). The title's placeholder in paste mode is the dated fallback it will use. The duplicate note has a plural English form ("Numbers 2 and 4 appear twice"). The source switch sizes each option to its label with the deck's 168px as a minimum (DG-21), not fill (corrected for QA-51-3).
  • Behaviour worth knowing: a retry after a failed process does not send the text again; a refused send (400, 409, 413, 415) drops the upload identity, and a 409 re-reads the import first. Ctrl or Cmd+Enter submits the form, and is ignored while isComposing. The monospace box has no brand font, so on a machine whose monospace font lacks precomposed Vietnamese letters the placeholder's marks may stack (seen with the Linux fallback in the sandbox; Menlo and Consolas carry them).
  • Headless Chromium leaves readText() pending without a permission, it does not refuse, so paste-import.spec.ts denies clipboard-read through CDP (Browser.setPermission for the context) to take the real refusal path.

T-R4.40 — Students

Depends on: T-R4.4, T-R4.5b, T-R4.1a, T-R4.1b, T-R4.1c, T-R4.2b, T-R4.2c, T-R4.20 Touches: web/src/features/students/pages/teacher/StudentsPage.tsx (from StudentsListPage.tsx), web/src/features/students/components/{NewStudentDialog,StudentDrawer,ResetSheet (new),MoveDialog (new)}.tsx, web/tests/units/students/ Reuses: DataTable, Pager, BulkActions, FacetFilter, NewStudentDialog, TemporaryPasswordCard, StudentDrawer (as a Sheet), EditStudentForm, the class member operations Size: M Done when:

  • Header "Students" / "{n} students across {m} classes." + Add student (people.students.create): Full name, Email, Class (own classes and "No class yet"); the temporary password in a dismiss-only dialog with copy, never a toast
  • Filters Class (multi) and Status (Active, Password reset); a wired search
  • Columns: select, Student (initials, name, sub-line "{email} · classes · last active" as columns drop), Classes ≥780, Average, Last active ≥920 (last test until R5 records activity), Account ≥660, menu
  • Row menu: View results (the results sheet until R9), Move to class ("Keep them in their current class too"; add, then remove), Reset password (confirm, then the temporary password), Remove from class (account and results stay). No Send message until R7
  • Bulk: Move, Reset passwords (the bulk operation, then a printable sheet with Print), Remove. A STUDENT_SHARED refusal, single or in the bulk report, says another teacher also reaches the student and an admin can reset the password (vi first)
  • Each control is offered only to a role that holds its operation's permission (useCan): Add student and the edit of a student people.students.create; Reset password, single and bulk, people.students.reset_password; Move to class and Remove from class teaching.classes.write. A role without the key sees no such control. A unit test signs in the built-in Assistant, who holds people.students.read and none of these keys, and finds none of them (#285)
  • Phones: cards, no bulk, no menu
  • States: skeleton; "No students yet" + Add student; error; deck check at the five widths, light and dark; keys vi first

As built (2026-10-11):

  • StudentsPage.tsx replaces StudentsListPage.tsx, on DataTable, FacetFilter, BulkActions and data/Pager, at the route's width: 1320. The record (?studentId, a row's click or View results, the results sheet until R9) is StudentDrawer as a Sheet. The departures are DG-166: search by name or email, a Suspended status, the last test as Last active, and an Admin's suspend and delete.
  • A temporary password is never in TanStack's caches, a URL, storage, a toast or a log (DG-167). createStudent, resetStudentPassword and resetStudentsPasswords are called directly, not through useMutation; the answer lives in component state (StudentsPage's created, ResetPasswordFlow's outcome) until ResetSheet's Done or X sets it to null. ResetSheet ignores Escape and a click outside, copies a password only on a click, and prints alone (data-print="credentials" in index.css). TemporaryPasswordCard is gone.
  • The bulk reset refuses more than 40 (BULK_RESET_LIMIT, the operation's maxItems) before asking, and lists each failure with its reason; STUDENT_SHARED reads the same, single or bulk.
  • Move to class adds to the chosen class and then, unless "Keep them in their current class too", removes from the others, one student at a time (membership.ts); a failure lists each student it left with the server's reason and keeps the dialog open. Remove from class removes from every class in the student's row. A row lists the classes the caller teaches, or every class for an Admin (scope.all), so the row's Remove and the Move dialog name the classes the student leaves, and the bulk Remove counts the memberships it ends and names their classes (SR-40-1).
  • Move stays client-side in R4 (security review of #504): it adds before it removes, so a failure part way leaves a student in more classes, never in none; AddMember is ON CONFLICT (class_id, user_id) DO NOTHING and RemoveMember answers nil on zero rows, so running the dialog again after a partial failure converges; and the server authorizes each step. A server operation is warranted only when a move must be one audit event or one transaction; R5's Admin Classes revisits it when it takes the scope.all list over (DG-53, T-R4.54).
  • A rate-limited reset says how long to wait, from Retry-After. Escape on the password sheet moves focus to Done.
  • The Assistant (people.students.read alone) sees no Add student, no selection, a menu with View results only, and a record with no edit, reset, remove or access control (students-page.test.tsx).

T-R4.41 — Classes list

Depends on: T-R4.4, T-R4.5b, T-R4.18, T-R4.22, T-R4.1c, T-R4.2b, T-R4.45a Touches: web/src/features/classes/pages/teacher/ClassesPage.tsx (from ClassesListPage.tsx), web/src/features/classes/components/{NewClassDialog,JoinQr (new),JoinLink (new)}.tsx, web/tests/units/classes/ Reuses: CardGrid, CopyField, FormDialog, ArchiveClassDialog, the segmented control, qrcode.react (already a dependency) Size: M Done when:

  • Header "Classes" / "Students join with a code or a link." + New class
  • Cards minmax(300px,1fr): name (→ detail), "{schedule} · {room}" or "No schedule yet", Students / Live tests / Average; footer Join code in full, mono (DG-01), copy link ("Join link copied: {url}"), QR. Codes come from the readable code on listClasses' items (T-R4.18), never one getJoinCode per card
  • Links use one format, /join/K7QM-2PXA, on the app origin until R11 moves them (DG-01)
  • Card menu: Edit class (name, schedule, room), Show QR code (QR, link, Copy join link, Download quizzivy-{code}.png "for slides or Zalo"), Copy join link, New join code (confirm), Archive class; an Active | Archived segmented control, as Admin › Classes draws it (the Teacher page draws no such filter), with Restore on archived cards
  • New class (name, schedule, room) creates the class, then issues its code (30 days, 40 uses) and toasts "Class created · join code {code}"
  • While the join_codes.rotated notification is unread, a callout says the codes changed on {date} and to share the new ones; a legacy code, should one remain, shows "••••-{hint}" and New code
  • States: skeleton; "No classes yet" + New class; error; deck check at the five widths, light and dark; keys vi first

As built:

  • Files: pages/teacher/ClassesPage.tsx (renamed from ClassesListPage.tsx, now in TeacherLayout with the crumb "Classes"), components/{ClassCard,EditClassDialog,JoinQr, ShowQrDialog}.tsx (new), NewClassDialog.tsx (rebuilt on FormDialog), ArchiveClassDialog.tsx (takes the card's fields and a return target), classFields.ts, joinLink.ts and joinQrImage.ts (new) in features/classes/. JoinLink is joinLink.ts's joinLinkOf and useCopyJoinLink, not a component. newClassSchema.ts and the old list's test are gone; classes-page.test.tsx replaces it.
  • Codes: the page asks listClasses for withCodes only for a reader with teaching.classes.write, and its query has gcTime: 0, so the codes leave memory with the page; the card, the menu and the QR dialog read the code from the item and nothing stores it. A reader without the permission sees the cards with the code masked, and no menu or New class.
  • Links: joinLinkOf(code) is the app's origin, /join/ and the grouped code, dash kept (/join/K7QM-2PXA); the join page normalises either form. Copying toasts "Join link copied: {url}". The QR encodes the same link; its PNG is drawn from the on-screen SVG with the paper tokens resolved, 512 px square.
  • Menu and dialogs (F-21): Edit class, Show QR code, Copy join link, New join code (a confirm, then a code for 30 days and 40 uses, toasted) and Archive class; an archived card has Restore in its footer and Restore and Delete class in its menu. Every dialog gives focus back to the control that opened it: the card's menu trigger (data-class-menu) or the footer button.
  • New class creates the class with selfJoinEnabled: true, then issues its code (30 days, 40 uses); if the second call fails the class stays and the toast says to make a code from its card.
  • Callout: the newest page of /me/notifications is searched for an unread join_codes.rotated; the callout names its date, and dismissing it hides it at once and marks it read.
  • Pager: the deck's data/Pager, 24 or 48 cards, shown whenever total is above zero.
  • Departures: DG-178 (what the cards do that the deck does not draw). DG-01 holds the link format.
  • Theme spec: theme.spec.ts's console-not-yet-rebuilt cases now visit Students.
  • Measured at 1280 and 360, light and dark, against the deck served locally: a card is 317 × 179 at 1280 (the deck's 317 × 178) and the footer 315 × 51 (51); the grid is 976 wide in both. The heading keeps 1.25 (DG-122). 768, 1024 and 1440 are VER-41's.

T-R4.42 — Class detail and the join-code card

Depends on: T-R4.41, T-R4.1a, T-R4.1b, T-R4.2c Touches: web/src/features/classes/pages/teacher/ClassDetailPage.tsx, web/src/features/classes/components/{JoinCodePanel,ClassSettingsCard,ClassAssignmentsCard,AddMemberDialog}.tsx, web/tests/units/classes/ Reuses: JoinCodePanel (rebuilt), ClassSettingsCard, ClassAssignmentsCard, AddMemberDialog, JoinQr, DataTable, DirtyBar Size: M Done when:

  • Crumb Classes › {name}; minmax(0,2fr) minmax(0,1fr) from 1000 content width
  • Members: search; select, Student, Joined via ≥620 (Admin pill or "Code ••{hint}" mono), Joined ≥720, Submitted, Average ≥520 (danger below 60%), menu (View results, Remove from class); bulk Remove selected; Add student (list picker, "Pick at least one student", "Add {n}"); the deck's empty states and footer note
  • Removing a member is offered only with teaching.classes.write (#285)
  • Join code card: the full code always, no "shown only once" text (DG-01); "Expires {date}, 23:59 · in {n} days"; "Uses {u} / {max}"; the "Students can join" switch with the paused text; Copy link, Copy code, QR and Download; New code (Expires after 30 / 7 / 90 days; Maximum uses 1–1,000, default 40 per O-06); Stop (danger confirm); the no-code state with Create code
  • Class card: Name, Description, Schedule, Room; Save when dirty; "Saved. Students see the new name."
  • Assignments card: "{n} assignments · {live} open", View all, up to five rows; "Assign to class" opens the wizard with ?class=
  • States: skeleton, not found, error; deck check at the five widths, light and dark; keys vi first

Reopened in D4 (74d-d4-deck-update.md): T-D4.20 made the side column sticky from 1000px of content; T-D4.23 replaced the join-code card with v2's (the QR button and its full-size dialog, Copy code, Copy link and Download QR, Expires and Uses); its "Joined via" box, the full code in the members' table, is still open there.


As built:

  • Files: pages/teacher/ClassDetailPage.tsx (in TeacherLayout, the crumb Classes › {name}), components/ClassMembersCard.tsx (new), and JoinCodePanel.tsx, ClassSettingsCard.tsx, ClassAssignmentsCard.tsx and AddMemberDialog.tsx rebuilt, in features/classes/. api.ts gains JoinCode and getJoinCode, whose 404 is null. join-code-options.test.tsx is gone; class-detail.test.tsx and join-code-panel.test.tsx are rewritten. DirtyBar is not used: the Class card's own Save is what the deck draws.
  • Head and layout: PageHead with the way back to Classes, the student count as a pill, Add student (teaching.classes.write) and Assign to class (teaching.assignments.write, not on an archived class), a link to /teacher/assignments/new?class={id}. From a content width of 1000 (useContentWidthAtLeast, the deck's tw) the page is minmax(0,2fr) minmax(0,1fr); the right column is repeat(auto-fit,minmax(min(100%,300px),1fr)), as the deck draws it, so below 1000 the join code and Class cards sit side by side when both fit.
  • Members: DataTable with T-R4.1a's members geometry (56px rows, 18px padding, a 32px menu track, no frame of its own) and the deck's column sets by content band (520, 620, 720, 1000, 1080), the sets the harness's members case uses, so Joined via and Joined drop again when the card becomes two thirds at 1000. Joined via is "Admin" on the primary fill or "Code ••{hint}" in mono; Joined is the day and month this year and the full date otherwise; Average is "—" until something is graded and danger ink under 60%. A selected row takes the selected tone, the deck's muted tint. The menu, named "Actions for {name}", has View results (people.students.read, the results sheet at /teacher/students?studentId= until R9) and Remove from class (teaching.classes.write, #285), whose confirm names the student and the class and gives focus back to the row's menu trigger (F-21). Remove selected runs on the deck's bulk bar. The search asks the server; the deck's pager shows 20 or 50 once there are more than 20.
  • Add students: a server search over the students the teacher reaches (useLazyList), one checkbox row each with their classes, a member already in marked and not choosable, "Pick at least one student" and "Add {n}"; it adds one at a time, toasts the count and names any it could not add, and gives focus back to Add student.
  • Join code card (§6, DG-01): the code is read with getJoinCode only by a reader with teaching.classes.write, under ["admin-class", id, "code"] with gcTime: 0, so it leaves memory with the screen; nothing logs or stores it. The full code, "Expires {date}, 23:59 · in {n} days", "Uses {u} / {max}", the "Students can join" switch with the paused text (it stays operable while its request is out and ignores a second press), Copy link (/join/K7QM-2PXA), Copy code, QR (ShowQrDialog) and Download (quizzivy-{code}.png, drawn from a hidden JoinQr). New code is a FormDialog: Expires after 30, 7 or 90 days, Maximum uses 1 to 1,000, 40 by default (O-06). Stop is a danger confirm that gives focus back to New code. A class with no code offers Create code. DG-180 has the states the deck does not draw.
  • Class card: Name, Description, Schedule and Room; Save is aria-disabled until something changed and the name is not blank; a cleared Schedule or Room is sent as null; a save says "Saved. Students see the new name."
  • Assignments card: "{n} assignments · {open} open" (plural in en), View all to /teacher/assignments?classId={id}, up to five rows, open ones first, each opening its assignment; Closes shows from 560px of the card.
  • States: a skeleton; "Class not found" with the way back for a 404, which is not retried; any other failure a load error with Retry; the members' error, empty and no-match lines.
  • Keys: nineteen classDetail keys nothing reads were removed in their own commit.
  • Departures: DG-179 (members, the picker, a reader who cannot write, not found) and DG-180 (the join code card's undrawn states). DG-01 holds the code and the link.
  • Measured at 1280 and 360, light and dark, against the deck served locally: the heading sits where the deck draws it and a member row is 56px. The join code and Class cards are taller than the deck's by the actions and fields the plan adds. With the sidebar expanded the members show the deck's columns at every width probed: Name and Submitted at 360 (the table scrolls 48px inside its card, as T-R4.1a records), all five in one column at 1024 and 1280, and all five in the two-thirds card at 1440. 768 and the collapsed sidebar are VER-42's.

T-R4.43 — Settings: frame, Profile, Sign-in & security, Appearance

Depends on: T-R4.4, T-R4.5b, T-R4.2b, T-R4.2c, T-R4.7, T-R4.8, T-R4.9 Touches: web/src/features/settings/pages/teacher/TeacherSettingsPage.tsx (new; replaces web/src/features/auth/pages/AdminSettingsPage.tsx), web/src/features/settings/sections/{Profile,Security,Devices,Appearance}.tsx (new), web/src/features/auth/components/{SettingsSections,SettingsPage}.tsx, web/tests/units/auth/{settings-sections,change-password}.test.tsx and web/tests/units/i18n/language-section.test.tsx (all three import SettingsSections.tsx), web/tests/e2e/{settings,settings-navigation}.spec.ts Reuses: SettingsLayout, ProfileSection, PasswordSection, GoogleSection, ApiDocsSection, DirtyBar, FileDrop Size: M Done when:

  • Header "Settings" / "Your profile, sign-in and how Quizzivy works for you."; sections Profile, Sign-in & security, Notifications, Assignment defaults, Appearance, plus API reference for system.api_reference holders until R5; /teacher/settings/:section (profile, security, notifications, defaults, appearance, api); sections stay mounted
  • Profile: photo with Change photo ("Square PNG or JPG, at least 200 × 200") and Remove (confirm); Full name; Name students see ("Shown on tests and messages"); Email read-only ("Used to sign in"); Phone; Language (Tiếng Việt first); Time zone from a curated list; DirtyBar with Discard / Save changes and "Settings saved"
  • Sign-in & security: password change and Google link as today; Signed-in devices ("Mac · Chrome · Ho Chi Minh City · now", "This device", per-row Sign out); Sign out other devices (danger confirm "Every session except this one ends right away.")
  • Signed-in devices, on GET /auth/sessions (T-R4.9): device null reads "Unknown device" / "Thiết bị không xác định"; deviceKind picks the icon; location is shown as given. Every Sign out button and Sign out other devices are disabled while any revoke is pending, and the list refetches after the refresh settles: a revoke moves the caller's session epoch, so the next request 401s and recovers through the single-flight refresh, and a second revoke in flight would 401 a retried request and show the sign-in overlay. A 401 from the list's writes means no refresh cookie names a live session of the caller; the client's normal refresh fails and the sign-in overlay shows, which is intended. The 409 SESSION_IS_CURRENT message is the server's own
  • Appearance: Light / Dark / Match device (a split swatch, not a gradient, DG-34), applied at once and never dirty; Compact tables applied at once, untouched by Discard (DG-37)
  • States: skeleton, save error, devices error; deck check at the five widths, light and dark; keys vi first; the settings E2E specs green

As built (2026-10-10):

  • The time-zone line is split off to T-R4.43b (below). It changes lib/i18n/datetime, which every screen reads, and the assignment wizard, reopen, extend and overrides in features/assignments, where lane 3 works, so it gets its own pull request and review. This task builds the Time zone field and saves the zone; nothing else reads it differently yet.
  • The page. features/settings/pages/teacher/TeacherSettingsPage.tsx replaces AdminSettingsPage and auth/components/SettingsPage.tsx, both deleted, at /teacher/settings/:section? with the crumb "Settings" and width 1080. The sections are profile (also the bare address), security, appearance and api (for system.api_reference); any other section, preferences included, is replaced by the bare address. /admin/settings/* reaches it through the legacy redirect. Notifications and Assignment defaults are not listed until T-R4.44 (DG-157). The old .settings-panel animation is removed with the old frame.
  • Cards. components/shared/SettingsCard.tsx is the deck's card head (title, sentence, an action). PasswordSection, GoogleSection and ApiDocsSection stay in auth/components/SettingsSections.tsx, restyled as cards; ProfileSection and LanguageSection are retired there.
  • Profile (sections/Profile.tsx, ProfilePhoto.tsx, profileFormSchema.ts). One form on profileFormSchema (pinned to the whole PATCH /auth/me body); Save changes sends the changed fields through saveProfilePatch, so the language goes through the existing path that stores it for boot.js and the zone through applyUser. The photo uses uploadFile with the new method: "PUT" and replaceAccountUser (new in accountPreferences.ts), which accepts the returned user only for the actor that sent it. No preview object URL is made. The curated zones are in features/settings/timeZones.ts (DG-158).
  • Devices (sections/Devices.tsx, features/settings/api.ts). ["auth-sessions"]; one revoking state turns every Sign out off; after a revoke the list is invalidated and read through api(), whose single-flight refresh absorbs the epoch's 401 (DG-164).
  • Appearance (sections/Appearance.tsx). The theme goes through chooseAccountPreference; Compact tables is a new { compactTables } intent, previewed by lib/compactTables.ts and read by DataTable's dense default (DG-37); a table's own dense wins. The swatches have tokens of their own (--swatch-{light,dark}-{side,line}, --swatch-device).
  • Focus (VER-43, QA-43-1). No Settings control turns disabled during its own request: the swatches, Compact tables, the DirtyBar's buttons, Change password, Link Google, Open API reference and every Sign out take aria-disabled and ignore a press, so focus stays on them. "Disabled while any revoke is pending" above is met that way. DirtyBar takes a required returnFocus, a function that returns the element (unlike DialogShell's RefObject prop), and forgets a press whose save failed; Profile returns the field last edited. A signed-out row hands focus to the next row's Sign out, the previous one's, or Sign out other devices once the row has actually left the list, not when the buttons turn back on: in Chromium the list's new data renders after the pending state clears (#485 review). Unlinking Google hands focus to the "Not linked" line. A failed Sign out other devices shows its reason in the confirmation, which stays open. units/settings/focus.test.tsx and devices.test.tsx pin each, and a Chromium probe on a production build confirmed the device rows.
  • Leaving with unsaved changes asks first (F-57, the user, 2026-10-11). features/settings/UnsavedChangesGuard.tsx wraps the sections; a section reports its unsaved edits through useUnsavedSection (Profile does). One useBlocker for the page asks "Leave Settings without saving?" with the bar's "You have unsaved changes.", Leave and Stay; Stay returns focus to what started the navigation. Moving between sections is never blocked (they stay mounted), nor is /login, and a navigation judged once the URL has already left Settings goes through, so a Back within the departing page's last render is not reverted (F-35). A reload or a closed tab gets beforeunload while anything is unsaved. units/settings/leave.test.tsx.
  • A Google-only account is told the fact, not a step it cannot take (VER-43, the Tech Lead's option 3). POST /auth/change-password answers PASSWORD_REQUIRED without a current password and nothing lets the caller set a first password, so the shared "Set a password before unlinking" (which DG-107's student card keeps) named an action the screen cannot offer. The teacher card reads settings.googleOnlyTeacher, "Bạn chỉ đăng nhập bằng Google, nên chưa thể bỏ liên kết Google." / "You sign in with Google only, so Google cannot be unlinked yet.", and keeps Unlink focusable and switched off with that sentence as its description, as the student card does (the deck draws no Google card, DG-157). The fix is T-R5.3's set-password link.

Reopened in D4 (74d-d4-deck-update.md): T-D4.20 made the settings nav sticky from @min-[768px]/settings; T-D4.38 added the AI assistant section (/teacher/settings/ai).


T-R4.43b — The account's time zone in every teacher wall-clock input

Depends on: T-R4.43 Touches: web/src/lib/i18n/datetime.ts, web/src/features/assignments/{draft,wizardValues,studentRules}.ts, web/src/features/assignments/components/ (reopen, extend, overrides), web/tests/integration/{assignments,dashboard}/display-zone-mounted.test.tsx, web/tests/units/assignments/rules-preview.test.tsx Size: S Done when:

  • Every teacher wall-clock input (the assignment wizard, the edit form while it exists, reopen, extend and overrides) and the rules preview read and write in the account's zone; display-zone-mounted and rules-preview are rewritten to the user's zone; one zone source, not per-caller

T-R4.44 — Settings: Notifications and Assignment defaults

Depends on: T-R4.43, T-R4.10a, T-R4.45a Touches: web/src/features/settings/sections/{Notifications,AssignmentDefaults}.tsx (new), web/tests/units/settings/ (new) Reuses: SettingsLayout, DirtyBar, switch and segmented primitives Size: S Done when:

  • Notifications: "Choose where each update reaches you."; columns Update and In app (Email appears in R7); rows A student submits ("Grouped every 15 minutes"), An attempt is flagged, An assignment is about to close. New message and Weekly summary arrive in R7
  • Assignment defaults: Time limit 30/45/60/90 min, Shuffle questions, Show score after submitting, Block copy and paste, Require fullscreen; saved to preferences.assignmentDefaults
  • Both use the DirtyBar; Discard restores the saved values
  • Tests: a saved default reaches a new wizard; a switch off stops that notification (with T-R4.10b); keys vi first

As built (2026-10-10):

  • features/settings/sections/{Notifications,AssignmentDefaults}.tsx join the page after Sign-in & security, at /teacher/settings/notifications and /teacher/settings/defaults; Assignment defaults only for teaching.assignments.write (DG-157).
  • Notifications reuses useNotificationPreferences (T-R4.45a): the three teacher switches, In app only, and Save sends all five rows with the student's two as read (DG-165). The server's TestASwitchedOffEventWritesNothing (T-R4.10b) covers a switch that is off; the web test pins the payload.
  • Assignment defaults saves all five values through savePreferences({ assignmentDefaults }); the wizard already starts from them (emptyDraft, AssignmentWizardPage), so the wizard is unchanged and the test runs the saved values through emptyDraft. With nothing stored the card starts from emptyDraft() itself, not a copy of its §10.3 values, and a change undone by hand drops the draft, so a later change to the stored values never raises the bar.
  • Each section has its own DirtyBar; Discard restores the saved values.
  • Rebased on T-R4.43 as merged (2026-10-11). Each bar passes returnFocus, which returns the control last changed (a switch, or the chosen time limit), and each section reports its unsaved edits with useUnsavedSection, so leaving Settings asks first. Under T-35 the switches take aria-disabled while a save is in flight instead of disabled, and every control ignores a press until it settles, so a change made during the save is not dropped silently by the reset that follows it.

T-R4.45 — The bell in both consoles, and the student's settings

Depends on: T-R4.10a for T-R4.45a; T-R4.4, T-R4.10b, T-R4.7, T-R4.8 for T-R4.45b Touches: web/src/features/notifications/ (new: components/NotificationsPopover.tsx, useNotificationSummary.ts, kinds.ts, api.ts), web/src/app/modules.ts (notifications on), web/src/layouts/TeacherLayout.tsx, web/src/layouts/StudentLayout.tsx, R3's student Settings page, web/src/features/dashboard/NotificationsButton.tsx (removed), web/tests/units/notifications/ (new) Reuses: IconTile, useIdlePolling, Sheet on phones Size: M Delivered in parts: three, each one branch and one pull request T-R4.45a: the notifications web client (features/notifications/{api,kinds}.ts, useNotificationSummary). Depends on T-R4.10a. T-R4.41, T-R4.44 and T-R4.46 use its files and do not need the bell T-R4.45b: the bell in both shells and the student's settings. Depends on T-R4.4, T-R4.10b, T-R4.7, T-R4.8 and T-R4.45a T-R4.45c: the student's Change photo, reusing T-R4.43's photo dialog; depends on T-R4.43 As built (T-R4.45a: the notifications client):

  • Files and types: seven unmounted modules under web/src/features/notifications/ and nine support/test files under web/tests/units/notifications/. api.ts exports Notification, NotificationKind, NotificationEvent, NotificationParams, NotificationTarget, NotificationPreference and MySummary as their same-named components["schemas"] types; NotificationPage is operations["listNotifications"]["responses"][200]["content"]["application/json"]. No API, generated type, module flag, route or mounted surface changed.
  • Wire exports: notificationKeys has readonly tuples all: ["notifications"], list: ["notifications", "list"], summary: ["notifications", "summary"] and preferences: ["notifications", "preferences"]. Calls are listNotifications(params?: Readonly<{ before?: string; limit?: number }>, signal?: AbortSignal): Promise<NotificationPage>, markNotificationsRead(ids?: readonly string[]): Promise<void>, getMySummary(signal?: AbortSignal): Promise<MySummary>, getNotificationPreferences(signal?: AbortSignal): Promise<NotificationPreference[]> and updateNotificationPreferences(all: readonly NotificationPreference[]): Promise<NotificationPreference[]>. Absent list params default to {}; reads forward optional cancellation.
  • Mark read: absent IDs send {} to mark all; [] sends no request. More than 100 IDs travel sequentially in batches of at most 100, stopping on the first failure; 204 resolves void. useMarkNotificationsRead(): UseMutationResult<void, Error, readonly string[] | undefined, unknown> invalidates notificationKeys.all after either outcome, without optimistic changes.
  • View exports: NotificationTone = "neutral" | "info" | "success" | "warning" | "danger"; NotificationView = Readonly<{ icon: LucideIcon; tone: NotificationTone; text: string; to: string | null }>; KIND_LOOK: Record<NotificationKind, Readonly<{ icon: LucideIcon; tone: NotificationTone }>>; audienceOf(kind: NotificationKind): "teacher" | "student"; notificationPath(target: NotificationTarget | null): string | null; describeNotification(notification: Notification, t: TFunction, locale: Locale, options?: Readonly<{ canGrade?: boolean }>): NotificationView | null. An unknown kind or missing actual sentence input returns null; consumers skip it. Known zero counts and an empty-but-present class-name list remain valid.
  • Looks and sentences: waiting submitted papers use SquarePen/warning and “{toGrade} papers in {title} need grading”; ordinary hand-ins FileCheck/neutral and “{count} papers handed in for {title}”; flagged Flag/danger and “{studentName} left the test {focusLost} times”; closing Clock/warning and “{title} closes soon and {notSubmitted} students have not handed in”; joined UserPlus/neutral and “{studentName} joined {className}”; rotated RotateCw/info and “The join codes of {count} classes were replaced: {names}”; opened ClipboardList/info and “{title} is open and closes at {when}”; due soon Clock/warning and “{title} closes at {when}”; extended Clock/info and “{title} now closes at {when}”; ready CircleCheck/success and “Your result for {title} is ready”. Plain hand-in, closing, rotated, opened and extended variants are undrawn and reuse the decks' icons for those things.
  • **Words:**15 new notifications.kind keys after integrity and before pagination, Vietnamese first, with one/other in both languages for all five plural sentences. Names stay plain text; {when} uses the app's existing timezone formatter. The local sentence helper masks string values during translation and restores them in one callback pass, preserving literal braces, dollar syntax and opaque-token text while numeric counts still select the plural. The 15 fixed kind templates per locale must contain no reserved token representation; public guards hold that bounded contract. Class names join with comma-space and gain “…” only when count exceeds the supplied names. DG-129 records papers rather than answers, leaving the test rather than only fullscreen, and a ready result without a score.
  • Destinations: assignment→/teacher/assignments/:id, attempt→/teacher/attempts/:id, grading→/teacher/grading, classes→/teacher/classes, result→/app/attempts/:id/result, studentAssignment→/app/assignments/:id; missing/malformed UUIDs and unknown/null targets return null. Submitted with positive toGrade goes to Grading regardless of the merged target, settling T-R4.10a's question. Absent canGrade defaults true; false changes only that destination to the safe server target.T-R4.45b passes canGrade from useCan with the Grading nav item's own any-of rule. Attempt remains standalone until T-R4.25/46 provide assignment-sheet integration; no assignment ID is invented.
  • Summary: SUMMARY_POLL_MS = 60000 and useNotificationSummary(enabled?: boolean): { unread: number }, default enabled, read /me/summary through existing idle/resume polling. Polling stops while hidden or idle ten minutes; resume reads immediately. Unread is 0 while disabled, pending, refreshing or failed, including failure with prior cached data; disabled sends no request. This client is not mounted, so no bell-dot browser result is claimed.
  • List: useNotificationList(enabled?: boolean) defaults enabled and returns { items: Notification[]; hasMore: boolean; loadMore: () => void; loadingMore: boolean; isPending: boolean; isError: boolean; refetch: (options?: RefetchOptions) => Promise<QueryObserverResult<InfiniteData<NotificationPage, unknown>, Error>> }. Twenty items per cursor, no polling, no loaded cursor repeated, null ends the list; repeated loadMore calls coalesce. Disabled list/loadMore sends no request.
  • Preferences: TEACHER_EVENTS: readonly ["attempt.submitted", "attempt.flagged", "assignment.closing"] and STUDENT_EVENTS: readonly ["assignment.due_soon", "result.ready"]; withPreference(all: readonly NotificationPreference[], event: NotificationEvent, patch: Readonly<Partial<Pick<NotificationPreference, "inApp" | "email">>>): NotificationPreference[]; sameRows(a: readonly NotificationPreference[], b: readonly NotificationPreference[]): boolean. These retain all five rows, order, other console and unedited channels. useNotificationPreferences(): { query: UseQueryResult<NotificationPreference[], Error>; save: UseMutationResult<NotificationPreference[], Error, readonly NotificationPreference[], unknown> } caches the actual PUT answer. Email is stored/returned; no email is sent.
  • Consumers still to build: T-R4.44 uses the events, withPreference, sameRows and preferences hook for DirtyBar; T-R4.41 uses list/unread join_codes.rotated/read mutation; T-R4.46 edits KIND_LOOK. T-R4.45b mounts the bell/popover and student's settings, enables app/modules.ts, adds the /me/summary default MSW handler, invalidates the separate teacher nav-count query too, removes the old dashboard bell, and reads the teacher shell's /teacher/summary. Those surfaces retain their native keyboard/motion gates.
  • Evidence: the original foundation passed 246 tests across 18 files and 19 mutation targets, including one explicit type gate. The literal-value repair passed 268 tests across 19 files, three restored behavioral mutants and final 24-case sibling checks including two added template guards; these cover 270 distinct cases across the green runs. Actual typecheck and scoped lint/format pass. Independent source review, focused QA, exact-head CI and integration remain recorded gates. Future bell and settings Done-when checkboxes remain open.

Done when:

  • The bell shows a 7px dot only while something is unread (DG-82); the popover (340px, max 100vw − 24): "Notifications" and "Mark all read"; rows with a tone tile per kind, text from kind and params in the reader's locale, relative time, a lime unread dot; a click marks read and navigates (a hand-in with papers to grade goes to Grading, or to its assignment for a reader who cannot open Grading); "Load more"; empty "You're all caught up."; an error line
  • The teacher shell polls /teacher/summary, the student shell /me/summary, both every 60 s through useIdlePolling
  • Student Settings: Notifications with "Test due soon" and "Result ready" (Messages from teachers in R7, Daily practice reminder in R10); a server-saved Language; "Larger text in tests" saves to preferences.largerTestText, migrating R3's local value once (the last two were built by T-R4.7). The migration was built after the release review found it missing (M1, 2026-10-12): applyUser applies a theme, language or larger text that the account lacks and this browser stores as its own choice (features/auth/deviceChoices.ts; a value copied from an account is marked in quizzivy.preferenceMirror and never taken for one), and saves it once per sign-in in the ordered account lane, the preferences in one PATCH /me/preferences and the language in one PATCH /auth/me; a failed save stays on screen without a toast and is tried again at the next sign-in
  • (T-R4.45c) Profile gains Change photo
  • The student bell and the student's Notifications section are built here; R3 shipped neither. web/src/app/modules.ts turns the notifications module on, the one availability map R3's shell reads
  • Tests: dot only when unread, mark all read, the route for every kind, a student sees no teacher kind; a shell test that the bell appears only with the notifications flag on; deck check of both shells at the five widths, light and dark; keys vi first

As built (T-R4.45b: the bell and the student's settings):

  • Files: features/notifications/components/{NotificationBell,NotificationsPanel, StudentNotificationsSection}.tsx and relativeTime.ts; the bell mounts in TopBar (after the theme switch), in AdminLayout's header until R5 rebuilds that console, and in StudentLayout's header before the account button, each only while modules.notifications is on, which this turns on. features/dashboard/NotificationsButton.tsx and its three dashboard.* keys are gone.
  • Polling, one interval per shell: the teacher shells read the unread count from the /teacher/summary that useNavCounts already polls (it now returns unread); the student shell calls useNotificationSummary. The list loads only while the panel is open.
  • Amends T-R4.45a: useNotificationSummary keeps the last count while a later read is in flight, so the dot does not blink on every poll; it is still 0 while disabled, before the first read answers and after a read fails. useMarkNotificationsRead also invalidates dashboardKeys.summary, so the teacher dot follows a mark.
  • Frames: a 340px popover from 768px (the student's ends at the bar's edge, as drawn), the shared 380px Sheet below it (DG-149). Both return focus to the bell. A row that navigates puts focus on the shell's <main> once the navigation lands, unless something else holds it: the shells refocus <main> only when the route changes, and a row that changes only a parameter (/teacher/assignments/A to /B) would otherwise leave focus on <body> (QA-45b-1). The fix is in the bell, not in the shells' route rule, which deliberately keeps focus when a page moves between its own sections. A row marks itself read without holding the navigation; a failed mark is corrected by the next poll, and a failed "Mark all read" says so in a toast. A neutral kind's tile draws its icon in --muted-fg on --muted, as both decks do; IconTile's neutral tone (--fg) is unchanged. canGrade comes from reaches(user, "grading"), the sidebar's own rule (layouts/teacherNav.ts).
  • Mark all read: it sends no ids and the bell's audience, so the server marks every notification of this console's kinds and leaves the other console's (DG-170, below). The bell's count is of the same kinds, so "Mark all read" is active while a listed row is unread, with no exception for a dot lit by kinds the bell does not list.
  • Focus (QA-43-1, QA-47-1): "Mark all read", "Load more" and the two settings switches never take disabled while their own request is out, or once "Mark all read" has nothing left to do: they carry aria-disabled and their handlers ignore a press, so a focused control keeps focus instead of dropping it to <body>. When "Load more" answers, focus moves to the first row it added, or to the last row when the button has gone and nothing new is listed.
  • Student Settings: a Notifications section between Sign-in and Appearance at /app/settings/notifications; each switch sets in-app and email together (DG-82), saves at once, and puts the saved value back on a failure. The address goes to Profile while the module is off.
  • Split: "Change photo" moved to T-R4.45c, so the student's and the teacher's photo dialogs are one (T-R4.43's).
  • Departures: DG-149, DG-170. Tests: web/tests/units/notifications/{bell,shells,summary}.test.tsx, web/tests/units/student/{shell,settings}.test.tsx, web/tests/units/app/modules.test.ts.

As built, DG-170 (the server half: each bell counts, lists and clears only its own kinds).

  • The contract adds NotificationAudience (teacher, student) and an optional audience on getTeacherSummary, getMySummary and listNotifications (query) and on markNotificationsRead (body). An unknown value is refused by the schema (httpx.ValidateRequests), and the application refuses it again before the store (ErrUnknownAudience), so a caller that bypasses the handler cannot widen a read. No new operation, so permissions.golden and the isolation table are unchanged; the isolation suite still runs the four operations.
  • notifications/domain/audience.go holds the one table of kinds per audience (KindsOf, Kind.Audience). Every kind belongs to exactly one audience, and a test holds the table to the contract's enum and to the kinds the module defines, so a new kind without an audience fails CI.
  • The repository adds one kind = ANY($n::text[]) clause when an audience is given and nothing when it is not, so the statement of a caller that sends none is what it was. The audience applies to a marking by ids as to "mark all": an id of the other console's kind is skipped like another user's id. A plan test pins that the audience page is still read from notifications_user_recent_idx in its order with the kind as a filter (no sort), and the audience count from notifications_unread_idx. A page of one console's kinds reads past the other console's rows to fill itself: a cost bounded by that user's own rows, which PruneNotifications deletes after their retention, and met in practice only by an account that holds both consoles.
  • getTeacherSummary passes the audience to the notifications port through query.Nav.
  • What the web must call (a frontend lane's change): the teacher's bell and the shell's getTeacherSummary send audience=teacher; the student's bell and getMySummary send audience=student; both bells' listNotifications and markNotificationsRead (a row's read mark and "Mark all read") send the same. The mirror of the kinds table is web/src/features/notifications/kinds.ts (audienceOf). "Mark all read" then needs no "active while the dot is lit" exception, and DG-170's row in docs/design/gaps.md moves to built.
  • The web half (built). getTeacherSummary sends audience=teacher (both teacher shells' useNavCounts), and getMySummary(audience) takes it from useNotificationSummary, which the student shell calls with student. listNotifications and markNotificationsRead take the audience and send it on every request, each batch of 100 ids included; notificationKeys.list and .summary are per audience, so the two consoles never share a cache entry. NotificationsPanel passes its audience to useNotificationList and useMarkNotificationsRead, keeps its audienceOf filter, and no longer takes the bell's unread. Tests: web/tests/units/notifications/{api,bell,list,mark-read,summary,shells}, web/tests/units/student/shell.test.tsx.
  • Tests: notifications/domain/tests/audience_test.go, notifications/application/tests/reading_test.go, notifications/http/tests/handlers_test.go, dashboard/{application,http}/tests, core/router/tests/me_test.go, notifications/repositories/tests/{audience,plan}_test.go (integration) and server/tests/notifications_flow_test.go (e2e).

T-R4.46 — Integrity on the teacher side (D8)

Depends on: T-R4.23, T-R4.25, T-R4.45a Touches: web/src/features/integrity/tones.ts (new), web/src/features/integrity/components/Timeline.tsx, web/src/features/attempts/components/{Monitor,FocusLossCell}.tsx, web/src/features/dashboard/pages/teacher/TeacherDashboardPage.tsx, web/src/features/notifications/kinds.ts, web/tests/units/integrity/ Reuses: the integrity timeline and focus-loss cell Size: S Done when:

  • One tone map: the Flagged KPI and stat, the roster's "{n}×" with a flag icon and row highlight when flagged, the "Flagged" marker in Recent activity and the flagged notification all use the danger tokens
  • Timeline dots: info for the start, danger for focus loss with its duration ("Left fullscreen · 14 s"), success for submitted, border for autosave
  • The honest-limits text of spec §10.5 stays wherever rules are set
  • attempts/application/tests/events_test.go green and untouched; unit tests for the tone map; keys vi first

As built:

  • The tone map is features/integrity/tones.ts: FLAGGED (the danger tone, ink and soft classes) and TIMELINE_DOT, one dot class per mark (start, away, submitted, autosave, other), with timelineMark(kind) choosing it. The dashboard's KPI and Recent activity marker, the assignment's Flagged stat, the roster's row tone, the attempt sheet's Flagged marker and KIND_LOOK["attempt.flagged"] read FLAGGED.
  • The roster's Focus lost is FocusLossCell: "—" for none or unknown, otherwise "{n}×", muted, and in danger ink after a flag icon once the attempt is flagged. It replaces the warning badge the cell held, and the compact marker under the student's name draws the same cell wherever the Focus lost column is hidden (below a 700px table, DG-159), named by its sentence. A flagged row keeps --danger-soft on hover.
  • The compact timeline (the attempt sheet) runs from the start (info) to "Submitted" (success, from the attempt's submittedAt, only once it is handed in). Leaving the test (tab_hidden, window_blur, fullscreen_exit) and auto_submit are danger, with the duration on the label in seconds or minutes ("Left fullscreen · 14 s"); a lost connection, audio and every other kind keep a neutral dot. "Answers autosaved · while answering" (border) fills the space only when nothing else was recorded (DG-150). The full timeline is unchanged.
  • Honest limits: the wizard's Rules step keeps the deck's sentence beside the integrity rules and, under it, a closed "What it can and cannot see" disclosure with spec §10.5's full text (DG-151): only this tab losing focus is detected; a second device, a phone beside the laptop, a person in the room or a printed sheet is not seen; the signals are evidence for a conversation, not proof. wizard-rules.test.tsx pins both. The timeline keeps its own help under "How to read this". AssignmentFormPage, which carried §10.5 in full, is no longer routed (F-54).
  • Not here: the old assignments list (AssignmentsListPage) still draws its Flagged count in warning ink, and the review page's badge is warning; their rebuilds (T-R4.24, T-R4.47) take FLAGGED.

T-R4.47 — Attempt review page, restyled (DG-68)

Depends on: T-R4.46, T-R4.28 Touches: web/src/features/attempts/pages/teacher/AttemptReviewPage.tsx, web/src/features/integrity/components/Timeline.tsx (the full presentation's layout), web/src/features/attempts/components/GradeByQuestion.tsx (its head), web/src/features/attempts/components/{GradingCard,AnswerReview}.tsx (VER-47), web/src/hooks/useOverflowsX.ts (new), web/src/app/router.tsx (the route's handle), web/tests/units/attempts/{grading,flag-and-note}.test.tsx, web/tests/units/attempts/{review-focus,answer-review-true-false}.test.tsx (new), web/tests/units/integrity/timeline-layout.test.tsx (new), web/tests/units/app/legacy-teacher-redirect.test.tsx Reuses: AnswerReview, GradingCard, InterventionDialog, TeacherNoteCard, the integrity Timeline Size: S Done when:

  • Deck primitives only: crumb Assignments › {title} › {student}, the answers, extend / reset / void, flag, note, and the explicit Finish grading kept for re-grading; behaviour unchanged
  • Checked at 1280 and 360, light and dark; keys vi first

As built:

  • Shell: /teacher/attempts/:id declares a teacher handle (Assignments › {assignment} › {student}, 1320 wide), so it renders in TeacherLayout and follows the theme. The page names the assignment, linking to its detail, and the student with useCrumbs. An old /admin/attempts/{id} bookmark still redirects.
  • Head: PageHead with the student as the title, "Back to the assignment", the Graded pill and the Flagged pill (FLAGGED's danger tokens, replacing the warning badge) beside it, and the meta line (assignment · attempt n of m · submitted at) under it. The actions are those of the old bar: the score, the to-grade count, Flag or Remove flag, and Finish grading, which stays enabled only once nothing is pending and so still re-finishes after a re-grade.
  • Body: Paper and Integrity are the deck's underline tabs. The question rail is a card beside the answer from 1024px and above it below; it holds the squares, the to-grade note, Grade by question and the four figures. A save failure is a danger Callout. The answers, GradingCard, the model answer and the audio note keep their components. The note stays in the Integrity tab's TeacherNoteCard.
  • Extend, reset and void stay in the roster's row menu (DG-152): the deck draws no full attempt page, and its attempt sheet carries no action on the attempt.
  • The full timeline (layout only): the strip runs 2, 3 and 6 columns, the table and its aside stack below 1024px, and on a phone the table scrolls inside a region named by the card's title, which takes a Tab stop only while the table is wider than it (useOverflowsX, timeline-layout.test.tsx).
  • Keyboard (VER-47, QA-47-1): a save by Enter, Save & next or Skip moves focus to the next answer's points box, after GradingCard's remount by question, and never on the page's first mount. Flag and Remove flag are one button whose label, icon and tone change, and neither it nor Finish grading is disabled during its own request (aria-disabled instead), so focus stays on it; Finish grading stays enabled once the paper is graded, so focus stays there too (review-focus.test.tsx).
  • A true/false answer stored as a boolean marks the student's choice as single choice does, true naming the option at ordinal 0 and false the one at 1, as the server grades it (QA-47-2). An unknown attempt, or another teacher's, answers 404 and shows "This attempt was not found" without Retry.
  • Grade by question takes PageHead for its head, since the old one-line bar collapsed its title and ran its actions off a 360 screen in the teacher shell; the rest of the view is T-R4.28's.

T-R4.48 — Delete the old tree

Depends on: every screen task, including T-R4.57 and T-R4.63 to T-R4.66 Touches: web/src/layouts/AdminLayout.tsx, web/src/app/pages/AdminDashboardPage.tsx, web/src/features/assignments/pages/teacher/AssignmentFormPage.tsx, web/src/features/attempts/pages/teacher/{AssignmentAttemptsPage,GradingQueuePage}.tsx, web/src/features/attempts/components/GradeByQuestion.tsx, web/src/features/auth/pages/AdminSettingsPage.tsx, web/src/components/shared/{PageAside,SideColumn,PageHeader}.tsx, web/src/layouts/slots.ts, web/src/layouts/TeacherShell.tsx (its switch), web/src/features/assignments/components/{StudentPanel,ReopenMenu}.tsx, web/src/features/auth/components/{SettingsPage,SettingsSections}.tsx, web/src/features/imports/components/review/ReviewSummaryDialog.tsx, useForcedLightTheme in web/src/lib/theme.ts, web/src/hooks/useColumnWidth.ts, web/src/lib/i18n/locales/, the tests web/tests/units/ui/{page-aside,page-header}.test.tsx, web/tests/units/shared/side-column.test.tsx, web/tests/units/student/side-column.test.ts, web/tests/units/student/imports.test.ts (it asserts that the teacher's routes reach PageAside, SideColumn and useColumnWidth and that the three files exist), web/tests/units/auth/settings-api-reference.test.tsx, web/tests/units/lib/theme.test.ts, and the legacy pager (T-R4.1b): web/src/components/shared/Pager.tsx, web/src/components/ui/pagination.tsx, pageWindow and pageCountOf in web/src/lib/pagination.ts, the pagination.* keys and the Pager cases of web/tests/units/ui/pager.test.tsx, with MediaPage, TestsListPage and ImportsListPage moved to components/shared/data/Pager.tsx Size: S Done when:

  • Each file above is deleted once nothing imports it. PageAside, SideColumn, slots.ts and useColumnWidth.ts are the rebuilt question editor's settings column, and GradeByQuestion and SettingsSections are living parts of attempt review and Settings (R3 left a teacher-side user): they stay, and R5 revisits them beside T-R5.30
  • No page imports components/shared/Pager.tsx: Media, Tests and Imports use the deck's data/Pager (QA-F1-2), and T-R4.40, 41 and 42 build Students and Classes on it
  • Keys used only by deleted files are removed in their own commit
  • router-chunks.test.ts names no deleted path; pnpm lint, pnpm typecheck and pnpm build green

As built:

  • The switch: every leaf under /teacher declares a teacher handle since #504, so the router loads layouts/TeacherLayout directly. TeacherShell.tsx, AdminLayout.tsx and useForcedLightTheme (its only user) are deleted, and with them T-R4.4's two leftovers: skeletonFor takes only the path (the hand-off draws the teacher frame for every /teacher route) and BootSplash passes no handle; AccountMenu keeps only the deck's form (no deck or named props).
  • Deleted, nothing imported them: app/pages/AdminDashboardPage.tsx, assignments/pages/teacher/AssignmentFormPage.tsx (F-54), attempts/pages/teacher/ AssignmentAttemptsPage.tsx (a re-export of AssignmentPapersRedirect, which the router loads itself), assignments/components/StudentPanel.tsx, and what only the form used: StudentRulesPreview.tsx and TargetPickers.tsx. GradingQueuePage, AdminSettingsPage, SettingsPage, ReopenMenu and ReviewSummaryDialog were already gone. components/shared/ PageHeader.tsx goes once ImportsGate's "import is off" state draws PageHead (the deck's back link, inside DeckScale), and PageBarSlot with it.
  • Kept, still imported: PageAside, SideColumn, slots.ts (PageAsideSlot, PageRailSlot) and useColumnWidth: QuestionEditor draws its settings column with PageAside, and the group editor (GroupComposer) and the test builder (BuilderGroupPane, whose aside slot TestBuilderPage provides) render it. GradeByQuestion is part of AttemptReviewPage, and SettingsSections of Settings' Sign-in & security (sections/Security.tsx, TeacherSettingsPage). They go when their screens stop using them.
  • The legacy pager: components/shared/Pager.tsx, components/ui/pagination.tsx, and pageWindow, pageCountOf and PageSlot in lib/pagination.ts are deleted; pager.test keeps its usePage cases and pagination.test its parsePage, parsePageSize and pageRange ones.
  • Tests (T-41): each deleted subject's test goes (form-edit, target-pickers, page-header, the legacy Pager's and the forced-light hook's cases, the admin console's bell, the legacy account menu). A rule that still lives keeps a test on its living subject: teacher-shell.test pins TeacherLayout's own cases (the deck shell in the chosen theme, the title from the crumb or "Quizzivy", one sidebar across routes); rules-preview.test pins previewRules, which the wizard's aside reads; display-zone-mounted checks the preview through previewRules, and its "mounted dirty input stays fixed to HCM" case moved from the old form to the wizard at /teacher/assignments/:id/edit (the Schedule step's Closes picker, on a deck surface, with the same three checks; it fails if the field follows the display zone); papers.test routes the old address to AssignmentPapersRedirect; student/side-column.test's negative control loads the group editor, which still reaches SideColumn; gate.test follows the back link. With the switch gone, teacher-routes.test walks the router and requires a teacher handle on every /teacher leaf but the attempts redirect, which renders no page.
  • The canary: router-chunks.test's regexes name TeacherLayout instead of the deleted files, and its teacher pattern now also takes layouts/shell/ and teacherNav: T-R4.4 left them out because the splash loaded shell/handle.ts with the entry, which the hand-off no longer does. shell/contentWidth.ts stays out, because DataTable, StatStrip and ClassMembersCard share it. The assertions are unchanged.
  • Keys: 106 keys in both locales, read only by the deleted files (the old form's assignments.*, the old dashboard's dashboard.*, the old shell's nav.* and palette.open, pagination.*), in their own commit.
  • Left for later, by the Tech Lead's decision: DateTimeField's inherited (off-deck) branch goes in T-R5.30 with the other useDeckScale branches. The admin-* query keys and the file names that still say "admin" are renamed in one mechanical commit after v0.10.0 (the release checklist's hand-off to R5).

Part 5 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next


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

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