From c8e3ed2f70ce65d6aa4c15e04999c36f1b00c840 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Tue, 16 Jun 2026 14:39:37 +0530 Subject: [PATCH 1/6] feat: replace status-dot title tooltip with StatusDotTip floating hover-card Adds floating-ui-based StatusDotTip component with docs link and PR-phase "Open PR" link; replaces native HTML title on all three dot surfaces. --- app/frontend/package.json | 1 + app/frontend/pnpm-lock.yaml | 54 +++++ .../src/components/status-dot-tip.tsx | 171 ++++++++++++++++ .../src/components/status-dot.test.tsx | 65 +++++- app/frontend/src/components/status-dot.tsx | 134 +++++++----- app/frontend/tests/e2e/status-dot-tip.spec.md | 87 ++++++++ app/frontend/tests/e2e/status-dot-tip.spec.ts | 160 +++++++++++++++ docs/memory/run-kit/index.md | 2 +- docs/memory/run-kit/ui-patterns.md | 28 ++- .../.history.jsonl | 11 + .../.status.yaml | 51 +++++ .../260616-37ub-status-dot-tooltip/intake.md | 136 +++++++++++++ .../260616-37ub-status-dot-tooltip/plan.md | 192 ++++++++++++++++++ 13 files changed, 1030 insertions(+), 62 deletions(-) create mode 100644 app/frontend/src/components/status-dot-tip.tsx create mode 100644 app/frontend/tests/e2e/status-dot-tip.spec.md create mode 100644 app/frontend/tests/e2e/status-dot-tip.spec.ts create mode 100644 fab/changes/260616-37ub-status-dot-tooltip/.history.jsonl create mode 100644 fab/changes/260616-37ub-status-dot-tooltip/.status.yaml create mode 100644 fab/changes/260616-37ub-status-dot-tooltip/intake.md create mode 100644 fab/changes/260616-37ub-status-dot-tooltip/plan.md diff --git a/app/frontend/package.json b/app/frontend/package.json index f723fafb..79c97866 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -12,6 +12,7 @@ "test:e2e": "playwright test" }, "dependencies": { + "@floating-ui/react": "^0.27.19", "@tanstack/react-router": "^1.168.22", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", diff --git a/app/frontend/pnpm-lock.yaml b/app/frontend/pnpm-lock.yaml index e5dce0e4..9e3d4b5d 100644 --- a/app/frontend/pnpm-lock.yaml +++ b/app/frontend/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@floating-ui/react': + specifier: ^0.27.19 + version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router': specifier: ^1.168.22 version: 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -345,6 +348,27 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1405,6 +1429,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -1800,6 +1827,31 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/utils': 0.2.11 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2733,6 +2785,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.4.0: {} + tagged-tag@1.0.0: {} tailwindcss@4.2.2: {} diff --git a/app/frontend/src/components/status-dot-tip.tsx b/app/frontend/src/components/status-dot-tip.tsx new file mode 100644 index 00000000..b29526ae --- /dev/null +++ b/app/frontend/src/components/status-dot-tip.tsx @@ -0,0 +1,171 @@ +import { useState, type ReactNode } from "react"; +import { + useFloating, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useInteractions, + FloatingPortal, + safePolygon, + autoUpdate, +} from "@floating-ui/react"; +import { dotLabel } from "@/components/status-dot"; +import type { StatusDotState } from "@/components/pr-status-line"; +import type { WindowInfo } from "@/types"; + +/** + * Status-dot docs page (rendered by GitHub). Opens in a new tab from the + * hover-card's docs icon — the canonical "what does this dot mean" reference. + * docs/site is NOT served by the backend, so we link the GitHub blob (no + * anchor → lands at the top of the doc), matching the convention the only + * other in-app docs link uses (top-bar.tsx NOTIFICATIONS_HELP_URL). + */ +const STATUS_DOT_DOCS_URL = + "https://github.com/sahil87/run-kit/blob/main/docs/site/status-dot.md"; + +/** A single interactive link rendered inside the hover-card. */ +export type DotLink = { label: string; href: string; testid: string }; + +/** Resolved hover-card content for a window+state. */ +export type DotTipContent = { label: string; links: DotLink[] }; + +/** + * Pure content resolver: maps a window + its derived `StatusDotState` to the + * hover-card's text + interactive links. The label REUSES `dotLabel()` so the + * card text is the single source of truth shared with the dot's `aria-label`. + * + * Only PR-phase dots that actually carry a `prUrl` get a link ("Open PR #N"). + * Fab-phase and tmux-fallback dots get text only. The docs-link icon is NOT in + * `links[]` — it is a fixed element the card always renders (constant href), so + * it does not flow through per-state logic. + */ +export function dotTipContent(win: WindowInfo, state: StatusDotState): DotTipContent { + const label = dotLabel(win, state); + const links: DotLink[] = []; + if (state.phase === "pr" && win.prUrl) { + links.push({ + label: `Open PR #${win.prNumber}`, + href: win.prUrl, + testid: "dot-tip-pr-link", + }); + } + return { label, links }; +} + +/** "Open in new window" glyph (Nerd Font external-link), purely decorative. */ +const DOCS_GLYPH = ""; + +/** + * Shared link styling for the card's interactive rows. Click is stopped from + * bubbling so activating a link never selects/navigates the underlying window + * row (the dot sits inside a clickable sidebar row) — mirrors the proven + * PrStatusLine link pattern (pr-status-line.tsx). + */ +const LINK_CLASS = + "text-text-secondary hover:text-text-primary hover:underline whitespace-nowrap coarse:py-1"; + +type StatusDotTipProps = { + win: WindowInfo; + state: StatusDotState; + /** + * Renders the dot itself. Receives the floating reference setter and the + * reference interaction props (which carry hover/focus/aria wiring) to spread + * onto the dot element — keeping the dot the floating anchor while StatusDot + * owns the dot's shape/color markup. + */ + renderDot: ( + setReference: (node: HTMLElement | null) => void, + referenceProps: Record, + ) => ReactNode; +}; + +/** + * Custom hover-card wrapping a `StatusDot`. Replaces the native HTML `title` + * tooltip: a headless `@floating-ui/react` floating element gives full styling + * control (terminal aesthetic), portals out of the sidebar's `overflow:hidden` + * clip, flips/shifts at viewport edges, and uses a `safePolygon` bridge so the + * pointer can travel dot → card to click a link. + * + * Opens on hover (snappy delay) AND on keyboard focus (Constitution V — + * keyboard-first), dismisses on Escape / blur / pointer-leave. Always shows the + * dot's label text + a docs-link icon; PR-phase dots additionally show an + * "Open PR #N" link (from `dotTipContent`). + */ +export function StatusDotTip({ win, state, renderDot }: StatusDotTipProps) { + const [open, setOpen] = useState(false); + const { label, links } = dotTipContent(win, state); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: "top", + middleware: [offset(6), flip(), shift({ padding: 8 })], + whileElementsMounted: autoUpdate, + }); + + const hover = useHover(context, { + delay: { open: 150, close: 100 }, + handleClose: safePolygon(), + }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + // No `useRole({ role: "tooltip" })`: the W3C tooltip pattern requires a + // tooltip to contain NO interactive content, but this card holds real `` + // links (PR + docs). Advertising `role="tooltip"` would (a) lie to AT about + // there being nothing actionable and (b) wire a misleading `aria-describedby` + // from the dot to a "tooltip" that is actually a hover-card. The dot already + // carries its own accessible name via `aria-label`, and the links are + // real Tab-reachable controls — so we add no extra ARIA role here. + const { getReferenceProps, getFloatingProps } = useInteractions([ + hover, + focus, + dismiss, + ]); + + return ( + <> + {renderDot(refs.setReference, getReferenceProps())} + {open && ( + +
e.stopPropagation()} + data-testid="status-dot-tip" + className="z-50 flex flex-col gap-1 bg-bg-primary border border-border rounded-md shadow-lg px-2 py-1.5 text-xs font-mono w-max max-w-xs" + > + {label} + {links.map((link) => ( + e.stopPropagation()} + className={LINK_CLASS} + data-testid={link.testid} + > + {link.label} + + ))} + e.stopPropagation()} + className={`${LINK_CLASS} inline-flex items-center gap-1`} + data-testid="dot-tip-docs-link" + > + What do dots mean? + +
+ + )} + + ); +} diff --git a/app/frontend/src/components/status-dot.test.tsx b/app/frontend/src/components/status-dot.test.tsx index 292f0e17..496d58f6 100644 --- a/app/frontend/src/components/status-dot.test.tsx +++ b/app/frontend/src/components/status-dot.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; -import { StatusDot } from "./status-dot"; +import { StatusDot, dotLabel } from "./status-dot"; +import { dotTipContent } from "./status-dot-tip"; import { statusDotState, fabPhase, fabShape, prShape } from "./pr-status-line"; import type { WindowInfo } from "@/types"; @@ -243,16 +244,70 @@ describe("StatusDot — tmux fallback (monochrome)", () => { }); describe("StatusDot — accessibility label composition", () => { - it("composes '{stage} — {status}' for fab windows", () => { + it("composes '{stage} — {status}' for fab windows (aria-label, no native title)", () => { render(); const dot = screen.getByLabelText("apply — pending"); - expect(dot.getAttribute("title")).toBe("apply — pending"); + expect(dot.getAttribute("aria-label")).toBe("apply — pending"); expect(dot.getAttribute("role")).toBe("img"); + // The native `title` tooltip is replaced by the custom StatusDotTip card. + expect(dot.getAttribute("title")).toBeNull(); }); - it("composes 'PR — {status}' for the PR phase (PR-native words)", () => { + it("composes 'PR — {status}' for the PR phase (PR-native words, no native title)", () => { render(); const dot = screen.getByLabelText("PR — merged"); - expect(dot.getAttribute("title")).toBe("PR — merged"); + expect(dot.getAttribute("aria-label")).toBe("PR — merged"); + expect(dot.getAttribute("title")).toBeNull(); + }); +}); + +describe("dotTipContent — hover-card content resolution", () => { + it("PR-phase dot with a prUrl yields one 'Open PR #N' link to prUrl", () => { + const win = makeWindow({ + fabChange: "260615-x", + prNumber: 386, + prState: "open", + prChecks: "pass", + prUrl: "https://github.com/o/r/pull/386", + }); + const state = statusDotState(win); + expect(state.phase).toBe("pr"); + const content = dotTipContent(win, state); + expect(content.label).toBe(dotLabel(win, state)); + expect(content.links).toEqual([ + { + label: "Open PR #386", + href: "https://github.com/o/r/pull/386", + testid: "dot-tip-pr-link", + }, + ]); + }); + + it("PR-phase dot WITHOUT a prUrl yields no links (only label)", () => { + const win = makeWindow({ fabChange: "260615-x", prNumber: 7, prState: "open", prChecks: "pass" }); + const state = statusDotState(win); + expect(state.phase).toBe("pr"); + const content = dotTipContent(win, state); + expect(content.links).toHaveLength(0); + expect(content.label).toBe(dotLabel(win, state)); + }); + + it("fab-phase dot (change-bound, no PR) yields no links and the unchanged label", () => { + const win = makeWindow({ fabChange: "260615-x", fabStage: "apply", fabDisplayState: "active" }); + const state = statusDotState(win); + expect(state.phase).toBe("execution"); + const content = dotTipContent(win, state); + expect(content.links).toHaveLength(0); + expect(content.label).toBe(dotLabel(win, state)); + expect(content.label).toBe("apply — active"); + }); + + it("tmux-fallback dot (no fab change) yields no links and the bare activity label", () => { + const win = makeWindow({ activity: "active" }); + const state = statusDotState(win); + expect(state.phase).toBe("none"); + const content = dotTipContent(win, state); + expect(content.links).toHaveLength(0); + expect(content.label).toBe("active"); }); }); diff --git a/app/frontend/src/components/status-dot.tsx b/app/frontend/src/components/status-dot.tsx index 0be5e0ee..1e034999 100644 --- a/app/frontend/src/components/status-dot.tsx +++ b/app/frontend/src/components/status-dot.tsx @@ -1,4 +1,5 @@ import { statusDotState, PHASE_HUE, type DotShape, type StatusDotState } from "@/components/pr-status-line"; +import { StatusDotTip } from "@/components/status-dot-tip"; import type { WindowInfo } from "@/types"; /** @@ -29,10 +30,14 @@ import type { WindowInfo } from "@/types"; * by being bigger. The `failed` dot is the lone exception — a slightly larger 9px * footprint so its dotted bead-ring stays legible (see the failed branch below). * - * The dot always carries `role="img"` + `aria-label` + `title` composed from - * phase + status (e.g. "apply — active", "PR — merged", "review — failed", + * The dot always carries `role="img"` + `aria-label` composed from phase + + * status (e.g. "apply — active", "PR — merged", "review — failed", * "intake — pending"), or "active"/"idle" for the tmux fallback — color is - * never the sole channel (colorblind a11y + keyboard-first constitution). + * never the sole channel (colorblind a11y + keyboard-first constitution). The + * native `title` tooltip is intentionally NOT set: the dot is wrapped by the + * custom `StatusDotTip` hover-card (floating-ui based), which carries the same + * label text plus a docs-link icon and (on PR-phase dots) an "Open PR" link. + * A native `title` would double up with the custom card. */ // Every shape EXCEPT `failed` renders at one uniform footprint so the filled @@ -76,7 +81,7 @@ const PR_SHAPE_LABEL: Record = { * bare tmux activity word. Only a window with no `fabChange` is a true tmux * fallback. */ -function dotLabel(win: WindowInfo, state: StatusDotState): string { +export function dotLabel(win: WindowInfo, state: StatusDotState): string { if (state.phase === "pr") return `PR — ${PR_SHAPE_LABEL[state.shape]}`; if (!win.fabChange) return win.activity; // tmux fallback: "active" | "idle" return `${win.fabStage ?? "fab"} — ${SHAPE_LABEL[state.shape]}`; @@ -89,60 +94,89 @@ export function StatusDot({ win }: { win: WindowInfo }) { // has left its journey hue behind); every other shape uses the phase hue. const color = state.shape === "skipped" ? "text-text-secondary" : PHASE_HUE[state.phase]; - const common = { role: "img" as const, "aria-label": label, title: label }; + // The dot's shape markup. `setRef`/`tipProps` come from StatusDotTip — they + // make the dot the floating-card reference and wire hover/focus/aria. The + // native `title` is intentionally dropped (the custom card replaces it); the + // accessible name lives on `aria-label`. + const renderDot = ( + setRef: (node: HTMLElement | null) => void, + tipProps: Record, + ) => { + const common = { + ref: setRef, + role: "img" as const, + "aria-label": label, + // Make the dot keyboard-focusable so the hover-card also opens on focus + // (Constitution V — keyboard-first); the floating-ui reference props don't + // add a tabstop, so set it explicitly. + // + // Tradeoff (reviewed, accepted): on the sidebar this dot sits inside the + // row