Enhances achievement system with new features#37
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughAdds 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
Note: 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)
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes Possibly related PRs
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (41)
src/lib/quickfacts.ts (1)
13-13: Ghostty domain fix — LGTM; minor typing noteThe change is fine. Small follow‑up:
QuickFact.hrefis typed asRoute, but you’re passing external URLs here (and elsewhere). Consider widening tostringto avoid brittle typing.src/lib/presence-script.ts (2)
25-33: Fragile cookie name escaping; allow empty valuesThe 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 closedThrowing 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 deterministicMinification 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 literalPrevent 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 failureA 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 bothLocking 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 buttonBackdrop 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 handlermatchMedia 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 rulesAchievements 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 syncPrevents 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 mismatchCONFUSED_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 refactorIf 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
buildPresenceScriptsinsrc/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 AchievementIdand 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
hasgate 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 repeatedas AchievementIdcasts 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 tohas(), 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. Removeasyncon the component and theawaithere.-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
AchievementIdresolves tostringgivenACHIEVEMENTS: 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 firstConsider 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 withgroup. 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: Routeprevents external URLs at the type level even thoughisExternalis supported. Usestringfor flexibility.-type NavLinkProps = { - href: Route +type NavLinkProps = { + href: string
30-30: Unnecessary useMemo for a static import
NAVIGATIONis 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 insetExtract
8into 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 semanticsUsing
+ is adequate; consider dropping roles.role="menubar"/menuitemfor site navigation can be misleading. Nativesrc/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
⛔ Files ignored due to path filters (1)
bun.lockis 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.tssrc/components/layout/achievements/achievement-reset-button.tsxsrc/components/layout/achievements/unlock-on-mount.tsxsrc/components/layout/experience/_content.tsxsrc/components/layout/achievements/ladybird-secret-listener.tsxsrc/lib/quickfacts.tssrc/utils/ladybird.tssrc/components/layout/about/aboutme/quick-fact.tsxsrc/components/layout/about/pets/_content.tsxeslint.config.jssrc/components/layout/about/_content.tsxsrc/components/layout/projects/_content.tsxsrc/components/layout/achievements/achievement-card.tsxsrc/lib/presence-script.tssrc/components/layout/home/hero/profile-image.tsxsrc/app/layout.tsxscripts/build-presence-runtime.tssrc/components/layout/header/home-logo.tsxsrc/components/ui/theme-toggle.tsxsrc/components/ui/alert-dialog.tsxsrc/lib/achievements.tssrc/components/layout/header.tsxsrc/components/providers/achievements-provider.tsxsrc/components/layout/header/mobile-nav.tsxsrc/components/layout/header/nav-lava.tsxsrc/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.tssrc/components/layout/achievements/achievement-reset-button.tsxsrc/components/layout/achievements/unlock-on-mount.tsxsrc/components/layout/experience/_content.tsxsrc/components/layout/achievements/ladybird-secret-listener.tsxsrc/lib/quickfacts.tssrc/utils/ladybird.tssrc/components/layout/about/aboutme/quick-fact.tsxsrc/components/layout/about/pets/_content.tsxsrc/components/layout/about/_content.tsxsrc/components/layout/projects/_content.tsxsrc/components/layout/achievements/achievement-card.tsxsrc/lib/presence-script.tssrc/components/layout/home/hero/profile-image.tsxsrc/app/layout.tsxscripts/build-presence-runtime.tssrc/components/layout/header/home-logo.tsxsrc/components/ui/theme-toggle.tsxsrc/components/ui/alert-dialog.tsxsrc/lib/achievements.tssrc/components/layout/header.tsxsrc/components/providers/achievements-provider.tsxsrc/components/layout/header/mobile-nav.tsxsrc/components/layout/header/nav-lava.tsxsrc/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 — LGTMTreating 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 — LGTMUsing
target="_blank"withrel="noopener noreferrer"is correct. No issues.eslint.config.js (2)
12-12: Ignore generated presence bundle — LGTMKeeping the generated TS out of lint is right.
39-39: Extend script overrides to presence builder — LGTMApplying the same relaxed rules to
build-presence-runtime.tsis appropriate.src/utils/ladybird.ts (1)
1-7: SSR‑safe UA detection — LGTMGuard against non‑browser and case‑insensitive checks look good.
package.json (1)
27-27: New Radix dependency — sanity checkLooks 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 exportinitPresencescripts/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 forinitPresenceor inspect the entryPoints used by that script).src/types/presence-bundle.d.ts (1)
1-3: Ambient module for generated bundle — confirmedscripts/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 solidGood gating via root data attributes and motion fallbacks. No action.
src/lib/achievements.ts (2)
106-108: Types look goodAchievementId and UnlockedMap definitions align with the dynamic registry.
135-144: Cookie/JSON sanitization is robustGood 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 AchievementIdto 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' existsNo 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:hiddenwith 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.
Adds new achievements, improves UI, and enhances feature detection.
Summary by CodeRabbit
New Features
UI
Chores