Skip to content

feat(shell): the tooltips XP had, on the chrome that only had a title= - #319

Merged
oddharsh merged 3 commits into
mainfrom
claude/tooltip-improvements-2f648b
Aug 11, 2026
Merged

oddharsh merged 3 commits into
mainfrom
claude/tooltip-improvements-2f648b

Conversation

@oddharsh

@oddharsh oddharsh commented Aug 10, 2026 •

Copy link
Copy Markdown
Owner

Every control in the desktop shell carries a title, which buys the OS tooltip: one line, in the system font, after a delay nobody chose. Windows itself was richer than that. An Explorer infotip named a folder's contents ("Size: 110 MB / Folders: … / Files: …"), the tray's tooltips were live readouts, the clock's was the full date, and the ToolTip control put a line under a field saying what it wanted before you got it wrong.

holding/infotip.js draws those two shapes in Luna, on: taskbar app buttons, tray icons, the clock, the sound toggle, the Start orb, desktop icons, title-bar controls, and any form field carrying a tip.

What it says, and where each line comes from

Nothing is decorative and nothing is guessed. A row with no value is dropped rather than filled in, the same rule the photo tooltip's EXIF rows follow.

surface rows source
taskbar pin Type / Contains N pages / Status: Running nav.js's own destination table; the pressed state the taskbar already paints
desktop icon Type / Target / Contains N notes / Opens the href, plus posts.json for Notepad
tray Colo / From / Transport, Firewall, Build / Latest the same whoareyou.json + updates.json their click-balloons fetch — one fetch per page, shared
clock the full date + zone the clock
field one line the field's own title

How it treats title

It re-draws the attribute rather than replacing it: emptied on the way in, restored on the way out. AT still reads it at focus, and a page with JS off keeps the native tooltip. [data-tip] is the explicit opt-in for a control that never had a native one. Stashing lives in the module and not in the loader on purpose — a loader that stripped titles itself would leave them stripped if the module never arrived.

Field tips land on the three /coffee inputs, both /lens address bars, and /search.

hoist.js

