From 79bc2a883939c2e2a0c4e28338bd361cdee6f1b5 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Sat, 27 Jun 2026 07:14:15 +0800 Subject: [PATCH] Improve home discovery acceptance --- docs/performance/performance-budget.md | 31 ++++ src/app/[lang]/page.tsx | 167 ++++++++++-------- .../guards/home-discovery-acceptance.test.ts | 86 +++++++++ 3 files changed, 209 insertions(+), 75 deletions(-) create mode 100644 tests/guards/home-discovery-acceptance.test.ts diff --git a/docs/performance/performance-budget.md b/docs/performance/performance-budget.md index 55c2f85a..bb2d81b6 100644 --- a/docs/performance/performance-budget.md +++ b/docs/performance/performance-budget.md @@ -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: diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index b1d71959..04c95c9a 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -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" @@ -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 { const { lang } = await params @@ -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 - 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, @@ -220,37 +238,36 @@ export default async function Home({ params }: { params: Promise<{ lang: string - {/* Quick Navigation Cards - Compact Grid */} -
- {heroCategoryLinks.map((item) => { - const Icon = categoryIcons[item.key] - - return ( - -
- - - -
-
{categoryNavLabels[item.key]}
-
- - {item.toolCount} - -
-

- {categoryDescriptions[`${item.key}_desc`] || ''} -

- - ) + {/* Scenario entry points */} +
+ {scenarioCards.map((item) => { + const Icon = item.icon + + return ( + +
+ + + +
+
+ {getLocalizedToolTitle(item.toolKey)} +
+
+
+

+ {getLocalizedToolDescription(item.toolKey)} +

+ + ) })}
diff --git a/tests/guards/home-discovery-acceptance.test.ts b/tests/guards/home-discovery-acceptance.test.ts new file mode 100644 index 00000000..5501c4fe --- /dev/null +++ b/tests/guards/home-discovery-acceptance.test.ts @@ -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("") + 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") + }) +})