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.
The sort and frequency menus with a panel open. 14 elements against 30.
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.
| where | menus | summary padding | at 390, mouse | at 390, finger |
|---|---|---|---|---|
| in the feed, the head, the panels | 88 | 8 / 20 | 152x35 and 154x35 | 152x44 |
| in the footer, until 2026-08-08 | 105 | 4 / 8 | 90x27 | 90x44 |
| in the footer, now | 105 | 8 / 20 | 122x35 | 122x44 |
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 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.
| part | worn | mouse | finger |
|---|---|---|---|
| the summary, in case | 88 | 35 | 44 |
| the summary, footer | 105 | 27 | 44 |
.filter-panel li label | 1,012 | 140x33 and 186x33 | 140x33, unchanged |
.reverse-row | 3 | the row that holds the switch | see 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 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.
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.
| class | element | what it is | where the face comes from |
|---|---|---|---|
.filters-toggle | input | the checkbox the whole sheet hangs on, 1x1 and transparent so it stays focusable | this file, the same three declarations as every hidden input in it |
.filters-btn | label | the mark that opens it, and the dot that says a filter is off its default | iconbtn.css whole; this file adds the dot and nothing else |
.filters-scrim | label | the backdrop, and one of the three ways out | --shadow-ink-45, the same role as dialog.app-dialog::backdrop |
.filters-head | div | the sheet's title row | this file. dialog.css's .sheet-head was not reused: it carries a dialog's photographic head |
.filters-close | label | the cross in the head, covered by the course chrome in the review build | iconbtn.css whole |
.filters-foot | div | the row that ends the sheet | this file, one hairline and one padding |
.filters-done | label | Show results, the visible way out | button.css, .btn.btn-primary.btn-md |
.filters-reset | button | Reset, shown only while a filter is off its default | button.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.
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.
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.
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.
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.