Skip to content

Plan 74 R4 Part 1

github-actions[bot] edited this page Oct 14, 2026 · 1 revision

Part 1 of 6 of R4 — Teacher workspace (v0.10.0) · Next

R4 — Teacher workspace (v0.10.0)

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-r4 holds 00080 to 00091, so a v0.9.1 from develop cannot 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/summary and GET /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_KEY on every start, while an operator holds it only for R2's rekey procedure, supplied to cmd/maintenance from 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's RateLimits() 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 bump tests.updated_at through its trigger, and a bank edit to a question the draft uses bumps nothing on tests. 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.all today, copies from R7), keep the old asset, which leaves the library but keeps serving them. publish_snapshot_test.go is 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.all still reads and edits any teacher's content by id and powers the Admin console's cross-teacher views. Students and Classes keep scope.all lists 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.skills column. 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_reference holders 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.admin gate 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.Version becomes rules-v3 and PipelineVersion word-pipeline-v3; a run queued under v2 at the deploy fails PIPELINE_RETIRED and 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-pressed buttons 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 develop already (web/src/components/shared/content/editor/), behind VITE_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, so VITE_RICH_OPTION_EDITOR stays 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 QuestionContent excludes 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.

T-R4.1 — Console data composites

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 tests web/tests/units/shared/{data-table.test.tsx,table-layout.test.ts}. No locale key. BulkSelection.tsx, useBulkSelection.ts and RowMenu.tsx are composed as they are; ListState.tsx is not used by the table (the empty line is its own, and loading and error stay with the screen's QueryStates). None of the four was edited.
  • T-R4.1a: the props. DataTable<T extends { id: string }> takes label, columns, rows, rowSize, dense, shown, rowHref or onOpen, selection with rowName, menu, menuTrack ("44px"), padX (16), card, cardLayout ("stacked" or "joined"), framed (true), empty and footer. 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 without rowName. Every optional prop also takes undefined, so a screen writes menu={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, a ReadonlySet of 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, and columns is a module constant or a memo.
  • T-R4.1a: the shown prop replaces showFrom and the table's own band. Class members computes it from useContentBand([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's left = tw × 2/3 arithmetic 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 auto or max-content would 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 declares minmax(<deck px>, <n>fr). Cells carry min-width: 0. A cell's sized text (13px, 12.5px) is a block with its own leading-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. dense takes 16px off a height or a minimum and halves padY; 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 cell draws is wrapped in a Link (rowHref) or a <button> (onOpen): that is the tab stop, its keys are the element's own, and the first cell names the row through aria-labelledby. A click elsewhere on the row calls .click() on that element, so there is one path and a table with onOpen needs 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's cell holds no control; one goes in the column's aside, 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 carries aria-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: card is 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 without card does too. "stacked" is Assignments' separate cards (the deck's button, line-height normal); "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 own rowHref or onOpen. 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} and align: "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 empty of null, false or "" draws no line, so a screen may write empty={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 bare fr track would size to its content under this rule: write minmax(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 table primitive gains, under in-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; --muted on 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 footer and puts the bulk bar above the table. T-R4.25 adds the row tone: one more entry in BodyRow's cn(). The checkbox and the row menu are T-R4.2a's, merged before this; the table does not rely on RowMenu stopping its own clicks. Every row's menu button is still named "Thao tác": naming it after its row needs a key and RowMenu's label, 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 with sticky left-0 stays in place; the table primitive 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 --muted on hover. The states the deck does not draw were looked at in Vietnamese at 320, 360, 768 and 1280 (the cases long-vi, empty, dense and primitive): 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 keep components/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 it components/ui/pagination.tsx, pageWindow and pageCountOf in lib/pagination.ts, the pagination.* keys and the Pager cases of tests/units/ui/pager.test.tsx; that file's usePage cases stay.
  • T-R4.1b: the props. Pager takes page, pageSize and total as the API answered them, noun(count), sizes (PAGE_SIZES) and frame ("card"). noun returns 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. pageRange in lib/pagination.ts does the arithmetic: at least one page, the page clamped to the pages there are, both positions 0 for an empty list.
  • T-R4.1b: usePageSize is a second hook in usePage.ts; usePage and pageHref did not change. PAGE_SIZES is 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, removes page in the same update and keeps the rest. A screen calls the hook, sends the size as limit and puts it in its query key, not in usePage's filters.
  • 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, is aria-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 Select primitive, size="sm", restyled to the deck's control, with the primitive's deck classes restated under in-data-[scale=deck]: (the card fill, 13px). It is named by the visible label through aria-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's footer adds nothing. "card", the default: 10px 16px, a top border and sticky 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 own useMediaQuery), 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.tsx pins its HTML. On one it is the deck's inverted bar, in plain classes kept in constants of their own. The file writes no in-data-[scale=deck]: class, which would sit in the old bar's markup too. The old bar's buttons are the Button primitive and carry that primitive's own deck classes, as they did.
  • T-R4.1b: BulkAction gains icon and destructive (true when absent; false gives a plain confirmation, the deck's "Reset passwords"). BulkBarButton is the bar's button for a control a page passes as children, such as an action that opens its own form; the built-in actions render through it. On a deck surface the count is bulkBar.selected ("Đã chọn 2"), clear is a 30px × button named by common.clearSelection, and className lands 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 raw rgba(127,127,127,.35). The bar sets --focus to --primary-fg, because the global ring's --muted-fg does not show on --primary. Its buttons take hover: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: BulkSelectAll no longer passes the six indeterminate: 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 are pager and bulkBar, after pagination. useBulkSelection.ts and ListState.tsx were not edited. The cases in deck-harness/cases/r4-01b.tsx are pager-roster, pager-bank, pager-one-page, pager-plain, pager-joined, pager-wide-table, bulk-bar and bulk-bar-members, and for the states the deck does not draw pager-empty and bulk-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-plain matches 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, optional title, readonly { value, label } options, readonly selected and onChange(next), plus optional clearable, disabled, align and menuClassName. 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.active says 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-60 on 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 muted facet.none label and keeps the trigger enabled.
  • T-R4.1c: CardGrid takes label, readonly items, stable itemKey, numeric min, optional gap: 10 | 12 (12 by default), className and a card-rendering child. Its named ul role=list has one keyed, min-width-zero single-cell grid li per item and renders nothing when empty. A file-local no-redundant-roles lint directive preserves that explicit role with marker-free styling. Tracks are repeat(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.active and facet.none sit 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.tsx supplies 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:
  • DataTable is a CSS grid with ARIA table roles. Each column declares its track, a minimum and an optional showFrom content 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 table primitive (web/src/components/ui/table.tsx) gets its deck geometry here, alongside DataTable, 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)
  • Pager reads "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; usePage gains size (?size=, sent to the API as limit); 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) or plain (under separate cards: Assignments below 768, the import history)
  • BulkActions restyled 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; CardGrid takes the deck's minmax width. There is no FilterChips: 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

T-R4.2 — Console form and overlay composites

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 its deck form 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. DropdownMenuContent is portalled and carries data-scale itself, so its geometry is written data-[scale=deck]: (card, radius 10, the float shadow, --z-popover, 5px of padding, no width, and a max-height of 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 a collisionPadding of 8, the margin the deck's openMenu keeps 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 written in-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 what sm and xs set 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) and DropdownMenuItemText. 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-50 and 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.tsx pins each restated class.
  • RowMenu takes title and label (the trigger's name, common.actions by default). On a deck surface the trigger is 30px with a 16px icon and the menu 220px; another width is passed as data-[scale=deck]:w-*.
  • RowMenu stops 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.
  • Segmented exports SegmentedOption (icon, count) and takes size sm (28px, 12.5px) and xs (26px, 12px, radius 6 in a radius 8 track) and scroll. 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, no scroll and 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 sm row and a scrolling track of Segmented, 15px for xs and the meta figure, 18px for a tab. The default and large rows without scroll keep 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), never scrollIntoView, 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 --ring colour on --card, an 11px tick. The bar of the mixed state is in the primitive, the only classes added off a deck surface; BulkSelection still 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> under DeckScale, in a container of ?width=<px> when given. A task adds cases/<task>.tsx, which exports cases and 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, xs and 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 /app at 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 for max-height (none, now 480px at an 800px window) and overflow-y on 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 in DropdownMenuItemText ends 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.
  • DialogShell exposes 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; FormOption carries 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 disabled dropped focus to <body>, so a failed submit (the photo dialog, Sign out other devices, Extend, Duplicate, Close early) left a keyboard user nowhere. While pending the submit takes aria-disabled and 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, turn readOnly, 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. disabled without pending still disables natively, and pending wins 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. SegField now honours disabled itself, since the fieldset no longer disables while pending; the fieldset carries no aria-busy (the submit's aria-disabled says the form is busy). jsdom does not blur a focused element that turns disabled, so in form-dialog.test.tsx and confirm-dialog.test.tsx the assertions that discriminate are the submit's and the text fields' not.toBeDisabled() and readonly, and the hand-off to the submit; the browser behaviour is pinned by media-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.
  • ConfirmDialog preserves confirming and notice callers, adds an optional icon and danger tile, and retains the confirming Button's data-variant.
  • Segmented adds optional fill; all legacy defaults remain unchanged.
  • FileDrop chooses or removes the first file and permits same-file reselection; file validation and upload are caller responsibilities.
  • useFileDrop moves to hooks/ 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.
  • DateTimeField keeps joined inherited controls off deck and a single picker on deck; T-R5.30 removes the inherited branch with the other useDeckScale branches (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.
  • formDialog and datePicker namespaces 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, withChip and the useChipEntry hook that ChipInput and TagCombobox share), form/stepperValue.ts, form/tagOptions.ts, lib/localEscape.ts and the nested sheet Escape regression; ChipInput.tsx also exports ChipBox and CHIP_INPUT, which TagCombobox composes. Ten keys in a new controls namespace, before toast: unsaved, discard, decrease, increase, removeTag, addTag, tagSuggestions, createTag, newTag, tagKeys. No token, no dependency, nothing under components/ui/, features/ or layouts/. Everything here is plain classes, the same on and off a deck surface; a primitive's deck class is restated with in-data-[scale=deck]: where it changes.
  • Sheet { open, onOpenChange, title, subtitle?, leading?, width?: 320 | 380 | 420 (420), footer?, children } takes Radix's dialog primitive (Dialog from radix-ui) and imports nothing from components/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 behind aria-hidden and sets no aria-modal, and it leaves aria-describedby off 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. title is the h2 that names the dialog (15px, 600), subtitle its description, the close button is common.close. Overlay and panel are at --z-sheet, under --z-dialog and --z-popover. It slides in from the right over 200ms and is still under prefers-reduced-motion; the deck draws no motion. It is portalled, so it carries data-scale itself when it is opened from a deck surface.
  • Focus returns as DeckDialog returns 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 in StudentSettingsSections.tsx was the model and is not lifted. Nothing while not dirty; error takes the sentence's place as an alert; Save changes is type="submit" for the section's own form unless onSave is given; both buttons are off while saving. It wraps.
  • SettingsLayout { label, active, sections: readonly { id, label, icon, to, content }[] }. The navigation is links, so a section is a URL, with aria-current="page" on the current one. Every section's content is rendered in its own panel and hidden with the hidden attribute 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 with revealWithin; 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; onChange is 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 (in form/RadioCard.tsx) { label, value, onChange, options: readonly { value, label, hint?, disabled? }[], className? } is Radix's RadioGroup: 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. className replaces 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. same decides what is already there. The default, sameIgnoringCase, is the deck's lower-case equality, so accents count; TagCombobox passes sameFolded; 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 from form/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. tagOptions lists the suggestions that contain the draft under fold, 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 under fold. 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 TagsField for TagCombobox: TagsField.tsx and QuestionEditor.tsx are not touched here. Today's field offers recent tags before anything is typed; the deck's list opens only on a draft, and TagCombobox follows the deck. The data (the recent tags, listQuestions' tags, the counts for meta) 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. CopyButton gained label, onFailed and className and 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 --input border (DG-31), where the deck draws --border. A framed field's border turns --ring while 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-dialog and the tooltip at --z-popover, both open under a sheet (z-50 against 70). A focused input marks data-local-escape="true" only while the tag list is open or the stepper has a draft (including empty). Sheet passes preventLocalEscapeDismissal to Radix's native onEscapeKeyDown: the first Escape closes the list or restores the value and keeps focus; the next closes the sheet. T-R4.2b's FormDialog must pass the same helper to DialogContent's onEscapeKeyDown, so nesting follows the same rule without document listeners. The harness cases are cases/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-pressed buttons, 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)
  • FormDialog renders 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; submit aria-disabled and at half opacity until valid; a danger variant with a danger icon tile. ConfirmDialog is a FormDialog with no fields and its callers are unchanged. Its 23 callers render outside deck scale until rebuilt, so FormDialog must look right without data-scale, and every case of confirm-dialog.test.tsx stays
  • 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, fold for tags, exact equality for accepted answers under Match case) and TagCombobox (suggestions, bold match, ↑↓ Enter Esc) exist
  • FileDrop shows "Choose a file or drop it here", a limits line and remove; drag state per the deck. It is the FormDialog engine's file field, so both, with the useFileDrop move, are T-R4.2b
  • DirtyBar (sticky, Discard + Save changes). Sheet is built on the Radix dialog primitive that components/ui/dialog.tsx wraps and imports nothing from that file; it is pinned to the right edge (no ui/sheet.tsx exists) and adds the header, close and scrolling body; widths are the deck's 380, 420 and 320, full width on phones. CopyField composes components/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 and DirtyBar are new; R3 built neither. The unsaved-changes bar inside StudentSettingsSections.tsx is the model and is not lifted
  • RowMenu restyled: 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 the dropdown-menu primitive's under deck scale; flipping and roving focus are Radix's. The menu's content is portalled and carries data-scale itself, so its own geometry is data-[scale=deck]: and only what is inside it is in-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

T-R4.3 — Console display composites and idle-aware polling

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.ts and three test files: web/tests/units/shared/idle-polling.test.tsx and, in the new folder web/tests/units/shell/ that T-R4.4 fills, content-width.test.tsx and viewport-helper.test.ts. monitor.test.tsx and the integrity timeline.test.tsx gained cases.
  • T-R4.3a: useIdlePolling(ms: number, enabled = true): number | false is a query's refetchInterval: ms while enabled, the document is visible and the user is active, else false. It is called on the line above useQuery, and refetchIntervalInBackground: false stays.
  • T-R4.3a: activity is a pointerdown, pointermove, keydown, wheel or touchstart on window within IDLE_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 false until 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): void refetches 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, because enabled changed, or on an input from a user who never left. The query client has refetchOnWindowFocus: 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.polling sentence is untrue for an idle teacher. AssignmentDetailPage still 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 from live && refetchInterval === false, and changes the two tests that wait on it (web/tests/units/assignments/detail.test.tsx and web/tests/e2e/author-and-publish.live.spec.ts) and Monitor's doc comment with it.
  • T-R4.3a: Monitor and the integrity Timeline switched, four lines each: the import, const refetchInterval = useIdlePolling(POLL_MS, live), the shorthand in the query, and useRefetchOnResume(<query>.refetch, live). The import pages' polls (imports/availability.ts and the three pages) are left to T-R4.36 to T-R4.38, and the student's AssignmentIntroPage keeps 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's tw. 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.ts exports three things. registerContentElement(element | null) is the ref of the shell's <main> (one element at a time, null unregisters; measured at once and on every ResizeObserver callback, observed with box: "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. Without ResizeObserver the 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 calls contentWidth(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.tsx pins [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 a ResizeObserver that fires only when told, returns { resize(next) }, and takes both away when the test finishes; it is called inside a test or a beforeEach. A <main> the tree under test registers in the stand-in's place (a rendered shell) has the same width and follows resize, 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.tsx kept its four cases and changed two lines of scaffolding. Its setVisibility dispatches inside act, since a visibility change now re-renders the monitor, and its afterEach unmounts 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.tsx and the harness cases tests/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-md is 6px off a deck surface). No dependency and no token.
  • T-R4.3b: KpiTile is 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. With to the whole tile is a react-router Link, named by its label, value, hint and action, whose border takes --ring under the pointer. Without to it 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 carry group/kpi: the composed LiveDot pauses 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?: never under exactOptionalPropertyTypes).
  • T-R4.3b: the tile's line heights. The deck's tile is a <button>, so its rows have the browser's normal: the label row (13px) and the hint row (12.5px) are leading-4, 16px, and the value keeps the deck's own leading-[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: StatStrip takes items: readonly { label, value, suffix?, tone?: "default" | "danger" }[] and renders a <dl>. It is four columns when useContentWidthAtLeast(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 is text-stat on leading-normal; danger is --danger-ink on the value and the suffix stays muted. It is drawn for four items.
  • T-R4.3b: ProgressBar takes { value, max? (100), label, size?: 6 | 8, tone?: "accent" | "danger" | "warning" | "success", cap?: "square" | "round", className? }. It is a progressbar named by label whose aria-valuenow is the whole percentage, which is also the fill's width: percent(value, max) rounds, stays within 0 to 100 and gives 0 for a max of 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: Meter takes { label, valueText, max, parts: readonly { key, value, tone: "accent" | "info" | "warning" | "danger" }[], className? }. It is a meter named by label; aria-valuenow is the sum of the parts, never more than max, and aria-valuetext is valueText. A part's width is its share of max to 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; a max that is not a number is not guarded.
  • T-R4.3b: BarChart takes { caption, columns: readonly [string, string], data: readonly { key, label, value, title }[], labelEvery? (1), className? }. Bars and labels are aria-hidden and each column keeps its title. The text alternative is a <table> (the caption, two column headers, a row per item) inside an sr-only box: a table ignores a 1px height, and bare it would add its own to the page's scroll. The chart's root is relative: 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. labelEvery 2 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: IconTile takes { icon, size?: 28 | 30 | 32 | 34 | 36 | 40 | 42 | 44 (32), tone?: "neutral" | "info" | "success" | "warning" | "danger" | "accent", className? } and is aria-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, in className; the bell's muted row passes text-muted-fg.
  • T-R4.3b: Callout takes { 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; md is 10/12, radius 8, gap 8, 13px on 1.5, 15px; sm is 8/10, radius 8, gap 8, 12.5px on 1.5, 14px. It has no role unless announce (role="alert"). actions are items of the callout's own row, which then wraps, with the text at flex: 1 1 280px; null and false count as no actions, so a screen may write actions={can && <Button />}. onDismiss adds 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}, lead and className="gap-3 [&>svg]:mt-px [&>svg]:size-[1.0625rem]". Line 1978 (T-R4.30): tone="warning", icon={PencilLine}, lead, the two buttons as actions and className="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. className is 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 say flex-start, and that is what is built; a screen that wants the middle passes items-center.
  • T-R4.3b: LockNotice is 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 the title and a visually hidden sentence after the word, and the marker takes no tab stop. The marker is relative, as the chart's root is, so the hidden sentence scrolls with it.
  • T-R4.3b: EventList takes { label, items: readonly { key, tone: "info" | "danger" | "success" | "warning" | "neutral", text: ReactNode, time }[], className? }: an <ol> named by label. The dot is aria-hidden (neutral is --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.locked and display.dismiss, in a namespace before monitor.
  • 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. Only stat-strip and the two charts change with width.
  • T-R4.3c: SplitPane takes label, first, second, unit, defaultSize, min, max, optional minSecond, step, storageKey, handle, split and 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 clamp so the second pane retains its minimum as the row shrinks without an observer; interaction ceilings measure the row less the grip's 14px and minSecond. 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 split false 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: Waveform takes seed, optional progress and className; 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.hint is inserted after timeline in 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 R1 LiveDot), StatStrip, ProgressBar (6/8px; accent or threshold tone) and Meter exist
  • BarChart: tokens only, the latest bar in --accent-c, a title per bar, day labels, a visually hidden table; no chart library
  • IconTile, Callout and LockNotice (the deck's inline lock marker; the Teacher deck draws no dashed lock), and a generic EventList (dots by tone, text, time; the name Timeline is features/integrity/components/Timeline.tsx) exist
  • SplitPane: role="separator" with aria-valuenow/min/max, pointer drag, arrow keys, double-click to reset, clamping, and a localStorage key read and written inside try/catch
  • Waveform: 28 decorative bars seeded from an asset id, aria-hidden
  • useIdlePolling(ms) returns a refetchInterval only 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, and useRefetchOnResume refetches 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. Monitor and the integrity Timeline switch 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 every ResizeObserver callback, through useSyncExternalStore, 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

T-R4.4 — TeacherLayout

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.* and tests/support/deck-harness/cases/r4-04.tsx. AccountMenu.tsx is three inner components now, to stay under the linter's complexity limit; its two earlier forms render as before, and shell/account-foot.test.tsx pins the student's byte for byte. student/shell.test.tsx needed no edit: its /teacher line passes no handle and stays true. No route gained a handle, so every teacher page still renders in AdminLayout. 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 declares handle: { crumb, width?, sidebar? } satisfies TeacherHandle. crumb is the WHOLE trail, ancestors first, each { key, to }; the last entry is the page itself and has no to. The tree is flat, so a trail cannot be collected from matched parents. width is 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 the imports gate moves nothing. Changed 2026-10-12 (DG-205, the design team's approved page-width design): width takes 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 unnamed PageHead does 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? }: one text-h1 h1, the 14px muted description, actions that wrap. back and status make the detail form (the link above the title, the pill beside it, a left column of flex: 1 1 280px); children follow the title. crumb calls useCrumbs with the title: a page uses that or its own useCrumbs, not both. className restates 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-sidebar carries data-state: expanded, collapsed or open.
  • Nav and gating (layouts/teacherNav.ts). Eight destinations in the deck's order; a later module is absent. Each carries operation and requires, the x-permission of that list, and contract/teacher-nav.test.ts compares the two. Six need the workspace, Students people.students.read. Grading opens today's page, so it carries listAttempts and the any-of [teaching.grading, teaching.attempts.intervene]; T-R4.28 moves it to T-R4.14's queue and teaching.grading, and the contract test makes it. activeNavId lights 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) read getDashboard under ["admin-dashboard"], so today's six invalidations refresh the nav, every 60 s through useIdlePolling and 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 to GET /teacher/summary. For an Admin the Assignments badge counts every teacher's open assignments until T-R4.19 narrows getDashboard to 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's NotificationsButton is 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. AccountMenu takes sidebar?: "expanded" | "collapsed" on its deck form: 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. WorkspaceSwitcher is a label for everyone, because Sidebar passes 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.tsx registers TeacherSkeleton; 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, and BootSplash passes 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 renderShell and serveDashboard from tests/units/shell/support.tsx (one dashboardBody for every test) and calls contentWidth(px) first: jsdom's <main> measures 0, so without it every threshold is unmet. viewport(800) and viewport(1050) pin the states between thresholds, viewport(360) the drawer. Outside the shell useCrumbs is inert and the width answers "wide".
  • The chunk canary. ADMIN gained layouts/Teacher(Layout|Shell|Skeleton); the page pattern was already T-R4.5b's, and layouts/shell/ is not named, because the splash loads handle.ts with the entry and contentWidth.ts is shared. The new case walks the static imports of the entry, of the chunk holding layouts/TeacherShell and of every chunk holding a route module, and finds no @dnd-kit module. On the unmodified tree, with AdminLayout as the shell, it was green with those roots: the library sits in the OutlineTree chunk, behind the builder's dynamic import.
  • Compared independently in the browser. The production-build harness exercised the actual TeacherLayout at 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 approved 0b8a5e7d: 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 use AdminLayout; no route was flipped in this task. T-R4.48 points the router at TeacherLayout and removes the switch, the handle condition in handoff.ts with BootSplash's argument, and AccountMenu's form without deck. T-R4.50 adds quizzivy.sidebar to 70 §6's storage list. Undrawn states built from the deck's parts: a collapsed item keeps its label as title and 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 in localStorage['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-permission of the list operation it opens; teacher-nav.test.ts reads api/openapi.yaml and fails when they differ. A user sees only the items they hold (DG-53). Six of the eight declare the pseudo-key workspace.teacher (checked with hasWorkspace), so teacherNav.ts takes a pseudo-key, a key or an any-of list; only Students (people.students.read) and Grading (any of teaching.grading and teaching.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 because teacherTree is 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.tsx portals 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 declares handle.crumb renders in TeacherLayout; a route without it keeps AdminLayout, forced light, with its slots, so TeacherLayout carries 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.admin and has no row to show in R4, so the tile is static
  • Nav counts come from one hook, useNavCounts, which reads getDashboard until T-R4.23 switches it to GET /teacher/summary, polled through useIdlePolling every 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, accent or startTheme; the sidebar's state comes from localStorage, the theme from lib/theme.ts; T-R4.7's web half makes it the server preference
  • router-chunks.test.ts: the teacher pattern gains layouts/Teacher(Layout|Shell|Skeleton) (T-R4.5b added features/[^/]+/pages/teacher/); nothing is removed, every existing assertion is kept, and a new one keeps @dnd-kit inside the builder's graph
  • Unit tests: nav filtered for a teacher, a custom role without people.students.read, one without both teaching.grading and teaching.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.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally