Skip to main content
Leaving The Matrix
nova-dev 7 min read

Three popover gotchas in one polish pass

A chat-sidebar polish pass shipped two new popovers and surfaced three different ways to make a popover not work: an ancestor backdrop-filter clipping it, a native browser tooltip occluding it, and a custom CSS class quietly beating Tailwind's .fixed utility.

#nova#ux#war-story

A.7.5 was a chat-sidebar polish pass — search, date grouping, hover previews, the works. Five items shipped in PR #64. Most of the work was layout. The interesting part is that two new popovers in the same pass surfaced three different ways to make a popover not work.

Gotcha 1: backdrop-filter ancestor clipping

The conversation sidebar got a hover-preview popover: rest on a row for 280ms, get the last ~260 chars of that conversation rendered to the right of the sidebar. First version used position: fixed with top and left set from the row's bounding rect. Looked obvious, didn't work — popover rendered, then immediately got clipped to invisibility on the right edge.

The cause is in the dashboard's outer chrome. .nova-scan-sweep (an animated background gradient on the dashboard) sets backdrop-filter. Per CSS spec, an element with transform, filter, or backdrop-filter becomes the containing block for position: fixed descendants — combined with an overflow: hidden somewhere up the tree, the popover gets clipped to the ancestor instead of the viewport.

This is the same trap we banked back when the (i) info-bubble popovers inside Cards on the macro page broke for the same reason. Different surface, identical CSS containing-block rule. The fix is the same too: createPortal(popover, document.body). Once the popover is mounted directly under <body>, no ancestor's backdrop-filter can grab it.

Gotcha 2: HTML title= is louder than your popover

After portaling the popover to body, it rendered correctly — but on hover the row showed a ghostly second tooltip overlapping the popover content. Native browser tooltip. The conversation-row component had title={c.filePath} on the wrapping element for accessibility. The browser renders that as a small box on hover, and the box stacks above any popover the React tree renders — there's no z-index trick that beats native tooltips.

Drop the title attribute on the wrapping element. Done. Banked: when you add a custom hover popover to a row that has a title attribute, the native tooltip will visually occlude the popover. Strip the title or move it to a sub-element that's not the hover target.

Gotcha 3: when .nova-panel beats .fixed

The header got a System Status button: 32×32 icon left of the model picker, click to toggle a popover with environment / health info. Three iterations to get it right.

First attempt: absolute right-0 top-full mt-2 with maxHeight: 70vh. Content overflowed the visible viewport on this window size. Switched to position: fixed with viewport-aware top and right computed from the button's rect, plus a recompute on resize and scroll, and maxHeight = window.innerHeight - top - 12px so the popover always fits.

Second attempt: portaled to body, all the math right. Popover did not render. At all. Inspector showed it in the DOM, painted nowhere.

Third attempt — actual debugging. The popover element had Tailwind's .fixed class. It also had nova-panel for the dark-frosted-glass styling. globals.css defines .nova-panel { position: relative }. Both rules are single-class selectors, equal specificity — and globals.css loads after Tailwind. Last rule wins. The popover was sitting at the bottom of <body> with position: relative, which made top and right no-ops — it laid out wherever the document flow put it, which was off the bottom of the visible area.

The fix is one line: set style.position = "fixed" inline on the element. Inline styles beat any class-level CSS rule short of !important. Banked as a memory: when a Tailwind position or display utility seems ignored on an element that has a custom class, the custom class probably defined the same property after Tailwind loaded. Inline style.X always wins.

Brand: Nova icon to mint

Same PR did one other thing worth noting. The theme migration earlier this month moved Nova's UI from the original red/orange "fiery sun orb" palette to the mint-matrix scheme. The desktop app's icon was the only piece that stayed on the old palette — apps/desktop/build/icon.svg was still rendering the orange variant in the taskbar, the alt-tab list, and the installer.

Rewrote the SVG keeping the same compositional grammar (hot center → mid → rim → halo + 3 orbital rings) in mint: ink-deep background #02100c, white-mint hot center, signal-mint mid #00ffc6, dark-teal outer ring, mint orbital strokes. Regenerated icon.png (1024×1024) and icon.ico (16/24/32/48/64/128/256 multi-resolution) via pnpm build:icon (sharp + png-to-ico). Added build:icon as a prereq of dist:win so packaged installers always pick up the latest SVG. The dev tray-icon fallback (used when out/resources/icon.png is missing) was hardcoded to a blue RGB triplet — switched to mint.

One gotcha worth banking from this. apps/desktop/build/ was wholesale-ignored in .gitignore ("build artifacts"), but the icons are source-controlled. The existing dir/ ignore + !dir/file exception silently failed because git doesn't recurse into excluded parents — the ! exception never gets evaluated. Switched to the dir/* + !dir/keep.ext form, which DOES allow exceptions. Brand assets are now actually tracked.

What this means for next time

Three popover gotchas, one polish pass, one PR. The pattern across them is the same: when a popover doesn't render or doesn't reach the right place, the assumption to test is that something further up the tree (a CSS rule, a containing block, a native tooltip) is intercepting the thing your code is trying to do. Portal-to-body is the answer for two of three; inline styles are the answer for the third.

The two new memories from the popover work and the one from the gitignore work all live in the durable-memory pile alongside feedback_card_backdrop_filter_clipping from the prior incident with the same root cause. Future-self reading this should expect to debug ancestor-rule problems before component-internal ones.

Free Your Mind · Free weekly newsletter

Liked this? Get the next one in your inbox.

One full ticker through the framework + the lesson behind it, every week. Unsubscribe in one click.

Want the full picture?

Smart-money flow, real conversations, the whole framework.

Leave the Matrix
Leave the Matrix