Gains Windows' two delays, both defaulting OFF so the content surfaces (photos, tracks, the Run preview) keep the instant show they were tuned for:

  • openMs — 400ms cold dwell (XP's SPI_GETMOUSEHOVERTIME), skipped while a tip is already up, which is XP's reshow behaviour and what makes sweeping a row of buttons feel instant.
  • autopopMs — 6s, which also covers the one dismissal a pointer cannot: a cursor that leaves the viewport for another window fires no pointerout.

The dwell is also what hides the lazy load, since the import runs during the wait.

Cost

1,713 B brotli, loaded on the first chrome hover, never on a coarse pointer. nav.js grew 1.1 KB minified and owns the target selector, passing it to the module — two copies is how a loader and its module come to disagree about what has a tip. Derivation (the page counting) sits on the lazy side for the same reason.

/nav.js's route-oracle ceiling goes 50,000 → 65,000. It is a did-the-build-minify tripwire rather than a byte budget, and a 241-byte gap turns it into one that fails on whichever unrelated change happens to cross it. A readable nav.js is ~98 KB, so it still catches the bypass it exists for; perf-budget.mjs is where a real budget belongs.

Verified

pnpm run build, 212 contract tests (3 new), perf-budget green, routes:check 122/122, cal's 59 tests, typecheck. Exercised in a browser against wrangler dev: pin, tray, clock, desktop icon, and field tips all render with live data, and a field's title is confirmed restored after the pointer leaves.

Content tooltips too (second commit)

The rule is the short one now: any [title] is an infotip, wherever it lives. The shell-only list meant the promise was kept in the chrome and broken two inches lower, on the citation links and demo controls that had titles all along. What the shell selectors still buy is the title-LESS chrome (the clock has no attribute to read) and the families whose card is built from live state.

A titled link that leaves the site gets the shortcut treatment — the title stays the tip, and Target names the host, path trimmed to ~46 chars with a visible ellipsis (darko.audio/2018/04/dial-m-for-multi-bit-schi…). The host is the decision; a 70-character slug wrapped over three lines is not, and a silently shortened URL in a tooltip about where a link goes is the one dishonesty this surface cannot afford. Same-origin links keep the bare line. An untitled link still gets nothing.

Four richer surfaces are skipped by name: tooltip.js's photos/tracks/artists/car links, /lens's glossary terms, serendipity's event covers. .lx-term is the sharp one — those ship a title as their no-JS fallback and lens.js strips it once its own surface is live, so without the skip a race between two lazy modules would decide whether you got the glossary card or a flat line. Verified: hovering a lens term opens its own card and the infotip module never loads.

Two consequences worth naming:

  • "What was hovered" is now one function, passed from nav.js to the module. The skip list made the rule too big to keep honestly in two places.
  • nav.js retires its loader listeners once the module starts. With the selector matching every titled element, leaving them attached would cost a second closest() walk per pointerover for the rest of the session, on a path that fires at every element boundary.

Escaping is now load-bearing, because some of those strings are not ours (/inbox carries webmention titles; /around and /reading carry crawled text). Measured in a browser: a title holding <img src=x onerror=alert(1)> renders as text and creates no element. A contract test pins every interpolation to esc().

Re-verified: build, 214 contract tests (5 new), perf-budget green, routes:check 122/122. nav.js minified is 49,866 B.

🤖 Generated with Claude Code

Every control in the desktop shell carries a `title`, which buys the OS
tooltip: one line, in the system font, after a delay nobody chose. Windows
itself was richer. An Explorer infotip named a folder's contents; the tray's
tooltips were live readouts; the clock's was the full date. /infotip.js draws
those in Luna, on the taskbar app buttons, the tray, the clock, the desktop
icons, the title-bar controls, and any form field carrying a tip.

Nothing it says is decorative. A pin's "Contains: 19 pages" is counted from
nav.js's own destination table, the tray's colo and build come from the same
JSON its click-balloons already fetch (one fetch per page, shared), Notepad's
note count from posts.json, the clock's date from the clock. A row with no
value is dropped rather than filled in, the same rule the photo tooltip's
EXIF rows follow.

It re-draws `title` rather than replacing it: the attribute is emptied on the
way in and restored on the way out, so AT still reads it at focus and a page
with no JS keeps the native tooltip. `[data-tip]` is the opt-in for a control
that never had one. Field tips land on the three /coffee inputs, both /lens
address bars, and /search.

hoist.js gains Windows' two delays, both defaulting OFF so the content
surfaces keep the instant show they were tuned for: openMs (400ms cold dwell,
skipped while a tip is already up, which is XP's reshow behaviour) and
autopopMs (6s, which also covers the dismissal a pointer cannot — a cursor
that leaves the viewport fires no pointerout). The dwell is what hides the
lazy load, since the import runs during the wait.

1,713 B brotli, loaded on the first chrome hover and never on a coarse
pointer. nav.js grew 1.1 KB minified and owns the target selector, which it
passes in: two copies is how a loader and its module come to disagree about
what has a tip.

/nav.js's route-oracle ceiling goes 50,000 -> 65,000. It is a did-the-build-
minify tripwire, not a byte budget, and a 241-byte gap turns it into one that
fails on whichever unrelated change crosses it; a readable nav.js is ~98 KB,
so it still catches the bypass it exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh 99ec663 Aug 11 2026, 10:44 AM

@github-actions

github-actions Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

d98bcc4 → d830a46

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

Total Brotli: 60.97 KiB → 63.56 KiB (+2.59 KiB, +4.2%) across 13 files, 5 changed.

file base head Δ
infotip.js — 1.88 KiB new
nav.js 13.44 KiB 13.84 KiB +0.40 KiB (+3.0%)
luna.css 8.88 KiB 9.04 KiB +0.16 KiB (+1.8%)
hoist.js 0.89 KiB 1.03 KiB +0.14 KiB (+15.3%)
tooltip.js 2.59 KiB 2.60 KiB +0.01 KiB (+0.5%)

Pages

Total Brotli: 476.77 KiB → 476.63 KiB (-0.14 KiB, -0.0%) across 46 files, 41 changed.

No single file moved by 0.13 KiB or more.

Plus 41 files under the 0.13 KiB floor, net -0.14 KiB — usually content-hash churn in /a/ references, counted in the total above.

Worker bundle

269.18 KiB → 269.52 KiB gzip (+0.34 KiB, +0.1%)

Largest module deltas (raw bytes in bundle):

file base head Δ
.build/cal/src/templates.js 18.23 KiB 18.70 KiB +0.46 KiB (+2.5%)
.build/holding/_worker.js/lens.js 151.33 KiB 151.52 KiB +0.19 KiB (+0.1%)
.build/holding/_worker.js/search.js 4.96 KiB 5.05 KiB +0.09 KiB (+1.9%)

Dictionary deltas (pd/)

134 → 134 deltas, 405.30 KiB → 405.54 KiB (+0.24 KiB).

oddharsh and others added 2 commits August 11, 2026 06:26
…e chrome

The first pass drew Luna infotips for the desktop shell and form fields. That
left the rule half-kept: a titled citation link on an /lwe page and a taskbar
button are the same promise to a visitor, and one of them still got the OS
tooltip. The rule is the short one now — ANY [title] is an infotip, wherever
it lives — so the shell selectors are left holding only what they alone can
answer: the title-LESS chrome (the clock has no attribute to read) and the
families whose card comes from live state rather than from the string.

A titled link that LEAVES the site gets the shortcut treatment: the title
stays the tip, and Target names the host, trimmed to about 46 characters with
a visible ellipsis. The host is the decision a reader is making; a 70-char
slug wrapped over three lines is not, and a silently shortened URL in a
tooltip about where a link goes is the one dishonesty this surface cannot
afford. Same-origin links keep the bare line, since a path is neither news
nor a risk. An UNtitled link still gets nothing at all.

Four richer surfaces are skipped by name (tooltip.js's photos/tracks/artists/
car links, /lens's glossary terms, serendipity's event covers), because each
already draws its own card from the same engine. `.lx-term` is the sharp one:
those ship a `title` as their no-JS fallback and lens.js strips it once its
surface is live, so without the skip a race between two lazy modules would
decide whether you got the glossary card or a flat line. Verified in a
browser: the lens term opens its own card and the infotip module never even
loads on that hover.

"What was hovered" is now ONE function, passed from nav.js to the module
rather than restated as a selector on each side — the skip list made the rule
too big to keep in two places honestly. nav.js also retires its three loader
listeners once the module starts: with the selector matching every titled
element, leaving them on would run a second closest() walk per pointerover
for the rest of the session, on a path that fires at every element boundary.

Escaping matters more now that any page's strings reach the surface, and some
of them are not ours (/inbox carries webmention titles, /around and /reading
carry crawled text). Measured in a browser: a title holding
`<img src=x onerror=alert(1)>` renders as text and creates no element. A new
contract test pins every interpolation to esc() so a later edit cannot open
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oddharsh
oddharsh enabled auto-merge (squash) August 11, 2026 10:43
@oddharsh
oddharsh merged commit 3dafa46 into main Aug 11, 2026
6 of 7 checks passed
@oddharsh
oddharsh deleted the claude/tooltip-improvements-2f648b branch August 11, 2026 10:44
oddharsh added a commit that referenced this pull request Aug 11, 2026
#319 failed this check twice the same way, sixteen minutes apart, on the
feature head and then on a merge commit that only pulled main in. Different
Request IDs, identical CAPIError. Worth stating outright, because an outage
that reproduces on whatever you push next is the case where someone starts
bisecting a diff for the cause of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oddharsh added a commit that referenced this pull request Aug 11, 2026
…t on (#321)

Gotcha 27 recognised a Copilot Autofix crash from the outside: a null output
title, an annotation at a directory, a repeating line number. #319's job log
gives the actual error instead:

  CAPIError: 400 The requested model is not supported.

The review agent asked GitHub's own model API for a model that API refused,
threw inside runAgenticLoop, and exited 1. It never read the diff, so the
check reports an outage in Copilot's model routing and no change to a branch
can turn it green. The note now leads with that, keeps the fingerprint as
history, and gives a test that names the failure: grep the job log for
CAPIError. A real finding leaves that empty and posts an inline review
comment on a source line instead.

Three commits across two PRs in twenty-one minutes hit it, including this
one, whose entire diff is the paragraph describing it.

Also recorded: the workflow is dynamic/agents/github-advanced-security and
lives nowhere in .github/workflows/, and a re-notification can arrive for a
run that already failed hours earlier, so compare id and completed_at before
investigating.
oddharsh added a commit that referenced this pull request Aug 11, 2026
…ore (#323)

#319 shipped and ramped to 100% with nothing staged, so /updates and /restore
stopped at v182 while production served the tooltips. This is the row that
closes the gap.

The title names three things a visitor can go and check rather than
describing the implementation, because all three are read from live data at
hover time: a taskbar button reporting its page count, the tray reading out
the colo serving that request, the clock spelling out the date.

bump-version.sh writes this file and nothing else. /updates renders the
committed projection at build time, so the entry appears when this ships, and
deploy:promote inserts it into D1 when traffic reaches 100%.
oddharsh added a commit that referenced this pull request Aug 11, 2026
…nd /restore (#327)

#325 shipped the cracker without its changelog entry. bump-version.sh says
to run it inside the PR being released; this is the follow-up that should
have been part of that one, in the same shape as #323 was for #319.

Projection only. /updates and /restore render the committed projection at
BUILD time, so the entry has to reach main before the production build for
the page to show it; deploy:promote writes the D1 row when traffic actually
reaches 100%.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
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