visitor@nextdash: ~/posts — Hover cards in Nextdash v1.3.2 – nextDash 80×24

nextDash

MIT

Your bookmarks. Your terminal. Your rules.

visitor@nextdash:~/posts$ cat hover-cards-in-nextdash-v1-3-2.md

hover-cards-in-nextdash-v1-3-2.md 18.4K

-rw-r--r-- jordi

Hover cards in Nextdash v1.3.2

16 min

A hover card that printed the address twice, hid the one state worth acting on, and slid away when you reached for it. This release rebuilds it into three bands that answer three questions — and gives it everything the rest of the app already knew about the link.


Hover a bookmark on the old dashboard and a card appeared. It showed you the title, the full URL in mono, the hostname on the next line, a picture, and the description the page had advertised about itself. Seven stacked divs, two of them saying the same thing.

The row underneath it was already showing more. The status dot knew whether the site had answered this morning. The Fresh count knew whether it had published anything. The shortcut letter knew how to reach it without a mouse. The card explaining the row knew less than the row.

v1.3.2 rebuilds it. The card is three bands now, in a fixed order, each absent rather than blank; it reads from what the app already holds in memory, so it knows about uptime, certificates, opens and Fresh; and how you reach it is a choice of three rather than a switch. v1.3.1, the week before, was a different kind of release — Fresh made to actually work, and the bookmark list made to survive a real library — and the second half of this article covers it.


01. Three bands, three questions

The card answers what is this, what does it say, and what do I know about it, in that order, so your eye lands in the same place every time.

The header — what this is. The favicon, the title, the address once, and a status pill when there is a check to report. The old card printed the full URL in mono and then its hostname on the line below: two of seven rows saying the same thing, neither of them clickable.

The body — what it says. The picture, the description fetched from the page, your note, and the tags. Your note is labelled and marked with a rule in the accent colour. It used to be rendered in the same tertiary grey as the site’s own marketing copy, and truncated twice — cut at 140 characters in JavaScript, mid-word, and then clamped to three lines in CSS on top of that. The one line on the card that nobody but you could have written was the faintest thing on it.

The facts strip — what you know about it. A definition list of everything nextDash has recorded, which is section 02.

Every band is absent rather than blank. A bare link — no picture, no description, no note — is a short card: a header, and the one fact row it can still answer (Never opened · saved 3 months ago, most likely), instead of a title floating over eighty percent empty space. Switch the fact rows off as well and it says No description was fetched from this page rather than drawing nothing.

And nothing is cut off silently any more. The card was max-height: min(360px, …) with overflow: hidden: a picture, a title, a description, a note, tags and three fact rows passed that easily, and the last rows simply were not there. The blocks clamp themselves now, so the card is only ever shorter for a reason you can see.


02. The card knows what nextDash knows

This is the part that changes what the card is for. It is no longer a bigger tooltip; it is the row’s own file.

Row What it says
Checked The last availability check: 84 ms, and how long ago. Amber past 1500 ms.
Failing Instead of Checked, when the last check failed: the error, and how long it has been failing.
Uptime Over the last thirty days, as a percentage — shown only when there are samples behind it, because no data and 0% up are different answers.
Cert Expires in 9 days — and only within thirty days of expiry. Red inside fourteen.
Fresh 3 new and when the last entry landed, or Nothing new for a page that publishes but has not.
Opens The count and when you last opened it — or Never opened, with when you saved it.
Key The shortcut letter, and where the bookmark lives: Work › Reference.

A hover asks health for nothing. That is the design constraint the whole strip is built around, and the interesting half is uptime and certificates: those live in the health report, which is a server request of its own.

It turns out no request was needed. The dashboard already fetches the health report on every load, to put a number on the health icon — a row per bookmark, carrying monitor statistics and certificate expiries — and it read twelve counts out of that and dropped the rest. The badge now keeps the four facts worth carrying (uptime over thirty days, certificate expiry, how long something has been failing, and the error), keyed by canonical URL, and the card reads them from memory. A card you pinned may fetch the report once, as a fallback for a dashboard with the health icon switched off. (The card’s text and picture are a separate matter: a bookmark whose preview has never been fetched still causes one /api/bookmark-preview request, hover or not, and the answer is stored.) When the health view itself is open its report wins, because it is the fresher of the two, and refreshing there updates what the cards quote.

While that index was being built, the request behind it got fixed too. The health badge was downloading the entire report on every dashboard load — every bookmark’s name, tags, scores and reasons, plus the duplicate groups, the daily trend and the fleet view — for twelve numbers. It asks ?view=facts now: the counts, plus only the bookmarks that have something to report. Measured against the library it was tried on: 8,424 bytes down to 458.


03. Peek and pinned are two different things

The old card tried to be both a thing you glance at and a thing you use, and failed at both: it repositioned itself on every pixel of mousemove while claiming to be reachable. Moving toward its refresh button moved the card away from your pointer, and on a dense grid it slid across the rows underneath.

Peek is what hovering gives you. It takes no pointer events, carries no buttons, and is anchored to the row it describes — there is nothing to aim at, so there is no reason for it to move. It is something to read.

