Plan 74d D4 Deck Update Part 3 - SekiroKenjii/Quizzivy GitHub Wiki
Part 3 of 5 of D4 — Deck update: the fourth export and v2 (v0.11.0) · Previous · Next
R4 built these screens from the third import. Each task below that reopens one names the R4 task whose result it changes, and compares the screen with the deck again.
Untouched by the export, and not reopened: T-R4.1 to T-R4.29, T-R4.32, T-R4.33, T-R4.35 to T-R4.38, T-R4.40 to T-R4.49, T-R4.53 to T-R4.57, T-R4.62, T-R4.63, T-R4.64, T-R4.66. The page's tokens, shell, content editor, question media block and bank editor did not move.
Rules for T-D4.2a, T-D4.2b, T-D4.2c, T-D4.14 and T-D4.15 (AGENTS.md, "A debounced autosave
owes the user two flushes"). Each edits the builder or something its autosave holds: T-D4.2b's
GapBindings renders inside the group editor's form, with its bundle autosave and its
recovery draft, and T-D4.2c replaces the dialog the builder opens only after it has flushed
the open question and the outline. The five canaries, tests/units/builder/*,
tests/units/question-groups/* (editor-save, material-transaction, model) and
tests/e2e/builder-group-recovery.spec.ts run on the branch point and on the last commit, both
runs in the PR. builder/autosave-unmount.test.tsx and autosave-flush.test.tsx are not
edited.
Reopens: T-R4.31a (the outline's group row)
Depends on: T-D4.1
Touches: web/src/features/tests/components/{OutlineTree,OutlineGroupRow}.tsx, web/src/features/tests/pages/teacher/TestBuilderPage.tsx (selecting the group from the line and from the menu items, nothing else), web/src/features/tests/components/BuilderGroupPane.tsx and web/src/features/question-groups/components/GroupComposer.tsx (focus on the title field when Rename asks; no save path changes), locales, web/tests/units/builder/ (the outline's cases)
Size: S
Done when:
- The deck's "group" stays the product's section (DG-186). A section's "…" menu is R4's
(Rename, Instructions, Move up, Move down, Remove group) and gains no "Shared content":
the export's menu entry
mi('Shared content', 'panel-left', …)(T 6986) is on every group, and a section has no shared content to hold. - A shared-context group keeps its own row inside its section (
OutlineGroupRow). The buttons R4 left on the row give way to a "…" menu: Rename, Shared content (thepanel-lefticon, the export's third item), Move up, Move down (disabled at the ends), Remove group. Move up, Move down and Remove group do what their buttons did; the row's drag, its keyboard equivalents and its focus ring are R4's. - Rename is new on this row: R4 gives Rename to a section's menu only, and a group's
title has one field, in
BuilderGroupPane(shared-group-title). Rename selects the group and puts focus in that field with its text selected. The title is saved by the group's editor, and the outline calls no operation: a rename in the outline would be a second writer of the bundle (T-D4.14, "One writer"). - Under the row's header, while the group is open, the export's shared-content line
(T 1581-1587, the button on
sec.editShared; its text is the script'ssharedText, T 6968) in the instructions line's geometry: margin 0 0 4px 26px, padding 4px 6px, radius 6px, 12px--muted-fg, a 13px icon, one line with an ellipsis. It readsfile-text"Passage · {the material's title}", plus " · {n} gaps" when it has gaps; oraudio-lines"Audio · {file name} · {n} plays" or "· unlimited plays", with "1 play" in the singular (the prototype prints "1 plays"). A group that holds more than one material, or both kinds, reads "{n} materials · {m} recordings". A group with no shared content shows no line. - The line is a button. It and the "Shared content" item select the group, which opens
BuilderGroupPane, until T-D4.14 opens the dialog. Selecting the group, from the line or from either item, saves what is pending in the open question first, as selecting a row does today. - Unit tests: the line in each form (a passage with and without gaps, a recording with a limit, with one play and with no limit, more than one material, none); the menu's items and their disabled states; "Rename opens the group, focuses its title and sends no request"; "a pending edit is saved when the line is pressed".
- Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, on a
shared-context group holding one passage: the deck's fixture shows "Passage · Why cities
need green space" under its first group (
INIT_SECS, T 6632). Known difference: the line and the item are on the group's row, not on the section's (DG-186). - Keys vi first, en from the deck, listed in the PR.
As built: one pull request, two commits (the row, then this note). OutlineGroupRow keeps R4's
grip, disclosure, title and summary; its Move up, Move down and Delete buttons became one "…"
trigger (builder.groupActions, "Thao tác với nhóm {title}"), 24px and shown on hover, focus and
while open from 768px as a section's is, and at 44px only while the group is selected below it
(DG-135). The menu is the export's: Rename, Shared content (panel-left), Move up, Move down
(disabled by stepUnit at the outline's ends, which the old buttons never were), a separator and
Remove group. Rename runs once the menu has closed (onCloseAutoFocus), because a focus moved
while the menu's focus scope is mounted is taken back; Remove group hands its trigger to the
builder's confirmation as returnFocus, since the menu item it opened from is gone when the
dialog closes. The line is sharedContentLine (features/tests/sharedContentLine.ts): one
material with no audio is a passage, one material that is one recording and nothing to read
(empty paragraphs aside) is audio, anything else is "{n} materials · {m} recordings", even with
no recording ("2 materials · 0 recordings"). The file name is the asset's originalFilename, as
the composer's recording select prints it, from the assets of each loaded group, which the
builder passes to the outline as groupFiles; a name it does not hold is left out ("Audio · 2
plays"), so the export's "no file yet" has no use. Rename selects the group through
selectGroup with a title request, BuilderGroupPane hands it to GroupComposer as
titleFocus, and the composer focuses and selects shared-group-title; no request leaves the
outline. The line and Shared content select the group through the same selectGroup, which
flushes the open question first. Tests: tests/units/builder/group-row.test.tsx (new, 16: the
line's forms in both languages, its geometry and its collapse, the menu's order, icons, disabled
ends and keyboard use, its escape from the scrolling column, and on the real builder Rename's
focus with and without the group already open, the focus after a cancelled removal, and the
pending edit saved before the line opens the group); builder-groups.live.spec.ts now moves and
removes the group through its menu. Measured on a live stack with getBoundingClientRect at 360,
768, 1024, 1280 and 1440, light and dark, the sidebar expanded (768 to 1440), collapsed (1024 to
1440) and a drawer (360): the line is 26px high, 26px in from the header, 12px --muted-fg with a 13px
icon, 4px 6px padding, radius 6, an ellipsis, flush under the header and 4px above the members;
the menu is 192px and inside the window at every width; no horizontal scroll. The deck itself
could not render (unpkg.com does not answer through the proxy), so it was compared with its
markup and script (sec.hasShared, sec.sharedText, sec.menu), not side by side.
Reopens: T-R4.34 (question groups, restyled)
Depends on: T-D4.1
Touches: web/src/features/question-groups/components/GapBindings.tsx, locales, web/tests/units/content/gap-bindings.test.tsx
Size: XS
Done when:
-
GapBindingstakes the row the export draws (T 1670-1693): a dashed chip "gap {n}" (at least 52px wide, 24px high, 1.5px dashed--ring,--muted, 12px 600), a 32px select whose first entry is "Choose a question…", a danger border while the gap is unbound, and a 28px "Remove gap". An unbound row is also named as unbound for a screen reader, so the state is not carried by the border alone. - Above the rows: "Optional: add gaps for a cloze. Each gap is answered by a choice question or a blank in this group, shown at the gap." The export's sentence says "a single-choice question"; the product also binds a gap to a blank (DG-187).
- The rules do not change (DG-187;
model.ts:113-155,group_validation.go:176-212): a gap is inserted at the caret by the editor's "Insert gap", a target is a choice member or one blank of a fill-in, a target is used once and shows disabled elsewhere, and a group with an unbound gap does not save. The export's "[gap n]" appended at the end of the text, any question as a target and a saved unbound gap are not taken. -
gap-bindings.test.tsxpasses with only its locators moved, and gains the unbound row's name.group-authoring.spec.ts,group-authoring.live.spec.tsandbuilder-groups.live.spec.tsare green. - Compared with the deck at 360 and 1280, light and dark, in the builder's group pane and in the bank's group editor, which share the component.
As built: one pull request, two commits (the row, then this note). The row is the export's:
a chip "gap {n}" (groups.gapChip, "chỗ trống {n}" in Vietnamese, so wider than 52px), a 32px
select at 12.5px with 8px padding and radius 7 on --card, and a 28px ghost "Remove gap"
(groups.removeGap, "Xoá chỗ trống {n}", one name per row) with the 14px x; rows 6px apart,
8px between their parts, the hint 12px --muted-fg 12px above them. The rows share one grid
(grid-cols-subgrid), so the selects line up when two chips differ by a few pixels. The select's
first entry, "Choose a question…", is today's "Remove binding" moved into the export's place: it
unbinds the gap, and while the gap is unbound it is what the select reads. The bound border is
--input, the product's control border (DG-30), and the unbound one --danger through
aria-invalid. An unbound row is a role="group" named "Gap {n}, not linked"
(groups.gapLabelUnbound), a bound one "Gap {n}" (groups.gapLabel); the select is "Question
for gap {n}" (groups.gapTarget, the export's aria-label) whatever its state, so a locator holds
across a bind. "Remove gap" deletes the gap from the material's text, as the export's does, not
only its binding, which the first entry already does: removeGap (gapCommands.ts) deletes the
gap node through the material's editor and leaves the caret where it was, so it is one undo step
and undo brings back the gap and its binding (MaterialContent keeps a removed gap's binding for
undo, as for a chip deleted in the text). That needs the editor beside the rows, so
MaterialContent passes onEditor and GroupComposer's material pane holds it; GapBindings
draws the button only when the host passes onRemove, for T-D4.14's dialog to pass its own. The
hint shows on every rich-text material, with or without gaps, as the export draws it; a Markdown
material has no rows and no hint. The targets keep the product's labels ("Question {n}", "Question
{n} · blank {b}"), not the export's "Question {id} · {title}". Locators moved in
group-authoring.spec.ts (the select's name, the unbind entry, its text); gap-bindings.test.tsx
gains "an unbound gap's row is named as unbound, and Remove gap takes the gap out of the text",
on the real GroupComposer. Measured on a live stack with getBoundingClientRect at 360 and 1280,
light and dark, in the bank's group editor and the builder's group pane: every value above, no
horizontal scroll, and the focus ring 2px --focus offset 2px on the select and on Remove gap.
The chip's 1.5px border computes as 1px in this Chromium at device pixel ratio 1 and 2, as an inline
border: 1.5px does, so the deck's own chip would too. The deck could not render here (its CDN
scripts do not load), so it was compared with its markup and script (shc.gaps, T 754, 2940).
Depends on: T-D4.2a, T-D4.2b. It builds on what R4 left on develop: the builder (T-R4.31a, T-R4.31b), the group editor (T-R4.34), useMediaUpload (T-R4.35), the content editor (T-R4.63) and AudioPolicyPanel with "Choose from Media" (T-R4.65)
Touches: web/src/features/tests/components/{SharedContentDialog.tsx (new),OutlineGroupRow.tsx,BuilderGroupPane.tsx}, web/src/features/tests/pages/teacher/TestBuilderPage.tsx, web/src/features/question-groups/{model.ts,sharedContent.ts (new),useGroupEditor.ts} (the last only if saveNow must hand its outcome to the dialog; its queue, its base and its recovery writes are not changed), web/src/features/question-groups/components/{GapBindings,MaterialContent,MaterialAssetDialog}.tsx (reused, not restyled again), web/src/features/media/components/AssetLibraryDialog.tsx (an initial kind), locales, web/tests/units/builder/shared-content-dialog.test.tsx (new), web/tests/units/question-groups/editor-save.test.tsx, web/tests/e2e/{builder-groups.live.spec.ts,builder-group-recovery.spec.ts}
Reuses: FormDialog, Segmented, ContentEditor (profile document), GapBindings, AudioPolicyPanel, AssetLibraryDialog, useMediaUpload, materialTransaction, and the group's one writer: useGroupEditor behind GroupRecoveryGate, with recovery.ts's draft
Size: M
High risk: this is the builder's autosave and its device-recovery draft (AGENTS.md, "A
debounced autosave owes the user two flushes"). Before and after, both runs in the PR: the five
canaries, tests/units/builder/*, tests/units/question-groups/* (editor-save,
material-transaction, model) and tests/e2e/builder-group-recovery.spec.ts.
One writer. A section-owned group already has one: BuilderGroupPane mounts
useGroupEditor (BuilderGroupPane.tsx:85-101), which owns the revision base and the
device-recovery draft (useGroupEditor.ts:43-46, 94-117, 121-136) and raises copyRequired on
a revision it did not write. A group's member questions are part of that bundle; they have no
question autosave of their own. T-D4.2a makes the menu item and the outline line select the
group, so the pane and its editor are mounted while the dialog is open. A dialog
that called updateQuestionGroup itself would move the revision under the editor: the
teacher's next keystroke in the pane would meet "This group changed elsewhere", and a recovery
draft holding the older materials could be offered back over what the dialog saved.
Done when:
- No operation is added, and the dialog calls none: it saves through the editor, whose
saveis today'supdateQuestionGroup(x-permission: [content.questions.write, content.tests.write]; a group in a test section needscontent.tests.write), already in the isolation suite with itsx-resourceentries. The PR says so. - The dialog is rendered by
BuilderGroupPane's form, which holds the editor. "Shared content" in a shared-context group's menu, and its outline line, select the group and ask the pane to open it; the pane answers once its recovery gate has resolved, so a recovery draft is offered first, as today, and the dialog opens over the bundle the teacher chose. - It is 560px (
min(560px, 100% − 24px)): "Shared content for “{group}”", "A passage or recording shown beside every question in this group. Students see it beside the questions while they answer." (the deck's "on the left", reworded because a phone shows a drawer; DG-187), a scroll area (gap 12px, at most 56vh), Cancel and Save. - Kind: a full-width Segmented, 30px, "None" (
circle-slash), "Passage" (file-text), "Audio" (audio-lines). - Passage: "Title" (36px, "e.g. Why cities need green space", required, 1 to 200
characters); "Passage" is the content editor in T-R4.63's frame with the placeholder
"Paste or type the passage students read" (DG-187), and the hint "Optional: add gaps for
a cloze. …" over T-D4.2b's
GapBindingsrows. The editor's toolbar holds the dialog's only "Insert gap" (T-R4.63 shows it for thedocumentprofile, at the caret); the deck's separate 30px button beside the hint is not built, so there are never two (DG-187). - Audio: the empty zone (1.5px dashed
--border, radius 10px, padding 12px,audio-lines17px) reads "MP3 or M4A up to 50 MB and 5 minutes" (DG-63), with "Choose file" and "From Media"; the attached card shows the file name and "Replace". Uploads go throughuseMediaUploadwith its progress and failure. "From Media" isAssetLibraryDialogopened on Audio: R4 built it to open on All from the question media block, its only entry point then (T-R4.65), so it gains an initial kind and the block keeps All. ThenAudioPolicyPanel: "Plays", "Allow skipping ahead", "Show transcript after submitting", and "Transcript" with "Optional. Students see it after they submit." shown only while that switch is on. "Students can pause" appears under "Plays" when T-D4.15 merges; the panel is shared, so the dialog is not edited for it. - What "Audio" stores is the shape the server accepts: one material whose content is the
single audio node, and one recording bound to that asset (
group_validation.go:235, 243-246). The material's required title is the file's name without its extension. It is a teacher-side label: it shows in the outline line and in the full pane, and the student never sees it, because an audio-only material draws no heading (T-D4.5, T-D4.8). - The dialog edits a group with at most one material and one recording of one kind. A
group that holds more (an import, a copy from the bank, a material mixing text and
audio) opens
BuilderGroupPaneinstead, and nothing is dropped. Saving another kind over stored content asks first, naming what goes and how many gaps unbind. "None" removes the shared content and keeps the group and its members. - The dialog works on a copy of
editor.bundletaken when it opens. Save callseditor.change(next), wherenextis the editor's current bundle with onlystimuliandrecordingsreplaced (so a member edited in the pane a second ago is in what is sent), then awaitseditor.saveNow(). The revision,expectedTestUpdatedAt(through the pane'scoordinate), the recovery draft and the conflict keep their one owner. Save is disabled, with one sentence, while a gap is unbound, the title is empty, the Audio kind has no file, or the editor'scopyRequiredis true (today's "This group changed elsewhere"). A failed save keeps the dialog open with the editor's message. Cancel discards the dialog's copy and nothing else; with edits it asks first. Publishing still flushes first, through the pane'sflushRef. -
shared-content-dialog.test.tsxcovers the three kinds, the refusals, the kept members, the group that opens the pane instead, that a transcript never reachesquestion-groups/preview.ts's output, that Save sends exactly one request, and "an edit in the pane right after a dialog save is saved, with no conflict".builder-group-recovery.spec.tsgains: a dialog save, a reload before the server answers, and the recovery draft offered holds the dialog's content, not the older one.builder/autosave-unmount.test.tsxandautosave-flush.test.tsxstay green. - Deck check at the five widths, light and dark; keys vi first, en from the deck except the one reworded sentence.
As built: one pull request, two commits (the dialog, then this note). No operation was
added and the dialog calls none: SharedContentDialog (features/tests/components/) hands its
content to the pane, and BuilderGroupPane's form saves it through the group's editor as
editor.change(withSharedContent(editor.bundle, content)), await editor.persist(), await editor.saveNow(); the request is today's updateQuestionGroup with the pane's revision and
coordinate. useGroupEditor is not changed: saveNow already rejects with the reason a save
failed, which the dialog shows. The persist() between the two is the editor's own recovery
writer, called at once instead of 300 ms later, so the device draft holds the dialog's content
before the request leaves; builder-group-recovery.spec.ts fails without it (no draft is offered
after the reload). The kinds are read by readSharedContent (question-groups/sharedContent.ts),
which sharedContentLine now uses too, so the line and the dialog agree on what a passage and a
recording are; anything else is null, and the pane keeps the group with the sentence "This group
holds more shared content than the Shared content dialog shows, so it is edited here." The
outline asks through a new onOpenSharedContent(id, opener): the line passes itself, the menu
item its trigger, once the menu has closed and returned focus to it; the builder keeps a request
counter beside titleFocus and the pane answers it after GroupRecoveryGate, so a recovery draft
is offered first. The dialog is DialogShell and its parts at 560px rather than FormDialog,
whose typed fields hold none of this and whose empty form would take the first focus: the dialog
opens with focus on the pressed kind and returns it to the opener. The kind switch is the
Segmented (fill, 30px, 13px, 14px icons); the other kind keeps its fields while the dialog is
open. Passage: "Title" (36px, required, at most 200) and MaterialContent, which gains label,
placeholder, minHeight, fontSize (13.5px, a 175px body) and assetKinds: the passage offers
Image only, so it never turns into a material mixing text and audio. The toolbar's "Insert gap" is
the only one; GapBindings takes the editor's removeGap as T-D4.2b left it. Audio: the zone and
the attached card as drawn (30px buttons with no icons, a 28px Replace, which returns to the zone),
useMediaUpload with UploadStatus, and AssetLibraryDialog opened on Audio through the kind
prop T-R4.65 already gave it, so features/media/ is not changed. AudioPolicyPanel gains
transcriptHint, which the dialog sets to "Optional. Students see it after they submit." while
"Show transcript after submitting" is on. The listening settings show before a file is chosen, as
drawn, and a library file's stored play limit starts them when it is the first file and they were
not edited (DG-69). A recording is saved as one material titled with the file's name without its
extension, whose content is the one audio block labelled "Audio", and one recording bound to it.
Save is disabled with one sentence for the conflict, a missing file, and whatever groupIssue
says of the group with the copy (the title, an unbound gap). Saving another kind over the stored
one asks, naming the passage or the file and the number of linked gaps; Cancel, Escape and the
close button ask before discarding an edit. Tests: tests/units/builder/shared-content-dialog.test.tsx
(new, 10: focus on open, the passage saved in one request with its members kept and focus back on
the line, an edit in the pane right after it saved at revision 2 with no conflict, a recording
from Media with its stored shape and a transcript absent from groupPreview, an upload refusal,
None over a passage with its linked gap named, the refusals, a failed save and a conflict, the two
ways to cancel, and the group left to the pane); group-row.test.tsx moves its Shared content
assertions to onOpenSharedContent; builder-group-recovery.spec.ts gains the reload during a
dialog save; builder-groups.live.spec.ts gains a recording, then a passage over it, through the
real API. Compared with the deck (rendered with React, Babel and lucide served from their npm
tarballs) at 360, 768, 1024, 1280 and 1440, light and dark, measured with getBoundingClientRect:
the dialog is 560px from 768 and 336px at 360, the switch 30px with 13px 500 labels and 14px
icons, the title field 36px, the zone 1.5px dashed --border, radius 10, padding 12, 17px icon,
its buttons 30px and 91px wide as the deck's, the card 50px with a 28px Replace, Cancel and Save
36px; no horizontal scroll; the focus ring on the pressed kind is 2px --focus offset 2px. Known
differences: the frame is the product's (DialogShell: a close button, 16px title, the footer's
rule), the passage is the content editor with its toolbar and word count (DG-187), "Plays" is the
product's radiogroup (DG-69), the transcript keeps its placeholder and two rows, the limit sentence
is DG-63's and wraps at 360, and the title field is 16px below 1024 as every product field is.
Reopens: T-R4.65 (the question media block's "Plays", and "Students can pause is not built")
Depends on: T-D4.14; the four pause bullets also on T-D4.13. If the owner declines Q4, the task is its "Plays" bullet and the comparison
Touches: web/src/features/question-bank/components/AudioPolicyPanel.tsx, web/src/features/question-bank/{audioPolicy.ts,questionSchema.ts}, web/src/features/question-groups/{model.ts,recovery.ts}, locales, web/tests/units/question-bank/editor.test.tsx, web/tests/units/question-groups/{recovery-compat.test.ts,recording-plays.test.tsx} (new)
Size: S
Done when:
- "Plays" is one control everywhere (DG-69, Q25). T-R4.65 built "Once | Twice | Unlimited"
for a question's audio, with a stored other value as a fourth option while it is the
value. A group's recording shows the same control, in the group pane and in the Shared
content dialog:
AudioPolicyPanelis the one component for both, and if R4 left the group's recording on the older select (1, 2, 3, 5, Unlimited), this task moves it. The export's third drawing, a select with "3 plays" (T 1714-1719), is not taken. A unit case in the group pane: a recording stored with 3 plays shows "3" as a fourth option, and choosing "Twice" saves 2. -
AudioPolicyPanelshows "Students can pause" under "Plays", on by default, for a question's audio and for a group's recording; it savesallowPause. A policy read without the field shows the switch on. - Both parsers carry the field instead of stripping it:
audioPolicySchema(questionSchema.ts:14-18) and the recovery draft'spolicy(recovery.ts:15-19) arez.object, which drops a key it does not name, so each gainsallowPauseas an optional boolean.QuestionValuesAreAcceptedByTheContractstill holds. - A recovery draft stored before this task has no
allowPause. When it is restored, a policy without the field takes the value the server's bundle holds for the same recording or member question (GroupRecoveryGatehasstored), and true where the server has none, so the switch shows the truth and an old draft cannot switch pausing back on. If that merge were ever skipped, T-D4.13's rule (an absent field keeps the stored value) still holds the line.recovery-compat.test.ts, in the manner oftake-test/draft-compat.test.ts, reads a draft written without the field (a literal in the test), restores it over a group whose recording hasallowPause: false, and asserts the restored and the saved bundle both read false. - A test saves it off for a question and for a recording and reads it back.
- The media block and the group pane's recording are compared with the deck again at 360 and 1280, light and dark.
As built: one pull request, two commits (the switch and the parsers, then this note).
"Plays" was already one control: T-D4.14 left the group pane's recording and the Shared content
dialog on AudioPolicyPanel, so nothing moved; recording-plays.test.tsx pins it in the builder's
group pane (a stored 3 shows "3 plays" as a fourth option; Twice saves 2, and the fourth goes). The
panel draws "Students can pause" (questionEditor.questionMedia.allowPause) between "Plays" and
"Allow skipping ahead", checked when the policy has no allowPause, and its change writes the
field. DEFAULT_AUDIO_POLICY gains allowPause: true, so a new question's audio, a new recording
in the pane and a new recording in the dialog send the drawn default rather than leaving the
server to fill it; shared-content-dialog.test.tsx's stored recording takes the field for that
reason. The panel's three switches are the primitive's sm (36 by 20, 16px knob): the deck draws
the pause switch 34 by 20 and the bank editor's two at 36 by 20, and the primitive has no 34.
audioPolicySchema and the recovery draft's policy take allowPause: z.boolean().exactOptional():
.optional() would infer boolean | undefined, which exactOptionalPropertyTypes does not let
QuestionValuesAreAcceptedByTheContract assign to the contract's allowPause?: boolean. The
builder's question pane and the group editor save their values unparsed and already carried the
field; the bank's Question editor saves questionSchema's output, which stripped it. A recovered
draft is merged in GroupRecoveryGate, where stored is: withStoredPause (recovery.ts) fills
each recording's and member question's missing allowPause from the stored bundle by id, and true
where the server has none, before the editor mounts, so the switch, the next device write and the
next save all carry the stored rule, and a conflicting draft copied to the bank through
independentBundle keeps it too. It runs when the children render rather than in the gate's effect,
whose dependencies are the owner and the id; useGroupEditor reads recovered only on mount.
useGroupEditor and the draft's version are not changed. Tests: recovery-compat.test.ts (new, 4: a
literal draft without the field is read, restores false for the recording and the member question
and true for a recording the server lacks, saves both false through useGroupEditor, and a draft
that has the field keeps it); recording-plays.test.tsx (new, 4, through BuilderGroupPane: the
fourth option, the switch's place and default, pausing saved off and read back off, and a pre-switch
draft restored off over a stored false); editor-page.test.tsx saves it off for a bank question and
reads it back; editor.test.tsx and shared-content-dialog.test.tsx see the switch on by default.
Compared with the deck (React, Babel and lucide from their npm tarballs) at 360 and 1280, light and
dark, in the question media block, the group pane's recording and the Shared content dialog: the
Plays group (185 by 30, radius 7, --muted), its options (26px, 12px 500), the label (12.5px 400,
8px gap), the switch's place (18px after the group at 1280, wrapping under it at 360), its track
(--primary on, --border off, both themes) and its 16px white knob equal the deck's; no
horizontal scroll; the focus ring is 2px --focus offset 2px. Known differences: the switch is
36px wide against 34 (the primitive's size); the row holds "Allow skipping ahead" and "Show
transcript after submitting" (DG-111), so it is 81px tall at 1280 and 141 at 360 against the deck's
51 and 81; and the dialog shows the same row instead of the export's select and its 500-weight
label (Q25).
Reopens: T-R4.31b (the builder's Student preview), T-R4.30 (Test detail's preview), T-R4.39 (the import's preview)
Depends on: T-D4.1
Touches: web/src/features/tests/components/{DraftPreviewDialog,StudentPreviewPane}.tsx, web/src/features/tests/pages/teacher/TestDetailPage.tsx, web/src/features/tests/components/StudentPreview.tsx (the compare outline and chips T-R4.30 added go), web/src/features/imports/pages/teacher/ImportConfirmPage.tsx, locales, web/tests/units/builder/preview-and-leave.test.tsx, web/tests/units/tests/, web/tests/units/imports/
Size: M
Done when:
- The builder's Preview opens a full-window overlay in place of R4's 720px dialog
(T 5737-5761; the script's
builderPreviewVals, T 6896-6901):role="dialog"named "Student preview",--overlay, padding 14px, gap 12px. Its bar is a card (radius 12px,--shadow-lg, padding 8px 10px 8px 14px) with the eye icon (16px), "Student preview · {title}" (14px 600, ellipsis), the Segmented "Computer | Phone" (28px buttons, 12.5px, the iconsmonitorandsmartphone) and a 34px "Close preview". Under the bar, a frame as tall as the space left:min(1320px, 100%)wide with a 12px radius for Computer, 390px wide with a 28px radius for Phone. - There is no Previous / Next. Pending edits are flushed before the overlay opens, as before R4's dialog. Esc closes it, focus is trapped and returns to Preview: the export draws none of the three (DG-36).
- Test detail (T 5846-5853, under
td.hasPreview): the preview is a frame on--sidebarwith 16px of padding. Computer is full width, 620px high, radius 12px; Phone is 390px wide, 720px high, radius 24px. "Changes from version {prev}" keeps its Added / Changed / Answer / Points rows above the frame, and nothing inside the frame is outlined or carries a "New" or "Changed" chip (Q23). The line "Showing {k} of {n} questions…" goes. The not-published state and?version=&compare=1&device=phone&history=1are R4's. - The import's preview (T 4975-4980, the frame sized by
wi.pvWandwi.pvH) is Test detail's frame, with the same two sizes. It holds the whole review draft through R4's adapter, never a fixture, with answers and notes left out. "Showing {k} of {n} questions…" goes (Q24). - One frame component serves the three hosts (
StudentPreviewPane); a host passes its sizes. In all three the frame holds R4'sStudentPreview, scrolling inside it, until T-D4.16 puts the engine there (DG-188). - Strings: "Close preview" and the bar's title are new, vi first. The keys of the retired dialog's sub line, Previous / Next, the two chips and the count line are removed in their own commit. The PR lists each test that pinned a retired frame beside the case that replaces it.
- Unit tests.
preview-and-leave.test.tsx: an edit still pending is saved before the overlay opens (today's one preview case does not assert it); Esc closes the overlay and focus returns to Preview; "Computer | Phone" changes the frame's size. Undertests/units/tests/: with compare on, the rows stand above the frame and nothing inside it carries an outline or a chip; the frame's two sizes. Undertests/units/imports/: the frame holds the whole review draft, with no count line, no answer and no note. - Compared with the deck at 1280 and 1440 (Computer) and with Phone selected, and at 360, 768 and 1024 for the bar and the overlay, light and dark. Known difference, under DG-188: the frame's content.
As built: one pull request, six commits (the bar and the frame; the retired strings; the
scroller's positioning; the overlay's labels; the frame's width for the engine; this note).
StudentPreviewPane is now the frame alone: a bordered box on --bg with --shadow, centred
and max-w-full, sized by the classes a host passes (previewFrame.ts: PANEL_FRAME 100% ×
620px r12 or 390 × 720px r24 for Test detail and "Preview and create", WINDOW_FRAME h-full
min(1320px,100%) r12 or 390px r28 for the builder), holding a scroller (relative,
overflow-y-auto, padding 22px) that is a role="region" named "Màn hình của học viên" /
"The student's screen" (preview.frame) with tabIndex=0, so a paper with nothing focusable
still scrolls by keyboard. The frame measures itself with a ResizeObserver and provides
EngineWidthContext (T-D4.3's seam): without it the 390px frame on a wide window drew the
options four to a row, one word wide; T-D4.16's engine reads the same value. The R4 pane with
its own "Desktop | Phone" toggle, which the question group's preview dialog and (until v0.11.9) Assignment detail
still use, is kept unchanged as InlineStudentPreview; neither host is in this task.
DraftPreviewDialog is the overlay on Radix's Dialog: --overlay behind, a content box
fixed inset-0, padding 14px, gap 12px, aria-label "Xem như học viên" (builder.previewAsStudent,
the export's name; the bar's text is the Radix title but not the name), the bar (card, radius 12,
--shadow-lg, padding 8 10 8 14, gap 10) with the 16px eye, "Xem như học viên · {title}"
(builder.previewTitle, 14px 600, truncated), the Segmented sm with Test detail's "Máy tính |
Điện thoại" keys and a 34px "Đóng bản xem trước" (builder.previewClose). It sits at
--z-dialog (85), not the deck's 95, so toasts (90) and menus (100) stay above it as everywhere
else; the deck has no layer scale. Esc closes it, Radix traps focus, and on close focus returns to
the control that opened it (Preview), or to <main> if that control has gone, as Sheet does. The
builder still flushes the open question and the outline before it opens (openPreview, unchanged);
startAt and the Previous / Next paging went with the dialog. Test detail's frame sits on
--sidebar with 16px of padding under "Changes from version {n}", whose rows are R4's; the
compare outline, the chips (PreviewMark, previewMarks) and the count lines are removed, and
so is the import adapter's included count, which only the count line read. Tests that pinned a
retired frame: bank-picker-and-preview.test.tsx "steps through the questions with Previous and
Next" (removed; replaced by preview-and-leave.test.tsx "covers the window with a bar and a
frame, and has no Previous or Next"); detail-versions.test.tsx "lists the changes and marks the
questions they touch" (now "lists the changes above the frame and marks nothing inside it");
detail.test.tsx "renders the default version" (the count line is now asserted absent) and the
phone case (now asserts both sizes); confirm.test.tsx "shows every included question … and
switches to the phone frame" and "says how many it shows…" (now "holds the whole review draft …
no count line, no answer and no note", "draws Test detail's two frames" and "draws the questions
the paper can draw, with no count of the others"); preview-adapter.test.ts's two included
assertions. New: preview-and-leave.test.tsx (a pending title edit is sent and answered before
the overlay opens; Esc and "Đóng bản xem trước" close it and focus returns to Preview; Tab stays
inside; "Máy tính | Điện thoại" changes the frame's classes) and tests/units/tests/preview-frame.test.tsx
(the sizes, the region, the measured width). document-scroll.spec.ts caught a frame scroller
without relative (the paper's sr-only labels lengthened it by 599px), fixed in its own commit.
Compared in Chromium with the deck (React, Babel and lucide from their npm tarballs through route
interception) at 360, 768, 1024, 1280 and 1440, light and dark, Computer and Phone, 60 cells: the
overlay, the bar, the eye, the title's type, the segment track and buttons (28px, 12.5px 500),
the close button and the frame equal the deck's in position, size, radius, padding, colour,
border and shadow within 0.5px, except that each segment button is 1px narrower (the sm
Segmented's 13px icon, against the deck's 14px, as on Test detail since R4). On Test detail and
"Preview and create" the wrapper's padding and colour and the frame's width, height, radius,
border, background and shadow equal the deck's in every cell; the frame starts lower or higher
only by what stands above the section (the deck's fixture has a draft banner; DG-122's 30px h1 on
"Preview and create"). No cell scrolls sideways. In Vietnamese at 360 the bar's title keeps 24px
and ellipsizes, as the deck's English one does at 37px. Known difference, under DG-188: the
frame's content is R4's read-only paper until T-D4.16.
Depends on: T-D4.2c, T-D4.3, T-D4.4, T-D4.5, T-D4.7, T-D4.8 (and T-D4.10 if it is built)
Touches: web/src/features/take-test/components/{EnginePreview.tsx (new),EngineHeader.tsx,SaveState.tsx}, web/src/features/take-test/index.ts (new), web/src/features/tests/components/{DraftPreviewDialog,StudentPreviewPane,StudentPreview (removed)}.tsx, web/src/features/tests/pages/teacher/TestDetailPage.tsx, web/src/features/imports/pages/teacher/ImportConfirmPage.tsx, web/src/features/question-groups/preview.ts, locales, web/tests/units/builder/{group-preview,preview-and-leave}.test.tsx, web/tests/units/take-test/preview.test.tsx (new), web/tests/integration/router-chunks.test.ts (run, not edited)
Size: L
Frozen: the live engine's wiring in TakeTestPage (store, autosave, drafts, integrity,
fullscreen, play counting); preview.ts's stripping of keys and transcripts.
Done when:
-
EnginePreviewmountsPaperfrom a paper source held in component state and a width source that measures its frame (T-D4.3's two seams). It imports no store, starts no autosave, writes no draft and no open-question key, mounts no integrity monitor, asks for no fullscreen and counts no play; a unit test spies on each and finds no call. - Its header has no leave button and no Submit, and shows the chip "Student view" (30px,
radius 999,
--info-soft/--info-ink, 12.5px 600, a 14px eye). Finish raises the info toast "This is a preview. Students submit here." - No timer runs and no time is shown, because none of the three hosts has one: a test has
no time limit,
durationMinutesis the assignment's (api/openapi.yaml3175, 3195;Assignment2640-2645), andpreviewTestreturnsversion,questions,sectionsandgroupsonly (4391-4398), as the builder draft and the review draft do. In the timer's place stands a pill of the same geometry (36px, radius 999, the timer icon) reading "––:––", not atimer, named "The time limit is set when the test is assigned" ("Thời gian làm bài được đặt khi giao bài"), so the header keeps its width in the 390px frame.Clockis not mounted. Listed under DG-188 beside the deck's 45:00. - The save line does not claim a save. Where the engine says "All answers saved" the preview says "Answers here are not saved." ("Câu trả lời ở đây không được lưu."), with the eye icon, and no "Saving…" follows a pick; the deck's preview keeps the live save line (S 961-963, 1617), which DG-188 lists as a slip.
- The integrity policy is the assignment's too (
IntegrityPolicy, 2615-2637), so the preview has none: pasting into an answer is allowed, nothing is counted, no strike line and no fullscreen bar are drawn. - Answers can be chosen and are kept only while the preview is open (Q11).
- The three hosts mount it in the frames T-D4.2c built: the builder
from the draft through
preview.ts, Test detail frompreviewTestfor the selected version, the import from the review draft's adapter.StudentPreview.tsxis removed when nothing imports it. - At 390px the frame shows the phone engine (the drawer, the grid button) on a desktop window: the width comes from the frame, never from the viewport.
- The engine enters the teacher tree as a lazy chunk;
router-chunks.test.tspasses untouched. - The three previews are compared with the deck at 1280 and 1440 (Computer) and at 390 (Phone), light and dark. Known differences, all under DG-188: the placeholder where the deck runs a 45:00 timer; the save line's sentence; content is the teacher's own, not a fixture.
As built: one pull request (the moves; the frame-aware thresholds and sheet; the preview; the
group preview spec's focus check; this note; then E2E 1 read through the engine, the store cut out
of the preview's imports, and the engine's keys checking for a dialog only for a key they take). Paper moved from TakeTestPage.tsx to components/Paper.tsx,
useLivePaperSource from paper.ts to livePaper.ts, dotsOf to answered.ts, and the
engine's keys to shortcuts.ts (paperKey, which TakeTestPage acts on as before), with no change,
and remainingMs from store.ts to timeLeft.ts (the store re-exports it), so
EnginePreview (take-test/components/EnginePreview.tsx, exported by take-test/index.ts)
mounts Paper with no store, livePaper or integrity monitor in its imports:
preview-imports.test.ts makes each of the three throw when loaded and imports the preview. The
one integrity module left in its graph is integrity/fullscreen.ts, through FullscreenBar,
which asks for fullscreen only on a click and is not drawn, since the preview passes
fullscreenBar={false}. It holds
the paper's answers, flags and open question in component state, with every audio callback a
no-op, and Paper's preview mode draws EngineHeader's preview (no leave button, no Submit,
the chip, NoTimeLimit in the timer's place, UnsavedLine as the save line, no
SaveAnnouncement), its panes as a <section> rather than a second <main>, and Finish as
notify.info with the eye. A preview paper takes the focus only once the teacher moves (on Test
detail it would otherwise take it from the page as it loads), and the engine's keys act on
keydown inside it, the open-dialog check scoped to it. preview.test.tsx subscribes to both
stores and spies on saveAnswers, submitAttempt, getAttempt, both play records,
writeDraft, writeOpenQuestion, Storage.setItem, fetch, useIntegrityMonitor,
recordAudioEvent, recordSharedAudioEvent, enterFullscreen, exitFullscreen and
requestFullscreen while it answers, flags, plays both recordings, types, pastes (not prevented)
and finishes, and finds no call; a last case shows the subscription does catch a write.
The thresholds: the engine's lg: classes (the blank and the answer field, the sheet's squares,
the seek track of AudioPlayer) are now engine-lg:, a custom variant that is lg: except
under [data-engine-narrow], which StudentPreviewPane sets on its region below 1024px of
measured width; the same mark puts .student-surface's 44px floor on a narrow frame. So the 390px
frame draws a phone's sizes on a wide window, a computer frame narrower than 1024px draws what a
student's window that wide would, and under FocusLayout, where nothing carries the mark, the
generated rules are the old ones (:where, so no specificity is added; they sort after the base
utilities in index.css's output, as lg: did). The question sheet is a Radix dialog that
portals to the body; Navigator portals it into EngineLayerContext's element
(take-test/frame.ts), which EnginePreview provides, and DialogContent gained a container
prop; the preview root is [contain:layout], so the sheet and its backdrop sit in the frame. The
frame's region now holds the engine at full height with no padding and no tabIndex; hosts draw
their loading, error and empty states in PreviewFrameMessage. The engine is loaded through
PaperPreview (tests/components/), a lazy of @/features/take-test, into a chunk the three
hosts share with TakeTestPage; router-chunks.test.ts passes untouched. Test detail keys the
preview by version, so another version starts at its first question. StudentPreview.tsx stays:
InlineStudentPreview (the group preview dialog, and Assignment detail until v0.11.9, outside this task)
imports it, and group-preview.test.tsx covers it unchanged. Tests moved: preview-and-leave
(the bar holds only the segment and Close; the frame is the engine's, with its Finish),
detail-versions (version 2 is read from its second square, as one question shows at a time),
confirm (the draft is stepped through with Next), preview-frame (the engine in the region; the
narrow mark at 388 and 1000px, none at 1250), navigator, renderers and panes (the renamed
classes), group-preview.spec.ts (the engine in the frame, the drawer at the phone's width, the
version switch) and document-scroll.spec.ts (the first question's prompt). Compared in Chromium
with the deck (its React, Babel and lucide from their npm tarballs, the app's Be Vietnam Pro for its
Google Fonts link) on all three hosts at 1280 and 1440, Computer and Phone, and the overlay at 360,
768 and 1024 in both, light and dark, 36 cells, measured relative to the frame: the frame, the
header (60px, its border), the title, the pill (36px, its width, radius, --muted), the chip
(30px, 121.5px in English, radius, --info-soft / --info-ink, 12.5px 600, 10px padding), Next
and the grid button equal the deck's within 0.5px. The pill sits 6px to the right where the title
column shows, because "Answers here are not saved." is longer than "All answers saved" (18.5px on
the import, whose title is the draft's); on Test detail at 1280 and the overlay at 1024 the rail
shows where the deck's grid button does, because the deck's first question is beside a passage
and ours is not. No cell scrolls sideways. In Vietnamese at 360 the chip is 150px and the header
still fits the frame. Known differences, under DG-188: the placeholder, the save line, the
content.
Four tasks, all web, none touching a high-risk folder except T-D4.20's edit of the builder's outline
geometry (the builder chain's rule applies to it). Each reopens the R4 task named, compares the screen
with the v2 deck again at the five widths in two themes, and adds one closing line to that task in
74-r4.md through T-D4.12.
Reopens: T-R4.4 (the sidebar), T-R4.31a (the outline), T-R4.27a, T-R4.30, T-R4.32, T-R4.33, T-R4.38, T-R4.42, T-R4.43 (their side columns)
Depends on: T-D4.1; runs first on the builder chain, before T-D4.2a
Touches: web/src/layouts/shell/Sidebar.tsx, web/src/components/shared/StickyAside.tsx (new), web/src/features/tests/pages/teacher/{TestBuilderPage,TestDetailPage}.tsx, web/src/features/assignments/{pages/teacher/AssignmentWizardPage.tsx,components/wizard/WizardAside.tsx}, web/src/features/question-bank/pages/teacher/{QuestionBankPage,QuestionEditorPage}.tsx, web/src/features/imports/pages/teacher/ImportReviewPage.tsx, web/src/features/classes/pages/teacher/ClassDetailPage.tsx, web/src/features/settings/pages/teacher/TeacherSettingsPage.tsx, web/tests/units/shell/sidebar.test.tsx, web/tests/units/shared/sticky-aside.test.tsx (new), web/tests/units/builder/ (the outline's geometry case)
Size: S
Done when:
-
Sidebardraws a 1px--borderrole="separator"(margin 0 10px 4px) between nav groups while their labels are hidden (collapsed), in both consoles (V-01, A-01); the drawer and the expanded sidebar are unchanged. - One
StickyAsidewraps every side column:position: sticky; top: 16px; max-height: calc(100vh − 88px); overflow-y: auto; padding 1px 2px 2px; margin −1px −2px −2px(the deck's allowance for focus rings), from a content width the host passes (the wizard keeps its@min-[794px]/wizardcontainer query, whichStickyAsidetakes over fromWizardAside'ssticky top-0; 1000 for Class detail and the review, 900 for the question editor's aside, 768 for the settings nav), static below it. Every host reads its own container's width, never the viewport's: the pages render in one frame up to 1600px (DG-205), so the deck's per-screen widths no longer decide where a column docks. The import review is full-bleed (width: "full"), so its 1000 is its container too; the version history keeps its owntop: 16pxand cap; the bank's filter aside getstop: 16pxfrom 1024 and staystop: 0below (V-02). - The builder's outline is sticky and scrolls inside itself whenever the builder splits
(
outPos,outMaxH,outOv, T 1633 and 8690); a section's "…" menu still escapes it (V-03): one unit case opens the menu on the last section of a long outline and finds it unclipped. - No string changes; no behaviour changes (a behavioural change never rides in a refactor).
- Compared with the deck at 1024, 1280 and 1440 with a long page scrolled, light and dark, on the builder, the wizard, Test detail, the bank, the editor, the review, Class detail and Settings; the collapsed sidebar at 1024 in both consoles.
As built: #564 (two commits: the separator, then the columns). Sidebar draws the separator
(bg-border mx-2.5 mb-1 h-px, 23 × 1 px inside the 60px rail) before each labelled group while
collapsed, and nothing expanded or in the drawer; the Admin console is R5's and takes the same
component. StickyAside (components/shared/StickyAside.tsx) is an aside (or the as it is
given) that always carries the deck's allowance (-mx-0.5 -mt-px -mb-0.5 px-0.5 pt-px pb-0.5) and
sticks with docked, or behind a host's container query on the sticky-column utility
(index.css: position: sticky; top: 16px; max-height: calc(100vh - 112px); overflow-y: auto). The cap is 112px, not the deck's 88: our <main> adds 24px of top padding above the 16px offset, so a stuck column starts 40px under the top bar, and the deck's cap would end it 8px below the window. 112 ends it 16px above, as the deck's geometry intends (TL, review of #564).
The hosts: the wizard from @min-[794px]/wizard (basis 304px and minimum 244px, so the card stays
300 and 240 inside the allowance); the question editor's settings, now a card inside the aside,
from @min-[900px]/qe; Class detail's column, a div, with docked={two} (1000px of content, as
before); the settings nav from @min-[768px]/settings, a container SettingsLayout's root now
declares (its row basis and cap grow by 4px and its column minimum is 204px, so the links keep
their width); and the outline with docked={split}, in a first pane that now stretches to the
row, since a sticky box cannot move inside a pane as tall as itself. The review's aside is
Preview and create's (ImportConfirmPage.tsx, from @min-[1000px]/confirm): the deck's
wiSide (T 1886) is that screen's summary, and the review workspace (ImportReviewPage) has no
side column, so it is untouched. The version history keeps its own card, now sticky-column, and
the bank's filters are top-4 (they render only from 1024; below it they are a sheet).
AssignmentWizardPage and TeacherSettingsPage did not change. Every threshold is below the
frame's 1600px, so <main>'s content width and each host's container agree, and the deck's
tw numbers keep their meaning inside the frame; two do not quite: the settings nav sticks from
768px of content although it docks from about 704 (it is static, not overlapping, in between),
where the deck sticks it from a 768px window; and top: 16px is measured from <main>'s content
edge, under its 24px top padding, so a stuck column starts 40px below the top bar and a capped one
ends 8px below the window, as the deck's own numbers place it. Tests:
tests/units/shell/sidebar.test.tsx (new, 4), tests/units/shared/sticky-aside.test.tsx (new, 11),
tests/units/builder/deck-frame.test.tsx (the outline sticky while split and static stacked; the
last of 24 sections' menu opens outside the scrolling column), and
tests/units/shared/settings-layout.test.tsx (the nav's new classes and its container query).
The comparison was measured on a live stack with getBoundingClientRect at 1024, 1280 and 1440,
900 and 640 high, light and dark, sidebar expanded and collapsed, with a 25-section draft and a
mocked 30-question Preview and create; the deck itself could not render in the container
(unpkg.com, which serves its React and Babel, answers 403 through the proxy), so the app was
compared with the deck's markup and layoutVals numbers, not side by side.
Reopens: T-R4.28
Depends on: T-D4.1
Touches: web/src/features/attempts/pages/teacher/{GradingPage,GradingQueueAside,GradingAnswerCard}.tsx, web/tests/units/attempts/grading-layout.test.tsx (new), web/tests/e2e/teacher-grading.spec.ts
Size: S
Built on develop, not on this branch: #555 (merged into develop as 81eb1f98, 2026-10-12)
built it at the owner's request after a teacher escalation, so it could ship in v0.10.1. The task
has no AI dependency; the AI "Suggest a score" stays in T-D4.36. It left this branch's work lane
when the develop sync of 2026-10-12 brought it in.
Done when:
- From 768 the page is
calc(100vh − 120px)high with a 560px minimum; the queue aside is a column whose list scrolls (flex: 1 1 auto; min-height: 0), and the card's body scrolls inside it under the 3px progress bar (V-04, T 847-875). Below 768 everything is static and the queue keeps its 180px cap. -
j/kkeep the active queue row in view inside the scrolling list; the card's score buttons and the footer stay reachable without scrolling the page. - Unit cases at
viewport("desktop")andviewport("phone"); compared with the deck at 768, 1024 and 1280 with a long queue and a long answer, light and dark.
As built: #555 touched GradingPage.tsx, GradingQueueAside.tsx and GradingAnswerCard.tsx.
From 768 the header and the panes share one block of calc(100vh - 120px) with a 560px minimum,
and the panes no longer wrap; the queue aside is a flex column whose list takes the rest
(flex: 1 1 auto; min-height: 0) and the card's body scrolls inside the card, under the 3px
progress bar, with the footer (Previous, "Save & next", Finish) in view. Below 768 the page is
static and the queue keeps its 180px cap. Finish recovery is not drawn, so it stays below the
panes, outside the viewport-high block. j and k call scrollIntoView({ block: "nearest" })
on the active row. grading-layout.test.tsx holds five cases (the queue column and its list from
768, the 180px cap on a phone, the active row kept in view, the card body scrolling under a fixed
footer, and the page's block holding the header and the panes without Finish recovery);
teacher-grading.spec.ts adds cases at 768, 1024 and 1280 in light and dark with 24 students and
a 40-line answer in an 800px-high window, and one at 360. The "compared with the deck" box is met
by those measured cases, not by a recorded side-by-side with the deck. The page frame (DG-205)
did not change this task: the grading page fills the frame like every other, and the numeric
score field keeps its --field-max cap.
Reopens: T-R4.28 (Finish recovery below the panes)
Depends on: T-D4.21, T-D4.36
Deck: page-width-proposal/Grading Finish States.dc.html (the design team's answer of
2026-10-13; its notes 1 to 9, the "Which state shows" table, "Other cases" and "Every English
line" are the specification)
Touches: web/src/features/attempts/pages/teacher/GradingPage.tsx,
web/src/features/attempts/{gradingMessages.ts,useGradingQueue.ts,gradingRecovery.ts},
web/src/components/shared/ListState.tsx, locales, web/tests/units/attempts/,
web/tests/e2e/teacher-grading.spec.ts
Size: M
No contract change: every figure in a row's check comes from getAttemptForReview, which
the recovery panel already reads; finishGrading is unchanged. Finish stays explicit and nothing
finishes on its own.
Done when:
- The header reads "No answers left." on an empty queue; while answers wait it adds the "{n} papers ready to finish" link, which unfolds the card and moves focus to it. The DG-136 chip stays last and keeps its focus rule.
- The empty state's variant follows the table: running check, failed check (Try again), papers
to finish (variant b, "Review {n} papers" opens the oldest paper's check and focuses its
Finish), and "All graded" (variant a) only after a complete check, naming the filter with
"Show all assignments" / "Show all students" when the queue is narrowed.
EmptyStategains optional icon and tone props (the 44px tile, 16/600 title); existing callers do not change. - The card: "Ready to finish" with its count and sentence; Refresh (replacing Rescan) beside "Checked {time}" from the query's last update, "Checking…" while it runs, a 44px icon button at 360; folded to one line while answers wait; the 180px scroll box goes.
- Rows grouped by assignment, oldest hand-in first for groups and rows, five per group then
"Show {n} more" / "Show fewer"; a single group has no header; two groups sharing a title add
the earliest hand-in date. A row shows "Handed in …", "Time ran out" and "Flagged" from the
list row, and one Review / Close button with
aria-expanded; one check open at a time. - The check (today's candidate panel, moved into the row): score, "{a} of {t} answered", how
it was marked (you marked n / automatically / left blank), the flag and blank lines; Finish
disabled unless
pendingManual === 0, with the "now waiting to be marked" line otherwise. - After Finish: the row leaves, a toast, the polite live region "… Next: {name}.", the next row's check opens with focus on its Finish (its group unfolds if needed); after the last, focus goes to the empty state's title.
- Finish failures: inside the check above its buttons; the answer card's alert names the
student. Strings as the page lists them,
grading.rescanretired, vi written first. - Unit cases for each table row, grouping and Show more, the check's three kinds, the focus moves and the failures; E2E at 360, 768 and 1280, light and dark, compared with the page.
As built: No contract or server change. useGradingQueue groups the scan's rows
(groupReadyPapers in gradingRecovery.ts: by assignmentId, the oldest submittedAt first for
groups and rows, a row without one last) and exposes them as readyGroups/readyPapers; the
candidate is the row whose check is open (openCheck, no lock and no flush now that the panes stay
up), and paperCheck reads the check from the review: the score, the questions with a non-null
answer against all questions, "Left blank" when none, "You marked n answers" when n answers
requiresManual, otherwise "Marked automatically", and the flag from attempt.integrity.
finishCandidate rereads the paper; a changed count or status is written into the check's query,
so the check shows the waiting line with Finish disabled and nothing is sent. On success the row
leaves, a toast and the polite live region speak, and the hook returns the next paper in order
(wrapping round, nextOpenItem); the page opens its check, unfolds its group if it is past the
fifth row, and focuses its Finish once the read matches (the Full paper review link when Finish is
disabled, the check itself when the read failed). After the last paper focus goes to the empty
state's title, or to the page heading while the panes show. The card is
pages/teacher/GradingReadyCard.tsx; the empty-state variant and the header link are in
GradingPage.tsx. EmptyState gains icon, tone (neutral, muted, success, warning) and
titleRef; without icon it renders as before. The scan's states: running while the first read
or a retry after an error is out, failed after an error, done otherwise; a Refresh with rows keeps
them and reads "Checking…". While answers wait the card is drawn for papers to finish or a failed
check, and not while the check runs (note 8 and the table; the page's script also draws it
running). The answer card's Finish failure names the student (FinishState's item gains
studentName and assignmentTitle) and drops the server's message line, which the deck does not
draw; a failure from a check is a line inside it, and Finish grading is the retry. The keyboard
shortcuts ignore keys inside the card, in place of being off while a check was open. Departures,
each for the data or a decided rule: "Flagged" and the check's flag line use the danger tones of
features/integrity/tones.ts (spec §10.4, D8: one tone for "Flagged"; DG-208), not the drawing's warning
tones; a paper the teacher flagged with no focus loss reads checkFlagged_zero ("Flagged. Check
the full paper before you finish."); "All graded" names the filter only when the queue, the scan or
a paper finished in this visit holds its name, and otherwise reads the unfiltered sentence;
common.retry keeps today's "Retry" / "Thử lại"; a row's date is the app's dayDate and
formatTime. Controls are 44px below 768, 36px (40px for Finish, Full paper review, Show more
and the empty state's action) from 768 and 32px (36px) from 1024, the drawing's 768 and 1280
frames. Tests: grading-finish.test.tsx (new, 20 cases: each table row, grouping and Show more,
the shared-title date, the row's pills and one open check, the three kinds, the flag, the waiting
line, the reread refusal, the failure and its retry, the focus after Finish and after the last,
the filtered "All graded" and Show all, the folded card and its link, Refresh);
grading-recovery.test.ts adds the grouping and the check's reading; the integration suite and
grading-messages.test.ts follow the rewritten strings; teacher-grading.spec.ts adds twelve
cases (360, 768 and 1280, light and dark: the empty queue's list, Review, the focused Finish, the
toast and live region, the next check, the heights, no sideways scroll; and the card folded below
the panes with its link). Compared in Chromium with the page (React, Babel and lucide from their
npm tarballs by route interception, the deck's demo data served as the API) at 360, 768, 1024,
1280 and 1440, light and dark: every measured box within 0.6px, the card within 1.3px of the
frame, and the same computed colours, except the Flagged tones above, the row's weekday (the
drawing's demo dates name the wrong weekday) and the avatar's initials (the app's Avatar).
Reopens: T-R4.25 (the four stat tiles)
Depends on: T-D4.1
Touches: web/src/features/assignments/pages/teacher/AssignmentDetailPage.tsx, web/src/features/attempts/components/{Monitor.tsx,ProgressCard.tsx (new)}, locales, web/tests/units/assignments/detail.test.tsx, web/tests/units/attempts/monitor.test.tsx
Size: M
Done when:
- The four
StatStriptiles give way to the card (V-05, T 607-631): "{n} of {total} submitted" ("All {n} submitted"; "Not assigned yet" with no target), an 8px two-segment bar (submitted--success, in progress--info), a legend ofaria-pressedbuttons (28px, radius 7, 12.5px) each with a dot or, for Flagged, the flag icon in danger ink, and a count in--fg(Flagged's in danger ink), then "Average {avg}" (15px 600) after a 1px divider. The counts are the monitor's whole-assignment counts, not the page's. - A legend button filters the roster to its group (Submitted includes timed out, as the
monitor groups them), opens the Students tab, resets the page and names the filter in the
URL (
?roster=, which T-R4.25's redirect already writes); pressing it again shows everyone; an entry hides at 0 unless it is the one selected; opening the page from the list clears the filter. The tooltip reads "Show only {group}" / "Show everyone". - The average keeps T-R4.25's rule (the rounded mean of graded ratios; "—" with none).
- Strings:
assignmentDetail.progress.{submittedOf,allSubmitted,notAssigned,average, showOnly,showEveryone}, vi first; the four tile keys go in their own commit if nothing else reads them. - Unit tests: the three sentences, the bar's widths, a filter press and release, the hidden zero entries, the URL; compared with the deck at 360, 768, 1024, 1280 and 1440 on a live assignment with flagged papers, light and dark. Known difference: none expected.
As built: one pull request, three commits (the card and the filters, the removal of the
tiles' keys, this note). ProgressCard (features/attempts/components/ProgressCard.tsx) draws
the deck's card on --card (radius 12, 1px --border, --shadow, padding 12 × 16, gaps 10 × 16,
wrapping): the sentence at 13.5px 600 (assignmentDetail.progress.{submittedOf,allSubmitted, notAssigned}), the bar (flex: 1 1 160px, at least 120px, 8px, --muted, segments --success
and --info as shares of every row, transition: width .2s except under reduced motion and
hidden from a screen reader, which the sentence and the legend already tell), the legend as a
role="group" "Filter students" (progress.filterLabel) of aria-pressed buttons (28px, radius 7,
12.5px --muted-fg, 9px padding, a transparent border that turns --border on --muted when
pressed, an 8px dot or the 12px flag in --danger-ink, the count 600 in --fg or --danger-ink),
and "Average" (progress.average) with its value at 15px 600 after a 1px --border and 14px of
padding. Each button's name is "{group} {n}", so a screen reader hears the count; its tooltip is the
shared Tooltip (hover and keyboard focus), "Show only {group}" or "Show everyone"
(progress.showOnly, progress.showEveryone, the group lowercased). The legend's labels are
progress.groups.{submitted,inProgress,notStarted,flagged}; Flagged is "Bị gắn cờ", as on the
dashboard. While the monitor loads the card holds two skeleton bars (role="status"); when it
fails it says "Couldn't load progress." with Retry (progress.loadFailed), not as an alert, since
the Students tab's own error card is one. Counts: assignmentStats counts each group as
rosterMatches filters it, over the whole monitor whatever the search or filter, so a count is
always the rows its press shows: Submitted is submitted, timed out and graded; a flagged attempt
that was later voided is no longer counted as Flagged (T-R4.25's tile counted it, though its
filter never showed it), and assignment-detail-logic.test.ts says so. Filter: a press
writes ?tab=students&roster={group} and drops ?page= (replace, as the select does); a second
press removes roster. RosterFilter gains inProgress, and the roster's select keeps its five
filters (pinned by papers.test.tsx) and gains "In progress" (papers.tabs.inProgress), so the
select and the legend read and write one value and Needs grading stays reachable; the search box
stays too. The list's rows link to the page without a query, so opening an assignment from it
clears the filter. The three keys only the tiles read went in their own commit
(assignmentDetail.average, monitor.cards.inProgress, assignments.detail.flagged); the rest of
monitor.cards was already unread and is left alone. StatStrip has no other host now and is
kept, with its own tests. Tests: detail.test.tsx gains ten cases (the three sentences, the bar's
widths and the average, a press and release by mouse and keyboard with the URL, the whole-monitor
counts under a search, the hidden zero entries and a selected zero, both tooltips, the filter
dropped on opening from the list, and the skeleton and the retry); monitor.test.tsx one (the
select's In progress presses the legend's); teacher-shell.spec.ts replaces the stat strip's
640px case with the card's geometry at 360, 768, 1024, 1280 and 1440 in both themes, and a press
and release from the keyboard with its 2px focus outline and its URL. Compared with the deck
side by side with the deck's open assignment ("Mid-term Reading Mock": 18 of 24 submitted, 2 in progress, 4
not started, 1 flagged, 78%), the app on a production build with a stubbed monitor holding the same
figures (no live API), English, sidebar expanded, at the five widths, light and dark, idle and with
Flagged pressed: every box, font, weight, colour, border, radius, padding and gap equals the deck's
within 0.5px, and the card wraps to the same height at every width (164.75, 146.75, 84.25, 54, 54).
The two differences are not visible: the computed box-shadow carries Tailwind's transparent ring
layers before the same --shadow, and a button's text has a space between the label and the count
for its accessible name (a whitespace node in a flex box, so it renders nothing). The deck was
served with React 18.3.1, react-dom 18.3.1, @babel/standalone 7.29.0 and lucide-static 0.469.0
from their npm tarballs by route interception, as in docs/team/verification-r4-51.md. The
page frame (DG-205) did not change: the card adds no threshold.
Reopens: T-R4.42 (the join-code card)
Depends on: T-D4.1
Touches: web/src/features/classes/components/{JoinCodePanel,JoinQr,JoinQrDialog (new)}.tsx, web/src/features/classes/pages/teacher/ClassDetailPage.tsx (members' "Joined via"), locales, web/tests/units/classes/join-code.test.tsx
Size: S
Done when:
- The card (V-16, T 4287-4345): a 112px QR button (
--qr-bg/--qr-fg, radius 10, 1px border,cursor: zoom-in, named "Show full size for the projector", at opacity .4 while joining is paused) that opens a 420px dialog "Join {class}" with the QR at full size and "Show this on the projector. Students scan it or type the code." (or "Joining is paused, so this code won’t work until you turn it back on."); beside the button the full code (22px monospace, 600, letter-spacing .08em) with the join link under it (12.5px muted, ellipsis) and, while paused, a "Joining paused" chip (pause, warning tones) in place of the paragraph; under them a row of three outline buttons (32px,flex: 1 1 auto, ellipsis): Copy code, Copy link, Download QR; then Expires and Uses under a 1px divider; the footer "A new code makes the old code, link and QR stop working."; the New-code dialog's sentence "Share the code, the link or the QR with your students.". The switch, New code, Stop and the no-code state are T-R4.42's. - Members' "Joined via" shows the full code in monospace where T-R4.42 showed "Code ••{hint}" (the code is always shown in full to its teacher, D5, DG-01).
- DG-01 is marked Answered in
gaps.mdand its "Meanwhile" rule deleted (T-D4.1 opens the row; this task closes it). - Strings vi first; unit tests for the paused state, the dialog and the three buttons; compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark, with joining on and paused.
As built:
-
Files:
components/JoinCodePanel.tsx(the card),components/JoinQrDialog.tsx(new: the projector dialog and the New join code dialog onDialogShell, which traps focus and gives it back),components/JoinQr.tsx(the link without its scheme, the code at line height 1.5, as the deck's QR field draws them;ShowQrDialogon Classes reads it too),joinLink.ts(joinLinkLabel, anduseCopyJoinCodebesideuseCopyJoinLink), infeatures/classes/. The task namedjoin-code.test.tsx; the card's suite isjoin-code-panel.test.tsx, extended there. -
The card: the 112px QR button draws the real QR of the join link on
--qr-bg/--qr-fg, named "Show QR code full size" with the title "Show full size for the projector", as the deck has both; Download QR still rasterises the hiddenJoinQron the paper tokens. A code that cannot be read back, or a reader withoutteaching.classes.write, shows "••••-{hint}" with no QR and no share row (DG-180), and the chip while joining is paused. - New code now opens the New join code dialog with the code the rotation returned, in place of the toast that named it; Done gives focus back to New code. Classes keeps its toast (DG-178).
-
Not built: members' "Joined via" in full.
ClassMembercarriesjoinCodeHintonly, so the full code needs a contract field, and a code a member used may since have been revoked or rotated; that is a §6 decision for the Tech Lead, not a display change. The column still shows "Code ••{hint}" (DG-01's row says so). -
Keys: thirteen
classDetailkeys added, vi first;classDetail.qr,selfJoinOffHintandcodeShareHintremoved in their own commit. -
Measured against the deck served locally (React, Babel and lucide from their npm tarballs)
at 360, 768, 1024, 1280 and 1440, light and dark, joining on and paused, the sidebar expanded,
collapsed and as the drawer (36 cells): the card's size equals the deck's in every cell but
1440 collapsed, where the page's column is 1.3px wider; the QR button, code, chip, share row,
Expires and Uses and footer are within 0.5px and equal in every computed colour. New code is
5.5px wider, T-R4.42's 13.5px label against the deck's 13px. Both dialogs are the deck's width
and within 0.6px of its height, the shared
DialogShellHeader's difference. The link keeps the code's dash,joinLinkOf's one format (DG-01), where the deck drops it.
Part 3 of 5 of D4 — Deck update: the fourth export and v2 (v0.11.0) · Previous · Next
Generated from docs/plan/74d-d4-deck-update.md. Edit that file in the repository; changes made on this page are overwritten.