Skip to content

Enhances achievement system with new features#37

Merged
kiliantyler merged 42 commits intomainfrom
feat/add-existing-achievements
Sep 16, 2025
Merged

Enhances achievement system with new features#37
kiliantyler merged 42 commits intomainfrom
feat/add-existing-achievements

Conversation

@kiliantyler
Copy link
Copy Markdown
Collaborator

@kiliantyler kiliantyler commented Sep 16, 2025

Adds new achievements, improves UI, and enhances feature detection.

  • Introduces new achievements related to site exploration, browser usage (Ladybird), and meta-achievements.
  • Implements client-side logic for unlocking achievements based on user actions and browser detection.
  • Enhances the achievement system with features like reset functionality, cross-tab synchronization, and UI improvements.
  • Uses a pre-hydration script to accurately display achievements and related features such as a Pet Gallery link.
  • Refactors the navigation to dynamically include links based on achievement status.

Summary by CodeRabbit

  • New Features

    • Expanded Achievements: new badges, hints, auto-unlock behavior, unified achievement cards, secret keyboard unlock, and a reset-with-confirmation flow.
    • Pet Gallery now unlocks when all pets are seen; several pages auto-award visit-based achievements.
  • UI

    • Nav gains Achievements and Pet Gallery entries with entry/sparkle animations and pre-hydration reveals.
    • Header and theme menu simplified; consistent cross-breakpoint behavior.
    • External quick-fact links open in a new tab; terminal link updated.
  • Chores

    • Added a presence detection build/runtime and related build scripts.

Enhances user experience by opening links in a new tab.

This prevents users from navigating away from the current page
when clicking on external links.
Changes the description on achievement cards to show the unlock hint when the achievement is locked.
Updates type definition for achievement entries.

This provides a better user experience by giving the user a clue about how to unlock the achievement.
@vercel
Copy link
Copy Markdown

vercel bot commented Sep 16, 2025

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

Project Deployment Preview Comments Updated (UTC)
kil-dev Ready Ready Preview Comment Sep 16, 2025 6:26pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Sep 16, 2025

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a presence runtime and build pipeline, refactors and expands the achievements system and UI (new components, provider sync/reset, auto-unlock), wires presence-gated pre-hydration nav reveals, simplifies header/theme APIs, removes the pets drawer in favor of achievements, and adds related scripts, types, styles, and utilities.

Changes

Cohort / File(s) Summary of changes
Presence runtime & build
scripts/build-presence-runtime.ts, src/lib/presence-script.ts, src/types/presence-bundle.d.ts, eslint.config.js, .gitignore, package.json, src/lib/achievements.ts, src/lib/presence-bundle.ts*
Adds esbuild-based bundling script for a browser presence runtime, generated presence-bundle module and ambient type, ESLint ignores/overrides, gitignore entry, new package scripts/deps, and a buildPresenceScript API that uses PRESENCE_RUNTIME_BUNDLE.
App wiring (presence + achievements)
src/app/layout.tsx, src/styles/globals.css
Injects inline pre-hydration presence scripts into document head and adds CSS rules/animations to reveal Achievements and Pet Gallery items based on root data attributes and one-time “just unlocked” sparkle flags.
Achievements core & persistence
src/lib/achievements.ts, src/components/providers/achievements-provider.tsx
Converts achievements to a data-driven registry, adds new IDs, sanitization/parsing helpers, cross-tab storage sync, auto-unlock rule, session sparkle gating, root data-attributes, and exposes a reset() method on the achievements context.
Achievements UI & helpers
src/components/layout/achievements/achievement-card.tsx, src/components/layout/achievements/achievement-reset-button.tsx, src/components/layout/achievements/ladybird-secret-listener.tsx, src/components/layout/achievements/unlock-on-mount.tsx, src/app/achievements/page.tsx
New AchievementCard composite, Reset dialog/button, secret key listener, UnlockOnMount helper, and rewired Achievements page to use the new components and behaviors.
Header & navigation
src/components/layout/header.tsx, src/components/layout/header/home-logo.tsx, src/components/layout/header/nav-lava.tsx, src/components/layout/header/mobile-nav.tsx
Simplifies Header and HomeLogo (removes condensed/responsive state), introduces internal NavLink abstraction for NavLava, and extends MobileNav to include dynamic Achievements/Pet Gallery items, updated positioning/animation logic and responsive breakpoint changes.
Theme toggle
src/components/ui/theme-toggle.tsx
Removes onFlyoutWidthChange/onOpenChange props, unifies behavior across breakpoints, broadens backdrop/scroll-lock, and simplifies animations/layout.
Pets flow & deletions
src/components/layout/about/pets/_content.tsx, src/components/layout/about/pets/pet-drawer.tsx
Replaces PetDrawer-driven gallery flow with achievement unlocking (awards PET_PARADE) and deletes the pet-drawer.tsx component.
Page unlock triggers
src/components/layout/about/_content.tsx, src/components/layout/experience/_content.tsx, src/components/layout/projects/_content.tsx
Adds UnlockOnMount to unlock page-specific achievements when those pages mount.
Home hero & UA util
src/components/layout/home/hero/profile-image.tsx, src/utils/ladybird.ts
Adds isLadybirdUA() utility and updates hero logic to gate Ladybird-related unlocks and session capture via the achievements API.
UI primitives & quick tweaks
src/components/ui/alert-dialog.tsx, src/components/layout/about/aboutme/quick-fact.tsx, src/lib/quickfacts.ts
New AlertDialog wrapper UI (Radix-based), external QuickFact links open in new tab with security attrs, and quickfacts data update (ghostty.org).
Types
src/types/presence-bundle.d.ts
Ambient module declaring PRESENCE_RUNTIME_BUNDLE: string.