Pinned is asked for. Shift + V on the focused row (the old [ still works) opens a card that is a real dialog: focusable, with Copy, Refresh and Edit in a footer, closing on Escape and handing focus back to the row it came from. Only a pinned card is allowed to ask the server for the health figures.

Turning cards on also used to take the description away from screen readers, which is the kind of bug that hides in a feature nobody tests with the mouse unplugged. The card removes the row’s title so the browser tooltip does not sit on top of it — and hovering is mouse-only, so assistive tech was left with nothing at all. The text now moves into a hidden element the row points at with aria-describedby.


04. Three ways to reach it, not a switch

Config → Appearance → Display → Link preview cards is a panel of its own now, and the mode is three labelled choices rather than a checkbox:

Off The row keeps its ordinary tooltip.
On hover Appears beside the row after the delay below. The default.
Keyboard only Never appears on its own; Shift + V opens it for the selected row.

Keyboard only exists because “I want what the card says” and “I do not want a panel appearing under my pointer” are different objections, and off was the only answer to either — which throws away the whole feature to avoid one behaviour of it.

The hover delay starts calm, at 250 ms rather than 150. A card that opens the moment the pointer crosses a row opens on every row you were only passing over on the way somewhere else, and now that the card carries six kinds of information, the cost of opening one you did not ask for is higher than it was. Fast (100 ms) and Balanced (150 ms) are still there, and an install that had chosen one of the three delays that actually worked keeps it. The four that never took effect land on Calm.

The list used to read Instant, 200 ms, 400 ms, 700 ms, 1 s while the code accepted 100, 150 or 250 and quietly rewrote everything else to 150. Four of the five options did nothing at all. It offers the three that work.

A checklist of the eight rows, so a reader who writes no notes never sees a note row and one who monitors nothing never sees a status row: image, description, your note, tags, status & uptime, opens & last opened, Fresh count, shortcut & location.

And the card itself, live. Beside the checklist, a real card drawn from one of your own bookmarks, redrawn as you tick — using the card’s own drawing code, not a mock-up free to drift from it. This was the setting you otherwise had to leave the screen and go hover something to understand.

:preview on and :preview off still work from the search bar, and off followed by on now puts back the mode it took away, rather than moving someone who chose keyboard only onto hover.


05. The fixes that were the point

Several of these are the actual reason for the rebuild.

  • “Never opened” is a row. if (openCount > 0) hid the usage line — so the most interesting state a bookmark can be in, saved and never returned to, rendered as nothing at all. It now reads Never opened · saved 3 months ago, in amber.
  • A dead thumbnail leaves no gap. The image was hot-linked with no onerror and its wrapper shown before it loaded, so a rotted og:image drew the browser’s broken-image glyph in a 150-pixel band — in the one application that exists to notice rot.
  • The card no longer chases the cursor. Anchored to its row, which is what the keyboard path always did.
  • One payload, one card. What the card draws was assembled by hand in three places — hover, the keyboard toggle, the refresh button — so every new field meant three edits, which is why there had been none. buildPreviewPayload() is the only answer now.
  • Entities are decoded. Titles and descriptions come out of content="…" attributes where an apostrophe is written '. The server unescapes what it fetches now, and the card decodes what was stored before this release — through a table and numeric escapes, never innerHTML: this is text from someone else’s page, and it does not become markup on the way through.

Elsewhere in 1.3.2

  • An edit no longer resets a bookmark’s statistics. The dashboard saves a page by POSTing the whole list back, built from what the browser held in memory — so anything the server had written since the page loaded was silently absent, and the save wrote that absence. Open a bookmark, then edit any bookmark on that page, and the open count went to zero; last check, error, fetched preview text and certificate host went with it. carryServerOwnedBookmarkFields() puts back every field the payload does not carry, matched on canonical URL. A value the payload does carry still wins, so an import keeps its own counts and a re-test still clears the error it just cleared.
  • “Show in Health” lands on the row you asked for. The health list selects the row under the pointer — and a row arriving under a cursor that never moved is not a hover, though the browser reports it as one. Hovering now has to be earned by an actual pointermove after a row is focused by name.
  • The inline editor takes the clicks aimed at it in Safari. Every non-editing row’s children carried filter: blur(), which gives each column its own compositing layer, and WebKit hit-tests those in paint order rather than by z-index — so a blurred neighbouring column was painted over the tall inline form and ate every click on its fields. At 18% opacity the blur was doing almost nothing visible, so it is gone. The rows stay dimmed.
  • The Pin entry in the context menus is drawn, not typed. Every other entry is a glyph tinted with the accent colour; Pin was 📌, painted by the system font in its own red and yellow on every theme, deaf to the red a destructive row is given. It is an SVG in currentColor now.
  • Duplicate locale keys now fail the build. bookmarksLoading and bookmarksKeysHint were each written twice in the same object, in all four languages — a JSON parser keeps the last one and says nothing, so the earlier line was a string that looked live and could never render. npm run validate:locale-duplicates catches it, reported by path and line.

06. And in 1.3.1, the week before

1.3.1 was two things: Fresh made to work, and the bookmark list made to survive a real library.

Fresh, made to work

Fresh shipped in 1.3.0 depending on a setting most installs have switched off — link previews — because it learned where a feed lived only as a side effect of fetching one. An install with previews off never fetched a page, never discovered anything, and answered “nothing new” forever.

  • It looks for its own feeds now: it reads the head of each saved page itself, records the pages that have none so they are not asked again for a month, and polls what it found. Find feeds now repeats the round on demand, and the panel says what came of it — 7 of 7 bookmarks asked · 0 publish a feed, which is an answer and not a fault.
  • It polls on an hour of its own, rather than borrowing the health recheck interval, which can be switched off entirely and can run as slowly as once a day.
  • You can see which bookmarks take part, three ways: a Feed line in the bookmark editor, status:feed in search, and an optional quiet dot on rows that publish (off by default).
  • Fresh is a tab of its own under Behavior, and introduces itself with a card and a four-step walkthrough that ends on the switch.

The separate Fresh article on this site covers the whole of it — what it costs, which bookmarks take part, and what to do when nothing shows up.

Ctrl/Cmd+click opens a new tab

As it does on every other link on the web. It used to tick the row for a bulk action, with preventDefault — so the one modifier every browser honours did the opposite of what it does everywhere else, and on a Mac it came with the row menu on top, Ctrl+click being the platform’s secondary click. The mouse route into a selection is Alt+click now, on the grid and in the health view alike. Shift+click still extends a range and x still ticks the row under the cursor.

The bookmark list, for real libraries

  • The list draws a screenful, not the whole library. Infinite scroll answered how much is fetched and nothing about how much is painted: a row is thirty-four elements, so a thousand rows is thirty-four thousand nodes, and five thousand is a third of a second of layout on every repaint — a tag added, a row ticked. Rows near the viewport are drawn and the rest are two spacers of the right height, so the scrollbar still describes the whole list. Two thousand rows go from tens of thousands of nodes to a screenful — measured at sixty-eight thousand down to about a thousand, and the build from 88 ms to 3 ms. Off at a hundred and twenty rows and under, where the arithmetic buys nothing.
  • The filters are in the address bar. Query, category, tag, cleanup filter and sort ride in the hash, so “the 41 untagged on Work” is a link you can hand to someone, and a reload does not throw it away.
  • An empty list says which filter emptied it, rather than blaming your search whatever was actually narrowing.
  • A selection survives a filter change, and the bulk bar is honest about it: 12 selected (3 not shown by this filter).
  • Undo for bulk tags, pins and availability, on the same eight-second toast bulk delete already had — restoring the pages as they were, since replace cannot be reversed by reasoning about what it did.
  • One answer to “which bookmarks.” status:untagged in the search bar and the Without tags cleanup filter are the same question, and they were written twice, so they were free to disagree — a tag of nothing but spaces counted in one place and not the other, and Statistics had a third opinion. Fourteen of those questions now come from one registry, bookmark-predicates.js.
  • Typing in the search box no longer throws away how far you had scrolled.

Statistics

  • What this says — Overview now leads with the three things that follow from the figures, each with the button that acts on it, and nothing at all when there is nothing to report. A count you cannot act on is trivia.
  • The tiles carry a direction: +7 this week when it moved, nothing when it did not, read from the daily points the health report has been recording all along through a new /api/health/trend.
  • The activity chart in a sentence, above the bars — how long the window is, where the busiest point sits, and how many bookmarks it accounts for. A screen reader had thirty numbers and no shape.
  • The CSV carries every tab. Health and Inbox load when their tab is opened, so an export from Overview silently left two of five tabs out of a complete-looking file.
  • computeStats() walked every bookmark five times over on every paint. Once per paint now, shared by six callers.

Health

  • The row menu is as tall as its contents. It capped at 384px, so a row whose repair options ran long put a scrollbar inside a context menu — and a menu you have to scroll is a menu whose last item nobody finds. The menu flips above its trigger when there is no room below instead. The window is the only cap left.

At a glance

Setting Config → Appearance → Display → Link preview cards
Modes Off · On hover (default) · Keyboard only
Hover delay Fast 100 ms · Balanced 150 ms · Calm 250 ms (default)
Pin a card Shift + V on the focused row ([ still works)
Pinned card Copy, Refresh, Edit · Escape closes and returns focus
Card bands Header · body (image, description, note, tags) · facts strip
Facts Checked/Failing, Uptime 30d, Cert expiry, Fresh, Opens, Key & location
Checklist Eight rows, individually switchable, with a live card beside them
Cost of a hover No health request — the facts ride along with the badge’s report
Endpoints GET /api/bookmark-preview · GET /api/bookmark-health?view=facts
Command :preview on / :preview off
Since v1.3.2 (21 August 2026)

Where to go next

Tags: nextdash, updates, link previews, dashboard, bookmarks, accessibility

online uptime 26d 11 posts utf-8 wp 7.1