Plan 74 R4 Part 5 - SekiroKenjii/Quizzivy GitHub Wiki
Part 5 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next
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.
QuestionEditorPageis a deck surface, 1320px wide, whose routes carry the crumb Question bank › New question or Edit question.PageHeaddraws 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 showsQuestionUsageBanner: "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) andExplanationFieldare the blocks the builder'sBuilderQuestionEditor,QuestionEditor(still used by the group composer) and this page share; their placeholders moved toquestionEditor.promptPlaceholderandquestionEditor.explanationPlaceholder. -
Type control.
QuestionTypeControlis 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.
blockingIssuenames 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 andBlanksEditor, 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.
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:
-
GroupsListPageandGroupEditorPageuse deck primitives only (page header, table or cards,FormDialog,Sheet); behaviour unchanged -
group-authoring.spec.ts,group-authoring.live.spec.tsandbuilder-groups.live.spec.tsgreen - The group composer's starter question is created as rich text (content), not as the
Markdown starter prompt
features/question-groups/model.ts:68writes 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):
MaterialAssetDialoguses the sharedFileInputanduseMediaUpload;material-asset-limit.test.tsxstays green As built: -
The two pages render in
TeacherLayout.question-bank/groupsand its:idroute 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'scrumb). -
The list is
PageHeadwith New group,BankNavigationdrawn as the deck's segmented track (links, so each half opens its page), the deck's search, an Active | ArchivedSegmented, theDataTablewith selection, a row that opens the editor and a row menu (Duplicate, Archive or Restore and Delete permanently), the sameBulkActions, and the deck'sPager. 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
PageHeadwith a back link to the list, the save state under the title, Preview and Save; the conflict, archived and failure notes areCallouts (warning, neutral, danger and announced).GroupComposeris 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 andRichProseEditor'sstarter, opens empty under the placeholder until it is edited. -
R35-8, the dialog half.
MaterialAssetDialogtakes the sharedFileInputfor both kinds and uploads throughuseMediaUpload, shown byUploadStatus, onDialogShell.material-asset-limit.test.tsxkeeps its three cases and finds the shared input by its name; the refusal it reads is the shared wording.groups.uploadFailedis removed (own commit). -
Specs.
group-authoring.spec.tsandgroup-authoring.live.spec.tsfollow the back link, the row menu and the statusSegmented, and read a rich prompt by its text;builder-groups.live.spec.tsneeded no change.builder-group-diagnostics.test.tsxsets 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.
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 inapp/router.tsx, which now declares a teacher handle (crumbmedia.title, width 1320) so the page renders inTeacherLayoutand follows the theme, its row intests/units/app/teacher-routes.test.ts, themedianamespace of both locales, andtests/e2e/media-library.spec.ts(new, stubbed).router-chunks.test.tsis unedited: its admin pattern names all offeatures/media/, and the hook is not exported fromfeatures/media/index.ts. -
useMediaUpload({ onUploaded, kind?, send? })runs the pre-check (quickCheck: the kind's extensions, then its size;precheckadds an audio file's duration), the upload with its progress, and a refusal, failure or cancellation as a message. It draws nothing:UploadStatusdraws 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 sendsdefaultMaxPlaysand the replace dialogreplaceMedia. A new start supersedes the one in flight; unmounting aborts it, whereUploadPanellet it finish into an unmounted host. Nothing retries. -
The two hosts render
<input aria-label="Chọn tệp từ máy">andUploadStatusthemselves, so the live suite's selector holds.MaterialAssetDialog's audio path moved to the hook; its image path, with its own size check andgroups.imageLimitmessage, 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
quickCheckas field validation, then the full pre-check on submit. -
The page:
?tab=(audio,image,unused) and?q=live in the URL throughuseListFilters, a change resets the page, and the query sendskindorunused=truewith the debouncedq; the tab counts are the response's facets. 24 or 48 files a page (?size=) on the deck'sdata/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 whenevertotalis 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 fromformatStorage, 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'snowrap, 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
replaceMediaonce; 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 withusageCount > 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 isupdateMediawith 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 bydownload, so the file keeps its storage name. Follow-up (backend): aresponse-content-dispositionon 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:
useFileDropkeeps its window listeners bound for its lifetime and always cancels a drop that carries files;enabledgates 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:
readDurationsettles as unknown after 10 s (PROBE_TIMEOUT_MS) and revokes its object URL either way;cancelends the check as well as the upload, andUploadStatusoffers 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:
UploadStatusreads the deck tokens.
- R35-1:
-
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 carriesdata-media-menu={id}; the page records it when an item opens a dialog and passes it as the dialog'sreturnFocus, 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
cancelledstate, 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.
- 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
-
Finding (QA-35-1, not fixed here): the same menu-item-to-dialog pattern, with no
returnFocus, is on the rebuiltAssignmentDetailPage("Close early" from its row menu, and the Reopen menu's choices) and on the not yet rebuiltTestsListPage,ClassesListPage,ClassDetailPage,GroupsListPage,QuestionBankPageand the builder'sOutlineTree.Monitoralready passes its menu trigger asreturnFocus. -
Follow-up (R35-5, the DG-09 copy owner): a 409
MEDIA_REFERENCEDthat 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 27media.*keys that nothing reads any more. -
Tests:
upload-panel.test.tsxbecameuse-media-upload.test.tsxwith 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.tsxgoes through the menu;limit-strings.test.tscovers the four new limit sentences.media-library.spec.tschecks 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 theAudioPlayerpath (.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 -
useMediaUploadholds whatUploadPaneldid: the pre-check, the probe, the upload and its progress and failure.QuestionMediaFieldandMaterialAssetDialogupload through it with their behaviour unchanged, andupload-panel.test.tsxmoves to the hook with no case dropped -
audio-player.test.tsx(theAudioPlayercanary) green and untouched - States: skeleton; "No media yet" + Upload; error; deck check at the five widths, light and dark; keys vi first
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'sdata/Pager(QA-F1-2), which replaced the history's own size select; the pager shows whenevertotalis 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, notuseContentWidthAtLeastand not the deck's viewport arithmetic; the query container is a wrapper around the card, outside its 1px border, so the width queried ismain'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-pressedsemantics, not the deck'srole="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
getWordImportCapabilitiesreports - 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.tsxcovers 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.tsxcovers 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 withignoreDefaultArgs: ['--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.
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 fromrun.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'scharacters; 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.tsxandprocessing-poll.test.tsxcover 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.
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.tsxcovers 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.tsxcovers 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.tsxstays 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.tsxis gone, its header nowReviewCardand its bodyQuestionEditor. 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 sharedSplitPane(percent, 42 within 30 to 65, step 2, remembered underquizzivy.importReview.split); below it one pane, chosen by the toolbar's Source | Test, the other kept mounted andhiddenso 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 theimports.originandimports.severitycopy 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.tsxandreviewHarness.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).
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 columnsminmax(0,1fr) 360pxfrom 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/confirmunderImportsGate, as/reviewis, 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.ReviewSummaryDialogandimports.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), andfeatures/tests/index.tsexportingStudentPreview. -
Columns:
minmax(0,1fr) 360pxfrom 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:
useImportCommitkeeps its request per revision; it now takes the autosave flush as an optional argument and re-reads the review as well as the import onIMPORT_CONFLICTand onSTALE_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" isaria-disabledwhile 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.
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'sWI_detect.paste-scan.test.tsruns every case ofapi/testdata/pasted-text-counts.jsonand expects the numbers the Go test expects; each rule ofpaste.tsis 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 newuploadId; 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 (
useBlockerandbeforeunload). Switching the Source option does not ask, nor does the navigation after a start -
findings.tsgivesTEXT_MARKS_UNAVAILABLEits text - Tests:
paste-flow.test.tsxcovers 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) andupload-flow.test.tsx(its deferred-paste case now asserts the box).router.tsxgivesimports/newthe deck's trail, Tests › Imports › the page (QA-57-3); in paste modePageHead'scrumbrenames the last crumb.api.ts,findings.ts,fixtures.tsandi18n.test.tsare unedited: every new key is a literalt()call thati18n.test.tsalready resolves. -
paste.tsfollows the server's text path (recognition/exam.go,keys.go,inline_keys.goandbuild.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 inpaste.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, sopaste-import.spec.tsdeniesclipboard-readthrough CDP (Browser.setPermissionfor the context) to take the real refusal path.
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_SHAREDrefusal, 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 studentpeople.students.create; Reset password, single and bulk,people.students.reset_password; Move to class and Remove from classteaching.classes.write. A role without the key sees no such control. A unit test signs in the built-in Assistant, who holdspeople.students.readand 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.tsxreplacesStudentsListPage.tsx, onDataTable,FacetFilter,BulkActionsanddata/Pager, at the route'swidth: 1320. The record (?studentId, a row's click or View results, the results sheet until R9) isStudentDraweras aSheet. 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,resetStudentPasswordandresetStudentsPasswordsare called directly, not throughuseMutation; the answer lives in component state (StudentsPage'screated,ResetPasswordFlow'soutcome) untilResetSheet's Done or X sets it to null.ResetSheetignores Escape and a click outside, copies a password only on a click, and prints alone (data-print="credentials"inindex.css).TemporaryPasswordCardis gone. - The bulk reset refuses more than 40 (
BULK_RESET_LIMIT, the operation'smaxItems) before asking, and lists each failure with its reason;STUDENT_SHAREDreads 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;
AddMemberisON CONFLICT (class_id, user_id) DO NOTHINGandRemoveMemberanswers 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 thescope.alllist 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.readalone) 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).
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 onlistClasses' items (T-R4.18), never onegetJoinCodeper 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.rotatednotification is unread, a callout says the codes changed on {date} and to share the new ones; alegacycode, 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 fromClassesListPage.tsx, now inTeacherLayoutwith the crumb "Classes"),components/{ClassCard,EditClassDialog,JoinQr, ShowQrDialog}.tsx(new),NewClassDialog.tsx(rebuilt onFormDialog),ArchiveClassDialog.tsx(takes the card's fields and a return target),classFields.ts,joinLink.tsandjoinQrImage.ts(new) infeatures/classes/.JoinLinkisjoinLink.ts'sjoinLinkOfanduseCopyJoinLink, not a component.newClassSchema.tsand the old list's test are gone;classes-page.test.tsxreplaces it. -
Codes: the page asks
listClassesforwithCodesonly for a reader withteaching.classes.write, and its query hasgcTime: 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/notificationsis searched for an unreadjoin_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 whenevertotalis 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.
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(inTeacherLayout, the crumb Classes › {name}),components/ClassMembersCard.tsx(new), andJoinCodePanel.tsx,ClassSettingsCard.tsx,ClassAssignmentsCard.tsxandAddMemberDialog.tsxrebuilt, infeatures/classes/.api.tsgainsJoinCodeandgetJoinCode, whose 404 isnull.join-code-options.test.tsxis gone;class-detail.test.tsxandjoin-code-panel.test.tsxare rewritten.DirtyBaris not used: the Class card's own Save is what the deck draws. -
Head and layout:
PageHeadwith 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'stw) the page isminmax(0,2fr) minmax(0,1fr); the right column isrepeat(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:
DataTablewith 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'smemberscase 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 theselectedtone, 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
getJoinCodeonly by a reader withteaching.classes.write, under["admin-class", id, "code"]withgcTime: 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 hiddenJoinQr). New code is aFormDialog: 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-disableduntil something changed and the name is not blank; a cleared Schedule or Room is sent asnull; 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
classDetailkeys 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.
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_referenceholders 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):devicenull reads "Unknown device" / "Thiết bị không xác định";deviceKindpicks the icon;locationis 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 409SESSION_IS_CURRENTmessage 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 infeatures/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.tsxreplacesAdminSettingsPageandauth/components/SettingsPage.tsx, both deleted, at/teacher/settings/:section?with the crumb "Settings" and width 1080. The sections areprofile(also the bare address),security,appearanceandapi(forsystem.api_reference); any other section,preferencesincluded, 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-panelanimation is removed with the old frame. -
Cards.
components/shared/SettingsCard.tsxis the deck's card head (title, sentence, an action).PasswordSection,GoogleSectionandApiDocsSectionstay inauth/components/SettingsSections.tsx, restyled as cards;ProfileSectionandLanguageSectionare retired there. -
Profile (
sections/Profile.tsx,ProfilePhoto.tsx,profileFormSchema.ts). One form onprofileFormSchema(pinned to the wholePATCH /auth/mebody); Save changes sends the changed fields throughsaveProfilePatch, so the language goes through the existing path that stores it forboot.jsand the zone throughapplyUser. The photo usesuploadFilewith the newmethod: "PUT"andreplaceAccountUser(new inaccountPreferences.ts), which accepts the returned user only for the actor that sent it. No preview object URL is made. The curated zones are infeatures/settings/timeZones.ts(DG-158). -
Devices (
sections/Devices.tsx,features/settings/api.ts).["auth-sessions"]; onerevokingstate turns every Sign out off; after a revoke the list is invalidated and read throughapi(), whose single-flight refresh absorbs the epoch's 401 (DG-164). -
Appearance (
sections/Appearance.tsx). The theme goes throughchooseAccountPreference; Compact tables is a new{ compactTables }intent, previewed bylib/compactTables.tsand read byDataTable'sdensedefault (DG-37); a table's owndensewins. The swatches have tokens of their own (--swatch-{light,dark}-{side,line},--swatch-device). -
Focus (VER-43, QA-43-1). No Settings control turns
disabledduring its own request: the swatches, Compact tables, the DirtyBar's buttons, Change password, Link Google, Open API reference and every Sign out takearia-disabledand ignore a press, so focus stays on them. "Disabled while any revoke is pending" above is met that way.DirtyBartakes a requiredreturnFocus, a function that returns the element (unlikeDialogShell'sRefObjectprop), 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.tsxanddevices.test.tsxpin 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.tsxwraps the sections; a section reports its unsaved edits throughuseUnsavedSection(Profile does). OneuseBlockerfor 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 getsbeforeunloadwhile 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-passwordanswersPASSWORD_REQUIREDwithout 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 readssettings.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).
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-mountedandrules-previeware rewritten to the user's zone; one zone source, not per-caller
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}.tsxjoin the page after Sign-in & security, at/teacher/settings/notificationsand/teacher/settings/defaults; Assignment defaults only forteaching.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'sTestASwitchedOffEventWritesNothing(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 throughemptyDraft. With nothing stored the card starts fromemptyDraft()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 withuseUnsavedSection, so leaving Settings asks first. Under T-35 the switches takearia-disabledwhile a save is in flight instead ofdisabled, 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.
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 underweb/tests/units/notifications/.api.tsexportsNotification,NotificationKind,NotificationEvent,NotificationParams,NotificationTarget,NotificationPreferenceandMySummaryas their same-namedcomponents["schemas"]types;NotificationPageisoperations["listNotifications"]["responses"][200]["content"]["application/json"]. No API, generated type, module flag, route or mounted surface changed. -
Wire exports:
notificationKeyshas readonly tuplesall: ["notifications"],list: ["notifications", "list"],summary: ["notifications", "summary"]andpreferences: ["notifications", "preferences"]. Calls arelistNotifications(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[]>andupdateNotificationPreferences(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>invalidatesnotificationKeys.allafter 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.kindkeys afterintegrityand beforepagination, 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 fromuseCanwith 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 = 60000anduseNotificationSummary(enabled?: boolean): { unread: number }, default enabled, read/me/summarythrough 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"]andSTUDENT_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/summarydefault 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
kindandparamsin 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 throughuseIdlePolling - 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):applyUserapplies 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 inquizzivy.preferenceMirrorand never taken for one), and saves it once per sign-in in the ordered account lane, the preferences in onePATCH /me/preferencesand the language in onePATCH /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.tsturns 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}.tsxandrelativeTime.ts; the bell mounts inTopBar(after the theme switch), inAdminLayout's header until R5 rebuilds that console, and inStudentLayout's header before the account button, each only whilemodules.notificationsis on, which this turns on.features/dashboard/NotificationsButton.tsxand its threedashboard.*keys are gone. -
Polling, one interval per shell: the teacher shells read the unread count from the
/teacher/summarythatuseNavCountsalready polls (it now returnsunread); the student shell callsuseNotificationSummary. The list loads only while the panel is open. -
Amends T-R4.45a:
useNotificationSummarykeeps 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.useMarkNotificationsReadalso invalidatesdashboardKeys.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
Sheetbelow 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/Ato/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-fgon--muted, as both decks do;IconTile's neutral tone (--fg) is unchanged.canGradecomes fromreaches(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
disabledwhile their own request is out, or once "Mark all read" has nothing left to do: they carryaria-disabledand 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 optionalaudienceongetTeacherSummary,getMySummaryandlistNotifications(query) and onmarkNotificationsRead(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, sopermissions.goldenand the isolation table are unchanged; the isolation suite still runs the four operations. -
notifications/domain/audience.goholds 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 fromnotifications_user_recent_idxin its order with the kind as a filter (no sort), and the audience count fromnotifications_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, whichPruneNotificationsdeletes after their retention, and met in practice only by an account that holds both consoles. -
getTeacherSummarypasses the audience to the notifications port throughquery.Nav. -
What the web must call (a frontend lane's change): the teacher's bell and the shell's
getTeacherSummarysendaudience=teacher; the student's bell andgetMySummarysendaudience=student; both bells'listNotificationsandmarkNotificationsRead(a row's read mark and "Mark all read") send the same. The mirror of the kinds table isweb/src/features/notifications/kinds.ts(audienceOf). "Mark all read" then needs no "active while the dot is lit" exception, and DG-170's row indocs/design/gaps.mdmoves to built. -
The web half (built).
getTeacherSummarysendsaudience=teacher(both teacher shells'useNavCounts), andgetMySummary(audience)takes it fromuseNotificationSummary, which the student shell calls withstudent.listNotificationsandmarkNotificationsReadtake the audience and send it on every request, each batch of 100 ids included;notificationKeys.listand.summaryare per audience, so the two consoles never share a cache entry.NotificationsPanelpasses itsaudiencetouseNotificationListanduseMarkNotificationsRead, keeps itsaudienceOffilter, and no longer takes the bell'sunread. 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) andserver/tests/notifications_flow_test.go(e2e).
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.gogreen 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) andTIMELINE_DOT, one dot class per mark (start, away, submitted, autosave, other), withtimelineMark(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 andKIND_LOOK["attempt.flagged"]readFLAGGED. -
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-softon 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) andauto_submitare 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.tsxpins 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 iswarning; their rebuilds (T-R4.24, T-R4.47) takeFLAGGED.
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/:iddeclares a teacher handle (Assignments › {assignment} › {student}, 1320 wide), so it renders inTeacherLayoutand follows the theme. The page names the assignment, linking to its detail, and the student withuseCrumbs. An old/admin/attempts/{id}bookmark still redirects. -
Head:
PageHeadwith 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'sTeacherNoteCard. - 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-disabledinstead), 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,
truenaming the option at ordinal 0 andfalsethe 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
PageHeadfor 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.
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.tsanduseColumnWidth.tsare the rebuilt question editor's settings column, andGradeByQuestionandSettingsSectionsare 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'sdata/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.tsnames no deleted path;pnpm lint,pnpm typecheckandpnpm buildgreen
As built:
-
The switch: every leaf under
/teacherdeclares a teacher handle since #504, so the router loadslayouts/TeacherLayoutdirectly.TeacherShell.tsx,AdminLayout.tsxanduseForcedLightTheme(its only user) are deleted, and with them T-R4.4's two leftovers:skeletonFortakes only the path (the hand-off draws the teacher frame for every/teacherroute) andBootSplashpasses no handle;AccountMenukeeps only the deck's form (nodeckornamedprops). -
Deleted, nothing imported them:
app/pages/AdminDashboardPage.tsx,assignments/pages/teacher/AssignmentFormPage.tsx(F-54),attempts/pages/teacher/ AssignmentAttemptsPage.tsx(a re-export ofAssignmentPapersRedirect, which the router loads itself),assignments/components/StudentPanel.tsx, and what only the form used:StudentRulesPreview.tsxandTargetPickers.tsx.GradingQueuePage,AdminSettingsPage,SettingsPage,ReopenMenuandReviewSummaryDialogwere already gone.components/shared/ PageHeader.tsxgoes onceImportsGate's "import is off" state drawsPageHead(the deck's back link, insideDeckScale), andPageBarSlotwith it. -
Kept, still imported:
PageAside,SideColumn,slots.ts(PageAsideSlot,PageRailSlot) anduseColumnWidth:QuestionEditordraws its settings column withPageAside, and the group editor (GroupComposer) and the test builder (BuilderGroupPane, whose aside slotTestBuilderPageprovides) render it.GradeByQuestionis part ofAttemptReviewPage, andSettingsSectionsof 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, andpageWindow,pageCountOfandPageSlotinlib/pagination.tsare deleted;pager.testkeeps itsusePagecases andpagination.testitsparsePage,parsePageSizeandpageRangeones. -
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.testpins TeacherLayout's own cases (the deck shell in the chosen theme, the title from the crumb or "Quizzivy", one sidebar across routes);rules-preview.testpinspreviewRules, which the wizard's aside reads;display-zone-mountedchecks the preview throughpreviewRules, 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.testroutes the old address toAssignmentPapersRedirect;student/side-column.test's negative control loads the group editor, which still reachesSideColumn;gate.testfollows the back link. With the switch gone,teacher-routes.testwalks the router and requires a teacher handle on every/teacherleaf but the attempts redirect, which renders no page. -
The canary:
router-chunks.test's regexes nameTeacherLayoutinstead of the deleted files, and its teacher pattern now also takeslayouts/shell/andteacherNav: T-R4.4 left them out because the splash loadedshell/handle.tswith the entry, which the hand-off no longer does.shell/contentWidth.tsstays out, becauseDataTable,StatStripandClassMembersCardshare 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'sdashboard.*, the old shell'snav.*andpalette.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 otheruseDeckScalebranches. Theadmin-*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.