Note: src/lib/presence-bundle.ts is generated at build-time by the new script and is referenced by the added gitignore/ESLint ignores.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Browser
  participant Layout as App Layout (<head>)
  participant Presence as PresenceRuntime (IIFE)
  participant DOM as document.documentElement
  participant Provider as AchievementsProvider

  Note over Layout,Presence: Pre-hydration presence gating
  Browser->>Layout: Load HTML with inline pre-achievements/pet-gallery scripts
  Layout-->>Presence: PresenceRuntime.initPresence({ key, attribute, cookieName? })
  Presence->>Presence: Read cookies, find key
  Presence-->>DOM: setAttribute(attribute, "true")

  Note over Browser,Provider: App hydration
  Browser->>Provider: Initialize context (reads storage/cookies)
  Provider->>DOM: Set data-has-... and data-...-just-unlocked (session-gated)
Loading
sequenceDiagram
  autonumber
  participant Page as Page Component
  participant Ach as useAchievements()
  participant Store as localStorage/cookie
  participant Tabs as Other Tabs
  participant DOM as document.documentElement

  Note over Page,Ach: Unlock on mount
  Page->>Ach: has(id)?
  alt Not unlocked
    Page->>Ach: unlock(id)
    Ach->>Store: Persist unlocked map
    Ach->>DOM: Update data-has-... / data-...-just-unlocked
    Ach-->>Page: Context state updates
    Tabs-->>Ach: storage event (sync)
    Ach-->>Tabs: Update state in other tabs
  else Already unlocked
    Ach-->>Page: No action
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60–90 minutes

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Enhances achievement system with new features" accurately reflects the PR's primary scope—adding new achievements, unlocking logic, UI components, reset and cross-tab sync—and is concise and clear for a teammate scanning history. It avoids noisy file lists or emojis while conveying the main intent. No further clarification is needed.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0f21331 and 7b6c406.

📒 Files selected for processing (1)
  • src/components/layout/header/nav-lava.tsx (6 hunks)

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (41)
src/lib/quickfacts.ts (1)

13-13: Ghostty domain fix — LGTM; minor typing note

The change is fine. Small follow‑up: QuickFact.href is typed as Route, but you’re passing external URLs here (and elsewhere). Consider widening to string to avoid brittle typing.

src/lib/presence-script.ts (2)

25-33: Fragile cookie name escaping; allow empty values

The regex escaping is easy to get wrong and doesn’t handle all specials reliably. Also allow empty cookie values.

Apply:

