Skip to content

feat: add secrete auth on mobile and index performance improvements - #60

Open
remcostoeten wants to merge 2 commits into
masterfrom
feat/speeltuintje
Open

feat: add secrete auth on mobile and index performance improvements#60
remcostoeten wants to merge 2 commits into
masterfrom
feat/speeltuintje

Conversation

@remcostoeten

@remcostoeten remcostoeten commented Feb 8, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced Component Studio with interactive animation editor, keyframe controls, and playback management
    • Added new UI components: HeroPill, GooeyToggle, and enhanced form controls (Select, Switch, Popover, Slider, Label)
    • Project showcase now displays Git metrics including last-updated information
  • Improvements

    • Optimized performance with Turbopack support and progressive deferred rendering
    • Improved load times through lazy initialization of dev tools and analytics
  • Chores

    • Simplified build configuration and removed redundant config files

@vercel

vercel Bot commented Feb 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
remcostoeten Error Error Feb 8, 2026 10:26pm

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @remcostoeten, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Feb 8, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive component studio and animation editor system with extensive UI components, API routes, and refactored data fetching. It removes configuration test outputs, adds Radix UI dependencies with a bun-based build workflow, replaces the playground with an interactive multi-level navigation system, and refactors project showcase to use client-side data fetching via new API routes.

Changes

