Skip to content

feat(student-web): name a filter and keep it as a collection - #10

Open
phibkro wants to merge 32 commits into
mainfrom
agent/collections
Open

feat(student-web): name a filter and keep it as a collection#10
phibkro wants to merge 32 commits into
mainfrom
agent/collections

Conversation

@phibkro

@phibkro phibkro commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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

  • Saving is offered only when there is something to name. An unfiltered list is the saved list, and a recipe that is already a collection cannot be saved twice, so the picker cannot fill with synonyms.
  • The name is a draft. Nothing is stored until Save; a refusal explains itself in place and clears when the name is edited.
  • No delete confirmation. Deleting a collection removes a question, not courses — the journey asserts both courses and the label survive it.
  • Persisted state moves to version 3 with a migration; a list saved before collections existed migrates to having none.

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.

phibkro and others added 30 commits July 25, 2026 02:38
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>
claude added 2 commits July 25, 2026 23:29
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants