webapp: improve the feature vocabulary and exposed to the user what each feature is - #20
Merged
Conversation
macabeus
force-pushed
the
web/feature-vocabulary
branch
from
August 4, 2026 21:52
8a9b090 to
4915e65
Compare
… the site The Function Explorer's Feature selector is 89 options long with no explanation of any option, and the cause is not the UI: the closed vocabulary PR #16 introduced governs only the REAL tier, so the synthetic tier carries 48 undeclared tags of its own, the six derived codegen tags are published without ever having been added to KNOWN_FEATURES, and apps/web cannot import the descriptions at all — apps/benchmark is not one of its dependencies, so every definition written in #16 is unreachable from the UI that would render it. The vocabulary moves to @asmlift/bench-schema as data both sides read: 69 ids, each with a label, a user-facing `group`, an `evidence` kind, a summary, and — where it earns one — prose and a worked C/asm example. Two orthogonal axes on purpose: `evidence` is how we know a tag is true (a producer's question, but worth surfacing), `group` is what kind of thing it is (a reader's question, and what the picker groups by). src/cases/features.ts keeps every detector and regex but stops declaring the vocabulary: SOURCE_CHECKED, CODEGEN_DERIVED and JUDGEMENT become filters over FEATURES by `evidence`. Two lists that must agree become one list with two views. Dataset changes, all metadata — no outcome, score or gap moves: - hard renames, no aliases: s64→int64, for/while→loop, multi-if, nested-if, logical-and, logical-or→branch, type-pun→union, pointer-arith→pointer, this-ptr→method - the eight compound divisor ids flatten to div/mod-{const,pow2,reg}, with signedness carried by the existing signed/unsigned tags — six orthogonal ids instead of eight compound ones - dropped: expr, void, byte, offset, range, select, mutate (unfalsifiable or true of everything), c++ (duplicates FunctionResult.language) and recursion (declared by #16, carried by no row) - `cast` removed from six specs whose conversion is IMPLICIT — the floor caught them, and their sign-extend/narrow/promotion tags already said it Source-level tags now derive per row, as the codegen ones already did: SynthSpec carries `src`, so sourceEvidence works on both tiers. Authored data therefore carries JUDGEMENT tags only — the machine decides what it can decide. Gates, all hard failures: every published tag has a definition; every definition is carried by a row; definitions are well-formed with resolvable seeAlso; derived tags match their evidence in both directions; judgement tags stay above a floor, now including break/continue/double/rotate/mask and the divisor family. Each was verified to fail when violated, not merely to pass. Two floor fixes found while wiring this up: a type tag reads the whole declaration rather than the body (`double dadd(double,double)` keeps its evidence in the signature), and `&&`/`||` count as conditional control flow for `branch`. bench-schema is no longer types-only, so its description, its header comment, README.md, docs/asmlift-101.md and apps/benchmark/README.md all say what it is now: the shared benchmark contract, zero dependencies, browser-safe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The building blocks the feature picker and the definition drawer need, added
first and on their own. All four live in src/shared/, which
.dependency-cruiser.cjs forbids from importing src/pages/ — so none of them
can learn anything about features, benchmarks or routes. Genericity is
enforced by the build rather than promised in a comment.
MultiSelect — a searchable, grouped multi-select popover. Takes options,
returns a string[]; what those strings mean, and whether they AND or OR, is
the consumer's. Its searching/grouping is a pure sibling module because
apps/web has no component-testing setup and adding one for a single
component is a worse trade: the logic that can be wrong sits where a test
reaches it, and the rendering stays untested, which is the standard the rest
of apps/web already holds.
HoverCard — floating content on hover or focus, for anchors whose
explanation needs markup or a link (the native `title` can hold neither, and
appears on the browser's own ~1s delay). Two things it has to get right:
- it is PORTALLED to <body> and positioned from the anchor's viewport
rect, because anchors sit in scroll containers — the benchmark's table
wrapper is `overflow-x-auto`, and a container that is `auto` on one axis
stops being `visible` on the other, so an absolutely positioned card
would be clipped by the very row it belongs to. Placement is a pure
hoverCardPosition(), unit tested for the flip threshold and the
left-edge clamp.
- it RE-MEASURES on scroll rather than dismissing. The listener has to be
capturing (an anchor inside a scroll container moves when that container
scrolls, and those events do not bubble to window), so it hears every
scroll on the page, including the ones an `overflow-auto` code pane
emits as its syntax highlighting resolves. Dismissing on those makes the
card vanish about a second after opening.
Dismiss-on-click runs in the capture phase, because anchors commonly
stopPropagation (a chip inside a clickable table row must not also open the
row) and a bubble handler would be skipped by exactly those anchors — with
the unmount deferred to a later task so the click finishes propagating to
the card's own buttons first.
Pill — the small rounded label. Two families: TINTED, where a hex drives the
text color and a 13%-alpha wash plus an optional dot, and NEUTRAL slate. The
color stays the caller's, because what a color means is the caller's
business.
overlay — the two things stacked drawers need, since a chip inside one
drawer opens another over it: a REF-COUNTED scroll lock (two drawers each
restoring body.overflow on unmount unlocks the page behind the one still
open) and Escape that closes only the TOPMOST (two window listeners both
fire, so one keypress would dismiss the whole stack; the top one listens in
the capture phase and stops the event). Which overlay is on top is tracked
there, so neither drawer has to know the other exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n drawer
The two complaints about the Function Explorer's Feature control: 89 options
long, and nothing says what any option means.
The <select> becomes a searchable multi-select grouped by the vocabulary's
user-facing axis, AND-ed: a row must carry every selected tag. No mode
toggle — instead each option's count is computed over the ALREADY-FILTERED
rows and zero-count options go disabled but stay visible, so a selection
that would empty the table is visible before it is clicked. That is the
affordance a toggle would have papered over.
A second consumer lands with it: the asmlift decline filter. One consumer
does not prove a component is generic, and declines.ts already has a closed
{key,label} vocabulary of 13 classes whose labels have the same legibility
problem, with declineClassesOf() already returning an array — so AND-ing
them means something without inventing semantics. The remaining six filters
stay single-select <select>s: short, mutually exclusive, no complaint behind
them.
Definitions open in the same right-side drawer the row detail uses, rather
than a separate page: "what does `table` mean?" is asked while reading the
table, and answering it by navigating away would discard the filters and
scroll position that prompted the question. Same chrome, same Escape, same
URL-state shape (`?about=<id>` beside `?fn=<row>`), so a definition is a
shareable link — and featureHref() builds from the live query string, so
opening one preserves the filters already set. It renders above every tab
rather than inside one, because a tag can be asked about from the table, the
picker or a chart and the answer should not depend on which. Chips inside
the row drawer open it too, stacking over that drawer, which is what the
ref-counted scroll lock and topmost-wins Escape in shared/utils/overlay are
for.
Tag chips are hoverable for a one-line definition plus a "read more" link,
and clickable for the full one. The evidence badge in the drawer header
carries its own explanation on hover, rather than a paragraph in the body
competing with the definition it annotates.
`feature` and `decline` become comma-separated lists in the URL; a
single-value link written before this still parses, pinned by a test. The
benchmark's badges are now Pill plus the domain rule that picks their color,
so OutcomeBadge reads as a color lookup and GapBadge as a bucket lookup.
feature-vocabulary.test.ts is the fifth gate, and the one that matters for
the site: it re-checks the closed vocabulary against the COMMITTED
results.json, so a snapshot regenerated by a checkout whose vocabulary had
drifted cannot ship a tag with no definition for a chip to open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
743 rows, unchanged board (asmlift 354 / m2c 342), zero outcome flips — this is metadata only. 89 distinct published tags → 69, every one of them backed by a definition the site can render. Separate from the code because a commit cannot record its own hash: the provenance here (50aa12e, clean) names the commit whose tree produced these numbers. The tag assertions in features.test.ts and feature-vocabulary.test.ts read the committed snapshot, so they are red until this lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
macabeus
force-pushed
the
web/feature-vocabulary
branch
from
August 4, 2026 22:01
4915e65 to
8b58935
Compare
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.
Closes the two complaints about the Function Explorer's Feature selector: it is 89 options long, and nothing tells a reader what any option means.
The selector was a symptom
The cause is upstream of the UI, and partly my own doing in #16:
KNOWN_FEATURES— the "closed" vocabulary from #16recursion)Three separate holes. The closure test reads
dataset/real/*.jsonand nothing else, so the synthetic tier speaks an undeclared 48-tag dialect —s64besideint64,type-punbesideunion,for/whilebesideloop, each pair silently halving every aggregate over it, which is the exact failure #16 fixed forfixedpoint/fixed-pointand left live in the other tier. The six derived codegen tags (hw-div,magic-div,jump-table, …) are published on every row whose asm supports them and were never added to the vocabulary at all. Andapps/webcannot importapps/benchmark— it is not a dependency — so every description written in #16 is unreachable from the UI that would render it. The tags were documented in a place the site can't see.What changed
The vocabulary is data in
@asmlift/bench-schema, read by the producer and the consumer alike: 69 ids, each with a label, a summary, anevidencekind and a user-facinggroup, plus prose and a worked C/asm example where it earns one. Two orthogonal axes on purpose —evidenceis how we know a tag is true,groupis what kind of thing it is, and only the second is what a reader wants to browse by.src/cases/features.tskeeps every detector but stops declaring the vocabulary: the three lists become filters overFEATURESbyevidence, so two lists that must agree became one list with two views.Authored data now carries judgement tags only. Source-level tags derive per row like the codegen ones already did —
SynthSpeccarriessrc, so the same detector works on both tiers. The machine decides what it can decide.Dataset, all metadata — no outcome, score or gap moves: 10 hard renames (no alias layer); the eight compound divisor ids flattened to
div/mod-{const,pow2,reg}with signedness carried by the existingsigned/unsignedtags; 9 drops (expr,void,byte,offset,range,select,mutateas unfalsifiable or true of everything,c++as a duplicate ofFunctionResult.language,recursionas unused). The floor also caught six specs taggedcastwhose conversion is implicit — theirsign-extend/narrow/promotiontags already said it.The UI: the
<select>becomes a searchable multi-select grouped bygroup, AND-ed. Definitions open in the same right-side drawer the row detail uses — same chrome, same Escape,?about=<id>beside?fn=<row>— because "what doestablemean?" is asked while reading the table, and answering it on a separate page would throw away the filters that prompted the question.Five gates, each verified to fail
seeAlsoresolvesapps/web, over the committed snapshotresults.jsonwhose vocabulary driftedThe last one is the one that matters for the site: without it a snapshot could ship a tag the picker silently omits and a chip opens an empty drawer. I broke each gate deliberately and confirmed it goes red — a gate that has only ever passed is decoration.
Design notes worth reviewing
No AND/OR toggle. At 3.3 tags per row, three AND-ed selections empty the table fast. Instead each option's count is computed over the already-filtered rows and zero-count options go disabled but stay visible, so a selection that would empty the table is visible before it is clicked. That is a better answer than the toggle, not a smaller one.
Three shared primitives, generic by construction.
MultiSelect,HoverCardandPilllive insrc/shared/, which.dependency-cruiser.cjsforbids from importingsrc/pages/— so none of them can reach for a vocabulary, a row count or a route.MultiSelectships with a second consumer (the decline filter) in the same PR, because one consumer does not prove a component is generic;Pillreplaced six hand-rolled copies of one class string. Their logic that can actually be wrong — option filtering, card placement — is extracted into pure modules and unit tested, sinceapps/webhas no DOM-testing setup and adding one for these would be a worse trade.Hard renames, no aliases, per review. The cost is that
?feature=s64-style permalinks break. Rather than hide that, the Explorer validates URL values against the now-closed vocabulary and reports the unknown one instead of rendering a silently empty table.Accepted, not fixed
shared/utils/overlay.ts: ref-counted scroll lock, Escape closes only the topmost) rather than avoided.Verification
bench run+merge→ 743 rows, asmlift 354 / m2c 342 — unchanged,regressionreports 0 lost / 0 gained / 0 flips,stale-checkfresh, provenance clean and an ancestor of this branch. 893 tests across 87 files,typecheck,lint(0 errors),format:check,depcruise, and a productionvite build. The picker, the drawer, stacked drawers, Escape ordering, and both "read more" paths were each exercised in a real browser.🤖 Generated with Claude Code