Cohort / File(s) Summary
Configuration & Dependencies
next.config.mjs, package.json, .prettierignore, .prettierrc.json, components.json
Updated build scripts to use bun, added Radix UI packages (label, popover, select, slider, switch), upgraded geist to ^1.7.0, added turbopack configuration, and removed Prettier/component configuration files.
Test Output & Documentation Cleanup
.api-test-output/..., depcheck-results.json, prompt.md
Deleted static JSON test/analysis outputs and design prompt documentation.
API Routes for Data Fetching
src/app/api/projects/git-metrics/route.ts, src/app/api/projects/showcase/route.ts
Added two new Next.js API routes: git-metrics for fetching project Git statistics and showcase for serving structured project data with caching and error handling.
Fonts & Configuration
src/app/fonts.ts
Introduced pixel font toggle (Press_Start_2P) with utility function getBodyFonts() for conditional font selection.
Page Refactoring
src/app/(marketing)/page.tsx, src/app/(marketing)/playground/page.tsx
Wrapped major sections with DeferredRender for viewport-based lazy loading; replaced Suspense-based playground with full client-side multi-level navigation (categories → components → studio) and local state management.
Component Studio Core
src/components/component-studio/playground/types.ts, src/components/component-studio/playground/component-registry.ts, src/components/component-studio/lib/*
Defined comprehensive TypeScript types for props, behaviors, animations, categories; established component registry with metadata and utility functions for JSX generation and icon resolution.
Playground UI System
src/components/component-studio/playground/animation-studio/*, src/components/component-studio/playground/playground-*.tsx, src/components/component-studio/playground/*-editor.tsx
Introduced interactive animation editor with bezier controls, timeline scrubbing, keyframe editing, playback controls, and animation export; added prop and behavior editors with component preview and code export panel.
UI Component Library
src/components/ui/gooey-toggle.tsx, src/components/ui/hero-pill.tsx, src/components/ui/label.tsx, src/components/ui/pill-showcase.tsx, src/components/ui/popover.tsx, src/components/ui/resizable.tsx, src/components/ui/select.tsx, src/components/ui/slider.tsx, src/components/ui/switch.tsx, src/components/ui/deferred-render.tsx
Added new Radix UI-based components: animated gooey toggle, hero pill with variants and ghost behavior, label wrapper, popover, resizable panels, select dropdown, slider, switch; introduced DeferredRender for intersection observer-based lazy rendering.
Hooks
src/hooks/use-animation-playback.ts, src/hooks/use-idle-detection.ts
Implemented animation playback control hook with frame-based progress and run management; added idle detection hook with timer-based user interaction tracking.
Authentication & Context
src/components/auth/vim-auth-provider.tsx
Added VimAuthContext for sharing openAuthModal function; memoized handler callbacks; refactored provider structure with context propagation.
Projects Showcase Refactoring
src/components/projects/components/project-*.tsx, src/components/projects/index.ts
Migrated from server-side data fetching to client-side React Query with API integration; removed lazy loading of previews; introduced ProjectShowcaseSkeleton; refactored data merging with git metrics on client.
UI & Layout Updates
src/components/home/hero.tsx, src/components/landing/playground.tsx, src/components/layout/footer.tsx
Reduced image dimensions in hero; replaced playground item list with single interactive studio showcase; added secret authentication trigger in footer.
Provider & Initialization Optimization
src/components/providers/providers.tsx, src/components/providers/dev-widget-wrapper.tsx, src/components/providers/posthog-provider.tsx, src/components/seo/web-vitals-reporter.tsx
Deferred DevWidget and PostHog initialization via lazy loading/requestIdleCallback; added isolated DevWidgetWrapper; deferred web-vitals metric subscription with cancellation support.

Sequence Diagram

sequenceDiagram
    participant User
    participant PlaygroundLayout as PlaygroundLayout<br/>(State Manager)
    participant PropEditor as PropEditor
    participant AnimationStudio as AnimationStudio
    participant ComponentPreview as ComponentPreview
    participant CodeExportPanel as CodeExportPanel

    User->>PlaygroundLayout: Select component/update props
    PlaygroundLayout->>PropEditor: Pass schema, values
    PropEditor->>PropEditor: Render prop controls
    User->>PropEditor: Edit prop value
    PropEditor->>PlaygroundLayout: onChange(name, value)
    PlaygroundLayout->>PlaygroundLayout: Update state.props
    PlaygroundLayout->>ComponentPreview: Pass merged props
    ComponentPreview->>ComponentPreview: Render live preview
    ComponentPreview->>User: Display updated component

    User->>PlaygroundLayout: Configure animation
    PlaygroundLayout->>AnimationStudio: Pass animation config
    AnimationStudio->>AnimationStudio: Handle keyframes, timing
    User->>AnimationStudio: Adjust bezier/duration
    AnimationStudio->>PlaygroundLayout: Dispatch animation updates
    PlaygroundLayout->>PlaygroundLayout: Generate animation CSS
    PlaygroundLayout->>ComponentPreview: Pass animationKeyframesCSS
    ComponentPreview->>User: Preview animated component

    User->>CodeExportPanel: Request code
    CodeExportPanel->>CodeExportPanel: Generate JSX via generateComponentJsx
    CodeExportPanel->>User: Display copy-ready code
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

codex, feature, component-studio, ui, animation

Poem

🐰 Hop through the studio, frames dance with flair,
Bezier curves bend, animations float in air,
Plugins of Radix, a playground so bright,
Keyframes and previews—pure coding delight!
From props to behaviors, all wired just right.

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title partially addresses the changes but is incomplete and vague. 'Secret auth on mobile' refers to footer auth trigger, but 'index performance improvements' is too broad and doesn't accurately capture the comprehensive scope of changes including component studio, playground overhaul, animations, API routes, and UI components. Consider a more specific and descriptive title that captures the primary changes, such as: 'feat: add secret auth trigger, build component studio with animation editor, and optimize page rendering' or similar more precise summary.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/speeltuintje

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/auth/vim-auth-provider.tsx (1)

67-79: ⚠️ Potential issue | 🟡 Minor

Fire-and-forget signOut() with no error handling.

If signOut() rejects (e.g., network error), the promise is silently swallowed. Consider awaiting or catching to avoid an unhandled rejection.

Proposed fix
 	useEffect(() => {
 		if (session?.user) {
 			const isAllowed =
 				session.user.name?.toLowerCase() === ALLOWED_GITHUB_USERNAME
 
 			if (!isAllowed) {
 				console.warn(
 					'Unauthorized user attempted login, signing out...'
 				)
-				signOut()
+				signOut().catch((err) =>
+					console.error('Failed to sign out unauthorized user:', err)
+				)
 			}
 		}
 	}, [session])
🤖 Fix all issues with AI agents
In `@src/app/api/projects/git-metrics/route.ts`:
- Around line 24-32: Replace the unbounded Promise.all over dbProjects that
calls fetchGitMetrics with a bounded-concurrency or batched approach (e.g., use
p-limit or process dbProjects in chunks) to avoid hitting GitHub rate limits;
ensure each call to fetchGitMetrics is wrapped in a try/catch so failures or
403s for a single project do not reject the whole operation, and still populate
metricsByProject[project.title] only when metrics is successfully returned.

In `@src/app/fonts.ts`:
- Line 6: The constant ENABLE_PIXEL_FONT is hardcoded true; change it to default
to false and drive it from an environment/build flag instead so it can't ship
enabled accidentally—replace the literal export of ENABLE_PIXEL_FONT with logic
that reads a public env var (e.g., NEXT_PUBLIC_ENABLE_PIXEL_FONT or similar) and
converts it to a boolean, falling back to false when unset or invalid; ensure
the flag is exposed to the client runtime (or build-time) per your framework
conventions and update any related docs/tests that assume the pixel font is
enabled.

In `@src/components/component-studio/playground/component-preview.tsx`:
- Around line 41-43: The component currently injects user-edited CSS via
animationKeyframesCSS into a <style> tag using dangerouslySetInnerHTML; replace
this by sanitizing and/or programmatically constructing the style content:
validate keyframe inputs in the keyframe editor pipeline (allowlist permitted
CSS property names and patterns and reject or escape unsafe values like
urls/data URIs), or build the style element via DOM APIs inside ComponentPreview
(or the function that produces animationKeyframesCSS) and set textContent
instead of using dangerouslySetInnerHTML; ensure you reference and sanitize
values that feed animationKeyframesCSS and centralize the validation logic so
only safe CSS is emitted.

In `@src/components/component-studio/playground/playground-layout.tsx`:
- Around line 196-261: The computed animation name stored in animNameRef (set in
the useEffect that depends on animKeyframeHash) is stale when read inside the
animationCSS useMemo because useEffect runs after render; replace this with
either (A) derive the name inside the same useMemo that builds animationCSS
(remove animNameRef and the useEffect) so the name is fresh whenever
animKeyframeHash changes, or (B) switch animNameRef to a state value (e.g.,
animName via useState and setAnimName in an effect) and include that state
(animName) in the animationCSS useMemo dependencies so the new name triggers a
re-render and the fresh name is used when building `@keyframes`; update references
to animNameRef.current to use the new local name/state.

In `@src/components/layout/footer.tsx`:
- Around line 60-76: The click-count timeout in handleSecretAuth uses
independent setTimeouts that are never cleared, causing stale timers to reset
clickCount mid-sequence; fix by adding a ref (e.g., timeoutRef via useRef<number
| null>) to store the active timeout id, call clearTimeout(timeoutRef.current)
at the start of handleSecretAuth before creating a new setTimeout, assign the
new timeout id to timeoutRef.current, and reset timeoutRef.current to null when
the timeout fires or when you trigger openAuthModal; also add a useEffect
cleanup to clearTimeout(timeoutRef.current) on unmount.

In `@src/components/projects/components/project-showcase.tsx`:
- Around line 36-44: Destructure isError, error and refetch from the useQuery
call in ProjectShowcase (alongside data and isLoading) and change the render
guard so the skeleton shows only while isLoading; when isError or data is
undefined render an error fallback UI that displays error?.message (and a retry
button that calls refetch) instead of the skeleton. Specifically update the
useQuery call that uses fetchProjectShowcaseData to include
isError/error/refetch and replace the current "isLoading || !data" check with an
ordered conditional: if (isLoading) -> skeleton, else if (isError || !data) ->
error fallback (show error message and retry), else -> render the normal project
grid.

In `@src/components/seo/web-vitals-reporter.tsx`:
- Around line 29-66: The component WebVitalsReporter currently returns early
before calling useEffect which violates the Rules of Hooks; remove the top-level
NODE_ENV early return so useEffect is always invoked, then move the
development-mode guard inside the effect (at the start of the effect callback)
to bail out immediately if process.env.NODE_ENV !== 'development'; keep the
existing setup async function, cancelled flag, logMetric, and cleanup logic
(returning the cleanup that sets cancelled) and maintain the empty dependency
array so onLCP/onFCP/onCLS/onTTFB are only registered in development builds.

In `@src/components/ui/hero-pill.tsx`:
- Around line 46-51: The effectiveVariant computation incorrectly falls back to
the original variant (which may be "ghost") when the ghost effect is inactive;
update the logic in effectiveVariant (and the PillVariant usage) so that when
variant === "ghost" but ghostBehavior is "idle" and isIdle is false, or when
ghostBehavior is "never", it returns a non-ghost variant (e.g., "default")
instead of variant; implement this by changing the ternary to explicitly return
"default" (or use a new prop activeVariant to allow callers to override the
non-ghost appearance) whenever the ghost behavior is not active, and update any
call sites that may rely on the old fallback.
🟡 Minor comments (16)
src/app/fonts.ts-14-19 (1)

14-19: ⚠️ Potential issue | 🟡 Minor

Geist CSS variables are unavailable when pixel font is enabled.

When ENABLE_PIXEL_FONT is true, neither GeistSans.variable nor GeistMono.variable is included in the output. Any component or Tailwind utility referencing --font-geist-sans or --font-geist-mono will silently fall back to the browser default. If the intent is a full pixel-font takeover this is fine, but if some components (e.g., code blocks) should still use Geist Mono, you'd want to preserve those variables:

 if (ENABLE_PIXEL_FONT) {
-    return `${pixelFont.variable} ${pixelFont.className}`
+    return `${GeistSans.variable} ${GeistMono.variable} ${pixelFont.variable} ${pixelFont.className}`
 }
src/components/ui/gooey-toggle.tsx-74-82 (1)

74-82: ⚠️ Potential issue | 🟡 Minor

{...props} spread could override type="checkbox".

Since type is not destructured from props, a consumer passing type="radio" (or type="text") would silently override the checkbox behavior. Move the spread before the explicit attributes, or destructure and discard type.

Proposed fix
       <input
-        type="checkbox"
-        ref={ref}
-        checked={isChecked}
-        onChange={handleChange}
-        data-checked={isChecked}
-        className={inputStyles}
         {...props}
+        type="checkbox"
+        ref={ref}
+        checked={isChecked}
+        onChange={handleChange}
+        data-checked={isChecked}
+        className={inputStyles}
       />
src/app/api/projects/showcase/route.ts-19-31 (1)

19-31: ⚠️ Potential issue | 🟡 Minor

Empty github URL will produce a broken link in the UI.

Line 25: when gitUrl is null, github becomes ''. Both ProjectRow and ProjectCard unconditionally render an <a href={project.github}> GitHub link. An empty href navigates to the current page, which is confusing.

Consider either filtering out projects without a gitUrl from the response, or setting a sentinel that the UI can check to conditionally hide the link.

One possible approach — omit the link-target when missing
-		github: dbProject.gitUrl ?? '',
+		github: dbProject.gitUrl ?? undefined!,

A cleaner fix would be to make github optional in IProject and guard the <a> in the card/row components.

src/hooks/use-idle-detection.ts-5-9 (1)

5-9: ⚠️ Potential issue | 🟡 Minor

elementRef parameter is accepted but never used.

The effect attaches listeners to document globally, yet elementRef is a required parameter and sits in the dependency array (Line 37). This is misleading — callers must create and pass a ref that has zero effect on behavior. Either scope the listeners to elementRef.current (which was likely the intent) or remove the parameter.

Option A: scope listeners to the element
   useEffect(() => {
     if (!enabled) {
       setIsIdle(false)
       return
     }
+    const el = elementRef.current
+    if (!el) return

     let timeoutId: ReturnType<typeof setTimeout>

     const resetTimer = () => {
       setIsIdle(false)
       clearTimeout(timeoutId)
       timeoutId = setTimeout(() => setIsIdle(true), idleMs)
     }

     const events = ["mousemove", "mousedown", "scroll", "touchstart", "keydown"]
-    events.forEach((e) =>
-      document.addEventListener(e, resetTimer, { passive: true })
-    )
+    for (const e of events) {
+      el.addEventListener(e, resetTimer, { passive: true })
+    }

     timeoutId = setTimeout(() => setIsIdle(true), idleMs)

     return () => {
       clearTimeout(timeoutId)
-      events.forEach((e) => document.removeEventListener(e, resetTimer))
+      for (const e of events) {
+        el.removeEventListener(e, resetTimer)
+      }
     }
   }, [elementRef, idleMs, enabled])
Option B: remove the unused parameter (if global detection is intentional)
-export function useIdleDetection(
-  elementRef: RefObject<HTMLElement | null>,
-  idleMs: number,
-  enabled: boolean
-): boolean {
+export function useIdleDetection(
+  idleMs: number,
+  enabled: boolean
+): boolean {

This would also require updating callers (e.g., hero-pill.tsx).

Also applies to: 37-37

src/hooks/use-idle-detection.ts-26-29 (1)

26-29: ⚠️ Potential issue | 🟡 Minor

Fix lint: forEach callbacks should not return a value.

The arrow expressions implicitly return the result of addEventListener/removeEventListener. Use block bodies to satisfy the Biome rule useIterableCallbackReturn.

Proposed fix
-    events.forEach((e) =>
-      document.addEventListener(e, resetTimer, { passive: true })
-    )
+    events.forEach((e) => {
+      document.addEventListener(e, resetTimer, { passive: true })
+    })
-      events.forEach((e) => document.removeEventListener(e, resetTimer))
+      events.forEach((e) => { document.removeEventListener(e, resetTimer) })

Or use for...of loops as shown in Option A above, which avoids the issue entirely.

Also applies to: 33-36

src/components/component-studio/lib/jsx-utils.ts-6-8 (1)

6-8: ⚠️ Potential issue | 🟡 Minor

String values are not escaped — embedded quotes will produce invalid JSX.

If a string prop value contains double quotes (e.g., hello "world"), the output becomes "hello "world"", which is malformed JSX. Escape special characters before interpolating.

Proposed fix
   if (typeof value === "string") {
-    return `"${value}"`
+    return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
   }
src/components/component-studio/playground/animation-studio/playback-controls.tsx-220-247 (1)

220-247: ⚠️ Potential issue | 🟡 Minor

Speed input and slider have mismatched max values (1000 vs 500).

The numeric input (Line 225) accepts speeds up to 1000 while the Slider (Line 239) caps at max={500}. If a user types 750 into the input, the slider handle overflows or clips, and dragging the slider afterward would snap the value down. Align the ranges or document the intentional discrepancy.

Proposed fix — align both to the same max
              onChange={(e) => {
                const v = parseInt(e.target.value, 10)
-               if (!isNaN(v) && v >= 1 && v <= 1000) onSpeedChange(v)
+               if (!isNaN(v) && v >= 1 && v <= 500) onSpeedChange(v)
              }}
              min={1}
-             max={1000}
+             max={500}

Or raise the slider max to 1000 and update the scale labels accordingly.

src/components/component-studio/playground/playground-toolbar.tsx-16-19 (1)

16-19: ⚠️ Potential issue | 🟡 Minor

Unhandled promise rejection from clipboard API.

navigator.clipboard.writeText returns a Promise that can reject (e.g., non-secure context, permission denied, or unsupported browser). This will produce an unhandled promise rejection at runtime.

Proposed fix
   const copyUrl = () => {
-    navigator.clipboard.writeText(window.location.href)
-    toast("URL copied to clipboard")
+    navigator.clipboard.writeText(window.location.href).then(
+      () => toast("URL copied to clipboard"),
+      () => toast.error("Failed to copy URL")
+    )
   }
src/components/component-studio/lib/jsx-utils.ts-79-80 (1)

79-80: ⚠️ Potential issue | 🟡 Minor

startsWith check can falsely match prop names that share a prefix.

If the schema has props like text and textColor, p.trim().startsWith("text") matches both, potentially preventing text from being added as a required prop.

Proposed fix — match the exact prop name followed by a delimiter
-          if (!propEntries.some((p) => p.trim().startsWith(propSchema.name))) {
+          if (!propEntries.some((p) => {
+            const trimmed = p.trim()
+            return trimmed === propSchema.name || trimmed.startsWith(propSchema.name + "=") || trimmed.startsWith(propSchema.name + "{")
+          })) {
src/components/providers/posthog-provider.tsx-33-42 (1)

33-42: ⚠️ Potential issue | 🟡 Minor

defaults: '2025-11-30' is not a valid value for the defaults config option.

The defaults property is a recognized PostHog configuration option used to enable/disable breaking change defaults. However, the only valid values are '2025-05-24' (enable updated default behaviors) or 'unset' (use legacy defaults). Using an invalid value like '2025-11-30' will be silently ignored or cause a TypeScript type error. Change this to either '2025-05-24' or 'unset'.

src/components/component-studio/playground/code-export-panel.tsx-88-106 (1)

88-106: ⚠️ Potential issue | 🟡 Minor

Hardcoded import path in baseCode may not match actual component locations.

Line 93 and 105 generate an import statement using @/components/ui/${registration.slug}, but the component's actual file path may differ from its slug. For example, a component with slug "gooey-toggle" would produce @/components/ui/gooey-toggle, which may or may not exist. Consider adding an optional importPath field to ComponentRegistration to make this accurate.

src/components/component-studio/playground/code-export-panel.tsx-3-4 (1)

3-4: ⚠️ Potential issue | 🟡 Minor

Unused import: Clipboard.

Clipboard is imported from lucide-react on line 4 but is never used in this file. Only Check and Copy are referenced.

Proposed fix
-import { Check, Copy, Clipboard } from "lucide-react"
+import { Check, Copy } from "lucide-react"
package.json-4-5 (1)

4-5: ⚠️ Potential issue | 🟡 Minor

Inconsistency: build:fast uses plain next build instead of next --bun build.

The build script uses next --bun build, but build:fast uses plain next build without the --bun flag. While build:fast appears intentional (it sets FAST_BUILD=true to disable TypeScript error checking in next.config.mjs), ensure that production deployments use the build script, not build:fast, to maintain consistent Bun runtime usage. The project is already Bun-configured (bun.lock, vercel.json with "bunVersion": "1.x"), so the main concern is preventing accidental use of build:fast in production.

src/components/component-studio/playground/animation-studio/animation-export.tsx-77-80 (1)

77-80: ⚠️ Potential issue | 🟡 Minor

navigator.clipboard.writeText is not awaited and has no error handling.

If clipboard access is denied (e.g., iframe restrictions, no user gesture, non-secure context), this will throw an unhandled promise rejection, and the toast will display "Copied" even though the copy failed.

🛡️ Proposed fix
-  const copy = (text: string) => {
-    navigator.clipboard.writeText(text)
-    toast("Copied to clipboard")
+  const copy = async (text: string) => {
+    try {
+      await navigator.clipboard.writeText(text)
+      toast("Copied to clipboard")
+    } catch {
+      toast.error("Failed to copy")
+    }
   }
src/components/ui/pill-showcase.tsx-222-253 (1)

222-253: ⚠️ Potential issue | 🟡 Minor

Clickable <div> lacks keyboard accessibility.

These grid items act as interactive buttons but are not focusable or operable via keyboard. Screen readers and keyboard-only users cannot activate them.

Add role="button", tabIndex={0}, and an onKeyDown handler for Enter/Space:

♿ Proposed fix
           <div
             key={variant.id}
             className="group relative flex aspect-[4/3] flex-col items-center justify-center gap-3 rounded-xl border border-border/40 bg-muted/10 p-4 transition-all hover:border-border/80 hover:bg-muted/30 cursor-pointer"
             onClick={() => handleCopy(variant)}
+            onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCopy(variant) } }}
+            role="button"
+            tabIndex={0}
+            aria-label={`Copy ${variant.label} variant code`}
           >
src/components/component-studio/playground/animation-studio/animation-studio.tsx-178-181 (1)

178-181: ⚠️ Potential issue | 🟡 Minor

Duration validation doesn't match the HTML min/max constraints.

The JS handler accepts any value > 0, but the <Input> declares min={10} and max={30000}. A user can type a value like 5 or 99999 and the dispatch will accept it, bypassing the intended constraints.

🛡️ Proposed fix to align JS validation with HTML constraints
              onChange={(e) => {
                const v = parseInt(e.target.value, 10)
-               if (!isNaN(v) && v > 0)
+               if (!isNaN(v) && v >= 10 && v <= 30000)
                  dispatch({ type: "SET_DURATION", value: v })
              }}
🧹 Nitpick comments (29)
src/components/providers/dev-widget-wrapper.tsx (1)

3-4: Inconsistent import path style.

Line 3 uses a relative path (../../../tools/dev-menu) while Line 4 uses the @/ alias. Prefer the alias for consistency and readability.

-import { DevWidget } from '../../../tools/dev-menu'
+import { DevWidget } from '@/tools/dev-menu'
src/components/ui/gooey-toggle.tsx (2)

104-106: Conditional render prevents the drop circle from animating in.

Because the circle is mounted/unmounted via {isChecked && ...}, the CSS transition-transform on dropCircleStyles has no effect on enter — the element simply pops in. If a smooth entrance is intended, consider always rendering the circle and toggling its scale/opacity instead.

Example: always-mounted with animated scale
-        {isChecked && (
-          <circle className={dropCircleStyles} cx="35" cy="-1" r="2.5" />
-        )}
+        <circle
+          className={dropCircleStyles}
+          cx="35"
+          cy="-1"
+          r="2.5"
+          style={{
+            transform: `scale(${isChecked ? 1 : 0})`,
+            transformOrigin: "35px -1px",
+          }}
+        />

112-129: Hardcoded id="gooey-filter" will cause duplicate-ID issues if GooeyFilter is rendered more than once.

If multiple layout segments or component trees mount GooeyFilter, the duplicate SVG filter ID can cause unpredictable rendering. Consider documenting that it must be a singleton (e.g., placed in the root layout), or accept an optional id prop to disambiguate.

src/components/layout/footer.tsx (2)

68-71: Toast reveals the secret auth mechanism.

Showing 'Secret auth triggered!' via a toast notification somewhat defeats the purpose of a secret auth trigger. Consider removing the toast or making it more subtle (e.g., only display it in dev mode).


160-165: active:scale-95 hints at interactivity on a "hidden" trigger.

The press animation is a visual clue that the copyright text is interactive. If secrecy is a goal, consider removing active:scale-95.

src/components/projects/components/project-preview.tsx (1)

70-70: loading ternary is dead code — isVisible is always true here.

Line 20 returns null when !isVisible, so any code past that guard can only execute when isVisible === true. The ternary isVisible ? 'eager' : 'lazy' will always evaluate to 'eager'. Either simplify to loading="eager" or, if lazy loading was intentional for some future path, move the early return.

Suggested simplification
 				<iframe
 					src={preview.embedUrl || preview.url}
 					title={name}
-					loading={isVisible ? 'eager' : 'lazy'}
+					loading="eager"
 					sandbox="allow-scripts allow-same-origin"
src/components/projects/components/project-card.tsx (1)

90-94: forceShowPreview sync effect is clean, but consider the interaction with local toggle.

When forceShowPreview flips from trueundefined, the effect on line 91 won't fire (condition forceShowPreview !== undefined is false), leaving showPreview in whatever state it was forced to. This seems intentional (user's local toggle takes over), but worth confirming the expected UX.

src/components/projects/components/project-showcase.tsx (1)

75-91: Duplicated merge logic for featured and other — consider extracting a helper.

The mapping logic on lines 75–82 and 84–91 is identical. A small helper would reduce duplication:

Suggested refactor
+const mergeGitMetrics = (projects: IProject[], metrics?: ProjectGitMetricsMap) =>
+	projects.map(project => ({
+		...project,
+		git: metrics?.[project.name] ?? project.git
+	}))
+
-const featured = useMemo(
-	() =>
-		(data?.featured ?? []).map(project => ({
-			...project,
-			git: gitMetrics?.[project.name] ?? project.git
-		})),
-	[data?.featured, gitMetrics]
-)
-
-const other = useMemo(
-	() =>
-		(data?.other ?? []).map(project => ({
-			...project,
-			git: gitMetrics?.[project.name] ?? project.git
-		})),
-	[data?.other, gitMetrics]
-)
+const featured = useMemo(
+	() => mergeGitMetrics(data?.featured ?? [], gitMetrics),
+	[data?.featured, gitMetrics]
+)
+
+const other = useMemo(
+	() => mergeGitMetrics(data?.other ?? [], gitMetrics),
+	[data?.other, gitMetrics]
+)
src/components/projects/components/project-card-skeleton.tsx (1)

5-42: FeaturedCardSkeleton in project-showcase-skeleton.tsx duplicates this component almost verbatim.

Both ProjectCardSkeleton (this file) and the internal FeaturedCardSkeleton (in project-showcase-skeleton.tsx, lines 6–39) render the same skeleton markup with the same withPreview prop. Consider reusing ProjectCardSkeleton inside ProjectShowcaseSkeleton to keep a single source of truth:

// In project-showcase-skeleton.tsx
import { ProjectCardSkeleton } from './project-card-skeleton'

// Then replace FeaturedCardSkeleton usage:
<ProjectCardSkeleton withPreview={i === 0} />
src/app/(marketing)/page.tsx (2)

44-53: Redundant lazy-loading layers: nextDynamic + DeferredRender + useQuery skeleton.

ProjectShowcase goes through three sequential loading phases, each showing a skeleton:

  1. DeferredRender — shows <ProjectShowcaseSkeleton> until the section scrolls into view
  2. nextDynamic({ ssr: false }) — shows its own loading skeleton while the JS chunk downloads
  3. useQuery inside ProjectShowcase — shows yet another skeleton while fetching API data

Since DeferredRender already defers rendering until the viewport is near, and ProjectShowcase is ssr: false (so it won't block the page), wrapping it in DeferredRender adds marginal value while creating a triple-skeleton waterfall. Consider either:

  • Removing DeferredRender and relying on nextDynamic + the internal useQuery skeleton, or
  • Removing nextDynamic and using only DeferredRender with a direct import (the component already handles its own loading).

The same pattern applies to ActivitySection (lines 92–97) and Playground (lines 112–119).

Also applies to: 99-110


6-6: Import path inconsistency: direct path vs. barrel export.

ProjectShowcaseSkeleton is imported from the deep component path here, but it's also exported from the barrel src/components/projects/index.ts (line 5). For consistency with how ProjectShowcase is imported (via barrel in the dynamic import on line 46–48), consider using:

-import { ProjectShowcaseSkeleton } from '@/components/projects/components/project-showcase-skeleton'
+import { ProjectShowcaseSkeleton } from '@/components/projects'
src/components/projects/components/project-showcase-skeleton.tsx (1)

62-62: Magic number 40 duplicates ROW_HEIGHT from the client component.

The value 40 on this line mirrors ROW_HEIGHT = 40 defined in project-showcase-client.tsx (line 15). If the row height changes in one place but not the other, the skeleton and the real component will visually mismatch, causing a layout shift on load.

Consider sharing a constant:

Suggested approach
// e.g., in a shared constants file or co-located
export const ROW_HEIGHT = 40

// Then import in both project-showcase-client.tsx and project-showcase-skeleton.tsx
src/components/ui/slider.tsx (1)

8-26: Single Thumb limits this to single-value sliders.

Radix Slider supports multiple values (e.g., range sliders) by rendering a Thumb per value. This implementation hardcodes one Thumb, so passing defaultValue={[25, 75]} would visually break. Fine if only single-value use is planned, but worth noting for future consumers.

src/components/landing/playground.tsx (1)

1-1: "use client" may be unnecessary here.

This component doesn't use any hooks, event handlers, or browser APIs directly. Child components like HeroPill already declare their own "use client" boundary. Removing the directive would let Next.js render this as a server component, keeping it out of the client bundle.

src/components/component-studio/lib/jsx-utils.ts (1)

34-65: Duplicate iteration pattern for props and behaviors — consider extracting a helper.

The prop-formatting logic in lines 35–49 and 52–65 is nearly identical. A small shared helper would reduce duplication and make future format changes less error-prone.

src/app/(marketing)/playground/page.tsx (1)

122-123: GooeyFilter is rendered for every category, not just the one that uses it.

<GooeyFilter /> injects an SVG filter definition into the DOM for all category views. It's harmless (just an invisible SVG <defs>), but it's slightly cleaner to conditionally render it only when the category actually contains gooey-toggle components, or to move it into the component preview that needs it.

src/components/component-studio/playground/code-export-panel.tsx (3)

53-55: Move PrismJS imports to the top of the file.

Imports are conventionally placed at the top of the file. Having them between component definitions (after CopyButton, before CodeBlock) is unconventional and can be confusing to readers.


57-75: dangerouslySetInnerHTML with PrismJS output — acceptable here, but note the static analysis flags.

Static analysis tools flag line 71 for XSS risk. In this case, Prism.highlight() processes internally-generated JSX strings (from generateComponentJsx) and HTML-encodes the source text before wrapping tokens in <span> elements, so this is safe. The input is developer-controlled registry data, not arbitrary user input.

If you want to eliminate the lint warnings entirely, consider a React-based syntax highlighter (e.g., react-syntax-highlighter, which is already in your package.json dependencies) instead of raw Prism + dangerouslySetInnerHTML.


18-51: Duplicate CopyButton implementation.

There is an existing CopyButton component at src/components/playground/copy-button.tsx with very similar functionality. Consider reusing that component (or extracting a shared one) to avoid duplication.

#!/bin/bash
# Verify the existing CopyButton component
fd "copy-button" --type f --exec cat {}
src/components/component-studio/playground/prop-controls/icon-picker-control.tsx (1)

58-73: Icon buttons lack accessible labels for screen readers.

The icon <button> elements only have a title attribute but no visible text or aria-label. Screen readers won't announce what each button represents.

Suggested fix
             <button
               key={key}
               onClick={() => {
                 onChange(key)
                 setOpen(false)
               }}
               className={cn(
                 "group flex h-10 w-full items-center justify-center rounded-none border border-transparent transition-colors hover:bg-accent/40",
                 value === key && "border-border/50 bg-accent/20"
               )}
               title={key}
+              aria-label={key}
             >
src/hooks/use-animation-playback.ts (1)

49-108: progress in the dependency array causes the effect to re-run every frame.

Since onProgressChange updates progress on every animation tick, this effect tears down and re-creates the rAF loop each frame. It works but adds unnecessary overhead (effect cleanup, closure allocation, rAF cancellation + re-scheduling ~60 times/sec).

A more efficient pattern: store progress, currentRun, etc. in refs so the tick closure always reads fresh values without requiring the effect to re-run. The effect would then only depend on isPlaying and isInDelay to start/stop the loop.

Sketch of the ref-based approach
+ const progressRef = useRef(progress)
+ const currentRunRef = useRef(currentRun)
+ useEffect(() => { progressRef.current = progress }, [progress])
+ useEffect(() => { currentRunRef.current = currentRun }, [currentRun])

  useEffect(() => {
    if (!isPlaying || isInDelay) {
      cancelAnimationFrame(rafRef.current)
      return
    }
    lastTimeRef.current = performance.now()

    const tick = (now: number) => {
      const delta = now - lastTimeRef.current
      lastTimeRef.current = now
-     const increment = (delta / duration) * 100 * speedMultiplier
-     const next = progress + increment
+     const increment = (delta / duration) * 100 * speedMultiplier
+     const next = progressRef.current + increment
      // ... rest uses refs instead of closure-captured state
      rafRef.current = requestAnimationFrame(tick)
    }
    rafRef.current = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(rafRef.current)
- }, [isPlaying, isInDelay, duration, speedMultiplier, progress, currentRun, ...])
+ }, [isPlaying, isInDelay, duration, speedMultiplier])
src/components/component-studio/playground/animation-studio/bezier-editor.tsx (1)

40-76: fromSvg is recreated every render, breaking useCallback memoization of handlePointerMove.

fromSvg is a plain inline function, so it's a new reference on each render. Since it appears in the dependency array of handlePointerMove (line 75), the callback is never actually memoized. Wrap fromSvg in useCallback (it only depends on size).

Suggested fix
- const fromSvg = (sx: number, sy: number): [number, number] => [
-   Math.round((sx / size) * 100) / 100,
-   Math.round(((1 - sy / size)) * 100) / 100,
- ]
+ const fromSvg = useCallback(
+   (sx: number, sy: number): [number, number] => [
+     Math.round((sx / size) * 100) / 100,
+     Math.round(((1 - sy / size)) * 100) / 100,
+   ],
+   [size]
+ )
src/components/component-studio/playground/component-preview.tsx (1)

49-49: Redundant type guard.

variantProp is already found with p.type === "enum" on line 35, making the second variantProp.type === "enum" check on line 49 unnecessary.

Suggested fix
-     {variantProp && variantProp.type === "enum" && (
+     {variantProp && (
src/components/component-studio/playground/animation-studio/timeline.tsx (2)

87-105: Wheel handler re-attaches on every progress change (~60fps during playback).

progress is in the dependency array, so during active playback the event listener is removed and re-added every frame. Use a ref to hold the current progress value so the effect only needs to depend on zoom, onZoomChange, and onProgressChange.

Suggested fix
+ const progressRef = useRef(progress)
+ useEffect(() => { progressRef.current = progress }, [progress])
+ const zoomRef = useRef(zoom)
+ useEffect(() => { zoomRef.current = zoom }, [zoom])

  useEffect(() => {
    const el = scrollRef.current
    if (!el) return
    const handler = (e: WheelEvent) => {
      if (e.ctrlKey || e.metaKey) {
        e.preventDefault()
        const delta = e.deltaY > 0 ? -0.5 : 0.5
-       onZoomChange(Math.max(1, Math.min(10, zoom + delta)))
+       onZoomChange(Math.max(1, Math.min(10, zoomRef.current + delta)))
      } else {
        e.preventDefault()
        const scrubAmount = e.deltaY > 0 ? 0.5 : -0.5
-       onProgressChange(Math.max(0, Math.min(100, progress + scrubAmount)))
+       onProgressChange(Math.max(0, Math.min(100, progressRef.current + scrubAmount)))
      }
    }
    el.addEventListener("wheel", handler, { passive: false })
    return () => el.removeEventListener("wheel", handler)
- }, [zoom, progress, onZoomChange, onProgressChange])
+ }, [onZoomChange, onProgressChange])

22-26: parseOffset silently returns 0 for malformed input.

If an offset string doesn't match the (\d+)% pattern (e.g., "from", "to", or a bare number), it defaults to 0, which would place the keyframe at the start. Consider handling "from"/"to" aliases or logging a warning for unexpected formats.

src/components/component-studio/lib/icons.tsx (1)

3-198: Consider extracting shared SVG boilerplate into a wrapper component.

Every SVG icon repeats identical props (xmlns, width, height, viewBox, fill, stroke, strokeWidth, strokeLinecap, strokeLinejoin) and most share the same className. A small IconWrapper component accepting children (and optionally overriding className) would cut ~100 lines of duplication and make adding new icons trivial.

♻️ Example wrapper approach
function IconSvg({
  children,
  className = "text-zinc-400 transition-colors duration-500 group-hover:text-zinc-200",
  strokeWidth = 1.8,
}: {
  children: React.ReactNode
  className?: string
  strokeWidth?: number
}) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={12}
      height={12}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={strokeWidth}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
    >
      {children}
    </svg>
  )
}

// Usage:
function PrismIcon() {
  return (
    <IconSvg>
      <path d="M2 22 12 2l10 20" />
      <path d="m3.5 19 8.5-15" />
      <path d="m20.5 19-8.5-15" />
      <path d="M2 22h20" />
    </IconSvg>
  )
}
src/components/ui/pill-showcase.tsx (2)

178-182: Avoid @ts-ignore — use a typed approach instead.

The @ts-ignore suppresses a real type mismatch (assigning a string to ReactNode). Since propsForGen is already cast to Record<string, unknown> on Line 186, you can avoid the directive entirely:

♻️ Proposed fix
       const propsForGen = { ...variant.props }
-      if (propsForGen.icon) {
-        // `@ts-ignore`
-        propsForGen.icon = "<Icon />"
-      }
+      const propsForGen: Record<string, unknown> = { ...variant.props }
+      if (propsForGen.icon) {
+        propsForGen.icon = "<Icon />"
+      }
 
       const code = generateComponentJsx(
         "HeroPill",
-        propsForGen as Record<string, unknown>,
+        propsForGen,
         {},
         registration
       )

12-55: PrismIcon naming collides with the icon in icons.tsx (different SVG paths).

This file defines its own PrismIcon with different SVG geometry than the one in src/components/component-studio/lib/icons.tsx. Having two different icons sharing the same name across the codebase will be confusing during future maintenance. Consider renaming one of them (e.g., PrismShowcaseIcon here) to differentiate.

src/components/component-studio/playground/animation-studio/animation-studio.tsx (1)

315-330: onChange and onAddProperty dispatch identical actions.

Both callbacks dispatch SET_KEYFRAME_PROPERTY with the same shape. If this is intentional (the reducer treats add and update identically), consider consolidating into a single callback to make that intent explicit. If the add path should differ (e.g., validation for duplicate property names), a distinct action type would be warranted.

Comment on lines +24 to +32
await Promise.all(
dbProjects.map(async project => {
if (!project.gitUrl) return
const metrics = await fetchGitMetrics(project.gitUrl)
if (metrics) {
metricsByProject[project.title] = metrics
}
})
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unbounded concurrent GitHub API requests may hit rate limits.

Promise.all fires fetchGitMetrics for every visible project simultaneously. On a cold cache (or revalidation), this could send dozens of concurrent requests to GitHub, risking 403 rate-limit responses — especially with unauthenticated requests (60/hr) or even authenticated ones (5000/hr shared across all endpoints).

Consider adding a concurrency limiter (e.g., processing in batches or using p-limit), or at minimum, handling per-project fetch failures gracefully so one rate-limited response doesn't reject the entire Promise.all.

Sketch: per-item error isolation
 		await Promise.all(
 			dbProjects.map(async project => {
 				if (!project.gitUrl) return
-				const metrics = await fetchGitMetrics(project.gitUrl)
-				if (metrics) {
-					metricsByProject[project.title] = metrics
+				try {
+					const metrics = await fetchGitMetrics(project.gitUrl)
+					if (metrics) {
+						metricsByProject[project.title] = metrics
+					}
+				} catch (err) {
+					console.warn(`[git-metrics] Failed for ${project.title}:`, err)
 				}
 			})
 		)
🤖 Prompt for AI Agents
In `@src/app/api/projects/git-metrics/route.ts` around lines 24 - 32, Replace the
unbounded Promise.all over dbProjects that calls fetchGitMetrics with a
bounded-concurrency or batched approach (e.g., use p-limit or process dbProjects
in chunks) to avoid hitting GitHub rate limits; ensure each call to
fetchGitMetrics is wrapped in a try/catch so failures or 403s for a single
project do not reject the whole operation, and still populate
metricsByProject[project.title] only when metrics is successfully returned.

Comment thread src/app/fonts.ts
import { Press_Start_2P } from 'next/font/google'

// Toggle this to true to enable the pixel font
export const ENABLE_PIXEL_FONT = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ENABLE_PIXEL_FONT is hardcoded to true — intended for production?

This merges into master with the pixel font permanently enabled. Press_Start_2P as a body font will significantly impact readability. If this is a development/playground toggle, consider defaulting to false or driving it via an environment variable so it doesn't accidentally ship.

-export const ENABLE_PIXEL_FONT = true
+export const ENABLE_PIXEL_FONT = process.env.NEXT_PUBLIC_ENABLE_PIXEL_FONT === 'true'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const ENABLE_PIXEL_FONT = true
export const ENABLE_PIXEL_FONT = process.env.NEXT_PUBLIC_ENABLE_PIXEL_FONT === 'true'
🤖 Prompt for AI Agents
In `@src/app/fonts.ts` at line 6, The constant ENABLE_PIXEL_FONT is hardcoded
true; change it to default to false and drive it from an environment/build flag
instead so it can't ship enabled accidentally—replace the literal export of
ENABLE_PIXEL_FONT with logic that reads a public env var (e.g.,
NEXT_PUBLIC_ENABLE_PIXEL_FONT or similar) and converts it to a boolean, falling
back to false when unset or invalid; ensure the flag is exposed to the client
runtime (or build-time) per your framework conventions and update any related
docs/tests that assume the pixel font is enabled.

Comment on lines +41 to +43
{animationKeyframesCSS && (
<style dangerouslySetInnerHTML={{ __html: animationKeyframesCSS }} />
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find where animationKeyframesCSS is defined and how it flows into component-preview.tsx
rg -n "animationKeyframesCSS" --type=ts --type=tsx -B 3 -A 3

Repository: remcostoeten/remcostoeten.nl

Length of output: 99


🏁 Script executed:

# Also search in the parent directory structure to find how this component is used
fd . src/components/component-studio --type f --name "*.tsx" --name "*.ts" | head -20

Repository: remcostoeten/remcostoeten.nl

Length of output: 322


🏁 Script executed:

# Look at the component-preview.tsx file to see full context around line 42
sed -n '1,80p' src/components/component-studio/playground/component-preview.tsx

Repository: remcostoeten/remcostoeten.nl

Length of output: 2266


🏁 Script executed:

# Find where ComponentPreview is imported and used
rg "ComponentPreview" --type ts -l 2>/dev/null | head -10

Repository: remcostoeten/remcostoeten.nl

Length of output: 201


🏁 Script executed:

# Search for where animationKeyframesCSS is set/assigned (excluding the component-preview file itself)
rg "animationKeyframesCSS\s*=" --type ts | head -20

Repository: remcostoeten/remcostoeten.nl

Length of output: 191


🏁 Script executed:

# Find files that might generate or construct animation keyframes CSS
rg "keyframes" --type ts -l | head -15

Repository: remcostoeten/remcostoeten.nl

Length of output: 451


🏁 Script executed:

# Look at playground-layout.tsx to see how animationCSS is constructed
cat -n src/components/component-studio/playground/playground-layout.tsx | head -150

Repository: remcostoeten/remcostoeten.nl

Length of output: 5609


🏁 Script executed:

# Check animation-studio.tsx to understand how keyframes are generated
wc -l src/components/component-studio/playground/animation-studio/animation-studio.tsx

Repository: remcostoeten/remcostoeten.nl

Length of output: 156


🏁 Script executed:

# Check animation-export.tsx to understand how CSS is built
cat -n src/components/component-studio/playground/animation-studio/animation-export.tsx

Repository: remcostoeten/remcostoeten.nl

Length of output: 4775


🏁 Script executed:

# Continue reading playground-layout.tsx to find where animationCSS is constructed
sed -n '150,300p' src/components/component-studio/playground/playground-layout.tsx

Repository: remcostoeten/remcostoeten.nl

Length of output: 5847


🏁 Script executed:

# Search for where animationCSS is defined/computed in playground-layout.tsx
rg -n "animationCSS" src/components/component-studio/playground/playground-layout.tsx -B 2 -A 5

Repository: remcostoeten/remcostoeten.nl

Length of output: 828


🏁 Script executed:

# Look at animation-studio.tsx to understand how keyframes are created/edited (around AnimationExport usage)
grep -n "AnimationExport\|animationCSS\|keyframes" src/components/component-studio/playground/animation-studio/animation-studio.tsx | head -30

Repository: remcostoeten/remcostoeten.nl

Length of output: 408


🏁 Script executed:

# Check animation-studio.tsx for UI validation or constraints on keyframe editing
sed -n '1,100p' src/components/component-studio/playground/animation-studio/animation-studio.tsx

Repository: remcostoeten/remcostoeten.nl

Length of output: 2814


🏁 Script executed:

# Search for any validation or sanitization functions related to keyframes/CSS
rg "validate|sanitize|escape" src/components/component-studio/playground/ --type ts

Repository: remcostoeten/remcostoeten.nl

Length of output: 54


🏁 Script executed:

# Check types.ts to see the structure of keyframes
cat -n src/components/component-studio/playground/types.ts | head -100

Repository: remcostoeten/remcostoeten.nl

Length of output: 2836


🏁 Script executed:

# Check KeyframeEditor to see if there's any validation on input
cat -n src/components/component-studio/playground/animation-studio/keyframe-editor.tsx | head -150

Repository: remcostoeten/remcostoeten.nl

Length of output: 5734


🏁 Script executed:

# Search for SET_KEYFRAME_PROPERTY to see how keyframe values are validated in the reducer
rg -n "SET_KEYFRAME_PROPERTY" src/components/component-studio/playground/playground-layout.tsx -B 5 -A 5

Repository: remcostoeten/remcostoeten.nl

Length of output: 530


dangerouslySetInnerHTML injects user-controlled CSS values without validation.

The animationKeyframesCSS string is built directly from the keyframe editor, which allows users to input arbitrary CSS property names and values with no validation beyond trimming. Users can inject CSS attacks such as url('data:...') or SVG data URIs with embedded event handlers, enabling data exfiltration and potential XSS in some contexts.

Validate keyframe inputs before injecting into the style tag — e.g., allowlist permitted CSS properties, escape/sanitize values, or build the <style> DOM element programmatically rather than via dangerouslySetInnerHTML.

🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 41-41: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.3.13)

[error] 42-42: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🤖 Prompt for AI Agents
In `@src/components/component-studio/playground/component-preview.tsx` around
lines 41 - 43, The component currently injects user-edited CSS via
animationKeyframesCSS into a <style> tag using dangerouslySetInnerHTML; replace
this by sanitizing and/or programmatically constructing the style content:
validate keyframe inputs in the keyframe editor pipeline (allowlist permitted
CSS property names and patterns and reject or escape unsafe values like
urls/data URIs), or build the style element via DOM APIs inside ComponentPreview
(or the function that produces animationKeyframesCSS) and set textContent
instead of using dangerouslySetInnerHTML; ensure you reference and sanitize
values that feed animationKeyframesCSS and centralize the validation logic so
only safe CSS is emitted.

Comment on lines +196 to +261
// Generate a stable animation name that changes only when keyframes/bezier/duration change
// This forces the browser to re-create the animation when those values change
const animKeyframeHash = useMemo(() => {
const keyframes = state.editedKeyframes ?? activeAnimSchema?.keyframes ?? {}
const [x1, y1, x2, y2] = state.bezierValue
return JSON.stringify({ keyframes, x1, y1, x2, y2, d: state.animationDuration })
}, [state.editedKeyframes, activeAnimSchema, state.bezierValue, state.animationDuration])

useEffect(() => {
animNameRef.current = `pg-anim-${Date.now()}`
}, [animKeyframeHash])

// Build animation CSS for the live preview
// Uses the negative animation-delay trick to scrub: paused animation at a specific point
const animationCSS = useMemo(() => {
if (!activeAnimSchema) return { style: undefined, css: undefined }

const keyframes = state.editedKeyframes ?? activeAnimSchema.keyframes
const name = animNameRef.current
const [x1, y1, x2, y2] = state.bezierValue

const framesCSS = Object.entries(keyframes)
.map(
([offset, props]) =>
`${offset} { ${Object.entries(props)
.map(([k, v]) => `${k}: ${v}`)
.join("; ")} }`
)
.join("\n ")

const css = `@keyframes ${name} {\n ${framesCSS}\n}`

// Determine iteration count
const effectiveIterCount = state.repeatCount > 0
? state.repeatCount
: state.loop
? "infinite"
: activeAnimSchema.iterationCount

const iterStr = effectiveIterCount === "infinite"
? "infinite"
: String(effectiveIterCount)

if (state.isPlaying && !state.isInDelay) {
// Playing: let the animation run with CSS
const style: React.CSSProperties = {
animation: `${name} ${state.animationDuration}ms cubic-bezier(${x1}, ${y1}, ${x2}, ${y2}) ${iterStr} ${state.direction} ${state.fillMode}`,
animationPlayState: "running",
}
return { style, css }
} else {
// Paused/scrubbing: use negative animation-delay to scrub to the current progress
const delayMs = -(state.playbackProgress / 100) * state.animationDuration
const style: React.CSSProperties = {
animation: `${name} ${state.animationDuration}ms cubic-bezier(${x1}, ${y1}, ${x2}, ${y2}) 1 ${state.direction} ${state.fillMode}`,
animationPlayState: "paused",
animationDelay: `${delayMs}ms`,
}
return { style, css }
}
}, [
activeAnimSchema, state.editedKeyframes, state.bezierValue,
state.animationDuration, state.isPlaying, state.isInDelay,
state.playbackProgress, state.loop, state.repeatCount,
state.direction, state.fillMode, animKeyframeHash,
])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stale ref: animNameRef is updated in useEffect (post-render) but read in useMemo (during render).

When animKeyframeHash changes, the useMemo at Line 210 recalculates during the current render, reading the old animNameRef.current. The useEffect at Line 204 updates the ref after render completes. Since ref mutations don't trigger re-renders, the newly generated animation name is never picked up — defeating the intent to force the browser to re-create the @keyframes rule.

Use useState instead of useRef so the name update triggers a re-render, or derive the name directly inside the useMemo:

🔧 Option A: derive the name inside useMemo (simplest)

Remove the useRef and useEffect, and compute the name inside the useMemo:

-  const animNameRef = useRef(`pg-anim-${Date.now()}`)
-
   ...
-
-  useEffect(() => {
-    animNameRef.current = `pg-anim-${Date.now()}`
-  }, [animKeyframeHash])
-
   const animationCSS = useMemo(() => {
     if (!activeAnimSchema) return { style: undefined, css: undefined }
 
     const keyframes = state.editedKeyframes ?? activeAnimSchema.keyframes
-    const name = animNameRef.current
+    // Use a hash-derived name to force CSS re-creation on keyframe/timing changes
+    const name = `pg-anim-${btoa(animKeyframeHash).slice(0, 8).replace(/[^a-zA-Z0-9]/g, '')}`
     const [x1, y1, x2, y2] = state.bezierValue
🔧 Option B: use useState instead of useRef
-  const animNameRef = useRef(`pg-anim-${Date.now()}`)
+  const [animName, setAnimName] = useState(`pg-anim-${Date.now()}`)
 
   ...
 
   useEffect(() => {
-    animNameRef.current = `pg-anim-${Date.now()}`
+    setAnimName(`pg-anim-${Date.now()}`)
   }, [animKeyframeHash])
 
   ...
-    const name = animNameRef.current
+    const name = animName

And add animName to the useMemo dependency array (replacing animKeyframeHash).

🤖 Prompt for AI Agents
In `@src/components/component-studio/playground/playground-layout.tsx` around
lines 196 - 261, The computed animation name stored in animNameRef (set in the
useEffect that depends on animKeyframeHash) is stale when read inside the
animationCSS useMemo because useEffect runs after render; replace this with
either (A) derive the name inside the same useMemo that builds animationCSS
(remove animNameRef and the useEffect) so the name is fresh whenever
animKeyframeHash changes, or (B) switch animNameRef to a state value (e.g.,
animName via useState and setAnimName in an effect) and include that state
(animName) in the animationCSS useMemo dependencies so the new name triggers a
re-render and the fresh name is used when building `@keyframes`; update references
to animNameRef.current to use the new local name/state.

Comment on lines +60 to +76
// Add secret auth trigger
const [clickCount, setClickCount] = useState(0)
const { openAuthModal } = useVimAuth()

const handleSecretAuth = () => {
const newCount = clickCount + 1
setClickCount(newCount)

if (newCount === 5) {
toast.success('Secret auth triggered!')
openAuthModal()
setClickCount(0)
}

// Reset count if no click within 1 second
setTimeout(() => setClickCount(0), 1000)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stale timeouts will reset click count mid-sequence.

Each click creates a new independent setTimeout that sets clickCount to 0. These are never cleared, so timeouts from earlier clicks in a sequence can fire and reset the counter while the user is still clicking. For example: 4 fast clicks, brief pause (~900ms), then resume — the first timeout fires and resets the counter to 0 before click 5.

Use a ref to track and clear the previous timeout on each click.

Proposed fix
+ import { useState, useMemo, useRef } from 'react'
- import { useState, useMemo } from 'react'
 	// Add secret auth trigger
 	const [clickCount, setClickCount] = useState(0)
+	const clickTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
 	const { openAuthModal } = useVimAuth()
 
 	const handleSecretAuth = () => {
+		if (clickTimeoutRef.current) {
+			clearTimeout(clickTimeoutRef.current)
+		}
+
 		const newCount = clickCount + 1
 		setClickCount(newCount)
 
 		if (newCount === 5) {
 			toast.success('Secret auth triggered!')
 			openAuthModal()
 			setClickCount(0)
+			return
 		}
 
 		// Reset count if no click within 1 second
-		setTimeout(() => setClickCount(0), 1000)
+		clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Add secret auth trigger
const [clickCount, setClickCount] = useState(0)
const { openAuthModal } = useVimAuth()
const handleSecretAuth = () => {
const newCount = clickCount + 1
setClickCount(newCount)
if (newCount === 5) {
toast.success('Secret auth triggered!')
openAuthModal()
setClickCount(0)
}
// Reset count if no click within 1 second
setTimeout(() => setClickCount(0), 1000)
}
import { useState, useMemo, useRef } from 'react'
// Add secret auth trigger
const [clickCount, setClickCount] = useState(0)
const clickTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const { openAuthModal } = useVimAuth()
const handleSecretAuth = () => {
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current)
}
const newCount = clickCount + 1
setClickCount(newCount)
if (newCount === 5) {
toast.success('Secret auth triggered!')
openAuthModal()
setClickCount(0)
return
}
// Reset count if no click within 1 second
clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000)
}
🤖 Prompt for AI Agents
In `@src/components/layout/footer.tsx` around lines 60 - 76, The click-count
timeout in handleSecretAuth uses independent setTimeouts that are never cleared,
causing stale timers to reset clickCount mid-sequence; fix by adding a ref
(e.g., timeoutRef via useRef<number | null>) to store the active timeout id,
call clearTimeout(timeoutRef.current) at the start of handleSecretAuth before
creating a new setTimeout, assign the new timeout id to timeoutRef.current, and
reset timeoutRef.current to null when the timeout fires or when you trigger
openAuthModal; also add a useEffect cleanup to clearTimeout(timeoutRef.current)
on unmount.

Comment on lines +36 to +44
export function ProjectShowcase({ visibleRowCount = 6 }: Props) {
const { data, isLoading } = useQuery({
queryKey: ['project-showcase'],
queryFn: fetchProjectShowcaseData,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No error state — failed fetch results in an infinite skeleton.

If fetchProjectShowcaseData fails (after 1 retry), useQuery sets isLoading: false and data remains undefined. The guard on line 93 (isLoading || !data) will keep rendering the skeleton indefinitely with no error feedback to the user.

Consider destructuring isError/error from useQuery and rendering an error fallback:

Proposed fix
-	const { data, isLoading } = useQuery({
+	const { data, isLoading, isError } = useQuery({
 		queryKey: ['project-showcase'],
 		queryFn: fetchProjectShowcaseData,
 		staleTime: 5 * 60 * 1000,
 		gcTime: 30 * 60 * 1000,
 		retry: 1,
 		refetchOnWindowFocus: false
 	})

 ...

-	if (isLoading || !data) {
+	if (isLoading || !data) {
+		return <ProjectShowcaseSkeleton visibleRowCount={visibleRowCount} />
+	}
+
+	if (isError) {
+		return null // or a minimal error message
+	}

Also applies to: 93-95

🤖 Prompt for AI Agents
In `@src/components/projects/components/project-showcase.tsx` around lines 36 -
44, Destructure isError, error and refetch from the useQuery call in
ProjectShowcase (alongside data and isLoading) and change the render guard so
the skeleton shows only while isLoading; when isError or data is undefined
render an error fallback UI that displays error?.message (and a retry button
that calls refetch) instead of the skeleton. Specifically update the useQuery
call that uses fetchProjectShowcaseData to include isError/error/refetch and
replace the current "isLoading || !data" check with an ordered conditional: if
(isLoading) -> skeleton, else if (isError || !data) -> error fallback (show
error message and retry), else -> render the normal project grid.

Comment on lines 29 to 66
export function WebVitalsReporter() {
if (process.env.NODE_ENV !== 'development') {
return null
}

useEffect(() => {
const logMetric = (metric: Metric) => {
let cancelled = false

const setup = async () => {
const { onCLS, onFCP, onLCP, onTTFB } = await import(
'web-vitals'
)
if (cancelled) return

const logMetric = (metric: Metric) => {
console.log(
`%c${metric.name}%c ${formatValue(metric.name, metric.value)} ${getEmoji(metric.rating)}`,
'font-weight: bold; color: #10b981',
'color: inherit'
)
}

console.log(
`%c${metric.name}%c ${formatValue(metric.name, metric.value)} ${getEmoji(metric.rating)}`,
'font-weight: bold; color: #10b981',
'color: inherit'
'%c📊 Web Vitals Reporter Active',
'font-size: 14px; font-weight: bold'
)
}

console.log(
'%c📊 Web Vitals Reporter Active',
'font-size: 14px; font-weight: bold'
)
onLCP(logMetric)
onFCP(logMetric)
onCLS(logMetric)
onTTFB(logMetric)
}

onLCP(logMetric)
onFCP(logMetric)
onCLS(logMetric)
onTTFB(logMetric)
void setup()
return () => {
cancelled = true
}
}, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Hooks violation: useEffect called after early return.

The early return on lines 30–32 causes useEffect on line 34 to be skipped in non-development builds, violating the Rules of Hooks. Biome correctly flags this. Even though NODE_ENV is a build-time constant, this pattern is fragile and breaks linting. Move the guard inside the effect.

Proposed fix
 export function WebVitalsReporter() {
-	if (process.env.NODE_ENV !== 'development') {
-		return null
-	}
-
 	useEffect(() => {
+		if (process.env.NODE_ENV !== 'development') return
+
 		let cancelled = false
 
 		const setup = async () => {
@@ ...
 		void setup()
 		return () => {
 			cancelled = true
 		}
 	}, [])
 
-	return null
+	return null
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function WebVitalsReporter() {
if (process.env.NODE_ENV !== 'development') {
return null
}
useEffect(() => {
const logMetric = (metric: Metric) => {
let cancelled = false
const setup = async () => {
const { onCLS, onFCP, onLCP, onTTFB } = await import(
'web-vitals'
)
if (cancelled) return
const logMetric = (metric: Metric) => {
console.log(
`%c${metric.name}%c ${formatValue(metric.name, metric.value)} ${getEmoji(metric.rating)}`,
'font-weight: bold; color: #10b981',
'color: inherit'
)
}
console.log(
`%c${metric.name}%c ${formatValue(metric.name, metric.value)} ${getEmoji(metric.rating)}`,
'font-weight: bold; color: #10b981',
'color: inherit'
'%c📊 Web Vitals Reporter Active',
'font-size: 14px; font-weight: bold'
)
}
console.log(
'%c📊 Web Vitals Reporter Active',
'font-size: 14px; font-weight: bold'
)
onLCP(logMetric)
onFCP(logMetric)
onCLS(logMetric)
onTTFB(logMetric)
}
onLCP(logMetric)
onFCP(logMetric)
onCLS(logMetric)
onTTFB(logMetric)
void setup()
return () => {
cancelled = true
}
}, [])
export function WebVitalsReporter() {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return
let cancelled = false
const setup = async () => {
const { onCLS, onFCP, onLCP, onTTFB } = await import(
'web-vitals'
)
if (cancelled) return
const logMetric = (metric: Metric) => {
console.log(
`%c${metric.name}%c ${formatValue(metric.name, metric.value)} ${getEmoji(metric.rating)}`,
'font-weight: bold; color: `#10b981`',
'color: inherit'
)
}
console.log(
'%c📊 Web Vitals Reporter Active',
'font-size: 14px; font-weight: bold'
)
onLCP(logMetric)
onFCP(logMetric)
onCLS(logMetric)
onTTFB(logMetric)
}
void setup()
return () => {
cancelled = true
}
}, [])
}
🧰 Tools
🪛 Biome (2.3.13)

[error] 34-34: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

Hooks should not be called after an early return.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
In `@src/components/seo/web-vitals-reporter.tsx` around lines 29 - 66, The
component WebVitalsReporter currently returns early before calling useEffect
which violates the Rules of Hooks; remove the top-level NODE_ENV early return so
useEffect is always invoked, then move the development-mode guard inside the
effect (at the start of the effect callback) to bail out immediately if
process.env.NODE_ENV !== 'development'; keep the existing setup async function,
cancelled flag, logMetric, and cleanup logic (returning the cleanup that sets
cancelled) and maintain the empty dependency array so onLCP/onFCP/onCLS/onTTFB
are only registered in development builds.

Comment on lines +46 to +51
const effectiveVariant: PillVariant =
variant === "ghost" && ghostBehavior === "always"
? "ghost"
: variant === "ghost" && ghostBehavior === "idle" && isIdle
? "ghost"
: variant

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

effectiveVariant logic doesn't correctly suppress ghost when idle is false.

When variant="ghost" and ghostBehavior="idle", the intent is to show ghost styling only while idle. However, when isIdle is false, the ternary falls through to the final branch which returns variant — still "ghost". The same issue applies to ghostBehavior="never" with variant="ghost" — it always renders as ghost.

The fallback should return a non-ghost variant (e.g., "default") when the ghost effect is inactive:

Proposed fix
   const effectiveVariant: PillVariant =
-    variant === "ghost" && ghostBehavior === "always"
-      ? "ghost"
-      : variant === "ghost" && ghostBehavior === "idle" && isIdle
-        ? "ghost"
-        : variant
+    ghostBehavior === "always"
+      ? "ghost"
+      : ghostBehavior === "idle" && isIdle
+        ? "ghost"
+        : ghostBehavior === "never"
+          ? variant
+          : variant === "ghost"
+            ? "default"
+            : variant

Alternatively, consider accepting a separate activeVariant prop so the non-ghost appearance is explicitly configurable rather than hardcoded to "default".

🤖 Prompt for AI Agents
In `@src/components/ui/hero-pill.tsx` around lines 46 - 51, The effectiveVariant
computation incorrectly falls back to the original variant (which may be
"ghost") when the ghost effect is inactive; update the logic in effectiveVariant
(and the PillVariant usage) so that when variant === "ghost" but ghostBehavior
is "idle" and isIdle is false, or when ghostBehavior is "never", it returns a
non-ghost variant (e.g., "default") instead of variant; implement this by
changing the ternary to explicitly return "default" (or use a new prop
activeVariant to allow callers to override the non-ghost appearance) whenever
the ghost behavior is not active, and update any call sites that may rely on the
old fallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant