Plan 74 R4 Part 4 - SekiroKenjii/Quizzivy GitHub Wiki
Part 4 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next
Depends on: T-R4.25, T-R4.11, T-R4.12, T-R4.13, T-R4.2b
Touches: web/src/features/assignments/pages/teacher/AssignmentDetailPage.tsx, web/src/features/assignments/components/{ItemAnalysis,SettingsGroups,ExtendDialog,DuplicateDialog,ReopenStudentDialog}.tsx (new), web/src/features/assignments/components/{CloseEarlyDialog,ReopenDialog}.tsx
Reuses: FormDialog, ProgressBar, LockNotice, CloseEarlyDialog, ReopenDialog (reopen for everyone on a closed assignment), DateTimeField
Size: M
As built:
-
Files.
ItemAnalysis,SettingsGroups(withsettingsBody.ts) andReopenStudentDialogare new; the list'sExtendAssignmentsDialogandDuplicateAssignmentDialogserve the detail too;overrides.tsholds the one-more-attempt rule and the Close early count. The wizard's Rules and Schedule steps are split into their groups (OrderRules,ResultRules,IntegrityRules,StudentNoteField,WindowFields,TimingRules) so the Settings dialogs reuse them (DG-162); the wizard composes them as before.ReopenMenuandReopenDialogare retired (DG-174). -
Questions.
getItemAnalysis(teaching.grading) lists the version's questions hardest first with the bar in danger below 40%, warning below 65%, success otherwise; without the permission the tab says so and reads nothing. T-R4.25's version card and student preview stay below it. -
Settings. Five cards: Test & timing (question order with it; locked while live, the lock
icon and the reason as its tooltip, no Edit), Window, Integrity, Results, and the note to
students (DG-71). Each Edit opens the wizard's controls for its group (DG-162) and saves with
updateAssignment, keeping every other value as stored and an unchanged window to the second; a draft stays a draft. The toast is "{group} saved. Students see the change right away."; a 409 keeps the dialog open with the server's message, and Save keeps focus through its request. Window is locked too once the assignment is closed, with its own reason: a closed assignment's close moves only through Extend, which keeps the reason and the audit (DG-160). -
Extend. The header's Extend opens the dialog on a scheduled, live or closed assignment
(QA-F1-4). For Everyone moves the close through
extendAssignment; Chosen students (withteaching.attempts.intervene) lists the roster with "Not started" and "In progress" and writes each override withextendByand the required reason (DG-160). On a closed assignment the steps count from now, and a fifth, "Pick a time…", takes a date and a time that must be ahead: Everyone reopens throughreopenAssignmentwith the reason and without Notify, which that operation lacks, and Chosen students getclosesAt, the list leaving out anyone whose own close is already later, so no override is shortened (DG-174). A list's or chips' options inFormDialogmay now be a function of the values, for that list. -
More menu. Edit settings (the Settings tab; a draft keeps Edit in the wizard), Duplicate
(#479's dialog), Reopen for a student (student, 30/45/60 min, a date and a time to stay open
until, the reason; one more attempt only for a student with none left, counting the extra
attempts their override already holds, read from
listStudentOverrides, and sending that number plus one, since the server replaces the stored value; DG-163, DG-175) and Close early (DG-161, with the count of students whose own extension reaches past now; none draws no sentence). Each dialog gives focus back to the "…" button, and Extend's to Extend (F-21); Publish keeps focus while it runs (aria-disabled). -
The sheet. "Grade answers" opens
/teacher/grading?assignment={id}&student={id}, the queue filtered to the student (QA-F1-5). -
Not done here.
FormDialogandConfirmDialogstill disable their submit and fields natively while pending (T-R4.1's contract, pinned byform-dialog.test.tsx), so a failed request in Extend, Duplicate, Reopen for a student or Close early leaves focus on the page body; that is a change to the shared component.
Done when:
- Students tab (T-R4.25): overrides shown on the row ("Extended to …", T-R4.12: the monitor row's
extendedTo, which is set only when the student's own close is later than the assignment's) - Questions tab: "Questions sorted by how often students got them wrong.", rows with number, prompt excerpt, type, and a bar in danger below 40%, warning below 65%, success otherwise
- Settings tab: four cards — Test & timing (locked while live, lock icon and tooltip, no
Edit), Window (opens, closes, closing time, assigned to), Integrity (fullscreen,
copy/paste, leaving the test with the wizard's options), Results (score, correct answers,
explanations, class average, and when results show: after submitting / after the window
closes, DG-65); each Edit is a
FormDialog; toast "{group} saved. Students see the change right away."; a 409ASSIGNMENT_LOCKEDshows its message. "Note to students (optional)" (T-R4.11) is shown and edited here too, placed per its DG entry - Extend dialog, which the header's Extend button opens: Extend by 15 min / 30 min / 1 hour /
1 day; For Everyone | Chosen students
(the students list with "Not started" / "In progress" meta, only with
teaching.attempts.intervene); Notify students ("They get an in-app message"; R7 adds email to the text); on a closed assignment it reopens throughReopenDialog's operation - Extend for chosen students, and Reopen for everyone and for a student, carry a required
"Reason" field: their operations require
reason(T-R4.12,reopenAssignment) and the deck draws no field for it. This task opens the gap row - Duplicate: "Assign to" chips, creates a draft; Reopen for a student: Student, Time limit 30/45/60 min, Open until; Close early confirm with the deck's copy
- The "Close early" dialog says how many students have an override that reaches past the close
and that they keep it (T-R4.12, R12a-7: closing early never removes an override, and the
monitor marks those students as still open through
extendedTo). The count comes from the monitor rows, so it needs no operation of its own; a count of zero draws no sentence - Tests: the lock renders for a live assignment; Extend chooses the operation by "For"; the dialogs validate; vi keys listed in the PR; deck check at the five widths, light and dark
Reopened in D4 (74d-d4-deck-update.md): T-D4.37 added "Explain results with AI" and its
insights to the Questions tab, with a line in its place while "Let AI read student answers" is off;
nothing this task built was replaced.
Depends on: T-R4.4, T-R4.5b, T-R4.2b, T-R4.2c
Touches: web/src/features/assignments/pages/teacher/AssignmentWizardPage.tsx (new, replaces AssignmentFormPage.tsx), web/src/features/assignments/components/WizardStepper.tsx (new), web/tests/units/assignments/, web/tests/e2e/support/live.ts, web/tests/e2e/admin-change-requests.live.spec.ts
Reuses: TestVersionPicker, ClassTargetPicker, StudentTargetPicker, useTargetRoster, TargetsLine, R1's MarqueeText, FormDialog
Size: M
Done when:
- Header "New assignment" / "Nothing is sent until you press Assign. Defaults are the safe
choice."; width 1080;
?step=in the URL;/teacher/assignments/:id/editopens a draft in the wizard - Stepper: Test, Students, Schedule, Rules (four columns, two on phones); any step can be opened; done steps show ✓; each value line marquees on overflow only and is static under reduced motion
- Step 1 "Which test?": published tests as radio rows with a wired search and meta "{skill} ·
{q} questions · {n} need manual grading";
?test=preselects - Step 2 "Who takes it?": class cards with student counts; "Add individual students" dialog
(searchable, "· already included" meta); the individual panel marks "Already included
through {class}"; the total line "{N} students will get this test · {c} from classes, {e}
added one by one";
?class=preselects -
?test=and?class=: today's names aretestIdandclassId; the four link sites and their tests change in this task - "Save draft" on every step persists every field (DG-65) and goes to the Drafts tab with "Saved as draft. Students cannot see it."; Cancel on step 1, Back after; leaving with unsaved changes asks
- Tests: dedupe count, preselection from the URL, draft round trip; keys vi first
As built:
-
Steps 1 and 2 only, as W3 decided. Schedule and Rules are in the stepper with their value
lines, but cannot be opened yet, and Continue on Students is disabled above the sentence
"Schedule and rules arrive with the next update" (
assignments.wizard.laterSteps)./teacher/assignments/:id/editstill opensAssignmentFormPage. T-R4.27b opens both steps, routes the edit address to the wizard and removes the sentence; it ticked the first two boxes. -
The draft is one shape for both pages.
features/assignments/draft.tsholdsAssignmentDraft,emptyDraft,draftBodyanddraftOf, which keeps the stored review policy whole (releaseandshowClassAverageincluded).usePreselect.tsholds the two URL preselection hooks.AssignmentFormPageimports both instead of keeping private copies. - Every field leaves on Save draft. The wizard holds a whole draft from the defaults, so a draft saved from step 1 or 2 carries the window, the time limit, the attempts, the review and the integrity policy (DG-65). Save draft needs a test, and says so in the footer when there is none.
-
?test=and?class=preselect without counting as a change, so leaving straight away does not ask. The wizard still readstestIdandclassId, because an old/admin/assignments/new?classId=bookmark redirects with its query. The four link sites (TestCard,ClassAssignmentsCard,ClassesListPage,ClassDetailPage) write the new names. -
Step 1 picks the current version. Each row reads its test's versions (one request a row,
shared with the old picker's cache key) for the question and manual-grading counts, and stays
disabled until they arrive; a row whose versions fail says so and offers Retry (QA-27a-2).
The meta describes only the version being picked: "{q} questions · {n} need manual grading".
The meta omits skills until TestVersion carries them (F-43):
Test.skillsis the draft's, and no version answer carries skills (QA-27a-1). When the field lands, T-R4.27b puts it back inversionMeta. - Save draft posts once. A ref is set before the request and cleared only on failure, so two clicks in one tick send one draft (QA-27a-3).
-
useTargetRosteralso returnsfromClassesandclassesOf. The total line is built from them, and so is the panel's "Already included through {class}", which names the first picked class, in the order picked, that already holds the student. The dialog's "· already included" comes from eachStudentRow.classes. The dialog loads every active student, 100 a page, when it opens. -
Leaving asks.
useBlockeris on any change of path except/login, anduseBeforeUnloadis on while there are unsaved choices. A step change is a search-parameter change and does not ask. -
The live helpers go through the wizard.
assignToClassand the change-requests spec pick the test and the class in the wizard, save a draft and assign it from the edit form. T-R4.27b moves the Assign to the wizard's last step. -
Tests:
web/tests/units/assignments/wizard.test.tsx(the header and stepper, preselection from the URL, the dedupe count and the panel, Save draft's body, the leave dialog, and the draft round trip).target-roster.test.tsxgained the two new fields.
Reopened in D4 (74d-d4-deck-update.md): T-D4.20 replaced WizardAside's sticky top-0 with
StickyAside from @min-[794px]/wizard (the deck's 16px offset and height cap).
Depends on: T-R4.27a, T-R4.7, T-R4.11
Touches: web/src/features/assignments/pages/teacher/AssignmentWizardPage.tsx, web/src/features/assignments/components/{ScheduleStep,RulesStep}.tsx (new), web/src/features/assignments/{studentRules.ts,components/StudentRulesPreview.tsx,input.ts}, web/tests/units/assignments/student-rules.test.ts
Reuses: DateTimeField, NumberStepper, segmented control, StudentRulesPreview, studentRules.ts
Size: M
Done when:
- Step 3: Opens and Closes in HCM, as every teacher wall-clock input is until T-R4.43 (spec v0.57); the switch to the account's zone is T-R4.43's; Time limit 30 / 45 / 60 / Custom (±5 stepper, 1–600); Attempts 1–99 with "One attempt, no retakes" or "The best score counts. Each attempt has its own time limit."
- Step 4: Question order (shuffle questions, shuffle options); After submitting (score, correct answers, explanations — dimmed with "Turn on correct answers first" until correct answers are on); when results show (after submitting / after the window closes); class average; "Note to students (optional)", up to 500 characters, shown on the student's Test intro (T-R4.11; placed per its DG entry)
- Integrity per D8: Require fullscreen; Block copy and paste; Leaving the test No limit /
Not allowed / Set a limit (1–99, "times allowed"); When the limit is passed Warn / Flag
for me / Auto-submit, dimmed under No limit with "Choose a limit first. Every time is
still recorded."; Ignore short absences 0–30 s; the footnote "A browser cannot prevent
cheating. These rules record what happened so you can decide." Mapped to
IntegrityPolicy(no limit 0, not allowed −1, limit n) - Defaults come from
preferences.assignmentDefaults, falling back to the deck: 45 min, shuffle off, show score on, block copy on, fullscreen off, no limit, flag, 3 s. Existing assignments keep their stored policy (70 §6) - Aside: summary lines, "What students will read" from
studentRules.ts(vi first), and "About {m×n} answers to grade by hand" or "Everything is marked automatically." - The score sentence follows
review.release(T-R4.11): withafter_closeit says the score is seen once the test has closed, never "after submitting". The preview and the student's Test intro sharestudentRules.ts, so both change;student-rules.test.tsruns both values (73-r3.md T-R3.8 left this here) - Final "Assign to {N} students" publishes and goes to the Scheduled tab with "Assigned. Students are notified when it opens."
- The rest of T-R4.27a's first two boxes:
/teacher/assignments/:id/editopens a draft in the wizard (AssignmentFormPage.tsxis then left unrouted for T-R4.48), Schedule and Rules open from the stepper, and the sentence "Schedule and rules arrive with the next update" and its key go - Tests: integrity mapping, dependent dimming, defaults precedence, the student text for each combination; deck check at the five widths, light and dark
As built:
-
The wizard is the edit form too.
/teacher/assignments/:id/editloads the assignment and its test's versions into the same wizard (skeleton, then the load error with Retry). A published assignment's header is "Edit assignment" with "Changes reach students as soon as you save.", it has no Save draft, and its last button is "Save changes", which saves in place and opens the detail page. A draft keeps Save draft and assigns like a new one.AssignmentFormPage.tsxis unrouted and left for T-R4.48, withform-edit.test.tsx. -
The window has one zone.
draft.tsreads and writes it throughWINDOW_ZONE(windowInput,windowInstant), which isAPP_TIME_ZONEuntil T-R4.43 moves every teacher wall-clock input together; the stepper's date line uses the same zone. The preview keeps HCM throughpreviewRules(now instudentRules.ts, shared by the aside andStudentRulesPreview). -
Defaults.
emptyDraft(now, defaults)takes the five stored fields ofpreferences.assignmentDefaults(read from the auth store) and falls back to the deck's; the rest are the deck's fixed starting point.draftOfnever reads the defaults, so a stored assignment keeps its policy. -
Rules beyond the deck. "When results show" (After submitting | After the window closes)
and "Class average" (DG-137's conditions in its hint) sit in the "After submitting" group;
"Note to students (optional)" is a plain field under the groups (DG-71) with a counter to
500, counted before trimming, and an empty note is sent as
null. Turning correct answers off also turns explanations off, so a stored policy never holds explanations without answers. -
Leaving the test maps through
leavingModeandleavingValue(wizardValues.ts): no limit 0, not allowed −1, a limit n, and a new limit starts at 2. Under no limit the action's three options are disabled and the row is drawn at 45%. - Assign. The last step's button is "Assign to {N} students" from the roster count, or "Assign" while it is counted. A missing test, no one picked or a window that does not close after it opens opens that step with the reason in the footer. A window that has already opened goes to the Open tab with "Assigned. Students can start now."; one still to open goes to Scheduled with the deck's sentence. Save draft and Assign share the one in-flight guard.
-
Live specs.
assignToClassgoes through all four steps; the change-requests spec sets the custom time limit, the attempts and the integrity options in the wizard and reads them back from the edit address. -
Tests:
web/tests/units/assignments/wizard-rules.test.tsx(the leaving mapping, defaults and their precedence, explanations waiting for answers, the action dimmed under no limit, the student text for release, fullscreen and score, assigning, the gap, editing a published assignment and a draft). The shared stubs areweb/tests/support/assignmentWizard.tsx.student-rules.test.tsalready runs both release values.
Depends on: T-R4.4, T-R4.5b, T-R4.14
Touches: web/src/features/attempts/pages/teacher/GradingPage.tsx (new, replaces GradingQueuePage.tsx and components/GradeByQuestion.tsx), web/src/features/attempts/components/GradingCard.tsx, web/tests/units/attempts/{by-question,shared-review}.test.tsx
Reuses: GradingCard, AnswerReview, gradeAttempt, finishGrading
Size: M
Done when:
- Header "Grading" / "{n} answers left from {m} students."; By student / By question in
?mode=;?assignment=and?student=narrow the queue - Queue aside 300px (full width and 180px high on phones): avatars or "Q{n}" tiles, "{n} left" or "Done", the active row with the lime inset bar
- Card: 3px progress bar, type pill and "Question {n} · {max} pt", prompt, the answer, Accepted chips when present, the sample answer for the teacher; score buttons from 0 to max in 0.5 steps with their key numbers when that is at most nine values, a number field otherwise
- Picking a score saves at once; comment "Comment (optional, student sees it)" with four snippets per kind (vi first) that append; Previous and "Save & next" follow the queue grouping (DG-64); no score picked shows an error-tone toast "Pick a score first (keys 1–{n})"
- After a successful last manual save and confirmed
pendingManual = 0, explicitly callfinishGrading(DG-64). Grade success is not Finish success. Keep the known attempt and Retry Finish after failure, without replaying Grade. Do not infer an absent optional pending count is zero. Score-release wording requires actual Finish and existing policy. - After reload, recovery exhausts all public
listAttemptspages separately for submitted and timed_out withpendingGrading=false, deduplicates attempt IDs, then applies the assignment/student filters and offers current review plus explicit Finish. These candidates also include auto-only or unanswered papers: never label all as interrupted grading or finish them unattended. OFFSET scans are complete for a stable finite fixture; concurrent changes can shift pages and require rescan. No new completion endpoint or write is needed. - Verify saved-mark preservation, Finish failure and Retry, reload discovery beyond the first 100 candidates and within student/assignment filters, independent review visibility and explicit re-grading. Task14 read approval does not close these consumer recovery gates.
- j / k move and 1…n pick, never inside an input or textarea; the footer reads "Answer {i} of {n} for {given name}" with the key hint on desktop
- States: skeleton, "Nothing to grade" empty, error; deck check at the five widths, light and dark; keys vi first
As built:
-
Queue order is stable (QA-416-2). The server returns pending answers only, so a graded
answer would leave its list and fall behind its group.
mergeQueueOrderkeeps every answer where the queue first showed it and slots a newly returned one after the answer the server returned before it; the aside lists groups in that order, with the server's complete counts. Save & next therefore reaches the same student's next answer by student, and the next student on the same question by question, and the footer's "Answer {i} of {n}" counts in that order. -
StrictMode (QA-416-1). The request controller is created in the effect that aborts it,
so StrictMode's simulated unmount no longer leaves a dead controller; the shared grading
test mount renders under
<StrictMode>. - Keyboard (QA-416-3). The shortcuts listen on the document while the page is mounted, so a key works wherever focus is not in an input, a textarea, a select, an editable element, a dialog, a menu or a listbox, and no modifier or composition is active. When a save settles with focus dropped to the page, focus returns to the card's chosen score (or its first score, or the card).
- Copy and geometry (QA-416-4): a graded group reads "Done"; the footer reads "Answer {i} of {n} for {given name}"; a score button is named "Give 1 point" or "Give {n} points" (vi "Chấm {n} điểm"); score digits 15px at deck scale; the sub line 14px; the heading's line height 36px; the type pill a block 22.8px high; card and aside on the card shadow token; the by-question tile 8px; Next padding 16px. The undrawn "Full paper review" link is an icon button named by its label, so the card's footer keeps the deck's single row (63px) from 768 to 1440. The filter selects are gone: a narrowed queue shows a removable chip (DG-136).
-
Concurrent grading is last write wins (T-11). Grading is last-write-wins in R4. The
second write replaces the score, the comment,
graded_byandgraded_at. Grading is not audited (spec §13.4 does not require it). The contract gets no concurrency token in R4. The grading queue refetches whenever its window regains focus (refetchOnWindowFocus: "always"), and the open card shows the refetched score and comment unless it holds an unsaved edit of its own. The queue holds only ungraded answers, so an answer graded in another tab leaves it; the open answer is kept on screen when that happens, and its score and comment are read from the attempt's review after each queue refetch (VER-416b, QA-416b-4). -
Where Save & next and Finish go (QA-416b-2). Both continue to the next ungraded answer
after the current one in the mode's order, wrapping round (
nextOpenItem); Finish skips the finished paper. By question, Finish on a student's last answer goes to the next student's ungraded answer, not to an answer already graded in this session. -
Removing a filter chip (QA-416b-3) navigates with
state.focus, and the remounted page puts focus on the heading (tabIndex=-1). -
Navigation guard (REV-416, R416-1). A score is written on the click, but a comment only by
the flush that
leaveruns.useGradingQueuereportsunsettledwhile the selected answer's trimmed comment differs from its acknowledged one (or the queue's), or a comment has no score. While it is set,useBlockerstops any navigation that changes the path or the query and asks "Rời trang khi nhận xét chưa lưu?" with "Lưu và rời đi" (leave, which saves, then proceeds) or "Ở lại"; with no score yet the dialog shows "pick a score first" and stays. The page's own navigations (filter chips, the mode toggle, "Full paper review") go throughleaveand are let through, by a flag that lasts for that one navigation: it resets whenever the location changes, and a chip or toggle whose target is the current query does not navigate (R416-6; before, clicking the pressed "By student" left the flag set and the guard off).beforeunloadwarns while it is set. The j and k keys compareevent.key.toLowerCase(), so Caps Lock moves too (R416-3). -
Retired (R416-2):
pages/teacher/GradingQueuePage.tsx, which nothing imported; the route isGradingPage. -
Deck geometry, VER-416b (QA-416b-5): the card head's student name 14px/21px; the
"Question", "Score" and "Comment" labels 18px high, the Comment label a full-width block; 8px
between a score's digit and its key; the heading's letter spacing -0.02em (-0.48px). The
tester's
matrix416bshows none of the five at any of the 18 boards. -
Tests:
grading-order-keys.test.tsx(StrictMode Grade, order by student and by question, Finish by question onto the next ungraded answer, focus on the heading after a chip is removed, a queue refetch on every refocus inside the stale time, another tab's grade on the open answer, keyboard-only 2 → j → 1, keys inside the comment and the number field, nine buttons up to four points and a number field above),grading-recovery.test.ts(mergeQueueOrder,nextOpenItem,scoreOptions),grading-messages.test.ts(the plural names), and the existinggrading-finish-recovery.test.tsx, now under StrictMode. -
Deck check with the tester's matrix (
matrix416.mjs) against the deck's Grading screen at 360, 768, 1024, 1280 and 1440, light and dark, sidebar expanded, collapsed and as a drawer: the header, sub line, toggle, aside, rows, card, type pill, score buttons, Next and the footer match in size, type and computed colour, apart from data (the seed's question numbers and answer lengths). Small differences remain: the 3px progress bar sits above the card's border, so it is 2px wider than the deck's, drawn inside it; the card's head row is 1.5px lower; and the outline buttons (scores, Previous) carry the button primitive's 1px shadow, which the deck does not draw (R5 sets the primitives' deck defaults).
Reopened in D4 (74d-d4-deck-update.md): T-D4.21 (#555) replaced the page's flow with one
viewport-high block from 768, the queue list and the card's body scrolling inside it; T-D4.21b
replaced Finish recovery below the panes with "Ready to finish" and its fresh checks; T-D4.36 added
"Suggest a score" to the grading card.
Depends on: T-R4.4, T-R4.5b, T-R4.1c, T-R4.2a, T-R4.15, T-R4.16
Touches: web/src/features/tests/pages/teacher/TestsListPage.tsx, web/tests/units/tests/, web/tests/units/imports/i18n.test.ts
Reuses: CardGrid, StatusBadge, TestStatusFacets, RowMenu, DeleteItemButton, features/imports/availability.ts
Size: S
As built (T-R4.29: tests list):
-
Touched beyond the list:
features/tests/pages/teacher/TestCard.tsx(new; in the pages folder becausefeatures/tests/components/is lane 1's), the tests route line inapp/router.tsx, which now declares a teacher handle (crumbteacherShell.nav.tests, width 1320) so the page renders inTeacherLayoutand follows the theme,components/shared/DeleteItemButton.tsx(below), thetestsblock of both locales,docs/design/gaps.md(DG-140) andtests/e2e/admin-change-requests.live.spec.ts, whose list half drove the old table.tests/units/tests/list-rows.test.tsxbecamelist-cards.test.tsx.router-chunks.test.tsis unedited. - Spec §8's approved list change request (2026-09-22) wins over the deck here (DG-140): a card has a selection checkbox before its pill, selecting shows the deck's bulk bar (Archive selected, Delete permanently), and Duplicate sits beside the "…" menu as a 26px button in the place of the deck's Share. Duplicating keeps the teacher on the list and marks the source and the copy "Just duplicated" until they leave.
- The whole card opens the test's detail, for every status, as the deck's card does; a draft no longer opens the builder from the list (New test and the detail's Open builder do). The card is a stretched link from its title; the checkbox and the footer's buttons sit above it.
-
The menu holds Duplicate and Archive, or for an archived test Restore, Duplicate and Delete
permanently (
deleteTesttakes only archived tests). A published test's menu also starts with "Assign to a class" (the wizard's?testId=), until T-R4.30 gives the detail its Assign button; 30 decides whether it stays. Delete opensDeleteItemDialog, split out ofDeleteItemButton, which now composes it and behaves as before. A dialog opened from the menu gives focus back to its trigger, or after a delete (and an archive in a filtered tab) to the neighbouring card's trigger, else to New test. -
The pill is
StatusBadgewith the deck's dot and tones (Published success-soft, Draft transparent with a border-coloured dot, Archived the deck's "closed" muted tone), no border. The skills areTest.skillsas chips with no remove button, labelled bytests.skill.*. {assigned} follows the deck's four forms in this order: "{n} live" while any is open, "Scheduled", "{n} closed", "Not assigned". Points are formatted in the locale ("12,5 điểm"). -
Tabs and search:
Segmentedwith the response's facets;?status=published|draftand?q=throughuseListFilters. The Archived tab is gone (archived tests are under All), and an old?status=archivedreads as All. 24 or 48 tests a page (?size=) on the deck'sdata/Pager(QA-F1-2), as Media: the deck draws no pager on this screen but the API pages, and 24 or 48 cards fill the grid's two, three or four columns. The pager shows whenevertotalis above zero, so a page past the end (the last test on it deleted, a stale?page=) still leads back. -
Not carried over: the tag filter (the deck draws none, and the card shows skills, not tags;
listTestskeeps itstagparameter), the headphone count, the per-row Edit, "View as a student" and "Version history" items (the detail has them), and the header's "{n} tests · {n} drafts" line, which the deck's sub replaces. Mutation errors are toasts. Eleventests.*keys that nothing reads any more were removed, in their own commit. -
Deck check (1440, 1280, 1024, 768 and 360, light and dark, sidebar expanded, collapsed and
as a drawer), against the deck's
TESTSas route stubs: geometry, type and computed colours match apart from DG-122 (every block 6px higher), the content the plan changes (points for the duration, the derived skills, Duplicate and "…" for Share, which lets a footer fit on one row at 1440 and makes "18 points" wrap where "25 min" did not at 1280) andformatRelative, which the rebuilt screens share: "3 days ago" and, past six days, a date, where the deck writes "3 d ago" and "1 w ago". Done when: - Header "Tests" / "Build once, assign to any class."; ghost Imports, outline "Import from
Word/PDF", outline "Paste a test" (→
/teacher/imports/new?source=paste) and primary New test, in that order; the Imports link shows while import intake is on (review-only or on), and the two import buttons show only while a new import can start - Tabs All / Published / Drafts with counts; a wired search; archived tests appear under All with their pill
- Cards
minmax(290px,1fr): status, updated, title, derived skill chips, footer "{n} questions · {pts} points · {assigned}" (tests carry no duration); a "…" menu keeps Duplicate, Archive or Restore, and Delete. No Share button until R7 -
import-entry.test.tsxcovers the "Paste a test" link: its target, and that it shows exactly when "Import from Word/PDF" does;imports/i18n.test.tslists its key among the entry points - States: skeleton; "No tests yet" + New test; error; deck check at the five widths, light and dark; keys vi first
Depends on: T-R4.29, T-R4.16
Touches: web/src/features/tests/pages/teacher/TestDetailPage.tsx, web/src/features/tests/components/{VersionHistory,VersionChanges}.tsx (new), web/src/features/tests/components/StudentPreview.tsx, web/tests/units/tests/
Reuses: StudentPreview (computer and phone), Sheet, FormDialog, the existing version mutations
Size: M
As built (2026-10-10):
-
The route has a teacher handle (Tests › the test's title, 1320px), so the detail renders in
TeacherLayoutand follows the theme; the builder's#versionslink opens the history sheet below the two columns, as?history=1does. -
Layout.
useContentWidthAtLeast(1000)picks the two columns (minmax(0,1fr) 340px, the history a sticky aside) or one column with "Version history" in the header and a 380pxSheet. At the five widths, both sidebar states and both themes, the grid's columns, the heading's place and the banner's height measure as the deck's. -
The address holds
?version=(a version not in the list falls back to the default),compare=1,device=phoneandhistory=1, each written withreplace; choosing a version clearscompareand closes the sheet, as the deck'sselectdoes. -
Assign links to
/teacher/assignments/new?test={id}and is off while the test has no version or is archived. The tests list menu loses "Assign to a class" (T-R4.29 left that to 30), so assigning starts from the detail or from the wizard. -
Publish opens a
FormDialogwith the deck's title and body (how many assignments keep the default version) and an optional change note, trimmed and sent only when not blank. The next number was the highest version listed plus one, which named a number the server would skip once the newest version was deleted (VER-30); F-46 replaced it withTest.nextVersion, which both the banner and this dialog read.PUBLISH_VALIDATION_FAILEDreports how many problems to fix in the builder. -
Diffs.
getVersionDiff(id, n, previous)names the previous version by number, so its query key is immutable and cached for good. The compare band and the computed summary of a card with no change note share it; the first version reads "First version".previewMarksmarks added questions "New" and changed or re-keyed ones "Changed" throughStudentPreview's newmarks. - Departures are DG-145: the preview is the student's paper, the band lists Removed rows, Assign is off when archived, and the off Restore and Delete stay focusable with their titles.
-
Keys are the new
tests.detail.*block; the old detail'stests.*keys andtests.assignToClassare removed in their own commit. Two live specs andgroup-preview.spec.tsfollow the new labels and the 390px frame. Done when: - Header: back "Tests", title and status, meta "{n} questions · {pts} points · {assigned}";
Version history (narrow only, a 380px sheet), Open builder, primary Assign (disabled
without a version; → wizard
?test=) - Draft banner when
unpublishedChanges > 0: "The draft has changes that are not published…", Review draft, "Publish version {n}" with the confirm naming how many assignments keep their version and an optional change note (DG-66) - Two columns
minmax(0,1fr) 340pxfrom 1000 content width; the history as a sticky aside or a sheet - History: the draft row; version cards (Version n, Default, "{q} q · {pts} pts", date and publisher, "In use · {n} assignments" or "Not used", the change note or the computed summary); Restore as draft, Make default, Delete (disabled while in use, with its title); the archived note
- Preview: "Changes from version {prev}" lists Added / Changed / Answer / Points rows and
outlines changed questions with "New" / "Changed" chips; Computer / Phone; the
not-published state;
?version=&compare=1&device=phone&history=1 - States: skeleton, error; deck check at the five widths, light and dark; keys vi first
Reopened in D4 (74d-d4-deck-update.md): T-D4.2c replaced the preview's compare outline, its
"New" / "Changed" chips and its count line with the bar's frame under "Changes from version {n}";
T-D4.16 put the take-test engine in that frame in place of the read-only paper; T-D4.20 made the
version history a sticky column; T-D4.34 added "Make a variant".
Depends on: T-R4.2a, T-R4.5b
Touches: web/src/components/shared/content/editor/{ContentEditor,ContentToolbar,PastePreview}.tsx, web/src/components/shared/content/editor/{extensions,useContentPaste,profile,clipboardHTML}.ts, web/src/components/shared/content/editor/{TableToolbar,LinkPopover,NoticeBand}.tsx (new), web/src/components/shared/content/content.css, web/tests/units/content/ (clipboard.test.ts among them), web/tests/e2e/rich-question-content.spec.ts
Reuses: ContentEditor and its profiles (prompt, question, document, option), ContentToolbar, PastePreview, useContentPaste, CLIPBOARD_HTML_LIMIT, safeContentURL, ContentView, R1's Toaster
Size: M
Done when:
- The 2026-10-04 export draws one editor in three places: the builder's "Prompt" and
"Explanation" and the review's "Question". This task gives
ContentEditorthat frame; the document model and the profiles do not change; the paste converter changes in one place, below. The deck's engine (document.execCommand) is prototype; the product's is TipTap -
ContentEditorhas three more hosts:OptionField(profileoption) and the group editor'sGroupComposer(profilequestion) andMaterialContent(profiledocument). The footer and the minimum height are props. Theoptionprofile shows the toolbar and the box with no footer and no minimum height, as today. The group editor takes the frame with the footer.rich-options.spec.tsandgroup-authoring.spec.tsstay green - The box:
--card, a 1px border, radius 10px. Focus inside shows the--ringborder and the 3px halo as drawn, and every control keeps its own:focus-visiblering (DG-30). Minimum height and text size are props: 96px and 15px for "Prompt", 84px and 14px for "Explanation", 64px and 14px for the review's "Question". The placeholder is a prop and shows only while the document has no text, table or gap - Inside the editor the content follows the deck's block styles, under
.content-editorincontent.css: headings 19px, 17px and 15.5px at 600; paragraphs 8px apart; table cells 6px 10px, at least 72px wide, with a--mutedheader row; links in--info-ink, underlined; a gap is the dashed chip (at least 44px wide, 24px high, 1.5px dashed--ring,--muted, its label 12px/600). The reader's styles (ContentView, the student's) do not change here - Toolbar: one row that scrolls sideways and never wraps; 32px buttons with the label in
titleandaria-label; the deck's order with its separators: "Bold (Ctrl+B)", "Italic (Ctrl+I)", "Underline (Ctrl+U)", "Strikethrough", "Superscript", "Subscript"; "Heading", "Paragraph"; "Bulleted list", "Numbered list"; "Link", "Insert table", "Insert gap"; "Undo (Ctrl+Z)", "Redo (Ctrl+Shift+Z)". The shortcut names the platform's key, as the top bar does (T-R4.4) - "Heading" toggles level 3 and is pressed inside any heading; a pasted level 1 or 2 keeps
its level. "Insert gap" shows only in a profile that has gaps (
prompt,document), so never in an explanation (DG-115). Undo and Redo stay disabled with nothing to undo. There is no "Image" and no "Audio" (DG-109) - The toolbar is one tab stop with arrow keys between buttons, and a pressed button says so
in
aria-pressed(DG-116). A toolbar press keeps the selection on touch as well as with a mouse - Table row, shown while the caret is in a cell: the caption "Table", then "Add row", "Add column", "Delete row", "Delete column", "Merge cells", "Split cell" and "Delete table" in danger ink. Merge and split work as today; when they cannot, they are disabled with the title "Select two or more cells to merge, or a merged cell to split". Inside a table "Insert table" reads "Already in a table" and is disabled. A new table has two columns, a header row and a body row. Tables use tokens and follow the theme (DG-35)
- "Link": a popover with "Link address", the placeholder "https://", "Apply" and, inside a
link, "Remove link". Enter applies. An address
safeContentURLrefuses shows "Use a full link that starts with https://" (role="alert"). With no selection the address without its scheme becomes the text. Esc closes it and returns focus to the button (DG-36) - Notice band at the foot of the box (
role="alert", warning tone, "Dismiss"), cleared by the next edit. It carries the deck's "The paste could not be converted: it has unsupported formatting or is over the content limit. The current content is unchanged. Use Ctrl+Shift+V to paste plain text and reapply formatting." (it replaces today'spasteBlockedwording) and "The content changed during preview. Paste again at the intended position; your current edits are unchanged.", today'seditBlockedandinvalidContentmessages (DG-115), and one notice for a pasted or dropped file, passed in by the host: files are not taken in the text. The builder and the bank's Question editor add that audio or an image goes in "Question media"; the review, the option editor and the group editor do not, since they have no such block. The deck's four file notices ("This image is larger than 10 MB. Use a smaller file.", "Only images and audio can be added here.", "Only images can be dropped into the text. …", "Only images can be pasted into the text. …") and its overlay "Drop the image to insert it here" wait for DG-109 - Footer: on the left "Formatting from Word or Google Docs is kept · Ctrl+Shift+V pastes plain text"; on the right "{n} words", tabular, counting text only. The count runs on a deferred value, never in the keystroke path
- "Preview content after pasting" keeps its title, its description, "Apply pasted content", its "Checking pasted content…" state, the 262,144-character limit and its stale check. New: when the copied content held images, the line "{n} image in the copied content was left out." or "{n} images in the copied content were left out."; the deck's second sentence waits for DG-109. A paste over the limit opens no dialog, and one that fails conversion closes it; both report in the notice band, as drawn. Focus is trapped and returns to the editor (DG-36)
- Today
clipboardHTMLrefuses a paste that holds an image (clipboard.test.tspins it, spec §7.1). It now dropsimg,picture,svg,videoandcanvaswithout loading them and returns their count, as the deck's cleaner does. Every other refusal stays: an unknown tag,del,h4, anhttp:link. The image case inclipboard.test.tsmoves from the refused list to a case that asserts the count and that no image node or address survives. A paste with nothing left once its images are out opens no dialog and shows the file notice. A paste whose HTML holds text is previewed whatever files ride along, and a plain paste beside a file inserts its text; only a file with no HTML text is refused. Default, not confirmed by Thuong; the alternative keeps the refusal and does not build the left-out line (open item) -
readOnlyis a new prop: it rendersContentView, with no toolbar and no footer. The review, which mountsContentViewitself today, uses it (T-R4.38); R7's Can-use builder will - The editor stays a lazy chunk with today's skeleton;
router-chunks.test.tsgreen and untouched - Tests:
content/content.test.tsxcovers the toolbar's order for each profile, the link popover (applied, refused, removed), the table row and its disabled titles, the words count and the read view;content/paste-editor.test.tscovers the notice band, its dismissal, the left-out line and the paths that open no dialog;content-editor.spike.tsgreen underpnpm e2e:content, andrich-question-content.spec.tsgreen underpnpm e2e - Deck check at the five widths, light and dark; keys vi first, en from the deck, listed in the PR
As built:
-
Files beyond "Touches":
editor/{ToolButton,LinkPanel,EditorFooter}.tsx,editor/{roving,shortcuts,clipboardText}.ts(new);features/question-groups/components/MaterialTools.tsx(its own Link and Remove-link controls and their dialog are gone, with the keysgroups.{link,removeLink,linkHint,linkURL, linkHTTPS}, so the toolbar's popover is the one way to link in the group material editor; Tech Lead's decision);tests/support/deck-harness/cases/r4-63.tsx; spec §7.1 and v0.61 (the image exception, brought forward from T-R4.50).GroupComposer.tsx,MaterialContent.tsx,OptionField,RichBlankEditor,RichProseEditorand the review'sQuestionEditorare not edited: the profile defaults carry the frame. -
Props.
ContentEditoraddsminHeight,fontSize,footer,placeholder,fileNoticeandreadOnly;toolsstays. Defaults by profile:prompt96/15,question84/14,document256 (today's height) at the inherited size, all three with the footer;optionkeeps today's box, no footer. So until T-R4.64 passes props, a non-blank prompt (profilequestion) draws at 84/14. Height and size are CSS variables on the box (--content-editor-min-height,--content-editor-font-size) read by.content-editor-body. The file notice is a notice kind,"file", resolved from thefileNoticeprop at render, so the extensions built once inuseStatenever hold a stale string; with no prop it sayscontentEditor.fileNotice. A document its profile cannot hold draws the box with theinvalidContentnotice and no editor. -
Toolbar.
role="toolbar"named "{label} formatting"; one tab stop (useRovingToolbar: Left, Right, Home, End over the enabled buttons, wrapping);aria-labelis the plain label,titleadds the shortcut as the deck writes it, andaria-keyshortcutsnames it (⌘ and Meta on Apple devices, from TipTap'sisMacOS/isiOS), approved by the Tech Lead so the specs that find "Hoàn tác" by name stay untouched. Pointer and mouse presses arepreventDefaulted. "Heading" sets level 3, or a paragraph when the caret is in any heading. Theoptionprofile keeps the marks and the history only. -
Link popover.
LinkPopoveris the button;LinkPanel, the modal Radix popover anchored to it byvirtualRef, loads on first use, because Radix Popover and floating-ui took the spike's editor chunk pastbundle.test.ts's 160 KiB (to 166 KiB gzip); it is 156 KiB with the panel split out; the 4 KiB left under the budget is T-R4.64's to watch. The draft starts at the link's address or "https://", and the panel takes Radix's open focus itself to put the caret after it: Radix selects the field's text on open, so a typed host replaced "https://" and every bare address was refused (review F1). The input istype="text"withinputMode="url", Enter submits the form. The button is a dialog opener only (aria-haspopup,aria-expanded); its look inside a link comes fromdata-active, notaria-pressed(F3). -
Words count. The footer selects the document node by reference, defers it, and builds the
text and the count in one
useMemo, so neither runs on the keystroke path (F2). The paste notice and the footer name the same platform shortcut through{{shortcut}}(F4). -
Paste (DG-115).
clipboardHTMLreturns{ content, imagesLeftOut } | null;inspectcovers a media element's whole source range, splices it from its parent and skips its children;checkElementis unchanged. With images out and no text and no table,contentis null. The extension sends HTML that shows text (clipboardHasText, a linear scan without a parser that skips tags, comments and the contents ofstyle,scriptandtitle) to the preview whatever files ride along; plain text with no such HTML is inserted throughview.pasteText; a file or an HTML image with no text gets the file notice and no dialog. A refused conversion closes the preview and reports in the band. - Not built: the deck's four file notices and its drop overlay (DG-109); the builder's and the bank's "Question media" file notice is their host's string (T-R4.64, T-R4.66).
- Deck check through the harness case against the deck served on 5185 (React, Babel and lucide served from the npm registry's tarballs of the deck's pinned versions, because the container cannot reach unpkg): the box, the toolbar, its buttons and separators, the editable area, the placeholder, the footer, the table row, the link popover and the notice band match in size and computed colour at 360, 768, 1024, 1280 and 1440, light and dark. The words count uses English plurals ("1 word"); the deck writes "words" always.
-
Lesson: the task should have been two PRs. About 1,110 changed lines of code and tests is
over the ~800 that 70 §3 sets for one review, and the seam was clear: the paste half
(
clipboardHTML,clipboardText, the paste paths inextensionsanduseContentPaste, the left-out line, spec §7.1) could have shipped before the frame, toolbar, link and footer. Review approved it whole rather than splitting it after the fact.
Depends on: T-R4.63
Touches: web/src/features/question-bank/components/{RichProseEditor,QuestionProseField,BlankPromptField,PromptField}.tsx, web/src/components/shared/content/editor/markdown.ts, web/src/components/shared/content/editor/MarkdownToolbar.tsx (new), web/src/components/shared/Markdown.tsx, web/package.json, .env.example, web/playwright.config.ts, web/tests/units/content/, web/tests/units/question-bank/prompt-preview.test.tsx, web/tests/e2e/{rich-question-content,rich-blanks}.spec.ts
Reuses: RichProseEditor's conversion preview, markdownToQuestionContent, Markdown (the reader students get), Segmented (T-R4.2)
Size: M
Delivered in parts: four, each one branch and one pull request, each cut from the head of the one before (the mode work came to ~2,150 lines, so it was split three ways)
T-R4.64a: the Markdown engine: the GFM subset plugin, the parser's strikethrough and tables, the serializer, the reader's tables and strikethrough. Its boxes: "Markdown gains tables and strikethrough" and "Markdown.tsx is on the student's path"; of the tests box, the round trip and the rendering
T-R4.64b: the Markdown body: the toolbar, "Write | Preview", the textarea, the preview and the footer, and the Segmented's disabled option. Its boxes: "Markdown toolbar" and "Body"
T-R4.64c: the modes of a prompt and an explanation: the header's Segmented, both conversions in place, the always-live rich body, question-prose-field.test.tsx and rich-question-content.spec.ts. Its boxes: both conversions and the serializer's use; the header box is met except for the builder's and the group composer's starter prompt (R64c-1), so it stays open; of the always-live box, the prose fields
T-R4.64d: the fill-in-the-blank prompt (the gaps rule), the removal of VITE_RICH_QUESTION_EDITOR and the view-then-edit flow, rich-blanks.spec.ts, the deck check of the whole field, and the keys. Every other box
Done when:
-
The field's header: its label, and on the right "Rich text | Markdown" with the deck's icons, a Segmented and not a tablist (DG-116). The selected mode is the form the field is stored in (DG-110): content is "Rich text", a Markdown string is "Markdown". A new prompt or explanation is rich text - Met for a new question in the bank editor (T-R4.64c), in the builder (T-R4.31b) and in the group composer (T-R4.34), whose starter prompts are created as rich text (T-17 a)
-
The editor is always live. Today's view-then-edit flow ("Format: {field}", "Edit: {field}", "Done") and
VITE_RICH_QUESTION_EDITORgo, from the code, from.env.exampleand fromweb/playwright.config.ts.VITE_RICH_OPTION_EDITORis untouched -
Rich text to Markdown asks in place (
role="alertdialog", warning tone), with "Cancel" and "Switch to Markdown". A new serializer inmarkdown.tskeeps text, bold, italic, strikethrough, headings, lists, tables and links, and drops underline, superscript and subscript; it replaces today's escaped plain text. The sentence is the deck's without "images and audio" (DG-109) and without "and gaps": while a fill-in-the-blank prompt holds gaps, "Markdown" is disabled and says the gaps must go first (DG-110) -
Markdown to Rich text shows the result in place of the body: "Review the conversion below before applying it. The original has not changed.", the rendered content, "Keep Markdown" and "Apply conversion". A Markdown the converter refuses keeps today's message and stays Markdown
-
Markdown toolbar: "Bold · **text**", "Italic · *text*", "Strikethrough ·
text"; "Heading · ### "; "Bulleted list · - ", "Numbered list · 1. "; "Link · text", "Insert table". An action wraps the selection, prefixes the line or inserts a block on its own lines, with the deck's placeholders. At the right end, sticky, "Write | Preview", a tab pair with its panel; an action taken in Preview returns to Write. No "Image" and no "Audio" -
Body: Write is a monospace textarea (13.5px, at least the field's minimum height, 96px for "Prompt" and 84px for "Explanation", resized vertically); Preview renders through
Markdown, so the teacher sees what the student will. Footer: on the left "Markdown · **bold**, *italic*, ### heading, - list, link", without the deck's; on the right "{n} words" -
Markdown gains tables and strikethrough, in
markdown.tsand inMarkdown.tsx, through the micromark and mdast extensions for tables and strikethrough behind one plugin; up to four direct dependencies, each named with its reason in the PR. A fill-in-the-blank prompt stored as Markdown keeps its{{n}}placeholders -
Markdown.tsxis on the student's path: the take-test unit tests run before and after (high-risk area), androuter-chunks.test.tsstays green and untouched -
Tests:
content/question-prose-field.test.tsxcovers a field opening in its stored form, both conversions and both ways back out, and the disabled switch while gaps exist; a round-trip test takes a document holding every kept construct to Markdown and back and gets the same document; a table and~~text~~render inMarkdown;rich-question-content.spec.tsandrich-blanks.spec.tsare rewritten to the always-live editor and the two-way switch, and are green underpnpm e2e; no case is dropped -
Deck check at the five widths, light and dark; keys vi first, en from the deck, listed in the PR As built (T-R4.64a: the Markdown engine):
-
One plugin, four packages.
components/shared/content/markdownGfm.tsexportsgfmSubset, which registers the micromark syntax and the mdast conversion for tables and for strikethrough, and nothing else of GFM: no autolinked addresses, footnotes or task lists, and a single~is text (singleTilde: false).markdown.ts's parser andMarkdown.tsxboth use it, so the editor and the student's reader read the same syntax. The packages are pinned exact:[email protected],[email protected],[email protected],[email protected](2.0.1 was under two weeks old).remark-gfmwas not taken: it brings autolinks, footnotes and task lists with no way to leave them out.markdown-tablearrives throughmdast-util-gfm-table. -
Parser.
~~text~~becomes thestrikemark. A GFM table becomes atableblock whose first row is the header, each cell one paragraph; a table with any column alignment is refused, since the model has no alignment. A run's marks come out in the model's order (bold, italic, strike) however the delimiters nest. -
Serializer.
editor/markdownSerializer.ts, re-exported frommarkdown.tsasquestionContentToMarkdown, writes text, bold (**), italic (*), strikethrough (~~), headings, lists, tables and links, and drops underline, superscript and subscript, keeping their text. Adjacent runs share their delimiters; a run's edge spaces sit outside them. It escapes the characters Markdown reads in text, guards a line start that would open a block (#,>,-,+,=,|,1.) and writes a line's edge spaces as character references; a hard break is a backslash. A list right after a list of the same kind takes the other marker (*,1)) so the two stay two. Lossy by design, and the confirmation keeps the deck's sentence, which does not name these: a merged table cell is written once and the cells it covered stay empty, the first row becomes the header, and a cell's paragraphs join on one line; an empty paragraph outside a table is not kept. -
Reader.
Markdown.tsxdraws a table as a horizontally scrolling block with bordered cells and a muted header row, from tokens. The take-test units and the four canaries ran at the branch point and at the head, green. -
The budget measure was corrected first (Tech Lead's decision, T-16).
bundle.test.tsbuilds from inside vitest, where NODE_ENV istest, so Vite resolved thedevelopmentexport condition and counted micromark's development builds, assertions included: code no student downloads. The test now names the production conditions inresolve.conditions;mode: "production"alone did not change the resolution. The limits stay at 220, 160 and 64 KiB. Lowering each limit just under its measured size fails the test, so it still bites. -
Bundles, gzip KiB, under the production measure:
Budget Limit Before (4257310f) After 64a Reader closure 220 212.6 215.3 Editor minus what the reader shares 160 157.0 157.0 Clipboard minus the initial chunks 64 62.4 62.4 Under the old measure the reader was 219.9 before and 222.8 after, which broke 220; the editor was 157.2 and the clipboard 62.4, both before and after.
-
English strings load on demand (F-20, 2026-10-10).
enis a separate chunk fetched on the first switch to English, or before the first render on a cold English boot, so new keys no longer grow the reader (181.9 KiB after, 37.7 for the en chunk). The reader and clipboard budgets leave that chunk out, andbundle.test.tspins that no chunk they count holds an English string. -
QA-63-2's two tests ride here: the words count over runs of spaces, line breaks and no-break spaces (
content.test.tsx), and Ctrl/Cmd+Shift+V making the next paste ignore its HTML (paste-editor.test.ts).
As built (T-R4.64b: the Markdown body):
-
Where it lives.
MarkdownToolbar.tsx,MarkdownProseEditor.tsxandmarkdownActions.tsare infeatures/question-bank/(the W0 approach, so nothing new reaches the entry chunk throughcomponents/shared). The box sharesEDITOR_BOXandframeStylewith the rich editor througheditor/frame.ts, so it does not import TipTap's chunk; the textarea takes the rich box's focus halo (.content-markdown:focusincontent.css). -
Toolbar. Bold, Italic, Strikethrough | Heading | Bulleted, Numbered | Link, Insert table,
in the deck's order, each named by its plain label with the deck's syntax as its
title;ToolButtonanduseRovingToolbarfrom T-R4.63, so it is one tab stop with arrow keys. An action wraps the selection or the deck's placeholder ("bold text", "italic text", "text", "link text") and selects it, prefixes the caret's line ("### ", "- ", "1. "), or inserts the deck's two-column table on lines of its own; the deck appends two blank lines after a table at the end of the text, the body does not. An action taken in Preview returns to Write and acts at the end of the text, since Preview has no selection, and selects what it added there. "Heading" prefixes "### " every time it is pressed, so a second press writes "### ### "; the rich toolbar's "Heading" toggles back to a paragraph. This is a known difference (review R64b-1), left as the deck'smdLinedraws it. -
Write | Preview is a tab pair (
role="tablist",aria-selected, roving with the arrow keys) beside the toolbar rather than inside its scroller; both sit in one 43px row as the deck draws them. The panel is the textarea (monospace 13.5px, line height 1.65, padding 12px 16px, the field's minimum height, resized vertically) or the preview throughMarkdownat the field's text size, withblankSlotsfor a fill-in-the-blank prompt; an empty preview says "Nothing to preview yet." - Footer. "Markdown · **bold**, *italic*, ### heading, - list, link" and the words count, which counts as the deck does (syntax characters read as spaces) on a deferred value.
-
Segmented takes
disabledanddescribedByper option, for T-R4.64d's gaps rule; an enabled option's classes are unchanged. -
Deck check through the harness case
r4-64against the deck's Markdown mode, at 360, 768, 1024, 1280 and 1440, light and dark, for the prompt (96px, 15px) and the explanation (84px, 14px): the box, the toolbar row, its buttons and separators, the "Write | Preview" track and tabs, the textarea, the preview and the footer match in size and computed colour. The deck's toolbar also has "Image" and "Audio" (DG-109), and its footer hint is longer by, so at 768 the footer wraps to two lines where the deck's wraps to three.
As built (T-R4.64c: the modes of a prompt and an explanation):
-
The mode is the stored form (T-17, a).
QuestionProseFieldopens a field with a document in the rich editor and a field with Markdown text in the Markdown body; a field with no text opens as Rich text, and its first edit stores a document without asking, since there is nothing to convert. The builder's starter prompt is stored Markdown, so a new builder question, and a new question in a group, opened as Markdown until T-R4.31b and T-R4.34 created them as rich text.useProseMode(features/question-bank/proseMode.ts) holds the mode and the confirmation the field is in;askopens a confirmation and never changes the mode, so the Segmented keeps showing the stored form until a confirmation is accepted. Each body is keyed by its mode, so a switch starts a new history. -
Exactly two calls change whether content is stored. "Switch to Markdown" calls
onChange(questionContentToMarkdown(content), null); for a field that never stored a document it only changes the mode. "Apply conversion" callsonChange(plainText(document), document). A unit test edits in each mode and finds every other call keeps the field's form. -
Header (
ProseModeHeader): the label (and the explanation's hint, moved out ofQuestionEditor) and "Rich text | Markdown" as the Segmented atxswith the deck's icons (Type,FileCode), named "{label} editing mode". The fill-in-the-blank prompt keepsQuestionEditor's label and today's flow until T-R4.64d. -
"Switch to Markdown" (
SwitchToMarkdown) is thealertdialogunder the toolbar on the warning tone, passed throughContentEditor'stoolsslot. Its sentence is the deck's without "images and audio" (DG-109) and without "and gaps". "Cancel" takes focus when it opens; Escape and "Cancel" close it and return focus to "Markdown". -
"Apply conversion" (
ConversionPanel) replaces the Markdown body under its toolbar, which is disabled meanwhile, and hides "Write | Preview": the info notice, the converted content throughContentView(at most 320px, scrolling), "Keep Markdown" and "Apply conversion". A Markdown the converter refuses shows today's message and "Keep Markdown" only. After either switch the new body takes focus. -
The rich body (
RichProseEditor) is always live, at the field's frame: 96px and 15px for a prompt, 84px and 14px for an explanation, as the deck draws them. -
Two live toolbars on one page. An empty explanation now opens the rich editor, so the
question editor holds two formatting toolbars (three with a rich option open);
rich-question-content,rich-blanksandrich-optionsfind a toolbar button inside its toolbar, by the toolbar's name. -
Keys:
proseMode.*;questionEditor.applyProseConversionnow reads "Apply conversion" (vi "Áp dụng chuyển đổi"), the deck's label. -
Deck check through the harness cases
r4-64/prose-richandprose-markdownagainst the deck's Prompt field, at 360, 768, 1024, 1280 and 1440, light and dark: the header, its label, the track, both modes and their icons, the rich body, the "Switch to Markdown" band, its sentence and buttons, and the conversion panel (notice, icon, content box, buttons) match in size and computed colour. The band is one line shorter where the deck's sentence wraps, because ours leaves out "images and audio" and "gaps".
As built (T-R4.64d: the fill-in-the-blank prompt, the flag and the docs):
-
The blank prompt's two modes.
BlankPromptFielduses the same header,useProseMode,SwitchToMarkdownandConversionPanelas a prose field, opening in its stored form. While the rich prompt holds a gap, "Markdown" is disabled and the line under the header says why ("Remove the gaps to switch to Markdown: the gaps hold the accepted answers."), named by the button'saria-describedby. With no gap left, "Switch to Markdown" writes the serializer's Markdown, stores no content and unbinds the answers it kept (gapId: null), as the old "Convert to Markdown" did. "Apply conversion" binds each{{n}}to a new gap withbindLegacyBlanksand stores the document and the blanks together; a Markdown the converter refuses names the reason (today's two messages, unsupported Markdown or unmatched markers). The Markdown body previews the{{n}}placeholders as the student's slots (blankSlots).RichBlankEditoris always live and keeps its answer bindings as before. -
Gone:
VITE_RICH_QUESTION_EDITOR(code,.env.example,playwright.config.ts), the view-then-edit flow ("Format: {field}", "Edit: {field}", "Done" for prompts and explanations; options keep theirs),PromptFieldand its test, whose starter-prompt casemarkdown-body.test.tsxcarries, and the keysquestionEditor.{bold, italic, list, link, markdown, formatProse, editProse, returnToMarkdown, removeProseWarning, confirmRemoveProse}. Spec §7.1 (v0.62), plans 17 and 19 and the open item say how a field is written now. -
The textarea's name is the field's label (T-17 b, DG-138), so the specs that find "Nội
dung câu hỏi" by label are unchanged. The bank's new fill-in-the-blank test switches to
Markdown before typing
{{n}}, since a new prompt is rich text. -
What the converter refuses stays refused, with today's message: an aligned GFM table
(
| :-: |) renders for the student, since the reader reads it, but "Apply conversion" refuses it, because the model has no alignment; so do raw HTML, code, images, quotations and links that are not HTTPS.ConversionPaneltakes the refusal's sentence fromconvert's own result, computed once when the panel opens (R64d-3). -
Removed after review:
blankMarkdownProjection, which had no caller once "Switch to Markdown" required no gaps (R64d-1). -
Deck check through
r4-64/blank-gapsandblank-markdownagainst the deck's Prompt field at the five widths, light and dark: the track, both modes and the rich body match; the deck draws no disabled "Markdown" and no reason line, which are DG-110's. The Markdown preview draws one slot per placeholder at every width.
Depends on: T-R4.2b, T-R4.2c, T-R4.17a, T-R4.35, T-R4.62, T-R4.66
Touches: web/src/features/question-bank/components/{QuestionMediaField,AudioPolicyPanel}.tsx, web/src/features/question-bank/components/QuestionEditor.tsx (the block's place only; T-R4.66 owns the answer areas), web/src/features/question-bank/{audioPolicy.ts,questionSchema.ts}, web/src/features/media/components/AssetLibraryDialog.tsx, web/src/features/media/api.ts, web/tests/units/question-bank/editor-audio-expiry.test.tsx, web/tests/units/media/file-drop.test.tsx, web/tests/units/builder/narrow-settings.test.tsx
Reuses: QuestionMediaField, AudioPolicyPanel, AssetLibraryDialog, AudioPlayer, useFileDrop, useMediaUpload (T-R4.35), probe.ts, limits.ts and the card's thumbnail (T-R4.35), Segmented, R1's Toaster
Size: M
As built (2026-10-10):
-
One block, both pages.
QuestionMediaFieldis the whole block andQuestionEditordraws it after the answer area and before the explanation, so the builder's editor column and the bank's Question editor show it in the same place (the W2 approach's ordered block list). The settings rail and its sheet hold points and tags only; the bank's old MEDIA section is gone, which is where this task's placement line differs from what was built: the bank keeps no aside copy, because DG-108 builds the builder's drawing of the question media on both pages.narrow-settings.test.tsxnow also pins that the sheet holds no media. -
Upload. A hidden
FileInput(features/media/components/FileInput.tsx) named "Chọn tệp từ máy" andopenFilePicker(features/media/filePicker.ts), which setsacceptbefore the click: either kind from "Choose file" and from "Upload new" on All, the attached kind from "Replace" and from "Upload new" on Images or Audio. A file's kind comes from its extension (kindOfName); a file of neither kind is refused before the hook runs.useMediaUploadgainsonRejected: with it, a pre-check refusal is an error toast whose title is the deck's words with the decided limit ("Images can be up to 10 MB", "Audio can be up to 50 MB", "Audio can be up to 5 minutes", "Choose an audio or image file") and whose description is today's message naming the file, and the block returns to its drop zone. The hook's checking state now carries the size and kind, so the block shows "{size} · checking length…" while an audio file's metadata is read; progress, cancel and failure areUploadStatusas before. A success toasts "Saved to Media · {name}". A window drop works as before, attached or not. -
Attached card. Icon tile in the info tone, name,
assetMeta("{m:ss} · {size}", "{w} × {h} · {size}", the image's size from the library row or, after an upload, from the preview's natural size), "Replace" with the accessible name "Thay media của câu hỏi này" and "Remove media". Audio:AudioPlayerin its new size "row" (no frame of its own, a 32px button, the time beside the track; "default" and "sm" unchanged) on a--sidebarband, with the expired-link retry, thenAudioPolicyPanel: "Plays" as a radiogroup of native radios (Once, Twice, Unlimited, plus the stored count while it is the value), "Allow skipping ahead", "Show transcript after submitting", and the transcript marked Optional. Attaching audio starts its plays from the library'sdefaultMaxPlays(0 is Unlimited), else Twice, unless the question already has a policy (DG-69). Image: the 220px preview (its alt is the alt text, else the file name), the alt field, and the helper "Học viên dùng trình đọc màn hình sẽ nghe câu này thay cho hình ảnh." under it througharia-describedby; a blank alt savesnull. -
Choose from Media.
AssetLibraryDialogonDialogShell(760px): title, "Files you uploaded.", Close; a Segmented kind filter with the facets' counts, which follow the search; "Search files" debounced 300ms; aminmax(160px, 1fr)grid paged 24 at a time throughLoadMoreSentinel; tiles with thumbnail or audio icon, name, meta and "Used in {n} questions" / "Not used yet",aria-pressedselection, double-click to pick; loading, error-with-retry, empty-library and "No files match." states; "Upload new", "Cancel", "Attach". It takeskind(default "all"); the block opens it on All only. -
R35-8 is split, not done. This task ships
FileInputand uses it in the question media block only.MaterialAssetDialogkeeps its own input and its image path; that half moved to T-R4.34's "Done when" (lane 3 ownsfeatures/question-groups/). -
Keys. New:
questionEditor.questionMedia.*andmedia.picker.*. Removed in their own commit:questionEditor.{audioAllowSeek, audioMaxPlays, audioPlaysCount, audioPlaysUnlimited, audioShowTranscript, media, mediaChoose, mediaDrop, mediaFromLibrary, mediaHint, mediaRemove, transcript, transcriptPlaceholder}andmedia.pickTitle. -
Deck check. Measured against the deck at 360, 768, 1024, 1280 and 1440, light and dark,
and at 1024 and 1280 with the sidebar collapsed; no horizontal scroll. What differs is
decided elsewhere: the second switch (DG-111) and the longer limits line (DG-63) make the
block taller;
Switchis the primitive's 38×22 against the deck's 34×20; the transcript and alt fields keep the primitives'--inputborder (DG-31) and their 16px text below 768. -
Live specs.
author-and-publish.live.spec.tsandaudio-plays.live.spec.tsattach the audio from the editor column (no settings sheet) and read "Gỡ media" and the "Số lần nghe" radiogroup.
Done when:
- Label "Question media" with the hint "· audio or an image shown above the prompt". Empty: a drop zone with "Drop an audio or image file here", the limits line "Audio: MP3 or M4A up to 50 MB and 5 minutes · Images: JPG, PNG or WebP up to 10 MB", "Choose file" and "From Media". The deck's "WAV" and "25 MB" do not ship (DG-63). The deck's line names no length; the limit stays (spec §11.1), so the line states it, as today's hint does
- Pre-checks from
limits.tsfor each kind (type, size, audio length, unreadable file), each naming the file, as today. The toasts are error-tone, in the deck's words with the decided limit: "Choose an audio or image file", "Images can be up to 10 MB", "Audio can be up to 50 MB". An upload shows its progress and its failure (DG-83, DG-111); on success the toast reads "Saved to Media · {name}".upload-panel.test.tsxmoves to the hook with T-R4.35; this task adds the image cases - Attached card: the icon tile, the file name, the meta, one form for an upload and for a file from Media: "{m:ss} · {size}" for audio and "{w} × {h} · {size}" for an image, as the dialog shows them. "checking length…" shows during the pre-check, before the upload starts. Then "Replace" and "Remove media". "Replace" picks a file of the same kind for this question; its accessible name says so, because the Media page's "Replace file" changes the file itself (DG-09, DG-111)
- Audio: the player row through
AudioPlayer, with.play()in the click's tick and the expired-link refresh kept;audio-player.test.tsxgreen and untouched. "Plays" is a radiogroup "Once | Twice | Unlimited", defaulting from the asset's default and otherwise Twice (DG-69; the question's value wins); a stored value outside the three shows as a fourth option while it is the value. Under it "Allow skipping ahead" and "Show transcript after submitting". "Transcript", marked "Optional", with the placeholder "Paste the audio script". "Students can pause" is not built (DG-111) - Image: the 220px preview and the field "Alt text: describe the image for screen readers",
saved as
mediaAlt(T-R4.62). The helper text under it, the deck's "students hear this", is this task's copy: by default one line, written vi first and then en - "Choose from Media" is
AssetLibraryDialog, rebuilt: the title, the sub "Files you uploaded." (DG-112) and Close; the kind filter "All | Images | Audio" with counts fromlistMedia's facets, a Segmented whose counts follow the search (DG-116); "Search files"; a gridminmax(160px, 1fr)of files, each with its thumbnail or the audio icon, name, meta and "Used in {n} questions" or "Not used yet"; one file selected at a time, and a double-click picks it; "No files match."; "Upload new", "Cancel" and "Attach", disabled until a file is selected - The dialog opens on All from "From Media", its only entry point in R4. "Replace" never opens it: as drawn, it opens the device's file picker limited to the attached kind. "Upload new" closes the dialog and opens the file picker for the selected kind, any kind on All. The dialog pages, and has loading, error and empty-library states from deck primitives (DG-83). Focus is trapped and returns to the button that opened it (DG-36)
- The block sits in the builder's editor column after the answer area, and in the MEDIA
section of the bank's Question editor (DG-108). The builder's settings rail and its
sheet no longer hold it;
builder/narrow-settings.test.tsxis updated, not removed - Tests: an image attaches, shows its preview and saves its alt text; audio saves "Plays"
and both switches; a stored 3 shows as a fourth option; each pre-check names its file;
the dialog's filter, search, selection, double-click and paging;
editor-audio-expiry.test.tsxgreen - The five canaries and the media module's unit tests run on the branch point and on the last commit, both runs in the PR (high-risk area)
- Deck check at the five widths, light and dark; keys vi first, en from the deck, listed in the PR
- Split (2026-10-10): R35-8 (from T-R4.35's review): one small
FileInput(the hidden input named "Chọn tệp từ máy", itsacceptfromlimits.ts, cleared after each choice) replaces the two copiesQuestionMediaFieldandMaterialAssetDialoghold since T-R4.35, andMaterialAssetDialog's image path moves ontouseMediaUpload, so both kinds share the pre-check, progress, cancel and failure;material-asset-limit.test.tsxstays green. This task shipsFileInputand uses it in the question media block; the dialog's half is a "Done when" line of T-R4.34
Reopened in D4 (74d-d4-deck-update.md): T-D4.15 added "Students can pause" (allowPause,
T-D4.13) between "Plays" and "Allow skipping ahead", replacing "Students can pause is not built";
"Plays" was already the one control, and DEFAULT_AUDIO_POLICY now sends allowPause: true.
Depends on: T-R4.2c, T-R4.64
Touches: web/src/features/question-bank/components/{QuestionEditor,OptionsEditor,OptionField,BlanksEditor,RichBlankEditor,BlankPromptField}.tsx, web/src/features/question-bank/components/{TrueFalseField,GradingNote}.tsx (new), web/src/features/question-bank/questionSchema.ts, web/tests/units/question-bank/{editor,fill-blank-editor,structure-validation}.test.ts(x), web/tests/units/content/gap-bindings.test.tsx, web/tests/e2e/rich-blanks.spec.ts
Reuses: QuestionEditor, OptionsEditor, BlanksEditor, RichBlankEditor, ChipInput (T-R4.2), QuestionProseField (T-R4.64)
Size: M
Done when:
- One block shows for the question's type, then the grading note. They are built once in
QuestionEditor, so the builder and the bank's Question editor show the same blocks (DG-108). The prompt and the explanation areQuestionProseField(T-R4.64). Matching has no block until R6 (DG-113) - Single choice: "Options · click the circle to mark the correct answer"; rows with the
radio, the letter, the text (
aria-label"Option {letter}"), "Correct" and remove; "Add option" up to eight, A–H, then "Up to 8 options" in an error-tone toast (DG-63); two at least. Option text is a plain input, as drawn. An option that already holds formatted content (an imported one) keepsOptionField's formatting path, as today; no new option gets it whileVITE_RICH_OPTION_EDITORis off - Multiple choice: "Options · tick every correct answer"; a checkbox per row, "Mark {letter} correct". With none ticked, "Tick at least two correct answers.", which blocks saving as the missing answer does today. With one ticked, "Only one answer is ticked. Tick another, or switch to Single choice.", a warning that does not block: the server takes one correct answer
- True / False: "Correct answer · students choose one", a radiogroup of two cards, "True"
and "False". The selected card has the success border and the
circle-checkicon named "Correct". The "Include “Not given”" switch is absent until R6 (DG-113) - Fill in the blank, with a rich prompt: "Accepted answers · for each gap in the prompt." One row per gap, in prompt order: the gap's chip, its answers as chips, an input ("Type an accepted answer, then press Enter", then "Add another accepted answer"), and "Match case". Enter, a comma or leaving the input adds; Backspace in an empty input removes the last; a duplicate is refused whatever its case. "Capital letters and extra spaces are ignored." follows the label while no gap matches case (DG-108). A gap with no answer has the danger border and "Add at least one accepted answer for gap {label}."
- With no gap in the prompt: the dashed card "No gaps in the prompt yet", "Put the cursor where students type the answer, then insert a gap." and "Insert gap", which inserts at the prompt's caret. Today's limit on gaps and its message stay
- A fill-in-the-blank prompt still stored as Markdown keeps its placeholders and
BlanksEditor, as today - Short answer: "Sample answer" with "Optional. Graders see it next to each student’s answer." and the placeholder "Write a model answer"
- Grading note under the block, with the deck's icon: "Marked automatically when the student submits." for single choice and true / false; the same with "An answer is correct if it matches any accepted answer." for fill in the blank and with "Students must choose exactly the ticked options." for multiple choice; "Graded by hand. Each answer goes to the grading queue." for short answer
- While the prompt holds gaps the other types are disabled: in the builder's menu with the meta "Remove gaps first", in the bank's type control with today's note. The deck's toast for it cannot fire and is not built (DG-115)
- The zod schema keeps its
Expect<AssignableTo<…>>assertion against the generated request type; switching type keeps what still applies and drops the rest, as today - Tests:
question-bank/editor.test.tsxcovers each type's block and note and both multiple-choice sentences;fill-blank-editor.test.tsxandcontent/gap-bindings.test.tsxcover a row per gap in prompt order, the three ways to add, the duplicate refusal, the "Match case" sentence and the empty card's "Insert gap"; an option with stored content still opens its rich editor (content/option-field.test.tsxgreen);rich-blanks.spec.tsgreen - Checked inside the builder and the bank's Question editor at the five widths, light and dark; keys vi first, en from the deck, listed in the PR
As built (T-R4.66a: the choice, true / false and short-answer blocks, the grading note):
-
Shape.
AnswerArea({ value, onChange, onInsertGap })picks the block for the type and putsGradingNote({ type })under it;QuestionEditorrenders it once, between the prompt and the explanation, and the aside no longer carries the grading sentence or the separator before it.onInsertGapis declared for 66b's empty card and not read yet. Every block reads and writesvalue, so a question or type swap loses nothing (builder/autosave-unmount.test.tsxgreen);OptionsEditorkeeps itseditorEpochkey. -
Touched beyond the list:
features/question-bank/questionType.ts(new:QUESTION_TYPES,CHOICE_TYPES,optionLetter,typeLocked,trueFalseOptions,retype; the bank's typeTabsandswitchTypereadtypeLockedandretype),components/{AnswerArea,SampleAnswerField}.tsx(new),tests/units/question-bank/ {answer-area.test.tsx (new),metadata-roundtrip.test.ts},tests/units/content/ option-field.test.tsx(names only: every control of an option, its formatting buttons and rich editor included, is named by letter, so "Nội dung lựa chọn 1" and "Định dạng phương án 1" are "Lựa chọn A" and "Định dạng lựa chọn A"; the flag's behaviour is unchanged),tests/e2e/rich-options.spec.ts(the same names), and the live specsauthor-and-publishandaudio-plays, whose option helpers fill "Lựa chọn A" and check "Đánh dấu A là đáp án đúng". -
Rules in one place.
choiceProblems(value)(tooFew,tooMany,noneCorrect,oneCorrectOfMany,manyCorrectOfOne) feeds bothvalidateChoiceandOptionsEditor's sentence; onlyoneCorrectOfManydoes not block.gapProblems(content, blanks)names the gaps whose answers are all blank, in prompt order, andvalidateBlankStructurerefuses them; 66b draws its rows from the same call. A one-tick multiple choice parses andpublishProblempasses it while the warning shows (answer-area.test.tsx; makingvalidateChoiceissue ononeCorrectOfManyfails that case). -
Eight options.
optionsis.max(8)withquestionEditor.errors.maxOptions, as the contract'smaxItems: 8andValidateAuthoringalready refuse a ninth. A legacy question stored with nine keeps all nine in the form and cannot be saved until one goes, which is the server's answer too; nothing is dropped silently. "Add option" stays enabled and at eight toasts "Up to 8 options" in the error tone; remove is disabled at two. -
True / False is a native radiogroup of two label cards around sr-only radios ("Đúng",
"Sai"); the selected card carries the success border and background, the
CircleCheckicon is decorative, and the radio is described by an sr-only "Đáp án đúng". Not given is absent (DG-113). -
True / False reads by text, not position (R66a-1, the rule of T-19).
readTrueFalsetakes the option stored as exactly "True" (trueFalseLabelKey's test) as the true one, or failing that the one beside an exact "False"; only when neither is canonical (a legacy rename, an imported "Đúng" / "Sai") is the first option true. The Word import keeps a question's own order, so a pair stored False-first is real, and reading it by position showed the inverse of the key.TrueFalseFieldandretypeboth read through it. Writing goes throughtrueFalseOptions(trueIsCorrect, answer), which emits the canonical pair, "True" first, and carries each option's id across (and, for an option that had content, the canonical text as plain content), so a legacy rename normalises to "True" / "False" (DG-139) without losing its rows. The ids are load-bearing: the server deletes and rewrites a question's options on every save and matches stored formatted content by id, falling back to the ordinal, so a reversed imported pair written without ids is refused withoptions[0].content("Phương án đã có định dạng…"); with them it saves. The version diff (unpublishedChanges) fingerprints options by ordinal, text and content, not by id, so id churn never reads as a change; normalising a False-first pair to True-first does, because the stored order changes. -
Geometry. Rows are the deck's: 4/6/4/4 padding, a 9px radius, a 32px mark target with an
18px ring, an 18px letter, a 32px bare input at 14px from 1024 (the 16px input floor holds
below it), "Correct" at 12px, a 28px remove; "Add option" is 30px with a 7px radius. An
unmarked row's border is
--input, not the deck's--border: the row is the text field's only boundary, and DG-31 holds input boundaries at 3:1. The sample answer's textarea uses--inputfor the same reason. -
Keys (
questionEditor): newmarkCorrect,optionLetter,removeOptionLetter,correct,maxOptionsToast,tickTwo,onlyOneTicked,trueFalseLabel,trueFalseHint,sampleAnswerPlaceholder,gradingNote.{auto,manual,anyAccepted,exactlyTicked},errors.maxOptions; new values foroptionsHint,optionsHintMulti,sampleAnswerHint, andformatOption/editFormattedOption, which take{{letter}}; removedautoGraded,manualGraded,removeOption,removeOptionN,optionTextLabel,optionPlaceholder. -
Left for 66b: the per-gap rows, the empty card and "Insert gap",
onEditor, the builder menu's "Remove gaps first" (31b), and the fill-in-the-blank half of the tests and of the deck check.
As built (T-R4.66b: the gap rows and "Insert gap"):
-
Which block.
AnswerAreaanswers a fill-in-the-blank withGapAnswerswhile the prompt is rich text: stored as rich content, or open in the rich editor, which is exactly when the host passesonInsertGap. A prompt in Markdown, the builder's new question included (its starter text opens the Markdown mode, and clearing it on focus does not change the mode), keepsBlanksEditor. An empty prompt's rich editor loads lazily, so for that first moment the Markdown block shows.BlanksEditorlost its rich branch (the rich preview, the orphan rows and their confirm) and is the Markdown editor only:{{n}}rows, the placeholder check, "Add blank", as before. -
GapAnswers(features/question-bank/components/): onerole="group"row per gap, named "Ô trống {label}", in prompt order (questionGaps); each row is the gap's dashed chip, aChipInput(success tone, 24px, no frame of its own: the row is the frame,--inputper DG-31) named "Đáp án được chấp nhận cho ô {label}", and "Match case" (Switchnamed "Phân biệt hoa thường cho ô {label}"). Enter, a comma and leaving the input add, Backspace in an empty input removes the last, a duplicate is refused whatever its case, and with "Match case" on the comparison is exact, so "London" and "london" are both kept (the server grades them apart). A comma always adds: an accepted answer cannot hold one (the plan let 66 decide; "Done when" lists the comma). The placeholder is "Type an accepted answer, then press Enter", then "Add another accepted answer". The header's "Capital letters and extra spaces are ignored." shows while no gap matches case. A gap with no answer has the danger border, its inputaria-invalid, and "Add at least one accepted answer for gap {label}." below, linked throughChipInput's newdescribedBy, drawn fromgapProblems(66a), the predicate the schema refuses with. Answers whose gap left the prompt keep today's alert and an explicit "Delete answers for gap {n}" behind the confirm; a bound gap's row has no remove: the gap is removed in the prompt. -
The empty card and "Insert gap".
ContentEditortakesonEditor, called with the live TipTap editor and withnullwhen it goes;RichBlankEditorandBlankPromptFieldpass it through, andQuestionEditorkeeps the prompt's editor in state and handsAnswerAreaanonInsertGaponly while one exists. The command isinsertGap, moved fromContentToolbartoeditor/gapCommands.tsand exported, with the prompt's owngapLabel(nextBlankOrdinal), so the card and the toolbar insert alike, at the prompt's last caret.gapCommands.tsimports only a type from TipTap, soQuestionEditor's chunk does not grow by the editor. The gap limit and its notice are the content budget's, unchanged. -
Commits beside the task's:
ChipInput'sdescribedByand the content editor'sonEditor/gapCommands.tsare each their own commit (W3 §2.4, composites); the removed keyquestionEditor.richBlankAnswersHintis its own commit. -
Keys (
questionEditor.gapAnswers):label,hint,caseIgnored,group,inputFor,placeholderFirst,placeholderMore,removeAnswer,matchCase,matchCaseFor,empty,noGapsTitle,noGapsText,insertGap,removedGroup,removeOrphan. -
VER-66a's two findings, fixed here. QA-66a-3: the Markdown blanks' hint printed
{{'{{1}}'}}raw, an escape i18next does not have;questionEditor.blanksHintnow takes{{first}}and{{second}}and the component passes the markers. QA-66a-2: a legacy pair stored as [Sai (correct), Đúng] showed "Đúng" correct, because no text was canonical and the first option was taken as true.readTrueFalsenow reads a truth word before position: the import'strueFalseValueset (t,true,đúng,dung;f,false,sai) and yes and no (yes,có;no,không), trimmed of spaces and full stops at either end as the import trims them, in any case.
Depends on: T-R4.4, T-R4.5b, T-R4.3c
Touches: web/src/features/tests/pages/teacher/TestBuilderPage.tsx, web/src/features/tests/components/{OutlineTree,OutlineGroupRow,AutosaveStatusLabel}.tsx, web/tests/units/builder/
Reuses: OutlineTree, OutlineGroupRow, BuilderGroupPane, BuilderWrites, useAutosave, SplitPane, MarqueeText, @dnd-kit (already a dependency)
Size: M
As built:
-
Touched beyond the list:
components/shared/SplitPane.tsx(it measures its row from the first paint and on every resize, so the separator'saria-valuenowis the rendered width andaria-valuemaxthe real ceiling before any key or drag; the builder is its only consumer),features/tests/components/QuestionPickerDialog.tsx(returnFocus: Escape gives focus back to the button that opened it, which Radix missed when the click had not focused it), the locales, and the live specs that read builder copy (builder-groups,admin-change-requests,author-and-publish,audio-plays). -
Words (DG-135). The deck's builder calls a section a "group"; the product says "section"
in en and "phần" in vi, because shared-context groups sit in the same outline. Everything
else follows the deck: "Outline" / "Dàn bài", "{n} questions · {pts} pts", "{n} · {pts} pts",
"1 pt" / "2 pts" on a row (
builder.pointsis a plural now), "Add question" with "to {section}" on the same line, "From question bank", "Instructions", "Drag questions here", "Preview", and "Question removed from this test. It stays in the bank." as a toast after Remove from test. vi keeps the short "{n} · {pts}đ" in a section header, as its rows read "1đ"; the outline's header says "{n} câu · {pts} điểm". -
Save label. In its
deckformAutosaveStatusLabelsays "Saved" from load and after every save, with no time, "Saving…" while a write is out and "Not saved" while an edit waits; the failed and stale states are unchanged. It carriesdata-state(idle,dirty,saving,saved), which the live specs wait on now that the text no longer changes after a save. -
Title bar. Two groups, as the deck: the title group (
flex: 1 1 320px: Back, the title, the outlined Draft pill, the save label) and the actions (Question settings and Versions as icon buttons with a tooltip, Preview, Publish). It keeps one row wherever the deck does, in both languages: Publish at y 80 and the outline at y 130 at 768 collapsed, 1024, 1280 and 1440, and both on a second row only at 360 and 768 expanded, where the deck wraps too. -
Outline geometry at deck scale. 13px rows in
--fg, the section header 28px with 2px 4px 2px 2px padding and a 7px radius, a 12px count, the summary at weight 500, the instructions line as the deck's 26px button with its scroll icon, the footer's 10px padding and 8px icon gap, and--shadowon the outlined footer buttons and Preview (0.3 in dark). Measured against the deck at all five widths, both themes: the outline is 34px taller than the deck's, which is the fourth footer button (DG-135), and 18px more at 360 for the selected row's 44px actions. -
Title and row widths (R414-3). The deck sizes the title for its text plus a space, room
for the caret, so the title button's right padding adds 0.21em; the title's hover frame sits on
its wrapper, as the deck's does, so the button is 34px high. A row's hidden actions cancel the
flex gap they would add (
-ml-1until shown). Title and row titles now measure the deck's 276.7×34 and 168.8×22 at 1280 and 1440, light and dark. - Rows. A row with a publish problem keeps its title, adds the warning icon, and names the problem as the title button's accessible description. Hover, or focus that is visible, swaps the points for Move up, Move down and Remove from 768; a mouse click on a title no longer reveals them. Below 768 only the selected row shows them, at 44px.
-
Drag. Over a section header the header tints
bg-brand-soft(--color-brand-soft, which is--accent-soft;data-outline-drop-into) instead of drawing a line above it, and so does an empty section's drop zone; rows keep the 2px primary line. -
Focus. Escape on the instructions dialog returns focus to the line or the "…" button that
opened it, and on the bank picker to "From question bank". "Add section" adds the section at
once with its name in an input, focused. A rename that loses focus to a pointer commits at the
click that follows the press, or after pointerup when no click follows (a tick for a mouse,
400 ms for touch or a pen, whose compatibility mousedown moves focus only after a zero timer has
run; a pointercancel commits at once), not on blur: the header shrinks when it leaves rename mode, and committing at
pointerdown moved the next control from under the pointer, so "Add question" right after "Add
section" lost its click (the live
admin-change-requestsspec), and on touch the click comes after pointerup. The rename is keyed by the section's identity and commits through the outline's current state, so a reorder or removal during the press renames the right section (R414-2, R414-4). Measured in Chromium, a tap fires pointerdown, touchstart, pointerup, touchend, then a zero timer, then the compatibility mousedown (which blurs the field), mouseup and click; the click-capture release covers that path, and the 400 ms only a press with no click. -
Not done here: the deck's 8px button radius (the primitives draw 9px at deck scale until
R5) and its
normalline height on buttons.
Done when:
- Title bar: inline title (Enter or Esc blurs; empty becomes "Untitled test"), marquee on
overflow only, status pill, Saving… / Saved, Preview, Publish (
builder/deck-frame,builder/autosave) - Outline header "Outline" / "{n} questions · {pts} pts"; group header with grip, chevron,
title (double-click renames, "↵ save · esc cancel"), "{n} · {pts} pts" and a "…" menu
(Rename, Instructions, Move up / Move down disabled at the ends, Remove group with the
deck's confirm); the instructions line under the header (
builder/section-menu,builder/deck-outline; "Remove section", DG-135) - The deck's "group" is the product's section; the shared-context groups in the outline
(
OutlineGroupRow,BuilderGroupPane) keep their behaviour and are restyled - Question rows: grip, number, type icon, title, a warning icon when publish would fail on
it, points; on hover Move up, Move down, Remove from test ("It stays in the bank")
(
builder/reorder,builder/outline-deck-states) - Drag with
@dnd-kit: a 2px primary drop line, a group header as a drop target, "Drag questions here" in an empty group, groups reorderable; every drag has a keyboard or menu equivalent; menus are never clipped (builder/deck-outline; the header tintsbg-brand-softrather than drawing a line, as the deck does) -
SplitPaneoutline clamped to [220, content − 374], default 300, stored inquizzivy.builder.outline, double-click resets; split whenuseMediaQuery("(min-width: 768px)") && useContentWidthAtLeast(594), stacked otherwise (builder/deck-frame,shared/split-pane) - Footer: "Add question to {group}" (15 characters then "…"), From question bank, Add group ("Add section", DG-135)
- Both autosave flushes stay (unmount and publish);
builder/autosave-unmount.test.tsxgreen - Deck check at the five widths, light and dark; keys vi first
Reopened in D4 (74d-d4-deck-update.md): T-D4.2a replaced the group row's Move up, Move down
and Delete buttons with its "…" menu (Rename, Shared content, Move up, Move down, Remove group) and
added the shared-content line under the row; T-D4.20 made the outline sticky while the builder
splits; T-D4.34 added "AI check" and "Generate" to the title bar before Preview; T-D4.5 reworded the
Instructions dialog's sentence to "above every question in the section".
Depends on: T-R4.31a, T-R4.15, T-R4.16, T-R4.65, T-R4.66
Touches: web/src/features/tests/pages/teacher/TestBuilderPage.tsx, web/src/features/tests/components/{PublishDialog,DraftPreviewDialog,QuestionPickerDialog,GroupPickerDialog,StudentPreview}.tsx, web/src/features/tests/components/OutlineTree.tsx (type icons), web/tests/units/builder/, web/tests/integration/builder-question-switch.test.tsx
Reuses: PublishDialog, publishProblem.ts, DraftPreviewDialog, QuestionPickerDialog, QuestionEditor with its answer areas and grading note (T-R4.66), QuestionProseField and ContentEditor (T-R4.63, T-R4.64), QuestionMediaField and "Choose from Media" (T-R4.65), TagCombobox
Size: M
As built (2026-10-10):
-
The pane.
BuilderQuestionEditor(features/tests/components/) is the editor pane for a standalone question, composed from the shared blocks and not fromQuestionEditor's frame (W2 §1.2). Its header has "Question {n}", the type menu, Points and the "…" menu; its body has the prompt,AnswerAreawithonInsertGapfrom the prompt's editor, the question media block, and<details>"More options" holding the explanation aboveTagsField. The type menu is aDropdownMenutitled "Question type", 250px, withQUESTION_TYPE_ICONS(question-bank/components/questionTypeIcons.ts, also on the outline's rows), "Current", and "Remove gaps first" on the itemstypeLockeddisables; it callsretype. -
Points and tags moved into the pane, so the builder's settings button, sheet and dialog
are gone at every width.
narrow-settings.test.tsxandsettings-dialog-width.test.tsxtested that dialog;editor-pane.test.tsxkeeps what they pinned that still holds (points and a tags draft across widths, the flush between questions) against the pane. -
The "…" menu. Duplicate calls
duplicateQuestionand inserts the copy after the original. "Move to section" (the deck's "Move to group": the deck's group is the product's section) is aFormDialogwith the sections, and moves the question to the end of the chosen one. "Delete question" confirms with "It stays in the question bank if it came from there." and removes it from the outline, opening the next question. -
Starter. The builder's starter question is created with
promptContent. A rich prompt whose text is still the starter opens empty under the placeholder "Write the question students will read", and the starter stays stored until the first edit, as the Markdown field already did (RichProseEditor'sstarter). The explanation's placeholder is "Explain the correct answer."; its hint is the existing rule (DG-108). -
Add from question bank is a 560px
DialogShellwith its own list: the search goes to the server (listQuestions({ q }), debounced 250ms), pages of 50 load as the list scrolls, and each row shows "{type} · {level} · used in {n} tests" fromusedInTests. Ticks survive searches and pages; "Add {n} questions" sends every ticked id in the order ticked. A question already in the test shows "In this test" and cannot be ticked. "Choose a complete question group" stays. -
Student preview is a 720px
DialogShellover the existingStudentPreviewPane, with no new preview logic (W3 (c); D4's T-D4.2c replaces it). Previous and Next step through the questions and scroll the dialog's body alone (neverscrollIntoView, which also scrolled the frame and left a band under the footer) to put each at the top with its passage above it, rather than drawing one question alone, because the reader draws a group's passage only above its first question; it opens on the question being edited (DG-143).StudentPreview.tsxis untouched (lane 2's T-R4.30). -
Publishing in place. Publish flushes both autosaves and opens "Publish test?"
(
FormDialog): the checks arepublishProblemfor each loaded question until the server answers, then its violations, each with "Fix it"; when nothing fails, "Every question has a correct answer" and "Every question has points", and "All groups have instructions or a title" whenever the test holds a group. Missing explanations are listed and never block. While a check fails, Publish refuses with an alert instead of sending (the deck greys it; DG-144). The optional change note goes topublishTest(trimmed; empty is no note). On success the builder stays open, takes the version'stestUpdatedAtas its outline base, and toasts "Published. You can now assign it." with Assign (/teacher/assignments/new?test=, the wizard's name since T-R4.27a).publish-in-place.test.tsxpins the base, the note, the toast and a genuineSTALE_WRITEfrom another tab. -
Live specs publish through
publishInBuilder(e2e/support/live.ts) and author new questions in rich text through the type menu. -
Shared-context groups (T-R4.31b-2, its own PR after this one).
OutlineGroupRowis drawn in the outline's own rows: a 32px header with the grip, the disclosure, the Layers icon, the title and "{n} · {points}", then Move up, Move down and Delete as 24px icon buttons; its members are question rows (number, the type's icon, the prompt, points), indented under a rule. Below 768px the group's actions show at 44px only while the group or a member is selected, and its summary gives way to them, as a question row does (DG-135). The deck draws no shared-context group, so these follow the question rows.BuilderGroupPanetakes the question editor's frame: a header with the group's title over its save state, and "Save a copy to the bank" as a 30px outline button, then the alerts and the embeddedGroupComposer(lane 3's, unchanged) in the editor's body padding. Every label anddata-outline-groupthe tests use is kept. -
From VER-65. QA-65-1: "Choose from Media" gives focus to the attached card's "Replace"
after Attach or a double-click (its
returnFocus), and back to "From Media" on Escape or Cancel. QA-65-4: a file's name in the block wraps to two lines and carries its full name as atitle.
Done when:
- Editor header "Question {n}", the type menu, Points, "…" (Duplicate, Move to group, Delete question with "It stays in the question bank if it came from there."). No "Save to question bank": every builder question already lives in the bank
- The deck's "group" is the product's section; the shared-context groups in the outline
(
OutlineGroupRow,BuilderGroupPane) keep their behaviour and are restyled (T-R4.31b-2) - Type menu, titled "Question type", 250px: "Single choice", "Multiple choice", "True /
False", "Fill in the blank" and "Short answer", with the deck's icons (
circle-dot,square-check,circle-check,text-cursor-input,align-left), which the outline's rows use too; the current type shows "Current". "Matching" is absent until R6 (DG-60, DG-113). Points is an input, not the deck's static "1" (DG-115) - The editor column, in the deck's order: "Prompt", the answer area with its grading note, "Question media", "More options". "Prompt" is the content editor (T-R4.63, T-R4.64) at 96px with the placeholder "Write the question students will read". The answer area is T-R4.66's, so options run A–H, up to eight (DG-63). "Question media" is T-R4.65's block
- The builder's starter question is created as rich text (content), so a new builder question opens in Rich text (T-17: a field opens in its stored form, and the starter prompt is stored as Markdown today)
- "More options" holds "Explanation" above Tags, in one column. "Explanation" is the same editor at 84px, without "Insert gap", with the placeholder "Explain the correct answer."; the deck's second sentence, "Students see this after grading.", is left out for the same reason as the hint. The hint states the rule, not the deck's "· shown after grading": students see it with their result when the assignment shows explanations (DG-108)
- Selecting another question saves what is pending and opens the next with its own content,
its own mode and no notice carried over.
builder/autosave-unmount.test.tsxandbuilder-question-switch.test.tsxstay green, the second extended to a rich prompt - Add from question bank: search, meta "{type} · {level} · used in {n} tests", "Add {n} questions"; Student preview with Previous / Next, rendering rich prompts, gaps, question media and each type through the engine's readers, not the deck's four demo options (DG-115)
- Publish dialog: the check rows with "Fix it" selecting the question, an optional change
note; on success the builder stays open, takes
testUpdatedAtas its autosave base and toasts "Published. You can now assign it." with Assign - A unit test beside
autosave-unmount.test.tsxedits after publishing and saves withoutSTALE_WRITE;STALE_WRITEfrom another tab still shows its notice - Deck check at the five widths, light and dark; keys vi first
Reopened in D4 (74d-d4-deck-update.md): T-D4.2c replaced the Student preview dialog and its
Previous / Next paging with the overlay's bar and frame; T-D4.16 put the take-test engine in the
frame in place of StudentPreview's read-only paper; T-D4.14 added the Shared content dialog the
outline opens.
Depends on: T-R4.4, T-R4.5b, T-R4.1a, T-R4.1b, T-R4.1c, T-R4.2c, T-R4.15
Touches: web/src/features/question-bank/pages/teacher/QuestionBankPage.tsx, web/tests/units/question-bank/
Reuses: DataTable, Pager, BulkActions, BulkTagDialog, AddToTestDialog, TagCombobox, Sheet, BankNavigation
Size: M
As built (T-R4.32: question bank):
-
Touched beyond the page: five new files beside it in
features/question-bank/pages/teacher/(bankFilters.ts,BankFilterPanel.tsx,BankTagFilter.tsx,BankCells.tsx,bankColumns.tsx; not incomponents/, which is lane 1's), the question-bank route line inapp/router.tsx, which now declares a teacher handle (crumbteacherShell.nav.questionBank, width 1320) so the page renders inTeacherLayoutand follows the theme, thebankblock of both locales, andtests/support/questionBank.tsx(new).TagComboboxis not used: the tag filter is a search over the server's tags, not an entry field, so it is its own combobox. -
The three contract gaps, built with what the contract has: Audio is the
hasAudioparameter and shows no count, becauseQuestionTypeFacetscarries none; the tag list shows no per-tag counts; and a row's tag chips have no remove button, becausetagQuestionsonly adds (a tag is removed in the editor). The page never calls a missing operation. -
Column thresholds are the deck's on the content width, raised where the 220px aside and
its 14px gap would leave the table narrower than its tracks need (
bankThresholds): Level from 598, Type from 750, Used from 842 and Updated from 944 beside the aside, the deck's 540, 640, 780 and 900 below 1024. Where the deck's tracks fit, the columns match it (all four at 1280 expanded); where the deck overflows its card by up to 62px (1024 to 1247), a column drops instead. Below the Type threshold the prompt carries "{type} · {level}". -
Labels: the types are the five the product stores, with the editor's names (Short answer
for the deck's "Open answer", DG-60), plus Audio; levels are Pre-A1 to C2 and skills the six
of DG-62. The search placeholder keeps "Search the question text":
qsearches the prompt only, so the deck's "Search prompts, answers, tags" would promise more than it does. Share (R7, DG-90) and Import (R6, DG-72) are absent.BankNavigation(Questions | Groups) stays under the header, the only way to the groups screens (DG-68). -
Not carried over: the per-row menu (Open, Add to test, Duplicate, Delete), the in-row
duplicate, the audio preview row and the usage row. The deck draws none of them on the list;
its question editor carries Add to test, Duplicate and Delete (T-R4.33). Until 33, Delete is
the bulk bar's, which confirms with the deck's sentence and names any question a draft still
holds with the server's message; Duplicate waits for 33.
AudioPreviewRowandQuestionUsageRoware unused until 33 decides. The oldlist,row-menu,bulk-selectionanddeletetests went with them;bank-list,bank-tag-filterandbank-bulkreplace them. - Deck check: the deck could not be rendered here (it loads React and Babel from unpkg, which the proxy refuses), so the page was measured against the deck's markup at 360, 1024 and 1280, light and dark, on the seeded API: the 24px h1, the 220px aside and 14px gap, the 34px search, the 40px header band and 10px row padding, no overflow at any width. The full matrix is the tester's (gate policy G-2). Done when:
- Header "Question bank" / "{n} questions you can reuse across tests." + New question (→ editor); the Import button is absent in R4 until a release owns question import (open item)
- Filters: a 220px sticky aside from 1024, a "Filters" sheet
min(320px,100%)below with a count; Type (Open answer isshort_answer, Audio ishasAudio), Level (Pre-A1…C2) and Skill with server counts; Tags search popover (Enter toggles the first match, Esc closes), chips, Clear, and Match Any | All from two tags - Columns: select, Question (+ tags), Type ≥640, Level ≥540, Used ≥780 ("{n} tests"), Updated ≥900; type and level inline below 640; a row opens the editor
- Bulk: Add to test (Test and Group), Add tag, Delete ("Tests that already use them keep their copy.")
- States: skeleton; "No questions have these tags." + Clear tags; error; filters and page in the URL; deck check at the five widths, light and dark; keys vi first
Reopened in D4 (74d-d4-deck-update.md): T-D4.20 gave the filter aside the deck's 16px sticky
offset from 1024; T-D4.34 added "Clean up" and "Generate" to the header.
Part 4 of 6 of R4 — Teacher workspace (v0.10.0) · Previous · Next
Generated from docs/plan/74-r4.md. Edit that file in the repository; changes made on this page are overwritten.