GappHub Expert Review
Project-wide code review cross-referenced against the UI spec, roadmap, drift report, interview logs, and task history. All findings verified with a second read of the source code.
Critical
Critical Reduced motion CSS selectors target stale element type
globals.css:109-124 — The prefers-reduced-motion
CSS rules target [role="gridcell"] a and
[role="toolbar"] a, but since Phase 7, all app icons are
<button> elements (see AppIcon.tsx:62).
These selectors match nothing — reduced motion users still get
hover scale/shadow and press-in transforms.
Additionally, Accessibility.test.tsx:114 asserts the stale
[role="gridcell"] a selector exists in the CSS, so the test
passes while the actual behavior is broken.
Fix: Change a to button in
all four CSS rule blocks and update the test assertions to match.
Critical Sort order omits badge "N" (New)
products.ts:52-58 — sortProducts calls
byBadge("L"), byBadge("B"),
byBadge("C"), and byBadge(null) but never
byBadge("N"). The Product type at
product.ts:7 explicitly allows "N" as a valid
badge. Any product added with badge "N" will silently
disappear from the grid. No products currently use this badge, so the
bug is latent.
Fix: Add ...byBadge("N") between
byBadge("B") and byBadge("C").
High
High Barrel import of entire lucide-react icon library
AppIcon.tsx:6 and AppStoreDrawer.tsx:7 both use
import * as icons from "lucide-react" to dynamically resolve
icons by name. This pulls ~1,000+ icons into the client bundle. Tree-shaking
cannot optimize dynamic key lookups like icons[pascalName].
Fix: Extract a map of only the icons referenced in
products.json, switch all products to custom PNGs, or verify
the bundle stays under the 150KB gzip performance budget.
High Duplicated icon rendering logic
getIcon(), CUSTOM_ICON_IDS,
iconAlignmentClassMap, and badgeColorMap are
copy-pasted between AppIcon.tsx and
AppStoreDrawer.tsx. If a product or color changes, both
files must be updated in lockstep.
Fix: Extract shared constants and helpers into
src/lib/icon-utils.ts.
Medium
Medium Unused useIsMobile hook
src/hooks/useIsMobile.ts is not imported by any production
component. The mock at Accessibility.test.tsx:5 is also
stale — none of the components imported by that test use the hook.
Medium Slide and Assemble animation variants are dead code
page.tsx:9 hardcodes variant="boot". The
SlidePhoneContent and AssemblePhoneContent
components, their state, and their timer effects in
PageContent.tsx are unreachable. These were kept after
Phase 5 comparison — may be intentional.
Medium Duplicated reduced-motion wrapper
PageContent.tsx:196-219 — Two conditional branches
produce identical motion.div wrappers with the same props.
Can be merged into a single condition.
Medium StatusBar hydration mismatch
StatusBar.tsx:6-8 — useState(() =>
new Date().toLocaleTimeString(...)) bakes the build-time clock
into the statically generated HTML. On client hydration, a different
time is computed, causing a React hydration warning. Fix: initialize
with empty string and set real time in useEffect, or add
suppressHydrationWarning.
Low
-
SearchOverlay.tsx:47-49— Backdrop dismiss area hasaria-hiddenwithoutrole="presentation". -
PageContent.tsx:48—document.activeElement as HTMLButtonElementis an unchecked cast (safe in practice). -
BadgeLegend.tsx:27-39— Deprecated icon in legend is a black square rather than a grayed-out app icon matching the grid visual. -
IconGrid.tsx:60-65—registerSwipecalled on every dependency change with no cleanup. Works correctly via ref overwrite, but pattern is fragile.
Spec Conformance
-
Sort order vs badge "N":
products.tssort implementation has no bucket for"N"(New) products, which theProducttype allows. Gap between type definition and sort logic. -
Reduced motion: Spec §Reduced Motion says
hover scale is disabled and press animation is replaced by opacity
dim via CSS media query. The CSS selectors target
abut icons arebutton, so requirements are not met.
Documented Decisions (no action needed)
-
next/imagelint warnings onAppIcon.tsxandAppStoreDrawer.tsxare intentional for local PNGs (roadmap Phase 6/7 notes). - No dark mode — explicit spec non-goal.
- Status bar easter eggs (signal/battery) deferred — drift report §Deferred.
-
PhoneSwipeContextundocumented in spec — drift report §Undocumented Code. - Former non-goal “No in-app product detail pages” overridden — App Store Drawer interview log confirms user authorization.
Positive Observations
-
Clean server/client architecture: SSR data fetching in
page.tsxwith client component tree below. No prop drilling beyond one level. -
Thorough accessibility: ARIA roles and labels on all interactive
regions, focus trap in drawer, keyboard grid navigation,
role="tablist"on page dots. -
useReducedMotionviauseSyncExternalStore— correct React 19 pattern with SSR-safe server snapshot. -
useAvailableRowswithResizeObserver— elegant dynamic grid sizing responsive to container height. - Drawer focus management: Tab wrapping, Escape to close, focus restoration to triggering icon, animation-gated drag.
- Solid test coverage: 111 tests across 12 files covering interactions, accessibility, pagination, search, responsive, and the App Store drawer.
Validation
npx tsc --noEmit— clean, no errors.vitest run— 12 files, 111 tests passing.next build— successful static generation.