diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 5900ca0c44..843425f8ae 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -8,7 +8,8 @@ * the v1 folder taxonomy. Run by `typecheck:v1`. * --code Imports: nothing outside src/v1 and the folder import contract * (which v1 folder may import which). No PropTypes, no antd `ant-` - * class names. Also scans v0 (the rest of src/) for imports that + * class names, and one notification entry point: toast + * libraries only in components/ui/feedback/. Also scans v0 (the rest of src/) for imports that * reach into v1 other than through `@v1`. Every file is read * except assets, data and docs; one of unknown type is read as * code, since Vite can load any file (`?worker`). Run by @@ -500,6 +501,56 @@ const ANT_CLASS = /(?`, `R["Toast"]`, aliases, + * `.then`). So the rule is structural, not a hunt for spellings: outside the + * feedback folder radix-ui may be used only as a static named import that does + * not name Toast, and every other reference to it is reported. (antd's + * notification and message are already rejected by Biome's noRestrictedImports, + * which lint:v1 runs first.) + */ +const RADIX = /^radix-ui(\/|$)/; +const isNamedRadixImport = (node) => + node.type === "ImportDeclaration" && + node.source.value === "radix-ui" && + node.specifiers.length > 0 && + node.specifiers.every( + (spec) => + spec.type === "ImportSpecifier" && + (spec.imported.name ?? spec.imported.value) !== "Toast", + ); + +function* notifyEntryPoints(ast, specs) { + const allowed = new Set( + ast.program.body.filter(isNamedRadixImport).map((node) => node.start), + ); + for (const arg of specs) { + const spec = arg.spec ?? ""; + if ( + TOAST_PACKAGES.test(spec) || + (RADIX.test(spec) && !allowed.has(arg.index)) + ) { + yield { index: arg.index, msg: NOTIFY_MSG }; + } + } +} + /* PropTypes and antd `ant-` class names, as { index, msg }. */ function* bannedUses(ast, src) { for (const node of nodes(ast.program)) { @@ -820,12 +871,18 @@ for (const rel of walk(ROOT)) { } const isTestFile = TEST_FILE.test(name); - for (const arg of importSpecs(parsed.ast)) { + const specs = importSpecs(parsed.ast); + for (const arg of specs) { const problem = v1ImportProblem(folder, rel, arg, isTestFile); if (problem) { report(rel, arg.line, problem); } } + if (!rel.startsWith(NOTIFY_HOME)) { + for (const { index, msg } of notifyEntryPoints(parsed.ast, specs)) { + report(rel, lineAt(src, index), msg); + } + } for (const { index, msg } of bannedUses(parsed.ast, src)) { report(rel, lineAt(src, index), msg); } diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 39c24d786b..af0dcb2ddf 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -64,6 +64,27 @@ is convention. icon's `size` prop. Put icons in a `ui:flex`/`ui:inline-flex` parent that is not one of those tags, or size the parent's font. +## Feedback + +- One notification entry point: `notify()` or `toast.success/error/warning/info` + from `@v1/lib/notify`, which `services/` can reach too. The `Toaster` in + `components/ui/feedback/` implements it; mount it once inside the v1 root. + With no `Toaster` mounted, `notify()` only logs to the console (with the + request id); nothing is shown or queued. `lint:v1` rejects sonner, + react-hot-toast, react-toastify and notistack outside + `components/ui/feedback/`, and there allows radix-ui only as a static named + import that does not name `Toast` (Biome already rejects antd in v1). +- Errors are announced assertively, everything else politely. Toasts stack + bottom-right (at most three), pause while hovered or focused, and F8 moves + focus to them; pass `persistent` to keep one until it is dismissed. At the + limit, timed toasts go before persistent ones; if all three are persistent + the oldest goes. Reusing an `id` replaces a toast with a fresh one (new + timer, announced again). A `requestId` is shown on the toast. +- Loading is a skeleton (`Skeleton` + `SkeletonText`, `SkeletonBlock`, + `SkeletonAvatar`, `SkeletonTableRows`), never a spinner inside a card. + `Spinner` sits only in buttons and running text. Errors on a screen are an + `InlineAlert` above the content, with `onRetry`. + ## Folders and what each may import Every folder may import from itself. Test files (`*.test.ts(x)`, @@ -91,7 +112,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | +| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, then the styling policy (S1 to S8 above) | | `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) | | `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/` and `test-fixtures/v1-harness/`. The harness is documented in `test/README.md` | diff --git a/frontend/src/v1/components/ui/feedback/InlineAlert.tsx b/frontend/src/v1/components/ui/feedback/InlineAlert.tsx new file mode 100644 index 0000000000..7cddc423aa --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/InlineAlert.tsx @@ -0,0 +1,67 @@ +import { cn } from "@v1/lib/cn"; +import type { NotifyKind } from "@v1/lib/notify"; +import type { ComponentProps, JSX, ReactNode } from "react"; +import { KIND_BORDER, KIND_ICON, KIND_TEXT } from "./kinds"; + +export type InlineAlertProps = Omit, "title" | "role"> & + Readonly<{ + kind?: NotifyKind; + title: ReactNode; + children?: ReactNode; + /** Shows a Retry button; the error state's one action. */ + onRetry?: () => void; + retryLabel?: string; + }>; + +/** + * A message above the content it concerns: dev spec section 8, "inline, above + * the content it concerns, with a retry". Errors are announced at once + * (`role="alert"`); other kinds wait for a pause (`role="status"`). + */ +export function InlineAlert({ + kind = "error", + title, + children, + onRetry, + retryLabel = "Retry", + className, + ...props +}: InlineAlertProps): JSX.Element { + const Icon = KIND_ICON[kind]; + return ( +
+ +
+

{title}

+ {children ? ( +
+ {children} +
+ ) : null} +
+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/v1/components/ui/feedback/ProgressBar.tsx b/frontend/src/v1/components/ui/feedback/ProgressBar.tsx new file mode 100644 index 0000000000..cfd4b1dd86 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/ProgressBar.tsx @@ -0,0 +1,54 @@ +import { cn } from "@v1/lib/cn"; +import { Progress as RadixProgress } from "radix-ui"; +import type { JSX } from "react"; + +type Named = + | Readonly<{ "aria-label": string; "aria-labelledby"?: never }> + | Readonly<{ "aria-labelledby": string; "aria-label"?: never }>; + +export type ProgressBarProps = Named & + Readonly<{ + /** 0 to `max`; leave it out (or null) while the total is unknown. */ + value?: number | null; + max?: number; + className?: string; + }>; + +/** A 6px rounded track with a primary fill (frame 757:310). The name is required. */ +export function ProgressBar({ + value = null, + max = 100, + className, + ...named +}: ProgressBarProps): JSX.Element { + const indeterminate = value === null; + // Radix rejects a max that is not a positive number, and a value that is not + // finite; an empty batch (0 of 0, or `done / total * 100` with total 0) shows + // as an empty bar rather than NaN. + const validMax = Number.isFinite(max) && max > 0; + const total = validMax ? max : 100; + const clamped = + indeterminate || !validMax || !Number.isFinite(value) + ? 0 + : Math.min(Math.max(value, 0), total); + return ( + + + + ); +} diff --git a/frontend/src/v1/components/ui/feedback/Skeleton.tsx b/frontend/src/v1/components/ui/feedback/Skeleton.tsx new file mode 100644 index 0000000000..32d58c4e70 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/Skeleton.tsx @@ -0,0 +1,148 @@ +import { cn } from "@v1/lib/cn"; +import type { JSX, ReactNode } from "react"; + +/* + * Dev spec section 8 and frame 676:9632: muted bars, heights 12 then 10, three + * widths only (100%, 78%, 46%), never a spinner inside a card. Two values are + * not tokens, so the closest ones stand in: radius 4 (`rounded-sm`, 6) and the + * bar colour #e4e4e7 (`muted`). + */ + +const BAR = "ui:block ui:rounded-sm ui:bg-muted ui:motion-safe:animate-pulse"; +const WIDTHS = ["ui:w-full", "ui:w-[78%]", "ui:w-[46%]"] as const; + +export type SkeletonProps = Readonly<{ + /** What is loading, read by screen readers ("Loading projects"). */ + label?: string; + className?: string; + children?: ReactNode; +}>; + +/** + * The container every skeleton sits in: one status region whose only text is + * the label. No `aria-busy`: on a live region it holds announcements until it + * turns false, and a skeleton unmounts instead, so the label would never be read. + */ +export function Skeleton({ + label = "Loading", + className, + children, +}: SkeletonProps): JSX.Element { + return ( + // biome-ignore lint/a11y/useSemanticElements: may hold only phrasing content, and skeletons hold blocks. +
+ {label} +
+ {children} +
+
+ ); +} + +export type SkeletonTextProps = Readonly<{ + lines?: number; + className?: string; +}>; + +/** Text lines: the first 12 tall, the rest 10, widths cycling 100%, 78%, 46%. */ +export function SkeletonText({ + lines = 3, + className, +}: SkeletonTextProps): JSX.Element { + return ( +
+ {Array.from({ length: lines }, (_, i) => ( + + ))} +
+ ); +} + +export type SkeletonBlockProps = Readonly<{ className?: string }>; + +/** A block (a card body, a chart, an image); size it with classes. */ +export function SkeletonBlock({ className }: SkeletonBlockProps): JSX.Element { + return ( + + ); +} + +export type SkeletonAvatarProps = Readonly<{ + size?: "sm" | "md"; + className?: string; +}>; + +export function SkeletonAvatar({ + size = "md", + className, +}: SkeletonAvatarProps): JSX.Element { + return ( + + ); +} + +export type SkeletonTableRowsProps = Readonly<{ + rows?: number; + columns?: number; + className?: string; +}>; + +/** Table rows: one 12-tall bar per cell, widths cycling so columns look real. */ +export function SkeletonTableRows({ + rows = 5, + columns = 4, + className, +}: SkeletonTableRowsProps): JSX.Element { + return ( +
+ {Array.from({ length: rows }, (_, r) => ( +
+ {Array.from({ length: columns }, (_, c) => ( + + + + ))} +
+ ))} +
+ ); +} diff --git a/frontend/src/v1/components/ui/feedback/Spinner.tsx b/frontend/src/v1/components/ui/feedback/Spinner.tsx new file mode 100644 index 0000000000..b18ece0c0b --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/Spinner.tsx @@ -0,0 +1,45 @@ +import { cn } from "@v1/lib/cn"; +import { LoaderCircle } from "lucide-react"; +import type { JSX } from "react"; + +export type SpinnerProps = Readonly<{ + /** + * `button` sits beside a button label; `inline` sits in running text. There + * is deliberately no large size: a page or card loads with a skeleton. + */ + size?: "button" | "inline"; + /** Read by screen readers; leave it out when the button text already says so. */ + label?: string; + className?: string; +}>; + +/** A spinner for inside buttons and inline text only (dev spec section 8). */ +export function Spinner({ + size = "inline", + label, + className, +}: SpinnerProps): JSX.Element { + const icon = ( + + ); + if (!label) { + return ( + + {icon} + + ); + } + return ( + + {icon} + {label} + + ); +} diff --git a/frontend/src/v1/components/ui/feedback/StepProgress.tsx b/frontend/src/v1/components/ui/feedback/StepProgress.tsx new file mode 100644 index 0000000000..5a9cc1afa5 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/StepProgress.tsx @@ -0,0 +1,82 @@ +import { cn } from "@v1/lib/cn"; +import { Check } from "lucide-react"; +import type { JSX } from "react"; + +export type StepProgressProps = Readonly<{ + steps: readonly string[]; + /** Index of the step in progress; equal to `steps.length` when all are done. */ + current: number; + "aria-label": string; + className?: string; +}>; + +type StepState = "done" | "current" | "upcoming"; + +function stateOf(index: number, current: number): StepState { + if (index < current) { + return "done"; + } + return index === current ? "current" : "upcoming"; +} + +const STATE_TEXT: Record = { + done: "completed", + current: "current", + upcoming: "not started", +}; + +/** Ordered steps with done, current and upcoming states. */ +export function StepProgress({ + steps, + current, + className, + ...named +}: StepProgressProps): JSX.Element { + return ( +
    + {steps.map((label, index) => { + const state = stateOf(index, current); + return ( +
  1. + + {state === "done" ? : index + 1} + + + {label} + {`, ${STATE_TEXT[state]}`} + +
  2. + ); + })} +
+ ); +} diff --git a/frontend/src/v1/components/ui/feedback/Toaster.test.tsx b/frontend/src/v1/components/ui/feedback/Toaster.test.tsx new file mode 100644 index 0000000000..63474b2956 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/Toaster.test.tsx @@ -0,0 +1,377 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + dismissNotification, + NOTIFY_KINDS, + notify, + toast, +} from "@v1/lib/notify"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { MAX_TOASTS, Toaster } from "./Toaster"; + +let container: HTMLElement; + +beforeEach(() => { + container = document.createElement("div"); + container.className = "v1"; + document.body.appendChild(container); +}); + +afterEach(() => { + vi.useRealTimers(); + container.remove(); +}); + +const mount = (duration?: number) => + render(); + +const toasts = () => container.querySelectorAll("[data-slot=toast]"); + +it("shows a toast with its title, description and kind in the viewport", () => { + mount(); + act(() => { + notify({ + kind: "success", + title: "Export started", + description: "JSON soon", + }); + }); + const [item] = toasts(); + expect(item?.textContent).toContain("Export started"); + expect(item?.textContent).toContain("JSON soon"); + expect(item?.getAttribute("data-kind")).toBe("success"); + expect(container.querySelector("ol")).not.toBeNull(); +}); + +const announcer = (text: string) => + [...document.querySelectorAll("[role=status][aria-live]")].find((el) => + el.textContent?.includes(text), + ); + +it("announces errors assertively and every other kind politely", async () => { + mount(); + for (const kind of NOTIFY_KINDS) { + let id: string | undefined; + act(() => { + id = notify({ kind, title: `Kind ${kind}`, persistent: true }); + }); + // Radix fills its announcers a frame after the toast mounts. + await waitFor(() => { + expect(announcer(`Kind ${kind}`)?.getAttribute("aria-live")).toBe( + kind === "error" ? "assertive" : "polite", + ); + }); + act(() => { + toast.dismiss(id as string); + }); + } +}); + +it("shows the request id where it can be copied", () => { + mount(); + act(() => { + toast.error("Could not save", { requestId: "req-42" }); + }); + const id = screen.getByText("Request ID: req-42"); + expect(id.className).toContain("ui:select-text"); +}); + +it("closes after the timeout unless persistent", () => { + vi.useFakeTimers(); + mount(1000); + act(() => { + toast.success("Gone soon"); + toast.warning("Stays", { persistent: true }); + }); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.queryByText("Gone soon")).toBeNull(); + expect(screen.getByText("Stays")).not.toBeNull(); +}); + +it("pauses the timeout while the pointer is over the toasts", () => { + vi.useFakeTimers(); + mount(1000); + act(() => { + toast.success("Read me"); + }); + // Radix listens on the region around the list; pointerleave does not bubble. + const region = screen.getByRole("region", { name: /Notifications/ }); + fireEvent.pointerMove(region); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(screen.getByText("Read me")).not.toBeNull(); + fireEvent.pointerLeave(region); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.queryByText("Read me")).toBeNull(); +}); + +it("runs the action and closes the toast", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + mount(); + act(() => { + toast.error("gpt-5.6 could not connect", { + action: { label: "Edit profile", onSelect }, + }); + }); + await user.click(screen.getByRole("button", { name: "Edit profile" })); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect.mock.calls[0]).toEqual([]); + expect(screen.queryByText("gpt-5.6 could not connect")).toBeNull(); +}); + +it("lets the keyboard reach an action toast through the F8 hotkey", async () => { + const user = userEvent.setup(); + mount(); + act(() => { + toast.info("Update ready", { + action: { label: "Reload", onSelect: () => undefined }, + persistent: true, + }); + }); + fireEvent.keyDown(document, { key: "F8", code: "F8" }); + expect(document.activeElement).toBe(container.querySelector("ol")); + await user.tab(); + expect(document.activeElement).toBe( + container.querySelector("[data-slot=toast]"), + ); + await user.tab(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Reload" }), + ); +}); + +it("dismisses from the close button, by id, and with Escape", async () => { + const user = userEvent.setup(); + mount(); + let id: string | undefined; + act(() => { + toast.info("One", { persistent: true }); + id = toast.info("Two", { persistent: true }); + toast.info("Three", { persistent: true }); + }); + await user.click( + screen.getAllByRole("button", { + name: "Dismiss notification", + })[0] as HTMLElement, + ); + expect(screen.queryByText("One")).toBeNull(); + act(() => { + dismissNotification(id as string); + }); + expect(screen.queryByText("Two")).toBeNull(); + (container.querySelector("[data-slot=toast]") as HTMLElement).focus(); + await user.keyboard("{Escape}"); + expect(screen.queryByText("Three")).toBeNull(); +}); + +it("keeps at most MAX_TOASTS, dropping the oldest, and replaces by id", () => { + mount(); + act(() => { + for (let i = 1; i <= MAX_TOASTS + 1; i++) { + toast.info(`Toast ${i}`, { persistent: true }); + } + }); + expect(toasts()).toHaveLength(MAX_TOASTS); + expect(screen.queryByText("Toast 1")).toBeNull(); + act(() => { + notify({ kind: "info", title: "Uploading 1 of 2", id: "upload" }); + }); + const open = container.querySelector("[data-kind=info]:last-child"); + expect(open?.textContent).toContain("Uploading 1 of 2"); + act(() => { + notify({ kind: "success", title: "Uploaded 2 of 2", id: "upload" }); + }); + expect(screen.queryByText("Uploading 1 of 2")).toBeNull(); + const replaced = screen + .getByText("Uploaded 2 of 2") + .closest("[data-slot=toast]"); + expect(replaced?.getAttribute("data-kind")).toBe("success"); + expect(toasts()).toHaveLength(MAX_TOASTS); +}); + +it("drops timed toasts before persistent ones at the limit", () => { + mount(); + act(() => { + toast.error("Upload failed", { persistent: true }); + }); + act(() => { + for (let i = 1; i <= MAX_TOASTS; i++) { + toast.success(`Saved ${i}`); + } + }); + expect(toasts()).toHaveLength(MAX_TOASTS); + expect(screen.getByText("Upload failed")).not.toBeNull(); + expect(screen.queryByText("Saved 1")).toBeNull(); + expect(screen.getByText("Saved 3")).not.toBeNull(); +}); + +it("gives a replacement its own timer and persistence", () => { + vi.useFakeTimers(); + mount(1000); + act(() => { + notify({ kind: "info", title: "Uploading", id: "upload" }); + }); + act(() => { + vi.advanceTimersByTime(900); + }); + act(() => { + notify({ + kind: "error", + title: "Upload failed", + id: "upload", + persistent: true, + }); + }); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(screen.getByText("Upload failed")).not.toBeNull(); + act(() => { + notify({ kind: "success", title: "Uploaded", id: "upload" }); + }); + act(() => { + vi.advanceTimersByTime(900); + }); + // A fresh 1000ms timer, not the remains of an earlier one. + expect(screen.getByText("Uploaded")).not.toBeNull(); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByText("Uploaded")).toBeNull(); +}); + +it("announces a replacement", async () => { + mount(); + act(() => { + notify({ kind: "info", title: "Uploading", id: "upload" }); + }); + await waitFor(() => expect(announcer("Uploading")).toBeDefined()); + act(() => { + notify({ kind: "error", title: "Upload failed", id: "upload" }); + }); + await waitFor(() => { + expect(announcer("Upload failed")?.getAttribute("aria-live")).toBe( + "assertive", + ); + }); +}); + +it("keeps a replacement that the toast's own action sends", async () => { + const user = userEvent.setup(); + mount(); + act(() => { + toast.error("Upload failed", { + id: "upload", + persistent: true, + action: { + label: "Retry", + onSelect: () => toast.info("Retrying", { id: "upload" }), + }, + }); + }); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(screen.queryByText("Upload failed")).toBeNull(); + expect(screen.getByText("Retrying")).not.toBeNull(); +}); + +it("moves focus to the viewport when a focused toast is removed", () => { + mount(); + let id: string | undefined; + act(() => { + id = toast.info("Focused", { persistent: true }); + }); + (container.querySelector("[data-slot=toast]") as HTMLElement).focus(); + act(() => { + dismissNotification(id as string); + }); + expect(document.activeElement).toBe(container.querySelector("ol")); +}); + +it("leaves focus alone when a later timeout empties the list", () => { + vi.useFakeTimers(); + mount(1000); + const input = document.createElement("input"); + document.body.appendChild(input); + let id: string | undefined; + act(() => { + id = toast.info("Focused", { persistent: true }); + toast.info("Timed"); + }); + (container.querySelector("[data-slot=toast]") as HTMLElement).focus(); + act(() => { + dismissNotification(id as string); + }); + input.focus(); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.queryByText("Timed")).toBeNull(); + expect(document.activeElement).toBe(input); + input.remove(); +}); + +it("times out the next toast after the last one closes under the pointer", () => { + vi.useFakeTimers(); + mount(1000); + act(() => { + toast.info("First", { persistent: true }); + }); + fireEvent.pointerMove(screen.getByRole("region", { name: /Notifications/ })); + fireEvent.click(screen.getByRole("button", { name: "Dismiss notification" })); + expect(screen.queryByText("First")).toBeNull(); + act(() => { + toast.info("Second"); + }); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(screen.queryByText("Second")).toBeNull(); +}); + +it("follows a new container", () => { + const other = document.createElement("div"); + document.body.appendChild(other); + const { rerender } = render(); + rerender(); + act(() => { + toast.info("Moved", { persistent: true }); + }); + expect(other.querySelector("[data-slot=toast]")?.textContent).toContain( + "Moved", + ); + expect(container.querySelector("[data-slot=toast]")).toBeNull(); + other.remove(); +}); + +it("stops receiving notifications once unmounted", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { unmount } = mount(); + unmount(); + expect(toast.error("After unmount")).toBeUndefined(); + expect(error).toHaveBeenCalled(); + error.mockRestore(); +}); + +it("renders into the v1 portal container by default", () => { + render(); + act(() => { + toast.success("Portalled"); + }); + const portal = document.getElementById("v1-portal"); + expect(portal?.classList.contains("v1")).toBe(true); + expect(portal?.querySelector("[data-slot=toast]")?.textContent).toContain( + "Portalled", + ); +}); diff --git a/frontend/src/v1/components/ui/feedback/Toaster.tsx b/frontend/src/v1/components/ui/feedback/Toaster.tsx new file mode 100644 index 0000000000..d3f71de228 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/Toaster.tsx @@ -0,0 +1,229 @@ +import { cn } from "@v1/lib/cn"; +import { type NotifyInput, type NotifyKind, setNotifier } from "@v1/lib/notify"; +import { getPortalContainer } from "@v1/lib/portal"; +import { X } from "lucide-react"; +import { Toast as RadixToast } from "radix-ui"; +import { + type JSX, + useCallback, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { KIND_ICON, KIND_TEXT } from "./kinds"; + +/** How long a toast stays when it is not persistent. */ +export const TOAST_DURATION_MS = 6000; + +/** Toasts shown at once; older ones close first. */ +export const MAX_TOASTS = 3; + +/* + * `id` is the caller's handle (replace and dismiss go through it); `rev` is + * one `show()` call. Keying and removing by `rev` means a replacement mounts a + * fresh Radix toast (its own timer, `persistent` and announcement), and an + * action that re-notifies its own id is not removed by its own close. + */ +type Entry = NotifyInput & Readonly<{ id: string; rev: number }>; + +export type ToasterProps = Readonly<{ + /** Overrides the default timeout (tests, or a page that needs longer). */ + duration?: number; + /** Where the viewport renders; the v1 portal container by default. */ + container?: HTMLElement; +}>; + +let sequence = 0; +const nextId = () => { + sequence += 1; + return `toast-${sequence}`; +}; +let revision = 0; +const nextRev = () => { + revision += 1; + return revision; +}; + +/* + * Keeps at most MAX_TOASTS. The newest always stays; older timed toasts go + * first, and a persistent one goes only when every other toast is persistent + * too (the cap still wins, so the stack cannot grow without bound). + */ +function capped(entries: readonly Entry[]): readonly Entry[] { + const kept = [...entries]; + while (kept.length > MAX_TOASTS) { + const older = kept.slice(0, -1); + const timed = older.findIndex((e) => !e.persistent); + kept.splice(timed === -1 ? 0 : timed, 1); + } + return kept; +} + +/** + * The single toast region of v1. Mount it once, inside the v1 root; it + * registers itself as the notifier, so `notify()` and `toast.*` reach it. + * Toasts stack bottom-right, pause while hovered or focused, and F8 moves + * focus to them so an action is reachable by keyboard. + */ +export function Toaster({ + duration = TOAST_DURATION_MS, + container, +}: ToasterProps): JSX.Element { + const [entries, setEntries] = useState([]); + const [target, setTarget] = useState(container ?? null); + // Bumped when the last toast goes, so the provider remounts: Radix leaves its + // hover pause set when the viewport empties under the pointer, and the next + // toast would otherwise never time out. + const [generation, setGeneration] = useState(0); + const current = useRef([]); + const viewportRef = useRef(null); + const refocus = useRef(false); + + // The remount replaces the viewport; keep focus on it rather than . + // biome-ignore lint/correctness/useExhaustiveDependencies: runs per remount. + useLayoutEffect(() => { + if (refocus.current) { + refocus.current = false; + viewportRef.current?.focus(); + } + }, [generation]); + + // Every change goes through here, synchronously, so several calls in one + // tick see each other's result. + const commit = useCallback((next: readonly Entry[]) => { + const prev = current.current; + const kept = new Set(next.map((e) => e.rev)); + // Removing a focused toast would drop focus to ; Radix only moves it + // for closes it runs itself. + const focused = + document.activeElement?.closest("[data-slot=toast]"); + if (focused && !kept.has(Number(focused.dataset.rev))) { + viewportRef.current?.focus(); + // Only a remount replaces the viewport; a stale flag would steal focus + // later, when some other change empties the list. + refocus.current = next.length === 0; + } + current.current = next; + setEntries(next); + if (next.length === 0 && prev.length > 0) { + setGeneration((g) => g + 1); + } + }, []); + + const removeRev = useCallback( + (rev: number) => commit(current.current.filter((e) => e.rev !== rev)), + [commit], + ); + + useLayoutEffect(() => { + setTarget(container ?? getPortalContainer()); + return setNotifier({ + show: (input) => { + const id = input.id ?? nextId(); + const others = current.current.filter((e) => e.id !== id); + commit(capped([...others, { ...input, id, rev: nextRev() }])); + return id; + }, + dismiss: (id) => commit(current.current.filter((e) => e.id !== id)), + }); + }, [commit, container]); + + const viewport = ( + + ); + + return ( + + {entries.map((entry) => ( + removeRev(entry.rev)} + /> + ))} + {target ? createPortal(viewport, target) : null} + + ); +} + +type ToastItemProps = Readonly<{ entry: Entry; onClose: () => void }>; + +function ToastItem({ entry, onClose }: ToastItemProps): JSX.Element { + const Icon = KIND_ICON[entry.kind]; + + return ( + { + if (!open) { + onClose(); + } + }} + duration={entry.persistent ? Number.POSITIVE_INFINITY : undefined} + type={assertive(entry.kind) ? "foreground" : "background"} + data-slot="toast" + data-kind={entry.kind} + data-rev={entry.rev} + className={cn( + "ui:box-border ui:flex ui:w-full ui:items-start ui:gap-3 ui:rounded-lg ui:border ui:border-border ui:bg-popover ui:py-3.5 ui:pr-3.5 ui:pl-4 ui:text-popover-foreground ui:shadow-md ui:focus-visible:focus-ring", + )} + > + +
+ + {entry.title} + + {entry.description ? ( + + {entry.description} + + ) : null} + {entry.requestId ? ( + // Radix makes the toast unselectable for swiping; the id must copy. +

+ Request ID: {entry.requestId} +

+ ) : null} + {entry.action ? ( + entry.action?.onSelect()} + className="ui:mt-1 ui:cursor-pointer ui:self-start ui:rounded-sm ui:border-0 ui:bg-transparent ui:p-0 ui:text-body-small ui:font-medium ui:text-primary ui:hover:underline ui:focus-visible:focus-ring" + > + {entry.action.label} + + ) : null} +
+ + + +
+ ); +} + +/** Errors interrupt; everything else waits for a pause. */ +function assertive(kind: NotifyKind): boolean { + return kind === "error"; +} diff --git a/frontend/src/v1/components/ui/feedback/__snapshots__/markup.md b/frontend/src/v1/components/ui/feedback/__snapshots__/markup.md new file mode 100644 index 0000000000..bf9bd50466 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/__snapshots__/markup.md @@ -0,0 +1,319 @@ +# Feedback markup + +Generated by `markup.test.tsx`; update with `vitest -u`. + +## inline alert success + +```html +
+ +
+

