Skip to content
Merged
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
31 changes: 31 additions & 0 deletions docs/performance/performance-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,37 @@ Budgets live in `scripts/gates/performance-budgets.json` for these baseline rout
- `/en/markdown-preview`
- `/en/image-resizer`

## All Tools Discovery Budget

The All Tools page uses a hybrid incremental-rendering strategy instead of list virtualization. Each category group renders up to 6 rich cards by default, then keeps overflow tools as compact, crawlable links behind the same group. This preserves SEO coverage for every tool link while avoiding a full rich-card render for large inventories.

Risk baseline: the catalog already has more than 100 tools and can grow past 200. Rendering every tool as a full card would scale card DOM, badges, descriptions, controls, and hover states linearly with catalog size. At the 300-tool acceptance target, an uncapped group would render 300 rich cards before any user interaction. Issue #244 tracks that risk even when the current page is not yet failing performance budgets.

Current after state:

- `src/features/tool-discovery/all-tools-discovery.tsx` sets `INITIAL_GROUP_TOOL_LIMIT = 6`.
- `tests/component/all-tools-discovery.test.tsx` renders 300 synthetic tools and asserts the collapsed state stays at 6 rich cards plus 294 compact crawlable links.
- The same test verifies filtering preserves the 6-card budget and exact search results collapse to 1 rich card with 0 compact overflow links.
- Rich cards are marked with `data-all-tools-card="true"` and compact SEO links are marked with `data-all-tools-compact-link="true"`.
- Expanded groups use `aria-expanded` and keep the full card list user-triggered.

Latest local after-change route report from `npm run build:app && npm run build:post`:

| Route | JS gzip | JS raw | Scripts | CSS gzip | HTML |
| --- | ---: | ---: | ---: | ---: | ---: |
| `/en/all-tools` | 260.4 KiB / 278.3 KiB | 953.9 KiB / 1001.0 KiB | 19 / 20 | 26.8 KiB / 34.2 KiB | 454.4 KiB / 605.5 KiB |

Route budget for `/en/all-tools` is enforced by `npm run check:performance-budget:report` after `npm run build:app`:

- initial JS gzip: 285000 bytes
- initial JS raw: 1025000 bytes
- initial scripts: 20
- CSS gzip: 35000 bytes
- CSS raw: 220000 bytes
- rendered HTML: 620000 bytes

PRs that materially change All Tools card markup, filters, search, or category rendering should include the before and after `/en/all-tools` row from `npm run check:performance-budget:report` and confirm the 300-tool component budget still passes.

## Updating A Budget

Only update a threshold when the route growth is intentional. The PR should include:
Expand Down
167 changes: 92 additions & 75 deletions src/app/[lang]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import {
Palette,
Network,
TerminalSquare,
Regex,
Calculator,
Share2,
} from "lucide-react"
import { notFound } from "next/navigation"
import { isValidLocale, requireTranslationValue } from "@/core/i18n/i18n"
Expand All @@ -26,13 +23,21 @@ import { ALL_TOOLS_SECTION_ID } from "@/core/routing/all-tools-route"
import { buildHomepageCanonicalUrl, buildLocalizedAlternates } from "@/core/seo/urls"
import { getGrowthIndex } from "@/core/growth/growth-pages"

type FeatureCard = {
key: "privacy" | "speed" | "keyboard" | "tools"
icon: typeof ShieldCheck
title: string
description: string
iconClass: string
}
type FeatureCard = {
key: "privacy" | "speed" | "keyboard" | "tools"
icon: typeof ShieldCheck
title: string
description: string
iconClass: string
}

type ScenarioCard = {
key: "api" | "jwt" | "logs" | "json" | "svg"
toolKey: "http_request_builder" | "jwt_decoder" | "log_scrubber" | "json_formatter" | "svg_optimizer"
slug: string
icon: typeof ShieldCheck
iconClass: string
}

