Repository navigation
Plan 74 R4 Part 1
Part 1 of 6 of R4 — Teacher workspace (v0.10.0) · Next
The release that moves the teacher from the old /admin web tree to the deck's Teacher workspace
at /teacher/*. Every screen that exists today is rebuilt to Quizzivy Teacher.dc.html, the
backend gains what those screens draw and the server lacks, in-app notifications arrive for the
teacher and the student, a test can be imported from pasted text, the rich content editor
becomes the way a prompt and an explanation are written, and every legacy hashed join code is
rotated (D5).
R4 runs after R3 (v0.9.0, released 2026-10-04). It changes no permission rule and no API prefix, beyond T-R4.49's contract steps (the legacy
role leaves the contract and the /admin alias goes).
It adds operations, each declaring an x-permission from 70-redesign-overview.md §4.1 and each
added to R2's isolation suite in the PR that adds it, with an x-resource entry for every uuid
it takes in its path, query or body (T-R2.16).
If v0.9.1 has not shipped, T-R4.49 carries its T-R3.1–T-R3.3 unchanged.
Modules the deck draws that ship later stay invisible here: Calendar and Attendance (R8), Messages and Shared with me (R7), Courses and Vocabulary (R10), Gradebook and Reports (R9), the Admin console (R5). No nav item, palette entry, menu item or button points at them (70 §2 rule 5).
Deliverable: the Teacher workspace from the deck, for every module that exists today, served
at /teacher/* inside the new TeacherLayout, with the backend it needs: profile and
preferences, signed-in devices, in-app notifications in both consoles, assignment review options
and per-student overrides, a grading queue, question level and skill, media rename and replace,
class schedule and room, dashboard figures, a version diff, imports from pasted text and alt
text for a question's image. The builder, the bank's Question editor and the import review
write prompts with one content editor, in the deck's frame. Every legacy join code is rotated
at release, with an in-app notice to its teacher.
Exit criteria: every /teacher/* route matches the deck at 360, 768, 1024, 1280 and 1440, in
light and dark, with the sidebar expanded, collapsed and as a drawer (T-R4.51); the old teacher
tree is deleted and router-chunks.test.ts passes with its regexes updated, not weakened; no
active join code in production uses the legacy lookup; the pasted-text count fixture passes in
the Go and the web suite (T-R4.56, T-R4.57); the five canaries are green and untouched;
every CI step of 70 §8 is green in GitHub CI on every pull request, and once locally, in order,
at T-R4.52; released as v0.10.0, and Neon compute hours are measured for the week after (PR-7).
Schema changes (numbers assigned at merge; none indexes a populated table, so none is
-- +goose NO TRANSACTION; every new column on a populated table is nullable or has a constant
default, so the R3 binary keeps inserting during the rolling deploy without a fill trigger). In
these migration notes and the tests they name, "R3" means the previous release, v0.9.0:
| File | Task |
|---|---|
00085_add_users_profile_fields.sql |
T-R4.7 |
00086_add_users_preferences.sql |
T-R4.7 |
00102_add_refresh_tokens_geo_label.sql |
T-R4.9 |
00080_create_notifications.sql |
T-R4.10a |
00081_create_notification_preferences.sql |
T-R4.10a |
NNNNN_add_assignment_review_options.sql |
T-R4.11 |
NNNNN_add_assignments_student_note.sql |
T-R4.11 |
00101_create_assignment_student_overrides.sql |
T-R4.12 |
00087_add_questions_level_skill.sql |
T-R4.15 |
00088_add_test_version_questions_level_skill.sql |
T-R4.15 |
00092_add_test_versions_change_note.sql |
T-R4.16a |
00082_add_media_assets_library_fields.sql |
T-R4.17a |
00083_add_media_assets_dimensions.sql |
T-R4.17a |
00084_widen_media_assets_audio_bytes.sql |
T-R4.17a |
00104_add_classes_schedule_room.sql |
T-R4.18 |
00089_add_word_import_draft_counts.sql |
T-R4.21 |
00090_allow_text_import_sources.sql |
T-R4.55 |
00091_add_word_import_sources_characters.sql |
T-R4.55 |
00097_add_questions_media_alt.sql |
T-R4.62a |
00098_add_test_version_questions_media_alt.sql |
T-R4.62a |
00105_drop_users_legacy_role.sql |
T-R4.49 (T-R3.1) |
00106_validate_ownership.sql |
T-R4.49 (T-R3.2) |
Order of work. The backend tasks (T-R4.7–T-R4.22, T-R4.53–T-R4.56 and T-R4.62) and the
composites (T-R4.1–T-R4.3) wait for no shell and no screen and start at once, at most four
builders at a time, of five agents. Among themselves each waits only for what its "Depends on"
line names; T-R4.53 lands before the tasks that register per-actor limits, T-R4.56 follows
T-R4.55, and T-R4.62 follows T-R4.16, whose diff it extends. The route rename (T-R4.5) needs
nothing and runs first; the shell (T-R4.4) follows T-R4.2a, T-R4.3a and T-R4.5b and reads its
counts from getDashboard until T-R4.23; T-R4.6 follows the shell and T-R4.13. The content editor
(T-R4.63, then T-R4.64) follows T-R4.2a and T-R4.5b and needs no backend; the answer areas
(T-R4.66) follow T-R4.64; the question media block (T-R4.65) follows T-R4.35, T-R4.62 and
T-R4.66. Each screen task waits for the shell and for the backend task it names: the builder's
editor pane (T-R4.31b) and the bank's Question editor (T-R4.33) wait for T-R4.65 and T-R4.66,
and the review workspace (T-R4.38) for T-R4.63; T-R4.57 follows T-R4.36 and fills the paste side
of its Source control. Close-out (T-R4.48–T-R4.52) comes last. Branches are
feature/t-r4-<nn>-<slug> off work/redesign-r4.
Decisions taken in this file, worth a second look.
-
R2's contract steps ship inside R4 by default (2026-10-08).
work/redesign-r4holds 00080 to 00091, so a v0.9.1 fromdevelopcannot be numbered safely. T-R3.1 to T-R3.3 run as T-R4.49, and the v0.9.1 release checklist runs inside this one (73-r3.md, "Migration numbers"). Thuong may still ship v0.9.1 separately, after renumbering R4's merged migrations. -
Time-based in-app notifications are materialised when the reader asks, not by a timer.
"Closes in 1 hour", "due tomorrow", "opened" and "result ready after close" are inserted
idempotently by
GET /me/summaryandGET /me/notifications, at most once per five minutes per user. An in-app item only matters while its reader is in the app, and a background timer would wake Neon at every threshold of every assignment (the #134/#143 lesson). R7 adds the email channel, and with it the first in-API notification scheduler. - Submission notifications are one row per assignment per 15-minute bucket, upserted at submit time. That is the deck's "Grouped every 15 minutes" without a flush job.
-
Legacy join codes are rotated by an idempotent job at API start-up, not by a manual
command. The API holds
JOIN_CODE_KEYon every start, while an operator holds it only for R2's rekey procedure, supplied tocmd/maintenancefrom the offline copy (operations.md); a human step at release is one that can be forgotten; the job takes an advisory lock, audits each rotation as System and does nothing on its second run. -
Extend is two operations. Extending the window for everyone changes the assignment
(
teaching.assignments.write); extending or reopening for chosen students is an accommodation (teaching.attempts.intervene, the "Reset, void or extend attempts" row). We keep one requirement per operation here by choice, so the dialog picks the operation. Both recompute the deadline of attempts already in progress, which today keep the deadline they started with. - The Grading page explicitly finishes an attempt after its last manual answer is saved (DG-64), using the existing separate Grade and Finish operations. A saved mark alone does not prove Finish or score release. Failed Finish remains retryable, including after reload through public attempt-list discovery and current review. Existing review/transcript policies govern visibility; the attempt page keeps its explicit button for re-grading.
-
Bulk actions reuse single-item operations through
BulkActions, which already confirms and reports partial failure. The exception is password reset: the single operation is rate-limited to five a minute per address (R2'sRateLimits()entry, per actor from T-R4.53), so a class-sized reset would 429. It gets one bulk operation, capped at 40 students, hashing at most two at a time inside the global argon2 semaphore. It stays in the API rather than the worker: forty hashes take about ten seconds and the screen waits for the printable sheet. -
The unpublished-changes banner comes from a diff, not from
updated_at. Publishing and "Make default" both bumptests.updated_atthrough its trigger, and a bank edit to a question the draft uses bumps nothing ontests. The diff that DG-66 needs anyway compares the draft with the latest version. -
Media replace creates a new immutable asset and re-points bank questions and draft-owned
groups only (DG-09), and only those owned by the asset's owner. Published versions, and other
owners' rows (bound through
scope.alltoday, copies from R7), keep the old asset, which leaves the library but keeps serving them.publish_snapshot_test.gois the proof. -
In the teacher workspace, content lists show the caller's own rows for everyone, admins
included (DG-53, the question R2 left to R4). Tests, bank, media, imports and assignments
list only what the caller owns;
scope.allstill reads and edits any teacher's content by id and powers the Admin console's cross-teacher views. Students and Classes keepscope.alllists until R5's Admin Classes and Users take them over (T-R4.54, 70 §4.1). -
Test cards show skills derived from their questions, not a
tests.skillscolumn. Chips that are computed cannot drift from the content; the deck's removable chips are prototype state. -
Nothing a teacher can do today disappears because the deck does not draw it. The test card
keeps a "…" menu (Duplicate, Archive, Delete); Classes keeps an Archived filter (the deck's own
archive dialog promises "you can restore it any time"); "View results" on a student opens
today's results sheet until R9's Gradebook; the API reference stays a Settings section for
system.api_referenceholders until R5 moves it to the Admin console. -
Display time zone is per user; the server stays UTC. The profile's time zone drives every
date the web formats and every date the teacher types. "Today" on the dashboard and the 23:59
expiry of a join code use the actor's zone, defaulting to
Asia/Ho_Chi_Minh. - A new join code expires at 23:59 local on its last day, as the deck draws ("24 Oct 2026, 23:59"), rather than at the minute it was made N days earlier. A code that dies mid-lesson is worse than one that lasts the day.
- Duplicate assignment has no Title field. Assignments are named by their test; there is no assignment title to copy. The dialog keeps "Assign to".
-
The workspace switcher ships as a static label. Its popover and the
workspace.admingate are built, but the "Admin console" row appears with the console in R5 (DG-53, rule 5). -
"Paste a test" is built here, as its own import source. The 2026-10-03 export draws it and
D1 builds what the deck draws, so it lands beside the Imports rebuild (T-R4.55–T-R4.57). The
text arrives through its own JSON operation, not a second body on
uploadImportSource: that route streams, so the contract validator would see neither the text nor its limit. - A pasted test is plain text, whatever the clipboard held. The box is a textarea and only its text is stored, so the deck's "formatting kept" pill variants and stage lines would be false and do not ship (DG-12). Recognition never takes an answer from a mark, so nothing a teacher needs is lost.
-
The server learns the conventions the paste page teaches, for text sources only, with one
exception. The rules of T-R4.56 are gated on the text evidence version, so Word and PDF
results do not move, except for the one-line answer key, which is read for every evidence
version (#233, decided 2026-10-04): the local corpus run may report keys that are now read,
and nothing else.
recognition.Versionbecomesrules-v3andPipelineVersionword-pipeline-v3; a run queued under v2 at the deploy failsPIPELINE_RETIREDand its teacher retries, as at the PDF release. -
The paste page counts with the server's grammar, not the deck's script. One fixture,
api/testdata/pasted-text-counts.json, is asserted by a Go recognition test and a web unit test, so "Found so far" cannot contradict the review. That is what lets Start processing stay disabled, as drawn, while the count finds no numbered question. -
A paste holds at most 100,000 Unicode code points, counted after NFC normalisation. The
page normalises before it counts and sends, so the contract's
maxLength, which counts the text as sent, agrees with the counter; intake normalises again and counts the same way. The deck counts UTF-16 units and its patterns miss decomposed Vietnamese. -
Status filters are the segmented control with counts; the Imports history changes layout by
its own width. The 2026-10-03 export replaced the filter pills and the 940px scrolling
table. The control keeps the product's semantics, a group of
aria-pressedbuttons and no tablist, because a filter has no tabpanel. The history turns from a grid into cards through a container query at 960px, not through the deck's viewport arithmetic. - The To review chips and the Ready card read "need action" for a pasted import too. The count covers every blocking finding, not only missing answers, so the deck's "needs an answer" wording does not ship in either place (DG-14).
The 2026-10-04 export redrew the builder's editor pane and the review's "Question" field. The
seven decisions below are this file's defaults for it. Thuong has not confirmed them; each has a
line in the open items, and all but the first an entry in docs/design/gaps.md.
-
The content editor is the field, for every teacher. The deck draws the rich editor as the
builder's "Prompt" and "Explanation" and as the review's "Question". Most of it is on
developalready (web/src/components/shared/content/editor/), behindVITE_RICH_QUESTION_EDITOR, which is off. R4 gives it the deck's frame and removes the flag (T-R4.63, T-R4.64). Options stay plain inputs, as drawn, soVITE_RICH_OPTION_EDITORstays off. - The editing mode is the form the field is stored in (DG-110). "Rich text" is stored content and "Markdown" is the stored Markdown string, as the contract has them today. A field opens in its own form, and a new field is rich text. The two conversions happen only on the teacher's "Switch to Markdown" or "Apply conversion". The prototype opens every question in Rich text and converts a field left in Markdown without asking; with autosave that would rewrite stored content nobody chose to change.
-
One
QuestionEditor, built to the builder's drawing (DG-108). The product renders the builder's editor pane and the bank's Question editor with one component, and the export redrew it in the builder only. The prompt, the explanation, the answer areas, the grading note and the question media are built once (T-R4.65, T-R4.66) and shown on both pages; each page keeps its own frame. "Match case" stays on a gap, because the model has it. -
A prompt and an explanation hold no media in R4 (DG-109). The contract's
QuestionContentexcludes media. Adding it runs through the publish snapshot, the student payload and the engine, three high-risk areas at once, and nothing draws the student side. R4 builds the toolbar without "Image" and "Audio" and refuses a pasted or dropped file; audio or an image is attached as question media. The backend and the two buttons go to R6. - The builder's type menu offers the five types the product stores. "Matching" and the "Include “Not given”" switch need R6's types (DG-60, DG-113), and a control without a backend stays off the screen, as the import's do (DG-67).
- Question media keeps the model's switches (DG-111). "Students can pause" has no model and is not built. "Allow skipping ahead" and "Show transcript after submitting" stay, because a teacher sets them today. An image's alt text is new data: one nullable column on the question, frozen at publish (T-R4.62). The limits are DG-63's on every screen.
- A pasted image is left out and counted; it no longer refuses the paste (DG-115). The deck's "Preview content after pasting" says how many images were left out. Today the converter refuses any paste that holds an image, and spec §7.1 says to reject it as one paste. T-R4.63 changes that one case and keeps every other refusal; T-R4.50 changes the spec line.
Depends on: R1 primitives; T-R4.3a for T-R4.1a
Touches: web/src/components/shared/data/DataTable.tsx (new), web/src/components/shared/data/FacetFilter.tsx (new), web/src/components/shared/CardGrid.tsx (new), web/src/components/shared/Pager.tsx, web/src/hooks/usePage.ts, web/src/lib/pagination.ts, web/src/components/ui/pagination.tsx, web/src/components/shared/BulkActions.tsx, web/src/components/shared/BulkSelection.tsx, web/src/hooks/useBulkSelection.ts, web/src/components/shared/ListState.tsx, web/src/components/ui/table.tsx, tests in web/tests/units/shared/ (exists), plus web/tests/units/ui/pager.test.tsx and web/tests/units/bulk-actions.test.tsx, which pin today's Pager and BulkActions, and a new web/tests/units/ui/table.test.tsx for the primitive, a case file per part in web/tests/support/deck-harness/cases/
Size: M
Delivered in parts: three, each one branch and one pull request
T-R4.1a: DataTable and the table primitive's deck geometry. Depends on T-R4.3a (the content width) and, softly, on T-R4.2a (the checkbox geometry arrives through the primitive)
T-R4.1b: Pager, usePage (size), BulkActions as the inverted bar. Depends on T-R4.1a (same folder, ListState)
T-R4.1c: FacetFilter, CardGrid. Depends on T-R4.2a (the menu's parts); it imports nothing of DataTable
As built:
-
T-R4.1a: beyond the list,
web/src/components/shared/data/tableLayout.ts(the pure parts: thresholds, the visible set, the template, the row box) and the testsweb/tests/units/shared/{data-table.test.tsx,table-layout.test.ts}. No locale key.BulkSelection.tsx,useBulkSelection.tsandRowMenu.tsxare composed as they are;ListState.tsxis not used by the table (the empty line is its own, and loading and error stay with the screen'sQueryStates). None of the four was edited. -
T-R4.1a: the props.
DataTable<T extends { id: string }>takeslabel,columns,rows,rowSize,dense,shown,rowHreforonOpen,selectionwithrowName,menu,menuTrack("44px"),padX(16),card,cardLayout("stacked"or"joined"),framed(true),emptyandfooter. A column is{ id, header, track, showFrom?, align?: "end", cell(row, shown), aside?(row, shown) }. The types refuse both ways to open at once and a selection withoutrowName. Every optional prop also takesundefined, so a screen writesmenu={can ? items : undefined}for a role without the permission. -
T-R4.1a: the sub-line is the screen's. The deck's four sub-lines share no rule and no
markup: Assignments joins two fields while either of two columns is dropped, the roster draws a
dot, the bank an icon, Students adds a part per dropped column. So a cell is told the visible
set (
shown, aReadonlySetof column ids) and the first column draws its own line. The set keeps its identity inside a band and each cell is memoised on its column, its row and that set: whatever else a cell draws from goes in a component of its own, andcolumnsis a module constant or a memo. -
T-R4.1a: the
shownprop replacesshowFromand the table's own band. Class members computes it fromuseContentBand([520, 620, 720, 1000, 1080]): name and Submitted always, then Average, then Joined via, then Joined, Joined dropped again from 1000 and back from 1080. A unit test checks those six sets against the deck'sleft = tw × 2/3arithmetic at every width from 300 to 1500. The screen keeps the six sets as constants, so the identity holds. -
T-R4.1a: a track is fixed or fractional with a minimum, never content-sized. Every row is a
grid of its own, so
autoormax-contentwould give each row its own column width. DG-21 is met by the screen: a column whose Vietnamese text can outgrow the deck's fixed track declaresminmax(<deck px>, <n>fr). Cells carrymin-width: 0. A cell's sized text (13px, 12.5px) is a block with its ownleading-normal, because the deck's cell is the grid item itself: left inline, the cell's own 13.5px line makes it 1px taller than the deck's. -
T-R4.1a: row height is
rowSize:{ height }(54, 56),{ minHeight }(60, 56) or{ padY }(10, the bank). The header is always 40px.densetakes 16px off a height or a minimum and halvespadY; DG-37's "Meanwhile" cell is corrected in place to say so, with no new gap row. A three-line Assignments row is 57.75px, so the compact minimum of 44 does not clip it. -
T-R4.1a: how a row opens. What the first column's
celldraws is wrapped in aLink(rowHref) or a<button>(onOpen): that is the tab stop, its keys are the element's own, and the first cell names the row througharia-labelledby. A click elsewhere on the row calls.click()on that element, so there is one path and a table withonOpenneeds no router. A click with Ctrl, ⌘, Shift or Alt held does nothing off that element:.click()carries no modifier, so it would navigate in place where the link itself opens a new tab. Nor does a click that landed on a control inside the row (a link, button, input, label, a focusable element) or outside it: React sends a click in a portalled menu up through the row. The row is not focusable and not a button, which would swallow the row and cell roles and nest the checkbox and the menu in a button. So the first column'scellholds no control; one goes in the column'saside, drawn under it in the same cell and outside the link or button: the bank's removable tag chips (deck 522). The aside's text joins the row's name, and a card below 768 has no aside. -
T-R4.1a: selection is
useBulkSelection's object, whole. Every row of a selecting table carriesaria-selected. A selected row is not tinted: the deck tints it on class members only, which T-R4.42 adds with the tone hook below. -
T-R4.1a:
cardis optional, and there are two lists. In the deck only Assignments and Students change below 768; the roster, the bank and class members stay grids at every width, checkboxes and menus included, so a table withoutcarddoes too."stacked"is Assignments' separate cards (the deck's button, line-heightnormal);"joined"is Students' one card of divided rows with the footer inside (deck 591). A card is a link, a button or plain, by the table's ownrowHreforonOpen. A joined row draws its focus ring inside itself (-outline-offset-2!), because the card around it clips an outline drawn outside. -
T-R4.1a:
framed={false}andalign: "end"are class members' (deck 1422 to 1427): its table sits inside a section that is the card, and Submitted and Average are right-aligned. -
T-R4.1a: the empty line takes the rows' place under the header, outside the element with
the table role, with the top border the bank draws (deck 530); the footer follows it. The deck's
Assignments puts the line after the pager. Below 768 it sits in a card of its own, a state the
deck does not draw. An
emptyofnull,falseor""draws no line, so a screen may writeempty={loading ? null : sentence}. -
T-R4.1a: content wider than the table scrolls sideways inside it. The deck's card is
overflow: hidden, and its own Assignments tracks need 876px at a content width of 860 to 875, and class members 380px on a 360 phone: the menu would be cut off and unreachable. The element with the table role is at least its tracks' minimum wide (min-width: min-content), so the header's background, the row borders and the hover tint span the scrolled width. Assignments therefore scrolls by up to 16px at a content width of 860 to 875, where the deck lets the tracks eat the right padding. A barefrtrack would size to its content under this rule: writeminmax(0,1fr). The menu column's header cell is positioned, so its visually hidden name scrolls with the table: unpositioned, it sat outside the card and the page scrolled sideways. -
T-R4.1a: the
tableprimitive gains, underin-data-[scale=deck]:only, 13.5px on a 1.5 line; header cells 40px, 12.5px, on--muted, 16px side padding (their muted text and weight 500 are today's classes); body cells padded 10px 16px with no fixed height;--mutedon hover. The row's top border is drawn on the body cells, so the header row has none and the last row keeps its own against today's[&_tr:last-child]:border-0. Compiled with Tailwind to check that every deck rule follows its plain one at the same specificity. Both comments that cited the removed boards are gone. -
T-R4.1a: left to others. T-R4.1b passes its pager as
footerand puts the bulk bar above the table. T-R4.25 adds the row tone: one more entry inBodyRow'scn(). The checkbox and the row menu are T-R4.2a's, merged before this; the table does not rely onRowMenustopping its own clicks. Every row's menu button is still named "Thao tác": naming it after its row needs a key andRowMenu'slabel, left to the first screen. -
T-R4.1a: compared with the deck through the harness, light and dark, with the English
strings: the Assignments grid (
r4-01a/assignments), the Students grid (students), class members inside its section (members) and the Question bank's padded rows with the tag chips in the aside (bank), each in a container of the deck's content width at 768, 1024, 1280 and 1440 with the sidebar expanded and collapsed, at 360, and on each side of every column threshold; Assignments' separate cards and Students' joined card at 360. The tracks, the box of every row and cell, the header band, type sizes, weights, colours, borders, paddings, gaps, the checkbox, the menu button, the hover fill and the cursor match. The differences: where the tracks need more than the card, the table scrolls and the deck clips (Assignments by 16px at a content width of 860, viewports 1164 expanded and 976 collapsed, by 1px at 875 and not at 859 or 876; class members by 50px at 360, where the deck cuts 30px and the menu with it; the bank by up to 62px at viewports from 1024 to 1247, where the deck's thresholds ignore its 234px filter column and its last column is cut: T-R4.32 decides those thresholds); the harness rows of Students open, so they are buttons with a pointer where the deck's phone rows are plain; the status pill is 2px wider than the deck's with its dot in the ink colour, and the 32px avatar's initials are 12px for the deck's 11.5px, both R1's primitives and not changed here. Seen in a browser for the first time: a joined row's ring shows on all four sides inside the row, its corners trimmed by the card on the first and last row; scrolled to its end, the header band, the row borders and the hover fill reach the last column, and a footer withsticky left-0stays in place; thetableprimitive on a deck surface has a 40px header at 12.5px and 500 on--muted, cells at 13.5px padded 10px 16px under a top border (41.25px for one line) and--mutedon hover. The states the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280 (the caseslong-vi,empty,denseandprimitive): a long title ends in an ellipsis in the grid and wraps in a card; the empty line sits under the header, in a card of its own above the footer, or inside the joined card; a compact three-line Assignments row is its 58px of text with no space above or below; Tab reaches every checkbox, link, button, chip and menu, each with a ring, and the link's ring on a three-line row overlaps the row's borders by 3px. That look found the hidden header text above. -
T-R4.1b: the deck's pager is a new file,
components/shared/data/Pager.tsx. The Classes list, Class detail and Students keepcomponents/shared/Pager.tsx(numbered links, nothing for one page) until T-R4.40 to T-R4.42 rebuild them; QA-F1-2 moved Tests, Media and the Imports history. T-R4.48 deletes with itcomponents/ui/pagination.tsx,pageWindowandpageCountOfinlib/pagination.ts, thepagination.*keys and the Pager cases oftests/units/ui/pager.test.tsx; that file'susePagecases stay. -
T-R4.1b: the props.
Pagertakespage,pageSizeandtotalas the API answered them,noun(count),sizes(PAGE_SIZES) andframe("card").nounreturns the screen's noun in the plural that fits the count, from the screen's own key: it is called with the total for "1–10 trên 312 bài giao" and with 0 for "Không có bài giao nào". Every figure is grouped for the locale, the positions and the page count too; the deck groups the total only. -
T-R4.1b: it is always drawn. One page and no rows read "Trang 1 trên 1" with all four arrows
off.
pageRangeinlib/pagination.tsdoes the arithmetic: at least one page, the page clamped to the pages there are, both positions 0 for an empty list. -
T-R4.1b:
usePageSizeis a second hook inusePage.ts;usePageandpageHrefdid not change.PAGE_SIZESis 10, 20, 30, 50. The size is?size=, absent for the first size; a value that is not listed reads as the first. The setter replaces the history entry, removespagein the same update and keeps the rest. A screen calls the hook, sends the size aslimitand puts it in its query key, not inusePage'sfilters. -
T-R4.1b: the arrows are links to
pageHref, so a page opens in a new tab and Back returns to the page before. One that cannot move points at the current page, isaria-disabled, at 45% and takes no pointer. It stays in the tab order, as the old pager's do. -
T-R4.1b: rows per page is the
Selectprimitive,size="sm", restyled to the deck's control, with the primitive's deck classes restated underin-data-[scale=deck]:(the card fill, 13px). It is named by the visible label througharia-labelledby, not by a<label>: Radix's trigger opens on a label's click only until it has itself been pressed with a mouse. It shows the size in the URL, so it answers at once while the range waits for the API. Its list is the product's, at least 128px wide and aligned to the trigger's end. -
T-R4.1b: the frame is the screen's choice at every width, and the Pager draws its own bar;
DataTable'sfooteradds nothing."card", the default: 10px 16px, a top border andsticky left-0, so the bar stays while a wide table scrolls sideways."plain": 4px 2px, no border, not sticky, under a list that is not one card: Assignments below 768 (that screen's ownuseMediaQuery), the import history, a card grid. Students' joined card keeps"card"on a phone. - T-R4.1b: below 768 the Pager drops rows per page and the first and last arrows, and nothing else.
-
T-R4.1b: the bulk bar has two forms, chosen by
useDeckScale(). Off a deck surface it is the old bar, byte for byte, for the eight old pages;bulk-actions.test.tsxpins its HTML. On one it is the deck's inverted bar, in plain classes kept in constants of their own. The file writes noin-data-[scale=deck]:class, which would sit in the old bar's markup too. The old bar's buttons are theButtonprimitive and carry that primitive's own deck classes, as they did. -
T-R4.1b:
BulkActiongainsiconanddestructive(true when absent; false gives a plain confirmation, the deck's "Reset passwords").BulkBarButtonis the bar's button for a control a page passes aschildren, such as an action that opens its own form; the built-in actions render through it. On a deck surface the count isbulkBar.selected("Đã chọn 2"), clear is a 30px × button named bycommon.clearSelection, andclassNamelands on the bar (class members:mx-3 mb-2.5). The confirmation, its list, the progress line and the failures are untouched. -
T-R4.1b:
hideOnPhone. The bar never hides by itself. Assignments and Students pass the prop, because below 768 their tables are cards without checkboxes; the bank and class members stay grids with checkboxes and keep the bar. A confirmation open when the bar hides stays open. The prop is read on a deck surface only. -
T-R4.1b: not copied as drawn, for the finisher. The action's border is
border-muted-fg/35, the nearest token to the deck's rawrgba(127,127,127,.35). The bar sets--focusto--primary-fg, because the global ring's--muted-fgdoes not show on--primary. Its buttons takehover:bg-primary-fg/10, 45% when disabled and the platform's cursor; the deck draws no hover, no disabled state and a pointer. -
T-R4.1b:
BulkSelectAllno longer passes the sixindeterminate:classes; the checkbox primitive carries them since T-R4.2a. Its own commit. -
T-R4.1b: not built. No "go to page" field and no numbered pages: the deck
draws neither. Tests are
shared/{data-pager,page-size}.test.tsx; the locale namespaces arepagerandbulkBar, afterpagination.useBulkSelection.tsandListState.tsxwere not edited. The cases indeck-harness/cases/r4-01b.tsxarepager-roster,pager-bank,pager-one-page,pager-plain,pager-joined,pager-wide-table,bulk-barandbulk-bar-members, and for the states the deck does not drawpager-emptyandbulk-bar-long-vi. -
T-R4.1b: compared with the deck through all ten harness cases at 360, 768, 1024,
1280 and 1440, light and dark, at expanded/collapsed or drawer reference widths:
200 rendered states match the visible geometry and tokens without settled overflow.
These isolated composites use shell-derived widths; the shell and drawer are verified
by T-R4.4. The default card-framed phone fixture is an undrawn variant;
pager-plainmatches the Assignments phone pager. The raw bulk-border substitution, added focus/hover and Radix Select/shared Confirm replacement are the stated departures. Forty-four functional/motion cases cover sticky horizontal scroll, URL replace/filter preservation, Back/new-tab, disabled-link focus, empty/one/last pages, resize-held plain/destructive confirmation and failed-only retry. Long Vietnamese was checked at 320, 360, 768 and 1280 in both themes. Observer-driven frames prove Select's 150ms and confirmation's 200ms entry/exit; reduced motion is 0.01ms with no intermediate frames or settled running animation. The pinned popup's 120ms ease-out movement was measured separately. No title marquee or continuous motion exists in this scope. Initial scratch path/selector and pre-settlement resize/focus failures were retained and corrected in the harness; no production defect or existing-test change was needed. - T-R4.1c: scope and corrections. FacetFilter depends on T-R4.2a's menu parts, not DataTable or ListState; the chosen-filter state is absent from the deck and follows DG-128. No page, primitive, existing grid, URL hook or ListState was changed.
-
T-R4.1c: FacetFilter takes
label, optionaltitle, readonly{ value, label }options, readonlyselectedandonChange(next), plus optionalclearable,disabled,alignandmenuClassName. It controls no selection state, URL, request or query; the screen owns those. -
T-R4.1c: the trigger is a native button, dashed, 34px high under deck scale, with a 14px
CirclePlus and no fixed label width. A nonempty selection adds the segmented control's 18px
count pill, hidden from accessibility;
facet.activesays the count once in the button's name. Unknown selected values count too. Both the native button and Radix trigger receive disabled. -
T-R4.1c: the menu defaults to end alignment and 240px (
data-[scale=deck]:w-60on its portalled content); the caller can replace that width, including the Students' 220px status menu. Optional title and truncated option labels compose T-R4.2a's parts unchanged. Ticks append or remove a value, preserve unknown values and other values' order, and stay open. Clear is disabled at zero and clears all values while closing;clearable={false}removes its row and separator. No options shows the mutedfacet.nonelabel and keeps the trigger enabled. -
T-R4.1c: CardGrid takes
label, readonlyitems, stableitemKey, numericmin, optionalgap: 10 | 12(12 by default),classNameand a card-rendering child. Its namedul role=listhas one keyed, min-width-zero single-cell gridliper item and renders nothing when empty. A file-local no-redundant-roles lint directive preserves that explicit role with marker-free styling. Tracks arerepeat(auto-fill, minmax(min(<min>px, 100%), 1fr)): one card keeps its drawn column, and the inner min prevents the 300px Classes track overflowing a 292px phone content. - T-R4.1c: intentionally absent. No menu search, single-choice facets, option counts, card chrome, loading/error/empty grid UI or pagination; screens compose their own cards and QueryStates. There are no marquee markers on these static labels and no reflow animation. Menu entrance/exit remains the approved primitive's fade; reduced motion is global.
-
T-R4.1c: strings and proof.
facet.clear,facet.activeandfacet.nonesit after queue in vi then en. Two new unit files cover controlled menus, count semantics, keyboard/focus, geometry classes and keyed card state;cases/r4-01c.tsxsupplies the comparison fixtures. Independent QA passes 55 distinct browser cases at five widths in both themes, with expanded/collapsed sidebar reference widths, measured tracks and gaps, 292px overflow, stable keyed state, native focus/keyboard and long Vietnamese. Actual menu entry and Escape/Clear/outside exit run for 150ms with native ease; reduced motion settles in 0.01ms. Trigger/check feedback is immediate and grid reflow is static; these labels have no marquee. The original 160px-grid fixture caps its caller at 560px; a separate genuine CardGrid fixture at the measured 722px picker width proves four 173px tracks without changing it. First scratch selector/easing-layer/caller-width failures remain in the QA report; this is component fixture acceptance, not a production-page or D4 comparison. Done when: -
DataTableis a CSS grid with ARIA table roles. Each column declares its track, a minimum and an optionalshowFromcontent width; a cell is told which columns are shown (cell(row, shown)), and each screen's first column draws the sub-line the deck draws for that table: Assignments shows "classes · when" whole while either column is dropped, and Submitted and Status drop without adding to it. A table whose rule is not an ascending threshold on the content width (class members, on two thirds of it from 1000) passes the visible set itself; the horizontal padding and the menu track are the table's (16 and 44px by default, 18 and 32px for class members). Columns size to content with a minimum, never a fixed label width (DG-21) - T-R1.5's deferred
tableprimitive (web/src/components/ui/table.tsx) gets its deck geometry here, alongsideDataTable, the first table on a deck surface (#165). Its current importers render outside[data-scale="deck"]and keep their classes until their screens are rebuilt - Tri-state select-all header; selection is
useBulkSelection's map of id to item, kept across pages (the confirm list names items of other pages); row click and Enter open the row; the row menu stops propagation - A table with
card(Assignments, Students) renders its cards below 768 with no checkbox and no row menu, and those two screens hide the bulk bar there (hideOnPhone). The roster, the bank and class members stay grids at every width, and the bank's and class members' bulk bars are drawn at every width - The header row is 40px. Each table passes its drawn body height (54, 56 or a 60px minimum; the bank's rows are padded 10px 16px). Compact takes 16px off a fixed height and halves a padded row's vertical padding (DG-37)
-
Pagerreads "1–10 of 312 {noun}" (vi first), rows per page 10/20/30/50 in?size=, "Page X of Y", first/prev/next/last at 32px; phones drop first, last and rows per page;usePagegainssize(?size=, sent to the API aslimit); it already resets the page when its filters change. The bar's frame is the screen's at every width:card(in the card, sticky at its left edge) orplain(under separate cards: Assignments below 768, the import history) -
BulkActionsrestyled as the deck's inverted bar ("{n} selected", 30px actions, × clear), keeping its confirm and partial-failure reporting -
FacetFilter(dashed 34px "⊕ Class", check menu, "Clear filter") exists;CardGridtakes the deck'sminmaxwidth. There is noFilterChips: since the 2026-10-03 export no screen draws a row of filter pills, and a status filter is the segmented control with counts (T-R4.2) - Unit tests: column drop and inline fallback at a threshold (the Assignments fixture reads
"classes · when" at 859 and at 720 and adds nothing at 559; the class-members fixture shows
Joined at 900, hides it at 1040 and shows it at 1100), tri-state header, selection across
pages, card list at 360 (
viewport("phone")), keyboard row activation, pager ranges
Depends on: R1 primitives
Touches: web/src/components/shared/form/FormDialog.tsx (new), web/src/components/shared/form/fields/* (new), web/src/components/shared/ConfirmDialog.tsx, web/src/components/shared/form/{NumberStepper,RadioCard,ChipInput,TagCombobox,FileDrop}.tsx (new), web/src/features/question-bank/components/TagsField.tsx (moved into TagCombobox), web/src/features/media/useFileDrop.ts → web/src/hooks/useFileDrop.ts, web/src/components/shared/{DirtyBar,SettingsLayout}.tsx (new; R3 built neither), web/src/components/shared/{Sheet,CopyField}.tsx (new; Sheet on components/ui/dialog.tsx, CopyField composing components/shared/CopyButton.tsx), web/src/components/shared/RowMenu.tsx, web/src/components/ui/dropdown-menu.tsx, web/src/components/ui/segmented.tsx, web/tests/support/deck-harness/ (new, T-R4.2a)
Size: M
Delivered in parts: three, each one branch and one pull request; T-R4.2b follows T-R4.2c to reuse its native local-Escape helper
T-R4.2a: menus (dropdown-menu, RowMenu), Segmented (icon, count, scroll), checkbox, tabs, and the deck harness. Depends on nothing in R4
T-R4.2b: FormDialog, ConfirmDialog on it, FileDrop (with the useFileDrop move, its own commit), the date and time field's deck geometry; the dialog primitive gains only its place in the z-order under deck scale (the tooltip with it), and the teacher's frame is the composite DialogShell. Depends on T-R4.2a and T-R4.2c
T-R4.2c: Sheet, CopyField, DirtyBar, SettingsLayout, NumberStepper, RadioCard, ChipInput, TagCombobox. Depends on T-R4.2a
As built (T-R4.2a: menus, Segmented, checkbox, tabs):
-
Touched beyond the list:
components/ui/{dropdown-menu,checkbox,tabs}.tsx,features/auth/AccountMenu.tsx(class strings of itsdeckform only),lib/revealWithin.ts(new),tests/support/deck-harness/(new),tests/units/ui/{menu,segmented,checkbox,tabs}.test.tsx,tests/units/shared/row-menu.test.tsx,tests/units/lib/reveal-within.test.ts. No locale key and no token was added. -
Which variant sits on which element.
DropdownMenuContentis portalled and carriesdata-scaleitself, so its geometry is writtendata-[scale=deck]:(card, radius 10, the float shadow,--z-popover, 5px of padding, no width, and amax-heightof 60vh or of the room Radix reports beside the trigger, whichever is less, with its own scroll: Radix flips a menu but never moves it up or down to fit). There it also passes Radix acollisionPaddingof 8, the margin the deck'sopenMenukeeps from the window's edges before it flips or shifts. What sits inside it (label, items, the check box, separator) and what sits on a deck surface (the row trigger, Segmented, checkbox, tabs) is writtenin-data-[scale=deck]:. A caller replaces a primitive's deck class by restating it with the same variant, after it. What is new here and has no earlier look to keep is plain classes, the same on and off a deck surface:DropdownMenuMeta,DropdownMenuItemText, and in Segmented the count, the icon and its 7px gap in the large row, and whatsmandxsset besides their deck height and radius. -
The menu's new parts, all optional:
DropdownMenuLabel(the title row),DropdownMenuMeta(the muted, tabular figure at a row's end) andDropdownMenuItemText. On a deck surface a row is 13px on a 16px line (30px high) and a 9px gap, with a 14px icon; a disabled row is at 45%,variant="destructive"reads--danger-ink, the separator has no margin, and a check row draws the deck's 16px box. -
A label truncates only inside
DropdownMenuItemText. A bare text child of a flex row cannot take an ellipsis, so it wraps when it is longer than the menu. A menu that lists names a user typed (classes, tags) wraps each in the part; a fixed label needs nothing. -
The student's account menu restates what the primitive would now override. On the content,
data-[scale=deck]:bg-card,shadow-float,z-(--z-popover),w-60,rounded-lg,p-1.5; on each item,in-data-[scale=deck]:gap-2.5,p-2,leading-4,data-[disabled]:opacity-50and the 15px icon. It keeps 240px, 6px of padding, 32px rows on a 10px gap; what it gains is that height cap and the 8px margin, and neither shows under the top bar.tests/units/ui/menu.test.tsxpins each restated class. -
RowMenutakestitleandlabel(the trigger's name,common.actionsby default). On a deck surface the trigger is 30px with a 16px icon and the menu 220px; another width is passed asdata-[scale=deck]:w-*. -
RowMenustops the click, and only the click, on its trigger and on its content, because a React event bubbles through a portal to the row. Pointer and key events still reach the row: a row that opens on Enter must act only when it is itself the event's target. -
SegmentedexportsSegmentedOption(icon,count) and takessizesm(28px, 12.5px) andxs(26px, 12px, radius 6 in a radius 8 track) andscroll. The count is the 18px primary pill, drawn at zero too, and is read as part of the button's name ("Đang mở 3"). With no icon, no count, noscrolland the default or large size, the markup is byte for byte R3's. -
A line height is a whole pixel, as Chrome gives the deck's buttons: 16px for a menu row and
for the
smrow and a scrolling track of Segmented, 15px forxsand the meta figure, 18px for a tab. The default and large rows withoutscrollkeep R3's markup, so their label sits 0.7px above the deck's in a box of the same size, as on R3's two screens. -
A scrolling track moves its own
scrollLeft(lib/revealWithin), neverscrollIntoView, which would also scroll the page: for the pressed option when it changes, and for an option that takes focus while the edge cuts it, which a browser reveals only when it is wholly out of view. Its buttons take a 1px outline offset, so the track does not clip the ring. The deck draws a 15px icon in the 32px row and 13px in the 26px one; the 30px row takes 15px and the 28px row 13px. -
Checkbox: on a deck surface radius 4, the
--ringcolour on--card, an 11px tick. The bar of the mixed state is in the primitive, the only classes added off a deck surface;BulkSelectionstill passes the same six and tailwind-merge keeps one copy. -
Tabs on a deck surface are the deck's underline row. The list scrolls sideways and would
clip the global focus ring, so a tab draws its ring inside its own box (
-outline-offset-2!), and a tab that takes focus while the row's edge cuts it is revealed as a Segmented option is. -
The deck harness (
tests/support/deck-harness/, never in the production build) renders?case=<file>/<name>underDeckScale, in a container of?width=<px>when given. A task addscases/<task>.tsx, which exportscasesand imports nothing from the harness. The harness gives a case no router, query client or user: a case that needs one sets it up. Its README has the commands. - Not built: the Source control's fixed 168px options (DG-21); the plain count of the review filters (deck line 1814; T-R4.38 adds it if it needs it); toggle and toggle group (no deck surface uses them).
-
Compared with the deck through the harness, light and dark: the row menu (768 to 1440; the
deck draws cards and no row menu below) and its disabled rows, the class filter's check menu
before and after a tick (360 to 1440), the Question type menu with its meta, Segmented with
counts and the Imports filter (360 to 1440, the sidebar expanded and collapsed, in a track of
the deck's own width), the
sm,xsand large rows and the tabs (360 and 1280), the checkbox. Every box, type size, weight, colour, radius, shadow, padding and gap matches. The differences: the separator is its own 1px element, where the deck draws a top border on the next row; the row's button keeps the hover fill while its menu is open (the deck's shared menu does not know its trigger; the Student deck fills its own avatar button); a flipped menu sits 4px above its trigger, where the deck places it by an estimate of 34px a row (27px above for five rows); menu rows keep the platform's cursor, as every button of the product does; every scrolling track hides its scrollbar, which the deck does on Imports and the review and not on Assignments; the checkbox's tick is the product's filled shape in the deck's 11px box and its bar is 8 by 2px, both heavier than the deck's 11px glyphs, while a check row of a menu draws the glyph itself. Seen in a browser for the first time: near the window's bottom the menu opens above its trigger; at 1280 by 300 with its trigger midway it is 123px high, ends 8px above the edge and scrolls to its last row; a click or a key on an option cut by either edge leaves it whole, 3px inside the track, and the page does not move. The student's account menu was measured on/appat 360 and 1280, light and dark, in both languages, against a build with the release branch's primitives: 43 computed values and the box of each of its nodes are equal, but formax-height(none, now 480px at an 800px window) andoverflow-yon the content. Segmented on the student's result and settings is byte for byte and value for value the same. The states the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280: a long title wraps, a long label inDropdownMenuItemTextends in an ellipsis and bare text wraps, nothing scrolls sideways, every control takes a visible ring from the keyboard. As built (T-R4.2b: dialog shell, form dialog, confirm dialog, file drop, date and time): - Teacher geometry belongs to
DialogShell; the primitive only raises content and overlay to deck z85, preserving all three Student frames. -
DialogShellexposes Header, Body and Footer with connected focus return; Teacher content and overlay mount and close instantly. Its max-height changes without a transition, so the footer stays inside a newly shortened viewport from the first layout. -
FormDialog<V>owns draft and touched state; the caller owns submission and close. Equivalent props preserve typing; reopening resets, and callbacks reject closed, pending, disabled or unmounted forms. - Its props are
open/onOpenChange, title and description values or functions, icon/danger/width/className, initial/fields/validate, submit/cancel/hideCancel, pending/disabled/error, onSubmit/children/returnFocus. -
FormField<V>includes text, email, number, password, area, date, time, select, seg, chips, list, toggle, file, info and qr;FormOptioncarries value, label and meta. Date and time were omitted from the original plan list. - Required and caller errors apply only to shown controls. Invalid submit remains pressable with aria-disabled and focuses the first invalid control; pending is guarded and keeps focus (F-59, below).
-
F-59 (T-35, 2026-10-11) replaces "pending is native disabled". In Chromium a focused submit that turned
disableddropped focus to<body>, so a failed submit (the photo dialog, Sign out other devices, Extend, Duplicate, Close early) left a keyboard user nowhere. Whilependingthe submit takesaria-disabledand ignores a press, so focus stays on it through the request and after a failure; text and area fields, and a list field's search box, turnreadOnly, so the field Enter submitted from keeps focus and its value; the other controls (seg, select, date, time, chips, a list's choices, toggle, file), which cannot start a submit, stay natively disabled, so their open portals still close on lock.disabledwithoutpendingstill disables natively, andpendingwins when a caller passes both. A submit that starts while focus sits on one of those controls moves focus to the submit first: Safari and Firefox on macOS do not focus a clicked button, and a select or picker returns focus to its trigger after a choice, so "pick a date with the mouse, click Save" would otherwise leave focus on a trigger that is about to be disabled.SegFieldnow honoursdisableditself, since the fieldset no longer disables while pending; the fieldset carries noaria-busy(the submit'saria-disabledsays the form is busy). jsdom does not blur a focused element that turns disabled, so inform-dialog.test.tsxandconfirm-dialog.test.tsxthe assertions that discriminate are the submit's and the text fields'not.toBeDisabled()andreadonly, and the hand-off to the submit; the browser behaviour is pinned bymedia-library.spec.ts("a failed rename keeps focus on the dialog's submit in a real browser"). - Engine fields share a native form with its associated footer submit; legacy children stay outside it. Native Enter submits current values exactly once, respects invalid and locked states, and leaves the controlled form open.
-
ConfirmDialogpreserves confirming and notice callers, adds an optional icon and danger tile, and retains the confirming Button's data-variant. -
Segmentedadds optional fill; all legacy defaults remain unchanged. -
FileDropchooses or removes the first file and permits same-file reselection; file validation and upload are caller responsibilities. -
useFileDropmoves tohooks/and adds enabled reset and listener suppression. A page with its own window drop must disable it under dialogs in T-R4.35 and T-R4.65. -
DateTimeFieldkeeps joined inherited controls off deck and a single picker on deck; T-R5.30 removes the inherited branch with the otheruseDeckScalebranches (every date input renders on a deck surface since T-R4.48). Optional trigger ARIA and disabled props preserve absent defaults; owned portals dismiss on lock without reopening after unlock. - Date commits immediately, including the highlighted current day of an empty required date; time and datetime wait for Done. Native mouse and focused Enter preserve this distinction. Wall-clock values, off-step minutes, parent Cancel, reopen and locale semantics stay intact; dismissal discards only the uncommitted picker draft.
- Tooltip raises deck content to z100 above dialogs with unchanged appearance. The preview field remains deferred to StudentPreview and future course work.
-
formDialoganddatePickernamespaces follow layout in vi and en. Independent native QA compared 130 frame/control states at 360, 768, 1024, 1280 and 1440 in both themes and passed nine unchanged Student production regressions. Original reduced-picker and current-day failures, the resize observation and the fixture composition failure remain recorded. The scoped repair campaign passes 37 distinct native cases in 39 executions, retaining two scratch Cancel-label locator failures. The normal picker enters over 120ms ease-out without an exit; under reduced motion it has no animation from the first rendered frame. Teacher resize stays within 450px from the first layout. Shared Select and Student motion retain their existing rules; no title marquee applies to these controls. The corrected unshipped Extend demonstration uses a separate named help button to avoid Tooltip overwriting Switch state. Its measured frame/row differences are +13.03125/+14.90625px at 360 and −3.765625/−1.890625px at wider boards; widths, paint, focus and overflow pass. These disclosed fixture differences do not approve a future product Extend dialog. Long Vietnamese text remains reachable by native body scrolling. Exact-head CI and future screen adoption remain separate gates. As built (T-R4.2c: sheet, copy field, dirty bar, settings layout, form controls): -
Touched beyond the list:
components/shared/form/chips.ts(the two comparisons,withChipand theuseChipEntryhook thatChipInputandTagComboboxshare),form/stepperValue.ts,form/tagOptions.ts,lib/localEscape.tsand the nested sheet Escape regression;ChipInput.tsxalso exportsChipBoxandCHIP_INPUT, whichTagComboboxcomposes. Ten keys in a newcontrolsnamespace, beforetoast:unsaved,discard,decrease,increase,removeTag,addTag,tagSuggestions,createTag,newTag,tagKeys. No token, no dependency, nothing undercomponents/ui/,features/orlayouts/. Everything here is plain classes, the same on and off a deck surface; a primitive's deck class is restated within-data-[scale=deck]:where it changes. -
Sheet{ open, onOpenChange, title, subtitle?, leading?, width?: 320 | 380 | 420 (420), footer?, children }takes Radix's dialog primitive (Dialogfromradix-ui) and imports nothing fromcomponents/ui/dialog.tsx: T-R4.2b writes that file's place in the z-order while this is built, and a deck class on its content or title would beat anything restated here. The primitive gives the focus trap, Esc and the backdrop; it marks the page behindaria-hiddenand sets noaria-modal, and it leavesaria-describedbyoff by itself when there is no subtitle, so the sheet passes none. -
The sheet's frame.
min(<width>px, 100%)at the right edge and the full height, so a phone gets the whole width with no second rule. 420: header 16 by 18 with a rule, body 18 with an 18px gap, footer 14 by 18 with a rule. 380: header 14 by 16 with a rule, body 14, no gap. 320: a header with no rule and one 16 by 18 column with an 18px gap; its header stays in place where the deck's scrolls away with the column. The body is the only scroller.titleis theh2that names the dialog (15px, 600),subtitleits description, the close button iscommon.close. Overlay and panel are at--z-sheet, under--z-dialogand--z-popover. It slides in from the right over 200ms and is still underprefers-reduced-motion; the deck draws no motion. It is portalled, so it carriesdata-scaleitself when it is opened from a deck surface. -
Focus returns as
DeckDialogreturns it (the rule is copied, not imported): to the control that opened the sheet, or to the<main>it was in when that control has gone, unless the page has already put focus somewhere else. -
DirtyBar{ dirty, saving?, error?, onDiscard, onSave? }is new; the student's bar inStudentSettingsSections.tsxwas the model and is not lifted. Nothing while notdirty;errortakes the sentence's place as an alert; Save changes istype="submit"for the section's own form unlessonSaveis given; both buttons are off whilesaving. It wraps. -
SettingsLayout{ label, active, sections: readonly { id, label, icon, to, content }[] }. The navigation is links, so a section is a URL, witharia-current="page"on the current one. Every section's content is rendered in its own panel and hidden with thehiddenattribute unless current, so a form keeps what was typed. Row and column are CSS at 768. The column is 200px or as wide as its longest label (DG-21), where the deck fixes 200px and scrolls. The row hides its scrollbar and brings the current link, and a link that takes focus, into view withrevealWithin; a link draws its ring inside its box, because the row clips. -
NumberStepper{ label, value, onChange, min, max, step? (1), format?, disabled? }. The value is an<input type="text" inputMode="numeric">with the spinbutton role, the one tab stop. It can be typed although the deck draws no input, so T-R4.27b loses nothing today's form can do: with the deck's step of 5 a limit of 47 minutes would be out of reach. Unfocused it shows the formatted text, focused the bare number, selected; only digits are kept; Enter or blur commits, clamped and never snapped to the step; Escape and an empty field restore the value;onChangeis never called per keystroke. The arrows, Home, End and the two buttons step from the committed value and drop a draft. The buttons are out of the tab order and do not take focus from the field. A hidden copy of the text sizes the field, so it keeps the deck's 54px least width and grows with a longer unit. -
RadioCards(inform/RadioCard.tsx){ label, value, onChange, options: readonly { value, label, hint?, disabled? }[], className? }is Radix'sRadioGroup: one tab stop, arrow keys choose. Its own unmodified arrow handler focuses the enabled destination synchronously, skips disabled cards and wraps with the group's direction. A guarded native microtask click selects once, with pending work cleared before the caller callback and on stale input, focus, value or semantic option changes. Space, click and Home/End retain Radix behavior. This fixes the pinned library's early-keyup race without changing its primitive or CSS.classNamereplaces the deck's grid. -
ChipInput{ label, values, onChange, placeholder?, removeLabel, tone?, size?, frame?, commaAdds?, invalid?, same? }. Enter, a comma and blur add; a paste adds every part before a comma; Backspace in the empty input removes the last value, once per press, not while the key is held. An entry is trimmed and put in NFC.samedecides what is already there. The default,sameIgnoringCase, is the deck's lower-case equality, so accents count;TagComboboxpassessameFolded; and T-R4.66 passes exact equality when "Match case" is on, because the server grades accented and, then, cased answers as different. Both comparisons are exported fromform/chips.ts.commaAdds={false}is for answers that may hold a comma (T-R4.66 decides). Removing a chip returns focus to the input; a long value ends in an ellipsis inside its chip. -
TagCombobox{ label, tags, onChange, suggestions: readonly { tag, meta? }[], placeholder? }is the chip field (neutral, framed, 22px) plus the list.tagOptionslists the suggestions that contain the draft underfold, not already chosen, those that start with it first and otherwise in the order given, six at most, then "Create" unless a suggestion or a chosen tag equals the draft underfold. The bold part is found on the suggestion's own text, combining marks included. Enter or Tab takes the active row, which is the first until an arrow moves it; Escape closes the list and keeps the draft; with the list closed Enter adds the draft, in a suggestion's spelling when it equals one. Blur adds the draft, as today's field does (the deck's only closes the list). The list is under the field at--z-popover, not portalled; the keys line is outside the listbox. -
For the editor chain (T-R4.64 onwards), which swaps
TagsFieldforTagCombobox:TagsField.tsxandQuestionEditor.tsxare not touched here. Today's field offers recent tags before anything is typed; the deck's list opens only on a draft, andTagComboboxfollows the deck. The data (the recent tags,listQuestions'tags, the counts formeta) stays with the caller, which also orders the suggestions. -
CopyField{ label, value, failedMessage, copyLabel?, mono? }is built from the deck's parts; the Teacher deck draws no such field: the form label, the 38px input, read-only, and the 28px bordered action button.CopyButtongainedlabel,onFailedandclassNameand nothing else: with none of them its markup is today's, pinned byte for byte, idle and copied. On a refused clipboard the field's whole value is selected and focused. -
Borders and focus. The stepper's box, the chip frame and the copy field take the
--inputborder (DG-31), where the deck draws--border. A framed field's border turns--ringwhile its input has focus and the frame, not the input inside it, draws the 2px focus ring (DG-30). The stepper's buttons round their outer corners, so the hover fill stays inside the box. -
States the deck does not draw: a switched-off stepper and radio card (45%), an invalid chip
frame (the danger border and
aria-invalid), a footer on the 380 and 320 sheets, a long title (it wraps), the clipboard's refusal under the copy field. -
Browser comparison and remaining layering. Until T-R4.2b puts the dialog at
--z-dialogand the tooltip at--z-popover, both open under a sheet (z-50against 70). A focused input marksdata-local-escape="true"only while the tag list is open or the stepper has a draft (including empty).SheetpassespreventLocalEscapeDismissalto Radix's nativeonEscapeKeyDown: the first Escape closes the list or restores the value and keeps focus; the next closes the sheet. T-R4.2b'sFormDialogmust pass the same helper toDialogContent'sonEscapeKeyDown, so nesting follows the same rule without document listeners. The harness cases arecases/r4-02c.tsx. Independent QA compared the seven drawn composites and settings navigation at 360, 768, 1024, 1280 and 1440, light/dark, with sidebar-derived expanded/collapsed or phone widths; 280 original fixture states and 80 distinct acceptance cases pass. Isolated composites do not retest the shell/drawer. Long Vietnamese has no settled sideways overflow. The phone DirtyBar's extra 5px follows the prescribed wrapping departure; editable 16px text, focus rings and the undrawn CopyField remain stated choices. Actual Sheet and overlay entry/exit are 200ms ease, with intermediate frames and removal; reduced motion is static with immediate removal. Other controls use their declared static states and shared hover transitions. No continuous motion or measured title marquee exists. QA found the rapid radio race; after the reviewed correction, 57 product checks plus one generic-Radix diagnostic pass, including native zero-delay arrows, callback counts, cancellation and 20 unchanged geometry/focus comparisons. Original failed traces remain; the generic primitive still reproduces its race. Existing tests are unchanged: the new sibling adds 24 cases, and all 34 radio cases pass; four repair mutants fail assertions after the earlier 30 control mutants, each with exact source restoration. Done when: - The rest of T-R1.5's deferred primitives get their deck geometry here, with R3's deck-scale context: checkbox, tabs, the date and time picker and popover (R3 did select, switch and segmented); toggle and toggle group only if a deck surface uses them (#165)
- Segmented takes an optional icon and an optional count per option (the deck's 18px
primary badge, drawn on every option and at zero), and its track scrolls sideways inside
itself, without a scrollbar, when the options do not fit. It stays a group of
aria-pressedbuttons, not a tablist, in every use: a list filter has no tabpanel, and the import page's Source control swaps one card of a shared page (DG-17) -
FormDialogrenders the deck's engine: text, email, number, password, area, select, seg, chips, list (search above 5 options, "{n} selected", "Nothing matches “q”"), toggle, file, info rows and QR; " (optional)" on non-required text-like fields; "Required" only after the first submit; submitaria-disabledand at half opacity until valid; a danger variant with a danger icon tile.ConfirmDialogis aFormDialogwith no fields and its callers are unchanged. Its 23 callers render outside deck scale until rebuilt, soFormDialogmust look right withoutdata-scale, and every case ofconfirm-dialog.test.tsxstays -
NumberStepper(36px, −/+, unit suffix, min/max clamp; its value can be typed, committed on Enter or blur, clamped, never snapped to the step),RadioCard,ChipInput(Enter, comma or blur adds; Backspace removes the last; a duplicate is refused by the caller's comparison: lower-case equality by default,foldfor tags, exact equality for accepted answers under Match case) andTagCombobox(suggestions, bold match, ↑↓ Enter Esc) exist -
FileDropshows "Choose a file or drop it here", a limits line and remove; drag state per the deck. It is theFormDialogengine'sfilefield, so both, with theuseFileDropmove, are T-R4.2b -
DirtyBar(sticky, Discard + Save changes).Sheetis built on the Radix dialog primitive thatcomponents/ui/dialog.tsxwraps and imports nothing from that file; it is pinned to the right edge (noui/sheet.tsxexists) and adds the header, close and scrolling body; widths are the deck's 380, 420 and 320, full width on phones.CopyFieldcomposescomponents/shared/CopyButton.tsx(its "Copied" feedback, announcement and clipboard-failure copy) beside a read-only field that is selected on failure -
SettingsLayout: 200px section column, a row on phones, sections stay mounted so a form survives switching. It andDirtyBarare new; R3 built neither. The unsaved-changes bar insideStudentSettingsSections.tsxis the model and is not lifted -
RowMenurestyled: 220px, flips above near the bottom, height capped at 60vh, meta, danger, separator, disabled at .45, roving focus (DG-36). The item geometry, the title, meta, separator and the .45 are thedropdown-menuprimitive's under deck scale; flipping and roving focus are Radix's. The menu's content is portalled and carriesdata-scaleitself, so its own geometry isdata-[scale=deck]:and only what is inside it isin-data-[scale=deck]:; R3's student account menu restates its classes with those variants in the same commit - Unit tests: required-after-submit, aria-disabled submit, list search, danger variant, stepper clamp, chip input keys, copy success and failure, segmented counts and pressed state
Depends on: R1 primitives
Touches: web/src/components/shared/stats/{KpiTile,StatStrip,ProgressBar,Meter}.tsx (new), web/src/components/shared/charts/BarChart.tsx (new), web/src/components/shared/{IconTile,Callout,LockNotice,EventList,SplitPane,Waveform}.tsx (new), web/src/hooks/useIdlePolling.ts (new), web/src/layouts/shell/contentWidth.ts (new), web/src/features/attempts/components/Monitor.tsx, web/src/features/integrity/components/Timeline.tsx, web/tests/support/viewport.ts (a numeric form, viewport(800))
Size: M
Delivered in parts: three, each one branch and one pull request
T-R4.3a: the two shell hooks, useIdlePolling (with useRefetchOnResume) and the content-width store (useContentWidthAtLeast), and the numeric form of the viewport() test helper. Depends on nothing in R4
T-R4.3b: KpiTile, StatStrip, ProgressBar, Meter, BarChart, IconTile, Callout, LockNotice, the generic event list. Depends on nothing in R4
T-R4.3c: SplitPane, Waveform. Depends on nothing in R4; it takes useColumnWidth's tests as its model
As built:
-
T-R4.3a: beyond the list, the helper
web/tests/support/contentWidth.tsand three test files:web/tests/units/shared/idle-polling.test.tsxand, in the new folderweb/tests/units/shell/that T-R4.4 fills,content-width.test.tsxandviewport-helper.test.ts.monitor.test.tsxand the integritytimeline.test.tsxgained cases. -
T-R4.3a:
useIdlePolling(ms: number, enabled = true): number | falseis a query'srefetchInterval:mswhileenabled, the document is visible and the user is active, elsefalse. It is called on the line aboveuseQuery, andrefetchIntervalInBackground: falsestays. -
T-R4.3a: activity is a
pointerdown,pointermove,keydown,wheelortouchstartonwindowwithinIDLE_AFTER_MS(600 000). One store for every consumer: the first attaches one set of listeners and one timer, the last removes them, an input only writes a timestamp, and a consumer re-renders when idleness or visibility flips, never on a mouse move. The listeners are passive and on the capture phase, so a handler that stops propagation (a row menu does) cannot hide an input. -
T-R4.3a: mounting counts as activity through the store's first consumer. The ten minutes
start when the store attaches. A consumer that joins an idle store answers
falseuntil an input or a return to the tab, so a mount nobody caused cannot wake every poll; a disabled consumer attaches nothing. -
T-R4.3a:
useRefetchOnResume(refetch: () => unknown, enabled = true): voidrefetches at once when the user comes back: once at the first input after idleness and once when a hidden document becomes visible, never on mount, becauseenabledchanged, or on an input from a user who never left. The query client hasrefetchOnWindowFocus: false, so without it a monitor watched without input would show data up to ten minutes old for one more interval after the teacher's first mouse move. TanStack restarts the interval from that refetch. - T-R4.3a: becoming visible counts as activity. A tab that comes back, after however long, refetches once and polls for another ten minutes; the first input after it does not refetch. Somebody brought the tab back, and without this a teacher who returns with the keyboard or on a tablet would read a roster that froze at the moment of return.
-
T-R4.3a: the old detail page's
monitor.pollingsentence is untrue for an idle teacher.AssignmentDetailPagestill prints "Tự cập nhật 15 giây/lần" on every open assignment, and the monitor under it now stops after ten minutes without input. The deck draws no such sentence: T-R4.25 removes it, or draws the paused state fromlive && refetchInterval === false, and changes the two tests that wait on it (web/tests/units/assignments/detail.test.tsxandweb/tests/e2e/author-and-publish.live.spec.ts) andMonitor's doc comment with it. -
T-R4.3a:
Monitorand the integrityTimelineswitched, four lines each: the import,const refetchInterval = useIdlePolling(POLL_MS, live), the shorthand in the query, anduseRefetchOnResume(<query>.refetch, live). The import pages' polls (imports/availability.tsand the three pages) are left to T-R4.36 to T-R4.38, and the student'sAssignmentIntroPagekeeps its own: a student waiting for a test to open is not idle. -
T-R4.3a: the content width is the registered element's border-box width minus its computed
horizontal padding (
offsetWidth − padding-left − padding-right), which is the deck'stw. A vertical scrollbar sits inside the border box, so its appearing moves no threshold and no hysteresis is needed. The padding is read at every measure, because it changes at 768. -
T-R4.3a:
contentWidth.tsexports three things.registerContentElement(element | null)is the ref of the shell's<main>(one element at a time,nullunregisters; measured at once and on everyResizeObservercallback, observed withbox: "border-box").useContentWidthAtLeast(px)is a boolean.useContentBand(thresholds)counts how many of an ascending array the width meets, one subscription for a table's whole column set. The snapshot is the boolean or the count, so a consumer re-renders only when its own answer changes. -
T-R4.3a: wide when unregistered. With no element (a unit test, a page in the old shell, a
server render) every threshold is met and the band is
thresholds.length. WithoutResizeObserverthe width is measured once and stays. A unit test that renders the shell is not unregistered: its<main>measures 0 in jsdom, so every threshold is unmet unless the test callscontentWidth(px). -
T-R4.3a: a page that mounts in the same commit as its shell renders "wide" once.
<main>'s ref attaches after that first render, and the consumer corrects itself when it subscribes (content-width.test.tsxpins[true, false]). T-R4.4 can keep that frame off the screen by rendering the outlet once<main>is registered. -
T-R4.3a: two test helpers.
contentWidth(px)(web/tests/support/contentWidth.ts) registers a stand-in<main>of that content width and aResizeObserverthat fires only when told, returns{ resize(next) }, and takes both away when the test finishes; it is called inside a test or abeforeEach. A<main>the tree under test registers in the stand-in's place (a rendered shell) has the same width and followsresize, whether the helper ran before the render or after it.viewport()also takes a number of CSS pixels:(min-width: Npx)matches at N and above,(max-width: Npx)at N and below, a query with both needs both, any other query is false.viewport(800)is "the sidebar shows and side panels are sheets",viewport(1050)"wide, below the search button's 1100"."phone"and"desktop"answer as before. -
T-R4.3a:
monitor.test.tsxkept its four cases and changed two lines of scaffolding. ItssetVisibilitydispatches insideact, since a visibility change now re-renders the monitor, and itsafterEachunmounts before it restores visibility, since a return to the tab now sends a request and that one would land in the next case's count. -
T-R4.3b: beyond the list,
stats/progress.ts(percent,thresholdTone,shares),charts/bars.ts(barHeights,labelShown),tests/units/shared/{stats,bar-chart,notices}.test.tsxand the harness casestests/support/deck-harness/cases/r4-03b.tsx. Every part is new, so its classes are plain, the same on and off a deck surface, with radii written as lengths (rounded-[0.5rem];rounded-mdis 6px off a deck surface). No dependency and no token. -
T-R4.3b:
KpiTileis a link when it has somewhere to go.{ label, icon, tone: "warning" | "danger" | "success" | "muted", value, hint, live? }and either{ to, action }or neither: the types refuse one without the other. Withtothe whole tile is a react-routerLink, named by its label, value, hint and action, whose border takes--ringunder the pointer. Withouttoit is a<div>with the same frame, label, icon, value and hint: no role, no tab stop, no hover and no action. The deck's fixtures always have a destination, so that state is not drawn. The icon is the solid tone, not the ink, and a label that does not fit ends in an ellipsis. Both linked and plain tiles carrygroup/kpi: the composedLiveDotpauses while that tile is hovered or contains focus, resumes afterward and remains static under reduced motion. The pause utilities are important (!):qz-live-dot's unlayered animation shorthand otherwise resets play state over normal layered utilities. This adds no tab stop or interactive hover styling to the plain tile and changes no shared CSS. Native border feedback is 150ms ease. A screen whose destination is conditional spreads it,{...(to ? { to, action } : {})}:to={undefined}does not compile (to?: neverunderexactOptionalPropertyTypes). -
T-R4.3b: the tile's line heights. The deck's tile is a
<button>, so its rows have the browser'snormal: the label row (13px) and the hint row (12.5px) areleading-4, 16px, and the value keeps the deck's ownleading-[1.15]. A tile with one-line rows is 112.5px high. The rows are<div>s, so the link's name reads with a space between them. -
T-R4.3b:
StatStriptakesitems: readonly { label, value, suffix?, tone?: "default" | "danger" }[]and renders a<dl>. It is four columns whenuseContentWidthAtLeast(640)and two below; it never reads the viewport, so it changes when the sidebar collapses, and with no content area registered it is four. The value istext-statonleading-normal;dangeris--danger-inkon the value and the suffix stays muted. It is drawn for four items. -
T-R4.3b:
ProgressBartakes{ value, max? (100), label, size?: 6 | 8, tone?: "accent" | "danger" | "warning" | "success", cap?: "square" | "round", className? }. It is a progressbar named bylabelwhosearia-valuenowis the whole percentage, which is also the fill's width:percent(value, max)rounds, stays within 0 to 100 and gives 0 for amaxof zero or less. The cap is square by default, clipped by the track (the Assignments list, item analysis);cap="round"is the Dashboard's.thresholdTone(percent)is danger below 40, warning below 65, else success. It is built of spans, so it can sit in a link, and the caller gives it its width. -
T-R4.3b:
Metertakes{ label, valueText, max, parts: readonly { key, value, tone: "accent" | "info" | "warning" | "danger" }[], className? }. It is a meter named bylabel;aria-valuenowis the sum of the parts, never more thanmax, andaria-valuetextisvalueText. A part's width is its share ofmaxto two decimal places (shares), and a part that would run past the end of the track is cut there. A part that is negative or not a number counts as 0 in the value and in the fill alike; amaxthat is not a number is not guarded. -
T-R4.3b:
BarCharttakes{ caption, columns: readonly [string, string], data: readonly { key, label, value, title }[], labelEvery? (1), className? }. Bars and labels arearia-hiddenand each column keeps itstitle. The text alternative is a<table>(the caption, two column headers, a row per item) inside ansr-onlybox: a table ignores a 1px height, and bare it would add its own to the page's scroll. The chart's root isrelative: the box is absolutely positioned, and with nothing positioned above it its containing block is the page, so it would not scroll with<main>and would lengthen the page instead (T-R4.1a met the same thing in the table's menu header). Heights are whole percentages of the largest value (barHeights: all 0 when nothing is above 0), and the last bar is the accent.labelEvery2 draws indexes 1, 3, 5 (labelShown) and an undrawn label keeps its slot. Heights transition for 200ms with native ease and no transition under reduced motion. No chart library. -
T-R4.3b:
IconTiletakes{ icon, size?: 28 | 30 | 32 | 34 | 36 | 40 | 42 | 44 (32), tone?: "neutral" | "info" | "success" | "warning" | "danger" | "accent", className? }and isaria-hidden. A size carries the deck's radius and icon size. The icon's size is set from the square ([&>svg]:size-*), so a caller restates it, or the radius, inclassName; the bell's muted row passestext-muted-fg. -
T-R4.3b:
Callouttakes{ tone?: "neutral" | "info" | "warning" | "danger" | "success", size?: "sm" | "md" | "lg", icon?, lead?, children, actions?, onDismiss?, announce?, className? }.lg, the default, is padding 12/14, radius 10, gap 10, 13px on 1.55 and a 16px icon;mdis 10/12, radius 8, gap 8, 13px on 1.5, 15px;smis 8/10, radius 8, gap 8, 12.5px on 1.5, 14px. It has no role unlessannounce(role="alert").actionsare items of the callout's own row, which then wraps, with the text atflex: 1 1 280px;nullandfalsecount as no actions, so a screen may writeactions={can && <Button />}.onDismissadds the 24px button, and with both the text keeps the 280px basis (the deck draws no callout with both). -
T-R4.3b: what a screen passes to
Callout. Line 1690 (T-R4.36):icon={ShieldCheck},leadandclassName="gap-3 [&>svg]:mt-px [&>svg]:size-[1.0625rem]". Line 1978 (T-R4.30):tone="warning",icon={PencilLine},lead, the two buttons asactionsandclassName="text-ui items-center gap-3 rounded-xl leading-normal [&>svg]:mt-0 [&>svg]:size-[1.0625rem]". Line 2046's muted icon:[&>svg]:size-[0.9375rem] [&>svg]:text-muted-fg.classNameis merged last and the icon's size, colour and margin are set from the root, so each is one class. -
T-R4.3b: the callout's icon sits on the first line of the text (DG-134). Lines 1690 and
1708 set no
align-items, so the deck's icon-font glyph stretches and lands at the middle of a text that wraps. The deck's other notes with the same 2px nudge sayflex-start, and that is what is built; a screen that wants the middle passesitems-center. -
T-R4.3b:
LockNoticeis not dashed.{ reason, label? }is deck line 360's inline marker: a 13px lock and "Đã khoá" / "Locked", 12px, muted, on one line. The Teacher deck draws no dashed lock: the dashed note is the Student result page's and stays there. The reason is thetitleand a visually hidden sentence after the word, and the marker takes no tab stop. The marker isrelative, as the chart's root is, so the hidden sentence scrolls with it. -
T-R4.3b:
EventListtakes{ label, items: readonly { key, tone: "info" | "danger" | "success" | "warning" | "neutral", text: ReactNode, time }[], className? }: an<ol>named bylabel. The dot isaria-hidden(neutralis--border), no line joins the dots, the last item keeps its 14px, and an empty list renders nothing. -
T-R4.3b: not built. Loading, empty and error states (the screens'), the grading card's 3px
strip (T-R4.28 passes a class), the Dashboard's Today and activity lists and the attempt sheet's
score tiles (their screens' markup), and a hover on the dismiss button (the deck draws none).
The two keys are
display.lockedanddisplay.dismiss, in a namespace beforemonitor. -
T-R4.3b: independently compared in a browser. QA covered 80 distinct non-easing cases
and 240 fixture states at 360/768/1024/1280/1440 in both themes, including sidebar-derived
widths and the 639/640px StatStrip boundary. The four original native-easing failures were
retained and pass after the two scoped corrections on
46b81552; chart intermediate and settled frames, 150/200ms timing, 1600ms pulse pause/resume and visible focus were measured. The narrow retest first passed 19/20; diagnostics passed 3/6 and 5/6. Failed strict zero-all-effects checks record a single permitted 0.01ms global color transition while the reduced dot remains opaque with animation-name none and no continuous pulse effect. Its exact trigger is unresolved; no failure was relabelled a pass or predicate relaxed. These static-title composites have no deck marquee trigger. Shared CSS and LiveDot stayed unchanged. Onlystat-stripand the two charts change with width. -
T-R4.3c:
SplitPanetakeslabel,first,second,unit,defaultSize,min,max, optionalminSecond,step,storageKey,handle,splitand the root/first/second class names. It adds no card styling and imports no legacy column or width-store code. -
T-R4.3c: pixels and percentages differ. Pixels default to 16px key steps and use CSS
clampso the second pane retains its minimum as the row shrinks without an observer; interaction ceilings measure the row less the grip's 14px andminSecond. Percentages default to 2-point steps and use the pointer's absolute position within the measured row. -
T-R4.3c: both pane wrappers retain their identity. A caller stacks by passing
splitfalse and its own gap, never by unmounting a pane. T-R4.31a must use both halves of the builder's rule:useMediaQuery("(min-width: 768px)") && useContentWidthAtLeast(594). - T-R4.3c: both handles are focusable vertical separators with current/min/max values, arrow keys, Home/End and double-click reset; the grip gains these beyond the deck (DG-36). The 14px gutter holds a sticky 4×40px grip; the overlapping line is 9px with -5/-4px margins. Grip background feedback uses 150ms native ease and is static under reduced motion; the line and waveform states are instantaneous in normal motion; native comparison is recorded below.
- T-R4.3c: storage is read once on mount, finite values clamped, absent/invalid values defaulted, and all access guarded. A successful release writes once, keys write each size, and reset removes the key. Cancel, capture loss, stacking and unmount restore capture and the body's prior cursor/selection; canceled gestures keep their local size without writing.
-
T-R4.3c:
Waveformtakesseed, optionalprogressandclassName; 28 decorative bars use the deck's numeric seed formula or stable 32-bit FNV-1a asset-id hash. Progress clamps to 0–1 and rounds the foreground bar count; the other bars use--ring. Default height is 44px, with caller height overrides, no role, label, tab stop or audio integration. -
T-R4.3c: scope and strings. No horizontal split, Enter collapse or audio-derived bars
are built.
splitPane.hintis inserted aftertimelinein both locales. The four harness cases show the grip, stacked panes, line and numeric waveform rows; playback is simulated. A fifth state fixture preserves two uncontrolled drafts across stacking and dragging. - T-R4.3c: independently compared in a browser. Chromium measured twenty width/theme/ sidebar references, genuine stored900 focus/first-key/reset, captured drag cleanup and stable drafts. Native grip feedback is 150ms ease with intermediate colours and no reduced transition; line focus has only the global 0.01ms reduced feedback before settling static. The 28 bars match the pinned heights and flex formula, with eleven active bars and instant toggles. QA records 53 executions, 43 passes and ten preserved scratch-probe failures, with no product defect, skip or automatic retry. Fixture frame and card widths are comparison context; future builder/review screens and passive continuous ARIA are not certified. No title marquee or continuous waveform animation belongs to this scope. Done when:
-
KpiTile(a link when it has a destination, a plain card without one: icon, label, value, hint, action; optional R1LiveDot),StatStrip,ProgressBar(6/8px; accent or threshold tone) andMeterexist -
BarChart: tokens only, the latest bar in--accent-c, atitleper bar, day labels, a visually hidden table; no chart library -
IconTile,CalloutandLockNotice(the deck's inline lock marker; the Teacher deck draws no dashed lock), and a genericEventList(dots by tone, text, time; the nameTimelineisfeatures/integrity/components/Timeline.tsx) exist -
SplitPane:role="separator"witharia-valuenow/min/max, pointer drag, arrow keys, double-click to reset, clamping, and alocalStoragekey read and written inside try/catch -
Waveform: 28 decorative bars seeded from an asset id,aria-hidden -
useIdlePolling(ms)returns arefetchIntervalonly while the tab is visible and the user has given pointer or keyboard input, or come back to the tab, within the last ten minutes; it resumes on input or visibility, anduseRefetchOnResumerefetches once at that moment: the query client does not refetch on focus, so without it a returning teacher reads data up to ten minutes old for one more interval.Monitorand the integrityTimelineswitch to it (PR-7). The three import pages' polls switch in T-R4.36 to T-R4.38 -
useContentWidthAtLeast(px)measures the element the layout registers (its border box minus its horizontal padding) when it is registered, then on everyResizeObservercallback, throughuseSyncExternalStore, so a consumer re-renders only when its threshold is crossed; it answers true when none is registered - Unit tests: split pane keyboard and clamp, storage failure tolerated, idle stop after ten minutes and resume on input (fake timers), chart text alternative present
Depends on: T-R4.2a, T-R4.3a, T-R4.5b
Touches: web/src/layouts/TeacherLayout.tsx (new), web/src/layouts/TeacherShell.tsx (new), web/src/layouts/TeacherSkeleton.tsx (new), web/src/layouts/shell/{Sidebar,TopBar,Breadcrumbs,WorkspaceSwitcher,PageHead}.tsx (new), web/src/layouts/teacherNav.ts (new), web/src/features/auth/AccountMenu.tsx, web/src/app/boot/handoff.ts, web/src/app/router.tsx, web/tests/units/shell/ (new), web/tests/units/contract/teacher-nav.test.ts (new), web/tests/integration/router-chunks.test.ts (pattern extended), web/tests/units/student/shell.test.tsx
Size: M
As built:
-
Touched beyond the list:
app/boot/BootSplash.tsx(one argument),layouts/shell/{handle,crumbs,sidebarState,useNavCounts}.ts(new),tests/units/app/{teacher-routes,teacher-landing}.test.*andtests/support/deck-harness/cases/r4-04.tsx.AccountMenu.tsxis three inner components now, to stay under the linter's complexity limit; its two earlier forms render as before, andshell/account-foot.test.tsxpins the student's byte for byte.student/shell.test.tsxneeded no edit: its/teacherline passes no handle and stays true. No route gained ahandle, so every teacher page still renders inAdminLayout. The Done-when lines below were corrected on the release branch while this was built (the whole trail, the any-of on Grading, both counts hidden at zero, the canary's pattern); the Touches line is the one still short. -
The route contract (
layouts/shell/handle.ts). A rebuilt route declareshandle: { crumb, width?, sidebar? } satisfies TeacherHandle.crumbis the WHOLE trail, ancestors first, each{ key, to }; the last entry is the page itself and has noto. The tree is flat, so a trail cannot be collected from matched parents.widthis 1320 (the default), 1080, 860, 720 or"full". Only the LEAF match's handle is read, by the switch, the layout and the splash: a handle on theimportsgate moves nothing. Changed 2026-10-12 (DG-205, the design team's approved page-width design):widthtakes only"full", the import review's. Every other route renders in one frame, the full width up to--page-max(1600px) and centred beyond it, and no page sets its own maximum (feature/page-frame-width, spec v0.92 §12). -
useCrumbs(tail)(layouts/shell/crumbs.ts) names a record: a tail of n{ label, to? }replaces the last n crumbs while the page is mounted. Null, undefined or an empty tail makes no registration, so an unnamedPageHeaddoes not erase another caller's record title. A named caller clears its registration when it becomes empty or unmounts; the handle's labels then show while a record loads. The trail never changes length; a longer tail gives its last entries. It compares by value and does nothing outside the shell. The last crumb is the document title, "{page} · Quizzivy"; leaving the shell restores the title found at mount. -
PageHead(layouts/shell/PageHead.tsx) is{ title, description?, actions?, back?, status?, crumb?, className?, children? }: onetext-h1h1, the 14px muted description, actions that wrap.backandstatusmake the detail form (the link above the title, the pill beside it, a left column offlex: 1 1 280px);childrenfollow the title.crumbcallsuseCrumbswith the title: a page uses that or its ownuseCrumbs, not both.classNamerestates a class of the row. -
The shell owns the page frame.
<main>is the scroller and the registered content element: padding 24/28/40, and 16/14/32 below 768, by CSS; inside it one wrapper, centred, with the handle's max width. It is registered in its ref callback and the outlet renders only after, so a page never renders a "wide" frame. On a change of the leaf route's id it returns to the top and takes focus unless focus is already inside it; a parameter change does neither. A page sets no padding and no max width. -
Sidebar state (
layouts/shell/sidebarState.ts).localStorage["quizzivy.sidebar"]holds"collapsed"or"expanded"; absent or unreadable is expanded, and a write storage refused is kept for the page's life. It never collapses by itself.handle.sidebar: "collapsed"(T-R4.38) starts a route collapsed for the visit: the toggle works, the storage is neither read nor written, and leaving returns to the stored state. The deck collapses on entry and never restores; the plan's rule is built. -
Below 768 the sidebar is a drawer on Radix's dialog primitive (272px,
--z-sheet), closed by default, with labels and badges whatever is stored. It closes on every navigation, the back button included, and does not reopen on a return to the entry it was opened at; focus then ends on<main>, and on the toggle after Esc.#teacher-sidebarcarriesdata-state:expanded,collapsedoropen. -
Nav and gating (
layouts/teacherNav.ts). Eight destinations in the deck's order; a later module is absent. Each carriesoperationandrequires, thex-permissionof that list, andcontract/teacher-nav.test.tscompares the two. Six need the workspace, Studentspeople.students.read. Grading opens today's page, so it carrieslistAttemptsand the any-of[teaching.grading, teaching.attempts.intervene]; T-R4.28 moves it to T-R4.14's queue andteaching.grading, and the contract test makes it.activeNavIdlights by a prefix at a/, with the import pages under Tests and an attempt's page under Assignments; Settings lights nothing. -
Counts (
layouts/shell/useNavCounts.ts) readgetDashboardunder["admin-dashboard"], so today's six invalidations refresh the nav, every 60 s throughuseIdlePollingand once on return. Null while loading and after a failed request: no badge and no error. Zero draws no badge for either figure, as in the deck's script. T-R4.23 changes this one file toGET /teacher/summary. For an Admin the Assignments badge counts every teacher's open assignments until T-R4.19 narrowsgetDashboardto the caller's own, while the list it opens shows the Admin's own rows. -
Top bar (
layouts/shell/TopBar.tsx): the toggle ("Collapse sidebar", "Expand sidebar", "Open menu" below 768), the trail (earlier crumbs are links, hidden below 768 by CSS), the search button (34, 200 and 260px by CSS at 768 and 1100) and the theme toggle. No bell: T-R4.45b adds it after the theme toggle, and today'sNotificationsButtonis not in the new shell. The palette is today's, mounted beside the deck root so it keeps its look until T-R4.6. -
Account and workspace.
AccountMenutakessidebar?: "expanded" | "collapsed"on itsdeckform: the foot row (a square avatar, the name, the role line "Admin" with the admin workspace and "Teacher" otherwise) with the student's menu opening above it; no "Admin console" row.WorkspaceSwitcheris a label for everyone, becauseSidebarpasses no destination; its button and "Switch workspace" menu are built and tested for a user with the admin workspace and a destination (R5). The mark is 18px high as drawn, under the kit's floor (DG-123), so the kit's development warning is expected. -
Skeleton.
TeacherLayout.tsxregistersTeacherSkeleton; its sidebar column shows from 768 and is 60px, tile and icon blocks only, under a stored collapse (the Splash deck draws 248 only).skeletonFor(pathname, handle?)draws the teacher frame only for a teacher handle, andBootSplashpasses the leaf route's, so a page not yet rebuilt keeps fading straight onto itself. -
A unit test of a page in the shell uses
renderShellandserveDashboardfromtests/units/shell/support.tsx(onedashboardBodyfor every test) and callscontentWidth(px)first: jsdom's<main>measures 0, so without it every threshold is unmet.viewport(800)andviewport(1050)pin the states between thresholds,viewport(360)the drawer. Outside the shelluseCrumbsis inert and the width answers "wide". -
The chunk canary.
ADMINgainedlayouts/Teacher(Layout|Shell|Skeleton); the page pattern was already T-R4.5b's, andlayouts/shell/is not named, because the splash loadshandle.tswith the entry andcontentWidth.tsis shared. The new case walks the static imports of the entry, of the chunk holdinglayouts/TeacherShelland of every chunk holding a route module, and finds no@dnd-kitmodule. On the unmodified tree, withAdminLayoutas the shell, it was green with those roots: the library sits in theOutlineTreechunk, behind the builder's dynamic import. -
Compared independently in the browser. The production-build harness exercised the
actual
TeacherLayoutat 360, 768, 1024, 1280 and 1440 in light and dark, expanded/collapsed or phone drawer states. The 93 geometry/function cases covered navigation, keyboard trap, focus restoration, list/detail headers, long Vietnamese and Splash; 10 production smoke/theme cases passed. Ten targeted animation retests approved0b8a5e7d: 200ms native ease-out for sidebar/drawer, 150ms menu motion, 1400ms skeleton shimmer paused on hover/focus, and static reduced motion. QA recorded the decided DG-122/DG-123/DG-22 departures and the deferred bell. Current page routes still useAdminLayout; no route was flipped in this task. T-R4.48 points the router atTeacherLayoutand removes the switch, the handle condition inhandoff.tswithBootSplash's argument, andAccountMenu's form withoutdeck. T-R4.50 addsquizzivy.sidebarto 70 §6's storage list. Undrawn states built from the deck's parts: a collapsed item keeps its label astitleand name, a count says what it counts, a long crumb ends in an ellipsis, a route with no crumb is titled "Quizzivy". Later page tasks must verify their actual route inside this independently compared shell. Done when: - Sidebar 248px expanded, 60px collapsed (icons only, label in
title, badges hidden), and a 272px drawer below 768 with an overlay that closes on every navigation, traps focus and closes on Esc. It never collapses by itself; the collapsed state persists inlocalStorage['quizzivy.sidebar']inside try/catch - A route can ask for a transient collapse, which leaves the stored state alone: the review workspace (T-R4.38) collapses the sidebar on entry, and leaving restores the previous state
- Nav, in R4: (no label) Dashboard; Teaching Assignments (muted count of live
assignments), Grading (lime count of answers to grade), Classes, Students; Content
Tests, Question bank, Media. Both counts are hidden at 0, and for an Admin the Assignments
count covers every teacher's until T-R4.19. Later modules are absent from
teacherNav.ts, not hidden by a flag - Each item carries the
x-permissionof the list operation it opens;teacher-nav.test.tsreadsapi/openapi.yamland fails when they differ. A user sees only the items they hold (DG-53). Six of the eight declare the pseudo-keyworkspace.teacher(checked withhasWorkspace), soteacherNav.tstakes a pseudo-key, a key or an any-of list; only Students (people.students.read) and Grading (any ofteaching.gradingandteaching.attempts.intervene, until T-R4.28) filter - Active mapping: assignment detail, wizard and attempt review light Assignments; test detail, builder and every import route light Tests; class detail lights Classes; the question editor and question groups light Question bank; Settings lights nothing
- Top bar 56px: sidebar toggle, divider, breadcrumbs from
handle.crumb, which holds the whole trail becauseteacherTreeis flat and only the leaf match's handle counts (only the last on phones; earlier crumbs are links), search button (260px, 200px below 1100, icon-only on phones, "⌘ K" or "Ctrl K" by platform), theme toggle; the bell slot is filled by T-R4.45 - Main content max 1320px (1080 for the wizard and Settings, 720 for import processing, 860 for Upload and setup, and full width at a fixed height for the review workspace), padding 24/28/40, on phones 16/14/32
-
PageHead(layouts/shell/PageHead.tsx) is the new shell's page header: h1, description and actions, with the crumb and document-title setter.components/shared/PageHeader.tsxportals into the old shell's slots and 22 files import it, so it stays until T-R4.48 - The shell switch (
layouts/TeacherShell.tsx): a route that declareshandle.crumbrenders inTeacherLayout; a route without it keepsAdminLayout, forced light, with its slots, soTeacherLayoutcarries no legacy code. This task merges with no route switched; each screen task switches its own route, and T-R4.48 deletes the switch - Account menu at the sidebar foot (avatar with DG-22 initials; the photo arrives with T-R4.43; name; a role line derived from workspaces): header with name and email; Settings, Dark mode / Light mode, Sign out. No "Admin console" row until R5
- Workspace switcher: the 32px mark tile, "Quizzivy" / "Teacher workspace"; its popover is
built behind
workspace.adminand has no row to show in R4, so the tile is static - Nav counts come from one hook,
useNavCounts, which readsgetDashboarduntil T-R4.23 switches it toGET /teacher/summary, polled throughuseIdlePollingevery 60 s; the document title is "{last crumb} · Quizzivy"; the teacher skeleton is registered with R1's splash hand-off - No prototype chrome: no floating screen switcher, no "Coming next" page (rule 5 hides
unshipped modules instead), and no designer props such as
frame,accentorstartTheme; the sidebar's state comes fromlocalStorage, the theme fromlib/theme.ts; T-R4.7's web half makes it the server preference -
router-chunks.test.ts: the teacher pattern gainslayouts/Teacher(Layout|Shell|Skeleton)(T-R4.5b addedfeatures/[^/]+/pages/teacher/); nothing is removed, every existing assertion is kept, and a new one keeps@dnd-kitinside the builder's graph - Unit tests: nav filtered for a teacher, a custom role without
people.students.read, one without bothteaching.gradingandteaching.attempts.intervene, and an admin; drawer at 360; collapse persists; active mapping for a nested route - Keys vi first, en from the deck, listed in the PR
Reopened in D4 (74d-d4-deck-update.md): T-D4.20 added the collapsed sidebar's 1px separator
between nav groups, in both consoles; the expanded sidebar and the drawer are as this task built
them.
Part 1 of 6 of R4 — Teacher workspace (v0.10.0) · Next
Generated from docs/plan/74-r4.md. Edit that file in the repository; changes made on this page are overwritten.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs