Skip to content

Commit

Merge branch 'worktree-agent-ae1299e92e4462012'

syntaqxcommitted Parentsad6f79a472fe0fBrowse files
50 files+842−5320/50 viewed
+7−3
99
1010 import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
1111
12+import { Hint } from "./ui/hint";
13+
1214 type Standing = { status: string; conclusion: Conclusion | null };
1315
1416 /** One icon for where a run, job or step stands. */
258260 return (
259261 <li key={status.context}>
260262 {path ? (
261− <Link to={path} className="flex items-center gap-2 hover:text-fg" title={status.description ?? undefined}>
262− {row}
263− </Link>
263+ <Hint label={status.description}>
264+ <Link to={path} className="flex items-center gap-2 hover:text-fg">
265+ {row}
266+ </Link>
267+ </Hint>
264268 ) : (
265269 <span className="flex items-center gap-2">{row}</span>
266270 )}
+6−7
55
66 import { Idle } from "./agents";
77 import { TimeAgo } from "./ui";
8+import { Hint } from "./ui/hint";
89
910 const ROLE: Record<RepoInstructions["files"][number]["role"], string> = {
1011 root: "Every run",
6263 <span className="ml-auto flex items-center gap-2 text-xs text-faint">
6364 {file.lastChanged && (
6465 <>
65− <Link
66− to={`${base}/commit/${file.lastChanged.commit}`}
67− className="font-mono hover:text-fg"
68− title={file.lastChanged.message}
69− >
70− {file.lastChanged.commit.slice(0, 7)}
71− </Link>
66+ <Hint label={file.lastChanged.message}>
67+ <Link to={`${base}/commit/${file.lastChanged.commit}`} className="font-mono hover:text-fg">
68+ {file.lastChanged.commit.slice(0, 7)}
69+ </Link>
70+ </Hint>
7271 <span>by {file.lastChanged.author}</span>
7372 <TimeAgo at={file.lastChanged.at} />
7473 </>
+31−23
2424 import { RunAudit } from "./audit";
2525 import { STAGE_LABEL, StageDots } from "./lifecycle";
2626 import { Avatar, SubmitButton, TimeAgo } from "./ui";
27+import { Hint } from "./ui/hint";
2728 import {
2829 AlertDialog,
2930 AlertDialogAction,
259260 <RunStatusBadge status={run.status} />
260261 </div>
261262 {run.step && (
262− <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`} title={inlinePlain(shownStep(run.step))}>
263− {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
264− <InlineMarkdown text={shownStep(run.step)} />
265− </p>
263+ <Hint label={inlinePlain(shownStep(run.step))}>
264+ <p className={`mt-2.5 truncate font-mono text-xs ${active ? "text-fg/85" : "text-muted"}`}>
265+ {active && <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
266+ <InlineMarkdown text={shownStep(run.step)} />
267+ </p>
268+ </Hint>
266269 )}
267270 {run.status === "failed" && run.error && <p className="mt-1.5 text-xs text-danger">{run.error}</p>}
268271 <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
271274 {run.startedAt || active ? <Elapsed from={run.startedAt ?? run.createdAt} to={run.finishedAt} /> : "—"}
272275 </span>
273276 {cost && (
274− <span className="flex items-center gap-1" title={active ? "So far, as the harness reports it" : "As the harness reported it"}>
275− <Coins size={12} />
276− {cost}
277− {active && " so far"}
278− </span>
277+ <Hint label={active ? "So far, as the harness reports it" : "As the harness reported it"}>
278+ <span className="flex items-center gap-1">
279+ <Coins size={12} />
280+ {cost}
281+ {active && " so far"}
282+ </span>
283+ </Hint>
279284 )}
280285 {run.model && <span className="font-mono">{run.model}</span>}
281286 {run.startedBy && <span>started by {run.startedBy}</span>}
437442 <RunStatusBadge status={current.status} />
438443 </div>
439444 {current.step && (
440− <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line" title={inlinePlain(shownStep(current.step))}>
441− {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
442− <InlineMarkdown text={shownStep(current.step)} />
443− </p>
445+ <Hint label={inlinePlain(shownStep(current.step))}>
446+ <p className="mt-3 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line">
447+ {active && <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />}
448+ <InlineMarkdown text={shownStep(current.step)} />
449+ </p>
450+ </Hint>
444451 )}
445452 {confidence && <ConfidenceLine confidence={confidence} />}
446453 <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
476483 export function AgentBadge({ run }: { run: AgentRun | undefined }) {
477484 if (!run) return null;
478485 return (
479− <span
480− title={run.step ? inlinePlain(shownStep(run.step)) : undefined}
481− className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged"
482− >
483− <Loader2 size={11} className="animate-spin" />
484− {RUN_KIND_LABEL[run.kind]}
485− </span>
486+ <Hint label={run.step ? inlinePlain(shownStep(run.step)) : undefined}>
487+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-merged/40 bg-merged/10 px-2 py-0.5 text-xs text-merged">
488+ <Loader2 size={11} className="animate-spin" />
489+ {RUN_KIND_LABEL[run.kind]}
490+ </span>
491+ </Hint>
486492 );
487493 }
488494
529535 const run = data?.runs.find((run) => run.number === number && isActiveRun(run.status));
530536 if (!run?.step) return null;
531537 return (
532− <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint" title={inlinePlain(shownStep(run.step))}>
533− {RUN_KIND_LABEL[run.kind]}: <InlineMarkdown text={shownStep(run.step)} />
534− </span>
538+ <Hint label={inlinePlain(shownStep(run.step))}>
539+ <span className="mt-0.5 block truncate font-mono text-[0.6875rem] text-faint">
540+ {RUN_KIND_LABEL[run.kind]}: <InlineMarkdown text={shownStep(run.step)} />
541+ </span>
542+ </Hint>
535543 );
536544 }
+26−16
1111
1212 import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit";
1313 import { Avatar, TimeAgo } from "./ui";
14+import { Hint } from "./ui/hint";
1415 import { Loading, SkeletonRows } from "./ui/skeleton";
1516
1617 function clock(at: string): string {
2122 export function OutcomeMark({ entry }: { entry: AuditEntry }) {
2223 const denied = entry.outcome === "denied";
2324 return (
24− <span
25− title={`${entry.outcome}: ${ruleLabel(entry.rule)} (${entry.rule})`}
26− className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs ${
27− denied ? "bg-danger/10 text-danger ring-1 ring-danger/30" : "bg-raised text-muted ring-1 ring-line"
28− }`}
29− >
30− {denied ? <ShieldAlert size={11} /> : <ShieldCheck size={11} />}
31− {entry.outcome}
32− </span>
25+ <Hint label={`${entry.outcome}: ${ruleLabel(entry.rule)} (${entry.rule})`}>
26+ <span
27+ className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs ${
28+ denied ? "bg-danger/10 text-danger ring-1 ring-danger/30" : "bg-raised text-muted ring-1 ring-line"
29+ }`}
30+ >
31+ {denied ? <ShieldAlert size={11} /> : <ShieldCheck size={11} />}
32+ {entry.outcome}
33+ </span>
34+ </Hint>
3335 );
3436 }
3537
6971 <span className="truncate font-mono text-xs text-muted">{targetLabel(entry)}</span>
7072 </div>
7173 <p className="mt-1 flex flex-wrap gap-x-3 text-xs text-faint">
72− <span title={entry.rule}>{ruleLabel(entry.rule)}</span>
74+ <Hint label={entry.rule}>
75+ <span>{ruleLabel(entry.rule)}</span>
76+ </Hint>
7377 <span>{entry.surface.toUpperCase()}</span>
7478 {entry.result && entry.result !== "ok" && <span>result: {entry.result}</span>}
7579 {entry.runId && entry.repo && (
8387 </Link>
8488 )}
8589 {entry.credentialId && (
86− <span className="font-mono" title="Credential">
87− {entry.credentialId}
88− </span>
90+ <Hint label="Credential">
91+ <span className="font-mono">
92+ <span className="sr-only">Credential </span>
93+ {entry.credentialId}
94+ </span>
95+ </Hint>
8996 )}
90− <span className="font-mono" title="Request id">
91− {entry.requestId}
92− </span>
97+ <Hint label="Request id">
98+ <span className="font-mono">
99+ <span className="sr-only">Request id </span>
100+ {entry.requestId}
101+ </span>
102+ </Hint>
93103 </p>
94104 {entry.outcome === "denied" && entry.message && <p className="mt-1 text-xs text-danger">{entry.message}</p>}
95105 </div>
+13−10
33 import { useFetcher } from "react-router";
44
55 import { ErrorText } from "./ui";
6+import { Hint } from "./ui/hint";
67 import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./ui/combobox";
78 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
89
4748 if (next && names.state === "idle" && !names.data) names.load(branchesUrl);
4849 }}
4950 >
50− <PopoverTrigger asChild>
51− <button
52− type="button"
53− title="Change the branch it merges into"
54− className="group inline-flex items-center gap-1 rounded-md border border-transparent px-1 font-mono text-fg transition-colors hover:border-line hover:bg-raised"
55− >
56− {shown}
57− <Pencil size={12} className="text-faint group-hover:text-fg" />
58− </button>
59− </PopoverTrigger>
51+ <Hint label="Change the branch it merges into">
52+ <PopoverTrigger asChild>
53+ <button
54+ type="button"
55+ className="group inline-flex items-center gap-1 rounded-md border border-transparent px-1 font-mono text-fg transition-colors hover:border-line hover:bg-raised"
56+ >
57+ {shown}
58+ <Pencil size={12} className="text-faint group-hover:text-fg" />
59+ <span className="sr-only">Change the branch it merges into</span>
60+ </button>
61+ </PopoverTrigger>
62+ </Hint>
6063 <PopoverContent align="start" className="w-72 p-0">
6164 <Command>
6265 <CommandInput placeholder="Find a branch to merge into" />
+44−38
77 import { CheckBadge } from "./checks";
88 import { host } from "./deploy";
99 import { Avatar, TimeAgo } from "./ui";
10+import { Hint } from "./ui/hint";
11+
12+function drift({ ahead, behind }: Drift, main: string): string {
13+ return `${ahead} ${ahead === 1 ? "commit" : "commits"} ahead of ${main}, ${behind} behind`;
14+}
1015
1116 export type ActiveBranch = {
1217 name: string;
3641 <Avatar name={branch.commit.author} size={13} />
3742 <span className="shrink-0">{branch.commit.author}</span>
3843 <span className="text-faint">·</span>
39− <Link
40− to={`${base}/commit/${branch.commit.hash}`}
41− className="min-w-0 truncate hover:text-fg"
42− title={branch.commit.message}
43− >
44− {branch.commit.message}
45− </Link>
44+ <Hint label={branch.commit.message}>
45+ <Link to={`${base}/commit/${branch.commit.hash}`} className="min-w-0 truncate hover:text-fg">
46+ {branch.commit.message}
47+ </Link>
48+ </Hint>
4649 <span className="shrink-0 text-faint">
4750 · <TimeAgo at={branch.commit.at} />
4851 </span>
5154 </span>
5255 <span className="ml-6.5 flex shrink-0 flex-wrap items-center gap-2 text-xs sm:ml-0">
5356 {branch.drift && (
54− <span
55− className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 font-mono tabular-nums text-muted"
56− title={`${branch.drift.ahead} ${branch.drift.ahead === 1 ? "commit" : "commits"} ahead of ${main}, ${branch.drift.behind} behind`}
57− >
58− <span className="inline-flex items-center gap-0.5">
59− <ArrowUp size={11} className={branch.drift.ahead > 0 ? "text-accent" : "text-faint"} />
60− {branch.drift.ahead}
57+ <Hint label={drift(branch.drift, main)}>
58+ <span className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 font-mono tabular-nums text-muted">
59+ <span className="sr-only">{drift(branch.drift, main)}</span>
60+ <span className="inline-flex items-center gap-0.5" aria-hidden>
61+ <ArrowUp size={11} className={branch.drift.ahead > 0 ? "text-accent" : "text-faint"} />
62+ {branch.drift.ahead}
63+ </span>
64+ <span className="inline-flex items-center gap-0.5" aria-hidden>
65+ <ArrowDown size={11} className={branch.drift.behind > 0 ? "text-warn" : "text-faint"} />
66+ {branch.drift.behind}
67+ </span>
6168 </span>
62− <span className="inline-flex items-center gap-0.5">
63− <ArrowDown size={11} className={branch.drift.behind > 0 ? "text-warn" : "text-faint"} />
64− {branch.drift.behind}
65− </span>
66− </span>
69+ </Hint>
6770 )}
6871 {branch.pull ? (
69− <Link
70− to={`${base}/pull/${branch.pull.number}`}
71− className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg"
72− title={branch.pull.title}
73− >
74− <GitPullRequest size={12} className={branch.pull.draft ? "text-faint" : "text-accent"} />#{branch.pull.number}
75− <CheckBadge status={branch.pull.checkStatus} />
76− </Link>
72+ <Hint label={branch.pull.title}>
73+ <Link
74+ to={`${base}/pull/${branch.pull.number}`}
75+ className="inline-flex items-center gap-1.5 rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg"
76+ >
77+ <GitPullRequest size={12} className={branch.pull.draft ? "text-faint" : "text-accent"} />#{branch.pull.number}
78+ <span className="sr-only">{branch.pull.title}</span>
79+ <CheckBadge status={branch.pull.checkStatus} />
80+ </Link>
81+ </Hint>
7782 ) : branch.drift?.ahead === 0 ? (
7883 // Nothing here that the default branch lacks: no pull request to open.
79− <span className="px-1.5 py-0.5 text-faint" title={`Every commit on ${branch.name} is already on ${main}.`}>
80− Nothing to merge
81− </span>
84+ <Hint label={`Every commit on ${branch.name} is already on ${main}.`}>
85+ <span className="px-1.5 py-0.5 text-faint">Nothing to merge</span>
86+ </Hint>
8287 ) : (
8388 <Link
8489 to={`${base}/pulls/new?branch=${encodeURIComponent(branch.name)}`}
8893 </Link>
8994 )}
9095 {branch.preview && (
91− <a
92− href={branch.preview}
93− className="inline-flex max-w-48 items-center gap-1 truncate rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg"
94− title={host(branch.preview)}
95− >
96− <Globe size={12} className="shrink-0 text-merged" />
97− Preview
98− </a>
96+ <Hint label={host(branch.preview)}>
97+ <a
98+ href={branch.preview}
99+ className="inline-flex max-w-48 items-center gap-1 truncate rounded-md border border-line px-1.5 py-0.5 text-muted hover:border-line-strong hover:text-fg"
100+ >
101+ <Globe size={12} className="shrink-0 text-merged" />
102+ Preview
103+ </a>
104+ </Hint>
99105 )}
100106 </span>
101107 </li>
+14−12
44
55 import { type ChecklistItem, dismiss, isDismissed, progress } from "../lib/checklist";
66 import { cn } from "../lib/cn";
7+import { Hint } from "./ui/hint";
78
89 const storage = () => (typeof window === "undefined" ? null : window.localStorage);
910 const listeners = new Set<() => void>();
4849 <span className="hidden h-1.5 w-24 overflow-hidden rounded-full bg-line sm:block" aria-hidden>
4950 <span className="block h-full rounded-full bg-accent" style={{ width: `${(done / total) * 100}%` }} />
5051 </span>
51− <button
52− type="button"
53− onClick={() => {
54− dismiss(storage, base);
55− for (const listener of listeners) listener();
56− }}
57− className="ml-auto rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
58− aria-label="Dismiss the checklist for this project"
59− title="Dismiss for this project"
60− >
61− <X size={14} />
62− </button>
52+ <Hint label="Dismiss for this project">
53+ <button
54+ type="button"
55+ onClick={() => {
56+ dismiss(storage, base);
57+ for (const listener of listeners) listener();
58+ }}
59+ className="ml-auto rounded-md p-1 text-faint transition-colors hover:bg-raised hover:text-fg"
60+ aria-label="Dismiss the checklist for this project"
61+ >
62+ <X size={14} />
63+ </button>
64+ </Hint>
6365 </div>
6466 <ol className="grid border-t border-line sm:grid-cols-2 lg:grid-cols-3">
6567 {items.map((item) => (
+8−4
22
33 import type { CheckStatus } from "@g1t/contracts";
44
5+import { Hint } from "./ui/hint";
6+
57 /**
68 * What a pull request's own check status says. A pull request's checks are
79 * the workflows run on it (see the merge box); this is set when the merge
2729 export function CheckBadge({ status }: { status: CheckStatus | null }) {
2830 if (!status) return null;
2931 return (
30− <span className="flex shrink-0 items-center gap-1 text-xs text-muted" title={LABELS[status]}>
31− <CheckIcon status={status} size={13} />
32− <span className="sr-only">{LABELS[status]}</span>
33− </span>
32+ <Hint label={LABELS[status]}>
33+ <span className="flex shrink-0 items-center gap-1 text-xs text-muted">
34+ <CheckIcon status={status} size={13} />
35+ <span className="sr-only">{LABELS[status]}</span>
36+ </span>
37+ </Hint>
3438 );
3539 }
+22−19
4646
4747 import { InlineMarkdown } from "./inline-markdown";
4848 import { SubmitButton, TimeAgo } from "./ui";
49+import { Hint } from "./ui/hint";
4950 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
5051 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
5152
204205 Keep
205206 </button>
206207 <EditAndKeep memory={memory} action={action} />
207− <button
208− type="button"
209− aria-label="Dismiss"
210− title="Dismiss: never suggested again in these words"
211− onClick={() => fetcher.submit({ intent: "dismiss", id: memory.id }, { method: "post", action })}
212− className="inline-flex items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-line-strong hover:text-danger"
213− >
214− <X size={12} />
215− Dismiss
216− </button>
208+ <Hint label="Dismiss: never suggested again in these words">
209+ <button
210+ type="button"
211+ aria-label="Dismiss"
212+ onClick={() => fetcher.submit({ intent: "dismiss", id: memory.id }, { method: "post", action })}
213+ className="inline-flex items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-line-strong hover:text-danger"
214+ >
215+ <X size={12} />
216+ Dismiss
217+ </button>
218+ </Hint>
217219 </div>
218220 </li>
219221 );
498500 <input type="hidden" name="intent" value="fix" />
499501 <input type="hidden" name="project" value={project} />
500502 <input type="hidden" name="rule" value={rule.rule} />
501− <SubmitButton
502− fetcher={fetcher}
503− pending="Opening…"
504− title={`Opens “${rule.fix.title}” and puts an agent on it`}
505− className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50"
506− >
507− <Bot size={12} />
508− Fix with an agent
509− </SubmitButton>
503+ <Hint label={`Opens “${rule.fix.title}” and puts an agent on it`}>
504+ <SubmitButton
505+ fetcher={fetcher}
506+ pending="Opening…"
507+ className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50"
508+ >
509+ <Bot size={12} />
510+ Fix with an agent
511+ </SubmitButton>
512+ </Hint>
510513 </fetcher.Form>
511514 )}
512515 </li>
+4−1
3939 } from "../lib/dependency-updates";
4040 import { TimeAgo } from "./ui";
4141 import { Badge, type BadgeTone } from "./ui/badge";
42+import { Hint } from "./ui/hint";
4243
4344 type Done = { ok: boolean; error?: string } | undefined;
4445
110111 </span>
111112 )}
112113 {entry.nextRunAt && (
113− <span title={utc(entry.nextRunAt)}>Next check {timeUntil(entry.nextRunAt)}</span>
114+ <Hint label={utc(entry.nextRunAt)}>
115+ <span>Next check {timeUntil(entry.nextRunAt)}</span>
116+ </Hint>
114117 )}
115118 <span>
116119 {entry.lastCheckedAt ? (
+11−6
66
77 import { SubmitButton } from "./ui";
88 import { Badge, type BadgeTone } from "./ui/badge";
9+import { Hint } from "./ui/hint";
910 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
1011 import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
1112 import { Skeleton } from "./ui/skeleton";
124125 {item.doneAt ? (
125126 <fetcher.Form method="post" action={ACTION}>
126127 <Fields intent="undone" id={item.id} />
127− <SubmitButton fetcher={fetcher} icon aria-label="Move back to the inbox" title="Move back to the inbox" className={ICON_BUTTON}>
128− <Undo2 size={14} />
129− </SubmitButton>
128+ <Hint label="Move back to the inbox">
129+ <SubmitButton fetcher={fetcher} icon aria-label="Move back to the inbox" className={ICON_BUTTON}>
130+ <Undo2 size={14} />
131+ </SubmitButton>
132+ </Hint>
130133 </fetcher.Form>
131134 ) : (
132135 <fetcher.Form method="post" action={ACTION}>
133136 <Fields intent="done" id={item.id} />
134− <SubmitButton fetcher={fetcher} icon aria-label="Done" title="Done" className={ICON_BUTTON}>
135− <Check size={14} />
136− </SubmitButton>
137+ <Hint label="Done">
138+ <SubmitButton fetcher={fetcher} icon aria-label="Done" className={ICON_BUTTON}>
139+ <Check size={14} />
140+ </SubmitButton>
141+ </Hint>
137142 </fetcher.Form>
138143 )}
139144 <DropdownMenu>
+1−5
528528 <div className="space-y-6 lg:order-1">
529529 <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
530530 {STACK.map((provider) => (
531− <div
532− key={provider}
533− className="flex flex-col items-center gap-2 rounded-xl bg-surface px-2 py-3 ring-1 ring-line"
534− title={PROVIDERS[provider].label}
535− >
531+ <div key={provider} className="flex flex-col items-center gap-2 rounded-xl bg-surface px-2 py-3 ring-1 ring-line">
536532 <ProviderMark provider={provider} size={30} />
537533 <span className="w-full text-center text-xs leading-tight text-muted">{PROVIDERS[provider].label}</span>
538534 </div>
+12−10
2323 } from "./ui/alert-dialog";
2424 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
2525 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
26+import { Hint } from "./ui/hint";
2627 import { Switch } from "./ui/switch";
2728
2829 const KIND: Record<MemoryKind, { label: string; about: string; tone: string }> = {
167168 </div>
168169 {editable && (
169170 <div className="flex shrink-0 items-start gap-0.5">
170− <button
171− type="button"
172− aria-label={pinned ? "Unpin" : "Pin"}
173− title={pinned ? "Unpin" : "Pin: given to every agent first"}
174− onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })}
175− disabled={fetcher.state !== "idle"}
176− className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
177− >
178− {pinned ? <PinOff size={13} /> : <Pin size={13} />}
179− </button>
171+ <Hint label={pinned ? "Unpin" : "Pin: given to every agent first"}>
172+ <button
173+ type="button"
174+ aria-label={pinned ? "Unpin" : "Pin"}
175+ onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })}
176+ disabled={fetcher.state !== "idle"}
177+ className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
178+ >
179+ {pinned ? <PinOff size={13} /> : <Pin size={13} />}
180+ </button>
181+ </Hint>
180182 <EditMemory memory={memory} action={action} />
181183 <AlertDialog>
182184 <AlertDialogTrigger aria-label="Forget" className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-danger">
+37−33
2929 import { duration } from "./actions";
3030 import { Elapsed, type Live, useRuns } from "./agents";
3131 import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui";
32+import { Hint } from "./ui/hint";
3233 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
3334 import { SkeletonLine } from "./ui/skeleton";
3435
128129 }
129130 return (
130131 <div className="relative">
131− <button
132− type="button"
133− aria-label="Copy the output"
134− title="Copy the output"
135− className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg"
136− onClick={() => {
137− void navigator.clipboard.writeText(stripAnsi(body));
138− setCopied(true);
139− setTimeout(() => setCopied(false), 1500);
140− }}
141− >
142− {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />}
143− </button>
132+ <Hint label="Copy the output">
133+ <button
134+ type="button"
135+ aria-label="Copy the output"
136+ className="absolute top-1.5 right-3 z-10 rounded-md border border-line bg-surface p-1.5 text-faint transition-colors hover:text-fg"
137+ onClick={() => {
138+ void navigator.clipboard.writeText(stripAnsi(body));
139+ setCopied(true);
140+ setTimeout(() => setCopied(false), 1500);
141+ }}
142+ >
143+ {copied ? <Check size={13} className="text-accent" /> : <Copy size={13} />}
144+ </button>
145+ </Hint>
144146 {cut && (
145147 <p className="border-b border-line px-3 py-1.5 text-xs text-faint">
146148 Showing the end of the output. Earlier lines were not kept.
416418 <Form method="post" key={status.context}>
417419 <input type="hidden" name="action" value="rerun-workflow" />
418420 <input type="hidden" name="run" value={runIdOf(status) ?? ""} />
419− <SubmitButton
420− variant="quiet"
421− match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }}
422− pending="Re-running…"
423− title={`Re-run the failed jobs of ${status.context}`}
424− >
425− <RotateCw size={13} />
426− Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""}
427− </SubmitButton>
421+ <Hint label={`Re-run the failed jobs of ${status.context}`}>
422+ <SubmitButton
423+ variant="quiet"
424+ match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }}
425+ pending="Re-running…"
426+ >
427+ <RotateCw size={13} />
428+ Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""}
429+ </SubmitButton>
430+ </Hint>
428431 </Form>
429432 ))}
430433 </div>
850853 is pushed.
851854 </p>
852855 {update.paths.length > 0 && (
853− <p className="mt-1 truncate font-mono text-xs text-faint" title={update.paths.join(", ")}>
854− {update.paths.slice(0, 5).join(", ")}
855− {update.paths.length > 5 && ` and ${update.paths.length - 5} more`}
856− </p>
856+ <Hint label={update.paths.join(", ")}>
857+ <p className="mt-1 truncate font-mono text-xs text-faint">
858+ {update.paths.slice(0, 5).join(", ")}
859+ {update.paths.length > 5 && ` and ${update.paths.length - 5} more`}
860+ </p>
861+ </Hint>
857862 )}
858− <p
859− className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line"
860− title={run?.step ?? undefined}
861− >
862− <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
863− {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")}
864− </p>
863+ <Hint label={run?.step}>
864+ <p className="mt-2 truncate rounded-lg bg-bg px-3 py-2 font-mono text-xs text-fg/85 ring-1 ring-line">
865+ <span className="mr-2 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
866+ {run?.step ?? (run ? "Starting a sandbox…" : "Waiting for a sandbox…")}
867+ </p>
868+ </Hint>
865869 <p className="mt-2 flex flex-wrap items-center gap-x-3 text-xs text-muted">
866870 <Elapsed from={new Date(startedAt).toISOString()} />
867871 <Link to={session} className="hover:text-fg">
+11−8
3636 import { Unavailable } from "./mission";
3737 import { TokenUsagePanel } from "./token-usage";
3838 import { Avatar, SubmitButton, TimeAgo } from "./ui";
39+import { Hint } from "./ui/hint";
3940 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
4041 import type { Loaded } from "../routes/home";
4142
413414
414415 function Stat({ label, value, hint, dot, title }: { label: string; value: string; hint: ReactNode; dot?: string; title?: string }) {
415416 return (
416− <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4" title={title}>
417− <p className="flex items-center gap-1.5 truncate text-xs text-muted">
418− {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />}
419− {label}
420− </p>
421− <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p>
422− <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>
423− </div>
417+ <Hint label={title}>
418+ <div className="min-w-0 bg-surface px-4 py-3.5 sm:px-5 sm:py-4">
419+ <p className="flex items-center gap-1.5 truncate text-xs text-muted">
420+ {dot && <span className={cn("size-1.5 shrink-0 rounded-full", dot)} />}
421+ {label}
422+ </p>
423+ <p className="mt-1 font-display text-2xl font-semibold tracking-tight tabular-nums sm:text-[1.75rem]">{value}</p>
424+ <p className="mt-0.5 truncate text-xs text-faint">{hint}</p>
425+ </div>
426+ </Hint>
424427 );
425428 }
426429
+22−14
3333 import { cn } from "../lib/cn";
3434 import { type ActivityGroup, type Need, type NeedKind, type Verb, isAgent, sparkPoints } from "../lib/mission";
3535 import { Avatar, TimeAgo } from "./ui";
36+import { Hint } from "./ui/hint";
3637 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
3738
3839 // --- Panels -------------------------------------------------------------------
127128 const look = NEED[need.kind];
128129 return (
129130 <li key={need.key} className="flex items-start gap-3 px-4 py-3">
130− <span className={cn("mt-0.5 shrink-0", look.tone)} title={look.label}>
131− {look.icon}
132− </span>
131+ <Hint label={look.label}>
132+ <span className={cn("mt-0.5 shrink-0", look.tone)}>
133+ {look.icon}
134+ <span className="sr-only">{look.label}</span>
135+ </span>
136+ </Hint>
133137 <span className="min-w-0 grow">
134138 <Link to={need.to} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent">
135139 {need.title}
447451 </span>
448452 )}
449453 </div>
450− <p className="mt-1.5 truncate text-[0.6875rem] text-faint" title={hint}>
451− {hint}
452− </p>
454+ <Hint label={hint}>
455+ <p className="mt-1.5 truncate text-[0.6875rem] text-faint">{hint}</p>
456+ </Hint>
453457 </>
454458 );
455459 const box = "block rounded-xl border border-line bg-surface p-4 transition-colors";
491495 {Array.from({ length: slots - shown.length }, (_, i) => (
492496 <span key={`empty-${i}`} className="h-2 flex-1 rounded-sm bg-line/60" />
493497 ))}
494− {shown.map((build) => (
495− <Link
496− key={build.id}
497− to={`${base}/deployments/${build.id}`}
498− title={`${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`}
499− className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")}
500− />
501− ))}
498+ {shown.map((build) => {
499+ const about = `${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`;
500+ return (
501+ <Hint key={build.id} label={about}>
502+ <Link
503+ to={`${base}/deployments/${build.id}`}
504+ aria-label={about}
505+ className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")}
506+ />
507+ </Hint>
508+ );
509+ })}
502510 </div>
503511 );
504512 }
+4−3
1010 import { type Connection, MODEL_TASKS, MODEL_TIERS, type ModelRoute, type ModelTask, type ModelTier, type Provider, PROVIDERS } from "@g1t/contracts";
1111
1212 import { Field, Input, SubmitButton } from "./ui";
13+import { Hint } from "./ui/hint";
1314 import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1415
1516 type Group = "labs" | "platforms" | "any";
216217 <ProviderMark provider={provider} size={28} />
217218 <span className="min-w-0 grow">
218219 <span className="block truncate text-sm font-medium">{PROVIDERS[provider].label}</span>
219− <span className="block truncate text-xs text-muted" title={blurb(provider)}>
220− {blurb(provider)}
221− </span>
220+ <Hint label={blurb(provider)}>
221+ <span className="block truncate text-xs text-muted">{blurb(provider)}</span>
222+ </Hint>
222223 </span>
223224 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
224225 </Link>
+6−7
1515 import { Avatar, CopyLine, TimeAgo, notACredential } from "./ui";
1616 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
1717 import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
18+import { Hint } from "./ui/hint";
1819 import { SkeletonLine } from "./ui/skeleton";
1920
2021 function encodePath(path: string): string {
256257 </Link>
257258 <span className="hidden min-w-0 sm:block">
258259 {commit ? (
259− <Link
260− to={`${base}/commit/${commit.hash}`}
261− className="block animate-fade-in truncate text-muted hover:text-fg hover:underline"
262− title={commit.message.split("\n")[0]}
263− >
264− {commit.message.split("\n")[0]}
265− </Link>
260+ <Hint label={commit.message.split("\n")[0]}>
261+ <Link to={`${base}/commit/${commit.hash}`} className="block animate-fade-in truncate text-muted hover:text-fg hover:underline">
262+ {commit.message.split("\n")[0]}
263+ </Link>
264+ </Hint>
266265 ) : last === undefined ? (
267266 <SkeletonLine width="10rem" barClassName="max-w-full" />
268267 ) : null}
+7−3
1414 import type { SecretsAction, SecretsData } from "../lib/secrets.server";
1515 import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui";
1616 import { CheckboxOption } from "./ui/checkbox";
17+import { Hint } from "./ui/hint";
1718 import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
1819 import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
1920
183184 ) : (
184185 <>
185186 {row.projects.length > 0 && (
186− <span className="mr-1 text-xs text-faint" title={row.projects.join(", ")}>
187− {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
188− </span>
187+ <Hint label={row.projects.join(", ")}>
188+ <span className="mr-1 text-xs text-faint">
189+ {row.projects.length} {row.projects.length === 1 ? "project" : "projects"}
190+ <span className="sr-only">: {row.projects.join(", ")}</span>
191+ </span>
192+ </Hint>
189193 )}
190194 {manage && (
191195 <>
+12−10
2525 import { SeverityBadge } from "./security";
2626 import { TimeAgo } from "./ui";
2727 import { Badge } from "./ui/badge";
28+import { Hint } from "./ui/hint";
2829 import { RadioGroup, RadioOption } from "./ui/radio-group";
2930 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
3031
390391 }
391392 return (
392393 <span className="flex flex-col items-end gap-1">
393− <button
394− type="button"
395− disabled={fetcher.state !== "idle"}
396− onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })}
397− className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}
398− title="Opens an issue assigned to g1t; its run is charged as agent usage"
399− >
400− <Sparkles size={13} />
401− {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"}
402− </button>
394+ <Hint label="Opens an issue assigned to g1t; its run is charged as agent usage">
395+ <button
396+ type="button"
397+ disabled={fetcher.state !== "idle"}
398+ onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })}
399+ className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}
400+ >
401+ <Sparkles size={13} />
402+ {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"}
403+ </button>
404+ </Hint>
403405 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
404406 </span>
405407 );
+22−9
5454 } from "../lib/security-alerts";
5555 import { Avatar, TimeAgo } from "./ui";
5656 import { Badge, type BadgeTone } from "./ui/badge";
57+import { Hint } from "./ui/hint";
5758 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
5859 import { RadioGroup, RadioOption } from "./ui/radio-group";
5960
345346 <Link to={`${base}/security/secret-scanning/${finding.id}`} className="text-sm font-medium first-letter:uppercase hover:underline">
346347 {finding.label}
347348 </Link>
348− <Badge tone={badge.tone} title={badge.about}>
349− {badge.label}
350− </Badge>
351− {finding.testValue && <Badge title={finding.testValue}>Likely test value</Badge>}
349+ <Hint label={badge.about}>
350+ <Badge tone={badge.tone}>{badge.label}</Badge>
351+ </Hint>
352+ {finding.testValue && (
353+ <Hint label={finding.testValue}>
354+ <Badge>Likely test value</Badge>
355+ </Hint>
356+ )}
352357 {finding.validity === "active" && (
353− <Badge tone="danger" title="Its issuer says it still works">
354− Active
355− </Badge>
358+ <Hint label="Its issuer says it still works">
359+ <Badge tone="danger">Active</Badge>
360+ </Hint>
356361 )}
357− {finding.validity === "inactive" && <Badge title="Its issuer refused it: revoked or expired">Inactive</Badge>}
358− {finding.bypass && <Badge tone="warn" title={`Bypassed by ${finding.bypass.by}`}>Bypassed</Badge>}
362+ {finding.validity === "inactive" && (
363+ <Hint label="Its issuer refused it: revoked or expired">
364+ <Badge>Inactive</Badge>
365+ </Hint>
366+ )}
367+ {finding.bypass && (
368+ <Hint label={`Bypassed by ${finding.bypass.by}`}>
369+ <Badge tone="warn">Bypassed</Badge>
370+ </Hint>
371+ )}
359372 {finding.state === "dismissed" && finding.dismissedReason && <Badge>{dismissLabel(finding.dismissedReason)}</Badge>}
360373 </div>
361374 <p className="mt-1 truncate font-mono text-xs">
+24−20
88 import { AgentButton, InboxBell } from "./inbox";
99 import { PinButton } from "./pin-button";
1010 import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
11+import { Hint } from "./ui/hint";
1112 import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "./ui/sheet";
1213 import { StatusDot, useSiteStatus } from "./footer";
1314 import { Logo, Mark } from "./logo";
136137 */
137138 function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
138139 return (
139− <div
140− title={about}
141− aria-disabled="true"
142− className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
143− >
144− <span className="shrink-0 opacity-70">{icon}</span>
145− <span className="grow truncate">{children}</span>
146− <SoonPill />
147− </div>
140+ <Hint label={about} side="right">
141+ <div aria-disabled="true" className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint">
142+ <span className="shrink-0 opacity-70">{icon}</span>
143+ <span className="grow truncate">{children}</span>
144+ <span className="sr-only">{about}</span>
145+ <SoonPill />
146+ </div>
147+ </Hint>
148148 );
149149 }
150150
167167 }) {
168168 const { pathname } = useLocation();
169169 return (
170+ <Hint label={about} side="right">
170171 <NavLink
171172 to={to}
172− title={about}
173173 prefetch="intent"
174174 className={({ isActive }) => {
175175 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
182182 <span className="min-w-0 grow truncate">{children}</span>
183183 <SoonPill />
184184 </NavLink>
185+ </Hint>
185186 );
186187 }
187188
234235 <DropdownMenu>
235236 {/* The name goes to the workspace; only the arrows switch it. The
236237 name gets all the room there is, and the whole of it on hover. */}
238+ <Hint label={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}>
237239 <Link
238240 to={current ? `/${current}` : "/workspaces/new"}
239241 prefetch="intent"
240− title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
241242 className="flex h-9 min-w-0 grow items-center gap-2 rounded-md px-2 transition-colors hover:bg-raised"
242243 >
243244 {workspace ? (
247248 )}
248249 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
249250 </Link>
251+ </Hint>
250252 <DropdownMenuTrigger
251253 aria-label="Switch workspace"
252254 className="flex h-9 w-7 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-raised data-[state=open]:text-fg"
274276 )}
275277 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
276278 {(user.workspaces ?? []).map((membership) => (
277− <DropdownMenuItem asChild key={membership.slug}>
278− <Link to={`/${membership.slug}`} title={displayName(membership)}>
279+ <Hint key={membership.slug} label={displayName(membership)} side="right">
280+ <DropdownMenuItem asChild>
281+ <Link to={`/${membership.slug}`}>
279282 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
280283 <span className="flex min-w-0 grow flex-col leading-tight">
281284 <span className="truncate">{displayName(membership)}</span>
289292 )}
290293 </Link>
291294 </DropdownMenuItem>
295+ </Hint>
292296 ))}
293297 <DropdownMenuSeparator />
294298 <DropdownMenuItem asChild>
655659 event.preventDefault();
656660 move(index, event.key === "ArrowUp" ? -1 : 1);
657661 }}
658− title={pinned.length > 1 ? "Drag, or Alt and an arrow key, to reorder" : undefined}
659662 className={`group/pin relative ${dragging === index ? "opacity-50" : ""}`}
660663 >
661664 {row(project, true)}
662665 {pinned.length > 1 && (
663− <GripVertical
664− size={12}
665− aria-hidden="true"
666− className="pointer-events-none absolute top-1/2 -left-1.5 -translate-y-1/2 text-faint opacity-0 transition-opacity group-hover/pin:opacity-100"
667− />
666+ <Hint label="Drag, or Alt and an arrow key, to reorder" side="right">
667+ <span className="absolute top-1/2 -left-1.5 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100">
668+ <GripVertical size={12} aria-hidden="true" />
669+ </span>
670+ </Hint>
668671 )}
669672 </li>
670673 ))}
903906 return (
904907 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
905908 <BackRow to={back.to} label={back.label} />
909+ <Hint label="Overview" side="right">
906910 <NavLink
907911 to={base}
908912 end
909913 prefetch="intent"
910− title="Overview"
911914 className={({ isActive }) =>
912915 `mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
913916 }
920923 <span className="font-semibold text-fg">{repo.name}</span>
921924 </span>
922925 </NavLink>
926+ </Hint>
923927 {/* A project's pages, in the order people use them. A page with more
924928 than one view shows them as tabs across its top. */}
925929 <Rule />
+47−46
2626 normalizeScopes,
2727 } from "../lib/token-scopes";
2828 import { Badge } from "./ui/badge";
29+import { Hint } from "./ui/hint";
2930 import { CONTROL } from "./ui/input";
3031
3132 // Choosing what a token or an application may do: a classic checklist.
4748 const by = impliedBy(ticked, scope);
4849 const checked = by !== null || ticked.includes(scope);
4950 return (
50− <label
51− className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")}
52− title={by ? `Included in ${by}` : undefined}
53− >
54− <input
55− type="checkbox"
56− name="scope"
57− value={scope}
58− checked={checked}
59− disabled={by !== null}
60− onChange={(event) => onToggle(scope, event.target.checked)}
61− className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")}
62− />
63− <span className="min-w-0">
64− <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}>
65− {scope}
51+ <Hint label={by ? `Included in ${by}` : undefined}>
52+ <label className={cn("flex min-w-0 items-start gap-2.5 py-1", by ? "cursor-default" : "cursor-pointer")}>
53+ <input
54+ type="checkbox"
55+ name="scope"
56+ value={scope}
57+ checked={checked}
58+ disabled={by !== null}
59+ onChange={(event) => onToggle(scope, event.target.checked)}
60+ className={cn("mt-0.5 size-4 shrink-0", isDangerous(scope) ? "accent-danger" : "accent-accent")}
61+ />
62+ <span className="min-w-0">
63+ <span className={cn("block font-mono text-[0.8125rem]", isDangerous(scope) ? "text-danger" : "text-fg", by && "opacity-60")}>
64+ {scope}
65+ </span>
66+ <span className="block text-xs leading-snug text-faint">{describeScope(scope)}</span>
67+ {by && <span className="sr-only">Included in {by}</span>}
6668 </span>
67− <span className="block text-xs leading-snug text-faint">{describeScope(scope)}</span>
68− </span>
69− </label>
69+ </label>
70+ </Hint>
7071 );
7172 }
7273
126127 <div className="flex flex-wrap items-center gap-1.5">
127128 <span className="mr-1 text-sm font-medium text-muted">Scopes</span>
128129 {PRESETS.filter((option) => allowFull || option.id !== "full").map((option) => (
129− <button
130− key={option.id}
131− type="button"
132− aria-pressed={preset === option.id}
133− title={option.description}
134− onClick={() => choosePreset(option.id)}
135− className={cn(
136− "rounded-full border px-2.5 py-0.5 text-xs transition-colors",
137− preset === option.id
138− ? option.id === "full"
139− ? "border-danger/50 bg-danger/10 text-danger"
140− : "border-accent/50 bg-accent/10 text-accent"
141− : "border-line text-muted hover:border-line-strong hover:text-fg",
142− )}
143− >
144− {option.label}
145− </button>
130+ <Hint key={option.id} label={option.description}>
131+ <button
132+ type="button"
133+ aria-pressed={preset === option.id}
134+ onClick={() => choosePreset(option.id)}
135+ className={cn(
136+ "rounded-full border px-2.5 py-0.5 text-xs transition-colors",
137+ preset === option.id
138+ ? option.id === "full"
139+ ? "border-danger/50 bg-danger/10 text-danger"
140+ : "border-accent/50 bg-accent/10 text-accent"
141+ : "border-line text-muted hover:border-line-strong hover:text-fg",
142+ )}
143+ >
144+ {option.label}
145+ </button>
146+ </Hint>
146147 ))}
147148 <span className="ml-auto text-xs text-faint">
148149 {count === null ? "Everything you can do" : count === 1 ? "1 scope" : `${count} scopes`}
241242 <div className={cn("mt-1.5 flex flex-wrap items-center gap-1.5", className)}>
242243 <Badge tone={tone}>{summary}</Badge>
243244 {scopes.map((scope) => (
244− <span
245− key={scope}
246− title={describeScope(scope)}
247− className={cn(
248− "rounded border px-1.5 py-px font-mono text-[0.6875rem]",
249− isDangerous(scope) ? "border-danger/40 text-danger" : "border-line text-muted",
250− )}
251− >
252− {scope}
253− </span>
245+ <Hint key={scope} label={describeScope(scope)}>
246+ <span
247+ className={cn(
248+ "rounded border px-1.5 py-px font-mono text-[0.6875rem]",
249+ isDangerous(scope) ? "border-danger/40 text-danger" : "border-line text-muted",
250+ )}
251+ >
252+ {scope}
253+ </span>
254+ </Hint>
254255 ))}
255256 </div>
256257 );
+16−13
33
44 import { cn } from "../lib/cn";
55 import { usd } from "../lib/mission-control";
6+import { Hint } from "./ui/hint";
67 import {
78 HEAT_LEVELS,
89 type MixPart,
109110
110111 function Tile({ label, value, hint, title }: { label: string; value: string; hint?: string; title?: string }) {
111112 return (
112− <div className="rounded-lg border border-line bg-bg/40 px-3 py-2.5" title={title}>
113− <dt className="text-[0.6875rem] text-muted">{label}</dt>
114− <dd className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums">
115− {value}
116− {hint && <span className="ml-1 text-xs font-normal text-faint">{hint}</span>}
117− </dd>
118− </div>
113+ <Hint label={title}>
114+ <div className="rounded-lg border border-line bg-bg/40 px-3 py-2.5">
115+ <dt className="text-[0.6875rem] text-muted">{label}</dt>
116+ <dd className="mt-0.5 text-lg font-semibold tracking-tight tabular-nums">
117+ {value}
118+ {hint && <span className="ml-1 text-xs font-normal text-faint">{hint}</span>}
119+ </dd>
120+ </div>
121+ </Hint>
119122 );
120123 }
121124
179182 <h3 className="text-xs font-medium text-fg-soft">Token mix</h3>
180183 <div className="mt-2 flex h-2.5 gap-[2px] overflow-hidden rounded" role="img" aria-label={parts.map((p) => `${p.label} ${Math.round(p.share * 100)}%`).join(", ")}>
181184 {parts.map((part) => (
182− <span
183− key={part.key}
184− title={`${part.label}: ${compactTokens(part.tokens)} (${Math.round(part.share * 100)}%)`}
185− className="h-full first:rounded-l last:rounded-r"
186− style={{ width: `${part.share * 100}%`, minWidth: 3, background: MIX_COLOR[part.key] }}
187− />
185+ <Hint key={part.key} label={`${part.label}: ${compactTokens(part.tokens)} (${Math.round(part.share * 100)}%)`}>
186+ <span
187+ className="h-full first:rounded-l last:rounded-r"
188+ style={{ width: `${part.share * 100}%`, minWidth: 3, background: MIX_COLOR[part.key] }}
189+ />
190+ </Hint>
188191 ))}
189192 </div>
190193 <ul className="mt-2.5 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[0.6875rem]">
+74−0
1+import { type ComponentProps, type MouseEvent, type PointerEvent, type ReactElement, type ReactNode, useRef, useState } from "react";
2+
3+import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
4+
5+/**
6+ * A hover hint: the site's tooltip around exactly one element, in place of
7+ * the browser's `title`, which g1t does not use (app/lib/no-native-title.test.ts
8+ * keeps it out). It opens on hover and keyboard focus, and on a tap of
9+ * something that does nothing else when tapped, since a touch screen has no
10+ * hover. Without a `label` the child renders as it is.
11+ *
12+ * The hint is not the element's accessible name: an icon-only button keeps
13+ * its `aria-label`, and text cut short keeps its whole text for a screen
14+ * reader. Anything else passed (as from a parent's `asChild`) goes to the child.
15+ */
16+export function Hint({
17+ label,
18+ children,
19+ side,
20+ align,
21+ disabled,
22+ onClick,
23+ onPointerDown,
24+ ...props
25+}: Omit<ComponentProps<typeof TooltipTrigger>, "children"> & {
26+ label: ReactNode;
27+ /** One element that takes a ref and passes its props on (asChild). */
28+ children: ReactElement;
29+ side?: ComponentProps<typeof TooltipContent>["side"];
30+ align?: ComponentProps<typeof TooltipContent>["align"];
31+ /**
32+ * The child is disabled, so it gets no pointer events and no focus: the
33+ * hint hangs on a focusable span around it instead, as when it says why.
34+ */
35+ disabled?: boolean;
36+}) {
37+ const [open, setOpen] = useState(false);
38+ const touch = useRef(false);
39+ if (label == null || label === false || label === "") return children;
40+ const trigger = disabled ? (
41+ <span tabIndex={0} className="inline-flex rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent *:pointer-events-none">
42+ {children}
43+ </span>
44+ ) : (
45+ children
46+ );
47+ return (
48+ <Tooltip open={open} onOpenChange={setOpen}>
49+ <TooltipTrigger
50+ asChild
51+ {...props}
52+ onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
53+ touch.current = event.pointerType === "touch";
54+ onPointerDown?.(event);
55+ }}
56+ onClick={(event: MouseEvent<HTMLButtonElement>) => {
57+ onClick?.(event);
58+ // A tap on text or a badge shows the hint; a tap on a link or a
59+ // button does what it does. preventDefault keeps the trigger's own
60+ // click handler from closing the hint again.
61+ if (touch.current && !event.defaultPrevented && !(event.target as Element).closest("a, button, input, select, textarea, label, summary")) {
62+ event.preventDefault();
63+ setOpen(true);
64+ }
65+ }}
66+ >
67+ {trigger}
68+ </TooltipTrigger>
69+ <TooltipContent side={side} align={align}>
70+ {label}
71+ </TooltipContent>
72+ </Tooltip>
73+ );
74+}
+13−11
1111 import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server";
1212 import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
1313 import { CheckboxOption } from "./ui/checkbox";
14+import { Hint } from "./ui/hint";
1415 import { RadioGroup, RadioOption } from "./ui/radio-group";
1516
1617 /** The events, in groups people recognise. */
177178 /** One webhook's action, as an icon: a spinner in its place while that action goes. */
178179 function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) {
179180 return (
180− <SubmitButton
181− icon
182− name="intent"
183− value={intent}
184− match={{ id }}
185− title={label}
186− aria-label={label}
187− className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
188− >
189− {children}
190− </SubmitButton>
181+ <Hint label={label}>
182+ <SubmitButton
183+ icon
184+ name="intent"
185+ value={intent}
186+ match={{ id }}
187+ aria-label={label}
188+ className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
189+ >
190+ {children}
191+ </SubmitButton>
192+ </Hint>
191193 );
192194 }
193195
+23−20
1111 import { MentionTextarea } from "./mention-textarea";
1212 import { Avatar, SubmitButton, TimeAgo } from "./ui";
1313 import { CheckboxOption } from "./ui/checkbox";
14+import { Hint } from "./ui/hint";
1415
1516 export { IssueIcon, PullIcon };
1617
5758 if (people.length === 0) return null;
5859 const shown = people.slice(0, 3);
5960 return (
60− <span
61− className="flex shrink-0 items-center"
62− title={`Assigned to ${people.join(", ")}`}
63− >
64− {shown.map((name, index) => (
65− <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`}>
66− <Avatar name={name} size={18} />
67− </span>
68− ))}
69− {people.length > shown.length && (
70− <span className="ml-1 text-xs text-faint">+{people.length - shown.length}</span>
71− )}
72− </span>
61+ <Hint label={`Assigned to ${people.join(", ")}`}>
62+ <span className="flex shrink-0 items-center">
63+ <span className="sr-only">Assigned to {people.join(", ")}</span>
64+ {shown.map((name, index) => (
65+ <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`} aria-hidden>
66+ <Avatar name={name} size={18} />
67+ </span>
68+ ))}
69+ {people.length > shown.length && (
70+ <span className="ml-1 text-xs text-faint" aria-hidden>
71+ +{people.length - shown.length}
72+ </span>
73+ )}
74+ </span>
75+ </Hint>
7376 );
7477 }
7578
7679 /** The agent working on an issue now. */
7780 export function Assignee({ agent }: { agent: string }) {
7881 return (
79− <span
80− className="inline-flex shrink-0 items-center gap-1 rounded-full border border-accent/35 bg-accent/10 px-2 py-px text-xs font-medium text-accent"
81− title={`Assigned to ${agent}`}
82− >
83− <Bot size={12} />
84− {agent}
85− </span>
82+ <Hint label={`Assigned to ${agent}`}>
83+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-accent/35 bg-accent/10 px-2 py-px text-xs font-medium text-accent">
84+ <Bot size={12} />
85+ <span className="sr-only">Assigned to </span>
86+ {agent}
87+ </span>
88+ </Hint>
8689 );
8790 }
8891
+88−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { join, relative } from "node:path";
4+import { test } from "node:test";
5+import { fileURLToPath } from "node:url";
6+
7+// Hover hints are the site's tooltip (components/ui/hint.tsx, or Tooltip
8+// from components/ui/tooltip.tsx), never the browser's `title`: it looks
9+// foreign, waits a second and a half, and never shows on a touch screen or
10+// to the keyboard. This fails on a `title` on an HTML element, or on a
11+// component that hands its props to one.
12+
13+/** Components that pass `title` on to an HTML element. */
14+const FORWARDS = ["Link", "NavLink", "Button", "ButtonLink", "SubmitButton", "Badge", "Switch", "Input", "Textarea", "TabLink"];
15+
16+/**
17+ * Where a native `title` is right. `file:line`, with the reason. An SVG's
18+ * `<title>` child is an element, not an attribute, and needs no entry.
19+ */
20+const ALLOWED: Record<string, string> = {};
21+
22+const APP = fileURLToPath(new URL("..", import.meta.url));
23+
24+function* sources(dir: string): Generator<string> {
25+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
26+ const path = join(dir, entry.name);
27+ if (entry.isDirectory()) yield* sources(path);
28+ else if (entry.name.endsWith(".tsx")) yield path;
29+ }
30+}
31+
32+/** Each opening tag of an HTML element or a forwarding component with a `title` attribute. */
33+function nativeTitles(source: string): { tag: string; line: number }[] {
34+ const found: { tag: string; line: number }[] = [];
35+ const opening = new RegExp(`<([a-z][a-z0-9]*|${FORWARDS.join("|")})(?=[\\s/>])`, "g");
36+ for (let match = opening.exec(source); match; match = opening.exec(source)) {
37+ // The tag's attributes, up to its closing `>`, with every {...} left out.
38+ let attributes = "";
39+ let depth = 0;
40+ let quote: string | null = null;
41+ for (let i = match.index + match[0].length; i < source.length; i++) {
42+ const c = source[i];
43+ if (quote) {
44+ if (c === quote) quote = null;
45+ if (depth === 0) attributes += c;
46+ continue;
47+ }
48+ if (c === '"' || c === "'" || c === "`") quote = c;
49+ else if (c === "{") depth++;
50+ else if (c === "}") depth--;
51+ else if (c === ">" && depth === 0) break;
52+ if (depth === 0) attributes += c;
53+ }
54+ if (/(^|\s)title=/.test(attributes)) {
55+ found.push({ tag: match[1], line: source.slice(0, match.index).split("\n").length });
56+ }
57+ }
58+ return found;
59+}
60+
61+test("finds a title on an element, not on a component's own prop or inside an expression", () => {
62+ const hits = nativeTitles(
63+ [
64+ `<span title="x">a</span>`,
65+ `<Section title="Heading">`,
66+ `<div\n className="a"\n title={label}\n>`,
67+ `<Link to="/" title={x}>`,
68+ `<button onClick={() => open({ title: "x" })}>`,
69+ `<p className={cn("a", b && "title=")}>`,
70+ ].join("\n"),
71+ );
72+ assert.deepEqual(hits, [
73+ { tag: "span", line: 1 },
74+ { tag: "div", line: 3 },
75+ { tag: "Link", line: 7 },
76+ ]);
77+});
78+
79+test("no native title hints in apps/web", () => {
80+ const offenders: string[] = [];
81+ for (const file of sources(APP)) {
82+ const name = relative(APP, file).replaceAll("\\", "/");
83+ for (const { tag, line } of nativeTitles(readFileSync(file, "utf8"))) {
84+ if (!ALLOWED[`${name}:${line}`]) offenders.push(`app/${name}:${line} <${tag} title=…>`);
85+ }
86+ }
87+ assert.deepEqual(offenders, [], "Use <Hint label=…> from components/ui/hint.tsx in place of a native title.");
88+});
+9−6
88 import { page } from "../../lib/meta";
99 import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
1010 import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
11+import { Hint } from "../../components/ui/hint";
1112 import { listArtifacts } from "../../lib/artifacts.server";
1213 import { actions } from "../../lib/services.server";
1314 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
106107 );
107108 // A link to the runners for those who manage them, a label for everyone else.
108109 return runnersPage ? (
109− <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`} title="Self-hosted runner">
110− {label}
111− </Link>
110+ <Hint label="Self-hosted runner">
111+ <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`}>
112+ {label}
113+ </Link>
114+ </Hint>
112115 ) : (
113− <span className={badge} title="Self-hosted runner">
114− {label}
115− </span>
116+ <Hint label="Self-hosted runner">
117+ <span className={badge}>{label}</span>
118+ </Hint>
116119 );
117120 }
118121 if (!job.startedAt) return null;
+6−3
44 import type { Route } from "./+types/branches";
55 import { ActiveBranches } from "../../components/branches";
66 import { Avatar, EmptyState, TimeAgo, notACredential } from "../../components/ui";
7+import { Hint } from "../../components/ui/hint";
78 import { readBranches } from "../../lib/branches.server";
89 import { page } from "../../lib/meta";
910 import { deployments, repos, work } from "../../lib/services.server";
9697 <Avatar name={head.author} size={13} />
9798 <span className="shrink-0">{head.author}</span>
9899 <span className="text-faint">·</span>
99− <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg" title={head.message}>
100− {head.message}
101− </Link>
100+ <Hint label={head.message}>
101+ <Link to={`${base}/commit/${head.hash}`} className="min-w-0 truncate hover:text-fg">
102+ {head.message}
103+ </Link>
104+ </Hint>
102105 <span className="shrink-0 text-faint">
103106 · <TimeAgo at={head.at} />
104107 </span>
+6−3
99 import { page } from "../../lib/meta";
1010 import { DiffView } from "../../components/diff-view";
1111 import { Avatar, TimeAgo } from "../../components/ui";
12+import { Hint } from "../../components/ui/hint";
1213 import { Skeleton } from "../../components/ui/skeleton";
1314 import { immutable } from "../../lib/immutable.server";
1415 import { pullForCommit } from "../../lib/provenance.server";
157158 <span className="flex items-center gap-2">
158159 <Avatar name={owner?.username ?? commit.author.name} image={owner?.avatar} size={20} />
159160 {owner ? (
160− <Link to={`/u/${owner.username}`} className="font-medium hover:underline" title={commit.author.name}>
161− {owner.username}
162− </Link>
161+ <Hint label={commit.author.name}>
162+ <Link to={`/u/${owner.username}`} className="font-medium hover:underline">
163+ {owner.username}
164+ </Link>
165+ </Hint>
163166 ) : (
164167 <span className="font-medium">{commit.author.name}</span>
165168 )}
+6−3
66 import type { Route } from "./+types/commits";
77 import { page } from "../../lib/meta";
88 import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
9+import { Hint } from "../../components/ui/hint";
910 import { accounts, repos } from "../../lib/services.server";
1011 import { getViewer, unwrap } from "../../lib/session.server";
1112
7576 {rest && <p className="mt-1 line-clamp-1 text-sm text-muted">{rest}</p>}
7677 <p className="mt-1 text-xs text-faint">
7778 {owner ? (
78− <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg" title={commit.author.name}>
79− {owner.username}
80− </Link>
79+ <Hint label={commit.author.name}>
80+ <Link to={`/u/${owner.username}`} className="relative z-10 text-muted hover:text-fg">
81+ {owner.username}
82+ </Link>
83+ </Hint>
8184 ) : (
8285 commit.author.name
8386 )}{" "}
+6−3
66 import type { Route } from "./+types/compare";
77 import { DiffView } from "../../components/diff-view";
88 import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9+import { Hint } from "../../components/ui/hint";
910 import { page } from "../../lib/meta";
1011 import { repos } from "../../lib/services.server";
1112 import { getViewer, unwrap } from "../../lib/session.server";
133134 <li key={commit.hash} className="flex items-center gap-3 px-4 py-2.5 text-sm">
134135 <GitCommitHorizontal size={15} className="shrink-0 text-faint" />
135136 <Avatar name={commit.author} size={16} />
136− <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent" title={commit.message}>
137− {commit.message}
138− </Link>
137+ <Hint label={commit.message}>
138+ <Link to={`${repoBase}/commit/${commit.hash}`} className="min-w-0 grow truncate hover:text-accent">
139+ {commit.message}
140+ </Link>
141+ </Hint>
139142 <span className="hidden shrink-0 text-xs text-muted sm:inline">{commit.author}</span>
140143 <Link to={`${repoBase}/commit/${commit.hash}`} className="shrink-0 font-mono text-xs text-faint hover:text-fg">
141144 {commit.hash.slice(0, 7)}
+29−29
99 import type { Route } from "./+types/deployments";
1010 import { page } from "../../lib/meta";
1111 import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
12+import { Hint } from "../../components/ui/hint";
1213 import { computeNoteFor } from "../../lib/compute.server";
1314 import { billing, deployments, projects } from "../../lib/services.server";
1415 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
132133 </p>
133134 ) : (
134135 <Form method="post" className="mt-4">
135− <SubmitButton
136− variant="accent"
137− name="intent"
138− value="enable"
139− pending="Turning on…"
140− disabled={!can.manage_integrations}
141− title={whyNot(can, "manage_integrations")}
142− >
143− <Rocket size={14} />
144− Turn on deployments
145− </SubmitButton>
136+ <Hint label={whyNot(can, "manage_integrations")} disabled={!can.manage_integrations}>
137+ <SubmitButton variant="accent" name="intent" value="enable" pending="Turning on…" disabled={!can.manage_integrations}>
138+ <Rocket size={14} />
139+ Turn on deployments
140+ </SubmitButton>
141+ </Hint>
146142 </Form>
147143 )}
148144 </section>
297293 <Form method="post" className="flex items-center gap-2">
298294 {branch != null && <input type="hidden" name="branch" value={branch} />}
299295 <input type="hidden" name="app" value={app} />
300− <SubmitButton
301− variant="quiet"
302− name="intent"
303− value="redeploy"
304− match={{ app }}
305− icon={compact}
306− pending="Redeploying…"
307− title="Build again from the current head"
308− >
309− <RotateCw size={13} />
310− {!compact && "Redeploy"}
311− </SubmitButton>
312− {up && (
296+ <Hint label="Build again from the current head">
313297 <SubmitButton
314298 variant="quiet"
315299 name="intent"
316− value="take-down"
300+ value="redeploy"
317301 match={{ app }}
318302 icon={compact}
319− pending="Taking down…"
320− title="Take it down now"
303+ pending="Redeploying…"
304+ aria-label={compact ? "Redeploy" : undefined}
321305 >
322− <Trash2 size={13} />
323− {!compact && "Take down"}
306+ <RotateCw size={13} />
307+ {!compact && "Redeploy"}
324308 </SubmitButton>
309+ </Hint>
310+ {up && (
311+ <Hint label="Take it down now">
312+ <SubmitButton
313+ variant="quiet"
314+ name="intent"
315+ value="take-down"
316+ match={{ app }}
317+ icon={compact}
318+ pending="Taking down…"
319+ aria-label={compact ? "Take down" : undefined}
320+ >
321+ <Trash2 size={13} />
322+ {!compact && "Take down"}
323+ </SubmitButton>
324+ </Hint>
325325 )}
326326 </Form>
327327 );
+8−7
77 import { page } from "../../lib/meta";
88 import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
99 import { Checkbox } from "../../components/ui/checkbox";
10+import { Hint } from "../../components/ui/hint";
1011 import {
1112 Assignee,
1213 AssigneeStack,
279280 <span className="mt-0.5 flex shrink-0 items-center gap-3 text-xs text-muted">
280281 <AssigneeStack people={issue.assignees} />
281282 {issue.pullCount > 0 && (
282− <span
283− className="flex items-center gap-1"
284− title={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}
285− >
286− <GitPullRequest size={13} />
287− {issue.pullCount}
288− </span>
283+ <Hint label={`${issue.pullCount} pull ${issue.pullCount === 1 ? "request" : "requests"}`}>
284+ <span className="flex items-center gap-1">
285+ <GitPullRequest size={13} />
286+ {issue.pullCount}
287+ <span className="sr-only">pull {issue.pullCount === 1 ? "request" : "requests"}</span>
288+ </span>
289+ </Hint>
289290 )}
290291 {issue.commentCount > 0 && (
291292 <span className="flex items-center gap-1">
+21−20
1010 import { page } from "../../lib/meta";
1111 import { type Tab as PageTab, tabsFor } from "../../lib/project-nav";
1212 import { Pill, SoonPill } from "../../components/ui";
13+import { Hint } from "../../components/ui/hint";
1314 import { TabStrip } from "../../components/ui/tab-strip";
1415 import { WatchMenu } from "../../components/notifications";
1516 import { PinButton } from "../../components/pin-button";
144145 return (
145146 <TabStrip label="Views" className="-mb-px gap-1">
146147 {tabs.map((tab) => (
147− <Link
148− key={tab.path}
149− to={`${base}/${tab.path}`}
150− title={tab.about}
151− prefetch="intent"
152− // Current by the tab's own rule: a link's address alone would also
153− // mark Overview current on every page under it.
154− aria-current={current(tab) ? "page" : undefined}
155− data-active={current(tab) || undefined}
156− className={`flex items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${
157− current(tab)
158− ? "border-accent font-medium text-fg"
159− : tab.soon
160− ? "border-transparent text-faint hover:text-muted"
161− : "border-transparent text-muted hover:text-fg"
162− }`}
163− >
164− {tab.label}
165− {tab.soon && <SoonPill />}
166− </Link>
148+ <Hint key={tab.path} label={tab.about}>
149+ <Link
150+ to={`${base}/${tab.path}`}
151+ prefetch="intent"
152+ // Current by the tab's own rule: a link's address alone would also
153+ // mark Overview current on every page under it.
154+ aria-current={current(tab) ? "page" : undefined}
155+ data-active={current(tab) || undefined}
156+ className={`flex items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${
157+ current(tab)
158+ ? "border-accent font-medium text-fg"
159+ : tab.soon
160+ ? "border-transparent text-faint hover:text-muted"
161+ : "border-transparent text-muted hover:text-fg"
162+ }`}
163+ >
164+ {tab.label}
165+ {tab.soon && <SoonPill />}
166+ </Link>
167+ </Hint>
167168 ))}
168169 </TabStrip>
169170 );
+31−25
4343 import { type ActiveBranch, ActiveBranches } from "../../components/branches";
4444 import { ProductionChecklist } from "../../components/checklist";
4545 import { PackageIcon } from "../../components/package-icon";
46+import { Hint } from "../../components/ui/hint";
4647 import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
4748 import { ProductionShot } from "../../components/production-shot";
4849 import { GithubLinkStrip } from "../../components/github";
421422 <span className="font-mono text-faint">#{card.number}</span>
422423 <span className="grow" />
423424 {card.needsYou ? (
424− <span className="inline-flex items-center gap-1 text-warn" title="Stopped: a person decides what happens next">
425− <Hand size={11} /> you
426− </span>
425+ <Hint label="Stopped: a person decides what happens next">
426+ <span className="inline-flex items-center gap-1 text-warn">
427+ <Hand size={11} /> you
428+ <span className="sr-only">: stopped, a person decides what happens next</span>
429+ </span>
430+ </Hint>
427431 ) : stage === "landed" ? (
428432 <TimeAgo at={card.at} />
429433 ) : card.runStarted ? (
436440 )}
437441 </span>
438442 {card.step && (
439− <span className="mt-1.5 block truncate font-mono text-[0.6875rem] text-fg/70" title={card.step}>
440− {card.runKind}: {card.step}
441− </span>
443+ <Hint label={card.step}>
444+ <span className="mt-1.5 block truncate font-mono text-[0.6875rem] text-fg/70">
445+ {card.runKind}: {card.step}
446+ </span>
447+ </Hint>
442448 )}
443449 </Link>
444450 </li>
771777 )}
772778 {settings?.enabled ? (
773779 loaderData.can.run && <Form method="post">
774− <SubmitButton
775− variant="quiet"
776− name="intent"
777− value="redeploy"
778− pending="Redeploying…"
779− title="Build production again from the default branch"
780− >
781− <RotateCw size={14} />
782− Redeploy
783− </SubmitButton>
780+ <Hint label="Build production again from the default branch">
781+ <SubmitButton variant="quiet" name="intent" value="redeploy" pending="Redeploying…">
782+ <RotateCw size={14} />
783+ Redeploy
784+ </SubmitButton>
785+ </Hint>
784786 </Form>
785787 ) : loaderData.can.manage_integrations && (
786788 <ButtonLink to={`${base}/settings/deployments`} variant="accent">
829831 to={commit ? `${base}/commit/${commit.hash}` : undefined}
830832 value={
831833 commit ? (
832− <span title={commit.message.split("\n")[0]}>
833− <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "}
834− <span className="font-normal text-muted">
835− <TimeAgo at={commit.authoredAt} />
834+ <Hint label={commit.message.split("\n")[0]}>
835+ <span>
836+ <span className="font-mono">{commit.hash.slice(0, 7)}</span>{" "}
837+ <span className="font-normal text-muted">
838+ <TimeAgo at={commit.authoredAt} />
839+ </span>
836840 </span>
837− </span>
841+ </Hint>
838842 ) : (
839843 "No commits yet"
840844 )
897901 {run.title ?? "A run"} {run.number != null && <span className="text-faint">#{run.number}</span>}
898902 </Link>
899903 {run.step && (
900− <p className="mt-1 truncate font-mono text-[0.6875rem] text-fg/70" title={run.step}>
901− <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
902− {run.step}
903− </p>
904+ <Hint label={run.step}>
905+ <p className="mt-1 truncate font-mono text-[0.6875rem] text-fg/70">
906+ <span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-merged align-middle" />
907+ {run.step}
908+ </p>
909+ </Hint>
904910 )}
905911 <p className="mt-1.5 flex items-center gap-3 text-[0.6875rem] text-faint">
906912 {formatCost(run.costUsd) && <span>{formatCost(run.costUsd)} so far</span>}
+12−7
1212 import { Outcome } from "../../components/outcome";
1313 import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
1414 import { Checkbox } from "../../components/ui/checkbox";
15+import { Hint } from "../../components/ui/hint";
1516 import { Label } from "../../components/work";
1617 import { billing, events, identity, work } from "../../lib/services.server";
1718 import {
263264
264265 {plan.status === "ready" && (
265266 <div className="mt-6 flex flex-wrap items-center gap-3">
266− <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
267− <Sparkles size={15} />
268− Open these and assign g1t
269− </SubmitButton>
270− <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}>
271− Only open the issues
272− </SubmitButton>
267+ <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
268+ <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run}>
269+ <Sparkles size={15} />
270+ Open these and assign g1t
271+ </SubmitButton>
272+ </Hint>
273+ <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
274+ <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run}>
275+ Only open the issues
276+ </SubmitButton>
277+ </Hint>
273278 <span className="text-xs text-muted">
274279 Untick any you do not want. Agents work on the independent ones at once
275280 and the rest follow as what they depend on merges.
+7−4
99 import { whyNot } from "../../lib/access";
1010 import { page } from "../../lib/meta";
1111 import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
12+import { Hint } from "../../components/ui/hint";
1213 import { computeNoteFor } from "../../lib/compute.server";
1314 import { work } from "../../lib/services.server";
1415 import {
7980 }
8081 />
8182 <div className="flex flex-wrap items-center gap-3">
82− <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run} title={whyNot(loaderData.can, "run")}>
83− <Sparkles size={15} />
84− Plan it
85− </SubmitButton>
83+ <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
84+ <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run}>
85+ <Sparkles size={15} />
86+ Plan it
87+ </SubmitButton>
88+ </Hint>
8689 <span className="text-xs text-muted">
8790 Nothing is opened until you have read the plan.
8891 </span>
+13−11
6363 usePending,
6464 } from "../../components/ui";
6565 import { CheckboxOption } from "../../components/ui/checkbox";
66+import { Hint } from "../../components/ui/hint";
6667 import { Loading, SkeletonLine } from "../../components/ui/skeleton";
6768 import { TabStrip } from "../../components/ui/tab-strip";
6869 import { WorkflowStatuses } from "../../components/actions";
12131214 />
12141215 )}
12151216 <div className="flex flex-wrap items-center gap-3">
1216− <SubmitButton
1217− variant="accent"
1218− name="action"
1219− value="merge"
1220− disabled={mergeBlocked != null}
1221− title={mergeBlocked ?? undefined}
1222− pending={mergeQueue ? "Adding to the queue…" : "Merging…"}
1223− >
1224− {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
1225− {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
1226− </SubmitButton>
1217+ <Hint label={mergeBlocked} disabled={mergeBlocked != null}>
1218+ <SubmitButton
1219+ variant="accent"
1220+ name="action"
1221+ value="merge"
1222+ disabled={mergeBlocked != null}
1223+ pending={mergeQueue ? "Adding to the queue…" : "Merging…"}
1224+ >
1225+ {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
1226+ {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
1227+ </SubmitButton>
1228+ </Hint>
12271229 <span className={`text-xs ${mergeBlocked ? "text-danger" : "text-muted"}`}>
12281230 {mergeBlocked ?? (mergeQueue
12291231 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
+10−7
55 import { page } from "../../lib/meta";
66 import { openedBy } from "../../lib/opened-by";
77 import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
8+import { Hint } from "../../components/ui/hint";
89 import { CheckBadge } from "../../components/checks";
910 import { ChangeSize, PullIcon, StateTabs } from "../../components/work";
1011 import { AgentBadge, useActiveRuns } from "../../components/agents";
112113 }))}
113114 />
114115 {loaderData.base && (
115− <Link
116− to={withFilter(list, current, "base", null)}
117− className="inline-flex h-8 items-center gap-1.5 rounded-md border border-accent/40 bg-accent/5 px-2.5 text-sm"
118− title="Show pull requests into every branch"
119− >
120− Into <span className="font-mono">{loaderData.base}</span>
121− </Link>
116+ <Hint label="Show pull requests into every branch">
117+ <Link
118+ to={withFilter(list, current, "base", null)}
119+ className="inline-flex h-8 items-center gap-1.5 rounded-md border border-accent/40 bg-accent/5 px-2.5 text-sm"
120+ >
121+ Into <span className="font-mono">{loaderData.base}</span>
122+ <span className="sr-only">: show pull requests into every branch</span>
123+ </Link>
124+ </Hint>
122125 )}
123126 {filtered && (
124127 <Link to={state === "closed" ? `${list}?state=closed` : list} className="text-xs text-muted hover:text-fg">
+10−8
55 import type { Route } from "./+types/security-settings";
66 import { page } from "../../lib/meta";
77 import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite";
8+import { Hint } from "../../components/ui/hint";
89 import { Switch } from "../../components/ui/switch";
910 import { securitySuite } from "../../lib/services.server";
1011 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
122123 </label>
123124 </fieldset>
124125 <div className="flex flex-wrap items-center gap-3">
125− <button
126− type="submit"
127− disabled={disabled || fetcher.state !== "idle"}
128− title={whyNot(can, "manage_settings")}
129− className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
130− >
131− {fetcher.state !== "idle" ? "Saving…" : "Save"}
132− </button>
126+ <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
127+ <button
128+ type="submit"
129+ disabled={disabled || fetcher.state !== "idle"}
130+ className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"
131+ >
132+ {fetcher.state !== "idle" ? "Saving…" : "Save"}
133+ </button>
134+ </Hint>
133135 <Link to={`${base}/settings/branches`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
134136 Require the checks in branch protection
135137 </Link>
+9−7
1313 type UpgradeFix,
1414 VulnerabilityList,
1515 } from "../../components/security";
16+import { Hint } from "../../components/ui/hint";
1617 import { Switch } from "../../components/ui/switch";
1718 import { security, work } from "../../lib/services.server";
1819 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
205206 </span>
206207 {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
207208 </span>
208− <Switch
209− className="mt-0.5"
210− checked={upkeepOn}
211− disabled={upkeep.state !== "idle" || !can.manage_settings}
212− title={whyNot(can, "manage_settings")}
213− onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })}
214− />
209+ <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
210+ <Switch
211+ className="mt-0.5"
212+ checked={upkeepOn}
213+ disabled={upkeep.state !== "idle" || !can.manage_settings}
214+ onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post", action })}
215+ />
216+ </Hint>
215217 </label>
216218 </div>
217219 </section>
+4−3
77 import { page } from "../../lib/meta";
88 import { RunCard, formatCost, useLiveRefresh } from "../../components/agents";
99 import { Avatar, TimeAgo } from "../../components/ui";
10+import { Hint } from "../../components/ui/hint";
1011 import { agents } from "../../lib/services.server";
1112 import { getViewer, unwrap } from "../../lib/session.server";
1213 import { accessTo } from "../../lib/access.server";
5758 <li className="flex gap-2.5 px-4 py-2 text-xs">
5859 <Wrench size={13} className="mt-0.5 shrink-0 text-faint" />
5960 <span className="shrink-0 font-medium text-muted">{entry.tool}</span>
60− <code className="min-w-0 grow truncate font-mono text-fg/85" title={entry.text}>
61− {entry.text}
62− </code>
61+ <Hint label={entry.text}>
62+ <code className="min-w-0 grow truncate font-mono text-fg/85">{entry.text}</code>
63+ </Hint>
6364 </li>
6465 );
6566 }
+8−4
77 import { page } from "../../lib/meta";
88 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
99 import { Avatar, EmptyState, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
10+import { Hint } from "../../components/ui/hint";
1011 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1112 import { projects } from "../../lib/services.server";
1213 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
5354 <span className="text-xs text-faint">no variable</span>
5455 )}
5556 {link.source === "file" && (
56− <span className="inline-flex items-center gap-1 text-xs text-faint" title="Declared in .g1t/project.yml">
57− <FileCode2 size={12} />
58− project.yml
59− </span>
57+ <Hint label="Declared in .g1t/project.yml">
58+ <span className="inline-flex items-center gap-1 text-xs text-faint">
59+ <FileCode2 size={12} />
60+ <span className="sr-only">Declared in .g1t/</span>
61+ project.yml
62+ </span>
63+ </Hint>
6064 )}
6165 {removable && link.source === "ui" && (
6266 <Form method="post" className="ml-auto">
+7−9
2020 AlertDialogTrigger,
2121 } from "../../components/ui/alert-dialog";
2222 import { CheckboxOption } from "../../components/ui/checkbox";
23+import { Hint } from "../../components/ui/hint";
2324 import { deployments } from "../../lib/services.server";
2425 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2526 import { requireCapability, requireInsider } from "../../lib/access.server";
238239 <Form method="post">
239240 <input type="hidden" name="intent" value="refresh" />
240241 <input type="hidden" name="id" value={domain.id} />
241− <SubmitButton
242− variant="quiet"
243− match={{ intent: "refresh", id: domain.id }}
244− pending="Checking…"
245− title="Ask Cloudflare to check the records again now"
246− >
247− <RotateCw size={14} />
248− Check now
249− </SubmitButton>
242+ <Hint label="Ask Cloudflare to check the records again now">
243+ <SubmitButton variant="quiet" match={{ intent: "refresh", id: domain.id }} pending="Checking…">
244+ <RotateCw size={14} />
245+ Check now
246+ </SubmitButton>
247+ </Hint>
250248 </Form>
251249 )}
252250 <RemoveDomain domain={domain} />
+6−3
33
44 import type { Route } from "./+types/tags";
55 import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
6+import { Hint } from "../../components/ui/hint";
67 import { page } from "../../lib/meta";
78 import { repos } from "../../lib/services.server";
89 import { getViewer, unwrap } from "../../lib/session.server";
4445 <Avatar name={tag.commit.author.name} size={13} />
4546 <span className="shrink-0">{tag.commit.author.name}</span>
4647 <span className="text-faint">·</span>
47− <Link to={`${base}/commit/${tag.commit.hash}`} className="min-w-0 truncate hover:text-fg" title={tag.commit.message}>
48− {tag.commit.message.split("\n")[0]}
49− </Link>
48+ <Hint label={tag.commit.message}>
49+ <Link to={`${base}/commit/${tag.commit.hash}`} className="min-w-0 truncate hover:text-fg">
50+ {tag.commit.message.split("\n")[0]}
51+ </Link>
52+ </Hint>
5053 <span className="shrink-0 text-faint">
5154 · <TimeAgo at={tag.commit.authoredAt} />
5255 </span>
+6−3
33 import type { Route } from "./+types/github";
44 import { page } from "../../lib/meta";
55 import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
6+import { Hint } from "../../components/ui/hint";
67 import { assertSameOrigin, requireUser } from "../../lib/session.server";
78 import { GithubMark } from "../../components/github";
89 import { githubSignIn } from "../../lib/github.server";
5455 )}
5556 <Form method="post">
5657 <input type="hidden" name="intent" value="unlink-github" />
57− <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
58− Unlink
59− </SubmitButton>
58+ <Hint label={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."} disabled={!github.hasPassword}>
59+ <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword}>
60+ Unlink
61+ </SubmitButton>
62+ </Hint>
6063 </Form>
6164 </div>
6265 </div>
+15−12
99 import { ConfirmDialog } from "../../components/repo-lifecycle";
1010 import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
1111 import { Badge } from "../../components/ui/badge";
12+import { Hint } from "../../components/ui/hint";
1213 import { page } from "../../lib/meta";
1314 import { ECOSYSTEM_LABEL, formatBytes, installCommands, shortDigest } from "../../lib/packages";
1415 import { packages } from "../../lib/services.server";
221222 <span className="text-xs text-faint">Untagged</span>
222223 )}
223224 {!npm && (
224− <code className="font-mono text-xs text-muted" title={version.digest}>
225− {shortDigest(version.digest)}
226− </code>
225+ <Hint label={version.digest}>
226+ <code className="font-mono text-xs text-muted">{shortDigest(version.digest)}</code>
227+ </Hint>
227228 )}
228229 {version.deprecated && (
229− <Badge tone="neutral" title={version.deprecated}>
230− Deprecated
231− </Badge>
230+ <Hint label={version.deprecated}>
231+ <Badge tone="neutral">Deprecated</Badge>
232+ </Hint>
232233 )}
233234 {version.symbols && (
234− <Badge tone="neutral" title="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server.">
235− Symbols
236− </Badge>
235+ <Hint label="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server.">
236+ <Badge tone="neutral">Symbols</Badge>
237+ </Hint>
237238 )}
238239 </div>
239240 {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>}
246247 )}
247248 {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>}
248249 {attached.length > 0 && (
249− <span title={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}>
250− {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")})
251− </span>
250+ <Hint label={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}>
251+ <span>
252+ {attached.length} attached ({attached.map((a) => artifactWord(a)).join(", ")})
253+ </span>
254+ </Hint>
252255 )}
253256 <span>
254257 {version.published_by ? `${version.published_by} · ` : ""}
+8−4
77 import { PackageIcon } from "../../components/package-icon";
88 import { CopyLine, EmptyState, TimeAgo } from "../../components/ui";
99 import { Badge } from "../../components/ui/badge";
10+import { Hint } from "../../components/ui/hint";
1011 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
1112 import { cn } from "../../lib/cn";
1213 import { page } from "../../lib/meta";
224225 <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex">
225226 {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>}
226227 <span className="w-16 text-right">{formatBytes(pkg.size)}</span>
227− <span className="inline-flex w-14 items-center justify-end gap-1" title={`${pkg.downloads.toLocaleString("en-US")} downloads`}>
228− <Download size={12} />
229− {shortCount(pkg.downloads)}
230− </span>
228+ <Hint label={`${pkg.downloads.toLocaleString("en-US")} downloads`}>
229+ <span className="inline-flex w-14 items-center justify-end gap-1">
230+ <Download size={12} />
231+ {shortCount(pkg.downloads)}
232+ <span className="sr-only"> downloads</span>
233+ </span>
234+ </Hint>
231235 </span>
232236 </Link>
233237 </li>