mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
## Summary
Replaces ~285 lines of hand-rolled Tooltip, HoverCard, and Toast code in
`fabro-web` with battle-tested primitives — gaining real keyboard
accessibility, Radix collision detection, and Sonner's toast lifecycle —
while keeping all 13+ call sites unchanged.
### Plan Summary
- **Tooltip + HoverCard → Radix wrappers**: `@radix-ui/react-tooltip`
and `@radix-ui/react-hover-card` replace the DIY `useHoverAnchor` hook.
A `TooltipProvider` is mounted in `app-shell.tsx` (200ms delay, 300ms
skip-delay for grouped sidebar hovers). `<Tooltip>` self-wraps in a
local provider when rendered outside the shell (tests, isolated mounts).
- **Toast system → Sonner**: `toast.tsx` shrinks to a ~30-line shim
preserving the `{ push, dismiss, clear }` API. `ToastProvider` becomes a
no-op pass-through in DOM contexts; in non-DOM test environments it
renders an `aria-live` fallback backed by `useSonner` so test assertions
still work. The `action` field is dropped (was test-only).
`toast.test.tsx` is rewritten against observable rendered text.
- **CSS-only tooltips → `<Tooltip>`**: Two inline `group-hover/*` blocks
in `settings-models.tsx` are swapped for the new wrapper, gaining
keyboard focus + Esc dismiss + collision avoidance.
- **SVG-anchored hovers → `FloatingTooltip`**: A new
`app/components/floating-tooltip.tsx` helper portals to `document.body`
and computes collision-avoiding `top`/`bottom` placement from a raw
`DOMRect` (no wrappable trigger). It absorbs `hover-card-style.ts`
(deleted) and is used by `run-overview.tsx` and `event-debug.tsx`.
### What changed and why
**`FloatingTooltip`** handles the two SVG/Graphviz hover sites where
there is no React trigger element to wrap — only a `DOMRect` measured
from DOM events. It uses `useLayoutEffect` + `ResizeObserver` to measure
its own rendered size before applying final position, so it never clips
at viewport edges. This is the one place a `useLayoutEffect` is
intentional and documented.
**`Tooltip` provider fallback**: Radix throws if `<Tooltip>` renders
without an ancestor `TooltipProvider`. Rather than requiring every test
to mount the shell, the component detects provider presence via context
and injects a local one when needed.
**Toast shim backward-compat**: `ToastProvider` previously accepted
`autoDismissMs` as a prop; that prop is silently dropped. The `action`
field on `ToastInput` is removed (only one test referenced it —
`run-detail.test.ts` is updated accordingly). All other consumers
compile without changes.
**CSP fix** (bundled): `img-src` gains
`https://avatars.githubusercontent.com` to allow GitHub avatar images,
with the corresponding integration-test assertion updated.
### Fabro Details
<details>
<summary>Ran 8 stages in 54m 1s for $32.86</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 23s | – | 0 |
| implement | 21m 38s | $22.48 | 0 |
| simplify_opus | 13m 51s | $7.00 | 0 |
| simplify_gpt | 3m 54s | $3.38 | 0 |
| verify | 9m 35s | – | 0 |
| **Total** | **54m 1s** | **$32.86** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
269 lines
8.7 KiB
TypeScript
269 lines
8.7 KiB
TypeScript
// Shared UI primitives. The install wizard set the visual baseline; this file
|
|
// exposes the primary button, secondary button, input, error message, and
|
|
// copy button so the auth and in-app surfaces can match.
|
|
|
|
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
|
|
import {
|
|
ClipboardDocumentCheckIcon,
|
|
ClipboardIcon,
|
|
} from "@heroicons/react/16/solid";
|
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
type ComponentProps,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
export const INPUT_CLASS =
|
|
"block w-full rounded-lg bg-panel-alt px-3.5 py-2.5 text-base text-fg outline-1 -outline-offset-1 outline-white/10 placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 sm:text-sm";
|
|
|
|
export const PRIMARY_BUTTON_CLASS =
|
|
"inline-flex items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-teal-500";
|
|
|
|
export const SECONDARY_BUTTON_CLASS =
|
|
"inline-flex items-center justify-center gap-2 rounded-lg bg-transparent px-3.5 py-2 text-sm font-medium text-fg-2 outline-1 -outline-offset-1 outline-white/10 hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60";
|
|
|
|
const DANGER_BUTTON_CLASS =
|
|
"inline-flex items-center justify-center gap-2 rounded-lg bg-coral px-4 py-2 text-sm font-medium text-on-primary transition-colors hover:bg-coral/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-coral disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral";
|
|
|
|
export const COMPACT_SECONDARY_BUTTON_CLASS =
|
|
"rounded-md border border-line bg-overlay px-2.5 py-1 text-xs text-fg-2 transition-colors hover:bg-overlay-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-50";
|
|
|
|
export function ErrorMessage({ message }: { message: string }) {
|
|
return (
|
|
<p
|
|
role="alert"
|
|
className="rounded-md bg-coral/10 px-3 py-2 text-sm/6 text-fg-2 outline-1 -outline-offset-1 outline-coral/40"
|
|
>
|
|
{message}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
export function CopyButton({
|
|
value,
|
|
label,
|
|
className = "",
|
|
}: {
|
|
value: string;
|
|
label: string;
|
|
className?: string;
|
|
}) {
|
|
const [copied, setCopied] = useState(false);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
} catch {
|
|
// Clipboard may be blocked; leave state unchanged.
|
|
}
|
|
}}
|
|
className={`inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted outline-teal-500 hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 ${className}`}
|
|
aria-label={copied ? "Copied" : label}
|
|
title={copied ? "Copied" : label}
|
|
>
|
|
{copied ? (
|
|
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
|
|
) : (
|
|
<ClipboardIcon className="size-4" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function ConfirmDialog({
|
|
open,
|
|
title,
|
|
description,
|
|
confirmLabel,
|
|
pendingLabel,
|
|
cancelLabel = "Cancel",
|
|
pending = false,
|
|
onConfirm,
|
|
onCancel,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
description: ReactNode;
|
|
confirmLabel: string;
|
|
pendingLabel?: string;
|
|
cancelLabel?: string;
|
|
pending?: boolean;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={() => {
|
|
if (!pending) onCancel();
|
|
}}
|
|
className="relative z-50"
|
|
>
|
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" aria-hidden="true" />
|
|
<div className="fixed inset-0 flex items-start justify-center pt-[20vh] px-4">
|
|
<DialogPanel className="w-full max-w-md rounded-lg border border-line-strong bg-panel shadow-2xl shadow-black/40">
|
|
<div className="px-5 py-4">
|
|
<DialogTitle className="text-sm font-semibold text-fg">{title}</DialogTitle>
|
|
<div className="mt-2 text-sm text-fg-3">{description}</div>
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={pending}
|
|
className={SECONDARY_BUTTON_CLASS}
|
|
>
|
|
{cancelLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
disabled={pending}
|
|
className={DANGER_BUTTON_CLASS}
|
|
>
|
|
{pending ? (pendingLabel ?? `${confirmLabel}…`) : confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</DialogPanel>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const TOOLTIP_DELAY_DURATION = 200;
|
|
const TOOLTIP_SKIP_DELAY_DURATION = 300;
|
|
|
|
const HasTooltipProviderContext = createContext(false);
|
|
|
|
type TooltipProviderProps = ComponentProps<typeof TooltipPrimitive.Provider>;
|
|
|
|
function canUseOverlayDom() {
|
|
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
}
|
|
|
|
export function TooltipProvider({
|
|
delayDuration = TOOLTIP_DELAY_DURATION,
|
|
skipDelayDuration = TOOLTIP_SKIP_DELAY_DURATION,
|
|
children,
|
|
...props
|
|
}: TooltipProviderProps) {
|
|
if (!canUseOverlayDom()) {
|
|
return <>{children}</>;
|
|
}
|
|
return (
|
|
<HasTooltipProviderContext.Provider value={true}>
|
|
<TooltipPrimitive.Provider
|
|
delayDuration={delayDuration}
|
|
skipDelayDuration={skipDelayDuration}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</TooltipPrimitive.Provider>
|
|
</HasTooltipProviderContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function Tooltip({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
// Tests and isolated mounts may render <Tooltip> without an ancestor
|
|
// <TooltipProvider>; Radix throws in that case, so supply a local provider.
|
|
const hasProvider = useContext(HasTooltipProviderContext);
|
|
|
|
if (!canUseOverlayDom()) {
|
|
return <span className="inline-flex">{children}</span>;
|
|
}
|
|
|
|
const root = (
|
|
<TooltipPrimitive.Root>
|
|
<TooltipPrimitive.Trigger asChild>
|
|
<span className="inline-flex">{children}</span>
|
|
</TooltipPrimitive.Trigger>
|
|
<TooltipPrimitive.Portal>
|
|
<TooltipPrimitive.Content
|
|
side="top"
|
|
align="center"
|
|
sideOffset={6}
|
|
collisionPadding={12}
|
|
className="pointer-events-none z-50 max-w-[min(32rem,calc(100vw-1.5rem))] rounded-md bg-panel px-2 py-1 text-xs text-fg-2 shadow-lg outline-1 -outline-offset-1 outline-line-strong"
|
|
>
|
|
{label}
|
|
</TooltipPrimitive.Content>
|
|
</TooltipPrimitive.Portal>
|
|
</TooltipPrimitive.Root>
|
|
);
|
|
|
|
return hasProvider ? root : <TooltipProvider>{root}</TooltipProvider>;
|
|
}
|
|
|
|
/**
|
|
* Hover-triggered rich popover. Unlike `Tooltip`, it anchors below the trigger,
|
|
* stays within the viewport, and allows multi-line wrapping content. The
|
|
* `content` node is mounted only while open, so consumers may fetch lazily.
|
|
*/
|
|
export function HoverCard({
|
|
content,
|
|
children,
|
|
className = "inline-flex",
|
|
openDelay = 0,
|
|
}: {
|
|
content: ReactNode;
|
|
children: ReactNode;
|
|
className?: string;
|
|
openDelay?: number;
|
|
}) {
|
|
if (!canUseOverlayDom()) {
|
|
return <span className={className}>{children}</span>;
|
|
}
|
|
return (
|
|
<HoverCardPrimitive.Root openDelay={openDelay} closeDelay={0}>
|
|
<HoverCardPrimitive.Trigger asChild>
|
|
<span className={className}>{children}</span>
|
|
</HoverCardPrimitive.Trigger>
|
|
<HoverCardPrimitive.Portal>
|
|
<HoverCardPrimitive.Content
|
|
side="bottom"
|
|
align="start"
|
|
sideOffset={6}
|
|
collisionPadding={12}
|
|
className="pointer-events-none z-50 max-w-[18rem] rounded-lg bg-panel p-3 text-xs text-fg-2 shadow-xl outline-1 -outline-offset-1 outline-line-strong"
|
|
>
|
|
{content}
|
|
</HoverCardPrimitive.Content>
|
|
</HoverCardPrimitive.Portal>
|
|
</HoverCardPrimitive.Root>
|
|
);
|
|
}
|
|
|
|
export function PopoverHeader({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PopoverRows({ children }: { children: ReactNode }) {
|
|
return <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">{children}</dl>;
|
|
}
|
|
|
|
export function PopoverRow({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<>
|
|
<dt className="text-fg-3">{label}</dt>
|
|
<dd className="min-w-0 text-fg">{children}</dd>
|
|
</>
|
|
);
|
|
}
|