Skip to content

perf(ui): one tooltip provider for the app, not one per tooltip - #194

Merged
ABB65 merged 2 commits into
mainfrom
feat/tooltip-provider-hoist
Aug 13, 2026
Merged

perf(ui): one tooltip provider for the app, not one per tooltip#194
ABB65 merged 2 commits into
mainfrom
feat/tooltip-provider-hoist

Conversation

@ABB65

@ABB65 ABB65 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes T08b — the half of T08 the plan held back until the list stopped rendering every row. Stacked on #193, which pages at 50, so this is now safe to land.

What was actually wrong

AtomsTooltip carried its own TooltipProvider. A paged list turns that into 150 providers — 50 rows × three action icons.

The count is the smaller problem. Radix's skipDelayDuration only applies within a single provider, so with one provider per tooltip it never applied at all: moving from the edit icon to the delete icon re-paid the full open delay every time. Scanning a row's icons is precisely what the tooltips were added for.

Hoisted, the second and third open instantly.

Why there is a flag, and why it lives with the provider

TooltipRoot throws without a provider, and Radix exports no way to ask whether one is present — injectTooltipProviderContext is not in the package's public surface. So the app root sets a flag the atom can read.

The flag and the provider are one component, AtomsTooltipScope. A flag provided without the provider it describes makes every tooltip in the app throw; binding them together means a later edit cannot separate them. My first attempt did separate them, and the test caught it — that test now mounts the real scope and asserts two tooltips, one provider.

Outside the scope the atom still supplies its own, so a component mounted alone in a test keeps working.

Delays

Moved to app/utils/tooltip-timing.ts. There were three: 200ms in InfoTooltip, 300ms in ContentStatsBar, and Radix's own 700ms default for anything that used neither. Which delay you got depended on which component you happened to hover.

Verified — including a claim I shipped unverified in #189

.reveal-on-hover (the touch-discoverability fix) rested on a cascade argument I reasoned about but did not check. I built the app and read the CSS:

rule byte layer
.reveal-on-hover{opacity:0} 10298 components
@media(hover:none){.reveal-on-hover{opacity:1}} 10332 components, later → wins on touch
.group-hover\/entry\:opacity-60 184921 utilities → outranks both on hover

So touch turns the icons on, hover still reveals them, and neither depends on how Tailwind sorts variants. The claim holds; now it is checked rather than argued.

The build also confirms the whole stack (#191#192#193 → this) compiles.

Gates: pnpm lint 0 errors · pnpm typecheck clean · pnpm test 145 files / 1243 tests.

Not verified — needs a browser

  • that the second tooltip in a row actually opens without delay (the mechanism is Radix's; only the provider count is asserted here)
  • tooltip placement and dark mode after the hoist

Contentrain added 2 commits August 14, 2026 01:40
rendering a thousand rows at once

Studio shipped a FlexSearch index, a worker handler, a `searchContent`
function and an `@modelId query` parser — and nothing in `app/` ever
called any of it. The palette's own help promised "search models,
entries, vocabulary" and "@modelId to search within a model"; neither
searched an entry. This wires the engine up rather than writing one.

## Two bugs in the engine, found on the way

- **The model filter was applied after the limit.** The worker asked
  the index for `limit` hits and then dropped the ones from other
  models, so a search scoped to `articles` competed for those slots
  against every other model — and could come back empty while matching.
- **Locale was stored and never filtered.** A Turkish list got English
  hits.

Both are now decided before the cut, in `app/utils/search-results.ts`,
where the order of operations can be stated and tested. Duplicate ids
are dropped too: FlexSearch returns one set per indexed field, and the
same entry was eating several slots.

## The list

A search box that queries the index, so a match on page twenty is found
without paging there. Results intersect with what this locale actually
holds — the index is rebuilt per sync and can briefly name an entry the
payload no longer has.

Rendering is paged at 50. Every row is a `<details>` plus a stateful
Radix dropdown plus three buttons; at 1000 articles that is the cost of
opening a model, not the data. Virtualisation was the other option and
was not taken: a new dependency, and rows change height when they
expand. Paging leaves the row component untouched.

"No matches" and "the index is not ready" are now different states.
`searchContent` resolves to `[]` when the worker is absent, and
reporting that as no results is a lie.

## The palette

Entry hits are filled into their own ref and appended, because
`buildResults` is synchronous and search is not. They go last, and the
selection resets on the synchronous results rather than the final list,
so an async hit landing does not yank the cursor from under someone
already arrowing down.

`SearchResult` carries no title, so each hit is read back and titled
through the same resolver the list uses — without it the palette would
list `f3a81c09d24e`.

Selecting one navigates with `?entry=`, and the list pulls that entry to
the front and opens it. Opening the model alone would have handed
someone a thousand rows to find it in, which is what they searched to
avoid.

Also: the dictionary filter's placeholder said "Filter keys..." while
the filter has always searched keys AND values.
T08b, the half of T08 held back until the list stopped rendering every
row. #193 pages at 50, so this is now safe to land.

`AtomsTooltip` carried its own `TooltipProvider`, which a paged list
turns into 150 of them — 50 rows times three action icons. The count is
the smaller problem. Radix's `skipDelayDuration` only applies within a
single provider, so with one provider per tooltip it never applied at
all: moving from the edit icon to the delete icon re-paid the full open
delay every time. Scanning a row's icons is precisely what the tooltips
were added for.

Hoisted, the second and third open instantly.

`TooltipRoot` throws without a provider and Radix exports no way to ask
whether one is present, so the app root sets a flag. The flag and the
provider live in one component — `AtomsTooltipScope` — because a flag
provided without the provider it describes makes every tooltip in the
app throw, and binding them together means a later edit cannot separate
them. Outside that scope the atom still supplies its own, so a component
mounted alone in a test keeps working.

Delays move to `app/utils/tooltip-timing.ts`. There were three: 200ms in
`InfoTooltip`, 300ms in `ContentStatsBar`, and Radix's own 700ms
default. Which one you got depended on which component you hovered.

Also verified, rather than assumed: `.reveal-on-hover` from #189 does
what it claimed. In the built CSS the base hide sits at byte 10298 in
the components layer, its `@media (hover: none)` override immediately
after at 10332, and `group-hover/entry:opacity-60` at 184921 in
utilities — so touch turns the icons on, hover still reveals them, and
neither depends on how Tailwind sorts variants.
@ABB65
ABB65 force-pushed the feat/tooltip-provider-hoist branch from 152d64b to a38ce56 Compare August 13, 2026 22:43
@ABB65
ABB65 merged commit cffecd9 into main Aug 13, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/tooltip-provider-hoist branch August 13, 2026 23:04
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