feat: Complete visual redesign with new design system#21
feat: Complete visual redesign with new design system#21
Conversation
Replace the dark/yellow theme with a deep navy + emerald/cyan design system. Redesign all pages (homepage, topic selector, content pages) with glass morphism cards, gradient accents, Framer Motion animations, and responsive layouts. Fix width mode selector hydration bug so it correctly shows the saved preference on page load. Redesign table of contents with active section tracking via IntersectionObserver, cleaner hierarchy, mobile FAB trigger, and pin/unpin UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR delivers a full UI refresh by introducing a new deep-navy + emerald/cyan design system, updating shared UI components to use theme variables, and improving navigation/UX elements (TOC, width switcher, ambient background) across the app.
Changes:
- Introduces new global design tokens/utilities (CSS variables, glass/card styles, accent gradient text, thin scrollbar styling).
- Redesigns key UI surfaces (Home, Junior Frontend selector, content sections/typography) with updated layout and motion.
- Updates Table of Contents behavior and adds animated width switcher indicator + hydration sync for width preference.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/width-switcher/width-switcher.tsx | Adds Framer Motion animated active indicator and refactors preset list/styles. |
| src/components/typography/text.tsx | Moves text colors to CSS variables and adjusts paragraph rhythm. |
| src/components/typography/subheader.tsx | Updates subheader sizing/weight/tracking to new design tokens. |
| src/components/typography/header.tsx | Switches header styling to gradient text utility. |
| src/components/typography/code-span.tsx | Rethemes inline code to use accent variables. |
| src/components/typography/callout.tsx | Rethemes callout to new accent/border/card styles. |
| src/components/table-of-contents/table-of-contents.tsx | Reworks TOC UI, adds active-section tracking, mobile FAB trigger, and thin scrollbar. |
| src/components/section-card/section-card.tsx | Converts section cards to new glass-card style with accent line. |
| src/components/page-header/page-header.tsx | Updates header styling and adds gradient accent line. |
| src/components/notes-area/notes-area.tsx | Rethemes notes area to new dashed border/card background variables. |
| src/components/layout/page-container.tsx | Syncs width state from localStorage/data attribute after hydration. |
| src/components/layout/content-page.tsx | Updates page text color to use foreground variable. |
| src/components/code-block/code-block.tsx | Rethemes code block chrome and highlight colors to new accents. |
| src/components/ambient-background/ambient-background.tsx | Replaces external image with CSS gradient mesh + animated orbs respecting reduced motion. |
| src/app/globals.css | Adds new design system variables/utilities and updates global styles (glass/card/scrollbar). |
| src/app/frontend/junior/frontend-junior.tsx | Full redesign of junior frontend topic selector with motion and icon cards. |
| src/app/(main)/home.tsx | Full redesign of homepage with hero, topic cards, and features grid + motion. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useEffect(() => { | ||
| // First check localStorage (most reliable client-side) | ||
| const stored = localStorage.getItem(storageKey); | ||
| if (stored && validPresets.has(stored)) { | ||
| setWidth(stored as WidthPreset); | ||
| document.documentElement.dataset.contentWidth = stored; | ||
| return; | ||
| } |
There was a problem hiding this comment.
This effect reads localStorage without a try/catch. In some browsers/environments (e.g., restricted storage, privacy mode), localStorage.getItem can throw and break rendering. Wrap the read in the same try/catch pattern used in applyWidthPreference, and fall back to the data attribute / initialWidth on failure.
| .glass-card:hover { | ||
| border-color: var(--border-strong); | ||
| background: rgba(15, 23, 42, 0.65); | ||
| box-shadow: | ||
| 0 8px 32px rgba(6, 182, 212, 0.08), | ||
| 0 0 0 1px var(--border-strong); |
There was a problem hiding this comment.
.glass-card:hover hard-codes a dark background (rgba(15, 23, 42, 0.65)), which will apply even in the prefers-color-scheme: light mode where --foreground is dark. This can significantly reduce contrast/readability on hover in light mode. Use a theme-aware variable (e.g., introduce --card-bg-hover) or derive the hover color from existing CSS vars so both color schemes remain consistent.
| import { motion } from 'framer-motion'; | ||
| import { useEffect, useState } from 'react'; | ||
|
|
||
| export function AmbientBackground() { | ||
| const [prefersReduced, setPrefersReduced] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); | ||
| setPrefersReduced(mq.matches); | ||
| const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); | ||
| mq.addEventListener('change', handler); | ||
| return () => mq.removeEventListener('change', handler); | ||
| }, []); | ||
|
|
There was a problem hiding this comment.
prefersReduced initializes to false, so users with prefers-reduced-motion: reduce may still see animations briefly until the effect runs. Since you’re already using Framer Motion, consider useReducedMotion() (or initialize state from matchMedia when available) and conditionally disable animations from the first render to fully respect reduced-motion.
| import { motion } from 'framer-motion'; | |
| import { useEffect, useState } from 'react'; | |
| export function AmbientBackground() { | |
| const [prefersReduced, setPrefersReduced] = useState(false); | |
| useEffect(() => { | |
| const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); | |
| setPrefersReduced(mq.matches); | |
| const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches); | |
| mq.addEventListener('change', handler); | |
| return () => mq.removeEventListener('change', handler); | |
| }, []); | |
| import { motion, useReducedMotion } from 'framer-motion'; | |
| export function AmbientBackground() { | |
| const prefersReduced = useReducedMotion(); |
| for (const entry of entries) { | ||
| if (entry.isIntersecting) { | ||
| setActiveId(entry.target.id); | ||
| } |
There was a problem hiding this comment.
IntersectionObserver callback sets activeId for every intersecting entry; when multiple headings intersect, the last entry processed wins (entry order is not guaranteed), which can cause flickering/incorrect active section highlighting. Consider selecting a single “best” entry (e.g., the closest to the top / highest intersectionRatio / smallest positive boundingClientRect.top) before calling setActiveId once per callback.
| for (const entry of entries) { | |
| if (entry.isIntersecting) { | |
| setActiveId(entry.target.id); | |
| } | |
| // Select a single "best" intersecting heading to avoid | |
| // order-dependent updates and flickering. | |
| const intersecting = entries.filter((entry) => entry.isIntersecting); | |
| if (intersecting.length === 0) { | |
| return; | |
| } | |
| const bestEntry = intersecting.reduce((best, entry) => { | |
| const bestTop = best.boundingClientRect.top; | |
| const currentTop = entry.boundingClientRect.top; | |
| // Prefer the smallest non-negative top (closest to top in view). | |
| const bestNonNegative = bestTop >= 0; | |
| const currentNonNegative = currentTop >= 0; | |
| if (bestNonNegative && currentNonNegative) { | |
| return currentTop < bestTop ? entry : best; | |
| } | |
| if (bestNonNegative && !currentNonNegative) { | |
| return best; | |
| } | |
| if (!bestNonNegative && currentNonNegative) { | |
| return entry; | |
| } | |
| // Both are negative: pick the one closest to the top (largest top). | |
| return currentTop > bestTop ? entry : best; | |
| }, intersecting[0]); | |
| if (bestEntry && bestEntry.target && bestEntry.target.id) { | |
| setActiveId(bestEntry.target.id); |
| const target = event.target as Element; | ||
| const tocNav = document.querySelector('nav[style*="top:"]'); | ||
| const tocContent = tocNav?.querySelector('.scrollbar-hide'); | ||
|
|
||
| const tocContent = tocNav?.querySelector('.scrollbar-thin'); | ||
| if (tocContent && !tocContent.contains(target)) { |
There was a problem hiding this comment.
The click-outside handler relies on document.querySelector('nav[style*="top:"]') and then queries for .scrollbar-thin. This is brittle (can match the wrong <nav> or break if styling changes) and may cause the TOC to not close on mobile. Prefer attaching a ref to the TOC panel/container and using ref.current.contains(event.target) for the outside-click check.
Summary
Test plan
//frontend/junior/frontend/junior/html&cssbun run build— passes with 0 errorsbun jest— all 7 tests pass🤖 Generated with Claude Code