export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
Expand Down Expand Up @@ -108,46 +113,59 @@ export default async function Home({ params }: { params: Promise<{ lang: string
const categoryToolCounts = Object.fromEntries(
registryStats.categories.map((category) => [category.key, category.toolCount])
) as Record<string, number>
const categoryIcons = {
data_code_formats: Braces,
encoding_crypto: KeyRound,
web_api_network: Network,
devops_logs: TerminalSquare,
text_regex: Regex,
images_svg_css: Palette,
generators_calculators: Calculator,
social_metadata: Share2,
} as const
const categoryIconClass = {
data_code_formats: "border-cyan-500/30 bg-cyan-500/12 text-cyan-400",
encoding_crypto: "border-blue-500/30 bg-blue-500/12 text-blue-400",
web_api_network: "border-indigo-500/30 bg-indigo-500/12 text-indigo-400",
devops_logs: "border-emerald-500/30 bg-emerald-500/12 text-emerald-400",
text_regex: "border-violet-500/30 bg-violet-500/12 text-violet-400",
images_svg_css: "border-pink-500/30 bg-pink-500/12 text-pink-400",
generators_calculators: "border-amber-500/30 bg-amber-500/12 text-amber-400",
social_metadata: "border-rose-500/30 bg-rose-500/12 text-rose-400",
} as const
const heroCategoryLinks = categoryLinks.map((item) => ({
...item,
toolCount: categoryToolCounts[item.key] ?? 0,
}))
const categoryNavLabels = Object.fromEntries(
const categoryNavLabels = Object.fromEntries(
categoryLinks.map((item) => [item.key, requireTranslationValue(t.nav[item.key], `nav.${item.key}`)])
) as Record<(typeof MENU_GROUP_DEFS)[number]["key"], string>
const getLocalizedToolTitle = (toolKey: string) =>
requireTranslationValue(localizedTools[toolKey]?.title, `tools.${toolKey}.title`)
const getLocalizedToolDescription = (toolKey: string) =>
requireTranslationValue(localizedTools[toolKey]?.description, `tools.${toolKey}.description`)
const featureCardSurfaceClass = {
privacy: "bg-[linear-gradient(160deg,hsl(189_94%_46%/0.12),transparent_55%)]",
speed: "bg-[linear-gradient(160deg,hsl(148_75%_45%/0.12),transparent_55%)]",
keyboard: "bg-[linear-gradient(160deg,hsl(39_94%_56%/0.13),transparent_55%)]",
tools: "bg-[linear-gradient(160deg,hsl(218_91%_60%/0.12),transparent_55%)]",
} as const

const toolCatalogGroups = categoryLinks.map((category) => {
const menuGroup = menuGroups.find((group) => group.key === category.key)
const featureCardSurfaceClass = {
privacy: "bg-[linear-gradient(160deg,hsl(189_94%_46%/0.12),transparent_55%)]",
speed: "bg-[linear-gradient(160deg,hsl(148_75%_45%/0.12),transparent_55%)]",
keyboard: "bg-[linear-gradient(160deg,hsl(39_94%_56%/0.13),transparent_55%)]",
tools: "bg-[linear-gradient(160deg,hsl(218_91%_60%/0.12),transparent_55%)]",
} as const
const scenarioCards: ScenarioCard[] = [
{
key: "api",
toolKey: "http_request_builder",
slug: "http-request-builder",
icon: Network,
iconClass: "border-indigo-500/30 bg-indigo-500/12 text-indigo-400",
},
{
key: "jwt",
toolKey: "jwt_decoder",
slug: "jwt-decoder",
icon: KeyRound,
iconClass: "border-blue-500/30 bg-blue-500/12 text-blue-400",
},
{
key: "logs",
toolKey: "log_scrubber",
slug: "log-scrubber",
icon: TerminalSquare,
iconClass: "border-emerald-500/30 bg-emerald-500/12 text-emerald-400",
},
{
key: "json",
toolKey: "json_formatter",
slug: "json-formatter",
icon: Braces,
iconClass: "border-cyan-500/30 bg-cyan-500/12 text-cyan-400",
},
{
key: "svg",
toolKey: "svg_optimizer",
slug: "svg-optimizer",
icon: Palette,
iconClass: "border-pink-500/30 bg-pink-500/12 text-pink-400",
},
]

const toolCatalogGroups = categoryLinks.map((category) => {
const menuGroup = menuGroups.find((group) => group.key === category.key)
const groupDescription = categoryDescriptions[`${category.key}_desc`] || t.features.tools_desc
return {
key: category.key,
Expand Down Expand Up @@ -220,37 +238,36 @@ export default async function Home({ params }: { params: Promise<{ lang: string
</div>
</section>

{/* Quick Navigation Cards - Compact Grid */}
<section
className="home-reveal grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
style={{ animationDelay: "80ms" }}
>
{heroCategoryLinks.map((item) => {
const Icon = categoryIcons[item.key]

return (
<Link
key={item.key}
href={`/${locale}/${item.slug}`}
prefetch={false}
className="group relative overflow-hidden rounded-2xl border border-border/70 bg-card/60 p-5 backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:bg-card/80 hover:shadow-xl hover:shadow-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 dark:hover:shadow-black/25"
>
<div className="flex items-center gap-3 mb-2">
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border ${categoryIconClass[item.key]}`}>
<Icon className="h-5 w-5" />
</span>
<div className="flex-1 min-w-0">
<div className="font-semibold text-foreground text-sm">{categoryNavLabels[item.key]}</div>
</div>
<span className="inline-flex min-h-6 items-center rounded-full border border-border/80 bg-background/75 px-2 text-xs font-medium text-muted-foreground">
{item.toolCount}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{categoryDescriptions[`${item.key}_desc`] || ''}
</p>
</Link>
)
{/* Scenario entry points */}
<section
className="home-reveal grid gap-3 sm:grid-cols-2 lg:grid-cols-5"
style={{ animationDelay: "80ms" }}
>
{scenarioCards.map((item) => {
const Icon = item.icon

return (
<Link
key={item.key}
href={`/${locale}/${item.slug}`}
prefetch={false}
className="group relative min-w-0 overflow-hidden rounded-2xl border border-border/70 bg-card/60 p-4 backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:bg-card/80 hover:shadow-xl hover:shadow-black/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 dark:hover:shadow-black/25"
>
<div className="mb-2 flex items-center gap-3">
<span className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border ${item.iconClass}`}>
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="break-words text-sm font-semibold text-foreground">
{getLocalizedToolTitle(item.toolKey)}
</div>
</div>
</div>
<p className="line-clamp-3 text-xs leading-relaxed text-muted-foreground">
{getLocalizedToolDescription(item.toolKey)}
</p>
</Link>
)
})}
</section>

Expand Down
86 changes: 86 additions & 0 deletions tests/guards/home-discovery-acceptance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import fs from "node:fs"
import path from "node:path"
import { describe, expect, it } from "vitest"

const ROOT = process.cwd()

function readSource(relativePath: string): string {
return fs.readFileSync(path.join(ROOT, relativePath), "utf8")
}

describe("home discovery acceptance guard", () => {
it("keeps homepage positioning, primary CTA, trust badges, and follow-up paths above the catalog", () => {
const pageSource = readSource("src/app/[lang]/page.tsx")
const enCopy = JSON.parse(readSource("src/core/i18n/translations/en.json"))
const metadataGuard = readSource("tests/guards/home-metadata-guard.test.ts")

expect(pageSource).toContain("{t.site.hero_badge}")
expect(pageSource).toContain("{t.site.hero_subtitle}")
expect(pageSource).toContain("const heroSearchLabel = t.site.hero_search")
expect(pageSource).toContain("<SearchButton label={heroSearchLabel} />")
expect(pageSource).toContain('href={`/${locale}/pipeline-builder`}')
expect(pageSource).toContain('href={`/${locale}/install-app`}')
expect(pageSource).toContain('href={`/${locale}/compare`}')
expect(pageSource).toContain("featureCards")
expect(pageSource).toContain('key: "privacy"')
expect(pageSource).toContain("t.features.privacy_title")
expect(pageSource).toContain("t.features.privacy_desc")

expect(enCopy.site.hero_badge).toMatch(/Open Source/i)
expect(enCopy.site.hero_badge).toMatch(/Browser-local/i)
expect(enCopy.site.hero_subtitle).toMatch(/without sending data to servers/i)
expect(enCopy.site.hero_subtitle).toMatch(/stays in your browser/i)
expect(enCopy.site.hero_search).toBe("Browse all tools")
expect(enCopy.features.privacy_desc).toMatch(/opaque services/i)
expect(enCopy.features.privacy_desc).toMatch(/network when you explicitly run/i)
expect(metadataGuard).toContain("getTranslation(\"fr\").site.title")
})

it("links concrete developer scenarios to the expected tools using localized tool copy", () => {
const pageSource = readSource("src/app/[lang]/page.tsx")

expect(pageSource).toContain("type ScenarioCard")
expect(pageSource).toContain("const scenarioCards: ScenarioCard[]")
expect(pageSource).toContain("getLocalizedToolTitle(item.toolKey)")
expect(pageSource).toContain("getLocalizedToolDescription(item.toolKey)")

for (const [toolKey, slug] of [
["http_request_builder", "http-request-builder"],
["jwt_decoder", "jwt-decoder"],
["log_scrubber", "log-scrubber"],
["json_formatter", "json-formatter"],
["svg_optimizer", "svg-optimizer"],
] as const) {
expect(pageSource).toContain(`toolKey: "${toolKey}"`)
expect(pageSource).toContain(`slug: "${slug}"`)
}

expect(pageSource).toMatch(/scenarioCards\.map[\s\S]*href=\{`\/\$\{locale\}\/\$\{item\.slug\}`\}/)
})

it("keeps All Tools discovery incrementally rendered, documented, and crawlable", () => {
const allToolsSource = readSource("src/features/tool-discovery/all-tools-discovery.tsx")
const componentTestSource = readSource("tests/component/all-tools-discovery.test.tsx")
const budgetDoc = readSource("docs/performance/performance-budget.md")

expect(allToolsSource).toContain("INITIAL_GROUP_TOOL_LIMIT = 6")
expect(allToolsSource).toContain("visibleTools = hasOverflowTools && !isExpanded")
expect(allToolsSource).toContain("compactTools = hasOverflowTools && !isExpanded")
expect(allToolsSource).toContain('data-all-tools-card="true"')
expect(allToolsSource).toContain('data-all-tools-compact-link="true"')
expect(allToolsSource).toContain("aria-expanded={isExpanded}")

expect(componentTestSource).toContain("LARGE_INVENTORY_TOOL_COUNT = 300")
expect(componentTestSource).toContain("LARGE_INVENTORY_CARD_BUDGET = 6")
expect(componentTestSource).toContain("LARGE_INVENTORY_COMPACT_LINK_BUDGET")
expect(componentTestSource).toContain("Synthetic Tool 299")

expect(budgetDoc).toContain("All Tools Discovery Budget")
expect(budgetDoc).toContain("Risk baseline")
expect(budgetDoc).toContain("Current after state")
expect(budgetDoc).toContain("300 synthetic tools")
expect(budgetDoc).toContain("6 rich cards")
expect(budgetDoc).toContain("294 compact crawlable links")
expect(budgetDoc).toContain("/en/all-tools")
})
})