fix: update tool count to 51, refresh hero copy and login page

- Update stale tool counts (48/49/50) to 51 across all apps, docs, and tests
- Add Meme Generator to landing page bento grid (was missing)
- Add 12 SEO-friendly rotating phrases to hero typing cursor
- Change landing hero subtitle to "Every image tool you need."
- Replace outdated login page copy with branded hero + rotating phrases
This commit is contained in:
SnapOtter
2026-05-10 22:18:11 +08:00
parent 32e7b03ab4
commit 172c0fa772
22 changed files with 125 additions and 52 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
lines.push(`# ${spec.info.title}`); lines.push(`# ${spec.info.title}`);
lines.push(""); lines.push("");
lines.push( lines.push(
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.", "> Self-hosted image processing API with 51 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
); );
lines.push(""); lines.push("");
lines.push("## Docs"); lines.push("## Docs");
+1 -1
View File
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
### API (`apps/api`) ### API (`apps/api`)
A Fastify v5 server exposing 50 tool routes (34 standard image operations + 15 AI-powered + editor) that handles: A Fastify v5 server exposing 51 tool routes (35 standard image operations + 15 AI-powered + editor) that handles:
- File uploads, temporary workspace management, and persistent file storage - File uploads, temporary workspace management, and persistent file storage
- User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page - User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
- Tool execution (routes each tool request to the image engine or AI bridge) - Tool execution (routes each tool request to the image engine or AI bridge)
+1 -1
View File
@@ -64,7 +64,7 @@ pnpm dev
## What You Can Do ## What You Can Do
### Image Processing (50 Tools) ### Image Processing (51 Tools)
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
+2 -2
View File
@@ -4,7 +4,7 @@ layout: home
hero: hero:
name: "SnapOtter" name: "SnapOtter"
text: "A Self Hosted Image Manipulator" text: "A Self Hosted Image Manipulator"
tagline: 50 tools. Local AI. No cloud. Your images never leave your home. tagline: 51 tools. Local AI. No cloud. Your images never leave your home.
actions: actions:
- theme: brand - theme: brand
text: Get started text: Get started
@@ -14,7 +14,7 @@ hero:
link: /api/rest link: /api/rest
features: features:
- title: 50 Image Tools - title: 51 Image Tools
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more. details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
- title: Local AI - title: Local AI
details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required. details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required.
+3 -3
View File
@@ -8,7 +8,7 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "SnapOtter | Self-Hosted Image Processing", title: "SnapOtter | Self-Hosted Image Processing",
description: description:
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.", "51 image processing tools with local AI. Runs 100% offline. No data leaves your network. Open source and free forever.",
metadataBase: new URL("https://snapotter.com"), metadataBase: new URL("https://snapotter.com"),
icons: { icons: {
icon: [ icon: [
@@ -24,7 +24,7 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
title: "SnapOtter | Self-Hosted Image Processing", title: "SnapOtter | Self-Hosted Image Processing",
description: description:
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.", "51 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
url: "https://snapotter.com", url: "https://snapotter.com",
siteName: "SnapOtter", siteName: "SnapOtter",
type: "website", type: "website",
@@ -41,7 +41,7 @@ export const metadata: Metadata = {
card: "summary_large_image", card: "summary_large_image",
title: "SnapOtter | Self-Hosted Image Processing", title: "SnapOtter | Self-Hosted Image Processing",
description: description:
"48 image processing tools with local AI. Runs 100% offline. No data leaves your network.", "51 image processing tools with local AI. Runs 100% offline. No data leaves your network.",
images: ["/og-image.svg"], images: ["/og-image.svg"],
}, },
}; };
+8 -1
View File
@@ -24,6 +24,7 @@ import {
Grid3x3, Grid3x3,
ImagePlus, ImagePlus,
Info, Info,
Laugh,
Layers, Layers,
LayoutGrid, LayoutGrid,
Maximize2, Maximize2,
@@ -275,6 +276,12 @@ const tools: { name: string; description: string; category: string; icon: Lucide
category: "watermark", category: "watermark",
icon: Layers, icon: Layers,
}, },
{
name: "Meme Generator",
description: "Create memes with templates and custom text",
category: "watermark",
icon: Laugh,
},
// Utilities // Utilities
{ {
name: "Image Info", name: "Image Info",
@@ -408,7 +415,7 @@ export function BentoGrid() {
<div className="mx-auto max-w-6xl"> <div className="mx-auto max-w-6xl">
<FadeIn> <FadeIn>
<h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl"> <h2 className="font-[family-name:var(--font-nunito)] text-center text-3xl font-bold tracking-tight md:text-4xl">
50 tools. Zero cloud dependency. 51 tools. Zero cloud dependency.
</h2> </h2>
<p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted"> <p className="mx-auto mt-4 max-w-xl text-center text-lg text-muted">
Search to find exactly what you need. Every tool runs 100% locally. Search to find exactly what you need. Every tool runs 100% locally.
+1 -1
View File
@@ -119,7 +119,7 @@ export function Hero() {
</h1> </h1>
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.1s_both] text-lg text-muted md:text-xl"> <p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.1s_both] text-lg text-muted md:text-xl">
The open-source, self-hosted image processing platform. Every image tool you need.
</p> </p>
<p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl"> <p className="mt-4 animate-[fadeUp_0.6s_ease-out_0.2s_both] text-xl font-medium md:text-2xl">
+1 -1
View File
@@ -7,7 +7,7 @@ const freePlan = {
price: "Free", price: "Free",
subtitle: "For everyone. Forever.", subtitle: "For everyone. Forever.",
features: [ features: [
"All 48 image processing tools", "All 51 image processing tools",
"Unlimited usage, no hidden caps", "Unlimited usage, no hidden caps",
"Full REST API with OpenAPI docs", "Full REST API with OpenAPI docs",
"Pipeline automation", "Pipeline automation",
+13 -1
View File
@@ -11,12 +11,24 @@ const phrases = [
"No limits. No hidden caps.", "No limits. No hidden caps.",
"Works fully offline.", "Works fully offline.",
"Unlimited batch processing.", "Unlimited batch processing.",
"48 image tools.", "51 image tools.",
"15 AI models. Your hardware.", "15 AI models. Your hardware.",
"Lightning fast. Built on Sharp.", "Lightning fast. Built on Sharp.",
"Air-gapped ready.", "Air-gapped ready.",
"One Docker container.", "One Docker container.",
"Open source. Always.", "Open source. Always.",
"Every pixel stays on your hardware.",
"Your server. Your rules.",
"Deploys in a single docker pull.",
"The self-hosted alternative.",
"Resize, compress, convert. Locally.",
"Free image editor. No watermarks.",
"Bulk image processing. Unlimited.",
"Remove backgrounds offline.",
"Self-hosted Canva alternative.",
"No API keys needed.",
"GDPR compliant by design.",
"Works without internet.",
]; ];
export function TypingCursor() { export function TypingCursor() {
@@ -2460,7 +2460,7 @@ function AboutSection() {
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
A self-hosted, privacy-first image processing suite with 50 tools. Resize, compress, A self-hosted, privacy-first image processing suite with 51 tools. Resize, compress,
convert, watermark, and automate your image workflows without sending data to the cloud. convert, watermark, and automate your image workflows without sending data to the cloud.
</p> </p>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
+64 -6
View File
@@ -1,6 +1,64 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useCallback, useEffect, useState } from "react";
import { setToken } from "@/lib/api"; import { setToken } from "@/lib/api";
const phrases = [
"No signups. No accounts.",
"No uploads to the cloud. Ever.",
"100% local processing.",
"Free. Forever.",
"No limits. No hidden caps.",
"Works fully offline.",
"Unlimited batch processing.",
"51 image tools.",
"15 AI models. Your hardware.",
"Lightning fast. Built on Sharp.",
"Air-gapped ready.",
"One Docker container.",
"Open source. Always.",
"Every pixel stays on your hardware.",
"Your server. Your rules.",
"Deploys in a single docker pull.",
"The self-hosted alternative.",
"Resize, compress, convert. Locally.",
"Free image editor. No watermarks.",
"Bulk image processing. Unlimited.",
"Remove backgrounds offline.",
"Self-hosted Canva alternative.",
"No API keys needed.",
"GDPR compliant by design.",
"Works without internet.",
];
function RotatingPhrase() {
const [index, setIndex] = useState(0);
const [visible, setVisible] = useState(true);
const advance = useCallback(() => {
setVisible(false);
setTimeout(() => {
setIndex((i) => (i + 1) % phrases.length);
setVisible(true);
}, 300);
}, []);
useEffect(() => {
const timer = setInterval(advance, 3000);
return () => clearInterval(timer);
}, [advance]);
return (
<span
className="inline-block transition-all duration-300 text-white/90"
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(-8px)",
}}
>
{phrases[index]}
</span>
);
}
export function LoginPage() { export function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -94,11 +152,11 @@ export function LoginPage() {
</div> </div>
</div> </div>
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl"> <div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
<div className="max-w-lg space-y-6 text-center"> <div className="max-w-lg space-y-4 text-center">
<h2 className="text-3xl font-bold">Your one-stop-shop for all your image needs.</h2> <h2 className="text-4xl font-extrabold tracking-tight">Your images. Stay yours.</h2>
<p className="text-lg text-white/80"> <p className="text-lg text-white/70">Every image tool you need.</p>
A privacy-first image suite that lets you resize, compress, convert, and process images <p className="text-xl font-medium h-8">
with 33+ powerful tools. <RotatingPhrase />
</p> </p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -225,7 +225,7 @@ export const en = {
regenerateKey: "Regenerate", regenerateKey: "Regenerate",
copyKey: "Copy Key", copyKey: "Copy Key",
aboutDescription: aboutDescription:
"SnapOtter is a self-hosted, privacy-first image processing suite with 50 tools.", "SnapOtter is a self-hosted, privacy-first image processing suite with 51 tools.",
aboutLinks: "Links", aboutLinks: "Links",
github: "GitHub", github: "GitHub",
documentation: "Documentation", documentation: "Documentation",
+2 -2
View File
@@ -13,7 +13,7 @@ test.describe("Docs Homepage", () => {
await expect(page.getByText("SnapOtter").first()).toBeVisible(); await expect(page.getByText("SnapOtter").first()).toBeVisible();
await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible(); await expect(page.getByText("A Self Hosted Image Manipulator")).toBeVisible();
await expect( await expect(
page.getByText("50 tools. Local AI. No cloud. Your images never leave your home."), page.getByText("51 tools. Local AI. No cloud. Your images never leave your home."),
).toBeVisible(); ).toBeVisible();
}); });
@@ -29,7 +29,7 @@ test.describe("Docs Homepage", () => {
test("features section renders all 6 feature cards", async ({ page }) => { test("features section renders all 6 feature cards", async ({ page }) => {
const features = [ const features = [
"48 Image Tools", "51 Image Tools",
"Local AI", "Local AI",
"Pipelines", "Pipelines",
"REST API", "REST API",
+3 -5
View File
@@ -23,9 +23,7 @@ test.describe("Landing Homepage", () => {
test("hero section renders headline and subheadline", async ({ page }) => { test("hero section renders headline and subheadline", async ({ page }) => {
await expect(page.getByText("Your images. Stay yours.")).toBeVisible(); await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
await expect( await expect(page.getByText("Every image tool you need.")).toBeVisible();
page.getByText("The open-source, self-hosted image processing platform."),
).toBeVisible();
}); });
test("hero CTA links to GitHub", async ({ page }) => { test("hero CTA links to GitHub", async ({ page }) => {
@@ -59,9 +57,9 @@ test.describe("Landing Homepage", () => {
}); });
test("bento grid renders with search and tool count", async ({ page }) => { test("bento grid renders with search and tool count", async ({ page }) => {
await expect(page.getByText("50 tools. Zero cloud dependency.")).toBeVisible(); await expect(page.getByText("51 tools. Zero cloud dependency.")).toBeVisible();
await expect(page.getByPlaceholder("Search tools...")).toBeVisible(); await expect(page.getByPlaceholder("Search tools...")).toBeVisible();
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible(); await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
}); });
test("enterprise section renders feature cards", async ({ page }) => { test("enterprise section renders feature cards", async ({ page }) => {
+6 -6
View File
@@ -9,28 +9,28 @@ test.describe("BentoGrid Interactions", () => {
const input = page.getByPlaceholder("Search tools..."); const input = page.getByPlaceholder("Search tools...");
await input.fill("resize"); await input.fill("resize");
await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible(); await expect(page.getByText("Resize", { exact: true }).first()).toBeVisible();
await expect(page.getByText(/Showing \d+ of 50 tools/)).toBeVisible(); await expect(page.getByText(/Showing \d+ of 51 tools/)).toBeVisible();
}); });
test("category pill filters tools", async ({ page }) => { test("category pill filters tools", async ({ page }) => {
await page.getByText(/AI Tools/).click(); await page.getByText(/AI Tools/).click();
await expect(page.getByText(/Showing 15 of 50 tools/)).toBeVisible(); await expect(page.getByText(/Showing 15 of 51 tools/)).toBeVisible();
await expect(page.getByText("Remove Background")).toBeVisible(); await expect(page.getByText("Remove Background")).toBeVisible();
}); });
test("clicking All resets category filter", async ({ page }) => { test("clicking All resets category filter", async ({ page }) => {
await page.getByText(/AI Tools/).click(); await page.getByText(/AI Tools/).click();
await expect(page.getByText(/Showing 15 of 50 tools/)).toBeVisible(); await expect(page.getByText(/Showing 15 of 51 tools/)).toBeVisible();
await page.getByText(/All \(50\)/).click(); await page.getByText(/All \(51\)/).click();
await expect(page.getByText(/Showing 50 of 50 tools/)).toBeVisible(); await expect(page.getByText(/Showing 51 of 51 tools/)).toBeVisible();
}); });
test("search with no results shows empty state", async ({ page }) => { test("search with no results shows empty state", async ({ page }) => {
const input = page.getByPlaceholder("Search tools..."); const input = page.getByPlaceholder("Search tools...");
await input.fill("xyznonexistent"); await input.fill("xyznonexistent");
await expect(page.getByText("No tools found. Try a different search.")).toBeVisible(); await expect(page.getByText("No tools found. Try a different search.")).toBeVisible();
await expect(page.getByText(/Showing 0 of 50 tools/)).toBeVisible(); await expect(page.getByText(/Showing 0 of 51 tools/)).toBeVisible();
}); });
test("combined search and category filter works", async ({ page }) => { test("combined search and category filter works", async ({ page }) => {
+1 -1
View File
@@ -16,7 +16,7 @@ test.describe("Login Page", () => {
await expect(page.getByRole("button", { name: /login/i })).toBeVisible(); await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
// Right side: marketing text (hidden on mobile, visible on lg+) // Right side: marketing text (hidden on mobile, visible on lg+)
await expect(page.getByText("Your one-stop-shop")).toBeVisible(); await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
}); });
test("username and password inputs start empty", async ({ page }) => { test("username and password inputs start empty", async ({ page }) => {
+2 -2
View File
@@ -70,7 +70,7 @@ test.describe("Responsive - Desktop (1280x720)", () => {
await page.goto("/login"); await page.goto("/login");
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
await expect(page.getByText("Your one-stop-shop")).toBeVisible(); await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
await context.close(); await context.close();
}); });
@@ -405,7 +405,7 @@ test.describe("Responsive - Mobile (375x667)", () => {
await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible();
// Marketing panel is hidden on mobile (lg:flex means only visible at lg+) // Marketing panel is hidden on mobile (lg:flex means only visible at lg+)
await expect(page.getByText("Your one-stop-shop")).not.toBeVisible(); await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+1 -1
View File
@@ -53,7 +53,7 @@ test.describe("Visual Mobile (375x667)", () => {
// Verify stacked layout: marketing panel should be hidden on mobile // Verify stacked layout: marketing panel should be hidden on mobile
await expect(page.getByRole("heading", { name: /login/i })).toBeVisible(); await expect(page.getByRole("heading", { name: /login/i })).toBeVisible();
await expect(page.getByText("Your one-stop-shop")).not.toBeVisible(); await expect(page.getByText("Your images. Stay yours.")).not.toBeVisible();
await takeThemedScreenshots(page, "login-empty"); await takeThemedScreenshots(page, "login-empty");
}); });
+1 -1
View File
@@ -9,7 +9,7 @@ test.describe("Smoke tests", () => {
await expect(page.getByLabel("Password")).toBeVisible(); await expect(page.getByLabel("Password")).toBeVisible();
await expect(page.getByRole("button", { name: /login/i })).toBeVisible(); await expect(page.getByRole("button", { name: /login/i })).toBeVisible();
// Right panel marketing text // Right panel marketing text
await expect(page.getByText("Your one-stop-shop")).toBeVisible(); await expect(page.getByText("Your images. Stay yours.")).toBeVisible();
}); });
test("can log in with admin credentials", async ({ page }) => { test("can log in with admin credentials", async ({ page }) => {
+1 -1
View File
@@ -47,7 +47,7 @@ function generateLlmsTxt(spec: OpenAPISpec): string {
lines.push(`# ${spec.info.title}`); lines.push(`# ${spec.info.title}`);
lines.push(""); lines.push("");
lines.push( lines.push(
"> Self-hosted image processing API with 50 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.", "> Self-hosted image processing API with 51 tools. Resize, compress, convert, remove backgrounds, upscale, run OCR, and more.",
); );
lines.push(""); lines.push("");
lines.push("## Docs"); lines.push("## Docs");
+9 -9
View File
@@ -25,7 +25,7 @@ function getCountText(): string {
describe("BentoGrid", () => { describe("BentoGrid", () => {
it("renders the section heading", () => { it("renders the section heading", () => {
render(<BentoGrid />); render(<BentoGrid />);
expect(screen.getByText("50 tools. Zero cloud dependency.")).toBeDefined(); expect(screen.getByText("51 tools. Zero cloud dependency.")).toBeDefined();
}); });
it("renders the search input", () => { it("renders the search input", () => {
@@ -36,12 +36,12 @@ describe("BentoGrid", () => {
it("shows all tools by default", () => { it("shows all tools by default", () => {
render(<BentoGrid />); render(<BentoGrid />);
const text = getCountText(); const text = getCountText();
expect(text).toMatch(/Showing 50 of 50 tools/); expect(text).toMatch(/Showing 51 of 51 tools/);
}); });
it("renders category filter pills including All", () => { it("renders category filter pills including All", () => {
render(<BentoGrid />); render(<BentoGrid />);
expect(screen.getByText((_, el) => el?.textContent === "All (50)")).toBeDefined(); expect(screen.getByText((_, el) => el?.textContent === "All (51)")).toBeDefined();
expect(screen.getByText(/Essentials/)).toBeDefined(); expect(screen.getByText(/Essentials/)).toBeDefined();
expect(screen.getByText(/AI Tools/)).toBeDefined(); expect(screen.getByText(/AI Tools/)).toBeDefined();
expect(screen.getByText(/Optimization/)).toBeDefined(); expect(screen.getByText(/Optimization/)).toBeDefined();
@@ -53,7 +53,7 @@ describe("BentoGrid", () => {
fireEvent.change(input, { target: { value: "resize" } }); fireEvent.change(input, { target: { value: "resize" } });
expect(screen.getByText("Resize")).toBeDefined(); expect(screen.getByText("Resize")).toBeDefined();
const text = getCountText(); const text = getCountText();
expect(text).toMatch(/Showing \d+ of 50 tools/); expect(text).toMatch(/Showing \d+ of 51 tools/);
expect(screen.queryByText("OCR / Text Extraction")).toBeNull(); expect(screen.queryByText("OCR / Text Extraction")).toBeNull();
}); });
@@ -62,7 +62,7 @@ describe("BentoGrid", () => {
const aiButton = screen.getByText(/AI Tools/); const aiButton = screen.getByText(/AI Tools/);
fireEvent.click(aiButton); fireEvent.click(aiButton);
const text = getCountText(); const text = getCountText();
expect(text).toMatch(/Showing 15 of 50 tools/); expect(text).toMatch(/Showing 15 of 51 tools/);
expect(screen.getByText("Remove Background")).toBeDefined(); expect(screen.getByText("Remove Background")).toBeDefined();
expect(screen.queryByText("Resize")).toBeNull(); expect(screen.queryByText("Resize")).toBeNull();
}); });
@@ -73,7 +73,7 @@ describe("BentoGrid", () => {
fireEvent.change(input, { target: { value: "xyznonexistent" } }); fireEvent.change(input, { target: { value: "xyznonexistent" } });
expect(screen.getByText("No tools found. Try a different search.")).toBeDefined(); expect(screen.getByText("No tools found. Try a different search.")).toBeDefined();
const text = getCountText(); const text = getCountText();
expect(text).toMatch(/Showing 0 of 50 tools/); expect(text).toMatch(/Showing 0 of 51 tools/);
}); });
it("combines search and category filter", () => { it("combines search and category filter", () => {
@@ -89,9 +89,9 @@ describe("BentoGrid", () => {
it("clicking All resets category filter", () => { it("clicking All resets category filter", () => {
render(<BentoGrid />); render(<BentoGrid />);
fireEvent.click(screen.getByText(/AI Tools/)); fireEvent.click(screen.getByText(/AI Tools/));
expect(getCountText()).toMatch(/Showing 15 of 50 tools/); expect(getCountText()).toMatch(/Showing 15 of 51 tools/);
fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (50)")); fireEvent.click(screen.getByText((_, el) => el?.textContent === "All (51)"));
expect(getCountText()).toMatch(/Showing 50 of 50 tools/); expect(getCountText()).toMatch(/Showing 51 of 51 tools/);
}); });
it("renders each tool with name and description", () => { it("renders each tool with name and description", () => {
+2 -4
View File
@@ -41,9 +41,7 @@ describe("Hero", () => {
it("renders the subheadline", () => { it("renders the subheadline", () => {
render(<Hero />); render(<Hero />);
expect( expect(screen.getByText("Every image tool you need.")).toBeDefined();
screen.getByText("The open-source, self-hosted image processing platform."),
).toBeDefined();
}); });
it("renders the CTA button linking to GitHub", () => { it("renders the CTA button linking to GitHub", () => {
@@ -159,7 +157,7 @@ describe("Pricing", () => {
it("renders free plan features", () => { it("renders free plan features", () => {
render(<Pricing />); render(<Pricing />);
expect(screen.getByText("All 48 image processing tools")).toBeDefined(); expect(screen.getByText("All 51 image processing tools")).toBeDefined();
expect(screen.getByText("Unlimited usage, no hidden caps")).toBeDefined(); expect(screen.getByText("Unlimited usage, no hidden caps")).toBeDefined();
expect(screen.getByText("15 local AI models included")).toBeDefined(); expect(screen.getByText("15 local AI models included")).toBeDefined();
expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined(); expect(screen.getByText("AGPL-3.0 licensed")).toBeDefined();