Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/screenshot/specs/commits-tab-after-push.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ it("captures the Commits tab before and after pushing a workspace to remote", as
name: "commits-tab-after-push-02-after-push",
expectations: [
"The 'Push to remote' button is gone from the header (only 'Merge...' and the overflow menu remain).",
"A green toast in the bottom-left corner reads 'Pushed to remote' / 'Changes pushed successfully'.",
"A toast in the bottom-left corner with a green check icon reads 'Pushed to remote' / 'Changes pushed successfully'.",
"Workspace commit 1 and Workspace commit 2 are still listed, unchanged from the before-push screenshot.",
],
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ it("captures the header sync indicator before and after committing from the Revi
name: "header-sync-indicator-02-after-commit",
expectations: [
"The header now shows an '↑1' sync indicator with the circular refresh-arrows icon, just left of the green 'Merge...' button -- it appeared without navigating away from this workspace.",
"A green 'Commit created' toast is visible in the bottom-left corner.",
"A 'Commit created' toast with a green check icon is visible in the bottom-left corner.",
"The Review tab's left sidebar no longer has a 'Changes' section; sync-indicator.txt now sits under 'Committed', tagged 'A'.",
],
});
Expand Down
2 changes: 1 addition & 1 deletion scripts/screenshot/specs/sync-after-commit.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ it("captures workspace state before and after syncing a fresh commit", async ()
name: "sync-after-commit-02-after-sync",
expectations: [
"'Commit that must survive sync' is still listed in the Commits tab, in the same position as the before screenshot.",
"A green toast in the bottom-left reads 'Synced with remote' / 'Fetched and pushed changes'.",
"A toast in the bottom-left with a green check icon reads 'Synced with remote' / 'Fetched and pushed changes'.",
"The '↑1' ahead indicator is gone from the header — the workspace is now in sync with the remote.",
"No empty '(no description set)' commit appears above the real commit.",
],
Expand Down
95 changes: 95 additions & 0 deletions scripts/screenshot/specs/toast-styles.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as React from "react";
import { it } from "vitest";
import userEvent from "@testing-library/user-event";
import { render, screen } from "../../../test/test-utils";
import { useToast } from "../../../src/components/ui/toast";
import { captureDocument } from "../capture";

// Toast styling is a pure presentational concern with no backend involvement,
// so this drives the toast context directly (via useToast) rather than a real
// repo/workspace flow, to get all four toast types on screen deterministically.
function ToastHarness() {
const { addToast } = useToast();

return (
<button
type="button"
onClick={() => {
addToast({
title: "Synced with remote",
description: "Fetched and pushed changes",
type: "success",
});
addToast({
title: "Push failed",
description: "Permission denied (publickey)",
type: "error",
});
addToast({
title: "Uncommitted changes",
description: "You have local edits that were not pushed",
type: "warning",
});
addToast({
title: "Terminal Restarting",
description: "Using model: default",
type: "info",
});
}}
>
Fire toasts
</button>
);
}

it("captures all four toast type styles", async () => {
const user = userEvent.setup();
render(<ToastHarness />);

await user.click(await screen.findByRole("button", { name: "Fire toasts" }));

await screen.findByText("Synced with remote");
await screen.findByText("Push failed");
await screen.findByText("Uncommitted changes");
await screen.findByText("Terminal Restarting");

await captureDocument(document, {
name: "toast-styles-01-all-types",
expectations: [
"Four stacked toasts are visible in the bottom-left corner of the page.",
"Every toast shares the same neutral card-like surface (light background, subtle border, drop shadow, rounded corners) rather than a solid saturated color fill.",
"The 'Synced with remote' toast shows a green circular check icon on its left.",
"The 'Push failed' toast shows a red/destructive circular X icon on its left.",
"The 'Uncommitted changes' toast shows an orange triangle warning icon on its left.",
"The 'Terminal Restarting' toast shows a blue/primary-colored info icon on its left.",
"Each toast has a bold title line and a smaller, muted-colored description line below it.",
"Each toast has a small close (X) button on its right edge.",
],
});
}, 30000);

it("captures all four toast type styles in dark mode", async () => {
const user = userEvent.setup();
render(<ToastHarness />);
document.documentElement.classList.add("dark");

await user.click(await screen.findByRole("button", { name: "Fire toasts" }));

await screen.findByText("Synced with remote");
await screen.findByText("Push failed");
await screen.findByText("Uncommitted changes");
await screen.findByText("Terminal Restarting");

await captureDocument(document, {
name: "toast-styles-02-all-types-dark",
expectations: [
"The page background is dark (dark mode is active).",
"Four stacked toasts are visible in the bottom-left corner, using a dark neutral surface consistent with the rest of the dark-mode UI, not a solid saturated color fill.",
"The 'Synced with remote' toast shows a green check icon.",
"The 'Push failed' toast shows a red/destructive X icon.",
"The 'Uncommitted changes' toast shows an orange triangle warning icon.",
"The 'Terminal Restarting' toast shows a blue/primary info icon.",
"Toast title and description text is legible against the dark background.",
],
});
}, 30000);
63 changes: 35 additions & 28 deletions src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { X } from "lucide-react";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";

export type ToastType = "success" | "error" | "info" | "warning";
Expand Down Expand Up @@ -52,40 +52,47 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
);
}

const toastIcons: Record<ToastType, typeof CheckCircle2> = {
error: XCircle,
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
};

const toastIconStyles: Record<ToastType, string> = {
error: "text-destructive",
info: "text-primary",
success: "text-green-600 dark:text-green-500",
warning: "text-orange-500 dark:text-orange-400",
};

function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const typeStyles = {
error:
"bg-destructive dark:bg-destructive/90 border border-destructive/30 text-destructive-foreground",
info: "bg-primary dark:bg-primary/90 border border-primary/30 text-primary-foreground",
success:
"bg-green-600 dark:bg-green-700 border border-green-500/30 dark:border-green-600/30 text-white",
warning:
"bg-orange-600 dark:bg-orange-700 border border-orange-500/30 dark:border-orange-600/30 text-white",
};
const Icon = toastIcons[toast.type];

return (
<div
className={cn(
"min-w-[300px] rounded-xl py-2.5 px-4 shadow-lg animate-in slide-in-from-left",
typeStyles[toast.type],
)}
role="status"
className="pointer-events-auto flex w-full min-w-[320px] max-w-sm items-start gap-3 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-lg animate-in slide-in-from-left"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="text-base font-medium">{toast.title}</div>
{toast.description && (
<div className="text-base opacity-90 mt-0.5">
{toast.description}
</div>
)}
<Icon
className={cn("mt-0.5 h-5 w-5 shrink-0", toastIconStyles[toast.type])}
/>
<div className="flex-1 space-y-1">
<div className="text-sm font-semibold leading-none">
{toast.title}
</div>
<button
onClick={onClose}
className="text-current/70 hover:text-current hover:bg-white/20 dark:hover:bg-white/10 rounded transition-colors p-0.5"
>
<X className="w-4 h-4" />
</button>
{toast.description && (
<div className="text-sm text-muted-foreground">
{toast.description}
</div>
)}
</div>
<button
onClick={onClose}
className="shrink-0 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
Expand Down
Loading