Skip to content

feat(content): say what the row icons do, and what the media actually is - #189

Merged
ABB65 merged 1 commit into
mainfrom
feat/action-tooltips-media-preview
Aug 13, 2026
Merged

feat(content): say what the row icons do, and what the media actually is#189
ABB65 merged 1 commit into
mainfrom
feat/action-tooltips-media-preview

Conversation

@ABB65

@ABB65 ABB65 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes T08a and T09. Two complaints, one cause: an icon-only control that never explains itself.

Action icons (T08a)

The three icons on an entry row had no visible label, and the pin — the one nobody guesses — carried a hardcoded English aria-label reading "Pin to context", which names the mechanism rather than the effect. It now reads "Attach entry to chat context", from the dictionary, with the tooltip and the aria-label resolved from one helper so the two cannot drift.

Pin is a toggle, so it also gets aria-pressed and a label naming the direction it will go — "Remove from chat context" once attached.

Inventory came out larger than the brief's:

brief actual
hardcoded aria-label 4 6AssetCard and the colour picker were also carrying one
buttons with no accessible name ~7 9InfoTooltip's own button was one of them

grep -rn 'aria-label="[A-Z]' --include='*.vue' app/ now returns nothing.

Two accessibility bugs found on the way

Both from the opacity-0 that hides the icons until row hover:

  • On a touch device there is no hover, so edit / delete / attach were permanently invisible — while still taking taps. Worse than absent.
  • Tabbing to one left it invisible, focus ring included.

The base hide moved to .reveal-on-hover in the components layer, where @media (hover: none) turns it back on and the existing group-hover utilities still outrank it, plus focus-visible:opacity-100 on each button. Doing it in the cascade rather than as another utility means it does not depend on how Tailwind happens to sort variants — which I did not want to bet on.

Media fields (T09)

An IMAGE row was a 32px thumbnail beside 8d2ed576-57e5-4cab-8f57-bfe52d56ddff.webp.

  • The thumbnail stays 32px — list density is why it is small. The full-size look opens on hover, or on tap where there is no hover (v-model:open + disableClosingTrigger).
  • A storage UUID renders as WEBP · 8d2ed576; a real filename is left alone, because a name someone chose is the best label available.
  • video and file were in the URL branch and printed as raw text. Video now gets a tile and a preview; file gets a tile and no preview, because there is nothing to preview.
  • The large variant is only requested once the tooltip opens — Radix mounts the content then.

The three states that used to look identical

before now
stored asset, loads thumbnail thumbnail + preview
stored asset, 404 broken image error tile + "Asset could not be loaded"
never a stored asset (external URL, custom ref) broken image not fetched at all; "Not a stored asset"

The third is the actual complaint — it is not broken, and rendering it as broken is what made editors report corrupt media for values Studio never held.

Tooltip atom

atoms/Tooltip.vue takes its trigger as a slot, which InfoTooltip cannot — it owns its button. InfoTooltip and ContentStatsBar now delegate to it, retiring the two hand-rolled Radix stacks and their two different delays (200ms / 300ms).

The provider sits inside the atom: TooltipRoot throws without one, so hoisting it to the app root would break any component test that mounts a tooltip-bearing component on its own. The cost is Radix's cross-tooltip skipDelayDuration — which neither ad-hoc stack had either, so nothing regresses; hoisting later is a one-line change.

Also fixed, same files

ContentStatsBar's hardcoded plurals, ContentModelList's locale/locales, ContentCollectionView's entries, and ContentFieldDisplay's Yes/No all went to the dictionary. 14 new keys.

Verified

  • pnpm lint — 0 errors (7 pre-existing warnings)
  • pnpm typecheck — clean
  • pnpm test — 140 files / 1180 tests
  • 10 unit tests on app/utils/media-display.ts, 7 component tests on the media branches, 5 on the atom. The T11 tests already in content-field-display.nuxt.test.ts are intact — I overwrote the file at one point and restored it; the suite now runs 15 there.

Not verified — needs a browser

Everything below is a claim about rendering, and this PR does not prove any of it:

  • the tooltip's placement and dark-mode look on a real row
  • touch behaviour (@media (hover: none), tap-to-preview) — no touch device in the test env
  • the preview staying inside the viewport in the 280px panel (Radix collision handling is configured, not observed)
  • a real broken asset producing the error tile rather than the browser's own broken-image glyph

Out of scope

T08b — per-row tooltips at list scale and touch discoverability inside the list — waits for T05's progressive rendering, per the split agreed in the plan.

Two complaints with one cause: an icon-only control that never explains
itself.

**Action icons.** The three icons on an entry row carried no visible
label, and the pin — the one nobody guesses — carried a hardcoded
English `aria-label` saying "Pin to context", which names the mechanism
rather than the effect. They now read "Attach entry to chat context",
from the dictionary, with tooltip and `aria-label` resolved from one
helper so the two cannot drift. Six hardcoded labels moved to the
dictionary (the brief found four; `AssetCard` and the colour picker were
also carrying them) and nine buttons that had no accessible name at all
got one.

Pin is a toggle, so it also gets `aria-pressed` and a label that names
the direction it will go — "Remove from chat context" once attached.

**Two accessibility bugs found on the way**, both from the same
`opacity-0` that hides the icons until row hover:

- On a touch device there is no hover, so edit / delete / attach were
  permanently invisible — while still taking taps. Worse than absent.
- Tabbing to one left it invisible, focus ring included.

The base hide moves to a `.reveal-on-hover` class in the components
layer, where `@media (hover: none)` turns it back on and the existing
`group-hover` utilities still outrank it, plus `focus-visible:opacity-100`
on each button. Doing it in the cascade rather than as another utility
means it does not depend on how Tailwind happens to sort variants.

**Media fields.** An `IMAGE` row was a 32px thumbnail beside
`8d2ed576-57e5-4cab-8f57-bfe52d56ddff.webp`. The thumbnail stays that
size — list density is the reason it is small — and the full-size look
now opens on hover, or on tap where there is no hover. A storage UUID
renders as `WEBP · 8d2ed576`; a real filename is left alone.

`video` and `file` were in the URL branch and printed as raw text. They
are media now: video gets a tile and a preview, file gets a tile and no
preview, because there is nothing to preview.

And the three outcomes that used to look identical are now distinct: an
asset that loads, a stored asset that 404s (an error — Studio put it
there), and a value that was never a stored asset at all. The third is
not broken, and is no longer fetched just to fail; it says so instead.

New `atoms/Tooltip.vue` takes its trigger as a slot, which `InfoTooltip`
could not — it owns its button. `InfoTooltip` and `ContentStatsBar` now
delegate to it, retiring the two hand-rolled Radix stacks with their two
different delays; `ContentStatsBar`'s hardcoded plurals went to the
dictionary with them. `InfoTooltip`'s button had no accessible name
either.

Media string handling is in `app/utils/media-display.ts` — decisions
about a string, better described by a table of inputs than by a mounted
component.
@ABB65
ABB65 merged commit 2c5a00e into main Aug 13, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/action-tooltips-media-preview branch August 13, 2026 21:10
ABB65 pushed a commit that referenced this pull request Aug 13, 2026
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 added a commit that referenced this pull request Aug 13, 2026
* feat(content): connect the search that was already built, and stop
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.

* perf(ui): one tooltip provider for the app, not one per tooltip

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.

---------

Co-authored-by: Contentrain <mcp@contentrain.io>
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