Filters

Level 2. A <details> holding a panel holding a list of labelled inputs: the sort menu, the category filter and the language chooser are one molecule. 193 menus and 1,012 choosing rows on 105 painted screens, and 105 of the 193 never receive half their rules. Measured 2026-08-08 in a browser, at 390 and 1280, in both pointer branches.

193 menus 1,012 rows 105 out of scope 1,012 rows at 33 tall

The menu, with its panel open

The sort and frequency menus with a panel open. 14 elements against 30.

Vault, dark
Sort: Trending
Reverse sort
Daylight, light
Sort: Trending
Reverse sort

What this page is for

The level and its reason stay on the molecules page. This page holds the two arguments that make this component the most instructive in the system: where a scope reaches, and what it costs to hide an input the wrong way.

105 of the 193 stand outside the scope, and every one is in the footer

wheremenussummary paddingat 390, mouseat 390, finger
in the feed, the head, the panels888 / 20152x35 and 154x35152x44
in the footer, until 2026-08-081054 / 890x2790x44
in the footer, now1058 / 20122x35122x44

Every painted screen carries a footer and the footer stands outside the app-case wrapper. Until 2026-08-08 all three of this file's summary rules opened with that wrapper, so the language chooser got a surface, a hairline and a pill corner and nothing else: no padding of its own, no transition, no hover and no press. It was a control that answered a mouse with silence, once on every screen in the product.

It was the third sighting of one shape and the last one, because the wrapper went rather than the control being copied. The unscoped .amount-input rendered as a white User Agent field; the .chip-amount in a dialog got no rule at all. This one was the version of the defect that survives a screenshot: it rendered, it looked nearly right, and it was missing its behaviour. Verified by pressing, in both themes: hover and press now read the same values as the sort menu in the feed, and the 30px of width between them is the difference between the word "English" and "Sort: Trending". docs/backlog.md 58, closed.

The touch floor is not affected and that is the design working. It lives in components/base.css and names .filter-menu summary without a scope, because a <summary> is the one target in this system with no class of its own to be a family. So both kinds reach 44 with a finger, measured, and the gap is a pointer-state gap rather than a target gap.

The menu, open

Vault, darkDaylight, light
Sort: Closing soon
Reverse
Sort: Closing soon
Reverse

The chosen row takes a brass tint from the ladder's nearest rung, brass ink and semibold. Until 2026-08-07 that ground was a colour function written inline in the stylesheet, color-mix at 20 per cent of the action colour, which is a rung the ladder does not have and a value no sweep matching token NAMES can see. The nearest rung is 4 per cent quieter on graphite, improves the label to 5.87:1, and on chalk steps the way every other brass tint does instead of staying flat.

Hidden, not removed, and the difference is the whole control

display:none generates no box, so the browser takes the element out of the tab order AND out of the accessibility tree. Measured before the change: 400 Tab presses on the feed at 1440 with the panel open, 0 stops inside the panel, and 16 inputs all computing display:none. The sort radiogroup and the category checkboxes could be operated by a mouse and by nothing else, on 104 screens. The focus rule below could never match, because the input could never be focused.

The replacement is the idiom this system had already chosen twice: a 1x1 transparent absolutely positioned box with pointer events off. Absolute, so it is not a flex item and the label's layout does not move by a pixel; pointer events off, so the mouse still lands on the label and nothing else. tokens.css names the hairline "the 1x1 box of a visually hidden input" and tabs.css uses the same three declarations.

The ring therefore goes on the label, and its offset turns INWARD. base.css draws the ring once on the element that has focus, and that element is now a 1px transparent box. The panel is overflow:hidden and the label carries 4px of side margin, so a ring 2px outside it would be clipped on both edges. Same width, same role, one sign flipped: the ring stays one decision.

1,012 choosing rows, 33 tall

partwornmousefinger
the summary, in case883544
the summary, footer1052744
.filter-panel li label1,012140x33 and 186x33140x33, unchanged
.reverse-row3the row that holds the switchsee toggle

The row a person actually chooses with is the one the floor does not reach. The summary opens the menu; the label picks the value, and there are 1,012 of them at 33 tall with a finger on them. They clear WCAG 2.5.8 at 24x24 and fail 44. It is the same shape as the 365 account rows on nav item, and it is the biggest single group of it in the product. docs/backlog.md 54 carries both.

