Skip to content

webapp: improve the feature vocabulary and exposed to the user what each feature is - #20

Merged
macabeus merged 4 commits into
mainfrom
web/feature-vocabulary
Aug 4, 2026
Merged

webapp: improve the feature vocabulary and exposed to the user what each feature is#20
macabeus merged 4 commits into
mainfrom
web/feature-vocabulary

Conversation

@macabeus

@macabeus macabeus commented Aug 4, 2026

Copy link
Copy Markdown
Owner

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:

distinct tags in the published dataset 89
KNOWN_FEATURES — the "closed" vocabulary from #16 36
…published tags outside it 54
…declared but carried by no row 1 (recursion)

Three separate holes. The closure test reads dataset/real/*.json and nothing else, so the synthetic tier speaks an undeclared 48-tag dialects64 beside int64, type-pun beside union, for/while beside loop, each pair silently halving every aggregate over it, which is the exact failure #16 fixed for fixedpoint/fixed-point and 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. And apps/web cannot import apps/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, an evidence kind and a user-facing group, plus prose and a worked C/asm example where it earns one. Two orthogonal axes on purpose — evidence is how we know a tag is true, group is what kind of thing it is, and only the second is what a reader wants to browse by. src/cases/features.ts keeps every detector but stops declaring the vocabulary: the three lists become filters over FEATURES by evidence, 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 — SynthSpec carries src, 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 existing signed/unsigned tags; 9 drops (expr, void, byte, offset, range, select, mutate as unfalsifiable or true of everything, c++ as a duplicate of FunctionResult.language, recursion as unused). The floor also caught six specs tagged cast whose conversion is implicit — their sign-extend/narrow/promotion tags already said it.

The UI: the <select> becomes a searchable multi-select grouped by group, 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 does table mean?" 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

gate catches
every published tag has a definition a new tag nobody documented
every definition is carried by a row a definition that rotted unused
definitions well-formed, seeAlso resolves a half-filled entry
derived tags match their evidence, both directions a tag with no evidence, or evidence with no tag
the same closure check in apps/web, over the committed snapshot a regenerated results.json whose vocabulary drifted

The 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, HoverCard and Pill live in src/shared/, which .dependency-cruiser.cjs forbids from importing src/pages/ — so none of them can reach for a vocabulary, a row count or a route. MultiSelect ships with a second consumer (the decline filter) in the same PR, because one consumer does not prove a component is generic; Pill replaced 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, since apps/web has 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

  • Chips in the row-detail drawer stack a second drawer over the first. That is deliberate and handled (shared/utils/overlay.ts: ref-counted scroll lock, Escape closes only the topmost) rather than avoided.
  • The hover card's lifecycle has no automated test — only its placement maths does. Two of the three bugs I hit during this work were lifecycle bugs found by driving a real browser, so this is a genuine gap, not a shrug.

Verification

bench run + merge743 rows, asmlift 354 / m2c 342 — unchanged, regression reports 0 lost / 0 gained / 0 flips, stale-check fresh, provenance clean and an ancestor of this branch. 893 tests across 87 files, typecheck, lint (0 errors), format:check, depcruise, and a production vite 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

@macabeus macabeus changed the title One closed feature vocabulary, a searchable picker, and a definition drawer webapp: improve the feature vocabulary and exposed to the user what each feature is Aug 4, 2026
@macabeus
macabeus force-pushed the web/feature-vocabulary branch from 8a9b090 to 4915e65 Compare August 4, 2026 21:52
macabeus and others added 4 commits August 4, 2026 22:59
… 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
macabeus force-pushed the web/feature-vocabulary branch from 4915e65 to 8b58935 Compare August 4, 2026 22:01
@macabeus
macabeus merged commit d9afd6f into main Aug 4, 2026
2 checks passed
@macabeus
macabeus deleted the web/feature-vocabulary branch August 4, 2026 22:09
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.

1 participant