-function getCookieValue(name: string): string | null {
+function getCookieValue(name: string): string | null {
   try {
-    const re = new RegExp('(?:^|;\\s*)' + name.replace(/[.*+?^${}()|[\\]\\\\]/g, r => '\\' + r) + '=([^;]+)')
+    const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+    const re = new RegExp('(?:^|;\\s*)' + escaped + '=([^;]*)')
     const m = re.exec(document.cookie)
     return m ? m[1]! : null
   } catch {
     return null
   }
 }

58-70: Don’t throw in pre‑hydration path; fail closed

Throwing here can break early‑head scripts. Safer to catch and bail.

Apply:

 export function initPresence(config: PresenceScriptConfig): void {
-  validateConfig(config)
+  try {
+    validateConfig(config)
+  } catch {
+    return
+  }
scripts/build-presence-runtime.ts (3)

14-28: Fix: respect dev mode for minify and make output deterministic

Minification is always on. Tie it to isDev and assert a single output for safety.

-  const result = await build({
+  const result = await build({
     entryPoints: [entry],
     write: false,
     bundle: true,
-    minify: true,
+    minify: !isDev,
     platform: 'browser',
     format: 'iife',
     globalName: 'PresenceRuntime',
     target: ['es2018'],
     legalComments: 'none',
     sourcemap,
   })

And ensure exactly one output:

-  const outputFile = result.outputFiles?.[0]
-  if (!outputFile?.text) {
+  if (!result.outputFiles || result.outputFiles.length !== 1) {
+    throw new Error(`Expected a single output file, got ${result.outputFiles?.length ?? 0}`)
+  }
+  const outputFile = result.outputFiles[0]
+  if (!outputFile.text) {
     throw new Error('Failed to produce bundled presence runtime')
   }

36-39: Prettier guard for giant string literal

Prevent formatter churn on the generated bundle export.

-  const header =
-    `// @generated by scripts/build-presence-runtime.ts\n` + `// Do not edit manually.\n` + `/* eslint-disable */\n`
+  const header =
+    `// @generated by scripts/build-presence-runtime.ts\n` +
+    `// Do not edit manually.\n` +
+    `/* eslint-disable */\n` +
+    `/* prettier-ignore */\n`

43-47: Log stack traces on failure

A stack is more actionable than message-only.

-main().catch((err: unknown) => {
-  const msg = err instanceof Error ? err.message : String(err)
-  console.error(msg)
+main().catch((err: unknown) => {
+  if (err instanceof Error) console.error(err.stack ?? err.message)
+  else console.error(String(err))
   process.exit(1)
 })
src/components/ui/theme-toggle.tsx (3)

56-66: Safer scroll lock: lock both html and body, restore both

Locking only documentElement can fail on some engines and can fight with other overlays.

   useEffect(() => {
     if (typeof window === 'undefined') return
     if (open) {
-      const prev = document.documentElement.style.overflow
-      document.documentElement.style.overflow = 'hidden'
+      const prevHtml = document.documentElement.style.overflow
+      const prevBody = document.body.style.overflow
+      document.documentElement.style.overflow = 'hidden'
+      document.body.style.overflow = 'hidden'
       return () => {
-        document.documentElement.style.overflow = prev
+        document.documentElement.style.overflow = prevHtml
+        document.body.style.overflow = prevBody
       }
     }
   }, [open])

333-356: Backdrop semantics: presentational, not a button

Backdrop shouldn’t be focusable or announced as a button; global Esc handling already closes.

-      <div
-        aria-hidden={!open}
-        role="button"
-        tabIndex={open ? 0 : -1}
-        aria-label="Close theme menu"
-        onClick={() => {
+      <div
+        aria-hidden="true"
+        onClick={() => {
           setOpen(false)
           setOpenedViaKeyboard(false)
         }}
-        onKeyDown={e => {
-          if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
-            e.preventDefault()
-            setOpen(false)
-            setOpenedViaKeyboard(false)
-          }
-        }}
         className={cn(
-          'fixed inset-0 z-[115] transition-opacity duration-200',
+          'fixed inset-0 z-[115] transition-opacity duration-200',
           open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none',
           // Subtle flashy backdrop: tint + blur + vignette-ish gradient
           'bg-black/40 backdrop-blur-sm',
         )}
       />

Also applies to: 351-355


246-269: Minor: avoid layout query in hot key handler

matchMedia in keydown runs every key press. Cache at open-time or compute orientation via CSS class on container.

src/styles/globals.css (1)

333-356: DRY: factor sparkle/reveal rules

Achievements and Pet Gallery blocks are nearly identical. Consider a shared rule keyed by a generic data attribute to cut duplication.

Also applies to: 388-409

src/lib/achievements.ts (3)

19-104: Add a runtime assert to keep keys and definition.id in sync

Prevents accidental key/id drift without complicating types.

 export const ACHIEVEMENTS: Record<string, AchievementDefinition> = {
   ABOUT_AMBLER: {
     id: 'ABOUT_AMBLER',
@@
   },
 }
+
+// Dev-time guard: ensure keys match their `id`
+if (process.env.NODE_ENV !== 'production') {
+  for (const [k, v] of Object.entries(ACHIEVEMENTS)) {
+    if (v.id !== k) {
+      throw new Error(`ACHIEVEMENTS key/id mismatch: key="${k}" id="${v.id}"`)
+    }
+  }
+}

19-104: Nit: imageAlt mismatch

CONFUSED_CLICK imageAlt says “Confused Glimpse”. Rename to match the title.

-    imageAlt: 'Confused Glimpse',
+    imageAlt: 'Confused Click',

167-175: Export PresenceConfig and provide ID constants to avoid string literals

  • Export the config type for caller ergonomics.
  • Provide centralized ID constants to remove ad‑hoc 'as AchievementId' casts across files.
-import { PRESENCE_RUNTIME_BUNDLE } from '@/lib/presence-bundle'
-type PresenceConfig = { cookieName?: string; key: string; attribute: string }
+import { PRESENCE_RUNTIME_BUNDLE } from '@/lib/presence-bundle'
+export type PresenceConfig = { cookieName?: string; key: string; attribute: string }
 export function buildPresenceScript(cfg: PresenceConfig): string {
   cfg.cookieName ??= ACHIEVEMENTS_COOKIE_NAME
   const serializedCfg = JSON.stringify(cfg)
   const invoke = ';try{window.PresenceRuntime&&window.PresenceRuntime.initPresence(' + serializedCfg + ')}catch(e){}'
   return PRESENCE_RUNTIME_BUNDLE + invoke
 }
+
+// Centralized IDs to avoid string literals in callsites
+export const ACH_ID = Object.freeze(
+  Object.keys(ACHIEVEMENTS).reduce((acc, k) => {
+    acc[k as AchievementId] = k as AchievementId
+    return acc
+  }, {} as Record<AchievementId, AchievementId>),
+)
src/components/layout/about/pets/_content.tsx (2)

11-11: Avoid stringly-typed ID and unnecessary rerenders

  • Use a centralized ID constant (ACH_ID) to drop the cast.
  • Store seen IDs in a ref to avoid spurious rerenders while keeping logic unchanged.
-import { useAchievements } from '@/components/providers/achievements-provider'
+import { useAchievements } from '@/components/providers/achievements-provider'
+import { ACH_ID } from '@/lib/achievements'
@@
-  const [, setFlippedPetIds] = useState<Set<string>>(new Set())
+  const flippedRef = useRef<Set<string>>(new Set())
@@
-      setFlippedPetIds(prev => {
-        const next = new Set(prev)
+      {
+        const next = new Set(flippedRef.current)
         if (flipped) {
           next.add(petId)
         }
@@
-          if (allSeen && !celebratedRef.current) {
+          if (allSeen && !celebratedRef.current) {
             celebratedRef.current = true
-            if (!has('PET_PARADE')) {
-              unlock('PET_PARADE' as AchievementId)
+            if (!has(ACH_ID.PET_PARADE)) {
+              unlock(ACH_ID.PET_PARADE)
             }
           }
         }
-
-        return next
-      })
+        flippedRef.current = next
+      }
     },
-    [requiredPetIds, has, unlock],
+    [requiredPetIds, has, unlock],

Also applies to: 34-39, 45-46


11-11: If keeping state: add a TODO for future refactor

If you prefer state for now, add a TODO to switch to ref; it’s not user-visible but saves renders.

src/components/layout/experience/_content.tsx (1)

9-9: Remove the cast; rely on the AchievementId union for safety.

Same note as other pages: prefer a typed literal/const over as AchievementId.

-import { UnlockOnMount } from '@/components/layout/achievements/unlock-on-mount'
-import type { AchievementId } from '@/lib/achievements'
+import { UnlockOnMount } from '@/components/layout/achievements/unlock-on-mount'
@@
-      <UnlockOnMount id={'EXPERIENCE_EXPLORER' as AchievementId} />
+      <UnlockOnMount id={'EXPERIENCE_EXPLORER'} />

Or:

const EXPERIENCE_EXPLORER_ID: AchievementId = 'EXPERIENCE_EXPLORER'
<UnlockOnMount id={EXPERIENCE_EXPLORER_ID} />

Also applies to: 1-1, 2-2

src/app/layout.tsx (1)

35-43: Inline scripts: add CSP nonce and annotate the intentional use of dangerouslySetInnerHTML.

These are build-time strings, but linters will block and CSP might strip them. Add a nonce and suppress the Biome rule at the call site.

Minimal patch to silence Biome locally (adjust comment style if using ESLint instead):

-        <script
+        {/* biome-ignore lint/security/noDangerouslySetInnerHtml: pre-hydration, build-time string only */}
+        <script
           id="pre-achievements"
           dangerouslySetInnerHTML={{
             __html: buildPresenceScript({
               key: 'RECURSIVE_REWARD',
               attribute: 'data-has-achievements',
             }),
           }}
-        />
+          // nonce={cspNonce} // TODO: wire CSP nonce via headers
+        />
-        <script
+        {/* biome-ignore lint/security/noDangerouslySetInnerHtml: pre-hydration, build-time string only */}
+        <script
           id="pre-pet-gallery"
           dangerouslySetInnerHTML={{
             __html: buildPresenceScript({
               key: 'PET_PARADE',
               attribute: 'data-has-pet-gallery',
             }),
           }}
-        />
+          // nonce={cspNonce}
+        />

Also consider deduping the presence runtime so it isn’t inlined twice. Add a helper that returns one bundle + many inits, e.g., buildPresenceScripts([{key,attribute}, ...]), and replace the two tags with one.

If you want, I can sketch buildPresenceScripts in src/lib/achievements.ts.

Also applies to: 45-52

src/components/layout/about/_content.tsx (1)

9-9: Avoid masking typos with a cast; use a typed const.

Same pattern: remove as AchievementId and let the union type validate.

-import { UnlockOnMount } from '@/components/layout/achievements/unlock-on-mount'
-import type { AchievementId } from '@/lib/achievements'
+import { UnlockOnMount } from '@/components/layout/achievements/unlock-on-mount'
@@
-      <UnlockOnMount id={'ABOUT_AMBLER' as AchievementId} />
+      <UnlockOnMount id={'ABOUT_AMBLER'} />

Or:

const ABOUT_AMBLER_ID: AchievementId = 'ABOUT_AMBLER'
<UnlockOnMount id={ABOUT_AMBLER_ID} />

Also applies to: 1-1, 2-2

src/components/layout/achievements/unlock-on-mount.tsx (1)

7-15: Harden against StrictMode double-invocation to avoid redundant unlocks in dev.

The has gate helps, but double effects can still race pre-write. Track a local “requested” flag.

 export function UnlockOnMount({ id }: { id: AchievementId }) {
   const { has, unlock } = useAchievements()
 
-  useEffect(() => {
-    if (has(id)) return
-    unlock(id)
-  }, [has, unlock, id])
+  const requestedRef = useRef(false)
+  useEffect(() => {
+    if (requestedRef.current) return
+    requestedRef.current = true
+    if (has(id)) return
+    unlock(id)
+  }, [has, unlock, id])
 
   return null
 }
src/components/layout/achievements/ladybird-secret-listener.tsx (2)

20-35: Ignore IME composition to prevent false positives on non-Latin input.

Cheap guard to avoid consuming keystrokes mid-composition.

     function onKeyDown(e: KeyboardEvent) {
       if (shouldIgnoreTarget(e.target)) return
+      if ((e as any).isComposing) return
       const key = e.key
       if (!key || key.length !== 1) return

28-33: Prefer a typed const for the achievement ID over in-place casts.

Ensures compile-time validation without as.

-      if (bufferRef.current === target) {
+      if (bufferRef.current === target) {
         bufferRef.current = ''
-        const id = 'LADYBIRD_LANDING' as AchievementId
-        if (!has(id)) {
-          unlock(id)
-        }
+        const LB_ID: AchievementId = 'LADYBIRD_LANDING'
+        if (!has(LB_ID)) unlock(LB_ID)
       }
src/components/layout/home/hero/profile-image.tsx (2)

57-69: Replace repeated as AchievementId casts with typed constants.

Keeps strong typing and avoids masking typos for IDs used multiple times.

-    if (isLadybird) {
-      if (!has('LADYBIRD_LANDING' as AchievementId)) {
-        unlock('LADYBIRD_LANDING' as AchievementId)
-      }
-    }
-    if (useConfused) {
-      if (!has('CONFUSED_CLICK' as AchievementId)) {
-        unlock('CONFUSED_CLICK' as AchievementId)
-      }
-    }
+    const LB_ID: AchievementId = 'LADYBIRD_LANDING'
+    const CONFUSED_ID: AchievementId = 'CONFUSED_CLICK'
+    if (isLadybird && !has(LB_ID)) unlock(LB_ID)
+    if (useConfused && !has(CONFUSED_ID)) unlock(CONFUSED_ID)
@@
-    if (!has('GRUMPY_GLIMPSE' as AchievementId)) {
-      unlock('GRUMPY_GLIMPSE' as AchievementId)
-    }
+    const GRUMPY_ID: AchievementId = 'GRUMPY_GLIMPSE'
+    if (!has(GRUMPY_ID)) unlock(GRUMPY_ID)
@@
-      if (!has('GRUMPY_GLIMPSE' as AchievementId)) {
-        unlock('GRUMPY_GLIMPSE' as AchievementId)
-      }
+      const GRUMPY_ID: AchievementId = 'GRUMPY_GLIMPSE'
+      if (!has(GRUMPY_ID)) unlock(GRUMPY_ID)

Also applies to: 74-81, 87-99


57-69: Duplicate unlock path with LadybirdSecretListener—confirm if both are needed.

Both this component and LadybirdSecretListener unlock LADYBIRD_LANDING. It’s safe due to has(), but redundant.

Do you want to keep both for resilience, or consolidate unlock to a single place (listener or profile image) to trim work?

src/components/layout/achievements/achievement-reset-button.tsx (1)

18-45: Solid UX/A11y; consider destructive styling for the confirm action.

Looks good overall. To make the destructive nature clearer, style the confirm button as destructive (if your AlertDialog or Button supports it) and optionally wire up aria-describedby to the dialog description for tighter SR semantics.

src/components/layout/header/home-logo.tsx (3)

38-39: Tailwind class likely invalid: use duration-[250ms] instead of duration-250.

Tailwind doesn’t ship duration-250 by default. Use an arbitrary value.

Apply this diff:

-              'inline-block align-top overflow-hidden transition-[max-width] duration-250 ease-out relative max-w-[30ch]'
+              'inline-block align-top overflow-hidden transition-[max-width] duration-[250ms] ease-out relative max-w-[30ch]'
@@
-              'inline-block -translate-y-[0.125rem] transition-[margin] duration-250 ease-out will-change-[margin]'
+              'inline-block -translate-y-[0.125rem] transition-[margin] duration-[250ms] ease-out will-change-[margin]'

Also applies to: 87-90


17-20: Trim unnecessary dependencies in useMemo.

shortContent and longContent are constants; they don’t need to be in the deps array.

-  }, [isHovered, longContent, shortContent])
+  }, [isHovered])

21-35: Reduce verbosity in aria-label (optional).

Screen readers will announce the braces; consider using “Kilian.DevOps”/“kil.dev” without braces for aria-label.

src/components/layout/achievements/achievement-card.tsx (3)

32-38: Ladybird tip text is inverted.

The “Tip for friends not on Ladybird…” shows when the user IS on Ladybird. Swap the condition.

-  let description = isUnlocked ? def.cardDescription : def.unlockHint
-  if (id === ('LADYBIRD_LANDING' as AchievementId)) {
-    let friendTip = "Thanks for checking out the site on Ladybird! (I assume you did that and didn't cheat, right?)"
-    if (isLadybird) {
-      friendTip = 'Tip for friends not on Ladybird: on the Achievements page, type ‘ladybird!’ to unlock this.'
-    }
-    description = isUnlocked ? `${def.cardDescription} ${friendTip}` : `${def.unlockHint}`
-  }
+  let description = isUnlocked ? def.cardDescription : def.unlockHint
+  if (id === 'LADYBIRD_LANDING') {
+    const friendTip = isLadybird
+      ? "Thanks for checking out the site on Ladybird! (I assume you did that and didn't cheat, right?)"
+      : "Tip for friends not on Ladybird: on the Achievements page, type 'ladybird!' to unlock this."
+    description = isUnlocked ? `${def.cardDescription} ${friendTip}` : `${def.unlockHint}`
+  }

32-33: Remove unnecessary type assertion.

The comparison works with the literal; the cast adds noise.

-  if (id === ('LADYBIRD_LANDING' as AchievementId)) {
+  if (id === 'LADYBIRD_LANDING') {

55-69: Background image alt text (optional).

As this is a decorative/background image, consider empty alt to avoid duplicate verbosity; keep the meaningful title/description on the card body.

src/components/providers/achievements-provider.tsx (1)

86-94: Add ‘Secure’ on cookies when served over HTTPS.

Prevents cookie leakage on non‑TLS channels in production; gate it to keep local dev working.

-      document.cookie = `${ACHIEVEMENTS_COOKIE_NAME}=${encodeURIComponent(value)}; path=/; expires=${expires}; samesite=lax`
+      const secure = window.location?.protocol === 'https:' ? '; secure' : ''
+      document.cookie = `${ACHIEVEMENTS_COOKIE_NAME}=${encodeURIComponent(value)}; path=/; expires=${expires}; samesite=lax${secure}`
src/components/layout/header/mobile-nav.tsx (1)

323-459: Backdrop scroll lock only on small screens: good; consider restoring body overflow on route change mid‑animation.

Rare edge: if navigation fires before the cleanup runs, body overflow could stay hidden. Not a blocker.

src/app/achievements/page.tsx (3)

10-12: Drop unnecessary async/await around cookies()

cookies() is synchronous in Next.js App Router. Remove async on the component and the await here.

-export default async function AchievementsPage() {
+export default function AchievementsPage() {
   // Read per-user achievement state only for this page to keep other routes static
-  const cookieStore = await cookies()
+  const cookieStore = cookies()

13-13: Type simplification for entries

AchievementId resolves to string given ACHIEVEMENTS: Record<string, …>. You can simplify the type here.

-const entries: Array<[AchievementId, (typeof ACHIEVEMENTS)[AchievementId]]> = Object.entries(ACHIEVEMENTS)
+const entries = Object.entries(ACHIEVEMENTS) as Array<[AchievementId, (typeof ACHIEVEMENTS)[string]]>

29-34: Optional UX: surface unlocked first

Consider sorting to show unlocked achievements first (then by title) for quicker recognition.

-{entries.map(([id]) => (
+{entries
+  .sort(([a], [b]) => a.localeCompare(b))
+  .sort(([, aDef], [, bDef]) => Number(Boolean(unlocked[bDef.id])) - Number(Boolean(unlocked[aDef.id])))
+  .map(([id]) => (
src/components/layout/header/nav-lava.tsx (5)

255-292: Underline won’t animate without group; add group class

group-hover: requires a parent with group. Add it to the Link.

-      className={cn(
-        'relative z-10 rounded-md px-3 py-2 text-sm font-medium outline-none transition-colors',
+      className={cn(
+        'group relative z-10 rounded-md px-3 py-2 text-sm font-medium outline-none transition-colors',
         NAV_TEXT.base,
         NAV_TEXT.hover,
         isActive && (!hoveredKey || hoveredKey === href) ? NAV_TEXT.active : undefined,
         className,
       )}

219-230: Widen href type to allow externals

href: Route prevents external URLs at the type level even though isExternal is supported. Use string for flexibility.

-type NavLinkProps = {
-  href: Route
+type NavLinkProps = {
+  href: string

30-30: Unnecessary useMemo for a static import

NAVIGATION is a static import; memoization adds noise. Use a plain const.

-const items = React.useMemo(() => NAVIGATION, [])
+const items = NAVIGATION

44-55: Avoid magic number for indicator inset

Extract 8 into a named constant to document intent.

+const INDICATOR_INSET_PX = 8
 ...
-const width = targetRect.width - 8
+const width = targetRect.width - INDICATOR_INSET_PX

123-131: ARIA roles: prefer native nav semantics

Using role="menubar"/menuitem for site navigation can be misleading. Native

+ is adequate; consider dropping roles.

src/components/ui/alert-dialog.tsx (1)

41-44: Fix calc() spacing in Tailwind arbitrary value

calc() requires spaces around operators. Use underscores to encode spaces in Tailwind.

-          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
+          'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%_-_2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e2ac4e7 and 0f21331.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (30)
  • .gitignore (1 hunks)
  • eslint.config.js (2 hunks)
  • package.json (2 hunks)
  • scripts/build-presence-runtime.ts (1 hunks)
  • src/app/achievements/page.tsx (1 hunks)
  • src/app/layout.tsx (2 hunks)
  • src/components/layout/about/_content.tsx (1 hunks)
  • src/components/layout/about/aboutme/quick-fact.tsx (1 hunks)
  • src/components/layout/about/pets/_content.tsx (2 hunks)
  • src/components/layout/about/pets/pet-drawer.tsx (0 hunks)
  • src/components/layout/achievements/achievement-card.tsx (1 hunks)
  • src/components/layout/achievements/achievement-reset-button.tsx (1 hunks)
  • src/components/layout/achievements/ladybird-secret-listener.tsx (1 hunks)
  • src/components/layout/achievements/unlock-on-mount.tsx (1 hunks)
  • src/components/layout/experience/_content.tsx (1 hunks)
  • src/components/layout/header.tsx (1 hunks)
  • src/components/layout/header/home-logo.tsx (4 hunks)
  • src/components/layout/header/mobile-nav.tsx (12 hunks)
  • src/components/layout/header/nav-lava.tsx (6 hunks)
  • src/components/layout/home/hero/profile-image.tsx (2 hunks)
  • src/components/layout/projects/_content.tsx (1 hunks)
  • src/components/providers/achievements-provider.tsx (5 hunks)
  • src/components/ui/alert-dialog.tsx (1 hunks)
  • src/components/ui/theme-toggle.tsx (6 hunks)
  • src/lib/achievements.ts (2 hunks)
  • src/lib/presence-script.ts (1 hunks)
  • src/lib/quickfacts.ts (1 hunks)
  • src/styles/globals.css (2 hunks)
  • src/types/presence-bundle.d.ts (1 hunks)
  • src/utils/ladybird.ts (1 hunks)
💤 Files with no reviewable changes (1)
  • src/components/layout/about/pets/pet-drawer.tsx
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx,js,jsx,cjs,mjs}

📄 CodeRabbit inference engine (.cursor/rules/posthog-integration.mdc)

**/*.{ts,tsx,js,jsx,cjs,mjs}: Never hardcode or hallucinate the PostHog API key; always read it from the value populated in the .env file
Create new feature flag names that are clear and descriptive
Gate flag-dependent code on a check that verifies the flag’s values are valid and expected
If a custom property for a person or event is referenced in two or more files or in two or more callsites within the same file, centralize the property name in an enum (TS) or const object (JS)

Files:

  • src/types/presence-bundle.d.ts
  • src/components/layout/achievements/achievement-reset-button.tsx
  • src/components/layout/achievements/unlock-on-mount.tsx
  • src/components/layout/experience/_content.tsx
  • src/components/layout/achievements/ladybird-secret-listener.tsx
  • src/lib/quickfacts.ts
  • src/utils/ladybird.ts
  • src/components/layout/about/aboutme/quick-fact.tsx
  • src/components/layout/about/pets/_content.tsx
  • eslint.config.js
  • src/components/layout/about/_content.tsx
  • src/components/layout/projects/_content.tsx
  • src/components/layout/achievements/achievement-card.tsx
  • src/lib/presence-script.ts
  • src/components/layout/home/hero/profile-image.tsx
  • src/app/layout.tsx
  • scripts/build-presence-runtime.ts
  • src/components/layout/header/home-logo.tsx
  • src/components/ui/theme-toggle.tsx
  • src/components/ui/alert-dialog.tsx
  • src/lib/achievements.ts
  • src/components/layout/header.tsx
  • src/components/providers/achievements-provider.tsx
  • src/components/layout/header/mobile-nav.tsx
  • src/components/layout/header/nav-lava.tsx
  • src/app/achievements/page.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/posthog-integration.mdc)

In TypeScript, store feature flag names in an enum with members written in UPPERCASE_WITH_UNDERSCORE and use a consistent naming convention

Files:

  • src/types/presence-bundle.d.ts
  • src/components/layout/achievements/achievement-reset-button.tsx
  • src/components/layout/achievements/unlock-on-mount.tsx
  • src/components/layout/experience/_content.tsx
  • src/components/layout/achievements/ladybird-secret-listener.tsx
  • src/lib/quickfacts.ts
  • src/utils/ladybird.ts
  • src/components/layout/about/aboutme/quick-fact.tsx
  • src/components/layout/about/pets/_content.tsx
  • src/components/layout/about/_content.tsx
  • src/components/layout/projects/_content.tsx
  • src/components/layout/achievements/achievement-card.tsx
  • src/lib/presence-script.ts
  • src/components/layout/home/hero/profile-image.tsx
  • src/app/layout.tsx
  • scripts/build-presence-runtime.ts
  • src/components/layout/header/home-logo.tsx
  • src/components/ui/theme-toggle.tsx
  • src/components/ui/alert-dialog.tsx
  • src/lib/achievements.ts
  • src/components/layout/header.tsx
  • src/components/providers/achievements-provider.tsx
  • src/components/layout/header/mobile-nav.tsx
  • src/components/layout/header/nav-lava.tsx
  • src/app/achievements/page.tsx
**/*.{js,jsx,cjs,mjs}

📄 CodeRabbit inference engine (.cursor/rules/posthog-integration.mdc)

In JavaScript, store feature flag names as string values in a const object (simulating an enum) with members written in UPPERCASE_WITH_UNDERSCORE and use a consistent naming convention

Files:

  • eslint.config.js
🧠 Learnings (1)
📚 Learning: 2025-09-10T03:43:31.223Z
Learnt from: kiliantyler
PR: kiliantyler/kil.dev#2
File: src/components/providers/theme-provider.tsx:100-103
Timestamp: 2025-09-10T03:43:31.223Z
Learning: In the kil.dev project's theme system, non-base themes like 'cyberpunk' and 'halloween' are self-contained with complete CSS variable definitions. They don't inherit from base theme classes like '.dark'. The --theme-darklike variable is a flag for card readability styling, not an indicator of CSS inheritance. Theme application should remove all existing theme classes and apply only the selected theme class.

Applied to files:

  • src/styles/globals.css
🧬 Code graph analysis (17)
src/components/layout/achievements/achievement-reset-button.tsx (1)
src/components/providers/achievements-provider.tsx (1)
  • useAchievements (271-275)
src/components/layout/achievements/unlock-on-mount.tsx (2)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/components/providers/achievements-provider.tsx (1)
  • useAchievements (271-275)
src/components/layout/experience/_content.tsx (2)
src/components/layout/achievements/unlock-on-mount.tsx (1)
  • UnlockOnMount (7-16)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/components/layout/achievements/ladybird-secret-listener.tsx (2)
src/components/providers/achievements-provider.tsx (1)
  • useAchievements (271-275)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/components/layout/about/pets/_content.tsx (2)
src/components/providers/achievements-provider.tsx (1)
  • useAchievements (271-275)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/components/layout/about/_content.tsx (2)
src/components/layout/achievements/unlock-on-mount.tsx (1)
  • UnlockOnMount (7-16)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/components/layout/projects/_content.tsx (2)
src/components/layout/achievements/unlock-on-mount.tsx (1)
  • UnlockOnMount (7-16)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/components/layout/achievements/achievement-card.tsx (6)
src/lib/achievements.ts (2)
  • AchievementId (106-106)
  • ACHIEVEMENTS (19-104)
src/components/providers/achievements-provider.tsx (1)
  • useAchievements (271-275)
src/utils/ladybird.ts (1)
  • isLadybirdUA (1-7)
src/components/ui/flipping-card.tsx (1)
  • FlippingCard (25-218)
src/components/layout/achievements/achievement-card-front.tsx (1)
  • AchievementCardFront (3-5)
src/components/layout/achievements/achievement-card-back.tsx (1)
  • AchievementCardBack (3-22)
src/components/layout/home/hero/profile-image.tsx (3)
src/utils/ladybird.ts (1)
  • isLadybirdUA (1-7)
src/hooks/posthog.ts (1)
  • captureLadybirdDetected (69-74)
src/lib/achievements.ts (1)
  • AchievementId (106-106)
src/app/layout.tsx (1)
src/lib/achievements.ts (1)
  • buildPresenceScript (170-175)
src/components/ui/alert-dialog.tsx (2)
src/lib/utils.ts (1)
  • cn (4-6)
src/components/ui/button.tsx (1)
  • buttonVariants (51-51)
src/lib/achievements.ts (1)
src/types/presence-bundle.d.ts (1)
  • PRESENCE_RUNTIME_BUNDLE (2-2)
src/components/layout/header.tsx (2)
src/components/layout/header/home-logo.tsx (1)
  • HomeLogo (6-95)
src/components/ui/theme-toggle.tsx (1)
  • ThemeToggle (25-403)
src/components/providers/achievements-provider.tsx (1)
src/lib/achievements.ts (3)
  • UnlockedMap (107-107)
  • createEmptyUnlocked (109-111)
  • parseUnlockedStorage (157-165)
src/components/layout/header/mobile-nav.tsx (2)
src/components/providers/achievements-provider.tsx (1)
  • useAchievements (271-275)
src/lib/navmenu.ts (1)
  • NAVIGATION (11-16)
src/components/layout/header/nav-lava.tsx (2)
src/lib/navmenu.ts (1)
  • NAVIGATION (11-16)
src/lib/utils.ts (1)
  • cn (4-6)
src/app/achievements/page.tsx (5)
src/lib/achievements.ts (4)
  • ACHIEVEMENTS_COOKIE_NAME (118-118)
  • parseUnlockedCookie (120-133)
  • AchievementId (106-106)
  • ACHIEVEMENTS (19-104)
src/components/layout/achievements/ladybird-secret-listener.tsx (1)
  • LadybirdSecretListener (7-42)
src/components/ui/section-label.tsx (1)
  • SectionLabel (9-12)
src/components/layout/achievements/achievement-reset-button.tsx (1)
  • AchievementResetButton (18-45)
src/components/layout/achievements/achievement-card.tsx (1)
  • AchievementCard (13-78)
🪛 ast-grep (0.38.6)
src/app/layout.tsx

[warning] 36-36: 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)


[warning] 45-45: 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.1.2)
src/app/layout.tsx

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

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

(lint/security/noDangerouslySetInnerHtml)


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

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

(lint/security/noDangerouslySetInnerHtml)

🔇 Additional comments (22)
.gitignore (1)

5-5: Add presence bundle to VCS ignore — LGTM

Treating the presence bundle as a generated artifact is correct and keeps noise out of the repo.

src/components/layout/about/aboutme/quick-fact.tsx (1)

20-22: Open external links safely — LGTM

Using target="_blank" with rel="noopener noreferrer" is correct. No issues.

eslint.config.js (2)

12-12: Ignore generated presence bundle — LGTM

Keeping the generated TS out of lint is right.


39-39: Extend script overrides to presence builder — LGTM

Applying the same relaxed rules to build-presence-runtime.ts is appropriate.

src/utils/ladybird.ts (1)

1-7: SSR‑safe UA detection — LGTM

Guard against non‑browser and case‑insensitive checks look good.

package.json (1)

27-27: New Radix dependency — sanity check

Looks fine. Ensure any tree‑shaken subcomponents aren’t bloating your bundle and that licenses align with your policy.

src/lib/presence-script.ts (1)

1-9: Build contract: format 'iife' + globalName 'PresenceRuntime' verified — confirm named export initPresence

scripts/build-presence-runtime.ts sets format: 'iife' and globalName: 'PresenceRuntime' (lines 23–24). Confirm the bundled entrypoint actually exports a named initPresence (search the repo for initPresence or inspect the entryPoints used by that script).

src/types/presence-bundle.d.ts (1)

1-3: Ambient module for generated bundle — confirmed

scripts/build-presence-runtime.ts builds an IIFE (format: 'iife', globalName: 'PresenceRuntime') and writes export const PRESENCE_RUNTIME_BUNDLE: string = ... to src/lib/presence-bundle.ts (scripts/build-presence-runtime.ts ≈ line 38).

src/styles/globals.css (1)

322-441: Pre‑hydration reveals with reduced‑motion fallbacks look solid

Good gating via root data attributes and motion fallbacks. No action.

src/lib/achievements.ts (2)

106-108: Types look good

AchievementId and UnlockedMap definitions align with the dynamic registry.


135-144: Cookie/JSON sanitization is robust

Good filtering of unknown keys and blank values. LGTM.

Also applies to: 146-155, 157-165

src/components/ui/theme-toggle.tsx (1)

25-25: ThemeToggle callsites updated — no props remain
Usages verified: src/components/ui/theme-toggle.tsx:25 (export function ThemeToggle), src/components/layout/header.tsx:24 (), src/components/ui/theme-toggle.stories.tsx:15 ().

src/components/layout/projects/_content.tsx (1)

10-10: Drop the type assertion; use a typed literal instead.

Avoid as AchievementId to keep the compiler catching typos. The literal is already assignable to the union.

[ suggest_nitpick_refactor ]

Apply:

-import { UnlockOnMount } from '@/components/layout/achievements/unlock-on-mount'
-import type { AchievementId } from '@/lib/achievements'
+import { UnlockOnMount } from '@/components/layout/achievements/unlock-on-mount'
@@
-      <UnlockOnMount id={'PROJECTS_PERUSER' as AchievementId} />
+      <UnlockOnMount id={'PROJECTS_PERUSER'} />

If TS widens in your setup, use a checked const:

const PROJECTS_PERUSER_ID: AchievementId = 'PROJECTS_PERUSER'
<UnlockOnMount id={PROJECTS_PERUSER_ID} />

Also applies to: 1-1, 3-3

src/components/layout/header.tsx (1)

13-27: Nice simplification; fewer layout side-effects and props.

Header is easier to reason about; removal of flyout width coupling avoids layout thrash. LGTM.

src/components/layout/home/hero/profile-image.tsx (1)

42-56: Good UA guard; side-effect is isolated to the client.

The navigator checks live inside isLadybirdUA(), and sessionStorage is guarded. LGTM.

src/components/providers/achievements-provider.tsx (3)

70-84: Nice cross‑tab sync with minimal churn.

Good use of storage event + shallow equality to avoid redundant state updates.


133-141: Reset UX is clean and side‑effects are centralized.

Good: single source of truth, persistence handled by the unlocked effect, toast confirmation included.


214-227: Auto‑unlock logic reads clearly; no infinite loop risk.

has() guard and microtask scheduling keep things sane. LGTM.

src/components/layout/header/mobile-nav.tsx (3)

48-66: Dynamic items gating is hydration‑safe.

Gating on isMounted and using suppressHydrationWarning avoids mismatch. Nicely done.


226-249: Keyboard nav scales with dynamic item count.

Good use of items.length in deps and focus management.


401-406: Verify custom responsive alias 'nav' exists

No tailwind.config.* located in the workspace — confirm 'nav' is defined (screens.nav in tailwind.config.{js,ts,cjs} or a custom variant via addVariant('nav')), otherwise replace nav:hidden with a standard breakpoint. File: src/components/layout/header/mobile-nav.tsx (lines 401-406).

src/app/achievements/page.tsx (1)

18-18: Resolved — LadybirdSecretListener is a client component.
src/components/layout/achievements/ladybird-secret-listener.tsx begins with 'use client', so using hooks there is valid.

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