The panel is where the switch used to live, and it left on 2026-08-05 because a switch and a sort menu are two families: toggle carries that argument in full. What stayed is the row it sits in.

The phone form: one mark and a sheet

The two menus carry their own value, so they measure 152 and 154. At 360 the feed column offers 298 and the pair needs 314, so until 2026-08-14 they stood one above the other and the list head cost 114.4px. At 390 the same pair fits on one course and the head costs 71.9. The width this project designs from was the only one of the three paying for a second course, which is why it survived every audit: 390 and 1280 are the two widths everybody reads and the defect lives between them.

Below DESK 640 the pair is replaced by one control. The whole band, heading to first card, measured on a coarse pointer: 253.4 at 320 and 360, 201.4 at 390, 149.4 at 430, against 112.0 at all four after. The band is now the same number at every phone width, which is the second half of the change and not a side effect: the row of chips under it stopped wrapping on the same day.

Vault, dark
Daylight, light

The button is pinned visible by .tk-show-filters, one declaration, and every other thing about it here is the system's: it is an icon-btn, the same circle the header's bell and avatar wear, so the ground, the edge, the corner, the 36px box, the hover, the press and the 44px floor all arrive from iconbtn.css and base.css. The sheet itself is not drawn on this stand and the cell below says so: its box is position:fixed with a bottom inset, a max-height in svh, a top-corner radius and a lift, and a cell that restated those would be a hand-kept copy drifting from the file that owns them.

It read Filters with a chevron for one build and that was the wrong weight. 93px of chrome standing beside a display-face heading, and the heaviest thing in a row whose subject is the heading. And the first icon-only cut had no drawing at all: the class was still carrying the summary's chip face, so iconbtn.css set a 36 x 36 box and filters.css, imported later as a molecule over an atom, set padding:8px 20px inside it. 40px of padding in a 36px box leaves a negative content box and the mark rendered as a two-pixel dot in a circle that still looked like a button. A chip face is a padding and an icon button is a box, and putting both on one element runs it past every selector written for either.

The dot is what an icon alone gives up, bought back. The two pills were good at exactly one thing, saying what the filter was set to without being opened, and a mark says nothing. :has() reads the radios that are already in the document and [data-default] marks the two the feed ships with, so the dot appears the moment either group leaves its default. It answers the half that matters: whether the list a person is looking at is the default one. The ring around it is --bg-plate, the surface the head stands on, so the brass reads as a separate object rather than as a bite out of the button's edge.

The sheet is a below-640 face. Not rendered at this width.

Seven classes arrived with the phone form and this is all of them. The stand named one of the seven for a day, which is the same shape of gap as a Reads: line that lists a third of what a file uses: the page said what the sheet DOES and never what it is made of, so nothing here could be checked against the stylesheet.

classelementwhat it iswhere the face comes from
.filters-toggleinputthe checkbox the whole sheet hangs on, 1x1 and transparent so it stays focusablethis file, the same three declarations as every hidden input in it
.filters-btnlabelthe mark that opens it, and the dot that says a filter is off its defaulticonbtn.css whole; this file adds the dot and nothing else
.filters-scrimlabelthe backdrop, and one of the three ways out--shadow-ink-45, the same role as dialog.app-dialog::backdrop
.filters-headdivthe sheet's title rowthis file. dialog.css's .sheet-head was not reused: it carries a dialog's photographic head
.filters-closelabelthe cross in the head, covered by the course chrome in the review buildiconbtn.css whole
.filters-footdivthe row that ends the sheetthis file, one hairline and one padding
.filters-donelabelShow results, the visible way outbutton.css, .btn.btn-primary.btn-md
.filters-resetbuttonReset, shown only while a filter is off its defaultbutton.css, .btn.btn-ghost.btn-md

Five of the eight own no face at all, which is the answer to whether this was drawn or assembled: the mark and the cross are iconbtn.css, the two buttons are button.css, the backdrop is the role the product's dialogs already stand behind, and the 44px floor on all four is base.css. What filters.css adds is the sheet's box, the head, the foot, the dot and the rung they all appear at.

