diff --git a/README.md b/README.md index 776ba689..8639b51e 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Each window in the sidebar, dashboard, and pane panel carries a single **status Exactly one signal drives the dot, in precedence order **PR > fab > tmux**. -![StatusDot stage × status matrix](docs/img/status-dot-matrix.svg) +![StatusDot stage × status matrix](https://raw.githubusercontent.com/sahil87/run-kit/main/docs/img/status-dot-matrix.svg) See the [status dot reference](docs/site/status-dot.md) for the full matrix, the per-state rendering, and the design rationale. 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-label.ts b/app/frontend/src/components/status-dot-label.ts new file mode 100644 index 00000000..24be16f9 --- /dev/null +++ b/app/frontend/src/components/status-dot-label.ts @@ -0,0 +1,49 @@ +import type { DotShape, StatusDotState } from "@/components/pr-status-line"; +import type { WindowInfo } from "@/types"; + +/** + * Shared label resolver for the status dot, extracted into its own module so + * both `status-dot.tsx` (the dot) and `status-dot-tip.tsx` (the hover-card) + * can import it without forming an import cycle between the two components. + * `status-dot.tsx` re-exports `dotLabel` to keep its public surface stable. + */ + +/** Human word for the SHAPE/status axis used in the accessible label. */ +const SHAPE_LABEL: Record = { + ring: "pending", + solid: "active", + failed: "failed", + done: "done", + skipped: "skipped", +}; + +// PR-phase status words. The shared SHAPE_LABEL vocabulary is fab-stage language +// ("active"/"done"); for a PR those read unnaturally, so the PR branch maps the +// same shapes onto PR-native words — a PR is "open", "merged", "failing", not +// "active"/"done"/"failed". +const PR_SHAPE_LABEL: Record = { + ring: "checks running", + solid: "open", + failed: "failing", + done: "merged", + skipped: "closed", +}; + +/** + * Compose the accessible label. The fab branch uses the real stage word + * ("apply — active"); the PR branch uses PR-native words ("PR — merged"); the + * tmux fallback uses the bare activity word ("active"/"idle"), no journey. + * + * The tmux fallback is gated on `!win.fabChange`, NOT on `phase === "none"`: a + * fab-bound window whose `fabStage` is unknown/absent maps to `phase: "none"` + * via `fabPhase` (and may carry a `failed`/`done` shape), yet it still + * represents fab state — so it gets a `{stage} — {status}` label (the raw + * `fabStage`, or the literal "fab" when the stage word is absent), never the + * bare tmux activity word. Only a window with no `fabChange` is a true tmux + * fallback. + */ +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]}`; +} 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..e13792ef --- /dev/null +++ b/app/frontend/src/components/status-dot-tip.tsx @@ -0,0 +1,202 @@ +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-label"; +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 }; +} + +/** + * Circled-"i" info glyph for the docs affordance — an inline SVG (matching the + * codebase's hand-built SVG icons, e.g. window-row's pin) rather than a Nerd + * Font glyph, so it renders crisply at any size, themes via `currentColor`, and + * doesn't depend on the user's terminal font being patched. "info" intent reads + * as "know more" without competing with the "Open PR" external-link affordance. + */ +function InfoIcon() { + return ( + + ); +} + +/** + * 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 row: status text on the left, a quiet circled-(i) docs + affordance pinned top-right. No visible copy — the icon's + "info / know more" convention plus its aria-label/title carry + the meaning, keeping the card terse and matching its register. */} +
+ {label} + e.stopPropagation()} + className="ml-auto mt-px text-text-secondary hover:text-text-primary coarse:p-1" + aria-label="What do status dots mean? (opens docs)" + title="What do status dots mean?" + data-testid="dot-tip-docs-link" + > + + +
+ {links.map((link) => ( + e.stopPropagation()} + className={LINK_CLASS} + data-testid={link.testid} + > + {link.label} + + ))} +
+ + )} + + ); +} 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..f6e09670 100644 --- a/app/frontend/src/components/status-dot.tsx +++ b/app/frontend/src/components/status-dot.tsx @@ -1,6 +1,13 @@ -import { statusDotState, PHASE_HUE, type DotShape, type StatusDotState } from "@/components/pr-status-line"; +import { statusDotState, PHASE_HUE } from "@/components/pr-status-line"; +import { StatusDotTip } from "@/components/status-dot-tip"; +import { dotLabel } from "@/components/status-dot-label"; import type { WindowInfo } from "@/types"; +// `dotLabel` lives in `status-dot-label.ts` (shared with `status-dot-tip.tsx` +// to avoid an import cycle); re-export it so this module's public surface — and +// existing `@/components/status-dot` import sites — stay unchanged. +export { dotLabel }; + /** * Unified lifecycle status dot reused on the sidebar window row, the dashboard * window cards, and the pane-panel header. It renders a single signal per @@ -29,10 +36,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 @@ -42,46 +53,6 @@ import type { WindowInfo } from "@/types"; // dotted bead-ring has room to read (see the failed branch below). const DOT_SIZE = "w-[7px] h-[7px]"; -/** Human word for the SHAPE/status axis used in the accessible label. */ -const SHAPE_LABEL: Record = { - ring: "pending", - solid: "active", - failed: "failed", - done: "done", - skipped: "skipped", -}; - -// PR-phase status words. The shared SHAPE_LABEL vocabulary is fab-stage language -// ("active"/"done"); for a PR those read unnaturally, so the PR branch maps the -// same shapes onto PR-native words — a PR is "open", "merged", "failing", not -// "active"/"done"/"failed". -const PR_SHAPE_LABEL: Record = { - ring: "checks running", - solid: "open", - failed: "failing", - done: "merged", - skipped: "closed", -}; - -/** - * Compose the accessible label. The fab branch uses the real stage word - * ("apply — active"); the PR branch uses PR-native words ("PR — merged"); the - * tmux fallback uses the bare activity word ("active"/"idle"), no journey. - * - * The tmux fallback is gated on `!win.fabChange`, NOT on `phase === "none"`: a - * fab-bound window whose `fabStage` is unknown/absent maps to `phase: "none"` - * via `fabPhase` (and may carry a `failed`/`done` shape), yet it still - * represents fab state — so it gets a `{stage} — {status}` label (the raw - * `fabStage`, or the literal "fab" when the stage word is absent), never the - * bare tmux activity word. Only a window with no `fabChange` is a true tmux - * fallback. - */ -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]}`; -} - export function StatusDot({ win }: { win: WindowInfo }) { const state = statusDotState(win); const label = dotLabel(win, state); @@ -89,60 +60,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