feat(student-web): name a filter and keep it as a collection - #10
Open
phibkro wants to merge 32 commits into
Open
feat(student-web): name a filter and keep it as a collection#10phibkro wants to merge 32 commits into
phibkro wants to merge 32 commits into
Conversation
Define the List, Schedule, and Degree product contracts, then deliver the first local-first List slice with validated recovery, accessible save/remove Undo, and bilingual browser coverage. Co-Authored-By: GPT 5.6 Sol via Codex
Add label CRUD, memberships, composable URL-backed Any/All/Exclude filters, and bulk labelling to the canonical saved List. Keep destructive actions deliberate and hidden disclosure controls inert. Co-Authored-By: GPT 5.6 Sol via Codex
Nineteen hand-written font sizes sat within a 2.6-pixel band; they are now six Tailwind steps, and six weights are three. The outcome-scale toggle sat under a course card's whole-card overlay, so it could not be clicked, and it missed the 44px touch target every sibling control keeps. Compact density no longer shrinks a saved course's title: density buys its scan line from spacing, not legibility. The bottom navigation's filled pill tracked importance rather than route, so Explore wore it everywhere and the current destination could not say so. It now marks the current destination, its glyph is one size in every state, and the orphaned isPrimary flag is gone. An offering's academic year restated what its season and calendar year already say, and it was the part that truncated in narrow Refine chips. Three rules keep the appearance honest: components size type from the scale, every inline style declares why Tailwind cannot express it, and a preset swatch must show the tokens its preset actually applies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nine hand-written transitions ran at 120-180ms against CSS `ease`. Tailwind's scale is 75/100/150/200/300ms, so each now snaps to its nearest step and takes `ease-in-out`. The curve is not identical to `ease` and the timing shifts by at most 20ms; neither is perceptible, and both stop being a value only one call site knows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All 269 token colours move from hex to OKLCH, which states lightness, chroma, and hue as separable quantities instead of three channels that have to be decoded before a tonal step means anything. The conversion is exact: every value round-trips to its original byte, so nothing on screen moves. Material's roles come in pairs, and the pairing is only worth anything if each `on-x` actually meets WCAG AA on its `x`. axe can only check the theme a rendered test happened to select, but contrast is a pure function of two colours, so it is now checked exhaustively: 186 pairs across six presets in both modes, all currently at or above 4.5:1. Picking a colour that cannot carry its own foreground is no longer something anyone has to notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converting the tokens to OKLCH removed the last hint of where their values came from, and the rules they obey lived only in a test. The token source now names them: Material 3 colour roles for the vocabulary and the `on-<role>` pairing, CSS Color 4 for the notation, and WCAG 2.2 AA for the contrast floor the suite enforces. The contrast rule cites the same two, so the requirement and its authority point at each other rather than drifting apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`font` is a shorthand, so a `[font:inherit]` utility silently resets the line-height, size, and weight set beside it. Tailwind's preflight already gives buttons, inputs, and textareas the inherited font from the base layer, where it loses to any utility — so every one of these was redundant as well as destructive. In the bottom bar it reset `leading-none` on the one destination rendered as a button, giving Style a taller label than its peers and floating its icon above the row. Four others were quietly discarding `font-semibold`, `font-bold`, `text-base`, and a line-height. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The font-shorthand fix is invisible to every existing check: the bar stayed accessible, the markup did not change, and only the rendered line-height moved. Measuring each destination's glyph turns "the Style icon sits higher" into a number, and the one destination rendered as a button is exactly where a reset like that resurfaces. Verified by reintroducing the shorthand: the test fails, and passes again once it is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixture served one course, which put whole surfaces out of reach: there was nothing to filter a saved list against, and the pass/fail toggle only appears when a course reports both grading scales, so no journey could render it. Six courses now vary credits, level, term, campus, teaching language, assessment form, and grading scale, including one that changed scheme mid-period and one whose small cohort leaves outcomes suppressed rather than empty. Search, grade summaries, and decision signals are expanded from one descriptor list, so a course is described once and the three surfaces cannot disagree. Sample sizes, percentages, and failure rates are derived from the bucket counts rather than authored beside them. None of it is real NTNU data: the codes read naturally, every fact is invented, and every piece of evidence stays marked as a fixture. The journeys that assumed a single course now say what they meant — the catalogue count comes from the catalogue, and a per-card control is matched per card. A new journey drives the grading-scale toggle that nothing could reach before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erlay Appearance was a dialog rendered over whichever page you were on, which gave it two paths — `/appearance` and `/list/appearance` — a focus trap, an animated panel, a close button, and a dialog submodel to keep in step with the URL. It is a third route now. Both old paths still resolve, because links to them exist. The navigation follows. Appearance was the one destination rendered as a button rather than a link, in both the sidebar and the bottom bar, which is why it needed its own styling and why a stray reset could drift it away from its peers. It is a link in both now, it reports `aria-current` like every other destination, and the bottom bar has no special case left at all. Net 163 lines lighter: a dialog model, its message, its sync-with-the-URL helper, and a whole class of overlay state are gone rather than maintained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making Appearance a destination put it in the shared navigation model, which the sidebar rendered in its destination list while still keeping its own entry beside the language control. One of the two had to go; the preference belongs with the preferences, so the destination list drops it and the remaining link is named for what the student sees on the page. Its accessible name follows the visible word rather than contradicting it. The main column was never centred on a wide screen. It was capped and offset in the same box, and a margin that clears a fixed sidebar has to be explicit — which beats `auto` and dropped every spare pixel on the right, 340px of it at 1900px wide. The offset stays on the column, the cap moves inside it, and the two gaps now match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A rail and a bottom bar do not weight their slots alike. Reading down a sidebar, first means top; reaching across a bottom bar one-handed, the middle is the easiest slot and importance falls away towards the corners. Encoding either order directly makes one surface's convenience the other's arbitrary sequence, so the model carries rank and each surface seats it: the sidebar in rank order, the bottom bar with the leading destination in the middle and the rest filling outwards. An even bar has no middle, so the rule degenerates to rank order, which is the right answer there rather than a special case. Explore leads on both now. The bottom bar is unchanged, which is the point — the same ranking produces the seating it already had. Language moves out of the page footer, where finding it depended on how far down a long catalogue the student had scrolled, into a control that stays in reach on the narrow layout. The licence and source links stop repeating under every page and are stated once, on Style, where the sidebar keeps them company beneath the controls rather than above them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eading The sticky control put a preference ahead of the thing each page is about, which is the wrong order to read a page in. The narrow layout has no sidebar, so Style is where its app-level preferences live — one tap from the bottom bar, beside the theme and the licence, and never competing with a heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exclusion lived behind a "Combine labels" disclosure, which hid the harder half of the filter language and took a collapsed-but-mounted panel, an inert attribute, and a piece of model state to keep from trapping focus. Both halves are sections now, and all of that goes with the disclosure. Include keeps the match switch; Exclude has none, and that asymmetry is the design rather than an omission. Excluding two labels means excluding both — the negation distributes over the set, so the alternative reading is one a student would never intend. Included labels have two real readings, so the switch stays there and only there. It also only appears once it decides something. With fewer than two included predicates Any and All select the same courses, so the choice would be a choice of nothing. When it does appear it carries the size of each outcome, which turns the decision from a connective into a result: any 2, all 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Explore is a destination in both navigations, so a permanent link to it in the List header repeated something already on screen. When nothing was saved it also repeated the empty state's own call to action, putting the same button on the page twice at exactly the moment it was the only thing to do. The empty state keeps its link, because there it is the action rather than a duplicate of one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removal now carries a set. One course and several are the same act on sets of different size, so they share one action, one undo path, and one place where memberships travel alongside the courses they belonged to — undo restores what the student had, not just the identities. Removing a selection asks first, in the tray rather than over the page, and the question names the size of the set it applies to. Changing or clearing the selection retracts it, so a prompt can never outlive the set it asked about. A single course still removes without a prompt: undo already restores it, and asking about a reversible act only teaches the student to dismiss asking. Remove reads as destructive only where it is. In Explore it is the off-state of a save toggle that flips straight back, and every saved card would carry it; in List it discards a note and every label attached to the course, which is what the error colour is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ve it The notice reported something that had already happened from the top of the reading column, which on a narrow screen is wherever the student is not: at the top of a scrolled page it was announced and then invisible. It hovers near the bottom bar now, where the eye already is, and settles into the bottom-right corner on a wide layout rather than banding across the column. It is positioned rather than in flow, so saying what happened never pushes the page around, and it still never times out — an undo the student blinked past is an undo they did not have. Everything that hovers over the page bottom now shares one stack, so the notice and the selection tray space themselves instead of each guessing how tall the other is. That guess was already wrong: a message long enough to wrap covered the tray it was supposed to sit above. The column reserves the room the stack occupies, which is what lets it appear without reflowing anything while leaving the last row scrollable clear of a notice that stays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ls MCP A second action used to overwrite the first one's snapshot, so saving two courses in a row cost the student the ability to undo either but the last. Up to three notices now stand at once, each holding the snapshot that reverses its own action, and each offering its own Undo. Past the third the oldest snapshot is dropped rather than kept forever. Notices are keyed by what happened, so repeating an action refreshes its notice instead of stacking the same sentence twice. Stacking costs the student a dismissal per notice, so the group offers one way out as soon as there is more than one to clear — and dismissing is not undoing: the actions stand. Idle stops being a variant of the action type. An empty queue is what "nothing to undo" means, so the state can no longer be reported as an action that did not happen. The DevTools MCP server is wired for development: the Vite plugin opens the port, the runtime is handed the Message schema, and `.mcp.json` sits at the root where agents look for it. It lets an agent read the live Model, follow message history, and dispatch into a running tab, rather than rebuilding a state through the interface in order to look at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three legacy `api-worker` files boot a local workerd runtime and account for 20 of the suite's 21 seconds; the eight student-web files that most changes actually touch run in three and a half. Scoping that path to a named script makes the quick loop something to reach for rather than something to remember, without changing what `bun run test` covers before a commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A saved row mixed content and controls: the chips saying which labels a course carries sat in the same group as the button for changing them, below and away from Remove. The chips are what the row reports; the button is what acts on it, so only the button joins Remove at the end of the row. Both controls that act on a course now sit together, in the same place on every row. In the selection tray the destructive action moves to the end, furthest from the additive one, so a hurried tap lands on Clear selection rather than on Remove. A label form's commit moves to the end of its row for the same reason it belongs there: it is where a form's confirmation is looked for. The legacy rules file gains the edge it was missing. It named two edges onto the current stack's critical path; `packages/contracts` also imports `CourseSummary` from `packages/domain`, which makes three. It also now records that three `api-worker` files carry twenty of the suite's twenty-one seconds, and points at the scoped script for the fast loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last leg of Explore -> Inspect -> List -> Compare. Narrowing was already solved; what a student could not do was see how the courses they had kept actually differ, because a card shows everything about one course and six cards show six of everything. Arity is structural rather than checked. `compareSelection` is the only way to build one, it returns null for anything that is not two to four distinct saved courses, and nothing downstream re-counts. A duplicate, an unsaved code, or an unparseable one fails the whole selection instead of quietly comparing fewer courses than were asked for — that would be a different comparison, silently. Difference-first is the point of the table. A row is hidden only when every course reported the same known value, so a dimension nobody reported is never mistaken for agreement; each cell keeps the state it had, and cells that were never known read differently from cells that agree. `Differences` turns the collapse off. The comparison lives in the URL, because the spec asks that a refresh or a back step keep it, while selection stays ephemeral and out of the URL as before. It renders above the list it was drawn from: a mode within List, not a destination. Attendance, remote evidence, and programme relations are absent rather than shown empty; their data gates are still closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… page The tray's four actions were not peers. Two carried the page-level button, which stretches to fill a narrow screen — right for a form's single commit, wrong beside three siblings — so two filled the row and two hugged their text. The eye groups by similarity before it reads a label, so that read as two kinds of control rather than four choices. They share one treatment now, with tone carrying meaning and width carrying none. The tray also followed the student off the page. `locationMatchesModel` deliberately ignores the route, so a route-only step looked like "nothing moved" to one of the two URL branches and left the selection behind — bulk actions offered against rows no longer on screen. Page-scoped state is now dropped in one place that both branches route through, so a third branch cannot miss it. A journey holds the page to the narrowest phone: wide content may scroll inside its own container, as the comparison matrix does, but the page itself must not scroll sideways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A saved course learns its title from enrichment, so a row that fits while loading can stop fitting once it knows what it holds. Held rigid, the row's action group set a floor under its width — two pills, a checkbox, and the gaps between them — and a real catalogue title pushed past it. Under mobile emulation the layout viewport widens to fit content rather than growing a scrollbar, which is why the page came out 373 pixels wide on a 320 pixel screen. The row wraps now, and a long title is allowed to break. The floor was partly mine: co-locating the row's controls put both pills in the header row where they could not wrap. The guard that should have caught it could not. It compared `scrollWidth` with `innerWidth`, and under mobile emulation those grow together, so it passed on any width. It measures against the device width now, and skips containers that scroll on purpose — verified by reintroducing the rigid row and watching it report 373. The fixture gains a course whose title is as long as a real one, because the short titles it had were why this never reproduced here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d axis Wrapping fixed the overflow by letting pieces fall wherever they fit, which is placement by accident: the title still shared its line until it could not, so on a phone it read in a gutter beside two controls. The row is a single column now and gains a horizontal axis only when it has the width for one. Selection and identity stay adjacent because they name the same thing; the controls that act on the course take the far side when there is a far side and the line below when there is not, which leaves the title the full width exactly when width is scarce. The rule answers to the row's own width rather than the viewport's, so it holds inside the sidebar-offset column and on a phone without a second arrangement to keep in step — the same move as ranking destinations once and letting each surface seat them. At 320 pixels the title goes from sharing a line to owning three, and widens from 195 to 306 pixels once the row promotes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule that fixed the saved row generalises: a group of controls is one full-width column first and becomes a row once its container has the width for one. Stacked, the buttons share a width and centre their labels, so the column reads as one block of choices instead of a ragged edge of differently sized pills; side by side they take only what their labels need. The selection tray and the comparison's header both use it, and both answer to their own container rather than the viewport — a tray pinned above the bottom bar and a panel inside the reading column are different widths at the same screen size, and neither needs its own arrangement. A selected row now says so as a row. Selection was reported only by a small box at the edge, which left the row reading as a static item that happened to carry a control; the border and surface carry the state now, so what is selected is legible from a glance down the column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ore cards Refine was pinned into a corner and carried the page-level button rule, which widens a button to fill a narrow screen — inside a box anchored to the right edge with no left, that is where the weirdness came from. It is centred above the bottom bar now and no longer widened. Centring it revealed the real problem: two fixed boxes both owned the page bottom, so the trigger landed on a notice and axe reported the touch target it covered. Refine joins the stack that already owns that region, which spaces them the same way it spaces a notice above the selection tray. One owner of the page bottom, not three. An Explore card gets the rule its saved-row counterpart already has: identity and the save control are one column until the card has the width for a line, its facts pair up only when they fit, and a long title may break. The card is the container, so this holds in the two-column layout where the card is much narrower than the screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ne cornered Six container widths had accumulated between 22 and 34 rem — near enough to look deliberate, far enough apart to disagree, and each one a number only its call site knew. They collapse to three named steps: facts pair at 24, a control group becomes a row at 28, a header gives its controls the far side at 32. A rule in the architecture test keeps a fourth from appearing, and caught the fourth this change tried to add. Tailwind reads class names out of source text, so these cannot be composed from a variable — a threshold built by interpolation is simply never generated. The scale therefore lives as literal strings in named constants; naming them is the strongest rung available, and the test is what makes it hold. Refine returns to the corner. It is centred only below the narrowest width the layout supports, which is to say never, so the corner needs no threshold at all. It stays in the bottom stack, which is what keeps it off the notices it used to land on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The domain half of collections. A collection is a name over the recipe the filter already speaks — included labels, the match mode, exclusions — and it stores that recipe rather than the courses the recipe currently selects, so its membership follows the labels instead of freezing a copy of them. That is what keeps it from becoming a second place a course can live. Two saves are refused rather than stored. An inactive filter selects every saved course, which is the saved list, and naming it would put one set on screen under two names. A duplicate name is refused for the reason a duplicate label name is: two identical entries in a picker are a choice the student cannot make. Persisted state goes to version 3, and a list saved before collections existed migrates to having none. A recipe naming a label that has since been deleted survives: the filter normaliser already drops unknown ids and says so, so the collection outlives its labels rather than being repaired away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The surface for collections, on the filter rather than beside the list. A collection is a named filter state, so choosing one sets the recipe it names and the rows below are the same saved courses asked a different question. A tab would have said container, which is the one thing a collection must never be. Saving is offered only when there is something to name — an unfiltered list is the saved list — and only when the active recipe is not already a collection, so the picker cannot fill with synonyms. The name is a draft while it is typed: nothing is stored until Save, a refusal explains itself in place, and editing the name clears it. The recipe travels in the URL exactly as a hand-built filter does, so a collection is shareable and survives a reload without becoming a second kind of state. Deleting one removes the question and leaves every course and label where they were, which the journey checks rather than the copy merely claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Collections: a named filter state over the one canonical saved list.
What this adds
A collection stores the recipe the filter already speaks — included labels, match mode, exclusions — never the courses that recipe currently selects. Its membership follows the labels, which is what keeps it from becoming a second place a course can live.
The surface sits on the filter, not beside the list. Choosing a collection sets the recipe it names; the rows below are the same saved courses asked a different question. A tab would have implied a container, which is the one thing a collection must not be.
Decisions worth reviewing
Verification
bun run check, 310 unit tests, 52 browser journeys. The journey drives the whole loop: no save action without a filter, save, recognition of the active recipe, leaving and returning, reload, then delete with the courses and label intact.