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
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.
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 to184536f0…(Teacher),8c043d26…(Admin),dc6d6077…(Student),52196e86…(Landing),15ab7e2b…(Sign in) andbf0598f3…(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.thumbnailare not imported. Neither UIUX branch updated the manifest; this task regenerates it (Thuong's standing instruction). -
MANIFEST.sha256is regenerated with README's command, andnode scripts/check-design-deck.mjsprints "Deck OK: 15 files, 7 pages". - The check script reads
<dc-import name="X">as a reference toX.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-importwith the propsexamanddark; on the Student pagerenderVals()isrenderValsBase()overlaid byexamVals(), 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 fixturesEXAMS,EX_RAW,EX_GROUP,EX_MATCH,EX_UL,EX_SIGN,AUD_LEN,MAP_CELLS, including their answer keys (the seventh tuple entry andans), which never reach a student payload; the 45:00 preview timer; the two toasts after Submit for s2 and s9; the dead brancheshasPaneTabs,dotsMode,hasRefandisSign; the unread fixture fieldsq.orig,q.stem,printed,multiSec,pausesandexamBold(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") andtd.pvOptCols(T 7508-7509). And three slips of the prototype a reader must not copy: the arrow keys set the open question without going throughgo()(S 1616 against 1639), so they neither close the drawer or the sheet nor pause a recording when the section changes; Esc clears the oldsheetstate, notexSheet(S 1530); in a preview the header still says "All answers saved" and "Saving…" (S 961-963,saved()inexPick, S 1617). From v2: the AI plumbing (aiAskcalling a browser-side model with inline prompts, theAI_*_DEMOarrays shown when it fails,AI_USE0andaiSpendfaking usage,AI_LIMIT50,000, the per-kind costs, the 3.2s minimum run), the Admin fixturesAI_POOL,AI_DAY,AI_DAYS,AI_TEACHERSandAI_REQ0, the attendance fixturesATT_TODAY,ATT_ROOMandATT_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: nowrapand the leave buttonflex: 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 thedocumentprofile; 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), andpreviewTest, 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, noinert, 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 isaria-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, withinertin place ofaria-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 bypassgo(), 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 readapi/openapi.yaml(QuestionGroup,StudentGroup,AudioPolicy),web/src/features/take-test/**andweb/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:unmarkedis 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+nlabel is replaced by the id its row took, in a commit that holds nothing else. (DG+1toDG+21became 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/orserver/changes. The pull request targetswork/deck-d4:developkeeps 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.
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.12rangebecomestoday | 7d | 30d | all | customwithfromandtodates (inclusive, in the viewer's zone) forcustom, andqmatches 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 andaria-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 fromGET /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 kindsai.allowance_warning,ai.credit_requestandai.credit_request.decidedin 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;unmarkedstays). -
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'svocabkind (aPOST /teacher/ai/words/generateoperation underai.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-110readsoptionIds). 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 gainsSharedContentDialog.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
qmatches the action's sentence on the server, sodomain/actions.goholds each action'sviandensentence andcatalog_test.gokeeps them equal to the locale files. T-R5.18's usage read gainsaiCreditsand its attention listai_credit_requests, through a port typed as T-D4.31's credits query. T-R8.6's sessions list gainssince,marked,room,studentCount,countsandnotMarked, and T-R8.2's marks table a nullablenote. T-R10.4's stats gainbandsand its words listband, 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
generateWordsoperation to theaimodule, which section G prices but T-D4.32 does not list. -
Stale text outside the list.
76-r6.mdsaid true / false answers score 0; the grader has readoptionIdssincegrading.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).
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/mediarun 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.yaml2724-2737: a draft or an older attempt may still holdtrue_falsewith a boolean, whichanswered.ts:18-19andgrading.goread, anddraft-compat.test.tsis the proof it survives),answered()'s boolean rule, the flags keysessionStorage['quizzivy.flags.<attemptId>'], the held-session keysessionStorage['quizzivy.session.<attemptId>'], written bygetAttempt,startOrResumeAttemptandcontinueAttemptand named on every attempt read (#337), the draft keyquizzivy.answer-draft.<attemptId>, and the open-question keysessionStorage['quizzivy.open-question.<attemptId>'], which holds a question's id and is written fromTakeTestPageon every move and read on load (#320, ondevelopsince49c9f0e1). 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 undertests/units/integrity/(includingsequence.test.ts,shared-audio-record.test.tsandtimeline-shared-audio.test.ts).clientSequses an attempt-relative offset calibrated ingetAttemptfromstartedAtandserverTime, 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.goandtimeline_sparse_test.gopreserve 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.
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:
-
Paperand every component under it read the paper from one context: the title, the questions, sections and groups, the answers andsetAnswer, the flags andtoggleFlag, the lock and the submit state, the save status, the play counts, and for a shared recordingnotePlay, the pending-sync flag andflush.TakeTestPagefills it fromuseTakeTestStore,useGroupPlaybackStoreanduseSaveStatus. After this task no file undercomponents/thatPapermounts imports a store: todayClock,EngineHeader,GroupContext,QuestionAudio,QuestionCardandSaveStatedo, andPaperitself (TakeTestPage.tsx:347-353). The stores are not edited. The open question's restore and its write (openQuestion.ts) stay inTakeTestPage, outside the source. - The timer comes through the source too.
ClockreadsdeadlineAt,offsetMsand the deadline lock from it, and its one-second tick calls a stabletimeLeft()reader the source carries, so it still reads the live store on every tick and never a value captured at render (Clock.tsx:47-63today). The deadline-lock rule of #329 is not touched:timer.test.ts,deadline.test.tsandheader.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,onAudioEndedandonAudioBlocked, whichTakeTestPagefills with today's calls (notePlayandrecordAudioEvent(attemptId, "audio_play" | "audio_ended" | "audio_blocked", id),QuestionAudio.tsx:34-46).QuestionAudiono longer importsfeatures/integrity.question-audio-events.test.tsxpasses with its assertions untouched, its render call moved torenderWithPaper(see the next bullet but one), andaudio-events.spec.tswith no edit. - One hook answers a width question,
useEngineWidthAtLeast(px). UnderFocusLayoutit isuseMediaQuery, soviewport()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.tsgains 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 withmaxPlays2: 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 carriesansor a correct index.deck-fixtures.test.tsvalidates 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,integrityandmediapasses with no edit beyond the render helper insupport.ts: the three suites that mount a paper component bare (renderers.test.tsx'sQuestionCardcases,question-audio-events.test.tsx,shared-audio-events.test.tsx) render throughrenderWithPaper, which mounts the live source asTakeTestPagedoes, 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 restatesaveStatus.ts;paper-source.test.tsxrendersPaperfrom 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'spnpm e2e:liveare 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.
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 neitherSideColumn,PageAsidenoruseColumnWidth:tests/units/student/side-column.test.tspasses untouched. - Per section: its title (12.5px, line-height 1.35; 600
--fgfor the open question's section, 500--muted-fgotherwise) 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-softwith a primary border; empty is--cardwith--border. The open question adds the--accent-cborder and0 0 0 2px var(--accent-soft). A flag is a 9px--warningdot with a 2px--cardborder 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)returnsnone,partlyorfull. Partly is a fill-in with at least one blank typed and not all.answered()is unchanged and still equalsprogress === "full";answered.test.tsgains 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, thelayout-gridicon 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.tsxkeeps 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.tsxcrosses 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.tsxis 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.
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
--sidebaronly 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;
opensSectionandSectionInstructions.tsxare removed. - That section header is drawn by
Paper, aboveQuestionCard, never byQuestionSheet.StudentPreviewstacks every question in one list and mountsQuestionSheetfor 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 rendersStudentPreviewwith 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 ofquestionEditor.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.tsasserts 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
h2is insideisPassage), 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.GroupMaterialsgains the audio-only rule; the audio node itself is still today's label until T-D4.8. -
StudentPreviewtakes the type names (it sharesQuestionSheet) and the kicker and heading (PreviewGroupContextsharesPassageBodyandGroupMaterials);builder/group-preview.test.tsxandgroup-preview.spec.tsfollow. - 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'sStudentSectiondescription ("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.tsxgains "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.
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-fgon--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,--primaryonce typed),--mutedfill, 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"andspellcheck="false"; "1 word" is singular in English. - Larger text: the sentence 20px, options 17px, as drawn (
sentSize,optSize). -
renderers.test.tsxasserts 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.tspasses 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".
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-lgwhile open, a backdrop that darkens to 45% of the overlay token (no literal colour:no-raw-colours.test.tsstays 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, thefile-textoraudio-linesicon (15px), a 7px--accent-cdot 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 insessionStorage['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
inertand stays mounted, so the place in the passage and a playing recording survive; underprefers-reduced-motionthere is no nudge, no tip animation and no slide. The deck marks the closed draweraria-hidden(S 1041, 1700);inertis taken instead becausearia-hiddenleaves 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.tsswipes 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.
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'srenderAudio, which already receives the node, the asset and the recording, returns the player.GroupListeningleaves the question pane and itsstimuli.length === 0branch 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.
hasPassagebecomes "has shared content" (stimuliorrecordings), 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).
-
AudioPlayergainslayout="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, orrotate-ccwafter a finished play); a 6px track on--mutedfilled--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 sametoggle()serves both layouts;audio-player-card.test.tsxrepeats 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.
AudioPlayercallsonPlayfor the first andonResumefor the second; both hosts count ononPlayonly. 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 onloadedmetadata), never insidetoggle()ahead ofplay(); - the value the guard compares with is set to the remembered position beforecurrentTimeis, so the guard sees no jump, puts nothing back and does not callonSeekBlocked: 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 throughonPlayand 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_playevent; 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,pauseandpausedonly):audio-player-resume.test.tsx(a pause and a resume callonPlayonce; a second start after the end calls it twice; a remembered play resumes withplay()in the click's tick and noonPlay; an empty memory counts; a restore does not callonSeekBlocked; a seek by the student with seeking off still does);question-audio-events.test.tsxgains "a resume records no second play";group-context.test.tsxgains 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.tsgains (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 thatcurrentTimeis past the remembered point and the server's count is still 1. Its existing assertions are unchanged.student-engine.phone.spec.tsrepeats (b) in mobile-chromium against the stubbed API, counting thegroup-audio-playrequests. - 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.
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:
-
AudioPlayertakesallowPause(default true). When false and playing, the button isaria-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 recordingallowPause: 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
allowPausedoes, and what it cannot stop. -
audio-player-pause.test.tsxcovers 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.
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:
- Ties between a run's squares and a "One screen" entry in the legend, in the rail and the sheet (T-D4.10a).
- 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). - The flag button names the open gap's question (T-D4.10a).
- The line reads "{k} of {m} gaps" for part of a cloze, and "Cloze" on a gap shown on its own (T-D4.10a).
- The run's hint names the gaps that have their own screens (T-D4.10a).
- Result: a shared prompt is printed once on a run line, over one-line rows (T-D4.10b).
- Result: the passage's words in the verdict colour, "recommended, can follow" (T-D4.10b).
- 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.tsfinds a cloze run: two or more consecutivesingle_choicemembers of one group, each bound by aGroupQuestionGapto 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 byQuestionSheetas 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.tscovers 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 inrepeat(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
QuestionSheetprints it (DG-80), so a run whose members are worth different points says so gap by gap. - Choosing an option writes that member's
choiceanswer 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.tsxhas the case.open-question.test.tsxpasses 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--mutedwith a--ringborder, filled--accent-soft, open--cardwith a primary border and0 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.tsxpasses with one new case: five empty gaps are five unanswered). -
cloze.test.tsxcovers 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 onechoiceanswer 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.
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_choiceortrue_falsequestion whose prompt is plain (nopromptContent), 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--mutedwith a--ringline, or the chosen option's plain text on--accent-softwith an--accent-cline. A screen reader hears "blank" or the chosen text in place. - Any other prompt prints as written.
sentence-gap.test.tslists 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.
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).
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-integrationand the Go unit tests ofquestions,tests,attemptsandmediarun on the branch point and on the last commit, both runs in the PR (high-risk area). The integration run is the one that holdspublish_snapshot_test.go,attempts/application/tests/events_test.go,TestLockForVersionUseSerialisesAgainstDeleteandTestLockForDraftUseSerialisesAgainstDelete; 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_questionsstill followsmedia.LockForVersionUse, and every insert intoapp.test_section_questionsstill followsquestions.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 booleanandtest_version_questions.audio_allow_pause boolean, both nullable withCHECK (audio_allow_pause IS NULL OR media_asset_kind IS NOT DISTINCT FROM 'audio')and read asCOALESCE(…, true);group_recordings.allow_pauseandtest_version_group_recordings.allow_pause, bothboolean 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 biconditionalquestions_audio_policy_iff_audiois not widened, because the previous binary writes no value. - The contract. A group has no separate read and write shape:
QuestionGroupBundleis the response body (StoredQuestionGroup, 2412) and the request body (GroupCreateInput2451,GroupUpdateInput2463), and oneAudioPolicy(1694-1709,additionalProperties: false, three required fields) is referenced five times:AdminQuestion.audio(2031),StudentQuestion.audio(2141),StudentGroupRecording.policy(2175),GroupRecording.policy(2385) andQuestionInput.audio(3124), the last also inside every bundle'sGroupQuestionInput. 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: -AudioPolicystays the authoring shape (2031, 2385, 3124) and gainsallowPause: boolean, optional. Its description: the server always sends it; in a request an absent field means "do not change it". - A newStudentAudioPolicyhas the same three fields andallowPause, all four required, and replaces the reference at 2141 and 2175. The engine never guesses.make genis its own commit; MSW fixtures validate with ajv;pnpm typecheckpasses. 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, andQuestionValuesAreAcceptedByTheContract(questionSchema.ts:211-218, an assignability check) still holds without an edit. - What an absent field means, because
updateQuestionandupdateQuestionGroupreplace the whole object and three clients send none: a tab loaded before the deploy, whosez.objectparsers 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
allowSeekis: 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.goldenand the isolation suite are unchanged, and the PR says so. The existing second-teacher cases forupdateQuestionandupdateQuestionGroupcover the field. Nox-resourceentry is added or changed:allowPauseis a boolean, not an id. - Leak review in the PR:
allowPauseis a rule a student is meant to read, not a key. The four forbidden keys are unchanged;StudentAudioPolicyholds none of them;payload_test.go,openapi-contract.test.tsand 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.mdrecord 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 nameallowPause, this task and DG-183. DG-111's row ingaps.mdsays 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.