Title

+
Body
+
+
+``` + +## inline alert error + +```html + +``` + +## inline alert warning + +```html +
+ +
+

Title

+
Body
+
+
+``` + +## inline alert info + +```html +
+ +
+

Title

+
Body
+
+
+``` + +## inline alert with retry + +```html + +``` + +## progress bar + +```html +
+
+
+
+``` + +## progress bar indeterminate + +```html +
+
+
+
+``` + +## step progress + +```html +
    +
  1. + +A, completed + +
  2. +
  3. + +B, current + +
  4. +
  5. + +C, not started + +
  6. +
+``` + +## skeleton + +```html +
+Loading + +
+``` + +## spinner inline + +```html + + + +``` + +## spinner button labelled + +```html + + +Saving + +``` + +## toast success + +```html +
  • + +
    +
    Title
    +
    Description
    + +
    + +
  • +``` + +## toast error + +```html +
  • + +
    +
    Title
    +
    Description
    + +
    + +
  • +``` + +## toast warning + +```html +
  • + +
    +
    Title
    +
    Description
    + +
    + +
  • +``` + +## toast info + +```html +
  • + +
    +
    Title
    +
    Description
    + +
    + +
  • +``` diff --git a/frontend/src/v1/components/ui/feedback/feedback.test.tsx b/frontend/src/v1/components/ui/feedback/feedback.test.tsx new file mode 100644 index 0000000000..69eba199e5 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/feedback.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { InlineAlert } from "./InlineAlert"; +import { ProgressBar } from "./ProgressBar"; +import { + Skeleton, + SkeletonAvatar, + SkeletonTableRows, + SkeletonText, +} from "./Skeleton"; +import { Spinner } from "./Spinner"; +import { StepProgress } from "./StepProgress"; + +it("announces an inline error at once and other kinds politely", () => { + const { rerender } = render(); + expect(screen.getByRole("alert")?.textContent).toContain( + "Could not load projects", + ); + rerender(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("status")?.getAttribute("data-kind")).toBe("warning"); +}); + +it("offers Retry only when given onRetry, and calls it", async () => { + const user = userEvent.setup(); + const onRetry = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole("button")).toBeNull(); + rerender( + , + ); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(onRetry).toHaveBeenCalledOnce(); + // Not the click event: `onRetry={load}` must not receive it as an argument. + expect(onRetry.mock.calls[0]).toEqual([]); +}); + +it("reports determinate progress, clamped to the range", () => { + const { rerender } = render(); + const bar = screen.getByRole("progressbar", { name: "Upload" }); + expect(bar?.getAttribute("aria-valuenow")).toBe("40"); + expect(bar?.getAttribute("aria-valuemax")).toBe("100"); + rerender(); + expect(bar?.getAttribute("aria-valuenow")).toBe("100"); + rerender(); + expect(bar?.getAttribute("aria-valuenow")).toBe("0"); + rerender(); + expect(bar?.getAttribute("aria-valuenow")).toBe("3"); + expect(bar?.getAttribute("aria-valuemax")).toBe("4"); +}); + +it("fills the bar as a fraction of max", () => { + const { container } = render( + , + ); + const indicator = container.querySelector("[data-slot=progress-bar] > div"); + expect(indicator?.getAttribute("style")).toContain("--progress: 0.75"); +}); + +it("shows an empty bar, not NaN, when the total is zero or invalid", () => { + const { rerender, container } = render( + , + ); + const bar = screen.getByRole("progressbar", { name: "Batch" }); + const indicator = container.querySelector("[data-slot=progress-bar] > div"); + expect(bar.getAttribute("aria-valuenow")).toBe("0"); + expect(indicator?.getAttribute("style")).toContain("--progress: 0"); + rerender(); + expect(bar.getAttribute("aria-valuenow")).toBe("0"); + expect(bar.getAttribute("aria-valuemax")).toBe("100"); + // `done / total * 100` with total 0. + rerender(); + expect(bar.getAttribute("aria-valuenow")).toBe("0"); + expect(bar.getAttribute("data-state")).not.toBe("indeterminate"); + expect(indicator?.getAttribute("style")).toContain("--progress: 0"); + expect(indicator?.getAttribute("style")).not.toContain("NaN"); +}); + +it("leaves aria-valuenow out while progress is indeterminate", () => { + render(); + const bar = screen.getByRole("progressbar", { name: "Extracting" }); + expect(bar?.hasAttribute("aria-valuenow")).toBe(false); + expect(bar?.getAttribute("data-state")).toBe("indeterminate"); +}); + +it("marks the current step and says each step's state", () => { + render( + , + ); + const list = screen.getByRole("list", { name: "Setup" }); + const items = screen.getAllByRole("listitem"); + expect(list).not.toBeNull(); + expect(items.map((i) => i.dataset.state)).toEqual([ + "done", + "current", + "upcoming", + ]); + expect(items[1]?.getAttribute("aria-current")).toBe("step"); + expect(items[0]?.textContent).toContain("Source, completed"); + expect(items[2]?.textContent).toContain("Destination, not started"); +}); + +it("marks every step done when current is past the end", () => { + render(); + const states = screen.getAllByRole("listitem").map((i) => i.dataset.state); + expect(states).toEqual(["done", "done"]); +}); + +it("exposes one status region with the label for a skeleton and hides the bars", () => { + render( + + + + + , + ); + const region = screen.getByRole("status"); + // aria-busy would hold the announcement until the skeleton unmounts. + expect(region.hasAttribute("aria-busy")).toBe(false); + expect(region?.textContent).toContain("Loading projects"); + expect( + region + .querySelector("[aria-hidden=true]") + ?.contains( + region.querySelector("[data-slot=skeleton-text]") as HTMLElement, + ), + ).toBe(true); +}); + +it("draws text lines 12 then 10 tall, widths cycling 100%, 78%, 46%", () => { + const { container } = render(); + const bars = [ + ...container.querySelectorAll("[data-slot=skeleton-text] > span"), + ]; + expect(bars.map((b) => b.className.match(/ui:h-[\d.]+/)?.[0])).toEqual([ + "ui:h-3", + "ui:h-2.5", + "ui:h-2.5", + "ui:h-2.5", + ]); + expect(bars.map((b) => b.className.match(/ui:w-\S+/)?.[0])).toEqual([ + "ui:w-full", + "ui:w-[78%]", + "ui:w-[46%]", + "ui:w-full", + ]); +}); + +it("names a spinner only when asked", () => { + const { rerender } = render(); + expect(screen.queryByRole("status")).toBeNull(); + rerender(); + expect(screen.getByRole("status")?.textContent).toContain("Saving"); +}); diff --git a/frontend/src/v1/components/ui/feedback/index.ts b/frontend/src/v1/components/ui/feedback/index.ts new file mode 100644 index 0000000000..4f34903a69 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/index.ts @@ -0,0 +1,22 @@ +export { InlineAlert, type InlineAlertProps } from "./InlineAlert"; +export { ProgressBar, type ProgressBarProps } from "./ProgressBar"; +export { + Skeleton, + SkeletonAvatar, + type SkeletonAvatarProps, + SkeletonBlock, + type SkeletonBlockProps, + type SkeletonProps, + SkeletonTableRows, + type SkeletonTableRowsProps, + SkeletonText, + type SkeletonTextProps, +} from "./Skeleton"; +export { Spinner, type SpinnerProps } from "./Spinner"; +export { StepProgress, type StepProgressProps } from "./StepProgress"; +export { + MAX_TOASTS, + TOAST_DURATION_MS, + Toaster, + type ToasterProps, +} from "./Toaster"; diff --git a/frontend/src/v1/components/ui/feedback/kinds.tsx b/frontend/src/v1/components/ui/feedback/kinds.tsx new file mode 100644 index 0000000000..3227a83002 --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/kinds.tsx @@ -0,0 +1,31 @@ +import type { NotifyKind } from "@v1/lib/notify"; +import { + CircleAlert, + CircleCheck, + Info, + type LucideIcon, + TriangleAlert, +} from "lucide-react"; + +/** The icon and its colour for each kind, shared by toasts and inline alerts. */ +export const KIND_ICON: Record = { + success: CircleCheck, + error: CircleAlert, + warning: TriangleAlert, + info: Info, +}; + +// There is no `info` token yet; `primary` stands in until the designer adds one. +export const KIND_TEXT: Record = { + success: "ui:text-success", + error: "ui:text-destructive", + warning: "ui:text-warning", + info: "ui:text-primary", +}; + +export const KIND_BORDER: Record = { + success: "ui:border-success", + error: "ui:border-destructive", + warning: "ui:border-warning", + info: "ui:border-primary", +}; diff --git a/frontend/src/v1/components/ui/feedback/markup.test.tsx b/frontend/src/v1/components/ui/feedback/markup.test.tsx new file mode 100644 index 0000000000..5cb83c2c6d --- /dev/null +++ b/frontend/src/v1/components/ui/feedback/markup.test.tsx @@ -0,0 +1,104 @@ +import { act, render } from "@testing-library/react"; +import { NOTIFY_KINDS, type NotifyKind, notify } from "@v1/lib/notify"; +import type { ReactElement } from "react"; +import { expect, it } from "vitest"; +import { InlineAlert } from "./InlineAlert"; +import { ProgressBar } from "./ProgressBar"; +import { + Skeleton, + SkeletonAvatar, + SkeletonBlock, + SkeletonTableRows, + SkeletonText, +} from "./Skeleton"; +import { Spinner } from "./Spinner"; +import { StepProgress } from "./StepProgress"; +import { Toaster } from "./Toaster"; + +// One snapshot per variant and state, so a markup or class change shows in review. +const VARIANTS: [string, ReactElement][] = [ + ...NOTIFY_KINDS.map((kind): [string, ReactElement] => [ + `inline alert ${kind}`, + + Body + , + ]), + [ + "inline alert with retry", + undefined}> + The server did not answer. + , + ], + ["progress bar", ], + ["progress bar indeterminate", ], + [ + "step progress", + , + ], + [ + "skeleton", + + + + + + , + ], + ["spinner inline", ], + ["spinner button labelled", ], +]; + +/** The markup of one toast of the given kind, as the Toaster renders it. */ +function toastMarkup(kind: NotifyKind): string { + const host = document.createElement("div"); + document.body.appendChild(host); + const { unmount } = render(); + act(() => { + notify({ + kind, + title: "Title", + description: "Description", + action: { label: "Action", onSelect: () => undefined }, + }); + }); + const html = host.querySelector("[data-slot=toast]")?.outerHTML ?? ""; + unmount(); + host.remove(); + return html; +} + +/* + * One file for every variant and state, so a markup or class change shows in + * review. Markdown rather than a `.snap`, because v1 holds no `.snap` files. + */ +it("matches the committed markup of every variant", async () => { + const sections = VARIANTS.map(([name, element]) => { + const { container, unmount } = render(element); + const html = (container.firstChild as HTMLElement).outerHTML; + unmount(); + return [name, html] as const; + }); + for (const kind of NOTIFY_KINDS) { + sections.push([`toast ${kind}`, toastMarkup(kind)]); + } + const doc = [ + "# Feedback markup", + "", + "Generated by `markup.test.tsx`; update with `vitest -u`.", + ...sections.flatMap(([name, html]) => [ + "", + `## ${name}`, + "", + "```html", + html.replaceAll("><", ">\n<"), + "```", + ]), + "", + ].join("\n"); + await expect(doc).toMatchFileSnapshot("./__snapshots__/markup.md"); +}); diff --git a/frontend/src/v1/lib/notify.test.ts b/frontend/src/v1/lib/notify.test.ts new file mode 100644 index 0000000000..b4bd8a7460 --- /dev/null +++ b/frontend/src/v1/lib/notify.test.ts @@ -0,0 +1,84 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + dismissNotification, + type Notifier, + notify, + setNotifier, + toast, +} from "./notify"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function fakeNotifier(): Notifier & { shown: unknown[]; dismissed: string[] } { + const shown: unknown[] = []; + const dismissed: string[] = []; + return { + shown, + dismissed, + show: (input) => { + shown.push(input); + return "id-1"; + }, + dismiss: (id) => { + dismissed.push(id); + }, + }; +} + +it("sends notifications to the registered notifier and returns its id", () => { + const fake = fakeNotifier(); + const unregister = setNotifier(fake); + expect(notify({ kind: "success", title: "Saved" })).toBe("id-1"); + dismissNotification("id-1"); + unregister(); + expect(fake.shown).toEqual([{ kind: "success", title: "Saved" }]); + expect(fake.dismissed).toEqual(["id-1"]); +}); + +it("logs with the request id when no notifier is mounted, so nothing is silent", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + expect( + notify({ + kind: "error", + title: "Could not save", + description: "Server error", + requestId: "req-42", + }), + ).toBeUndefined(); + notify({ kind: "warning", title: "Slow" }); + expect(error).toHaveBeenCalledWith( + "[v1 notify] Could not save: Server error (request req-42)", + ); + expect(warn).toHaveBeenCalledWith("[v1 notify] Slow"); +}); + +it("unregistering an older notifier leaves the newer one in place", () => { + const first = fakeNotifier(); + const second = fakeNotifier(); + const unregisterFirst = setNotifier(first); + const unregisterSecond = setNotifier(second); + unregisterFirst(); + notify({ kind: "info", title: "Hi" }); + unregisterSecond(); + expect(first.shown).toEqual([]); + expect(second.shown).toHaveLength(1); +}); + +it("has one shorthand per kind", () => { + const fake = fakeNotifier(); + const unregister = setNotifier(fake); + toast.success("a"); + toast.error("b", { requestId: "r" }); + toast.warning("c"); + toast.info("d", { persistent: true }); + unregister(); + expect(fake.shown).toEqual([ + { kind: "success", title: "a" }, + { kind: "error", title: "b", requestId: "r" }, + { kind: "warning", title: "c" }, + { kind: "info", title: "d", persistent: true }, + ]); +}); diff --git a/frontend/src/v1/lib/notify.ts b/frontend/src/v1/lib/notify.ts new file mode 100644 index 0000000000..75ba1081e3 --- /dev/null +++ b/frontend/src/v1/lib/notify.ts @@ -0,0 +1,99 @@ +/** + * The notification contract: the one way v1 tells the user something happened. + * + * It lives in `lib/` so every folder can reach it, including `services/`, which + * may import only `lib/` (the P0-T05 error normaliser is meant to call + * `notify()`), and the `Toaster` in `components/ui/feedback/` registers itself + * as the notifier. No + * other toast library may be imported in v1 (`lint:v1` enforces it). + */ + +export const NOTIFY_KINDS = ["success", "error", "warning", "info"] as const; +export type NotifyKind = (typeof NOTIFY_KINDS)[number]; + +export type NotifyAction = Readonly<{ + /** The button text; also its accessible name. */ + label: string; + onSelect: () => void; +}>; + +export type NotifyInput = Readonly<{ + kind: NotifyKind; + title: string; + description?: string; + action?: NotifyAction; + /** + * Stays until the user dismisses it; otherwise it times out. At the stack + * limit, timed toasts are dropped before persistent ones. + */ + persistent?: boolean; + /** The backend request id, shown (and selectable) on the toast for support. */ + requestId?: string; + /** + * Reusing an id replaces the open notification instead of stacking another; + * the replacement starts its own timer and is announced again. + */ + id?: string; +}>; + +export type Notifier = Readonly<{ + /** Shows the notification and returns its id. */ + show: (input: NotifyInput) => string; + dismiss: (id: string) => void; +}>; + +let current: Notifier | null = null; + +/** Registers the notifier; returns a cleanup that unregisters only this one. */ +export function setNotifier(notifier: Notifier): () => void { + current = notifier; + return () => { + if (current === notifier) { + current = null; + } + }; +} + +const FALLBACK_LOG: Record = { + error: "error", + warning: "warn", + success: "info", + info: "info", +}; + +/** + * Tells the user something happened. With no notifier mounted (a test, or a + * failure before the app shell renders) the message is only logged to the + * console with its request id: it is not shown, not queued for a later mount, + * and its action is lost. Callers that must reach the user before the shell + * renders need their own fallback. + */ +export function notify(input: NotifyInput): string | undefined { + if (current) { + return current.show(input); + } + const parts = [input.title, input.description].filter(Boolean).join(": "); + const trace = input.requestId ? ` (request ${input.requestId})` : ""; + // The documented fallback: logged to the console, not shown. + console[FALLBACK_LOG[input.kind]](`[v1 notify] ${parts}${trace}`); + return undefined; +} + +export function dismissNotification(id: string): void { + current?.dismiss(id); +} + +type Shorthand = Omit; + +/** Shorthands for `notify()`, one per kind. */ +export const toast = { + success: (title: string, rest?: Shorthand) => + notify({ ...rest, kind: "success", title }), + error: (title: string, rest?: Shorthand) => + notify({ ...rest, kind: "error", title }), + warning: (title: string, rest?: Shorthand) => + notify({ ...rest, kind: "warning", title }), + info: (title: string, rest?: Shorthand) => + notify({ ...rest, kind: "info", title }), + dismiss: dismissNotification, +} as const; diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index bb8342b994..59d69659c4 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -78,6 +78,20 @@ const CASES = parseTable(` v1-unknown-file-type | typecheck:v1 | Unknown file type under v1 v1-ant-class-prefix | lint:v1 | No antd 'ant-' class names v1-ant-built-class | lint:v1 | No antd 'ant-' class names + # One notification entry point: toast libraries only in components/ui/feedback/. + v1-notify-sonner | lint:v1 | One notification entry point in v1 + v1-notify-radix-toast | lint:v1 | One notification entry point in v1 + v1-notify-radix-namespace | lint:v1 | One notification entry point in v1 + v1-notify-dynamic | lint:v1 | One notification entry point in v1 + v1-notify-radix-dynamic | lint:v1 | One notification entry point in v1 + v1-notify-radix-bracket | lint:v1 | One notification entry point in v1 + v1-notify-radix-jsx | lint:v1 | One notification entry point in v1 + v1-notify-radix-require | lint:v1 | One notification entry point in v1 + v1-notify-radix-reexport | lint:v1 | One notification entry point in v1 + v1-notify-radix-then | lint:v1 | One notification entry point in v1 + # Outside the feedback folder, a static named radix-ui import without Toast is fine. + v1-notify-radix-named-ok | lint:v1 | - + v1-notify-feedback-ok | lint:v1 | - v1-explicit-any | lint:v1 | noExplicitAny # The v1 override must add to the shared rules, not replace them. v1-base-rules-still-apply | lint:v1 | noDoubleEquals diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-dynamic/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-dynamic/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..7c7c149c11 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-dynamic/src/v1/pages/demo/index.ts @@ -0,0 +1 @@ +export const load = () => import("react-hot-toast"); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-feedback-ok/src/v1/components/ui/feedback/Extra.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-feedback-ok/src/v1/components/ui/feedback/Extra.ts new file mode 100644 index 0000000000..d9d337efaf --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-feedback-ok/src/v1/components/ui/feedback/Extra.ts @@ -0,0 +1,3 @@ +import { Progress, Toast } from "radix-ui"; + +export const parts = [Toast.Root, Progress.Root]; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-bracket/src/v1/shell/Notice.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-bracket/src/v1/shell/Notice.ts new file mode 100644 index 0000000000..6c27dd3f4c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-bracket/src/v1/shell/Notice.ts @@ -0,0 +1,3 @@ +import * as Radix from "radix-ui"; + +export const Notice = Radix["Toast"].Root; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-dynamic/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-dynamic/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..f1914a07c8 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-dynamic/src/v1/pages/demo/index.ts @@ -0,0 +1,4 @@ +export async function load() { + const { Toast } = await import("radix-ui"); + return Toast.Root; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-jsx/src/v1/shell/Notice.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-jsx/src/v1/shell/Notice.tsx new file mode 100644 index 0000000000..8bb9e4952a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-jsx/src/v1/shell/Notice.tsx @@ -0,0 +1,3 @@ +import R from "radix-ui"; + +export const Notice = () => ; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-named-ok/src/v1/components/patterns/Bar.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-named-ok/src/v1/components/patterns/Bar.ts new file mode 100644 index 0000000000..9f4e437c91 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-named-ok/src/v1/components/patterns/Bar.ts @@ -0,0 +1,3 @@ +import { Progress } from "radix-ui"; + +export const Bar = Progress.Root; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-namespace/src/v1/shell/Notice.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-namespace/src/v1/shell/Notice.ts new file mode 100644 index 0000000000..b25a15bafd --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-namespace/src/v1/shell/Notice.ts @@ -0,0 +1,3 @@ +import * as Radix from "radix-ui"; + +export const Notice = Radix.Toast.Root; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-reexport/src/v1/components/patterns/radix.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-reexport/src/v1/components/patterns/radix.ts new file mode 100644 index 0000000000..3b7254e844 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-reexport/src/v1/components/patterns/radix.ts @@ -0,0 +1 @@ +export * from "radix-ui"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..726779d17f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-require/src/v1/pages/demo/index.ts @@ -0,0 +1,4 @@ +// biome-ignore lint/style/noCommonJs: the fixture is the CommonJS spelling. +const { Toast } = require("radix-ui"); + +export const Root = Toast.Root; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-then/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-then/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..999ef9d3fa --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-then/src/v1/pages/demo/index.ts @@ -0,0 +1 @@ +export const load = () => import("radix-ui").then((R) => R.Toast.Root); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-toast/src/v1/components/patterns/Saved.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-toast/src/v1/components/patterns/Saved.ts new file mode 100644 index 0000000000..18882fa27d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-radix-toast/src/v1/components/patterns/Saved.ts @@ -0,0 +1,3 @@ +import { Toast } from "radix-ui"; + +export const SavedToast = Toast.Root; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-notify-sonner/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-notify-sonner/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..aedec91780 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-notify-sonner/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import { toast } from "sonner"; + +export const announce = () => toast("Saved");