Plan 74d D4 Deck Update Part 2 - SekiroKenjii/Quizzivy GitHub Wiki

Part 2 of 5 of D4 — Deck update: the fourth export and v2 (v0.11.0) · Previous · Next

A. The import

T-D4.1 is the first pull request into work/deck-d4. It changes six of the deck's fifteen files against the third import: the five pages v2 touched and github.md (the fourth export's); the other nine are byte-identical to it. The manifest is the seventh file the pull request changes. No test reads a deck file (grep "design/deck" under web/ and scripts/ finds the token comment, the CI plan test and the check script only), so the import cannot turn a unit suite red. From its merge the engine on work/deck-d4 is behind the deck of record until section B ships, and the four R4 screens of section F and the AI surfaces of section G until those ship. That state never reaches develop.

T-D4.1 — Import the v2 export

Depends on: v0.10.0 released Touches: docs/design/deck/{Quizzivy Teacher.dc.html,Quizzivy Admin.dc.html,Quizzivy Student.dc.html,Quizzivy Landing.dc.html,Quizzivy Sign in.dc.html,github.md,MANIFEST.sha256}, docs/design/README.md, docs/design/gaps.md, scripts/check-design-deck.mjs, AGENTS.md ("Redesign in progress"), docs/plan/74d-d4-deck-update.md (the gap ids) Size: S Done when:

  • The seven files are copied byte for byte from origin/uiux/update-uiux-v2 @ 26b60795 (Thuong's v2 export, 2026-10-10, 10:44, which carries the fourth export of 2026-10-04, 17:31 in every file it did not touch), and hash to 184536f0… (Teacher), 8c043d26… (Admin), dc6d6077… (Student), 52196e86… (Landing), 15ab7e2b… (Sign in) and bf0598f3… (github.md); the full hashes are under "What the export changed" and "What the v2 export changed". The other seven files still hash to the manifest. screenshots/, uploads/ and .thumbnail are not imported. Neither UIUX branch updated the manifest; this task regenerates it (Thuong's standing instruction).
  • MANIFEST.sha256 is regenerated with README's command, and node scripts/check-design-deck.mjs prints "Deck OK: 15 files, 7 pages".
  • The check script reads <dc-import name="X"> as a reference to X.dc.html: the Teacher page now mounts the Student page three times (T 4978, 5756, 5850) and nothing checked it. A page that imports a missing page fails the script; the PR shows that failure once.
  • README, "Log": a fourth entry, dated the day of the import and naming the export's date, with the two hashes, the sizes (Student 123,460 to 173,426 bytes, Teacher 781,532 to 794,309) and the screens: Student take test (one data-driven screen: sections rail, stimulus pane for a passage, a cloze passage or a recording, the phone drawer, the grid button and its sheet, the gap card, matching, the inline blank, the "Student view" chip); Teacher builder ("Shared content" in the group menu and under the group header, its 560px dialog), and the three previews that now embed the Student page. A fifth entry, dated the same day, names the v2 export (2026-10-10, 10:44; 26b60795), its five pages with the hashes and sizes of "What the v2 export changed", and the screens: the AI assistant (the panel and its five kinds, "Suggest a score", "Explain results with AI", "Write with AI" and "Suggest wrong options", "Read with AI", Settings › AI assistant, Admin › AI credits); the assignment detail's progress card; the grading pane's heights; the join-code card; sticky side columns and the collapsed sidebar's separator; the Attendance page and the class-detail Attendance tab; the Audit log's search and date range; the word list's progress card; and the persona renames.
  • README, "Reading a page": the Teacher page mounts the Student page through dc-import with the props exam and dark; on the Student page renderVals() is renderValsBase() overlaid by examVals(), which holds every take-test rule.
  • README, "Prototype chrome never ships" and the demo-data paragraphs gain: previewExam, builderPreviewVals, sharedVals' pickFile (always "part-1-asking-the-way.mp3") and +plays || 99; the Student fixtures EXAMS, EX_RAW, EX_GROUP, EX_MATCH, EX_UL, EX_SIGN, AUD_LEN, MAP_CELLS, including their answer keys (the seventh tuple entry and ans), which never reach a student payload; the 45:00 preview timer; the two toasts after Submit for s2 and s9; the dead branches hasPaneTabs, dotsMode, hasRef and isSign; the unread fixture fields q.orig, q.stem, printed, multiSec, pauses and examBold (S 1470, on q12); on the Teacher page the script values no markup reads any more: wi.preview, wi.pvOptCols (T 7427-7428), td.preview, td.pvFoot ("Showing 5 of … questions") and td.pvOptCols (T 7508-7509). And three slips of the prototype a reader must not copy: the arrow keys set the open question without going through go() (S 1616 against 1639), so they neither close the drawer or the sheet nor pause a recording when the section changes; Esc clears the old sheet state, not exSheet (S 1530); in a preview the header still says "All answers saved" and "Saving…" (S 961-963, saved() in exPick, S 1617). From v2: the AI plumbing (aiAsk calling a browser-side model with inline prompts, the AI_*_DEMO arrays shown when it fails, AI_USE0 and aiSpend faking usage, AI_LIMIT 50,000, the per-kind costs, the 3.2s minimum run), the Admin fixtures AI_POOL, AI_DAY, AI_DAYS, AI_TEACHERS and AI_REQ0, the attendance fixtures ATT_TODAY, ATT_ROOM and ATT_SES, and the fixture persona (Giàu Bùi, Thường Võ, the demo accounts).
  • README, "Not imported": "ten red-pen markups" becomes eleven, with one new row: "Student take test, a cloze: the gap's options cut in one row and the footer's count cut | Options wrap in a grid of 132px minimum; the count gives way to a key hint when the rail shows, and is a short form otherwise". Under the table, the two defects the drawing shows and the designer did not mark, both fixed in the export: the rail's "Grammar and vocabulary" overlapping its own second line (S 1011), and the header's "All answers saved" on two lines (the label is now white-space: nowrap and the leave button flex: none, S 952, 963; the product already truncates the line and pins the button).
  • README gains "Defaults taken with the fourth import (DG-182 to DG-195)", one line per row below, and "Defaults taken with v2 (DG-196 to DG-202)".
  • gaps.md, existing rows (v2): - DG-01: Answered, 2026-10-10: the class-detail card shows the full code, the link, the QR and Copy code / Copy link / Download QR, and the New-code dialog says "Share the code, the link or the QR with your students."; the "Meanwhile" rule is deleted (T-D4.23 builds the card as drawn) - DG-22, DG-71, DG-106: a note that the deck's teacher is now "Giàu Bùi" with no honorific; the rules do not change - DG-63: the question editor's AI panel says "up to 6 options"; the cap stays 8 - DG-100: the testimonials now name the owner as "Centre manager"; placeholders still stand until consent - DG-36: the AI panel joins the list of dialogs with no Esc, no focus trap and no return
  • gaps.md, existing rows: - DG-80: the row's first sentence becomes "Since the fourth import the deck draws a passage, a cloze passage and a recording as shared content, the sections navigator (a rail and a sheet), fill in the blank, an image above the prompt and the phone drawer." Its "Meanwhile" keeps every frame of inventory row S-35 and drops, when the D4 task for it ships and not before: the place of a shared recording (T-D4.8; its scope and sync lines, a group with several recordings and a material that mixes text and audio stay listed), "a fill-in with its blanks" (T-D4.6), "gaps that lead to their question" (T-D4.10), and the whole "Footer and sheet" sentence but "a sealed paper's footer" and "the F key" (T-D4.4) - DG-60: a note that the Student page now draws TFNG, matching, a two-pick multiple choice and the word limit; essay is still undrawn - DG-63: "MP3, M4A or WAV up to 25 MB" appears a third time (T 1700) - DG-68: the builder now draws a group's passage and recording; the bank's group screens are still undrawn - DG-69: a third drawing of "Plays", a select with "3 plays" (T 1714-1719) - DG-111: its "Students can pause is not built" default is reopened by DG-183 - DG-113: the Student page's matching differs from R6's plan (DG-194) - DG-115: "The Student preview dialog was not redrawn" is answered (DG-188) - DG-116, DG-36: the Shared content dialog and the preview overlay join the list of dialogs with no name, no Esc and no focus trap
  • gaps.md, new rows, numbered from the first id free in the file on the day of the pull request. DG-182 goes in "Decisions that override the current drawings" only if the owner confirms Q3; until then all fourteen are Open, with these "Meanwhile" texts: - DG-182 Student › Take test, a recording at its play limit. The deck locks it ("No plays left", lock icon, "You have used all {N} plays"). Meanwhile: spec §11.4 stands; the button stays live, the status reads "You have used all {N} plays." with the product's "You can keep listening. Additional plays are recorded for your teacher to review." (T-D4.8). Please redraw the state, or Thuong changes §11.4. - DG-183 "Students can pause" (Teacher: question media and Shared content; Student: the inert button and two status lines). Meanwhile: one boolean, allowPause, default on, on a question's audio and on a group's recording (T-D4.13, T-D4.9, T-D4.15); the intro fixture's "you can pause once" is read as a note, not a count. - DG-184 A cloze is one item with n gaps on the Student page and one single-choice question per gap in the builder. The deck of record's import preview drew the product's model on a student-eye surface (a passage with gap chips and a member "23.1 Choose the word for gap 23.1." with its own options); this export removed it (T 4975-4980) and left its data in the script (T 7428). Meanwhile: one question per gap, each with its own number, square, points and result row; the engine shows a run of them as one screen, with the open member's own prompt and worth in the gap card, and a member that carries media is its own stop (T-D4.10). - DG-185 Numbers and counts: "Question 23 of 31" against 35; a matching numbered "1–5" counts five, another numbered "31" counts one. Meanwhile: every question has one number; the cloze screen reads "Questions 23–27 of {total}", which is 34 on the fixture paper T-D4.3 builds (s9 without its matching) and 35 once R6 adds the matching. - DG-186 The builder's group now carries shared content. Meanwhile: the deck's group is the product's section; shared content belongs to a shared-context group inside it, whose row carries the line and the menu item (T-D4.2a, T-D4.14). - DG-187 The Shared content dialog against the product's group: a plain textarea, one passage or one recording, "[gap n]" appended at the end, any question as a target, one question on two gaps, unbound gaps saved, "MP3, M4A or WAV up to 25 MB", no "Show transcript after submitting" and no "Allow skipping ahead", no title for a recording. Meanwhile: the content editor, whose toolbar holds the dialog's only "Insert gap" (T-R4.63 puts it there for the document profile; the deck's separate 30px button is not built, so the dialog never has two), a gap at the caret, the product's targets and rules, DG-63's limits, both switches kept; a group holding more than the dialog shows opens the full pane; the recording's material is named after the file, a name the student never sees. The deck's sentence "Students see it on the left while they answer." (T 7029) is reworded in English to "Students see it beside the questions while they answer.", because a phone shows a drawer, not a left pane (T-D4.14). - DG-188 The three previews embed the take-test screen, answerable, with a running 45:00 timer. Meanwhile: T-D4.2c builds the bar and the frame around R4's read-only preview; T-D4.16 puts the engine in the frame with no saving, no integrity record and no play counting. A test has no time limit and no integrity policy: both belong to the assignment (AssignmentInput.durationMinutes, IntegrityPolicy), and previewTest, the builder draft and the review draft carry neither. So, against the deck: the timer pill is a placeholder of the same size reading "––:––", named "The time limit is set when the test is assigned"; the save line reads "Answers here are not saved." where the deck still says "All answers saved"; pasting is allowed and nothing is counted. - DG-189 Student frames keyed by fixture ids, with no model: the sign card, the 60px word tiles, the image caption, the per-question hint, paragraph letters. Meanwhile: none is built; a notice is prompt content or an image, an underline is option content. - DG-190 The phone drawer draws no Esc, no focus move, no inert, no reduced-motion form and no rule for how long the tip is remembered; on a phone the recording is out of sight while the student answers. The closed drawer is aria-hidden (S 1041, 1700), which hides it from a screen reader and leaves its gap buttons in the Tab order. Meanwhile: T-D4.7's additions, with inert in place of aria-hidden. - DG-191 Navigation: the rail comes and goes between questions from 900 to 1179; the key hint names "A–D" on every question and omits E and F; the sheet has no title; the sheet's 42px squares, the rail's 34px, the 30px handle and the 38px blank are under the 44px floor below 1024; the rail prints the part of a section title after " · "; the arrow keys bypass go(), so in the deck they do not close the drawer or the sheet and do not pause a recording, and Esc does not close the sheet. Meanwhile: T-D4.4's rules; every move, by key or by click, goes through one function. - DG-192 Section instructions show above every question on the Student page while the builder says "above the group’s first question"; a group's own instructions are not drawn. Meanwhile: every question (T-D4.5); the builder's sentence follows. - DG-193 What spends a play, and unlimited plays ("Play 1 of 99"). Meanwhile: a start with no play in progress spends one, a resume none; a play in progress is remembered while the tab lives, and a start whose place cannot be put back counts as a play; leaving a group pauses its recording however the student left; unlimited reads "Play {n}" (T-D4.8). - DG-194 Matching on the Student page: chips per row, three key styles, the nouns "pick the {pick} for each {what}", 38px chips. Meanwhile: R6 (T-R6.10 as corrected by T-D4.19). - DG-195 The sync that produced this export read two migrations (github.md). Please read api/openapi.yaml (QuestionGroup, StudentGroup, AudioPolicy), web/src/features/take-test/** and web/src/features/question-groups/** at the next sync, and add them to the Screen map. - DG-196 Admin › AI credits, the pool's rules. The deck offers "When the centre pool runs out: Pause AI for everyone | Allow 10% extra", and says the extra and added credits are "billed on your next invoice". Meanwhile: D21 (the pool is never overspent without an Admin's approval) and the spec's non-goal on payments: the rule has one value, "Pause AI for everyone"; "Add credits" is the approval and records the approver; the dialog reads "Added to the centre pool right away. The extra is paid for outside Quizzivy." Please redraw the rule and the two sentences. - DG-197 Teacher › "Make a variant" and "Clean up the question bank". The deck saves a variant "as a draft version" of the test, and merges two bank questions so that "tests that used either question now share one copy". Meanwhile: a test has one draft, so a variant is a new draft test named "{title} · Variant", owned by the teacher, with "Open the variant" where the deck says "Review it in Version history"; a near-duplicate card offers "Open" for each question and "Keep both", and nothing merges (a published version holds frozen copies; a merge would need an operation of its own). Tag suggestions apply through the bulk tag write. - DG-198 Teacher › Class detail › Attendance (R8). The footer says "Students see their own attendance." and the save dialog "They count as absent until you mark them." Meanwhile: R8's decisions stand (T-R8.6: no student operation in R8; DG-92: unmarked is a status outside the rate); the two sentences are not taken until R8 decides. - DG-199 Admin › Roles & permissions. The matrix draws no row for the AI, while the product adds two grantable keys. Meanwhile: "Use AI features" in Content (Admin and Teacher on) and "Manage AI credits" in System (Admin on), drawn in the matrix's own style. Please draw the two rows. - DG-200 Teacher › Settings › AI assistant. The Admin page lists teachers' requests for more credits ("{name} asked for {n} more credits"), and the teacher side draws no way to ask. Meanwhile: "Ask for more credits" under the usage card (amount and reason, as the Admin card shows them), shown while the rule allows requests. Please draw it. - DG-201 Teacher › the AI panel's states. Not drawn: the panel with the provider off (the entry points are hidden; the Settings section says "AI is not turned on for this school"), a teacher who is paused or out of credits (the deck says "AI buttons stay visible but can’t be used": a disabled button with the reason as its tooltip, and the sentence "Your allowance is used up. Ask for more in Settings." in the panel's footer), a refusal or a timeout (the warning callout, with "Try again", never sample content), a suggestion that fails validation, and Esc, a focus trap and focus return (DG-36). Meanwhile: those states, from deck parts. - DG-202 Teacher › "Suggest a score", "Check this test", "Generate questions" against R6. The deck scores against the four IELTS criteria, offers "True / False / Not given", and fixes "Change to Not given", "Add word limit" and a matching heading. Meanwhile: D4 suggests a score in the grading card's own set (0 to max in 0.5 steps); the band form, the TFNG type and the word-limit fix join in R6 (T-R6.10, T-R6.11).
  • gaps.md's preface gains the paragraph for DG-182 to DG-195, in the manner of the one for DG-108 to DG-116, and one for DG-196 to DG-202 (v2, 2026-10-10).
  • This file: every DG+n label is replaced by the id its row took, in a commit that holds nothing else. (DG+1 to DG+21 became DG-182 to DG-202.)
  • AGENTS.md, "Redesign in progress": the bullet that says the design team's exports are not the deck of record is replaced by "Since the fourth import (D4, T-D4.1) the deck of record is the design team's v2 export of 2026-10-10, 10:44, which carries the fourth export of 2026-10-04. D4 (docs/plan/74d-d4-deck-update.md) rebuilds the take-test engine, the builder's shared content and the three previews to it, and builds the AI assistant it draws."
  • The PR says which screens changed. No file under web/ or server/ changes. The pull request targets work/deck-d4: develop keeps the third import until D4 merges.

As built: the six changed files were copied with git show origin/uiux/update-uiux-v2:<path> and hash to the values above. The v2 branch holds sixteen files under docs/design/, none of them screenshots/, uploads/ or .thumbnail, and the manifest is regenerated for the same fifteen paths. Against the third import exactly six files changed, so with the manifest the import touches seven; the plan's earlier "eight" added the fourth export's three files to v2's five without noticing that the Student and Teacher pages are in both lists. The check script reads <dc-import name="X"> through a third loop in localReferences; a Teacher page that imports a page nobody ships fails with "references Quizzivy Nowhere.dc.html, which is not in the deck", where the previous script printed "Deck OK". gaps.md takes DG-182 to DG-195 (the fourth export) and DG-196 to DG-202 (v2) in the sections Take test and student, Teaching, Roles, users and the Admin console, Foundations and accessibility, and Collaboration, schedule, insights, learn; DG-01 is Answered. README's log names the two imports as its fourth and fifth entries. No file under web/ or server/ changed.

T-D4.19 — R5 to R10 plan corrections

Depends on: T-D4.1 Touches: docs/plan/75-r5.md, docs/plan/76-r6.md, docs/plan/77-r7.md, docs/plan/78-r8.md, docs/plan/80-r10.md Size: S Done when (v2; the one-line head notes are on the branch docs/d4-deck-v2-plan already, so this task completes each into the task's own bullets):

  • 75-r5.md: T-R5.12 range becomes today | 7d | 30d | all | custom with from and to dates (inclusive, in the viewer's zone) for custom, and q matches the actor's name, the action's sentence and the entity's label; T-R5.26 draws the v2 filter row (the live search with a clear button and aria-label="Search audit log", the Date range menu with Custom range… and its From / To dialog, Reset, "No entries match these filters."), and drops "a range select … in place of the deck's static date button"; T-R5.20 adds "AI credits" (sparkles, ai.credits.manage, the request badge from GET /admin/ai/credits) first under System; T-R5.18 and T-R5.21 add the "AI credits" meter row and the attention item; T-R5.28a notes the sticky nav; T-R5.28b adds the row to Plan & usage; a new T-R5.35 — AI credits (M) draws the page of inventory row A-05 on D4's /admin/ai/* operations, with DG-196's one rule.
  • 76-r6.md: T-R6.10 gains "the AI's Generate offers "True / False / Not given" and its Check applies "Change to Not given" and "Add word limit" once the types exist (DG-202)"; T-R6.11 gains "Suggest a score takes the band form: the four criteria as reasons and the band as the score (DG-202)".
  • 77-r7.md: T-R7.23 gains the kinds ai.allowance_warning, ai.credit_request and ai.credit_request.decided in the Email column.
  • 78-r8.md: T-R8.12 describes v2's two screens (inventory rows V-17 and V-18): the Attendance page as the cross-class list of sessions in three cards, and the register as the Attendance tab of Class detail with the session picker, search, the progress row and its filters, selection and bulk marks, notes, the sticky save bar and the "not marked" confirm; T-R8.6 gains a note on DG-198 (no student operation; unmarked stays).
  • 80-r10.md: T-R10.12 draws the word list's progress card with its legend filters in place of the four tiles, and adds "Generate from text" opening the AI panel's vocab kind (a POST /teacher/ai/words/generate operation under ai.use, metered as section G describes); T-R10.13 notes the sticky asides. Done when (fourth export, unchanged):
  • 76-r6.md, the head. Old: "The student engine asks students to "Choose two" and counts words against a limit." New: "Since the fourth export (2026-10-04) the Student page draws True / False / Not given, matching, a multiple choice capped at two picks and a word limit on the take-test screen (DG-60, DG-194)." Old: "cloze groups (question groups, unchanged)". New: "cloze groups (question groups; the engine shows a run of gap-bound members as one stop, T-D4.10)".
  • T-R6.10, TFNG bullet. Old: "under the header "True, false or not given"". New: "with the type label "True / False / Not given"".
  • T-R6.10, "Choose N". Old: "the header reads "Choose two", with the number in words in both locales". New: "the type label stays "Multiple choice"; the hint line reads "Choose two answers.", with the number in words in both locales; one pick of two is "Partly" in the rail and the sheet (progress, T-D4.4)".
  • T-R6.10, Matching. Old: "the options are listed once with their roman numerals, then one row per lettered item with an option select". New: "one card per item (1.5px border, primary once matched, radius 11) with its key, its text and a radiogroup of chips, one per option key (38 by 38px from 1024, 44px below it, radius 9); a chip used by another item is at 50% opacity, choosing it moves it, and tapping the chosen chip clears it; above the cards "{k} of {n} matched"; when the options have text, the question has no shared content and the engine is at least 1000 wide, a 220px card lists the options once, each with the item that uses it; keys are shown as authored (DG-194); a matching is one question, one number and one square, "Partly" until every item is matched".
  • T-R6.10, last bullet. Old: "TFNG, "Choose two" and the word counter are compared with the deck's fixture paper. Matching and essay frames are listed for DG-80 and DG-60." New: "TFNG, the two-pick multiple choice, the word counter and matching are compared with the deck's papers s1, s2 and s9. The essay frame is listed for DG-80 and DG-60."
  • T-R6.10 gains: "progress() (T-D4.4) follows T-R6.6's rules for the new types; the Submit dialog's sentence becomes the deck's "Unanswered gaps, matches and questions score zero." once matching exists."
  • T-R6.9b. Old: "The draft preview renders the new types through the engine's components." New: "The preview-mode engine (T-D4.16) renders the new types."
  • T-R6.5 gains: "the projection carries each matching item's and option's key as authored."
  • 76-r6.md, Open items: "True / false answers already marked 0" is removed (fixed: attempts/domain/grading.go:100-110 reads optionIds). Added: "Matching on the Student page (DG-194): three key styles and the nouns "pick the {pick} for each {what}" have no model; default, keys as authored and no nouns." and "A per-question hint line and an image caption (DG-189) have no field; default, not built."
  • 77-r7.md, T-R7.18: Touches gains SharedContentDialog.tsx, and its read-only bullet names the dialog for a Can-use recipient. O-R7.7 notes that "From Media" in that dialog is a third place that opens the media picker.

As built: the head notes on 75-r5.md, 77-r7.md, 78-r8.md and 80-r10.md (the docs/d4-deck-v2-plan branch) are folded into the tasks' own bullets and removed; 78-r8.md's T-R8.12 is rewritten to v2's two screens. Where a bullet needed a fact the deck does not give, this task wrote the default and the lead should read it:

  • Server reads the v2 screens need. T-R5.12's q matches the action's sentence on the server, so domain/actions.go holds each action's vi and en sentence and catalog_test.go keeps them equal to the locale files. T-R5.18's usage read gains aiCredits and its attention list ai_credit_requests, through a port typed as T-D4.31's credits query. T-R8.6's sessions list gains since, marked, room, studentCount, counts and notMarked, and T-R8.2's marks table a nullable note. T-R10.4's stats gain bands and its words list band, for the word list's progress card.
  • Rules. A note needs a status (422 otherwise) and is never copied into the audit row; the Attendance page's "Today" card lists started sessions only (O-R8.6); the AI kinds' emails have no recipient switch (T-R7.23); T-R10.12 adds the generateWords operation to the ai module, which section G prices but T-D4.32 does not list.
  • Stale text outside the list. 76-r6.md said true / false answers score 0; the grader has read optionIds since grading.go:100-110, so the design note and the release note say the fix is made.

Also waiting for a later release, with no task here: the sign card, the word tiles, the image caption and the per-question hint (DG-189, with DG-109's inline media in R6 if at all); a transcript on the student's result as a drawn frame (DG-106, unchanged).


B. The student engine, brought level with the fourth import

One chain, because every task but T-D4.6 and T-D4.11 edits Paper in TakeTestPage.tsx: T-D4.3, T-D4.4, T-D4.5, T-D4.7, T-D4.8, T-D4.9, T-D4.10, then T-D4.12. T-D4.6 and T-D4.11 edit QuestionBody.tsx and run beside the chain after T-D4.5.

Rules for every task in this section (AGENTS.md, "High-risk areas"):

  • The five canaries and pnpm test:unit tests/units/take-test tests/units/integrity tests/units/media run on the branch point and on the last commit, both runs in the PR.
  • Never edited: web/tests/units/media/audio-player.test.tsx, server/internal/modules/attempts/application/tests/events_test.go, web/tests/units/api/client.refresh.test.ts, publish_snapshot_test.go, web/tests/integration/router-chunks.test.ts.
  • Frozen unless a task says otherwise: take-test/{store,draft,strandedDraft,heldSession,groupPlayback, groupPlaybackDraft,saveStatus,openQuestion}.ts, take-test/useLeave.ts, features/integrity/**, the four answer shapes of the contract (choice, true_false, fill_blank, text; api/openapi.yaml 2724-2737: a draft or an older attempt may still hold true_false with a boolean, which answered.ts:18-19 and grading.go read, and draft-compat.test.ts is the proof it survives), answered()'s boolean rule, the flags key sessionStorage['quizzivy.flags.<attemptId>'], the held-session key sessionStorage['quizzivy.session.<attemptId>'], written by getAttempt, startOrResumeAttempt and continueAttempt and named on every attempt read (#337), the draft key quizzivy.answer-draft.<attemptId>, and the open-question key sessionStorage['quizzivy.open-question.<attemptId>'], which holds a question's id and is written from TakeTestPage on every move and read on load (#320, on develop since 49c9f0e1). Their suites pass untouched: open-question.test.tsx, draft.test.ts, draft-compat.test.ts, stranded-draft.test.ts, submit.test.ts, resume.test.ts, leave.test.tsx, leave-events.test.tsx, flags.test.ts, answered.test.ts, sections.test.ts, timer.test.ts, deadline.test.ts, end-states.test.tsx, load-failure.test.tsx, held-session.test.ts, session-claim.test.ts, superseded-load.test.tsx, deadline-lock.test.ts, sequence-anchor.test.ts, everything under tests/units/integrity/ (including sequence.test.ts, shared-audio-record.test.ts and timeline-shared-audio.test.ts). clientSeq uses an attempt-relative offset calibrated in getAttempt from startedAt and serverTime, never below the stored next sequence; it is monotonic within a tab, with no cross-tab chronology or uniqueness guarantee. The offset component caps at 2,000,000,000, but local increments can exceed it (#338). The timeline keeps its session order by earliest received time and session-id ties, resume first and takeover last, then differing non-null sequence values or event time and id. integrity_timeline_test.go and timeline_sparse_test.go preserve the session and sparse-sequence cases. A deadline lock rearms submission at the device's recorded deadline even after another refusal, without replacing a closed or superseded lock (#353, #337). A superseded tab removes a stored draft only if its own session wrote it, because the draft key is shared by every tab of the browser (#337).
  • A test that pinned a frame the deck replaced is rewritten to the new frame; no case is dropped without a case that replaces it, and the PR lists the pairs.
  • Strings vi first, en from the deck, listed in the PR. Removed keys go in their own commit.
  • Compared with the deck in a browser at 360, 768, 1024, 1280 and 1440, light and dark, plus the boundaries the task introduces, on the fixture papers T-D4.3 adds (the deck's s1, s9 and s2 as product sessions). pnpm e2e:live (E2E 5 to 9) passes with assertions unchanged and only locators moved.
  • A group fixture has the shape the server accepts, which ajv does not check: every recording's asset is an audio node of one of the group's materials, and every audio node has a recording (group_validation.go:170-177, 235, 243-246). A listening group therefore always has at least one material; "a group with recordings and no material" is not a product state.

T-D4.3 — Engine: one paper source and one width source (no visual change)

Depends on: v0.10.0 released. It changes no drawing, so it does not wait for T-D4.1. It reads 768 only, which viewport("phone" | "desktop") answers, and it does not edit tests/support/viewport.ts Touches: web/src/features/take-test/pages/TakeTestPage.tsx, web/src/features/take-test/components/{QuestionCard,EngineHeader,Clock,GroupContext,QuestionAudio,SaveState}.tsx, web/src/features/take-test/{paper.ts,width.ts} (new), web/tests/units/take-test/{support.ts,paper-source.test.tsx (new),deckSession.ts,deck-fixtures.test.ts} Size: M Done when:

  • Paper and every component under it read the paper from one context: the title, the questions, sections and groups, the answers and setAnswer, the flags and toggleFlag, the lock and the submit state, the save status, the play counts, and for a shared recording notePlay, the pending-sync flag and flush. TakeTestPage fills it from useTakeTestStore, useGroupPlaybackStore and useSaveStatus. After this task no file under components/ that Paper mounts imports a store: today Clock, EngineHeader, GroupContext, QuestionAudio, QuestionCard and SaveState do, and Paper itself (TakeTestPage.tsx:347-353). The stores are not edited. The open question's restore and its write (openQuestion.ts) stay in TakeTestPage, outside the source.
  • The timer comes through the source too. Clock reads deadlineAt, offsetMs and the deadline lock from it, and its one-second tick calls a stable timeLeft() reader the source carries, so it still reads the live store on every tick and never a value captured at render (Clock.tsx:47-63 today). The deadline-lock rule of #329 is not touched: timer.test.ts, deadline.test.ts and header.test.tsx's "the timer" cases (373-672) pass with no edit.
  • A question's audio reaches integrity through the source, not by itself: the source carries three callbacks per question, onAudioPlay, onAudioEnded and onAudioBlocked, which TakeTestPage fills with today's calls (notePlay and recordAudioEvent(attemptId, "audio_play" | "audio_ended" | "audio_blocked", id), QuestionAudio.tsx:34-46). QuestionAudio no longer imports features/integrity. question-audio-events.test.tsx passes with its assertions untouched, its render call moved to renderWithPaper (see the next bullet but one), and audio-events.spec.ts with no edit.
  • One hook answers a width question, useEngineWidthAtLeast(px). Under FocusLayout it is useMediaQuery, so viewport() keeps working; a provider may replace the source with a measured container (T-D4.16). After this task 768 is the only threshold read.
  • deckSession.ts gains the deck's other two papers as sessions the contract accepts, beside s1, so every later task has something to compare in a browser: s9 without its matching: 34 questions in four sections, the cloze as a group with one material holding five gaps and five single-choice members bound to them (all five with the deck's one prompt, "Read the passage and choose the best option for each gap.", so T-D4.10's screen can be laid over the deck's), and the three statements as three-option single choice; s2 as two listening groups, each with one material that holds only its audio node and one recording with maxPlays 2: part 1 with five single-choice members standing in for the five rows of the deck's matching (R6), so part 2 keeps the deck's numbers, 6 to 10: its four single-choice questions and its short answer. No fixture carries ans or a correct index. deck-fixtures.test.ts validates all three with ajv and asserts the server's group rule in TypeScript (each recording's asset is an audio node of a material, each audio node has a recording). Nothing renders them yet.
  • No box, string, role or name changes. Every existing test under tests/units/take-test, integrity and media passes with no edit beyond the render helper in support.ts: the three suites that mount a paper component bare (renderers.test.tsx's QuestionCard cases, question-audio-events.test.tsx, shared-audio-events.test.tsx) render through renderWithPaper, which mounts the live source as TakeTestPage does, and change nothing else, because a bare component has no source: hooks cannot live in the context value, and a second store-backed source as its default would restate saveStatus.ts; paper-source.test.tsx renders Paper from a hand-built source with no store and asserts that an answer, a flag, a recording's play and all three audio callbacks of a question reach the source, and that the timer follows a deadline the source moves.
  • pnpm e2e (student-interactions, student-engine.phone, group-attempt, stranded-draft, integrity-leave, audio-events) and CI's pnpm e2e:live are green with no spec edited.
  • The engine is opened at 360 and 1280 before and after; the PR's two pairs of screenshots differ in nothing.
  • The PR holds no other change (a behavioural change never rides in a refactor).

As built: the source is PaperSource in take-test/paper.ts, a plain value carried by PaperSourceContext, which has no default; useLivePaperSource() fills it from the two stores and useSaveStatus, and TakeTestPage provides it around Paper and the submitted screen's header. A question's audio has a fourth callback, onAudioSeekBlocked, for the audio_seek record QuestionAudio already made, and a shared recording's end and block go through onRecordingEnded and onRecordingBlocked, so GroupContext no longer imports features/integrity either. Paper is a named export of TakeTestPage.tsx that takes the open index and the controls and derives the rest from the source. The width source is useEngineWidthAtLeast in take-test/width.ts; EngineWidthContext holds a measured width for T-D4.16 and is null under FocusLayout. The fixture papers are deckMockTestSession (s9) and deckListeningSession (s2) beside deckSession and deckPassageSession (s1); cloze gaps are labelled 23 to 27. s2's part 2 recording has allowPause: false, the deck's canPause: false, added when T-D4.13 made the field required.

T-D4.4 — Engine: sections rail, footer, question sheet and three-state squares

Depends on: T-D4.1, T-D4.3. Its tests use the numeric form of viewport() that T-R4.3a added (web/tests/support/viewport.ts, on develop from v0.10.0); this task writes no numeric form of its own Touches: web/src/features/take-test/components/Navigator.tsx (rewritten), web/src/features/take-test/components/SectionRail.tsx (new), web/src/features/take-test/pages/TakeTestPage.tsx (Paper), web/src/features/take-test/answered.ts (adds progress), locales, web/tests/units/take-test/{navigator,breakpoint,keyboard,submit-dialog,answered}.test.ts(x), web/tests/e2e/{student-interactions,student-engine.phone}.spec.ts Size: L Frozen: the key handler's rules (arrows stop at both ends; A to E choose; F flags; none acts in a field, with a modifier, under a dialog or the sheet, or on a locked paper); every move, by key or by click, goes through the one function that exists today, so nothing the deck's arrow keys skip (S 1616 against go(), S 1639) is skipped in the product; Finish and the sealed paper's footer; the sheet's focus rules, Esc included (the deck's Esc clears a state the new sheet does not use, S 1530); forgetRailWidth; answered(); the header, which this task does not change (header.test.tsx passes untouched). Done when:

  • The rail is the engine's own <aside> named "Sections" ("Các phần"): 236px, --sidebar, a right border, padding 16px 14px, gap 16px, scrolling by itself. It shows when the engine is at least 900 wide on a question with no shared content and at least 1180 on one with it, and never below 768 (DG-191). It imports neither SideColumn, PageAside nor useColumnWidth: tests/units/student/side-column.test.ts passes untouched.
  • Per section: its title (12.5px, line-height 1.35; 600 --fg for the open question's section, 500 --muted-fg otherwise) and "n / m" answered (11.5px, tabular); a grid of five columns, gap 5px, of 34px squares (radius 8, 1.5px border, 12.5px 600). The title is the section's whole title, not the deck's part after " · ". A paper of one untitled part shows the grid with no heading.
  • A square: answered is filled primary; partly answered is --accent-soft with a primary border; empty is --card with --border. The open question adds the --accent-c border and 0 0 0 2px var(--accent-soft). A flag is a 9px --warning dot with a 2px --card border at the top right. Names keep today's parts ("Câu 23", the part, "đang xem", "đã trả lời", "đã đánh dấu") and add "trả lời một phần".
  • progress(question, answer) returns none, partly or full. Partly is a fill-in with at least one blank typed and not all. answered() is unchanged and still equals progress === "full"; answered.test.ts gains the partly cases and loses none. liveAnsweredCount's SQL rule is not touched.
  • Legend, pinned to the rail's foot and at the sheet's end, 11.5px: "Answered" (11px square, radius 3), "Partly" (11px, --accent-soft, 1px primary border), "Flagged" (9px round).
  • The footer strip is gone at every width. With the rail: Previous, the key hint centred (two 22px <kbd> "←" "→", then "to move between questions · A–D to choose an answer", or "to move · A–D to choose" beside shared content; 12.5px muted, one line, ellipsis), Next. On a question with no options the hint stops at "to move". A to E and F keep working; the hint names what the deck names (DG-191).
  • Without the rail, at every width: Previous (icon only below 768), the grid button (flex 1, 44px, radius 10, 1px border, --card, 14px 500, the layout-grid icon at 16px), Next. Its label is "{no} · {answered}/{total}" beside shared content from 768 and below 420, otherwise "Question {no} · {answered} of {total} done"; it truncates with an ellipsis and never breaks onto two lines. A screen reader hears the long form and that it opens a dialog.
  • The grid button opens the sheet at any width. Below 768: pinned to the bottom, radius 16px 16px 0 0. From 768: a card 560px wide, centred, 76px from the bottom, radius 14px. Both: 1px border, at most 80% of the height, padding 12px 16px 20px, gap 16px, the 40 by 4 grabber. One block per section (title and "n / m"), six columns, gap 5px, squares 42px high from 1024 and 44px below it (the floor), 12.5px. No visible title or total: the dialog keeps the name "Questions" and the answered total for a screen reader. Focus starts on the open question's square, is trapped, Esc and the backdrop close, focus returns to the button unless a square took it.
  • The sheet closes if the engine grows past the rail's threshold while it is open.
  • The Submit dialog lists a partly answered question under "Go to", as it already does (it is not answered); its counts and sentences do not change.
  • Strings. New: takeTest.{sectionsRail,railDone,legendPartly,dotPartly,keysHint, keysHintShort,keysHintMove,gridLabel,gridLabelShort}. Removed, in their own commit: takeTest.{navPosition,navAnswered,navCount}.
  • Tests: navigator.test.tsx keeps every case of "the question sheet", "on a paper whose time is up", the sealed-paper cases and "the retired rail's remembered width"; its "the footer from 768" cases become rail cases at 900 and 1180 and grid-button cases at 768 and 1024 beside shared content; breakpoint.test.tsx crosses 768, 900 and 1180 with an answer being typed and keeps it; navigator.test.tsx:694, "keeps the title, the save line and the timer in the header, and draws no rail and no shortcut hint", keeps its header assertions and has its last two inverted (a rail at 900, a hint in the footer); keyboard.test.tsx is unchanged but for the hint.
  • Compared with the deck's papers s1 and s9 (T-D4.3's sessions) at the five widths and at 419 / 420, 899 / 900 and 1179 / 1180, light and dark: the rail, its squares in the four states, the legend, the hint, the grid button in both label forms, the sheet in both shapes. In Vietnamese at 320 the grid button stays on one line.

As built: the rail is SectionRail.tsx; the footer, the grid button, the sheet and the pieces both share (SectionBlocks, Legend) are in Navigator.tsx. Paper reads three more widths from useEngineWidthAtLeast (420, 900, 1180) and puts the rail first in <main>, so the panes keep their place and nothing remounts when it comes or goes. DotState gains partly beside answered, so SubmitDialog.tsx is untouched. A paper of one titled section heads its block too (the deck's s1 does); only an untitled one has none. The key hint is a <p>; on a question with no options (a fill-in or a short answer) it reads keysHintMove, "to move". The sheet keeps its name and states the grid button's long label as its description, both sr-only. The deck's <kbd> carries font: 600 11px/18px inherit, which is invalid CSS, so the deck renders the browser's monospace at the hint's 12.5px and weight 400; the product's <kbd> sets no font and renders the same. The rail's squares keep the open one in view (scrollIntoView, as the strip did). Tests rewritten to the new frame, case for case: navigator.test.tsx's "the footer from 768" cases became "the rail" (at 900) and "the rail beside shared content" (1179 / 1180, the grid button at 768 and 1024, coming and going between questions); breakpoint.test.tsx crosses 768 and 900, and 1180 beside a passage; panes.test.tsx's "uses the question pane alone" counts the rail in <main>; submit-dialog.test.tsx's "with the strip" case reads the rail and gains the partly-answered chip; keyboard.test.tsx moves its sheet locator and gains the hint; student-interactions.spec.ts gains the navigator at 320, 419, 420, 899, 900 and 1440. DG-80's "Footer and sheet" sentence is trimmed in this PR, as DG-80 asks.

T-D4.5 — Engine: question pane surface, section header, question line and stimulus head

Depends on: T-D4.4 Touches: web/src/features/take-test/pages/TakeTestPage.tsx (Paper), web/src/features/take-test/components/{QuestionSheet,PassageBody,GroupContext,SectionInstructions (removed)}.tsx, web/src/features/take-test/{questionType,sections}.ts, web/src/components/shared/content/GroupMaterials.tsx, web/src/features/tests/components/PreviewGroupContext.tsx, locales, web/tests/units/take-test/{panes,group-context,navigator}.test.tsx, web/tests/units/builder/group-preview.test.tsx, web/tests/units/i18n/type-names.test.ts (new) Size: M Frozen: QuestionCard's join to the paper source, useLanding (where focus lands), the gap callbacks of GroupMaterials, PAPER_SURFACE, UnknownType, the order of questions. Done when:

  • The question pane is on --sidebar only beside shared content from 768; alone, or on a phone, it is on --bg. Its column is at most 600px beside shared content and 720px alone.
  • At the top of the question column, on every question: the section's title (12px, 600, muted, uppercase, letter-spacing .04em) over its instructions (13.5px, muted), 2px apart, 12px of padding and a 1px border under them. It is left out on a paper of one section that has no instructions, and the instructions line when a section has none (DG-192). The first-question note and the headphones note go; opensSection and SectionInstructions.tsx are removed.
  • That section header is drawn by Paper, above QuestionCard, never by QuestionSheet. StudentPreview stacks every question in one list and mounts QuestionSheet for each, so a header inside the sheet would repeat a section's instructions above every question of the teacher's preview; it keeps its own header, once per section (StudentPreview.tsx:51-58), until T-D4.16 replaces it. A unit test renders StudentPreview with a three-question section and finds its instructions once.
  • The line reads "Question 4 of 8 · Single choice": the types are the builder's names. The engine keeps its own keys, takeTest.type.*, and their values become those of questionEditor.type.* as T-R4.31b's type menu left them: "Single choice", "Multiple choice", "True / False", "Fill in the blank" and "Short answer", with "Một đáp án" and the rest in Vietnamese. A value there that still differs from the deck's is set to the deck's in both groups. type-names.test.ts asserts the two groups are equal in vi and in en, so the engine and the builder's type menu cannot drift. Multiple choice keeps its instruction sentence as the hint line.
  • The stimulus pane's kicker is the group's title (12.5px, 600, muted, uppercase, letter-spacing .04em). Its 22px heading is the title of the group's first material that has something to read; when that title equals the group's, the kicker is the section's title, so nothing is said twice. A material whose content is only audio nodes has no heading at any size: a listening group is a kicker and its recording, as the deck draws audio (S 1054-1056: the h2 is inside isPassage), and the name a teacher or T-D4.14 gave that material (the file's name) is never printed for a student. A group's instructions stay under the heading, or under the kicker when there is none (14px, muted). Further materials keep their own headings. GroupMaterials gains the audio-only rule; the audio node itself is still today's label until T-D4.8.
  • StudentPreview takes the type names (it shares QuestionSheet) and the kicker and heading (PreviewGroupContext shares PassageBody and GroupMaterials); builder/group-preview.test.tsx and group-preview.spec.ts follow.
  • The builder's Instructions dialog, which T-R4.31a built with the deck's "Students see this above the group’s first question." (T 7030), reads "Students see this above every question in the section." in this PR. It is one string in both languages and no builder logic (DG-192).
  • api/openapi.yaml's StudentSection description ("the engine shows it above the section's first question") is corrected in T-D4.13's pull request, or in a contract pull request of its own if the owner declines Q4 and T-D4.13 is not built.
  • Tests: panes.test.tsx's cases on the part eyebrow, the listening note and the type labels are rewritten; "is the deck's line in English" asserts the new labels; group-context.test.tsx gains "a material that is only a recording has no heading" and "a material with text and a recording keeps its heading", on the validated shape.
  • Compared with s1 question 4, s9 question 5 (no shared content, 720px), s9 question 28 (shared content, 600px; the deck's 24, four later because the cloze is five questions) and s2 question 6 (the kicker with no heading) at the five widths, light and dark.

As built: the section header is SectionHeader in TakeTestPage.tsx, drawn by Paper above QuestionCard from sectionHead(groups, group) in sections.ts, which replaces opensSection; it is also left out for an untitled section with no instructions on a paper of several. SectionInstructions.tsx and Note.tsx, which only it used, are gone. The stimulus pane's head is materialsHead in the new components/shared/content/materials.ts (beside hasReading, a material with any block that is not an audio node, or legacy Markdown), kept out of GroupMaterials.tsx so that file exports only components; titles are compared and returned in NFC. GroupMaterials gives a material with nothing to read neither a heading nor a region name. PassageBody takes kicker and an optional title. PreviewGroupContext takes the same head with no section, so the teacher's preview loses its "Shared material · Questions n–m" eyebrow (preview.sharedRange stays, for the review screens). The type names: the vi values copied are the type menu's ("Một đáp án", "Nhiều đáp án", "Đúng/Sai", "Điền từ", "Tự luận"); en was already the deck's. The builder's line is builder.sectionInstructionsHint. StudentSection's description already said "above each of the section's questions" (T-D4.13). jumpToGap lists setReadingFor in its dependencies, because the React Compiler now compiles PaperAt and asks for it. Tests that pinned the old frame and moved with it: the type labels in open-question, header, renderers, breakpoint and student-engine.phone.spec.ts; sections.test.ts's opensSection case became three sectionHead cases; the group-title heading locators of group-attempt, document-scroll and student-engine.phone now find the material's heading.

T-D4.6 — Engine: answer areas (choice rows and columns, the blank, the short answer)

Depends on: T-D4.5 (shares panes.test.tsx); otherwise beside the chain Touches: web/src/features/take-test/components/QuestionBody.tsx, web/src/features/take-test/components/blankInputs.ts, locales, web/tests/units/take-test/{renderers,panes}.test.tsx Size: M Frozen: real radio and checkbox inputs inside their labels (the deck's <button aria-pressed> is not taken); the four answer shapes, of which this file writes three (choice, fill_blank, text) and must keep reading a stored true_false boolean as a chosen option (draft-compat.test.ts and stranded-draft.test.ts hold one and pass untouched); the ids answer-blank-<id> that a gap lands on; the <textarea> (line breaks of saved answers); 16px fields below 1024; paste blocking; the case-rule and worth lines (DG-80). Done when:

  • A chosen option's marker keeps its letter, in --primary-fg on --primary: no check icon. The multiple-choice marker's radius is 7px. Row line-height 1.4.
  • Options are one column, or a grid when every option is plain text of at most 16 characters and the question has no shared content: two columns below 768, min(4, n) from 768. An option with rich content forces one column.
  • A blank is the deck's field wherever it stands: no box, a 2px bottom border (--ring, --primary once typed), --muted fill, radius 6px 6px 0 0, padding 0 8px, left-aligned, placeholder "type here" ("nhập ở đây"), autocomplete="off", spellcheck="false"; 38px high from 1024 and 44px below; min(100%, 340px) wide when the question has one blank, 128px when it has several or the blank is in a table. A fill-in prompt is 18px (20px larger), weight 500, line-height 2.1.
  • The short answer gains autocomplete="off" and spellcheck="false"; "1 word" is singular in English.
  • Larger text: the sentence 20px, options 17px, as drawn (sentSize, optSize).
  • renderers.test.tsx asserts the letter in a chosen marker, the column rule at 359, 768 and with one long option, the blank's two widths, and that a typed blank still writes {type: "fill_blank", values}; draft-compat.test.ts passes untouched.
  • Compared with s9 questions 1 (four short options: four columns at 1280, two at 360), 5, 21 and 31 (the blank; the deck's 27), and s1 question 5, at the five widths, light and dark. The sign card, the 60px word tiles and the sentence gap are known differences (DG-189, T-D4.11).

As built: QuestionBody reads "no shared content" from the paper source (a group with stimuli holding the question; none in the teacher's preview, which has no source until T-D4.16) and the width from useEngineWidthAtLeast(768). "Plain text" is an option with no content, counted in code points. The blank's field is BLANK: 16px below 1024 and the sentence's size from 1024 (text-[length:inherit]), the global focus ring kept rather than the deck's outline: 0; in a rich table the paper-surface border colour still applies. "Alone in its sentence" is a question with one blank; "in a table" is a slot blankInputs marks with data-in-table, or a rich prompt's gap found inside a table block. "1 word" was already singular (takeTest.wordCount_one). New key: takeTest.blankPlaceholder. panes.test.tsx gains the engine case (three columns alone, one beside the passage); renderers.test.tsx the marker letter, the column rule at 359, 768, 1280, a long option, rich content and shared content, the blank's two widths and the table width, the typed underline and the fill-in answer it still writes, the short answer's attributes and "1 word".

T-D4.7 — Engine: the phone drawer

Depends on: T-D4.5 Touches: web/src/features/take-test/pages/TakeTestPage.tsx (Paper; PaneSwitch and PaneTab removed), web/src/features/take-test/components/{StimulusDrawer.tsx (new),GroupContext.tsx}, web/src/features/take-test/panes.ts, web/src/index.css (qz-nudge, qz-tip), locales, web/tests/units/take-test/{panes,group-context,navigator,keyboard}.test.tsx, web/tests/e2e/student-engine.phone.spec.ts Size: L Frozen: the integrity monitor and its listeners (the drawer adds pointer handlers on the panes' container only and never touches visibilitychange, blur, copy, paste); useKeptScroll's promise that a hidden pane keeps its place; the rule that the keys answer and flag only a question on screen. Done when:

  • Below 768, shared content is a drawer from the left, not a switcher: min(88% of the width, 420px) wide, over the question, transform .32s cubic-bezier(.22,.8,.24,1), --shadow-lg while open, a backdrop that darkens to 45% of the overlay token (no literal colour: no-raw-colours.test.ts stays green).
  • Closed, a handle at the left edge, 42% down: 30 by 84px, radius 0 12px 12px 0, --card, 1px border without the left side, the file-text or audio-lines icon (15px), a 7px --accent-c dot while the group's recording plays, chevrons-right (14px, muted), --shadow-lg. It is named, in English as the deck has it, "Open the passage" or "Open the audio".
  • Until a group's drawer has been opened once in this attempt, the handle nudges (7px, 1.4s, three times, after 0.4s) and a tip shows beside it (role="status", primary, 12.5px, at most 220px): "The passage is here. Swipe right or tap to read it." or "The recording is here. Swipe right or tap to open it.". Remembered per group in sessionStorage['quizzivy.stimulus-seen.<attemptId>'], inside try/catch.
  • Open, a sticky bar: "Swipe left to go back to the question" (12px, muted, chevrons-left) and a 34px close button.
  • A swipe starts after 10px when it is more horizontal than 1.2 times its vertical travel; released past a quarter of the drawer's width it opens, and an open drawer closes when dragged back past 30%. touch-action: pan-y; a click within 60ms of a drag does nothing; a tap on an option is never swallowed (a unit test taps one right after a vertical scroll).
  • What the deck does not draw (DG-190): opening moves focus to the close button and closing returns it to the handle; Esc and the backdrop close; the closed drawer is inert and stays mounted, so the place in the passage and a playing recording survive; under prefers-reduced-motion there is no nudge, no tip animation and no slide. The deck marks the closed drawer aria-hidden (S 1041, 1700); inert is taken instead because aria-hidden leaves the gap buttons inside it reachable by Tab, and DG-190 records the choice. The drawer's own background is --bg, stated on the pane (S 1041), since it now lies over the question.
  • A gap tapped in the drawer closes it and lands on its question or blank, as today. A move by the arrows, a square or "Go to" closes it: all of them go through the engine's one move function, where the deck's arrow keys bypass go() and leave the drawer open (S 1616, DG-191). While it is open the keys that answer or flag do nothing. The footer stays where it is, under the backdrop.
  • Crossing 768 with the drawer open shows both panes; the same pane element is used on both sides, so nothing inside it remounts.
  • Strings. New: takeTest.{openPassage,openRecording,drawerHint,drawerClose,tipPassage, tipRecording}. Removed, in their own commit: takeTest.{passage,paneSwitch}.
  • Tests: every case of panes.test.tsx's "the panes below 768" is rewritten to the drawer (the PR lists each pair); group-context.test.tsx's phone cases follow; pointer events drive the three thresholds; student-engine.phone.spec.ts swipes for real in mobile-chromium, and checks that no sideways scroll appears at 320.
  • Compared with s9 question 23 and s1 at 360 and 767, light and dark: the handle, the tip, the open drawer, its bar and the backdrop.

As built: the handle, the tip, the backdrop and the bar are in StimulusDrawer.tsx; the drawer is GroupContext's own article, given a drawer (DrawerPane) below 768, so the same element serves both sides of 768. The state is useStimulusDrawer in panes.ts, beside useDrawerSwipe, useSeenStimuli and useSharedAudioPlaying: the drawer is open for one visit of a question, so every move closes it; dismiss closes it for a gap without moving the focus to the handle. Esc is a window listener while it is open that stands aside for an open dialog; the click a swipe leaves behind is swallowed by a native capture listener on the panes, because React's onChange for a radio comes from the native click. The panes, the question's scroller and the drawer are all touch-action: pan-y, since a scroll container decides its own; a horizontal swipe that starts on a rich table scrolls the table. The playing dot reads the media events of the group's recordings (data-shared-audio), which stay in the question pane until T-D4.8. The backdrop is --overlay at the drawer's progress: 0.40 alpha in light and 0.60 in dark against the deck's 0.45 in both, with no literal colour. The close button is the deck's 34px and opts out of the 44px floor, as the handle does (Q21). Paper takes an optional attemptId for the seen-groups key; without one it remembers in memory. Pairs in panes.test.tsx, "the panes below 768": the switcher case became the closed drawer and its handle, plus opening, closing to the handle, Esc and the backdrop; the English switcher, the deck's English handle, tip and bar; "names the question in the switcher" the nudge and tip remembered per attempt (and a browser that refuses storage); the keys case the keys under an open drawer; the arrows case the arrows closing it; the two "shows the question again" cases "stays closed"; the Submit dialog case closing and landing; "keeps the student's place" both panes' places; the two "no switcher" cases "no handle"; "grows past 768" the same element on both sides; the answer-typed case unchanged but for the handle. New there: the swipe's three thresholds, following the finger, a vertical scroll and its tap, and the 60ms swallow. group-context.test.tsx's phone cases follow, with the playing dot; the navigator's "hides with the question" became "stays under the backdrop"; student-engine.phone.spec.ts swipes with real touches (CDP) and taps; group-attempt.spec.ts opens the drawer at 320 and checks for sideways scroll there with it open.

T-D4.8 — Engine: the recording card in the stimulus pane, and what spends a play

Depends on: T-D4.7 Touches: web/src/features/media/components/AudioPlayer.tsx, web/src/features/media/playback.ts (new), web/src/features/take-test/components/{GroupContext,QuestionAudio}.tsx, web/src/features/take-test/panes.ts, web/src/features/take-test/pages/TakeTestPage.tsx, web/src/components/shared/content/GroupMaterials.tsx, locales, web/tests/units/media/{audio-player-card,audio-player-resume}.test.tsx (new), web/tests/units/take-test/{group-context,panes,question-audio-events}.test.tsx, web/tests/e2e/{audio-events.spec.ts,audio-plays.live.spec.ts,student-engine.phone.spec.ts}, docs/quizzivy-spec-v0.3.md §11.3, §11.4 Size: L Frozen: toggle()'s order in AudioPlayer (element.play() first, nothing awaited or set before it); the player is never disabled by a count; the no-seek guard (AudioPlayer.tsx:240-247: with seeking not allowed, a jump of 0.5s or more is put back and onSeekBlocked is called; the canary's "puts the position back and reports it, because OS controls still seek"), which keeps refusing every jump a student makes; groupPlayback.ts and groupPlaybackDraft.ts (gesture ids, retries, monotonic counts); the audio_play, audio_ended and audio_blocked records of #314; the one-length rule of #321; the expired-link retry; pausing on a lock. A rejected play is read by its name: NotAllowedError says so under the player that stays; AbortError, including a pause before the start, says nothing (#339). audio_seek is recorded for a question's blocked seek (#339). A shared recording's end and block carry meta.recordingId; known end duration is elapsed device wall time since the latest start, not total listening time (#340). A resume replaces that start, and an end or block clears it. The timeline consumes the latest matching open play once, by question id first or otherwise recording id; valid numeric 0–86,400,000 ms durations are truncated, with missing, malformed or out-of-range durations falling back to clamped event-clock spans. Delayed counts or clock skew can leave an end unpaired; overlapping sessions have no play-id matching. timeline_shared_audio_test.go preserves those pairing rules. audio-player.test.tsx, audio-player-events.test.tsx, audio-player-length.test.tsx, group-playback.test.ts, audio-plays.test.ts, audio-player-blocked.test.tsx and shared-audio-events.test.tsx pass untouched.

The shape this task builds on. A recording never comes alone: the server accepts a recording only when its asset is an audio node inside one of the group's materials, and refuses an audio node with no recording (group_validation.go:170-177, 235, 243-246; api/openapi.yaml 2376-2381, "One explicit playback binding per audio asset used in the group's materials"). So a listening group reaches the student with at least one material (tests/support/groupPreview.ts:41-64 is the shape), hasPassage is already true for it, and today the passage pane prints that material's heading and the node's label (GroupContext.tsx:62) while the player sits in the question pane (GroupListening).

Done when:

  • The card is drawn at its audio node, in reading order, in the stimulus pane (the drawer below 768), in place of today's label line: GroupContext's renderAudio, which already receives the node, the asset and the recording, returns the player. GroupListening leaves the question pane and its stimuli.length === 0 branch goes.
  • A material whose content is only audio nodes draws no heading (T-D4.5) and no label: a listening group is the kicker and the card, as drawn (S 1054-1056, 1074-1097). A material with text and audio keeps its heading and has the card where the node stands. A group with several recordings has each card at its own node, and only then is the node's label printed above its card (today's 14px line; "Recording {n}" when the label is empty), so two cards can be told apart; with one recording nothing is printed and the label is the card's accessible name. Never the same recording twice: no list of recordings is drawn before or after the materials.
  • A recording no audio node names is a payload the server refuses; if one arrives anyway its card is drawn after the materials, so no payload can hide a recording. hasPassage becomes "has shared content" (stimuli or recordings), which is the same answer as today for every group the server accepts. One unit case keeps the refused shape, named for what it is.
  • The shared player stays mounted across the group's questions and across 768 (the pane is keyed by the group and is the same element on both sides, T-D4.7). Leaving the group by any means (Previous, Next, the arrow keys, a square, "Go to", a gap) pauses its recording, because the pane unmounts; the deck pauses on a click that changes the section and not on the arrow keys, which is a slip of the prototype (DG-191, DG-193).
  • AudioPlayer gains layout="card", used by the engine and the teacher's preview only: padding 18px, radius 14, 1px border, --card, --shadow; a 52px round primary button with a 22px icon (play, pause, or rotate-ccw after a finished play); a 6px track on --muted filled --accent-c; under it the time "0:00 / 2:34" and, at the far end, "Play {n} of {N}" (12.5px, tabular), which reads 1 before the first play; then a status line with a 15px headphones icon (13px, muted). The same toggle() serves both layouts; audio-player-card.test.tsx repeats the same-tick assertion for the card.
  • Status and names, as drawn: "Press play when you are ready.", "Playing", "Paused", "Finished · 1 play left" (plural in both languages); the button is "Play", "Pause", "Resume" or "Play again". With no limit the counter reads "Play {n}" and the finished line "Finished" (DG-193).
  • The status line has a place for the blocked sentence (media.playBlocked).
  • At the limit the button stays live (DG-182, spec §11.4, the canary's "still plays when the hint says none are left"): no lock icon, the status reads "You have used all {N} plays." and the product's "You can keep listening. Additional plays are recorded for your teacher to review." follows; the counter reads "Played {n}/{N} times".
  • A play is spent when playback starts and no play is in progress; a resume after a pause spends none. AudioPlayer calls onPlay for the first and onResume for the second; both hosts count on onPlay only. With seeking allowed, moving inside a play in progress spends nothing.
  • A play in progress and its position are remembered for the tab's life per recording or question (playback.ts, memory only, cleared with the attempt), so coming back to a group resumes where it stopped without a play; a reload forgets, and the next start counts. The restore must not meet the no-seek guard, which would put a remounted player back to 0:00 and so hand the student a replay that costs nothing: - the remembered position is applied when the element has its metadata (at mount if it is already there, otherwise on loadedmetadata), never inside toggle() ahead of play(); - the value the guard compares with is set to the remembered position before currentTime is, so the guard sees no jump, puts nothing back and does not call onSeekBlocked: a restore is never reported as a blocked seek; - if playback is running and the position is still more than 0.5s short of the remembered point (the restore was refused, or the metadata never came), the start is counted as a play through onPlay and the memory is cleared. A place that cannot be put back is never free.
  • Starting one recording pauses any other that is playing.
  • A question's own audio (QuestionAudio) takes the same card, in the question pane, with the same rules. A recording keeps "Allow skipping ahead": when it is on, the seek control lies over the 6px track. The scope line and the sync line with its Retry stay under a shared recording (DG-80).
  • Spec §11.3 and §11.4 are changed in this PR: "Each play that starts the recording sends an audio_play event; resuming a paused play sends none", one instance per recording that keeps its place while its group is left, and the rule above for a place that cannot be put back. Version bumped per §18.
  • Unit tests, which cannot see playback (jsdom plays nothing, and the existing tests stub play, pause and paused only): audio-player-resume.test.tsx (a pause and a resume call onPlay once; a second start after the end calls it twice; a remembered play resumes with play() in the click's tick and no onPlay; an empty memory counts; a restore does not call onSeekBlocked; a seek by the student with seeking off still does); question-audio-events.test.tsx gains "a resume records no second play"; group-context.test.tsx gains the card at its node for an audio-only material, a mixed material and two recordings, all on the validated shape.
  • panes.test.tsx: the fixture at 93-100 (stimuli: [] with a recording) is retired as the listening fixture, because the server refuses it; its case "draws a group that has only recordings inside the question pane" becomes "draws a listening group's recording in the stimulus pane, with no heading", on T-D4.3's s2 group. The PR lists the pair.
  • Browser proof, because the free replay cannot be seen in jsdom: audio-plays.live.spec.ts gains (a) a pause and a resume with the server's count still 1, and (b) with seeking off: start a group's recording, leave the group mid-play, come back, resume, and assert that currentTime is past the remembered point and the server's count is still 1. Its existing assertions are unchanged. student-engine.phone.spec.ts repeats (b) in mobile-chromium against the stubbed API, counting the group-audio-play requests.
  • Compared with s2 question 1 (part 1's recording: T-D4.3's fixture gives it choice members) at the five widths, light and dark: the card at rest, playing, paused and finished. Question 6 is the deck's recording that cannot be paused and is T-D4.9's comparison. The lock state is a known difference (DG-182).

As built: the memory is features/media/playback.ts (keepPlace, recallPlace, forgetPlace, forgetAllPlaces, and takeTurn/releaseTurn for the one player sounding); TakeTestPage forgets every place as the attempt closes. AudioPlayer keys a host's memory by place (recording:<id> or question:<id>). "A play in progress" is a start this player saw, with no end since, and a position past 0:00 or a place still to be put back: a pause before any sound, at 0:00, leaves "Play" on the button and the next press spends a play, as the deck's toggle does (S 1681) and as the frozen shared-audio-events.test.tsx already counts it. The guard compares with a ref (guardAt) where it read the position state, so the restore can set it before currentTime. The card's phase (ready, playing, paused, finished) comes from the player's own state and, for "finished", from the host's count, so a card that comes back after a finished play, or after a reload, reads "Play again". A finished card shows the whole track and the total, as the deck does at t >= len; the icons are outlines, as the deck's lucide font draws them. "At the limit" is a finished card with played >= limit; while a play runs past the limit the counter reads "Played n/N times" and the extra-plays line shows. GroupListening stays as the card list for a recording no node names (the refused shape), which shared-audio-events.test.tsx renders untouched; its "nothing to read" branch is gone, and hasPassage is hasSharedContent. With several recordings the printed label falls back to "Recording n" for a blank one (the validated shape refuses an empty label, so a blank is whitespace). QuestionCard loses its lead prop. GroupMaterials.tsx needed no change: the audio-only rule was T-D4.5's, and the label line was GroupContext's renderer. The teacher's preview keeps its own player until T-D4.16 draws the engine there. New keys under media.: resume, playAgain, playOf, playN, playedOf, statusReady, statusPlaying, statusPaused, statusFinished, statusFinishedLeft, statusUsedAll, extraPlays; removed, in their own commit: takeTest.{playsLeft,playsUsed,extraPlaysRecorded,sharedAudioUnlimited}. panes.test.tsx's pair: "draws a group that has only recordings inside the question pane" became "draws a listening group's recording in the stimulus pane, with no heading" (s2), and the phone's "has no handle for a group that has only recordings" became "puts a listening group's recording in the drawer, behind the audio handle"; the refused shape is group-context.test.tsx's "still draws a recording no audio node names". Tests that pinned the old frame moved with it: group-attempt.spec.ts (the counter, the card in the drawer at 320, "Play again" after the reload), audio-events.spec.ts ("Play again" after the end; new: a pause and a resume record one play), and the phone spec's 44px case opens the drawer. audio-plays.live.spec.ts: E2E 8 now reaches 2/2 through a resume (count still 1 after a reload) and a second play after the end, since its old second press is now a resume; E2E 8b authors a group recording in the builder and proves (b). The stubbed group recording is served with byte ranges (fulfillWithRanges), as object storage serves it: without them Chrome cannot seek, and the phone spec's second new case proves that such a place counts as a new play.

T-D4.9 — Engine: a recording that cannot be paused

Depends on: T-D4.8, T-D4.13 Touches: web/src/features/media/components/AudioPlayer.tsx, web/src/features/take-test/components/{GroupContext,QuestionAudio}.tsx, locales, web/tests/units/media/audio-player-pause.test.tsx (new), docs/quizzivy-spec-v0.3.md §11.3 Size: S Frozen: as T-D4.8. Done when:

  • AudioPlayer takes allowPause (default true). When false and playing, the button is aria-disabled, at opacity .45, and a click does nothing; the status reads "Playing · this recording can’t be paused"; before the first play it reads "Press play when you are ready. It can’t be paused once it starts."
  • If playback stops anyway (a headset button, another recording starting, leaving the group, a lock), the card reads "Paused" and "Resume" continues without a play. Nothing new is recorded: the product reports what it can see and does not pretend the pause could not happen.
  • Both hosts pass policy.allowPause, which T-D4.13 makes required in the two student shapes; a payload without the field (a tab that loaded before the API) pauses freely. T-D4.3's s2 session already gives part 2's recording allowPause: false, as the deck has it (canPause: false, S 1503): it took the field when T-D4.13 made it required.
  • Spec §11.3 says what allowPause does, and what it cannot stop.
  • audio-player-pause.test.tsx covers the inert button, the two sentences and the free resume; the canary passes untouched.
  • Compared with s2 question 6 (the deck's recording that cannot be paused) at 360 and 1280, light and dark: before the first play, playing with the inert button, and paused by leaving the group.

As built: AudioPlayer takes allowPause (default true, so an absent field pauses freely). With it false, toggle()'s pause branch does nothing while the element plays; the play branch is unchanged, so element.play() is still the first thing a press does. The button keeps its name ("Pause", as the deck's btnLabel), its icon and its focus ring, takes aria-disabled="true" and aria-disabled:opacity-45, and is never natively disabled; the row and sm players take the same rule, though no host passes it to them yet. The two sentences are media.statusReadyNoPause and media.statusPlayingNoPause; "Paused", "Resume" and the free resume are T-D4.8's, unchanged, so a stop from a headset button, another recording taking its turn, leaving the group or a lock reads "Paused" and continues through onResume. QuestionAudio passes question.audio?.allowPause and SharedAudio passes recording.policy.allowPause. Spec v0.98 changes §11.3. Beyond the new audio-player-pause.test.tsx, group-context.test.tsx gains s2 part 2's sentence and inert button through the engine, and question-audio-events.test.tsx gains "a stop from elsewhere resumes for no play" for a question's audio. Compared with s2 question 6 at 360 (in the drawer) and 1280, light and dark, in the three states: card, button (52px), opacity (.45 while playing, 1 otherwise), the button's name and the card's text equal the deck's in every cell; the differences are the focus ring (the deck draws none), the track's fill (the fixture's file is 10s, and the track follows the file's length), the scope line (DG-80) and, below 768, the card 35px lower under the engine's integrity strip, which the deck does not draw (T-D4.7's frame), and 284.8px wide against 285, so the playing sentence breaks one word earlier. Playwright counts an aria-disabled button as not enabled, so a spec that presses the inert button clicks it with force: true.

T-D4.10 — Engine: a cloze as one stop

Depends on: T-D4.5, T-D4.7; the design team's answer to Q6 (DG-184), given on 2026-10-13 (#581): the default Touches: web/src/features/take-test/stops.ts (new), web/src/features/take-test/components/{ClozeBody.tsx (new),GroupContext.tsx,QuestionSheet.tsx,Navigator.tsx}, web/src/features/take-test/pages/TakeTestPage.tsx, web/src/components/shared/content/{GroupMaterials.tsx,content.css}, locales, web/tests/units/take-test/{stops.test.ts (new),cloze.test.tsx (new),group-context,keyboard,submit-dialog}.test.tsx, web/tests/e2e/group-attempt.spec.ts Size: L, built as T-D4.10a (the engine) and T-D4.10b (the result), because the engine alone ran past 800 lines Frozen: one choice answer per member question, keyed by its id (autosave, drafts, the stranded-draft save, grading and the result are untouched); one square, one flag and one "Go to" chip per question; the number of a question is its position; the integrity monitor's questionId is the open gap's question; nothing a member carries is dropped from the screen.

What a member is. Every member of a group is a full question: prompt is required and not empty, and it may carry rich prompt content, its own image or audio and its own points (GroupQuestionInput and QuestionInput, api/openapi.yaml 2399-2413, 3100-3130; StudentQuestion 2112-2144). The deck's cloze has one prompt for five gaps; the product has five prompts.

The design team's answer. Q6 was answered on 2026-10-13 (#581) with this task's default, drawn in docs/design/deck/page-width-proposal/Cloze Decision.dc.html: one question per gap, shown as one screen. The Frozen list does not change. The answer adds eight pieces, built here:

  1. Ties between a run's squares and a "One screen" entry in the legend, in the rail and the sheet (T-D4.10a).
  2. The current ring on every square of the open run; the open gap's square has the lime ring and aria-current (T-D4.10a).
  3. The flag button names the open gap's question (T-D4.10a).
  4. The line reads "{k} of {m} gaps" for part of a cloze, and "Cloze" on a gap shown on its own (T-D4.10a).
  5. The run's hint names the gaps that have their own screens (T-D4.10a).
  6. Result: a shared prompt is printed once on a run line, over one-line rows (T-D4.10b).
  7. Result: the passage's words in the verdict colour, "recommended, can follow" (T-D4.10b).
  8. The Vietnamese name for a cloze, "Điền từ vào đoạn văn", and the keys above, vi first (both).

Done when:

  • stops.ts finds a cloze run: two or more consecutive single_choice members of one group, each bound by a GroupQuestionGap to a gap of the same material, and none with media of its own. A member that carries an image or audio ends the run and is a stop of one question, drawn by QuestionSheet as today (a player belongs to one mounted question, §11.3, and the gap card draws no media). Everything else is a stop of one question. stops.test.ts covers a run, a run broken by another type, a run broken by a member with an image and by one with audio, a lone bound member, a gap bound to a blank, and a paper with no groups.
  • A run is one screen. Its line reads "Questions 23–27 of 34 · Cloze · 5 gaps" on T-D4.3's s9 session; the total is the paper's question count (35 once R6 adds the matching, DG-185). Then "The gaps are in the passage. Pick a gap here or in the passage." (12.5px, muted), a strip of chips (32px, radius 8, a 20px circle with the question's number, then the chosen word or "—"; the open gap is filled primary), and a card (--sidebar, 1px border, radius 12, padding 14px) with "Gap 23" (13.5px 600), "{k} of {m} gaps filled", and the open gap's options in repeat(auto-fill, minmax(132px, 1fr)), gap 8px, rows 46px with the round letter marker.
  • Each member's own words stay on screen. When every member of the run has the same plain prompt and none has rich prompt content, that prompt is printed once above the hint at 17px, as the deck draws its single prompt, and not again in the card. Otherwise nothing is printed above the hint, and the card shows the open member's own prompt (plain or rich, 15px) between its header and its options. In both cases the card ends with the open member's worth line, as QuestionSheet prints it (DG-80), so a run whose members are worth different points says so gap by gap.
  • Choosing an option writes that member's choice answer and opens the next gap with no answer. Previous and Next and the arrows move by stop; a square, a "Go to" chip or a gap in the passage opens the run on that member's gap. A to E choose for the open gap; F flags its question.
  • The open-question key keeps holding a question's id: on a run it is the open gap's member, written on every change of gap. A reload reopens the run on the gap of the stored question, and cloze.test.tsx has the case. open-question.test.tsx passes untouched.
  • In the passage, a material that holds a gap is drawn in the deck's card (padding 18px 20px, 1px border, radius 12, --card, line-height 2.3). A gap bound to a choice question is the chip: at least 76px wide, 30px high, radius 7, 1.5px border, the 20px primary circle with the question's number, then the chosen option's text (its plain text, with an ellipsis past 160px); empty --muted with a --ring border, filled --accent-soft, open --card with a primary border and 0 0 0 3px var(--accent-soft). A gap bound to a blank keeps today's underline and goes to its blank. The chip is drawn for a lone bound member too, and in the teacher's preview with no chosen word.
  • The rail and the sheet keep one square per question; the squares of a run all open it, and the open gap's square is the current one. No square reads "Partly" for a cloze.
  • The Submit dialog's counts and chips do not change (submit-dialog.test.tsx passes with one new case: five empty gaps are five unanswered).
  • cloze.test.tsx covers both prompt cases (one shared prompt printed once; five different prompts, each shown with its gap; a rich prompt in the card), the worth line of the open member, and that choosing writes one choice answer under that member's id.
  • Compared with s9 question 23 at 360, 768, 1024, 1280 and 1440, light and dark, with no gap, two gaps and all five chosen. Known differences, all DG-184 and DG-185: gap numbers are question numbers, the line reads a range and "of 34", five squares stand where the deck draws one, and each gap shows its own member's prompt and worth where the deck has one prompt for all five.

As built (T-D4.10a, the engine): take-test/stops.ts holds paperStops(questions, groups), the stop of every position; a stop carries its Cloze, the material's single-choice-bound members, so a gap shown on its own still knows its cloze. "Media of its own" is question.media, an image or an audio (prompt content carries no media). A binding to a question outside its group, or to a member that is not single choice, makes no cloze. stepBack and stepOn move by stop and land on a stop's first question from either side; the key handler, Previous and Next all use them. A plain prompt is one with no promptContent or with rich content that is one plain paragraph (isPlainParagraph, now exported from sentenceGap.ts, T-D4.11b's reading), so a prompt made in the builder can be shared. A choice opens the next gap with no answer, going round from the open one as the design team's drawing does (the Student deck started from the first), and stays put when none is empty. ClozeBody draws the run. Its options are buttons with role="radio" in a radiogroup, not native radios, because a native radio chooses on the arrow keys and every choice opens the next gap; they are keyed by position, so focus stays in place when the next gap opens, and a polite live region names the open gap. Paper keys the visit and the kept scroll by the stop, so a gap opened inside the run (a chip, a choice) keeps the column's scroll and focus, while a square, a "Go to" chip or a passage gap lands as any move does. A gap on its own goes through QuestionCard with line and namesFlag. The navigator takes back and ahead. A run's squares are tied within a row (a 5 by 2px --ring span, aria-hidden); every square of the open run carries ring-2 ring-brand-soft, and the open gap's border-primary ring-2 ring-brand. Its aria-current stays "true", as on every current square, where the drawing writes "step". A square's name ends "bài điền từ câu 23 đến 27". The legend's "One screen" shows only on a paper with a run. In the passage, GapChip (components/shared/content/) draws the chip and GroupMaterials takes gapCard for the card; both styles are in content.css, which is unlayered, so the chip's focus ring is set there in --focus. The engine names a chip "Chỗ trống 25, relax" or "…, trống", as the drawing reads it; the teacher's preview draws it with no word and keeps its "go to question" link and name. New keys: takeTest.{flagQuestion,flaggedQuestion,legendOneScreen, dotInRun} and takeTest.cloze.{type,line,questions,gaps_one,gaps_other,gapsOf,hint,elsewhereOne, elsewhereMany,gap,filled,chip,empty}; a gap count is bound to its noun by a no-break space, and takeTest.cloze.type sits outside takeTest.type, which type-names.test.ts holds equal to the builder's menu. Tests: stops.test.ts (a run; broken by another type, an image, audio; a lone member; two materials; a blank; a member outside its group; no groups; stepping; going round; the shared prompt; the word), cloze.test.tsx (the screen and its English, one choice answer under the member's id, going round, the strip and the passage, the keys, the steps, a reload, own prompts with a rich one and their worth, a lock, a gap with media and a gap left alone, the ties and rings in the rail and the sheet), and submit-dialog.test.tsx's new case. Moved with the chip, no assertion dropped: group-context.test.tsx (six locators, and the underline class became the chip's empty state) and group-attempt.spec.ts (one locator). open-question.test.tsx and every Frozen suite of section B pass untouched. Spec §9 still says "one question at a time" (follow-up 8). Compared in a production build with drawing 2 of Cloze Decision.dc.html at 1280 (none, two and all five chosen, the run open on each gap, light and dark, English and Vietnamese) and with the Student deck's s9 question 23 at 360, 768, 1024, 1280 and 1440, light and dark: the strip's chips, the card and its header, the option rows (48px) and their markers, the worth line, the passage's chips (30px high, 76px at least), the cloze card (padding 18px 20px, 36.8px lines) and the rail's ties equal the drawing's in size, type and colour in every cell, and no cell scrolls sideways. Known differences: the question column is 12px narrower and 4px lower than the drawing's (T-D4.5's pane padding); the rail's section titles are the paper's whole titles (T-D4.4); gap 25 is worth 1 point on the fixture where the drawing gives it 2; the Student deck's card has no worth line (DG-184); below 768 the column sits 35px lower under the integrity strip (T-D4.7). QA-REV3-1 (T-D4.18c): a click that repeats the one that opened a gap (detail > 1, or the same option within 350ms) chooses nothing, so a double click or tap answers one gap; Space, Enter and A to E are never taken for a repeat.

As built (T-D4.10b, the result): ResultPage divides the result's questions with T-D4.10a's paperStops over the shared context's groups. A run whose members share one plain prompt (sharedPrompt, now generic over the question type) prints it once, under a run line ("Câu 23–27 · Điền từ vào đoạn văn · 5 chỗ trống", 12.5px 500 muted, over the prompt at 14px 500), above the first of the run's rows the filter keeps; under "Sai" it stands above 24. Those rows are ReviewItem's compact form: the number in 14px 600 and "Bạn trả lời …" on one line with the points at its end, 12px of padding, and the correct answer, the explanation and the comment under it as before. Own prompts print in every row as today. ReviewGroup takes gapWords (question id to the student's word and its mark), which only the student's result passes, so the teacher's grading views are unchanged: a gap bound to a choice question is then GapChip with a mark, the result's 28px chip (64px at least, padding 0 9px 0 4px, 14px 600), on --success-soft with a --success border when correct and --danger-soft with --danger when wrong, a wrong word struck through and no word an em dash; a policy that hides marks leaves it neutral (the engine's filled or empty chip). A passage that holds a chip is 15px with 2.3 lines (.content-review-gaps, only on the student's result). The chip is named "Câu 23, relax" or "Câu 26, Chưa trả lời" and leads to its row as the old gap did. New keys: result.cloze.{questions,chip}; the line reuses takeTest.cloze.{line,type,gaps,gapsOf}. Tests: result.test.tsx gains "a cloze on the result" (the run line and the one-line rows, the run line under a filter, own prompts, the chips' marks and their way to the row, neutral chips under a closed policy); its gap case and group-review.spec.ts's student case find the chip by its new name and assert its mark. Compared with drawing 3 of Cloze Decision.dc.html (both prompt modes, light and dark) in a production build at 360, 768, 1024, 1280 and 1440, light and dark, English and Vietnamese: the chips, the run line, the shared prompt and the rows equal the drawing's in size, type and colour, and no cell scrolls sideways. Known differences: the group card's header and the material's own heading are ReviewGroup's, as before; the drawing's passage sits in a plainer card.

T-D4.11 — Engine: a choice sentence with its gap

Depends on: T-D4.6 Touches: web/src/features/take-test/components/QuestionBody.tsx, web/src/features/take-test/sentenceGap.ts (new), web/tests/units/take-test/{sentence-gap.test.ts (new),renderers.test.tsx} Size: S Frozen: the prompt's stored text; the answer shape. Done when:

  • A single_choice or true_false question whose prompt is plain (no promptContent), one line, with exactly one run of three or more underscores and no other Markdown marker, is drawn as the deck's sentence: 18px (20px larger), weight 500, line-height 1.8, the run replaced by a box (at least 88px wide, padding 0 10px, radius 7, a 2px bottom border): empty on --muted with a --ring line, or the chosen option's plain text on --accent-soft with an --accent-c line. A screen reader hears "blank" or the chosen text in place.
  • Any other prompt prints as written. sentence-gap.test.ts lists the refusals: two runs, a rich prompt, a prompt of several lines, multiple choice, a run of two underscores.
  • Compared with s9 questions 5 and 12 at 360 and 1280, light and dark. This task is dropped without loss if the answer to Q17 is that the sentence is printed as written.

As built: sentenceGap(question) returns the text before and after the gap, or null. Q17 had no answer, so its default is built. "No other Markdown marker" is read conservatively, as characters: outside the run there is no _, *, `, ~, [, ], <, > or \, no HTML entity, and the line does not open with a heading (#), a list item (-, +) or a numbered item (1., 1)); | is allowed, since one line cannot be a table. The prompt is trimmed, so a trailing line break is still one line, and composed to NFC, as its Markdown is; diacritics play no part in the rule. The run may be longer than three (the deck splits on exactly ___). The box is a <span> in the sentence's <p>: empty, a non-breaking space hidden from assistive technology and "chỗ trống" / "blank" in sr-only (new key takeTest.sentenceGap); chosen, the option's text, or "Đúng" / "Sai" for a canonical true/false option as OptionText names it. --accent-c and --accent-soft are the brand and brand-soft utilities. It is not focusable, so it takes no ring; the rows keep theirs. max-w-full and break-words keep a long option inside a 360px column, which the deck does not draw. The teacher's previews draw QuestionSheet, so they show the gap too: detail.test.tsx, detail-versions.test.tsx and preview-and-leave.test.tsx found the preview by the literal prompt "They ___ …" and now find it as a screen reader reads the sentence (tests/support/gapSentence.ts); their fixtures are unchanged. The builder's prompt field and the import both write promptContent, so on content made in the product today the rule fired only for prompts kept in Markdown; T-D4.11b (follow-up 7) widens it to a rich prompt that is one plain paragraph. Compared with s9 questions 5 and 12 at 360 and 1280, light and dark, empty and chosen: the sentence and the box equal the deck's in position, size, type, padding, radius and colour in all 16 cells; the question sits 35px lower at 360 for the paper's integrity line above it, which the deck's paper has not.


C. Server and contract

What needs nothing, with the evidence. A passage, a cloze and a recording as shared content: StudentGroup carries stimuli, recordings and assets (api/openapi.yaml 2146-2165). A gap bound to a question or a blank: GroupStimulus.gaps (2326-2371), frozen at publish (migrations/00039) and validated on save (group_validation.go:176-212). Plays used: groupAudioPlays and recordGroupAudioPlay (6837-6870, x-permission: learning.take_tests). What spends a play is the client's decision; the counter and its description do not change. The rail, the sheet, numbering, the cloze stop and the drawer read the same session. The preview: previewTest and the browser's own mapping exist, and carry the paper only; a time limit and an integrity policy belong to the assignment, so the preview shows neither and no field is added for them (T-D4.16). The Shared content dialog: updateQuestionGroup replaces the graph under a revision, through the group editor that already calls it. No operation is added by any task in this file, so the isolation suite and the open list do not change.

What is not built, and has no task: a per-question hint, an image caption, a sign block, the word-size option mode, matching's nouns (DG-189, DG-194); a hard play limit (DG-182: it would make recordGroupAudioPlay refuse, which §11.4 forbids).

T-D4.13 — AudioPolicy.allowPause

Depends on: T-D4.1 (DG-183's row); Q4: the owner's yes, or the default (a boolean, on by default) if no answer has come when the task starts Touches: api/openapi.yaml (AudioPolicy, a new StudentAudioPolicy, StudentQuestion.audio, StudentGroupRecording.policy, the StudentSection description), server/gen/openapi/, web/src/lib/api/schema.d.ts, migrations/NNNNN_add_questions_audio_allow_pause.sql, NNNNN_add_test_version_questions_audio_allow_pause.sql, NNNNN_add_group_recordings_allow_pause.sql, NNNNN_add_test_version_group_recordings_allow_pause.sql (new; numbered at merge), server/internal/modules/questions/{domain/question.go,http/{input,questions}.go,repositories/store.go}, server/internal/modules/tests/{domain/{draft,publish_validation}.go,http/{tests,preview_groups}.go,repositories/{group_assets,group_frozen,group_read,preview,preview_groups,publish_groups,publish_group_snapshot,publish_load,publish_snapshot,version_draft}.go} and the group write, duplicate and group-copy paths, server/internal/modules/attempts/{domain/paper.go,http/{attempts,admin_attempts}.go,repositories/{store,review_review}.go}, the import commit's default, seed/, the student-shaped fixtures web/tests/support/groupPreview.ts, web/tests/units/take-test/{panes.test.tsx,question-audio-events.test.tsx,deckSession.ts}, web/tests/units/builder/group-preview.test.tsx, web/tests/e2e/audio-events.spec.ts and the MSW fixtures of a session, server/internal/modules/tests/application/tests/allow_pause_test.go (new), docs/plan/20-data-model.md, docs/quizzivy-spec-v0.3.md §11.1, §13, §15, docs/plan/74-r4.md and docs/plan/70-redesign-overview.md (their open items on "Students can pause") Size: M Done when:

  • make test-api-integration and the Go unit tests of questions, tests, attempts and media run on the branch point and on the last commit, both runs in the PR (high-risk area). The integration run is the one that holds publish_snapshot_test.go, attempts/application/tests/events_test.go, TestLockForVersionUseSerialisesAgainstDelete and TestLockForDraftUseSerialisesAgainstDelete; the PR names the four as passed.
  • The task adds a column to the rows these paths insert and changes no lock: every insert into app.test_version_questions still follows media.LockForVersionUse, and every insert into app.test_section_questions still follows questions.LockForDraftUse (AGENTS.md, "High-risk areas").
  • Four migrations, one concern each, each with a working Down, goose up/down/up in CI: questions.audio_allow_pause boolean and test_version_questions.audio_allow_pause boolean, both nullable with CHECK (audio_allow_pause IS NULL OR media_asset_kind IS NOT DISTINCT FROM 'audio') and read as COALESCE(…, true); group_recordings.allow_pause and test_version_group_recordings.allow_pause, both boolean NOT NULL DEFAULT true. A constant default and a nullable column: the previous binary keeps inserting during the rolling deploy, and none of the four rewrites or indexes a populated table. The biconditional questions_audio_policy_iff_audio is not widened, because the previous binary writes no value.
  • The contract. A group has no separate read and write shape: QuestionGroupBundle is the response body (StoredQuestionGroup, 2412) and the request body (GroupCreateInput 2451, GroupUpdateInput 2463), and one AudioPolicy (1694-1709, additionalProperties: false, three required fields) is referenced five times: AdminQuestion.audio (2031), StudentQuestion.audio (2141), StudentGroupRecording.policy (2175), GroupRecording.policy (2385) and QuestionInput.audio (3124), the last also inside every bundle's GroupQuestionInput. So "required in a response, optional in a request" cannot be said by adding an input schema, short of splitting the bundle in two (L). It is said this way instead: - AudioPolicy stays the authoring shape (2031, 2385, 3124) and gains allowPause: boolean, optional. Its description: the server always sends it; in a request an absent field means "do not change it". - A new StudentAudioPolicy has the same three fields and allowPause, all four required, and replaces the reference at 2141 and 2175. The engine never guesses. make gen is its own commit; MSW fixtures validate with ajv; pnpm typecheck passes. Tests are type-checked (tsconfig.app.json:38), so the student-shaped policy literals named in Touches gain the field or typecheck fails; the authoring-shaped ones (contract/group-contract.test.ts, question-bank/{editor-audio-expiry, structure-validation}.test.ts(x), question-groups/model.test.ts) compile unchanged, and QuestionValuesAreAcceptedByTheContract (questionSchema.ts:211-218, an assignability check) still holds without an edit.
  • What an absent field means, because updateQuestion and updateQuestionGroup replace the whole object and three clients send none: a tab loaded before the deploy, whose z.object parsers strip a key they do not name (questionSchema.ts:14-18); the same tab's device-recovery draft (question-groups/recovery.ts:15-19); and every tab until T-D4.15 teaches those parsers the field. On create, absent is true. On update, absent keeps the stored value: for a question by its id, for a member question by its id in the bundle, for a recording by its id; a recording or an audio policy that is new in the request is a create. "Absent means true" on update would silently turn a teacher's "cannot pause" back on at the next save from any of the three.
  • The value is copied wherever allowSeek is: publish (both snapshots), restore as draft, duplicate, group copy, the import commit (true), the teacher's preview, the student's session for a question's audio and for a group's recording, the teacher's review.
  • allow_pause_test.go: (a) publish a question and a recording with it off, edit both in the bank and the draft afterwards, and read the version's frozen false; (b) a version published before the migration reads true; (c) save false, then save the same question and the same group again with the field absent, and read false both times; (d) create with the field absent and read true; (e) a recording replaced by a new id in the same update reads true.
  • No operation is added or changes its x-permission; permissions.golden and the isolation suite are unchanged, and the PR says so. The existing second-teacher cases for updateQuestion and updateQuestionGroup cover the field. No x-resource entry is added or changed: allowPause is a boolean, not an id.
  • Leak review in the PR: allowPause is a rule a student is meant to read, not a key. The four forbidden keys are unchanged; StudentAudioPolicy holds none of them; payload_test.go, openapi-contract.test.ts and E2E 9 pass with their key lists untouched.
  • StudentSection's description reads "the engine shows it above each of the section's questions" (T-D4.5).
  • Spec §11.1 lists the fourth control, its default and the keep-on-absent rule; §13 and 20-data-model.md record the four columns and why two are nullable; §15 documents the two shapes.
  • 74-r4.md's open item ""Students can pause", "Matching" and "Not given"" and 70 §10's item on the content editor say that "Students can pause" is built: both name allowPause, this task and DG-183. DG-111's row in gaps.md says the same.
  • Rehearsed on a Neon branch of production; the timing goes in the release PR.

As built: the contract is AudioPolicy.allowPause (optional) and StudentAudioPolicy (all four required), which replaces AudioPolicy in StudentQuestion.audio and StudentGroupRecording.policy; the contract has two more AudioPolicy references than the plan counted (GradingQueueItem.audio and StoredQuestionInput.audio), both authoring-side, so they carry the optional field and the server sends it. The domain questions.AudioPolicy gains AllowPause *bool (nil is a writer that did not say) and PauseAllowed(); every reader selects coalesce(…, true) and fills it, so the value a repository returns is never nil. Migrations are 00107 to 00110 in the plan's order (00106 was the highest on develop and on work/deck-d4, and no open pull request added one); their Down was run with goose up, down-to 106 and up. F-68 then took 00111 to 00113 on develop, so the develop sync of 2026-10-12 renumbered the four files to 00114 to 00117, in order, and re-ran goose up, down and up on the whole set. Keep-on-absent is one statement for a question and for a group's member (CASE WHEN <audio present> THEN coalesce($new, audio_allow_pause, true) END), and for a recording the group update reads the stored allow_pause by id before it clears the group's recordings and rewrites them. An audio swap on the same question with the field absent keeps the stored value: it is the same question's policy, and only an audio policy that is new to the question (it had none, or an image) is a create. Publish copies the bank's nullable column as it is, so a NULL stays NULL and reads true; the recording column is copied as a bool. The version diff and the group-recording print count a changed rule (an unset rule and true are one rule), which the plan's Touches do not list: without it, flipping the switch after a publish would show "nothing changed". Three places outside the plan's Touches changed because pnpm typecheck found them: question-groups/preview.ts and tests/components/DraftPreviewDialog.tsx turn a teacher's policy into the student shape through one studentAudioPolicy() that fills a missing allowPause with true, and tests/units/take-test/superseded-load.test.tsx carries a student-shaped literal. deckSession.ts and the MSW fixtures hold no policy and needed nothing. The seed reads audio.allowPause and its listening question 30 cannot be paused, so T-D4.9 has one to compare. The import commit writes no audio, so its default is the create default and has no code of its own. Spec v0.90 changes §7, §11.1, §13 and §15; 20-data-model.md takes D-42 and §44; 74-r4.md, 70 §10, 40-open-items.md (O-R4.8) and DG-111's row in gaps.md name allowPause, T-D4.13 and DG-183 and say the engine and the switch follow. The Neon rehearsal is not done here: it belongs to T-D4.40's migration rehearsal, with the other D4 migrations. No operation was added, x-permission is unchanged and permissions.golden and the isolation suite are untouched.


Part 2 of 5 of D4 — Deck update: the fourth export and v2 (v0.11.0) · Previous · Next


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

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