And the one number that was invented has been given back. The sheet capped itself at 80svh for two hours, a fifth answer to a question the system already answers four times: 92vh on .bet-sheet, 88dvh on a modal dialog, and calc(100svh - 120px - var(--space-16)) on the two rails. A bottom sheet is a bottom sheet, so it takes .bet-sheet's 92 and the vh then svh pair the rails use and .bet-sheet does not. docs/backlog.md 152 and 153.

Why a checkbox and not a <dialog>. The two radio groups have to exist once. A dialog would need its own copy of them, and a radio group is keyed by name, so two copies in one document are one group with two sources of checked and two sets of ids: this kit pays for exactly that with its per-theme suffixes, and pays for it again two cells up. The checkbox leaves the panels where they are and moves the box they stand in, so the desk keeps its two pills out of the same markup.

The sheet ends in a way out, because the three it had were invisible. The scrim closes it, Escape closes it, and the cross in the head closes it: the first two are invisible by definition, and the third is 44px in a corner, covered by the course chrome's own toggle in the review build. So the sheet ended in a band of empty surface, which reads as an unfinished screen rather than a finished choice. An exit that only exists in the mechanism is not an exit. The primary is a <label for> and says Show results rather than Apply: every radio here is live, so there is no step to apply. Reset appears only while a filter is off its default, which makes it the other half of the dot on the closed control, and it is the one thing on this component that has to be script, because one <label> checks one radio and a reset checks two. It dispatches change rather than only setting checked, because the word printed beside each row is written by the page's own change listener and a property assignment fires nothing.

What it costs, stated rather than hidden. A checkbox has no Escape and no focus trap. Escape is added back by a nine-line script on the 57 screens that carry the strip, and the trap is not: docs/backlog.md 150.

What it does with width

Its own width query: 640 DESK, and what it does there changed on 2026-08-14. The menu is 152px at every width and the rung has never sized it. It now REPLACES it: below DESK the pair is display:none and one .filters-btn stands in the head, and the pair comes back inside a fixed bottom sheet when the toggle is checked.

The block that stood here before said something else and could not have been true. It read @media(max-width:39.99875rem){.filter-menu{position:relative}}, which restates at a rung the exact value the first line of filters.css declares unconditionally: it could not change a pixel at any width, and it read like a phone decision. This page carried the prose version of it, below DESK the panel takes position:relative so it opens in the flow rather than over it, for as long as the rule stood. A dead rule with a live sentence beside it is worse than a dead rule, because the sentence is what the next reader believes.

The rule

Hide an input by making it invisible, never by removing its box. A control with no box is a control with no keyboard and no screen reader, and the styling that was supposed to answer for it cannot fire either.

A scope has to name every place the control stands, and the honest way to do that is usually to have no scope. The footer is outside app-case, so a rule written under it reached 88 of 193. Naming the second place would have worked for this control and left the next one to be found the same way.

A value typed as a colour function is invisible to the system. The ladder has rungs so that a tint can be found, compared and moved; a color-mix in a rule is a decision nothing can audit.

A second form of a control is a second PLACEMENT, never a second copy of the controls. The phone sheet holds the same two radio groups the desk pills hold, because a duplicate is one group with two sources of checked and a face kept in two places.

The anti-rule

Never use display:none to hide a control you still want operable. Sixteen inputs, 104 screens, 400 Tab presses, 0 stops.

Never assume a scope covers the footer. It does not, and the control it misses looks almost right, which is the version of this defect that survives every screenshot.

Never draw a ring outside a clipped panel. Two of its four strokes disappear, and the one that is left reads as a border.

Never leave a rule at a rung that restates a value declared unconditionally. It cannot change a pixel and it reads like a decision, and the prose written beside it goes on being believed after the rule is gone.

Never reach for the nearest-sounding token for a backdrop. --scrim is the COURSE DRAWER's, at 30 per cent, and over graphite it darkens nothing: the sheet did not read as modal. The product answers this once already, in dialog.app-dialog::backdrop.

Where the rest of it is

The level and its reason: the molecules page. The stylesheet: components/filters.css. The switch that left: toggle. The floor: components/base.css. The row of chips that changed on the same day: catnav. The open rows: docs/backlog.md 54, 58 and 150.