From 8e16fd67a71475b68be33ff0d3b39e2314775a37 Mon Sep 17 00:00:00 2001 From: Rezn1r Date: Fri, 22 May 2026 17:37:30 +0800 Subject: [PATCH 1/6] refactor: replace framer-motion with CSS animations and IntersectionObserver hook - Create lightweight useInView hook (~20 lines) using IntersectionObserver - Rewrite FadeIn to use CSS transitions triggered by useInView - Rewrite StaggerContainer to use CSS @keyframes with staggered animation-delay - Replace motion.a in PluginCardGrid with Next.js Link for proper client-side navigation - Replace AnimatePresence/motion.div in NavbarClient with CSS max-height transition - Replace AnimatePresence/motion.div in FAQ accordion with CSS max-height/opacity transition - Add fadeSlideUp and fadeIn @keyframes to globals.css - Remove framer-motion dependency (~30KB gzipped savings) --- .kilo/plans/1779442136201-nimble-star.md | 176 +++++++++++++++++++++++ package.json | 1 - pnpm-lock.yaml | 54 ++----- src/app/dashboard/dev/page.tsx | 4 +- src/app/faq/page.tsx | 57 ++++---- src/app/globals.css | 21 +++ src/app/plugins/[slug]/page.tsx | 1 - src/components/FadeIn.tsx | 52 +++---- src/components/LiveBuildLog.tsx | 3 +- src/components/NavbarClient.tsx | 57 ++++---- src/components/PluginCardGrid.tsx | 14 +- src/components/StaggerContainer.tsx | 75 +++------- src/hooks/useInView.ts | 28 ++++ 13 files changed, 335 insertions(+), 208 deletions(-) create mode 100644 .kilo/plans/1779442136201-nimble-star.md create mode 100644 src/hooks/useInView.ts diff --git a/.kilo/plans/1779442136201-nimble-star.md b/.kilo/plans/1779442136201-nimble-star.md new file mode 100644 index 0000000..4e0d34a --- /dev/null +++ b/.kilo/plans/1779442136201-nimble-star.md @@ -0,0 +1,176 @@ +# Performance & UX Improvement Plan + +## Overview + +Next.js 14 app (EndGit — plugin marketplace). Two categories of work: **bundle/runtime performance** and **UX polish**. + +--- + +## 1. Performance — Bundle Size & Loading + +### 1A. Replace framer-motion with CSS animations + lightweight IntersectionObserver hook + +**Impact: HIGH** — framer-motion is ~30KB gzipped and is imported by `FadeIn`, `StaggerContainer`, `PluginCardGrid`, and every page that uses them. + +- Create a `useInView` hook (lightweight, ~50 lines, no dependency) +- Create CSS-only `FadeIn` and `StaggerContainer` components using CSS `@keyframes` + the `useInView` hook to add a class when in viewport +- Update `PluginCardGrid` to use CSS transitions instead of `motion.a` +- Remove `framer-motion` from `package.json` + +**Files:** `src/components/FadeIn.tsx`, `src/components/StaggerContainer.tsx`, `src/components/PluginCardGrid.tsx`, `src/components/HomeContent.tsx`, `package.json` + +### 1B. Optimize react-syntax-highlighter imports + +**Impact: MEDIUM** — Prism + all languages + vscDarkPlus theme is a large bundle (~200KB+). + +- Switch to `react-syntax-highlighter/dist/esm/light` (lighter build) +- Only register the languages actually needed (e.g., `python`, `cpp`, `javascript`, `typescript`, `bash`, `json`) +- Use `AsyncLight` build or dynamic imports for the syntax highlighter + +**Files:** `src/components/MarkdownTabs.tsx` + +### 1C. Convert LatestPluginsSection to a server component + +**Impact: MEDIUM** — Currently a client component that fetches via `useEffect`, meaning zero data on initial HTML, waterfall fetch, and unnecessary JS shipped. + +- Remove `"use client"` from `LatestPluginsSection` +- Fetch data directly with `fetch()` (server-side) using `next: { revalidate: 60 }` +- Pass data as a server component rendered in `HomeContent` (or move the fetch into `page.tsx` and pass as prop) +- Remove client-side `useState`/`useEffect` for data fetching + +**Files:** `src/components/LatestPluginsSection.tsx`, `src/components/HomeContent.tsx`, `src/app/page.tsx` + +### 1D. Use Next.js Image component in PluginImage + +**Impact: LOW-MEDIUM** — Currently uses raw `` with manual `Image()` preloading. Bypasses Next.js image optimization (automatic WebP/AVIF, responsive srcset, lazy loading). + +- Replace `` with Next.js `` component +- Add the remote patterns already configured in `next.config.mjs` +- Use `onError` fallback instead of manual preloading + +**Files:** `src/components/PluginImage.tsx`, `next.config.mjs` (already configured) + +### 1E. Prefetch plugin detail pages on card hover + +**Impact: LOW** — Navigation to plugin detail pages feels slow because the page isn't prefetched. + +- Add `prefetch={true}` to plugin card `` components (Next.js default is true for visible links, but verify) +- Consider using `router.prefetch()` on hover for cards using `` (will be plain `` after 1A) + +**Files:** `src/components/PluginCardGrid.tsx`, `src/components/LatestPluginsSection.tsx` + +### 1F. Deduplicate plugin data fetch on detail page + +**Impact: LOW** — `generateMetadata` and the page component both call `getPlugin()`, causing two identical API requests. + +- Use `React.cache()` or fetch once and pass through +- Or use Next.js `fetch` cache deduplication (automatic in Next.js 14 for same URL + same options) + +**Files:** `src/app/plugins/[slug]/page.tsx` + +--- + +## 2. Performance — Runtime & Perceived Speed + +### 2A. Add Suspense boundaries for client components on plugin detail page + +**Impact: MEDIUM** — `PluginAnalyticsChart`, `PluginRatings`, `DependencyGraph`, `VirusTotalCard` are all loaded via `dynamic()` but rendered synchronously in the component tree. Adding `` lets the page paint before these finish loading. + +- Wrap each dynamic component in `` +- Move dynamic imports above the component or use proper streaming + +**Files:** `src/app/plugins/[slug]/page.tsx` + +### 2B. Reduce search debounce from 500ms to 300ms + +**Impact: LOW** — 500ms feels sluggish. 300ms is standard for search-as-you-type. + +**Files:** `src/components/PluginSearch.tsx` + +### 2C. Inline critical CSS / preload Inter font + +**Impact: LOW** — The Inter font is loaded via `next/font/google` which is already optimized. Verify no layout shift. + +No changes needed — already handled by Next.js. + +--- + +## 3. UX Improvements + +### 3A. Terminal animation: don't replay on re-visit + +**Impact: MEDIUM** — The typewriter animation replays every time the user navigates back to the home page. Store a `sessionStorage` flag to skip animation on subsequent visits and show the completed state immediately. + +**Files:** `src/components/HomeContent.tsx` (TerminalMock) + +### 3B. Add visible focus indicators for keyboard navigation + +**Impact: MEDIUM** — Currently no `:focus-visible` styles. Users navigating with keyboard can't see which element is focused. + +- Add `:focus-visible` outline styles to `.btn`, `.input`, `.card`, links, and interactive elements +- Use `outline: 2px solid var(--accent-primary)` with `outline-offset: 2px` + +**Files:** `src/app/globals.css` + +### 3C. Improve error states + +**Impact: LOW-MEDIUM** — `LatestPluginsSection` silently returns `null` on error. `PluginAnalyticsChart` shows generic text. Show retry buttons and more informative messages. + +**Files:** `src/components/LatestPluginsSection.tsx`, `src/components/PluginAnalyticsChart.tsx` + +### 3D. Add loading="lazy" to below-fold images + +**Impact: LOW** — Plugin card images already use `loading="lazy"` in PluginImage. Verify all images are optimized. + +Already handled — no changes needed. + +### 3E. Improve mobile touch targets + +**Impact: LOW** — Pagination buttons and some interactive elements may be smaller than 44px on mobile. + +- Audit pagination controls, sort options, filter buttons +- Ensure minimum 44px touch targets per WCAG guidelines + +**Files:** `src/app/plugins/page.tsx`, `src/app/globals.css` + +### 3F. Add smooth scroll-to-top on pagination + +**Impact: LOW** — When clicking pagination, the page should scroll to the top of the plugin grid. + +**Files:** `src/app/plugins/page.tsx` (add `scroll: true` to router.push or use anchor links) + +--- + +## Implementation Order (by impact) + +1. **1A** — Replace framer-motion (biggest bundle win) +2. **1C** — Convert LatestPluginsSection to server component +3. **1B** — Optimize syntax highlighter +4. **3A** — Terminal animation persistence +5. **2A** — Add Suspense boundaries +6. **3B** — Focus indicators +7. **1D** — Next.js Image in PluginImage +8. **3C** — Error states +9. **2B** — Search debounce +10. **1E** — Link prefetching +11. **1F** — Deduplicate fetch +12. **3E** — Touch targets +13. **3F** — Scroll on pagination + +--- + +## Git Workflow + +- Create branch `refactor/performance-ux-improvements` from current branch before starting +- Run `pnpm run format && pnpm lint` before every commit +- Commit changes incrementally (one commit per logical group, e.g. "refactor: replace framer-motion with CSS animations") +- Do NOT touch unrelated files — only modify files listed in each step +- Push branch when complete + +## Verification + +- Run `pnpm build` to verify no build errors +- Run `pnpm lint` to verify no lint errors +- Run `pnpm type-check` to verify TypeScript +- Check bundle size with `npm run build` output (look at "First Load JS" per route) +- Manual test: home page, plugins list, plugin detail, mobile menu, theme toggle diff --git a/package.json b/package.json index 498a950..3447d71 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "dependencies": { "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", - "framer-motion": "^12.38.0", "lucide-react": "^1.11.0", "next": "14.2.35", "next-auth": "^4.24.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a19fa61..51183f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - framer-motion: - specifier: ^12.38.0 - version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^1.11.0 version: 1.14.0(react@18.3.1) @@ -1066,20 +1063,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - framer-motion@12.38.0: - resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1608,12 +1591,6 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - motion-dom@12.38.0: - resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} - - motion-utils@12.36.0: - resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3046,8 +3023,8 @@ snapshots: '@typescript-eslint/parser': 8.59.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -3066,7 +3043,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -3077,22 +3054,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.59.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -3103,7 +3080,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -3288,15 +3265,6 @@ snapshots: format@0.2.2: {} - framer-motion@12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - motion-dom: 12.38.0 - motion-utils: 12.36.0 - tslib: 2.8.1 - optionalDependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - fs.realpath@1.0.0: {} function-bind@1.1.2: {} @@ -4096,12 +4064,6 @@ snapshots: minipass@7.1.3: {} - motion-dom@12.38.0: - dependencies: - motion-utils: 12.36.0 - - motion-utils@12.36.0: {} - ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/src/app/dashboard/dev/page.tsx b/src/app/dashboard/dev/page.tsx index f6ef473..966300e 100644 --- a/src/app/dashboard/dev/page.tsx +++ b/src/app/dashboard/dev/page.tsx @@ -1558,7 +1558,9 @@ export default function DevDashboardPage() {
- - {open && ( - -
- {item.a} -
-
- )} -
+
+
+ {item.a} +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 0af4e35..9186abc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -912,6 +912,27 @@ ol { background: rgba(255, 255, 255, 0.2); } +/* ── Entry Animations ── */ +@keyframes fadeSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + /* ── Reduced Motion ── */ @media (prefers-reduced-motion: reduce) { *, diff --git a/src/app/plugins/[slug]/page.tsx b/src/app/plugins/[slug]/page.tsx index 2b81160..ccb0f2b 100644 --- a/src/app/plugins/[slug]/page.tsx +++ b/src/app/plugins/[slug]/page.tsx @@ -1103,7 +1103,6 @@ export default async function PluginDetailPage({ {/* Dependency Graph */} - diff --git a/src/components/FadeIn.tsx b/src/components/FadeIn.tsx index 35ddf93..1068f82 100644 --- a/src/components/FadeIn.tsx +++ b/src/components/FadeIn.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; +import { useInView } from "@/hooks/useInView"; import type { ReactNode } from "react"; interface FadeInProps { @@ -13,12 +13,12 @@ interface FadeInProps { style?: React.CSSProperties; } -const directionMap = { - up: { y: 20, x: 0 }, - down: { y: -20, x: 0 }, - left: { x: 20, y: 0 }, - right: { x: -20, y: 0 }, - none: { x: 0, y: 0 }, +const directionStyles = { + up: (d: number) => ({ transform: `translateY(${d}px)` }), + down: (d: number) => ({ transform: `translateY(-${d}px)` }), + left: (d: number) => ({ transform: `translateX(${d}px)` }), + right: (d: number) => ({ transform: `translateX(-${d}px)` }), + none: () => ({ transform: "scale(0.97)" }), }; export default function FadeIn({ @@ -30,37 +30,23 @@ export default function FadeIn({ className, style, }: FadeInProps) { - const prefersReducedMotion = useReducedMotion(); - const offset = directionMap[direction]; - const scale = direction === "none" ? 0.97 : 1; + const { ref, inView } = useInView(); - if (prefersReducedMotion) { - return ( -
- {children} -
- ); - } + const initialTransform = directionStyles[direction](distance); return ( - 0 ? distance : -distance) : 0, - y: offset.y !== 0 ? (offset.y > 0 ? distance : -distance) : 0, - scale, - }} - whileInView={{ opacity: 1, x: 0, y: 0, scale: 1 }} - viewport={{ once: true, margin: "-40px" }} - transition={{ - duration, - delay, - ease: [0.25, 0.1, 0.25, 1], - }} +
{children} - +
); } diff --git a/src/components/LiveBuildLog.tsx b/src/components/LiveBuildLog.tsx index b5e8b3a..c98371f 100644 --- a/src/components/LiveBuildLog.tsx +++ b/src/components/LiveBuildLog.tsx @@ -93,8 +93,7 @@ export default function LiveBuildLog({ fontWeight = "600"; } else if (line.includes("->")) color = "#94a3b8"; // muted slate-400 for sub-steps - else if (line.includes("Security Scan")) - color = "#c084fc"; // purple for security + else if (line.includes("Security Scan")) color = "#c084fc"; // purple for security return (
- - {dropdownOpen && ( - -
- {menuItems} -
- {signOutButton} -
- - )} - +
+
+ {menuItems} +
+ {signOutButton} +
+
); } @@ -344,6 +340,7 @@ export default function NavbarClient({ borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-lg)", overflow: "hidden", + animation: "fadeIn 0.15s ease", }} >
)} - + ); })}
diff --git a/src/components/StaggerContainer.tsx b/src/components/StaggerContainer.tsx index 89532ee..35a97da 100644 --- a/src/components/StaggerContainer.tsx +++ b/src/components/StaggerContainer.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion, useReducedMotion } from "framer-motion"; -import type { ReactNode } from "react"; +import { useInView } from "@/hooks/useInView"; +import { Children, cloneElement, isValidElement, type ReactNode } from "react"; interface StaggerContainerProps { children: ReactNode; @@ -16,34 +16,24 @@ export default function StaggerContainer({ className, style, }: StaggerContainerProps) { - const prefersReducedMotion = useReducedMotion(); - - if (prefersReducedMotion) { - return ( -
- {children} -
- ); - } + const { ref, inView } = useInView(); + + const indexedChildren = inView + ? Children.map(children, (child, i) => { + if (!isValidElement(child)) return child; + return cloneElement(child as React.ReactElement, { + style: { + ...(child.props.style || {}), + animation: `fadeSlideUp 0.45s cubic-bezier(0.25, 0.1, 0.25, 1) ${i * staggerDelay}s both`, + }, + }); + }) + : null; return ( - - {children} - +
+ {indexedChildren} +
); } @@ -56,34 +46,9 @@ export function StaggerItem({ className?: string; style?: React.CSSProperties; }) { - const prefersReducedMotion = useReducedMotion(); - - if (prefersReducedMotion) { - return ( -
- {children} -
- ); - } - return ( - +
{children} - +
); } diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts new file mode 100644 index 0000000..dd96c59 --- /dev/null +++ b/src/hooks/useInView.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export function useInView(options?: IntersectionObserverInit) { + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.disconnect(); + } + }, + { threshold: 0, rootMargin: "-40px", ...options }, + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [options]); + + return { ref, inView }; +} From 5afcd789650d02230dff1e515daf1844eaade5d2 Mon Sep 17 00:00:00 2001 From: Rezn1r Date: Fri, 22 May 2026 17:39:12 +0800 Subject: [PATCH 2/6] refactor: convert LatestPluginsSection to server component - Remove client-side useState/useEffect data fetching - Fetch data server-side with fetch() using next: { revalidate: 60 } - Move LatestPluginsSection rendering from HomeContent to page.tsx - Eliminates waterfall fetch and ships zero extra JS for data fetching --- src/app/page.tsx | 8 +- src/components/HomeContent.tsx | 4 - src/components/LatestPluginsSection.tsx | 353 +++++++++++------------- 3 files changed, 173 insertions(+), 192 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index bba14aa..4cff412 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import HomeContent from "@/components/HomeContent"; +import LatestPluginsSection from "@/components/LatestPluginsSection"; import type { Metadata } from "next"; export const metadata: Metadata = { @@ -27,5 +28,10 @@ async function StatsSection() { } export default function Home() { - return ; + return ( + <> + + + + ); } diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index e8a2444..2b3ad0c 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -15,7 +15,6 @@ import { } from "lucide-react"; import Link from "next/link"; import AnimatedNumber from "@/components/AnimatedNumber"; -import LatestPluginsSection from "@/components/LatestPluginsSection"; import FadeIn from "@/components/FadeIn"; import StaggerContainer, { StaggerItem } from "@/components/StaggerContainer"; @@ -564,9 +563,6 @@ export default function HomeContent({ stats }: HomeContentProps) { - {/* ── Recent Releases ── */} - - {/* ── Features ── */}
([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - async function fetchPlugins() { - try { - const apiUrl = - process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; - const res = await fetch( - `${apiUrl}/api/v1/plugins/latest?page=1&pageSize=6`, - ); - const json = await res.json(); - if (json?.success && json?.data?.plugins) { - setPlugins(json.data.plugins); - } else { - setError(true); - } - } catch { - setError(true); - } finally { - setLoading(false); - } +export default async function LatestPluginsSection() { + let plugins: Plugin[] = []; + try { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"; + const res = await fetch( + `${apiUrl}/api/v1/plugins/latest?page=1&pageSize=6`, + { next: { revalidate: 60 } }, + ); + const json = await res.json(); + if (json?.success && json?.data?.plugins) { + plugins = json.data.plugins; } - fetchPlugins(); - }, []); + } catch { + return null; + } + + if (plugins.length === 0) return null; - if (error && plugins.length === 0) return null; + const VERIFIED_ORGS = ["EndstoneMC", "two-tech-dev"]; return (
@@ -99,196 +85,189 @@ export default function LatestPluginsSection() {
- {!loading && ( - <> - - {plugins.map((plugin) => { - const avgRating = plugin.stars - ? Math.round((plugin.stars / 20) * 10) / 10 - : 0; - const repoOwner = - plugin.repoUrl?.match(/github\.com\/([^/]+)/)?.[1]; - const isVerified = repoOwner - ? VERIFIED_ORGS.includes(repoOwner) - : false; + + {plugins.map((plugin) => { + const avgRating = plugin.stars + ? Math.round((plugin.stars / 20) * 10) / 10 + : 0; + const repoOwner = plugin.repoUrl?.match(/github\.com\/([^/]+)/)?.[1]; + const isVerified = repoOwner + ? VERIFIED_ORGS.includes(repoOwner) + : false; - return ( - - + +
+
+ +
+ +
+

+ {plugin.displayName} + {isVerified && ( + + + + )} +

- -
- -
-

v{plugin.latestVersion || "1.0.0"} + - {plugin.displayName} - {isVerified && ( - - - + {plugin.repoUrl?.match(/github\.com\/([^/]+)/)?.[1] || + plugin.author?.displayName || + plugin.author?.username} + +

+
+ + {new Date(plugin.createdAt || "").toLocaleDateString( + "en-GB", + { + day: "2-digit", + month: "short", + year: "numeric", + }, )} - -
-
+ + {plugin.downloads?.toLocaleString() ?? 0} downloads + + {avgRating > 0 && ( + - v{plugin.latestVersion || "1.0.0"} + {"★".repeat(Math.round(avgRating))} + {"☆".repeat(5 - Math.round(avgRating))} - {plugin.repoUrl?.match( - /github\.com\/([^/]+)/, - )?.[1] || - plugin.author?.displayName || - plugin.author?.username} - -
-
- - {new Date( - plugin.createdAt || "", - ).toLocaleDateString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - })} - - - {plugin.downloads?.toLocaleString() ?? 0}{" "} - downloads + ({avgRating}) - {avgRating > 0 && ( - - {"★".repeat(Math.round(avgRating))} - {"☆".repeat(5 - Math.round(avgRating))} - - ({avgRating}) - - - )} -
-
+
+ )}
- - - ); - })} - - - )} +
+
+ +
+ ); + })} +
); } From 70ec7f2a3f7d1e817bf57c8a4d6bafc8df9e819a Mon Sep 17 00:00:00 2001 From: Rezn1r Date: Fri, 22 May 2026 17:39:50 +0800 Subject: [PATCH 3/6] perf: switch react-syntax-highlighter to light build with explicit language registration - Import Light build instead of full Prism build (~200KB+ savings) - Register only needed languages: python, cpp, javascript, typescript, bash, json, yaml, markdown, css, clike - Import individual vsc-dark-plus theme instead of full styles bundle --- src/components/MarkdownTabs.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/MarkdownTabs.tsx b/src/components/MarkdownTabs.tsx index 9399fcf..f08c18c 100644 --- a/src/components/MarkdownTabs.tsx +++ b/src/components/MarkdownTabs.tsx @@ -5,8 +5,29 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; +import vscDarkPlus from "react-syntax-highlighter/dist/esm/styles/prism/vsc-dark-plus"; +import python from "react-syntax-highlighter/dist/esm/languages/prism/python"; +import cpp from "react-syntax-highlighter/dist/esm/languages/prism/cpp"; +import javascript from "react-syntax-highlighter/dist/esm/languages/prism/javascript"; +import typescript from "react-syntax-highlighter/dist/esm/languages/prism/typescript"; +import bash from "react-syntax-highlighter/dist/esm/languages/prism/bash"; +import json from "react-syntax-highlighter/dist/esm/languages/prism/json"; +import yaml from "react-syntax-highlighter/dist/esm/languages/prism/yaml"; +import markdown from "react-syntax-highlighter/dist/esm/languages/prism/markdown"; +import css from "react-syntax-highlighter/dist/esm/languages/prism/css"; +import clike from "react-syntax-highlighter/dist/esm/languages/prism/clike"; + +SyntaxHighlighter.registerLanguage("python", python); +SyntaxHighlighter.registerLanguage("cpp", cpp); +SyntaxHighlighter.registerLanguage("javascript", javascript); +SyntaxHighlighter.registerLanguage("typescript", typescript); +SyntaxHighlighter.registerLanguage("bash", bash); +SyntaxHighlighter.registerLanguage("json", json); +SyntaxHighlighter.registerLanguage("yaml", yaml); +SyntaxHighlighter.registerLanguage("markdown", markdown); +SyntaxHighlighter.registerLanguage("css", css); +SyntaxHighlighter.registerLanguage("clike", clike); interface Tab { title: string; From e98b0c6da4c82d8e543704eb962d7ae547c38ddc Mon Sep 17 00:00:00 2001 From: Rezn1r Date: Fri, 22 May 2026 17:42:41 +0800 Subject: [PATCH 4/6] perf/ux: batch improvements for runtime, UX, and accessibility - Terminal animation: skip replay on re-visit using sessionStorage flag - PluginImage: replace manual Image() preloading with Next.js Image component - Plugin detail: add loading fallbacks to DependencyGraph, PluginRatings, VirusTotalCard - Plugin detail: deduplicate getPlugin() calls with React.cache() - PluginAnalyticsChart: add retry button on error state - PluginSearch: reduce debounce from 500ms to 300ms - Focus indicators: add :focus-visible outline styles for keyboard navigation - Pagination: increase touch targets to 44px minimum (WCAG) - Pagination: add #plugin-grid anchor for scroll-to-top on page change --- src/app/globals.css | 11 +++++ src/app/plugins/[slug]/page.tsx | 53 +++++++++++++++++++- src/app/plugins/page.tsx | 14 ++++-- src/components/HomeContent.tsx | 12 +++++ src/components/PluginAnalyticsChart.tsx | 65 ++++++++++++++++--------- src/components/PluginImage.tsx | 62 ++++++++--------------- src/components/PluginSearch.tsx | 2 +- 7 files changed, 149 insertions(+), 70 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 9186abc..7912c8f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -478,6 +478,17 @@ ol { color: var(--text-muted); } +/* ── Focus Indicators ── */ +.btn:focus-visible, +.input:focus-visible, +.card:focus-visible, +a:focus-visible, +button:focus-visible, +[role="button"]:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + /* ── Mobile Responsive ─────────────────────────────────────── */ @media (max-width: 768px) { diff --git a/src/app/plugins/[slug]/page.tsx b/src/app/plugins/[slug]/page.tsx index ccb0f2b..96eebc2 100644 --- a/src/app/plugins/[slug]/page.tsx +++ b/src/app/plugins/[slug]/page.tsx @@ -14,6 +14,7 @@ import { BadgeCheck, } from "lucide-react"; import dynamic from "next/dynamic"; +import { cache } from "react"; import Image from "next/image"; import PluginImage from "@/components/PluginImage"; import VersionSelector from "@/components/VersionSelector"; @@ -60,20 +61,68 @@ const PluginAnalyticsChart = dynamic( ); const DependencyGraph = dynamic(() => import("@/components/DependencyGraph"), { ssr: false, + loading: () => ( +
+

+ Loading dependency graph... +

+
+ ), }); const PluginRatings = dynamic(() => import("@/components/PluginRatings"), { ssr: false, + loading: () => ( +
+

+ Loading ratings... +

+
+ ), }); const VirusTotalCard = dynamic(() => import("@/components/VirusTotalCard"), { ssr: false, + loading: () => ( +
+

+ Loading security scan... +

+
+ ), }); -async function getPlugin(slug: string) { +const getPlugin = cache(async function getPlugin(slug: string) { const { data } = await fetchApi(`/api/v1/plugins/${slug}`, { revalidate: 30, }); return data?.data || null; -} +}); export async function generateMetadata({ params, diff --git a/src/app/plugins/page.tsx b/src/app/plugins/page.tsx index f16d63e..abf5234 100644 --- a/src/app/plugins/page.tsx +++ b/src/app/plugins/page.tsx @@ -61,7 +61,7 @@ export default async function PluginsPage({ p.set("category", searchParams.category as string); if (searchParams.sort) p.set("sort", searchParams.sort as string); if (searchParams.type) p.set("type", searchParams.type as string); - return `/plugins?${p.toString()}`; + return `/plugins?${p.toString()}#plugin-grid`; } // Generate page numbers to display @@ -127,7 +127,7 @@ export default async function PluginsPage({ {/* Plugin Grid */} -
+
{/* Pagination Controls */} @@ -151,6 +151,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-card)", @@ -170,6 +171,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-secondary)", @@ -203,6 +205,10 @@ export default async function PluginsPage({ href={pageUrl(p)} style={{ padding: "0.5rem 0.75rem", + minHeight: "44px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", borderRadius: "var(--radius-md)", border: p === currentPage @@ -220,7 +226,7 @@ export default async function PluginsPage({ fontWeight: p === currentPage ? 700 : 500, textDecoration: "none", transition: "all 150ms", - minWidth: "38px", + minWidth: "44px", textAlign: "center", }} > @@ -238,6 +244,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-card)", @@ -257,6 +264,7 @@ export default async function PluginsPage({ alignItems: "center", gap: "4px", padding: "0.5rem 0.75rem", + minHeight: "44px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-color)", background: "var(--bg-secondary)", diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index 2b3ad0c..ac8e891 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -40,6 +40,17 @@ function TerminalMock() { const timerRef = useRef[]>([]); useEffect(() => { + if ( + typeof window !== "undefined" && + sessionStorage.getItem("terminal-seen") + ) { + setTypedChars(COMMAND_TEXT.length); + setVisibleLines(OUTPUT_LINES.length); + setShowCursor(false); + setShowPrompt(true); + return; + } + const t = timerRef.current; t.length = 0; @@ -63,6 +74,7 @@ function TerminalMock() { setTimeout(() => { setShowCursor(false); setShowPrompt(true); + sessionStorage.setItem("terminal-seen", "1"); }, offset), ); diff --git a/src/components/PluginAnalyticsChart.tsx b/src/components/PluginAnalyticsChart.tsx index 6302bfd..39d23a5 100644 --- a/src/components/PluginAnalyticsChart.tsx +++ b/src/components/PluginAnalyticsChart.tsx @@ -21,32 +21,36 @@ interface AnalyticsData { export default function PluginAnalyticsChart({ slug }: { slug: string }) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); - useEffect(() => { - async function fetchAnalytics() { - try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/api/v1/plugins/${slug}/analytics`, + const fetchAnalytics = async () => { + setLoading(true); + setError(false); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000"}/api/v1/plugins/${slug}/analytics`, + ); + const json = await res.json(); + if (json.success && json.data?.length > 0) { + setData( + json.data.map((d: any) => ({ + date: new Date(d.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }), + downloads: d.downloads, + views: d.views || 0, + })), ); - const json = await res.json(); - if (json.success && json.data?.length > 0) { - setData( - json.data.map((d: any) => ({ - date: new Date(d.date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }), - downloads: d.downloads, - views: d.views || 0, - })), - ); - } - } catch { - // No data available - } finally { - setLoading(false); } + } catch { + setError(true); + } finally { + setLoading(false); } + }; + + useEffect(() => { fetchAnalytics(); }, [slug]); @@ -81,8 +85,23 @@ export default function PluginAnalyticsChart({ slug }: { slug: string }) { style={{ margin: "0 auto var(--space-2)" }} />

- No download analytics available yet + {error + ? "Failed to load analytics data." + : "No download analytics available yet"}

+ {error && ( + + )}
); } diff --git a/src/components/PluginImage.tsx b/src/components/PluginImage.tsx index 5b81806..2875cc1 100644 --- a/src/components/PluginImage.tsx +++ b/src/components/PluginImage.tsx @@ -1,5 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; +import Image from "next/image"; interface Props { iconUrl?: string | null; @@ -7,59 +8,38 @@ interface Props { alt: string; } -export default function PluginImage({ iconUrl, repoUrl, alt }: Props) { - const [imgSrc, setImgSrc] = useState(null); - - useEffect(() => { - let targetSrc = iconUrl; - if (!targetSrc && repoUrl) { - const repoPath = repoUrl - .replace("https://github.com/", "") - .replace(/\/$/, ""); - targetSrc = `https://raw.githubusercontent.com/${repoPath}/main/icon.png`; - } - - if (!targetSrc) { - setImgSrc("/logo.png"); - return; - } - - targetSrc = targetSrc.replace(/\/\.\//g, "/"); - - const img = new Image(); - img.onload = () => setImgSrc(targetSrc as string); - img.onerror = () => setImgSrc("/logo.png"); - img.src = targetSrc; - }, [iconUrl, repoUrl]); - - if (!imgSrc) { - return ( -
- ); +function getSrc(iconUrl?: string | null, repoUrl?: string | null): string { + if (iconUrl) return iconUrl.replace(/\/\.\//g, "/"); + if (repoUrl) { + const repoPath = repoUrl + .replace("https://github.com/", "") + .replace(/\/$/, ""); + return `https://raw.githubusercontent.com/${repoPath}/main/icon.png`; } + return "/logo.png"; +} - const isFallback = imgSrc === "/logo.png"; +export default function PluginImage({ iconUrl, repoUrl, alt }: Props) { + const [errored, setErrored] = useState(false); + const src = errored ? "/logo.png" : getSrc(iconUrl, repoUrl); + const isFallback = src === "/logo.png"; return ( - // eslint-disable-next-line @next/next/no-img-element - {isFallback setErrored(true)} style={{ width: "100%", height: "100%", borderRadius: "var(--radius-lg)", objectFit: "cover", padding: "0", - transition: "opacity 0.2s", }} /> ); diff --git a/src/components/PluginSearch.tsx b/src/components/PluginSearch.tsx index 6a5a28c..ff14fdb 100644 --- a/src/components/PluginSearch.tsx +++ b/src/components/PluginSearch.tsx @@ -18,7 +18,7 @@ export default function PluginSearch() { params.delete("page"); router.push(`?${params.toString()}`, { scroll: false }); } - }, 500); + }, 300); return () => clearTimeout(timeoutId); }, [search, router, searchParams]); From c0032c0e4891bfce2d47fd69a6156e154b5fe314 Mon Sep 17 00:00:00 2001 From: Rezn1r Date: Fri, 22 May 2026 17:53:09 +0800 Subject: [PATCH 5/6] fix: respect prefers-reduced-motion in FadeIn and StaggerContainer Add usePrefersReducedMotion hook with matchMedia listener to skip inline transition/animation styles when user prefers reduced motion. Previously the global CSS rule couldn't override inline styles. --- src/components/FadeIn.tsx | 23 +++++++++++++++++++++- src/components/StaggerContainer.tsx | 30 ++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/components/FadeIn.tsx b/src/components/FadeIn.tsx index 1068f82..cd0ff25 100644 --- a/src/components/FadeIn.tsx +++ b/src/components/FadeIn.tsx @@ -1,7 +1,7 @@ "use client"; import { useInView } from "@/hooks/useInView"; -import type { ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; interface FadeInProps { children: ReactNode; @@ -21,6 +21,18 @@ const directionStyles = { none: () => ({ transform: "scale(0.97)" }), }; +function usePrefersReducedMotion() { + const [prefersReduced, setPrefersReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReduced(mq.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + return prefersReduced; +} + export default function FadeIn({ children, delay = 0, @@ -31,6 +43,15 @@ export default function FadeIn({ style, }: FadeInProps) { const { ref, inView } = useInView(); + const prefersReducedMotion = usePrefersReducedMotion(); + + if (prefersReducedMotion) { + return ( +
+ {children} +
+ ); + } const initialTransform = directionStyles[direction](distance); diff --git a/src/components/StaggerContainer.tsx b/src/components/StaggerContainer.tsx index 35a97da..2864b85 100644 --- a/src/components/StaggerContainer.tsx +++ b/src/components/StaggerContainer.tsx @@ -1,7 +1,14 @@ "use client"; import { useInView } from "@/hooks/useInView"; -import { Children, cloneElement, isValidElement, type ReactNode } from "react"; +import { + Children, + cloneElement, + useEffect, + useState, + isValidElement, + type ReactNode, +} from "react"; interface StaggerContainerProps { children: ReactNode; @@ -10,6 +17,18 @@ interface StaggerContainerProps { style?: React.CSSProperties; } +function usePrefersReducedMotion() { + const [prefersReduced, setPrefersReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReduced(mq.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + return prefersReduced; +} + export default function StaggerContainer({ children, staggerDelay = 0.08, @@ -17,6 +36,15 @@ export default function StaggerContainer({ style, }: StaggerContainerProps) { const { ref, inView } = useInView(); + const prefersReducedMotion = usePrefersReducedMotion(); + + if (prefersReducedMotion) { + return ( +
+ {children} +
+ ); + } const indexedChildren = inView ? Children.map(children, (child, i) => { From f4e8d774880312d34a51ba3dce38d31e498d7ae1 Mon Sep 17 00:00:00 2001 From: Call Me Harry Date: Fri, 22 May 2026 17:01:13 +0700 Subject: [PATCH 6/6] Delete .kilo/plans/1779442136201-nimble-star.md --- .kilo/plans/1779442136201-nimble-star.md | 176 ----------------------- 1 file changed, 176 deletions(-) delete mode 100644 .kilo/plans/1779442136201-nimble-star.md diff --git a/.kilo/plans/1779442136201-nimble-star.md b/.kilo/plans/1779442136201-nimble-star.md deleted file mode 100644 index 4e0d34a..0000000 --- a/.kilo/plans/1779442136201-nimble-star.md +++ /dev/null @@ -1,176 +0,0 @@ -# Performance & UX Improvement Plan - -## Overview - -Next.js 14 app (EndGit — plugin marketplace). Two categories of work: **bundle/runtime performance** and **UX polish**. - ---- - -## 1. Performance — Bundle Size & Loading - -### 1A. Replace framer-motion with CSS animations + lightweight IntersectionObserver hook - -**Impact: HIGH** — framer-motion is ~30KB gzipped and is imported by `FadeIn`, `StaggerContainer`, `PluginCardGrid`, and every page that uses them. - -- Create a `useInView` hook (lightweight, ~50 lines, no dependency) -- Create CSS-only `FadeIn` and `StaggerContainer` components using CSS `@keyframes` + the `useInView` hook to add a class when in viewport -- Update `PluginCardGrid` to use CSS transitions instead of `motion.a` -- Remove `framer-motion` from `package.json` - -**Files:** `src/components/FadeIn.tsx`, `src/components/StaggerContainer.tsx`, `src/components/PluginCardGrid.tsx`, `src/components/HomeContent.tsx`, `package.json` - -### 1B. Optimize react-syntax-highlighter imports - -**Impact: MEDIUM** — Prism + all languages + vscDarkPlus theme is a large bundle (~200KB+). - -- Switch to `react-syntax-highlighter/dist/esm/light` (lighter build) -- Only register the languages actually needed (e.g., `python`, `cpp`, `javascript`, `typescript`, `bash`, `json`) -- Use `AsyncLight` build or dynamic imports for the syntax highlighter - -**Files:** `src/components/MarkdownTabs.tsx` - -### 1C. Convert LatestPluginsSection to a server component - -**Impact: MEDIUM** — Currently a client component that fetches via `useEffect`, meaning zero data on initial HTML, waterfall fetch, and unnecessary JS shipped. - -- Remove `"use client"` from `LatestPluginsSection` -- Fetch data directly with `fetch()` (server-side) using `next: { revalidate: 60 }` -- Pass data as a server component rendered in `HomeContent` (or move the fetch into `page.tsx` and pass as prop) -- Remove client-side `useState`/`useEffect` for data fetching - -**Files:** `src/components/LatestPluginsSection.tsx`, `src/components/HomeContent.tsx`, `src/app/page.tsx` - -### 1D. Use Next.js Image component in PluginImage - -**Impact: LOW-MEDIUM** — Currently uses raw `` with manual `Image()` preloading. Bypasses Next.js image optimization (automatic WebP/AVIF, responsive srcset, lazy loading). - -- Replace `` with Next.js `` component -- Add the remote patterns already configured in `next.config.mjs` -- Use `onError` fallback instead of manual preloading - -**Files:** `src/components/PluginImage.tsx`, `next.config.mjs` (already configured) - -### 1E. Prefetch plugin detail pages on card hover - -**Impact: LOW** — Navigation to plugin detail pages feels slow because the page isn't prefetched. - -- Add `prefetch={true}` to plugin card `` components (Next.js default is true for visible links, but verify) -- Consider using `router.prefetch()` on hover for cards using `` (will be plain `
` after 1A) - -**Files:** `src/components/PluginCardGrid.tsx`, `src/components/LatestPluginsSection.tsx` - -### 1F. Deduplicate plugin data fetch on detail page - -**Impact: LOW** — `generateMetadata` and the page component both call `getPlugin()`, causing two identical API requests. - -- Use `React.cache()` or fetch once and pass through -- Or use Next.js `fetch` cache deduplication (automatic in Next.js 14 for same URL + same options) - -**Files:** `src/app/plugins/[slug]/page.tsx` - ---- - -## 2. Performance — Runtime & Perceived Speed - -### 2A. Add Suspense boundaries for client components on plugin detail page - -**Impact: MEDIUM** — `PluginAnalyticsChart`, `PluginRatings`, `DependencyGraph`, `VirusTotalCard` are all loaded via `dynamic()` but rendered synchronously in the component tree. Adding `` lets the page paint before these finish loading. - -- Wrap each dynamic component in `` -- Move dynamic imports above the component or use proper streaming - -**Files:** `src/app/plugins/[slug]/page.tsx` - -### 2B. Reduce search debounce from 500ms to 300ms - -**Impact: LOW** — 500ms feels sluggish. 300ms is standard for search-as-you-type. - -**Files:** `src/components/PluginSearch.tsx` - -### 2C. Inline critical CSS / preload Inter font - -**Impact: LOW** — The Inter font is loaded via `next/font/google` which is already optimized. Verify no layout shift. - -No changes needed — already handled by Next.js. - ---- - -## 3. UX Improvements - -### 3A. Terminal animation: don't replay on re-visit - -**Impact: MEDIUM** — The typewriter animation replays every time the user navigates back to the home page. Store a `sessionStorage` flag to skip animation on subsequent visits and show the completed state immediately. - -**Files:** `src/components/HomeContent.tsx` (TerminalMock) - -### 3B. Add visible focus indicators for keyboard navigation - -**Impact: MEDIUM** — Currently no `:focus-visible` styles. Users navigating with keyboard can't see which element is focused. - -- Add `:focus-visible` outline styles to `.btn`, `.input`, `.card`, links, and interactive elements -- Use `outline: 2px solid var(--accent-primary)` with `outline-offset: 2px` - -**Files:** `src/app/globals.css` - -### 3C. Improve error states - -**Impact: LOW-MEDIUM** — `LatestPluginsSection` silently returns `null` on error. `PluginAnalyticsChart` shows generic text. Show retry buttons and more informative messages. - -**Files:** `src/components/LatestPluginsSection.tsx`, `src/components/PluginAnalyticsChart.tsx` - -### 3D. Add loading="lazy" to below-fold images - -**Impact: LOW** — Plugin card images already use `loading="lazy"` in PluginImage. Verify all images are optimized. - -Already handled — no changes needed. - -### 3E. Improve mobile touch targets - -**Impact: LOW** — Pagination buttons and some interactive elements may be smaller than 44px on mobile. - -- Audit pagination controls, sort options, filter buttons -- Ensure minimum 44px touch targets per WCAG guidelines - -**Files:** `src/app/plugins/page.tsx`, `src/app/globals.css` - -### 3F. Add smooth scroll-to-top on pagination - -**Impact: LOW** — When clicking pagination, the page should scroll to the top of the plugin grid. - -**Files:** `src/app/plugins/page.tsx` (add `scroll: true` to router.push or use anchor links) - ---- - -## Implementation Order (by impact) - -1. **1A** — Replace framer-motion (biggest bundle win) -2. **1C** — Convert LatestPluginsSection to server component -3. **1B** — Optimize syntax highlighter -4. **3A** — Terminal animation persistence -5. **2A** — Add Suspense boundaries -6. **3B** — Focus indicators -7. **1D** — Next.js Image in PluginImage -8. **3C** — Error states -9. **2B** — Search debounce -10. **1E** — Link prefetching -11. **1F** — Deduplicate fetch -12. **3E** — Touch targets -13. **3F** — Scroll on pagination - ---- - -## Git Workflow - -- Create branch `refactor/performance-ux-improvements` from current branch before starting -- Run `pnpm run format && pnpm lint` before every commit -- Commit changes incrementally (one commit per logical group, e.g. "refactor: replace framer-motion with CSS animations") -- Do NOT touch unrelated files — only modify files listed in each step -- Push branch when complete - -## Verification - -- Run `pnpm build` to verify no build errors -- Run `pnpm lint` to verify no lint errors -- Run `pnpm type-check` to verify TypeScript -- Check bundle size with `npm run build` output (look at "First Load JS" per route) -- Manual test: home page, plugins list, plugin detail, mobile menu, theme toggle