.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s

This commit is contained in:
wypark
2026-05-28 22:27:29 +09:00
parent 390778da3e
commit 8a2e05b003
31 changed files with 1221 additions and 385 deletions

562
DESIGN.md Normal file
View File

@@ -0,0 +1,562 @@
---
version: alpha
name: Apple-design-analysis
description: A photography-first interface that turns marketing into a museum gallery. Edge-to-edge product tiles alternate light and dark canvases, framed by SF Pro Display headlines with negative letter-spacing and a single Action Blue (#0066cc) interactive color. UI chrome recedes so the product can speak — no decorative gradients, no shadows on chrome, only the one signature drop-shadow under product imagery resting on a surface.
colors:
primary: "#0066cc"
primary-focus: "#0071e3"
primary-on-dark: "#2997ff"
ink: "#1d1d1f"
body: "#1d1d1f"
body-on-dark: "#ffffff"
body-muted: "#cccccc"
ink-muted-80: "#333333"
ink-muted-48: "#7a7a7a"
divider-soft: "#f0f0f0"
hairline: "#e0e0e0"
canvas: "#ffffff"
canvas-parchment: "#f5f5f7"
surface-pearl: "#fafafc"
surface-tile-1: "#272729"
surface-tile-2: "#2a2a2c"
surface-tile-3: "#252527"
surface-black: "#000000"
surface-chip-translucent: "#d2d2d7"
on-primary: "#ffffff"
on-dark: "#ffffff"
typography:
hero-display:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 56px
fontWeight: 600
lineHeight: 1.07
letterSpacing: -0.28px
display-lg:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 40px
fontWeight: 600
lineHeight: 1.1
letterSpacing: 0
display-md:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 34px
fontWeight: 600
lineHeight: 1.47
letterSpacing: -0.374px
lead:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 28px
fontWeight: 400
lineHeight: 1.14
letterSpacing: 0.196px
lead-airy:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 24px
fontWeight: 300
lineHeight: 1.5
letterSpacing: 0
tagline:
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
fontSize: 21px
fontWeight: 600
lineHeight: 1.19
letterSpacing: 0.231px
body-strong:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 17px
fontWeight: 600
lineHeight: 1.24
letterSpacing: -0.374px
body:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 17px
fontWeight: 400
lineHeight: 1.47
letterSpacing: -0.374px
dense-link:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 17px
fontWeight: 400
lineHeight: 2.41
letterSpacing: 0
caption:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.43
letterSpacing: -0.224px
caption-strong:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 14px
fontWeight: 600
lineHeight: 1.29
letterSpacing: -0.224px
button-large:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 18px
fontWeight: 300
lineHeight: 1.0
letterSpacing: 0
button-utility:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.29
letterSpacing: -0.224px
fine-print:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 12px
fontWeight: 400
lineHeight: 1.0
letterSpacing: -0.12px
micro-legal:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 10px
fontWeight: 400
lineHeight: 1.3
letterSpacing: -0.08px
nav-link:
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
fontSize: 12px
fontWeight: 400
lineHeight: 1.0
letterSpacing: -0.12px
rounded:
none: 0px
xs: 5px
sm: 8px
md: 11px
lg: 18px
pill: 9999px
full: 9999px
spacing:
xxs: 4px
xs: 8px
sm: 12px
md: 17px
lg: 24px
xl: 32px
xxl: 48px
section: 80px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.body}"
rounded: "{rounded.pill}"
padding: 11px 22px
button-primary-focus:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.pill}"
button-primary-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
rounded: "{rounded.pill}"
button-secondary-pill:
backgroundColor: "{colors.canvas}"
textColor: "{colors.primary}"
typography: "{typography.body}"
rounded: "{rounded.pill}"
padding: 11px 22px
button-dark-utility:
backgroundColor: "{colors.ink}"
textColor: "{colors.on-dark}"
typography: "{typography.button-utility}"
rounded: "{rounded.sm}"
padding: 8px 15px
button-pearl-capsule:
backgroundColor: "{colors.surface-pearl}"
textColor: "{colors.ink-muted-80}"
typography: "{typography.caption}"
rounded: "{rounded.md}"
padding: 8px 14px
button-store-hero:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.button-large}"
rounded: "{rounded.pill}"
padding: 14px 28px
button-icon-circular:
backgroundColor: "{colors.surface-chip-translucent}"
textColor: "{colors.ink}"
rounded: "{rounded.full}"
size: 44px
text-link:
backgroundColor: transparent
textColor: "{colors.primary}"
typography: "{typography.body}"
text-link-on-dark:
backgroundColor: transparent
textColor: "{colors.primary-on-dark}"
typography: "{typography.body}"
global-nav:
backgroundColor: "{colors.surface-black}"
textColor: "{colors.on-dark}"
typography: "{typography.nav-link}"
height: 44px
sub-nav-frosted:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink}"
typography: "{typography.tagline}"
height: 52px
product-tile-light:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
product-tile-parchment:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
product-tile-dark:
backgroundColor: "{colors.surface-tile-1}"
textColor: "{colors.on-dark}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
product-tile-dark-2:
backgroundColor: "{colors.surface-tile-2}"
textColor: "{colors.on-dark}"
rounded: "{rounded.none}"
product-tile-dark-3:
backgroundColor: "{colors.surface-tile-3}"
textColor: "{colors.on-dark}"
rounded: "{rounded.none}"
store-utility-card:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body-strong}"
rounded: "{rounded.lg}"
padding: 24px
configurator-option-chip:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.caption}"
rounded: "{rounded.pill}"
padding: 12px 16px
configurator-option-chip-selected:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
rounded: "{rounded.pill}"
search-input:
backgroundColor: "{colors.canvas}"
textColor: "{colors.ink}"
typography: "{typography.body}"
rounded: "{rounded.pill}"
padding: 12px 20px
height: 44px
floating-sticky-bar:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink}"
typography: "{typography.body}"
height: 64px
padding: 12px 32px
environment-quote-card:
backgroundColor: "{colors.surface-tile-1}"
textColor: "{colors.on-dark}"
typography: "{typography.display-lg}"
rounded: "{rounded.none}"
padding: 80px
footer:
backgroundColor: "{colors.canvas-parchment}"
textColor: "{colors.ink-muted-80}"
typography: "{typography.fine-print}"
padding: 64px
---
## Overview
Apple's web presence is a masterclass in **reverent product photography framed by near-invisible UI**. Every page is a stack of edge-to-edge product "tiles" — alternating light and dark canvases, each centered on a hero headline, a one-line tagline, two tiny blue pill CTAs, and an impossibly crisp product render. Nothing competes with the product. Typography is confident but quiet; color is either pure white, an off-white parchment, or a near-black tile; interactive elements are a single, quiet blue.
Density is unusually low even by contemporary SaaS standards. Each tile occupies roughly one viewport, and there is no decorative chrome — no borders, no gradients, no decorative frames, no shadows on headlines. Elevation appears only when a product image rests on a surface (a single soft `rgba(0, 0, 0, 0.22) 3px 5px 30px` drop for visual weight). The result is a catalog that feels more like a museum gallery: the wall disappears and the artifact takes over.
Store and shop surfaces retain the same chassis but switch modes. The product configurator (iPhone 17 Pro, accessories grid) introduces a tight grid of white utility cards at `{rounded.lg}` (18px) radius with a thin border, paired with a persistent thin sub-nav strip. The environment page leans darker and more editorial. Across all five surfaces the typographic system, spacing rhythm, and the single blue accent are consistent — this is one design language expressed at different volumes.
**Key Characteristics:**
- Photography-first presentation; UI recedes so the product can speak.
- Alternating full-bleed tile sections: white/parchment ↔ near-black, with the color change itself acting as the section divider.
- Single blue accent (`{colors.primary}`#0066cc) carries every interactive element. No second brand color exists.
- Two button grammars: tiny blue pill CTAs (`{rounded.pill}`) and compact utility rects (`{rounded.sm}`).
- SF Pro Display + SF Pro Text — negative letter-spacing at display sizes for the signature "Apple tight" headline feel.
- Whisper-soft elevation used only when a product image needs to breathe — exactly one drop-shadow in the entire system.
- Tight two-row nav: slim `{component.global-nav}` + product-specific `{component.sub-nav-frosted}` with persistent right-aligned primary CTA.
- Section rhythm across multiple pages: light hero → dark product tile → light utility tile → dark tile → parchment footer — a predictable pulse.
## Colors
> **Source pages analyzed:** homepage, environment, store, iPhone 17 Pro buy page, accessories index. The color system is identical across all five surfaces; only the surface-mode mix differs.
### Brand & Accent
- **Action Blue** (`{colors.primary}`#0066cc): The single brand-level interactive color. All text links, all blue pill CTAs ("Learn more", "Buy"), and the focus ring root. This is Apple's quiet but universal "click me" signal. Press state shifts to a slightly darker variant via the active scale transform rather than a hex change.
- **Focus Blue** (`{colors.primary-focus}`#0071e3): A marginally brighter sibling of Action Blue, reserved for the keyboard focus ring on buttons (`outline: 2px solid`).
- **Sky Link Blue** (`{colors.primary-on-dark}`#2997ff): A brighter blue used on dark surfaces for in-copy links and inline callouts, where Action Blue would disappear against the tile background.
### Surface
- **Pure White** (`{colors.canvas}`#ffffff): The dominant canvas. Content, utility cards, store tiles, configurator grids.
- **Parchment** (`{colors.canvas-parchment}`#f5f5f7): The signature Apple off-white. Used for alternating light tiles, footer region, and the default page canvas in store utility sections. Just different enough from white to create rhythm.
- **Pearl Button** (`{colors.surface-pearl}`#fafafc): A near-white used as the fill for secondary "ghost" buttons — lighter than the parchment canvas so the button still reads as a button against `{colors.canvas-parchment}`.
- **Near-Black Tile 1** (`{colors.surface-tile-1}`#272729): The primary dark-tile surface on the homepage product grid.
- **Near-Black Tile 2** (`{colors.surface-tile-2}`#2a2a2c): A micro-step lighter — used where a dark tile sits directly above or below Tile 1 to create the faintest separation.
- **Near-Black Tile 3** (`{colors.surface-tile-3}`#252527): A micro-step darker — used at the bottom of the stack and in embedded video/player frames.
- **Pure Black** (`{colors.surface-black}`#000000): Reserved for true void — video player backgrounds, edge-to-edge photographic overlays, the global nav bar background.
- **Translucent Chip Gray** (`{colors.surface-chip-translucent}`#d2d2d7): The base hex of the translucent gray chip used over photography for circular control buttons. In production, applied at ~64% alpha as `rgba(210, 210, 215, 0.64)`.
### Text
- **Near-Black Ink** (`{colors.ink}`#1d1d1f): The voice of every headline, every body paragraph, and the dark utility button's fill. Chosen instead of pure black to keep the page feeling photographic rather than printed.
- **Body** (`{colors.body}`#1d1d1f): Same hex as ink — Apple uses one near-black tone for all text on light surfaces.
- **Body On Dark** (`{colors.body-on-dark}`#ffffff): All text on dark tiles and on the global nav bar.
- **Body Muted** (`{colors.body-muted}`#cccccc): Secondary copy on dark tiles where pure white would be too loud.
- **Ink Muted 80** (`{colors.ink-muted-80}`#333333): Body text on the white Pearl Button surface — slightly softer than pure black.
- **Ink Muted 48** (`{colors.ink-muted-48}`#7a7a7a): Disabled button text and legal fine-print.
### Hairlines & Borders
- **Divider Soft** (`{colors.divider-soft}`#f0f0f0): The "border" tone on secondary buttons — functions as a ring shadow rather than a hard line. In production, often applied as `rgba(0, 0, 0, 0.04)`.
- **Hairline** (`{colors.hairline}`#e0e0e0): The 1px hairline border on store utility cards and configurator chips.
### Brand Gradient
**No decorative gradients.** Atmospheric depth on product photography (the iPhone 17 Pro camera plate, the Apple Watch bands, AirPods reflections) is inherent to the imagery, not a CSS gradient overlay. The environment page's hero uses photographic atmosphere (mountain vista at dawn) but no gradient tokens are defined. Apple is the rare luxury-brand site with zero gradient-based design tokens.
## Typography
### Font Family
- **Display**: `SF Pro Display, system-ui, -apple-system, sans-serif` — Apple's proprietary display face, optimized for sizes ≥ 19px. Defines the voice of every headline.
- **Body / UI**: `SF Pro Text, system-ui, -apple-system, sans-serif` — the text-optimized variant used for body copy, captions, buttons, and links below 20px.
- **OpenType features**: `font-variant-numeric: numerator` is enabled on numeric links (pricing tables, spec sheets). Display sizes rely on tight tracking rather than contextual ligatures.
### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|---|---|---|---|---|---|
| `{typography.hero-display}` | 56px | 600 | 1.07 | -0.28px | Hero headline; the signature "Apple tight" tracking |
| `{typography.display-lg}` | 40px | 600 | 1.10 | 0 | Tile headlines atop every product tile |
| `{typography.display-md}` | 34px | 600 | 1.47 | -0.374px | Section heads (SF Pro Text at display proportions) |
| `{typography.lead}` | 28px | 400 | 1.14 | 0.196px | Product tile subcopy |
| `{typography.lead-airy}` | 24px | 300 | 1.5 | 0 | Environment-page lead paragraphs (the rare weight 300) |
| `{typography.tagline}` | 21px | 600 | 1.19 | 0.231px | Sub-tile tagline; sub-nav category name |
| `{typography.body-strong}` | 17px | 600 | 1.24 | -0.374px | Inline strong emphasis |
| `{typography.body}` | 17px | 400 | 1.47 | -0.374px | Default paragraph |
| `{typography.dense-link}` | 17px | 400 | 2.41 | 0 | Footer / store utility link lists (relaxed leading) |
| `{typography.caption}` | 14px | 400 | 1.43 | -0.224px | Secondary captions, button text |
| `{typography.caption-strong}` | 14px | 600 | 1.29 | -0.224px | Emphasized captions |
| `{typography.button-large}` | 18px | 300 | 1.0 | 0 | Store hero CTAs (the rare weight 300) |
| `{typography.button-utility}` | 14px | 400 | 1.29 | -0.224px | Utility/nav button labels |
| `{typography.fine-print}` | 12px | 400 | 1.0 | -0.12px | Fine-print, footer body |
| `{typography.micro-legal}` | 10px | 400 | 1.3 | -0.08px | Micro legal disclaimers |
| `{typography.nav-link}` | 12px | 400 | 1.0 | -0.12px | Global nav menu items |
### Principles
- **Negative letter-spacing at display sizes.** Every headline at 17px and up carries a slight tracking tighten (`-0.12 → -0.374px`). This produces the iconic "Apple tight" headline cadence. Never used at 12px or below.
- **Body copy at 17px, not 16px.** Apple breaks the SaaS convention and runs paragraph text at 17px. The extra pixel gives the page an unmistakable "reading, not scanning" pace.
- **Weight 300 is real and rare.** Used deliberately on a handful of large-size reads (`{typography.button-large}` at 18px/300 and `{typography.lead-airy}` at 24px/300). It's not an accident — it's a light-atmosphere cue reserved for moments where the content should feel airy.
- **Weight 600, not 700, for headlines.** Apple's headlines sit at weight 600. Weight 700 is used sparingly for `{typography.tagline}` (21px) when a touch more assertion is needed.
- **Line-height is context-specific.** Display sizes use 1.071.19 (tight). Body uses 1.47. Utility link stacks in the footer/store use an unusually relaxed 2.41 (`{typography.dense-link}`). The 2.41 is not a bug — it's how the footer's dense link columns breathe.
- **Weight 500 is deliberately absent.** The ladder is 300 / 400 / 600 / 700. Mid-weight readings always use 600.
### Note on Font Substitutes
SF Pro is Apple's proprietary system font. When building off-system:
- Use `system-ui, -apple-system, BlinkMacSystemFont` as the first stack entry — on macOS/iOS/Safari this resolves to the real SF Pro.
- For non-Apple platforms, **Inter** (Google Fonts, variable) is the closest open-source equivalent. Inter at weight 600 with `font-feature-settings: "ss03"` approximates SF Pro's rounded "a" character.
- Nudge `letter-spacing` down by `-0.01em` on display sizes to re-create the Apple tight feel; Inter's default tracking runs slightly wider than SF Pro.
- For body text, tighten line-height by `0.03` (from 1.47 → 1.44) when substituting Inter — Inter's taller x-height needs less leading.
## Layout
### Spacing System
- **Base unit:** 8px. Sub-base values (2, 4, 5, 6, 7) are used for tight typographic adjustments; structural layout snaps to 8/12/16/20/24.
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 17px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px.
- **Section vertical padding:** `{spacing.section}` (80px) inside a product tile; tiles stack edge-to-edge with 0 gap (the color change provides the break).
- **Card padding:** `{spacing.lg}` (24px) inside utility grid cards.
- **Button padding:** 811px vertical, 1522px horizontal.
- **Universal rhythm constants:** the 17px body line-height multiplier (~25px line) and 21px tagline size show up on every analyzed page.
### Grid & Container
- **Max content width:** ~980px on text-heavy sections (environment), ~1440px on product grids (store, accessories), full-bleed for product tiles (homepage).
- **Column patterns:** 3 to 5 column utility card grid on store/accessories; 2-column side-by-side tiles on homepage occasional sections; single-column centered stack on product tile heroes.
- **Gutters:** 2024px between cards in a utility grid.
### Whitespace Philosophy
Apple's whitespace is the product's pedestal. Every tile begins with at least 64px of air above its headline and 4864px below. Product renders are never crowded; the nearest content to a product image is at least 40px away. The footer is the only area that breaks this — there, Apple goes deliberately dense to make the full information architecture visible at a glance.
## Elevation & Depth
| Level | Treatment | Use |
|---|---|---|
| Flat | No shadow, no border | Full-bleed tiles, global nav, footer, body sections |
| Soft hairline | 1px `rgba(0, 0, 0, 0.08)` border | Utility cards, sub-nav frosted-glass separator |
| Backdrop blur | `backdrop-filter: blur(N)` on Parchment 80% | Sub-nav and the iPhone buy floating sticky bar |
| Product shadow | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0` | Product renders resting on a surface (the only true "shadow" in the system) |
**Shadow philosophy.** Apple uses **exactly one** drop-shadow, and it is applied to photographic product imagery — never to cards, never to buttons, never to text. Elevation in the UI comes from (a) surface-color change (light tile ↔ dark tile) and (b) backdrop-blur on sticky bars. The single shadow is about giving the product weight, not about UI hierarchy.
### Decorative Depth
- **Atmospheric imagery** on the environment page (photographic vista) supplies mood; no CSS gradient involved.
- **Edge-to-edge tile alternation** creates rhythm without borders or shadows — the color change itself is the divider.
- **Backdrop-filter blur** on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` creates a "floating over content" effect that's functional, not decorative.
## Shapes
### Border Radius Scale
| Token | Value | Use |
|---|---|---|
| `{rounded.none}` | 0px | Full-bleed product tiles (no corner rounding) |
| `{rounded.xs}` | 5px | Inline links when styled as subtle chips (rare) |
| `{rounded.sm}` | 8px | Dark utility buttons (Sign In, Bag), inline card imagery |
| `{rounded.md}` | 11px | White Pearl Button capsules |
| `{rounded.lg}` | 18px | Store utility cards, accessories grid cards |
| `{rounded.pill}` | 9999px | Primary blue pill CTAs, sub-nav buy button, configurator option chips, search input — the signature Apple pill |
| `{rounded.full}` | 9999px / 50% | Circular control chips floating over photography |
### Photography Geometry
- **Hero imagery**: full-bleed, 21:9 or taller on the homepage; 16:9 on environment and shop pages. Product renders are photographic-realistic, often shot on a tinted surface that becomes the tile background.
- **Product renders**: PNG/WebP with transparency; rest on a surface tile and pick up the system shadow.
- **Accessory grid**: square 1:1 crops at `{rounded.lg}` (18px) radius, light neutral backgrounds, product centered with 2040px internal padding.
- **No rounded imagery in hero tiles** — images are full-bleed rectangular. Rounding (`{rounded.sm}`, `{rounded.lg}`) appears only on inline card imagery.
- Lazy-loading via responsive `srcset` and `sizes` across all breakpoints; CDN-optimized WebP.
## Components
### Top Navigation
**`global-nav`** — Persistent, ultra-thin black nav bar pinned to the top of every page. Background `{colors.surface-black}`, height 44px, text `{colors.on-dark}` in `{typography.nav-link}` (12px / 400 / -0.12px tracking). Links are quiet, spaced ~20px apart, running edge-to-edge across the top. Right-aligned cluster: Search, Bag icons — always visible. On mobile, collapses to hamburger at ~834px and the Apple logo centers.
**`sub-nav-frosted`** — Surface-specific nav that sticks below the global nav. Background `{colors.canvas-parchment}` at 80% opacity with backdrop-filter blur, creating a frosted-glass effect. Height 52px. Content on left: product category name ("iPhone", "Store", "Accessories") in `{typography.tagline}` (21px / 600). Content right: inline nav links in `{typography.button-utility}` (14px), ending in a persistent `{component.button-primary}` ("Buy") or a utility link.
### Buttons
**`button-primary`** — The signature Apple action. Background `{colors.primary}` (Action Blue #0066cc), text `{colors.on-primary}` in `{typography.body}` (SF Pro Text 17px / 400), rounded `{rounded.pill}` (full pill — capsule-shaped), padding 11px × 22px. The full-pill radius IS the brand action signal.
- Active state: `{component.button-primary-active}``transform: scale(0.95)` (the system-wide micro-interaction).
- Focus state: `{component.button-primary-focus}` — 2px solid `{colors.primary-focus}` outline.
**`button-secondary-pill`** — Used as the second CTA when two blue pills appear together ("Learn more" / "Buy"). Background transparent, text `{colors.primary}`, 1px solid `{colors.primary}` border, rounded `{rounded.pill}`, padding 11px × 22px. Reads as a "ghost pill."
**`button-dark-utility`** — Global nav actions (Sign In, Bag, language selector). Background `{colors.ink}` (#1d1d1f), text `{colors.on-dark}` in `{typography.button-utility}` (14px / 400 / -0.224px tracking), rounded `{rounded.sm}` (8px), padding 8px × 15px. Active state shrinks via `transform: scale(0.95)`.
**`button-pearl-capsule`** — Product-card secondary button. Background `{colors.surface-pearl}` (#fafafc), text `{colors.ink-muted-80}` in `{typography.caption}` (14px), 3px solid `{colors.divider-soft}` border (functions as a soft ring rather than a visible line), rounded `{rounded.md}` (11px), padding 8px × 14px.
**`button-store-hero`** — A larger primary CTA used on store hero surfaces. Same Action Blue + Paper White as `{component.button-primary}`, but with `{typography.button-large}` (18px / 300 — note the rare weight 300) and slightly more padding (14px × 28px). Used sparingly on the store landing.
**`button-icon-circular`** — Floats over photography. 44 × 44px, background `{colors.surface-chip-translucent}` at ~64% alpha, icon in `{colors.ink}`, rounded `{rounded.full}`. Used for carousel controls, close buttons, and in-image controls (product image thumbnails on the iPhone buy page).
**`text-link`** — Inline body links in `{colors.primary}` (Action Blue). Underlined or non-underlined per context.
**`text-link-on-dark`** — Inline body links on dark tiles in `{colors.primary-on-dark}` (Sky Link Blue #2997ff) — Action Blue would disappear against `{colors.surface-tile-1}`.
### Cards & Containers
**`product-tile-light`** — Full-bleed light tile. Background `{colors.canvas}` (white), text `{colors.ink}`, rounded `{rounded.none}` (0 — tiles touch edges), vertical padding `{spacing.section}` (80px). Centered stack: product name in `{typography.display-lg}` (40px / 600) → one-line tagline in `{typography.lead}` (28px / 400) → two `{component.button-primary}` CTAs ("Learn more" / "Buy") → product render resting on the surface with the system shadow.
**`product-tile-parchment`** — Same as `{component.product-tile-light}` but on `{colors.canvas-parchment}` (#f5f5f7). Used to break two consecutive white tiles.
**`product-tile-dark`** — Full-bleed dark tile. Background `{colors.surface-tile-1}` (#272729), text `{colors.on-dark}`, rounded `{rounded.none}`, vertical padding `{spacing.section}` (80px). Same content stack as the light tile but with `{component.text-link-on-dark}` for inline copy and `{component.button-primary}` (Action Blue still works on the dark surface). Used on the homepage product grid as the alternating dark band.
**`product-tile-dark-2`** — Variant on `{colors.surface-tile-2}` (#2a2a2c). Used where a dark tile sits directly above or below `{component.product-tile-dark}` to create the faintest separation through micro-step lightness change.
**`product-tile-dark-3`** — Variant on `{colors.surface-tile-3}` (#252527). Used at the bottom of the stack and in embedded video/player frames.
**`store-utility-card`** — Used in store grid and accessories grid. Background `{colors.canvas}` (white), 1px solid `{colors.hairline}` border, rounded `{rounded.lg}` (18px), padding `{spacing.lg}` (24px). Top: product image (1:1 crop with `{rounded.sm}` (8px) inner image radius). Below: product name in `{typography.body-strong}` (17px / 600), price in `{typography.body}` (17px / 400), and a `{component.text-link}` ("Buy" or "Learn more"). No shadow by default; product render itself carries the system product-shadow.
**`configurator-option-chip`** — Pill-shaped tappable cell used in the iPhone 17 Pro buy page. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.caption}`, rounded `{rounded.pill}`, padding 12px × 16px. Contains a small product thumbnail + label + price delta. Arranged in a grid of 45 options per row.
**`configurator-option-chip-selected`** — Selected state. Border upgrades to 2px solid `{colors.primary-focus}`. Same shape, same content.
**`environment-quote-card`** — A photographic-canvas hero specific to the environment page. Dark photographic backdrop (mountain vista at dawn) with `{colors.surface-tile-1}` as the fallback color, centered white-text headline in `{typography.display-lg}` (40px), small green "Apple 2030" pictographic logo above the headline, single `{component.button-primary}` below. Padding `{spacing.section}` (80px).
**`floating-sticky-bar`** — Floats at the bottom of the viewport on the iPhone 17 Pro buy page during scroll. Background `{colors.canvas-parchment}` at 80% opacity with `backdrop-filter: blur(N)`, height 64px, padding 12px × 32px. Left: running price total in `{typography.body}`. Right: `{component.button-primary}` ("Add to Bag").
### Inputs & Forms
**`search-input`** — The accessories search input. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.body}` (17px), 1px solid `rgba(0, 0, 0, 0.08)` border, rounded `{rounded.pill}` (full pill — search is also pill-shaped, matching the CTA grammar), padding 12px × 20px, height 44px. Leading icon: search glyph at 14px, muted tint.
Error and validation states were not surfaced in the analyzed pages.
### Footer
**`footer`** — Background `{colors.canvas-parchment}` (#f5f5f7), text `{colors.ink-muted-80}`. Link columns in `{typography.dense-link}` (17px / 400 / 2.41 line-height — the relaxed leading is what makes the dense columns scannable). Column headings in `{typography.caption-strong}` (14px / 600). Legal row at the very bottom in `{typography.fine-print}` (12px / 400) with `{colors.ink-muted-48}` text. Vertical padding 64px.
## Do's and Don'ts
### Do
- Use `{colors.primary}` (Action Blue #0066cc) for every interactive element — links, pill CTAs, focus signals — and nothing else. The single accent is non-negotiable.
- Set headlines in `{typography.hero-display}` or `{typography.display-lg}` with negative letter-spacing (`-0.28 → -0.374px`) to get the signature "Apple tight" cadence.
- Run body copy at `{typography.body}` (17px / 400 / 1.47 / -0.374px) — not 16px. The extra pixel defines the brand's reading pace.
- Alternate `{component.product-tile-light}` (or parchment) and `{component.product-tile-dark}` for full-bleed section rhythm. The color change IS the divider.
- Reserve `{rounded.pill}` for the primary blue CTA and any other element that should read as an "action" (configurator chips, search input, sticky bar CTA).
- Apply the single product-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) only to product renders resting on a surface — never on cards, buttons, or text.
- Use `transform: scale(0.95)` as the active/press state on every button — it's the system-wide micro-interaction.
- Keep the global nav `{colors.surface-black}` (true black) — it's the only place pure black appears on most pages.
### Don't
- Don't introduce a second accent color; every "click me" signal is `{colors.primary}` (Action Blue).
- Don't add shadows to cards, buttons, or text — shadow is reserved for product imagery.
- Don't use gradients as decorative backgrounds; atmosphere comes from photography.
- Don't set body copy at weight 500 — Apple's ladder is 300 / 400 / 600 / 700, with 500 deliberately absent. Body is always 400; strong inline is 600; display is 600.
- Don't round full-bleed tiles — tiles are rectangular and edge-to-edge; the color change is the divider.
- Don't tighten line-height below 1.47 for body copy — the editorial leading is part of the brand.
- Don't mix radii grammars — use `{rounded.sm}` for compact utility, `{rounded.lg}` for utility cards, `{rounded.pill}` for pills, and nothing in between (except the rare `{rounded.md}` Pearl Button).
- Don't use `{colors.primary-on-dark}` (Sky Link Blue) on light surfaces — it's the dark-tile-only variant. Action Blue is for light surfaces.
## Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Small phone | ≤ 419px | Single-column tiles; sub-nav collapses to category name + primary CTA only; hero typography drops to 28px |
| Phone | 420640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px |
| Large phone | 641735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps |
| Tablet portrait | 736833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA |
| Tablet landscape | 8341023px | Global nav returns fully expanded; 3-column utility grids become 2-column |
| Small desktop | 10241068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px |
| Desktop | 10691440px | Full layout; 45 column store grids; 1440px content max |
| Wide desktop | ≥ 1441px | Content locks at 1440px, margins absorb extra width |
The structural breakpoints that matter for agents: 1440px (content lock), 1068px (small-desktop), 833px (tablet landscape switch), 734px (tablet portrait), 640px (phone), 480px (small phone).
### Touch Targets
- Minimum 44 × 44px. `{component.button-primary}` lands at ~44 × 100px (with the full-pill radius making the visible hit area more generous than the label suggests).
- `{component.button-icon-circular}` is exactly 44 × 44px.
- Global nav utility links are smaller (~32 × 80px) — they deliberately sit at a tighter target because they're precision desktop actions, and the mobile hamburger replaces them at ≤ 833px.
### Collapsing Strategy
- **Global nav**: full horizontal link row on desktop → collapses to Apple logo + hamburger + bag icon at 834px and below.
- **Sub-nav**: category name + inline links + primary CTA → category name + primary CTA only at mobile; inline links move into a hamburger tray.
- **Product tiles**: stack from 2-column to 1-column at 834px; vertical padding tightens from 80px → 48px at small-phone.
- **Utility grids** (store, accessories): 5-col → 4-col (1440px) → 3-col (1068px) → 2-col (834px) → 1-col (640px).
- **Hero typography**: `{typography.hero-display}` (56px) → `{typography.display-lg}` (40px) at 1068px → 34px at 640px → 28px at 419px.
### Image Behavior
- All product imagery uses responsive `srcset` with breakpoint-matched crops.
- Hero photography may switch art direction at mobile (e.g., the environment page's vista crops to a taller aspect ratio on mobile, framing the subject differently).
- Product renders maintain their 1:1 or 4:3 aspect ratios across breakpoints; only scale changes.
- Lazy-loading is default; the above-fold hero loads eagerly.
## Iteration Guide
1. Focus on ONE component at a time. Reference its YAML key directly (`{component.product-tile-dark}`, `{component.search-input}`).
2. Variants of an existing component (`-active`, `-focus`, `-2`, `-3`) live as separate entries in `components:`.
3. Use `{token.refs}` everywhere — never inline hex.
4. Never document hover. Default and Active/Pressed states only.
5. Display headlines stay SF Pro Display 600 with negative letter-spacing. Body stays SF Pro Text 400 at 17px. The boundary is unbreakable.
6. The single drop-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) is reserved for product photography only.
7. When in doubt about emphasis: alternate surface (light → dark tile) before adding chrome.
## Known Gaps
- Form validation and error states were not surfaced on the analyzed pages; only the neutral search input is documented.
- The homepage's embedded video/player frame uses `{colors.surface-black}`; interior player controls are not documented (they're a platform widget, not a web-design token).
- Some component imagery is dynamic (rotating product hero) and its specific copy varies per surface — component specs name the structure, not the rotating content.
- Dark-mode counterparts for store and accessories utility cards were not surfaced on the analyzed pages; the system documented is the daytime/light-dominant variant Apple ships by default.
- Atmospheric photography (environment page mountain vista) is a content asset, not a design token; the documented `{component.environment-quote-card}` describes the structural surface only.
- The exact backdrop-filter blur radius on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` is platform-dependent; production CSS uses `saturate(180%) blur(20px)` as a typical baseline but the value isn't formalized as a token.

10
LOG.md
View File

@@ -2,6 +2,16 @@
## 2026-05-28
- `npx getdesign@latest add apple`로 Apple 참고용 `DESIGN.md`를 추가했다.
- `ThemeProvider`/`ThemeToggle`을 추가해 `wyp-theme-mode` 기반 `라이트/시스템/다크` 3단 토글을 TopHeader 우측에 배치했다.
- `html[data-theme]`, `html[data-theme-mode]` DOM 계약과 beforeInteractive 초기화 스크립트를 추가해 강제 라이트/다크와 시스템 설정 따르기를 분리했다.
- 전역 폰트를 Apple 우선 sans/mono stack으로 정리하고, Tailwind `dark:` variant를 `data-theme="dark"` 기준으로 전환했다.
- 홈, 아카이브, 카테고리, 로그인/회원가입, 글 상세, 댓글, 관리자 shell/dashboard 일부 surface를 Apple/macOS식 토큰과 control 색상으로 맞췄다.
- 검증: `npm run lint` 통과, `npm run build` 통과. build 중 sitemap fetch는 sandbox 네트워크 제한으로 기존처럼 경고를 출력했지만 실패하지 않았다. `NEXT_PUBLIC_API_URL=https://blogserver.wypark.me npm run dev` 후 Browser로 `/`, `/archive`, `/category/잡담`, `/posts/rtr-(refresh-token-rotation)`, `/login`, 로그아웃 상태 `/admin` 리다이렉트와 테마 전환/새로고침 유지 동작을 확인했다. localhost에서 배포 API를 직접 호출하는 카테고리 데이터는 백엔드 접근 제한으로 에러 상태 UI를 확인했다.
- 다음 추천 작업: 실제 배포 Origin에서 테마 토글 저장값과 관리자 로그인 상태의 하위 관리 화면 hover/focus를 최종 확인한다.
## 2026-05-28
- `docs/apple-style-frontend-development-guide.md` Phase 1 + Phase 2 기준으로 Apple 스타일 1차 MVP 프론트 리뉴얼을 적용했다.
- 전역 디자인 토큰과 작은 UI 컴포넌트(`Surface`, `StatusBadge`, `SegmentedControl`, `MetricCard`, `EmptyState`)를 추가했다.
- 공개 홈을 intro, notice, featured/latest, category shelves, popular reading, archive CTA 구조로 재구성하고 공개 운영 집계 노출을 피했다.

View File

@@ -275,14 +275,14 @@ export default function AdminPage() {
<div className="flex flex-wrap gap-2">
<Link
href="/admin/posts"
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
<FileText size={17} />
</Link>
<Link
href="/admin/posts/new"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-text)] px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:opacity-90 dark:bg-white dark:text-black"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--color-accent)] px-5 py-3 text-sm font-semibold text-white shadow-[var(--shadow-control)] transition hover:bg-[var(--color-accent-hover)]"
>
<PenLine size={17} />

View File

@@ -8,6 +8,9 @@ import { useMemo } from 'react';
import Link from 'next/link';
import { format } from 'date-fns';
import { PostListResponse } from '@/types';
import EmptyState from '@/components/ui/EmptyState';
import StatusBadge from '@/components/ui/StatusBadge';
import Surface from '@/components/ui/Surface';
const getTotalElements = (data?: PostListResponse) => {
return data?.page?.totalElements ?? data?.totalElements ?? 0;
@@ -53,29 +56,29 @@ export default function ArchivePage() {
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[50vh]">
<Loader2 className="animate-spin text-blue-500" size={40} />
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}
return (
<div className="max-w-4xl mx-auto py-10 px-4">
<div className="mx-auto max-w-5xl px-4 py-10">
{/* 헤더 섹션 */}
<div className="mb-12 text-center md:text-left border-b border-gray-100 pb-8">
<h1 className="text-3xl font-bold text-gray-900 flex items-center justify-center md:justify-start gap-3 mb-3">
<Archive className="text-blue-600" size={32} />
<div className="mb-12 border-b border-[var(--color-line)] pb-8 text-center md:text-left">
<h1 className="mb-3 flex items-center justify-center gap-3 text-3xl font-bold tracking-normal text-[var(--color-text)] md:justify-start">
<Archive className="text-[var(--color-accent)]" size={32} />
<span></span>
</h1>
<p className="text-gray-500">
<span className="text-blue-600 font-bold">{totalPosts}</span> .
<p className="text-[var(--color-text-muted)]">
<span className="font-bold text-[var(--color-accent)]">{totalPosts}</span> .
</p>
</div>
{/* 타임라인 컨텐츠 */}
{sortedYears.length > 0 ? (
<div className="space-y-12 relative">
<div className="relative space-y-12">
{/* 타임라인 수직선 (좌측 장식) */}
<div className="absolute left-4 top-4 bottom-4 w-0.5 bg-gray-100 hidden md:block" />
<div className="absolute bottom-4 left-4 top-4 hidden w-px bg-[var(--color-line)] md:block" />
{sortedYears.map((year) => {
const months = archiveGroups[year];
@@ -85,11 +88,11 @@ export default function ArchivePage() {
return (
<div key={year} className="relative">
{/* 연도 헤더 */}
<div className="flex items-center gap-4 mb-6">
<div className="hidden md:flex items-center justify-center w-9 h-9 rounded-full bg-blue-50 border-4 border-white shadow-sm z-10">
<div className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<div className="mb-6 flex items-center gap-4">
<div className="z-10 hidden h-9 w-9 items-center justify-center rounded-full border-4 border-[var(--color-page)] bg-[var(--color-accent-soft)] shadow-[var(--shadow-control)] md:flex">
<div className="h-2.5 w-2.5 rounded-full bg-[var(--color-accent)]" />
</div>
<h2 className="text-2xl font-bold text-gray-800">{year}</h2>
<h2 className="text-2xl font-bold text-[var(--color-text)]">{year}</h2>
</div>
{/* 월별 그룹 */}
@@ -101,12 +104,10 @@ export default function ArchivePage() {
return (
<div key={month} className="group">
<h3 className="text-lg font-bold text-gray-600 mb-4 flex items-center gap-2">
<Calendar size={18} className="text-gray-400" />
<h3 className="mb-4 flex items-center gap-2 text-lg font-bold text-[var(--color-text-muted)]">
<Calendar size={18} className="text-[var(--color-text-subtle)]" />
{month}
<span className="text-xs font-normal bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
{posts.length}
</span>
<StatusBadge tone="neutral">{posts.length}</StatusBadge>
</h3>
<div className="grid gap-3">
@@ -114,25 +115,23 @@ export default function ArchivePage() {
<Link
key={post.id}
href={`/posts/${post.slug}`}
className="block bg-white border border-gray-100 rounded-lg p-4 hover:border-blue-200 hover:shadow-md transition-all duration-200 group/item"
className="group/item block"
>
<div className="flex items-center justify-between gap-4">
<Surface interactive className="flex items-center justify-between gap-4 p-4 shadow-none">
<div className="flex-1 min-w-0">
<h4 className="text-base font-medium text-gray-800 truncate group-hover/item:text-blue-600 transition-colors">
<h4 className="truncate text-base font-semibold text-[var(--color-text)] transition-colors group-hover/item:text-[var(--color-accent)]">
{post.title}
</h4>
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-400">
<span className="bg-gray-50 px-1.5 py-0.5 rounded text-gray-500">
{post.categoryName}
</span>
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-subtle)]">
<StatusBadge tone="neutral" className="px-1.5 py-0.5">{post.categoryName || '미분류'}</StatusBadge>
<span></span>
<span className="tabular-nums">
{format(new Date(post.createdAt), 'yyyy.MM.dd')}
</span>
</div>
</div>
<ChevronRight className="text-gray-300 group-hover/item:text-blue-400 transition-colors" size={20} />
</div>
<ChevronRight className="text-[var(--color-text-subtle)] transition-colors group-hover/item:text-[var(--color-accent)]" size={20} />
</Surface>
</Link>
))}
</div>
@@ -145,10 +144,11 @@ export default function ArchivePage() {
})}
</div>
) : (
<div className="text-center py-20 bg-gray-50 rounded-xl border border-dashed border-gray-200">
<FileText className="mx-auto text-gray-300 mb-3" size={48} />
<p className="text-gray-500"> .</p>
</div>
<EmptyState
title="아직 작성된 기록이 없습니다."
icon={<FileText size={48} />}
className="py-20"
/>
)}
</div>
);

View File

@@ -8,6 +8,8 @@ import PostListItem from '@/components/post/PostListItem';
import PostSearch from '@/components/post/PostSearch'; // 🆕 검색 컴포넌트
import { Loader2, ChevronLeft, ChevronRight, LayoutGrid, List, Search as SearchIcon } from 'lucide-react';
import { clsx } from 'clsx';
import EmptyState from '@/components/ui/EmptyState';
import Surface from '@/components/ui/Surface';
export default function CategoryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
@@ -54,18 +56,18 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
if (isLoading || (postsData === undefined && !error)) {
return (
<div className="flex justify-center items-center h-64">
<Loader2 className="animate-spin text-blue-500" size={40} />
<div className="flex h-64 items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}
if (error) {
return (
<div className="text-center py-10 text-red-500">
<Surface className="mx-auto max-w-3xl px-5 py-10 text-center text-red-500">
.<br/>
<span className="text-sm text-gray-400"> .</span>
</div>
<span className="text-sm text-[var(--color-text-subtle)]"> .</span>
</Surface>
);
}
@@ -88,14 +90,15 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
};
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mx-auto max-w-5xl px-4 py-8">
{/* 헤더 영역 */}
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<h1 className="text-2xl font-bold text-gray-800 flex items-baseline gap-2 shrink-0">
{apiCategoryName} <span className="text-gray-400 text-lg font-normal"> </span>
<Surface strong className="mb-8 p-5">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<h1 className="flex shrink-0 items-baseline gap-2 text-2xl font-bold tracking-normal text-[var(--color-text)]">
{apiCategoryName} <span className="text-lg font-normal text-[var(--color-text-subtle)]"> </span>
</h1>
<div className="flex items-center gap-3 w-full md:w-auto">
<div className="flex w-full items-center gap-3 md:w-auto">
{/* 🔍 카테고리 내 검색바 */}
<PostSearch
onSearch={handleSearch}
@@ -104,37 +107,44 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
/>
{/* 뷰 모드 버튼 */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg shrink-0">
<div className="flex shrink-0 items-center gap-1 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-xl">
<button
onClick={() => handleViewModeChange('grid')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
activeViewMode === 'grid' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
'rounded-full p-2 transition-all duration-150',
activeViewMode === 'grid'
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
title="카드형 보기"
aria-label="카드형 보기"
aria-pressed={activeViewMode === 'grid'}
>
<LayoutGrid size={18} />
</button>
<button
onClick={() => handleViewModeChange('list')}
className={clsx(
"p-2 rounded-md transition-all duration-200",
activeViewMode === 'list' ? "bg-white text-blue-600 shadow-sm" : "text-gray-400 hover:text-gray-600"
'rounded-full p-2 transition-all duration-150',
activeViewMode === 'list'
? 'bg-[var(--color-surface-strong)] text-[var(--color-accent)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
title="리스트형 보기"
aria-label="리스트형 보기"
aria-pressed={activeViewMode === 'list'}
>
<List size={18} />
</button>
</div>
</div>
</div>
</Surface>
{/* 검색 결과 안내 (검색 중일 때만 표시) */}
{keyword && (
<div className="mb-6 flex items-center gap-2 text-sm text-gray-600 bg-blue-50 px-4 py-3 rounded-lg border border-blue-100">
<SearchIcon size={16} className="text-blue-500" />
<div className="mb-6 flex items-center gap-2 rounded-lg border border-[var(--color-line)] bg-[var(--color-accent-soft)] px-4 py-3 text-sm text-[var(--color-text-muted)]">
<SearchIcon size={16} className="text-[var(--color-accent)]" />
<span>
<strong>{keyword}</strong> : <strong>{totalElements}</strong>
</span>
@@ -142,11 +152,10 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
)}
{posts.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-gray-400 mb-2">
{keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
</p>
</div>
<EmptyState
title={keyword ? `${keyword}에 대한 검색 결과가 없습니다.` : '아직 작성된 글이 없습니다.'}
className="py-20"
/>
) : (
<>
{activeViewMode === 'grid' ? (
@@ -156,31 +165,31 @@ export default function CategoryPage({ params }: { params: Promise<{ id: string
))}
</div>
) : (
<div className="flex flex-col border-t border-gray-100">
<Surface className="flex flex-col p-2 shadow-none">
{posts.map((post) => (
<PostListItem key={post.id} post={post} />
))}
</div>
</Surface>
)}
<div className="flex justify-center items-center gap-6 mt-12 mb-8">
<div className="mb-8 mt-12 flex items-center justify-center gap-6">
<button
onClick={handlePrevPage}
disabled={page === 0}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
aria-label="이전 페이지"
>
<ChevronLeft size={24} />
</button>
<span className="text-sm font-medium text-gray-600">
<span className="text-gray-900 font-bold">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
<span className="text-sm font-medium text-[var(--color-text-muted)]">
<span className="font-bold text-[var(--color-text)]">{page + 1}</span> {totalPages > 0 && `/ ${totalPages}`}
</span>
<button
onClick={handleNextPage}
disabled={isLast || isPlaceholderData}
className="p-2 rounded-full hover:bg-gray-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors disabled:cursor-not-allowed"
className="rounded-full p-2 text-[var(--color-text-muted)] transition-colors hover:bg-black/[0.04] hover:text-[var(--color-text)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-white/10"
aria-label="다음 페이지"
>
<ChevronRight size={24} />

View File

@@ -1,59 +1,84 @@
@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
:root {
--color-page: #f5f5f7;
--color-surface: rgba(255, 255, 255, 0.78);
--color-surface-strong: #ffffff;
--color-line: rgba(0, 0, 0, 0.08);
--color-text: #1d1d1f;
--color-text-muted: #6e6e73;
--color-text-subtle: #86868b;
--color-accent: #007aff;
--color-accent-soft: rgba(0, 122, 255, 0.1);
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.08);
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.06);
--background: var(--color-page);
--foreground: var(--color-text);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--color-page: #0b0b0c;
--color-surface: rgba(28, 28, 30, 0.72);
--color-surface-strong: #1c1c1e;
--color-line: rgba(255, 255, 255, 0.1);
--color-text: #f5f5f7;
--color-text-muted: #a1a1a6;
--color-text-subtle: #8e8e93;
--color-accent: #0a84ff;
--color-accent-soft: rgba(10, 132, 255, 0.16);
--shadow-panel: 0 18px 50px rgba(0, 0, 0, 0.36);
--shadow-card: 0 10px 28px rgba(0, 0, 0, 0.28);
--background: var(--color-page);
--foreground: var(--color-text);
color-scheme: dark;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family:
--font-apple-sans:
-apple-system,
BlinkMacSystemFont,
"SF Pro Display",
"SF Pro Text",
"Apple SD Gothic Neo",
"Helvetica Neue",
Pretendard,
"Noto Sans KR",
system-ui,
sans-serif;
--font-apple-mono:
"SF Mono",
ui-monospace,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
monospace;
--color-page: #f5f5f7;
--color-surface: rgba(255, 255, 255, 0.72);
--color-surface-strong: #ffffff;
--color-control: rgba(255, 255, 255, 0.66);
--color-line: rgba(0, 0, 0, 0.1);
--color-text: #1d1d1f;
--color-text-muted: #6e6e73;
--color-text-subtle: #86868b;
--color-accent: #0066cc;
--color-accent-hover: #0071e3;
--color-accent-soft: rgba(0, 102, 204, 0.1);
--color-danger-soft: rgba(255, 59, 48, 0.1);
--shadow-panel: 0 22px 70px rgba(0, 0, 0, 0.1);
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.065);
--shadow-control: 0 8px 24px rgba(0, 0, 0, 0.08);
--focus-ring: 0 0 0 4px rgba(0, 102, 204, 0.14);
--background: var(--color-page);
--foreground: var(--color-text);
color-scheme: light;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-apple-sans);
--font-mono: var(--font-apple-mono);
}
html[data-theme="dark"] {
--color-page: #0b0b0c;
--color-surface: rgba(28, 28, 30, 0.72);
--color-surface-strong: #1c1c1e;
--color-control: rgba(44, 44, 46, 0.74);
--color-line: rgba(255, 255, 255, 0.12);
--color-text: #f5f5f7;
--color-text-muted: #a1a1a6;
--color-text-subtle: #8e8e93;
--color-accent: #2997ff;
--color-accent-hover: #46a6ff;
--color-accent-soft: rgba(41, 151, 255, 0.16);
--color-danger-soft: rgba(255, 69, 58, 0.16);
--shadow-panel: 0 22px 70px rgba(0, 0, 0, 0.42);
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
--shadow-control: 0 10px 28px rgba(0, 0, 0, 0.32);
--focus-ring: 0 0 0 4px rgba(41, 151, 255, 0.18);
--background: var(--color-page);
--foreground: var(--color-text);
color-scheme: dark;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-apple-sans);
font-feature-settings: "kern";
letter-spacing: 0;
text-rendering: optimizeLegibility;
}
::selection {
@@ -61,145 +86,156 @@ body {
color: var(--color-text);
}
@media (prefers-color-scheme: dark) {
/* Keep older Tailwind-only surfaces aligned with the app color tokens. */
.bg-white {
code,
kbd,
pre,
samp,
.tabular-nums {
font-family: var(--font-apple-mono);
}
:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* Keep older Tailwind-only surfaces aligned with the app color tokens. */
html[data-theme="dark"] .bg-white {
background-color: var(--color-surface-strong);
}
}
.bg-gray-50,
.bg-gray-100 {
html[data-theme="dark"] .bg-gray-50,
html[data-theme="dark"] .bg-gray-100 {
background-color: rgba(255, 255, 255, 0.08);
}
}
.bg-blue-50,
.bg-blue-100 {
html[data-theme="dark"] .bg-blue-50,
html[data-theme="dark"] .bg-blue-100 {
background-color: rgba(10, 132, 255, 0.14);
}
}
.bg-red-50,
.bg-red-100 {
html[data-theme="dark"] .bg-red-50,
html[data-theme="dark"] .bg-red-100 {
background-color: rgba(255, 69, 58, 0.13);
}
}
.bg-green-100 {
html[data-theme="dark"] .bg-green-100 {
background-color: rgba(48, 209, 88, 0.14);
}
}
.border-gray-100,
.border-gray-200,
.border-gray-300 {
html[data-theme="dark"] .border-gray-100,
html[data-theme="dark"] .border-gray-200,
html[data-theme="dark"] .border-gray-300 {
border-color: var(--color-line);
}
}
.border-blue-100 {
html[data-theme="dark"] .border-blue-100 {
border-color: rgba(10, 132, 255, 0.28);
}
}
.border-red-100 {
html[data-theme="dark"] .border-red-100 {
border-color: rgba(255, 105, 97, 0.28);
}
}
.text-gray-950,
.text-gray-900,
.text-gray-800,
.text-gray-700 {
html[data-theme="dark"] .text-gray-950,
html[data-theme="dark"] .text-gray-900,
html[data-theme="dark"] .text-gray-800,
html[data-theme="dark"] .text-gray-700 {
color: var(--color-text);
}
}
.text-gray-600,
.text-gray-500 {
html[data-theme="dark"] .text-gray-600,
html[data-theme="dark"] .text-gray-500 {
color: var(--color-text-muted);
}
}
.text-gray-400,
.text-gray-300 {
html[data-theme="dark"] .text-gray-400,
html[data-theme="dark"] .text-gray-300 {
color: var(--color-text-subtle);
}
}
.text-blue-700,
.text-blue-600 {
html[data-theme="dark"] .text-blue-700,
html[data-theme="dark"] .text-blue-600 {
color: #8ecbff;
}
}
.text-red-950,
.text-red-700,
.text-red-600 {
html[data-theme="dark"] .text-red-950,
html[data-theme="dark"] .text-red-700,
html[data-theme="dark"] .text-red-600 {
color: #ffb4ad;
}
}
.text-green-600 {
html[data-theme="dark"] .text-green-600 {
color: #8ff0a4;
}
}
.divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
html[data-theme="dark"] .divide-gray-100 > :not([hidden]) ~ :not([hidden]) {
border-color: var(--color-line);
}
}
.hover\:bg-white:hover,
.hover\:bg-gray-50:hover,
.hover\:bg-gray-100:hover,
.hover\:bg-gray-200:hover,
.focus\:bg-white:focus {
html[data-theme="dark"] .hover\:bg-white:hover,
html[data-theme="dark"] .hover\:bg-gray-50:hover,
html[data-theme="dark"] .hover\:bg-gray-100:hover,
html[data-theme="dark"] .hover\:bg-gray-200:hover,
html[data-theme="dark"] .focus\:bg-white:focus {
background-color: rgba(255, 255, 255, 0.11);
}
}
.hover\:bg-blue-50:hover {
html[data-theme="dark"] .hover\:bg-blue-50:hover {
background-color: rgba(10, 132, 255, 0.16);
}
}
.hover\:bg-red-50:hover {
html[data-theme="dark"] .hover\:bg-red-50:hover {
background-color: rgba(255, 69, 58, 0.16);
}
}
.hover\:text-gray-600:hover,
.hover\:text-gray-700:hover,
.hover\:text-gray-800:hover {
html[data-theme="dark"] .hover\:text-gray-600:hover,
html[data-theme="dark"] .hover\:text-gray-700:hover,
html[data-theme="dark"] .hover\:text-gray-800:hover {
color: var(--color-text);
}
}
.placeholder\:text-gray-300::placeholder,
.placeholder\:text-gray-400::placeholder {
html[data-theme="dark"] .placeholder\:text-gray-300::placeholder,
html[data-theme="dark"] .placeholder\:text-gray-400::placeholder {
color: var(--color-text-subtle);
}
}
.disabled\:bg-gray-50:disabled,
.disabled\:bg-gray-300:disabled,
.disabled\:bg-gray-400:disabled {
html[data-theme="dark"] .disabled\:bg-gray-50:disabled,
html[data-theme="dark"] .disabled\:bg-gray-300:disabled,
html[data-theme="dark"] .disabled\:bg-gray-400:disabled {
background-color: rgba(255, 255, 255, 0.1);
color: var(--color-text-subtle);
}
}
input:not([type="checkbox"]):not([type="radio"]),
textarea,
select {
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]),
html[data-theme="dark"] textarea,
html[data-theme="dark"] select {
background-color: rgba(255, 255, 255, 0.08);
border-color: var(--color-line);
color: var(--color-text);
}
}
input:not([type="checkbox"]):not([type="radio"]):focus,
textarea:focus,
select:focus {
html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):focus,
html[data-theme="dark"] textarea:focus,
html[data-theme="dark"] select:focus {
background-color: rgba(255, 255, 255, 0.11);
border-color: var(--color-accent);
}
}
input::placeholder,
textarea::placeholder {
html[data-theme="dark"] input::placeholder,
html[data-theme="dark"] textarea::placeholder {
color: var(--color-text-subtle);
}
}
option {
html[data-theme="dark"] option {
background-color: var(--color-surface-strong);
color: var(--color-text);
}
}
.shadow-xl,
.shadow-md,
.shadow-sm {
html[data-theme="dark"] .shadow-xl,
html[data-theme="dark"] .shadow-md,
html[data-theme="dark"] .shadow-sm {
box-shadow: var(--shadow-card);
}
}
.w-md-editor {
@@ -213,14 +249,12 @@ body {
background-color: #f9fafb !important;
}
@media (prefers-color-scheme: dark) {
.w-md-editor {
html[data-theme="dark"] .w-md-editor {
background-color: var(--color-surface-strong) !important;
border-color: var(--color-line) !important;
}
}
.w-md-editor-toolbar {
html[data-theme="dark"] .w-md-editor-toolbar {
background-color: rgba(255, 255, 255, 0.08) !important;
border-color: var(--color-line) !important;
}
}

View File

@@ -4,6 +4,7 @@ import Providers from './providers';
import Sidebar from '@/components/layout/Sidebar';
import TopHeader from '@/components/layout/TopHeader';
import Script from 'next/script';
import { THEME_INIT_SCRIPT } from '@/components/theme/theme';
export const metadata: Metadata = {
title: 'WYPark Blog',
@@ -16,11 +17,15 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="ko">
<html lang="ko" data-theme="light" data-theme-mode="system" suppressHydrationWarning>
<head>
<meta name="google-site-verification" content="cFJSK1ayy2Y4lqRKNv8wZ_vybg5De22zYCdbKSfvAJA" />
</head>
<body className="bg-[var(--color-page)] text-[var(--color-text)]">
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
{/* 🌟 Google Analytics 스크립트 */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-2GLCM9ZKMK"

View File

@@ -72,7 +72,7 @@ function LoginForm() {
type="email"
value={formData.email}
onChange={(event) => setFormData({ ...formData, email: event.target.value })}
className="w-full rounded-lg border border-[var(--color-line)] bg-white/70 px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
placeholder="example@email.com"
required
/>
@@ -84,7 +84,7 @@ function LoginForm() {
type="password"
value={formData.password}
onChange={(event) => setFormData({ ...formData, password: event.target.value })}
className="w-full rounded-lg border border-[var(--color-line)] bg-white/70 px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-[var(--color-text)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
placeholder="••••••••"
required
/>
@@ -95,7 +95,7 @@ function LoginForm() {
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-3 font-bold text-white transition-colors hover:bg-blue-700 disabled:bg-blue-400"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading && <Loader2 className="animate-spin" size={20} />}
@@ -104,7 +104,7 @@ function LoginForm() {
<div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
?{' '}
<Link href="/signup" className="font-medium text-blue-600 hover:underline">
<Link href="/signup" className="font-medium text-[var(--color-accent)] hover:underline">
</Link>
</div>

View File

@@ -350,7 +350,7 @@ function HomeContent() {
</Link>
<Link
href="#blog-search"
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 text-sm font-semibold text-[var(--color-text-muted)] transition hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10"
className="inline-flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
<Search size={16} />
@@ -450,7 +450,7 @@ function HomeContent() {
</div>
<Link
href="/archive"
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/70 px-4 py-2 text-sm font-semibold text-[var(--color-text)] transition hover:bg-white dark:bg-white/10"
className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-2 text-sm font-semibold text-[var(--color-text)] shadow-[var(--shadow-control)] transition hover:bg-[var(--color-surface-strong)]"
>
<ChevronRight size={16} />

View File

@@ -2,8 +2,8 @@ import { Loader2 } from 'lucide-react';
export default function Loading() {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
<div className="flex h-screen items-center justify-center">
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { useState, useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import { useAuthStore } from '@/store/authStore';
import axios from 'axios';
import { ThemeProvider } from '@/components/theme/ThemeProvider';
// 🛠️ JWT 토큰 만료 여부 체크 함수 (라이브러리 없이 구현)
function isTokenExpired(token: string) {
@@ -93,6 +94,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
);
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<AuthInitializer /> {/* 👈 앱 실행 시 토큰 자동 검사 */}
{children}
@@ -101,5 +103,6 @@ export default function Providers({ children }: { children: React.ReactNode }) {
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -71,14 +71,13 @@ export default function SignupPage() {
};
return (
// 🎨 배경색 수정: bg-gray-50 -> bg-white
<div className="min-h-screen flex items-center justify-center bg-white px-4 py-12">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 border border-gray-100">
<div className="flex min-h-screen items-center justify-center bg-[var(--color-page)] px-4 py-12">
<div className="w-full max-w-md rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-8 shadow-[var(--shadow-panel)] backdrop-blur-xl">
{/* 헤더 */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-2">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-[var(--color-text)]"></h1>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
{step === 'FORM' ? '' : '이메일로 전송된 6자리 코드를 입력하세요.'}
</p>
</div>
@@ -87,34 +86,34 @@ export default function SignupPage() {
{step === 'FORM' && (
<form onSubmit={handleSubmit(onSignupSubmit)} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input
{...register('email', {
required: '이메일은 필수입니다.',
pattern: { value: /\S+@\S+\.\S+/, message: '이메일 형식이 올바르지 않습니다.' }
})}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="user@example.com"
/>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input
{...register('nickname', { required: '닉네임을 입력해주세요.' })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="개발자"
/>
{errors.nickname && <p className="text-red-500 text-xs mt-1">{errors.nickname.message}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"></label>
<input
type="password"
{...register('password', { required: '비밀번호를 입력해주세요.', minLength: { value: 6, message: '6자 이상 입력해주세요.' } })}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none"
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="••••••••"
/>
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
@@ -123,7 +122,7 @@ export default function SignupPage() {
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400"
className="w-full rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? '처리 중...' : '인증 메일 받기'}
</button>
@@ -134,13 +133,13 @@ export default function SignupPage() {
{step === 'VERIFY' && (
<form onSubmit={onVerifySubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> (6)</label>
<label className="mb-1 block text-sm font-medium text-[var(--color-text-muted)]"> (6)</label>
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
maxLength={6}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 outline-none text-center text-2xl tracking-widest"
className="w-full rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-4 py-3 text-center text-2xl tracking-normal text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
placeholder="000000"
/>
</div>
@@ -148,7 +147,7 @@ export default function SignupPage() {
<button
type="submit"
disabled={loading}
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition-colors disabled:bg-gray-400"
className="w-full rounded-lg bg-[var(--color-accent)] py-3 font-bold text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? '처리 중...' : '인증 완료'}
</button>
@@ -156,7 +155,7 @@ export default function SignupPage() {
<button
type="button"
onClick={() => setStep('FORM')}
className="w-full text-sm text-gray-500 hover:underline"
className="w-full text-sm text-[var(--color-text-muted)] hover:underline"
>
</button>
@@ -164,9 +163,9 @@ export default function SignupPage() {
)}
{/* 하단 링크 */}
<div className="mt-6 text-center text-sm text-gray-500">
<div className="mt-6 text-center text-sm text-[var(--color-text-muted)]">
?{' '}
<Link href="/login" className="text-blue-600 font-semibold hover:underline">
<Link href="/login" className="font-semibold text-[var(--color-accent)] hover:underline">
</Link>
</div>

View File

@@ -15,14 +15,14 @@ function LegacyWriteRedirect() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={36} />
<Loader2 className="animate-spin text-[var(--color-accent)]" size={36} />
</div>
);
}
export default function WritePage() {
return (
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-blue-500" /></div>}>
<Suspense fallback={<div className="flex min-h-[60vh] items-center justify-center"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>}>
<LegacyWriteRedirect />
</Suspense>
);

View File

@@ -57,7 +57,10 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
return (
<div className="mx-auto max-w-7xl space-y-6 px-1 py-4 md:px-4">
<nav className="flex gap-2 overflow-x-auto border-b border-[var(--color-line)] pb-3" aria-label="관리자 메뉴">
<nav
className="flex gap-2 overflow-x-auto rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
aria-label="관리자 메뉴"
>
{navItems.map((item) => {
const Icon = item.icon;
const isActive = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
@@ -67,10 +70,10 @@ export default function AdminRouteShell({ children }: { children: React.ReactNod
key={item.href}
href={item.href}
className={clsx(
'inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition',
'inline-flex h-9 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-semibold transition',
isActive
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
: 'border border-[var(--color-line)] bg-white/70 text-[var(--color-text-muted)] hover:bg-white hover:text-[var(--color-text)] dark:bg-white/10',
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Icon size={16} />

View File

@@ -56,7 +56,7 @@ export default function AdminDashboardTrafficChart({
</div>
{points.length > 0 ? (
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-white/50 px-3 py-4 dark:bg-white/5">
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
{points.map((point) => {
const height = Math.max((point.views / maxViews) * 100, 4);

View File

@@ -62,7 +62,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
};
return (
<form onSubmit={handleSubmit} className="rounded-lg border border-[var(--color-line)] bg-white/50 p-4 dark:bg-white/5">
<form onSubmit={handleSubmit} className="rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-4">
{/* 비회원 입력 필드 */}
{!isLoggedIn && (
<div className="flex gap-2 mb-3">
@@ -71,7 +71,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
placeholder="닉네임"
value={guestInfo.nickname}
onChange={(e) => setGuestInfo({ ...guestInfo, nickname: e.target.value })}
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-white/70 px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
required
/>
<input
@@ -79,7 +79,7 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
placeholder="비밀번호"
value={guestInfo.password}
onChange={(e) => setGuestInfo({ ...guestInfo, password: e.target.value })}
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-white/70 px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="w-1/2 rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[var(--color-accent)]"
required
/>
</div>
@@ -91,13 +91,13 @@ export default function CommentForm({ postSlug, parentId = null, onSuccess, plac
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={isLoggedIn ? placeholder : '댓글을 남겨보세요.'}
className="h-24 w-full resize-none rounded-lg border border-[var(--color-line)] bg-white/70 p-3 pr-12 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10"
className="h-24 w-full resize-none rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-3 pr-12 text-sm text-[var(--color-text)] outline-none transition placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)]"
required
/>
<button
type="submit"
disabled={mutation.isPending}
className="absolute bottom-3 right-3 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-300"
className="absolute bottom-3 right-3 rounded-lg bg-[var(--color-accent)] p-2 text-white transition-colors hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
title="등록"
>
{mutation.isPending ? <Loader2 className="animate-spin" size={16} /> : <Send size={16} />}

View File

@@ -85,8 +85,8 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
className={clsx(
"relative p-4 rounded-xl transition-colors group",
comment.isPostAuthor
? "border border-blue-500/15 bg-blue-500/10"
: "border border-[var(--color-line)] bg-white/60 dark:bg-white/5"
? "border border-blue-500/15 bg-[var(--color-accent-soft)]"
: "border border-[var(--color-line)] bg-[var(--color-surface)]"
)}
>
<div className="flex items-center justify-between mb-2">
@@ -94,7 +94,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div
className={clsx(
"flex items-center justify-center rounded-full p-1.5",
comment.isPostAuthor ? "bg-blue-500/15 text-blue-600 dark:text-blue-300" :
comment.isPostAuthor ? "bg-[var(--color-accent-soft)] text-[var(--color-accent)]" :
!isGuestComment ? "bg-green-500/15 text-green-600 dark:text-green-300" :
"bg-black/[0.05] text-[var(--color-text-muted)] dark:bg-white/10"
)}
@@ -105,9 +105,9 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-0 sm:gap-2">
<span className={clsx("flex items-center gap-1 text-sm font-bold", comment.isPostAuthor ? "text-blue-700 dark:text-blue-300" : "text-[var(--color-text)]")}>
<span className={clsx("flex items-center gap-1 text-sm font-bold", comment.isPostAuthor ? "text-[var(--color-accent)]" : "text-[var(--color-text)]")}>
{comment.author}
{comment.isPostAuthor && <span className="px-1.5 py-0.5 bg-blue-100 text-blue-600 text-[10px] rounded-full font-medium"></span>}
{comment.isPostAuthor && <span className="rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]"></span>}
</span>
<span className="text-xs text-[var(--color-text-subtle)]">
{format(new Date(comment.createdAt), 'yyyy.MM.dd HH:mm')}
@@ -118,7 +118,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setIsReplying(!isReplying)}
className="rounded p-1.5 text-[var(--color-text-subtle)] hover:bg-blue-500/10 hover:text-blue-600 dark:hover:text-blue-300"
className="rounded p-1.5 text-[var(--color-text-subtle)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-accent)]"
title="답글 달기"
>
<MessageSquare size={14} />
@@ -152,7 +152,7 @@ export default function CommentItem({ comment, postSlug, depth = 0 }: CommentIte
placeholder="비밀번호 입력"
value={guestPassword}
onChange={(e) => setGuestPassword(e.target.value)}
className="rounded border border-[var(--color-line)] bg-white/70 px-2 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none dark:bg-white/10"
className="rounded border border-[var(--color-line)] bg-[var(--color-control)] px-2 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
autoFocus
/>
<button

View File

@@ -27,7 +27,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
const totalCount = comments ? countComments(comments) : 0;
if (isLoading) {
return <div className="py-10 flex justify-center"><Loader2 className="animate-spin text-blue-500" /></div>;
return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-[var(--color-accent)]" /></div>;
}
if (error) {
@@ -37,9 +37,9 @@ export default function CommentList({ postSlug }: CommentListProps) {
return (
<div className="mt-16 border-t border-[var(--color-line)] pt-10">
<div className="flex items-center gap-2 mb-6">
<MessageCircle className="text-blue-600" size={24} />
<MessageCircle className="text-[var(--color-accent)]" size={24} />
<h3 className="text-xl font-bold text-[var(--color-text)]">
<span className="text-blue-600">{totalCount}</span>
<span className="text-[var(--color-accent)]">{totalCount}</span>
</h3>
</div>
@@ -55,7 +55,7 @@ export default function CommentList({ postSlug }: CommentListProps) {
<CommentItem key={comment.id} comment={comment} postSlug={postSlug} />
))
) : (
<div className="rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 py-10 text-center text-sm text-[var(--color-text-muted)] dark:bg-white/5">
<div className="rounded-lg border border-dashed border-[var(--color-line)] bg-[var(--color-surface)] py-10 text-center text-sm text-[var(--color-text-muted)]">
. !
</div>
)}

View File

@@ -149,7 +149,7 @@ function SidebarContent() {
<button
type="button"
onClick={() => setIsOpen((previous) => !previous)}
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-surface-strong)] p-2 shadow-md transition-colors hover:bg-white md:hidden"
className="fixed left-4 top-4 z-50 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2 shadow-[var(--shadow-control)] backdrop-blur-xl transition-colors hover:bg-[var(--color-surface-strong)] md:hidden"
aria-label={isOpen ? '사이드바 닫기' : '사이드바 열기'}
>
{isOpen ? <X size={20} /> : <Menu size={20} />}
@@ -274,14 +274,14 @@ function SidebarContent() {
href={displayProfile.githubUrl || '#'}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-text)] hover:text-white dark:bg-white/10 dark:hover:bg-white dark:hover:text-black"
className="rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2.5 text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-all hover:bg-[var(--color-text)] hover:text-white dark:hover:bg-white dark:hover:text-black"
aria-label="GitHub"
>
<Github size={18} />
</a>
<a
href={`mailto:${displayProfile.email}`}
className="rounded-full border border-[var(--color-line)] bg-white/70 p-2.5 text-[var(--color-text-muted)] shadow-sm transition-all hover:bg-[var(--color-accent)] hover:text-white dark:bg-white/10"
className="rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-2.5 text-[var(--color-text-muted)] shadow-[var(--shadow-control)] transition-all hover:bg-[var(--color-accent)] hover:text-white"
aria-label="이메일"
>
<Mail size={18} />

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { LogOut, PenLine, Settings, User, UserPlus } from 'lucide-react';
import ThemeToggle from '@/components/theme/ThemeToggle';
export default function TopHeader() {
const router = useRouter();
@@ -19,17 +20,18 @@ export default function TopHeader() {
}
};
if (!_hasHydrated) return null;
return (
<div className="absolute top-6 right-6 z-30 flex items-center gap-3">
{isLoggedIn ? (
<div className="absolute right-4 top-4 z-30 flex max-w-[calc(100vw-5.5rem)] flex-wrap items-center justify-end gap-2 md:right-6 md:top-6 md:max-w-none md:gap-3">
<ThemeToggle />
{_hasHydrated && (
isLoggedIn ? (
<>
{isAdmin && (
<>
<Link
href="/admin"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-[var(--color-accent)] dark:bg-white/10"
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-semibold text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-accent)] sm:px-4"
>
<Settings size={16} />
<span className="hidden sm:inline"></span>
@@ -37,7 +39,7 @@ export default function TopHeader() {
<Link
href="/admin/posts/new"
className="flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md"
className="flex h-10 items-center gap-2 rounded-full bg-[var(--color-accent)] px-3 text-sm font-bold text-white shadow-[var(--shadow-control)] transition-all hover:brightness-105 sm:px-4"
>
<PenLine size={16} />
<span> </span>
@@ -47,7 +49,7 @@ export default function TopHeader() {
<button
onClick={handleLogout}
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-medium text-[var(--color-text-muted)] shadow-sm backdrop-blur-xl transition-colors hover:bg-white hover:text-red-500 dark:bg-white/10"
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-medium text-[var(--color-text-muted)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-colors hover:bg-[var(--color-surface-strong)] hover:text-red-500 sm:px-4"
>
<LogOut size={16} />
<span className="hidden sm:inline"></span>
@@ -57,7 +59,7 @@ export default function TopHeader() {
<>
<Link
href="/login"
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)]"
className="flex h-10 items-center gap-2 rounded-full px-2 text-sm font-semibold text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-accent)] sm:px-4"
>
<User size={18} />
<span></span>
@@ -65,12 +67,13 @@ export default function TopHeader() {
<Link
href="/signup"
className="flex items-center gap-2 rounded-full border border-[var(--color-line)] bg-white/75 px-4 py-2 text-sm font-bold text-[var(--color-accent)] shadow-sm backdrop-blur-xl transition-all hover:bg-white hover:shadow-md dark:bg-white/10"
className="flex h-10 items-center gap-2 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] px-3 text-sm font-bold text-[var(--color-accent)] shadow-[var(--shadow-control)] backdrop-blur-2xl transition-all hover:bg-[var(--color-surface-strong)] sm:px-4"
>
<UserPlus size={16} />
<span></span>
</Link>
</>
)
)}
</div>
);

View File

@@ -67,7 +67,7 @@ export default function MarkdownRenderer({ content }: MarkdownRendererProps) {
// 4. 테이블
table({ children }) {
return (
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-white/70 shadow-sm dark:bg-white/10">
<div className="my-8 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
<table className="w-full text-left text-sm text-[var(--color-text-muted)]">
{children}
</table>

View File

@@ -55,7 +55,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
if (isPostLoading) {
return (
<div className="flex justify-center items-center h-screen">
<Loader2 className="animate-spin text-blue-500" size={40} />
<Loader2 className="animate-spin text-[var(--color-accent)]" size={40} />
</div>
);
}
@@ -78,7 +78,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
{isAuthError ? '로그인이 필요하거나 비공개 게시글일 수 있습니다.' : errorMessage}
</p>
<div className="flex justify-center gap-3">
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-white/70 px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-white"></button>
<button onClick={() => router.push('/')} className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-5 py-2.5 font-semibold text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-strong)]"></button>
</div>
</div>
);
@@ -95,7 +95,7 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
<span className="text-sm font-medium"></span>
</Link>
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,820px)_220px] xl:justify-center xl:gap-16">
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,800px)_220px] xl:justify-center xl:gap-16">
<main className="min-w-0">
<article>
@@ -128,25 +128,25 @@ export default function PostDetailClient({ slug, initialPost }: PostDetailClient
</div>
</header>
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-a:text-[var(--color-accent)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<div className="prose prose-lg mb-20 max-w-none prose-headings:font-bold prose-headings:text-[var(--color-text)] prose-p:text-[var(--color-text)] prose-strong:text-[var(--color-text)] prose-li:text-[var(--color-text-muted)] prose-a:text-[var(--color-accent)] prose-hr:border-[var(--color-line)] prose-pre:bg-[#1e1e1e] prose-pre:text-gray-100">
<MarkdownRenderer content={post.content || ''} />
</div>
</article>
<nav className="mb-16 grid grid-cols-1 gap-4 border-y border-[var(--color-line)] py-8 md:grid-cols-2">
{prevPost ? (
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
<Link href={`/posts/${prevPost.slug}`} className="group flex w-full flex-col items-start gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 transition-colors hover:bg-[var(--color-surface-strong)]">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"><ChevronLeft size={16} /> </span>
<span className="line-clamp-1 w-full text-left font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{prevPost.title}</span>
</Link>
) : <div className="hidden w-full cursor-not-allowed rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> </span></div>}
) : <div className="hidden w-full cursor-not-allowed rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 opacity-60 md:block"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"><ChevronLeft size={16} /> </span></div>}
{nextPost ? (
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-transparent bg-white/60 p-5 transition-colors hover:border-[var(--color-line)] hover:bg-white dark:bg-white/10">
<Link href={`/posts/${nextPost.slug}`} className="group flex w-full flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 transition-colors hover:bg-[var(--color-surface-strong)]">
<span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)] transition-colors group-hover:text-[var(--color-accent)]"> <ChevronRight size={16} /></span>
<span className="line-clamp-1 w-full text-right font-bold text-[var(--color-text-muted)] transition-colors group-hover:text-[var(--color-accent)]">{nextPost.title}</span>
</Link>
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg bg-white/40 p-5 opacity-60 dark:bg-white/5 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"> <ChevronRight size={16} /></span></div>}
) : <div className="hidden w-full cursor-not-allowed flex-col items-end gap-1 rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] p-5 opacity-60 md:flex"><span className="flex items-center gap-1 text-xs font-bold text-[var(--color-text-subtle)]"> <ChevronRight size={16} /></span></div>}
</nav>
<CommentList postSlug={post.slug} />

View File

@@ -48,7 +48,7 @@ export default function PostSearch({
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full rounded-full border border-[var(--color-line)] bg-white/70 py-2.5 pl-10 pr-10 text-sm text-[var(--color-text)] shadow-sm outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:bg-white focus:ring-2 focus:ring-blue-500/15 dark:bg-white/10 dark:focus:bg-white/15"
className="w-full rounded-full border border-[var(--color-line)] bg-[var(--color-control)] py-2.5 pl-10 pr-10 text-sm text-[var(--color-text)] shadow-[var(--shadow-control)] outline-none transition-all placeholder:text-[var(--color-text-subtle)] focus:border-[var(--color-accent)] focus:bg-[var(--color-surface-strong)]"
/>
<Search
className="absolute left-3.5 top-1/2 -translate-y-1/2 cursor-pointer text-[var(--color-text-subtle)] transition-colors hover:text-[var(--color-accent)]"

View File

@@ -104,7 +104,7 @@ export default function TOC({ content }: TOCProps) {
return (
<aside className="w-full">
<div className="rounded-lg border border-[var(--color-line)] bg-white/65 p-4 shadow-sm backdrop-blur-xl dark:bg-white/10">
<div className="rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] p-4 shadow-[var(--shadow-control)] backdrop-blur-2xl">
<h4 className="mb-4 text-sm font-bold text-[var(--color-text)]"></h4>
<ul className="space-y-2.5">
{headings.map((heading, index) => (

View File

@@ -0,0 +1,125 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
import {
isThemeMode,
ResolvedTheme,
ThemeMode,
THEME_STORAGE_KEY,
} from './theme';
interface ThemeContextValue {
mode: ThemeMode;
resolvedTheme: ResolvedTheme;
setMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const THEME_MODE_EVENT = 'wyp-theme-mode-change';
const getSystemTheme = (): ResolvedTheme => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const resolveTheme = (mode: ThemeMode): ResolvedTheme => {
if (mode === 'system') return getSystemTheme();
return mode;
};
const applyTheme = (mode: ThemeMode, resolvedTheme = resolveTheme(mode)) => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.dataset.theme = resolvedTheme;
root.dataset.themeMode = mode;
root.style.colorScheme = resolvedTheme;
};
const getStoredMode = (): ThemeMode => {
if (typeof window === 'undefined') return 'system';
const savedMode = window.localStorage.getItem(THEME_STORAGE_KEY);
if (isThemeMode(savedMode)) return savedMode;
const documentMode = document.documentElement.dataset.themeMode;
if (isThemeMode(documentMode)) return documentMode;
return 'system';
};
const subscribeThemeMode = (callback: () => void) => {
if (typeof window === 'undefined') return () => undefined;
const handleStorage = (event: StorageEvent) => {
if (event.key === THEME_STORAGE_KEY) callback();
};
window.addEventListener(THEME_MODE_EVENT, callback);
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener(THEME_MODE_EVENT, callback);
window.removeEventListener('storage', handleStorage);
};
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const mode = useSyncExternalStore<ThemeMode>(subscribeThemeMode, getStoredMode, () => 'system');
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme());
const resolvedTheme = mode === 'system' ? systemTheme : mode;
useEffect(() => {
applyTheme(mode, resolvedTheme);
}, [mode, resolvedTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => {
setSystemTheme(getSystemTheme());
};
mediaQuery.addEventListener('change', handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange);
};
}, []);
const setMode = useCallback((nextMode: ThemeMode) => {
window.localStorage.setItem(THEME_STORAGE_KEY, nextMode);
applyTheme(nextMode);
window.dispatchEvent(new Event(THEME_MODE_EVENT));
}, []);
const value = useMemo(
() => ({
mode,
resolvedTheme,
setMode,
}),
[mode, resolvedTheme, setMode],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};

View File

@@ -0,0 +1,53 @@
'use client';
import { Monitor, Moon, Sun } from 'lucide-react';
import { clsx } from 'clsx';
import { ThemeMode } from './theme';
import { useTheme } from './ThemeProvider';
const options: Array<{
value: ThemeMode;
label: string;
icon: typeof Sun;
}> = [
{ value: 'light', label: '라이트', icon: Sun },
{ value: 'system', label: '시스템', icon: Monitor },
{ value: 'dark', label: '다크', icon: Moon },
];
export default function ThemeToggle() {
const { mode, setMode } = useTheme();
return (
<div
className="inline-flex h-10 shrink-0 items-center rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-2xl"
role="group"
aria-label="화면 테마"
>
{options.map((option) => {
const Icon = option.icon;
const isSelected = mode === option.value;
return (
<button
key={option.value}
type="button"
aria-pressed={isSelected}
aria-label={`${option.label} 모드`}
title={`${option.label} 모드`}
onClick={() => setMode(option.value)}
className={clsx(
'inline-flex h-8 min-w-8 items-center justify-center gap-1.5 rounded-full px-2 text-xs font-semibold transition duration-150 sm:min-w-10 lg:px-3',
isSelected
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-subtle)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>
<Icon size={15} strokeWidth={2.2} />
<span className="hidden lg:inline">{option.label}</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export type ThemeMode = 'light' | 'system' | 'dark';
export type ResolvedTheme = 'light' | 'dark';
export const THEME_STORAGE_KEY = 'wyp-theme-mode';
export const themeModes: ThemeMode[] = ['light', 'system', 'dark'];
export const isThemeMode = (value: unknown): value is ThemeMode => {
return typeof value === 'string' && themeModes.includes(value as ThemeMode);
};
export const THEME_INIT_SCRIPT = `
(function () {
try {
var storageKey = '${THEME_STORAGE_KEY}';
var savedMode = window.localStorage.getItem(storageKey);
var mode = savedMode === 'light' || savedMode === 'dark' || savedMode === 'system' ? savedMode : 'system';
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = mode === 'dark' || (mode === 'system' && prefersDark) ? 'dark' : 'light';
var root = document.documentElement;
root.dataset.theme = theme;
root.dataset.themeMode = mode;
root.style.colorScheme = theme;
} catch (error) {
document.documentElement.dataset.theme = 'light';
document.documentElement.dataset.themeMode = 'system';
document.documentElement.style.colorScheme = 'light';
}
})();
`;

View File

@@ -17,7 +17,7 @@ export default function EmptyState({
return (
<div
className={clsx(
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-white/45 px-5 py-10 text-center dark:bg-white/5',
'flex min-h-36 flex-col items-center justify-center rounded-lg border border-dashed border-[var(--color-line)] bg-[var(--color-surface)] px-5 py-10 text-center',
className,
)}
>

View File

@@ -23,7 +23,7 @@ export default function SegmentedControl<T extends string>({
return (
<div
className={clsx(
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-white/60 p-1 shadow-sm backdrop-blur-xl dark:bg-white/10',
'inline-flex h-9 rounded-full border border-[var(--color-line)] bg-[var(--color-control)] p-1 shadow-[var(--shadow-control)] backdrop-blur-xl',
className,
)}
role="group"
@@ -41,7 +41,7 @@ export default function SegmentedControl<T extends string>({
className={clsx(
'min-w-14 rounded-full px-3 text-sm font-semibold transition duration-150',
isSelected
? 'bg-[var(--color-text)] text-white shadow-sm dark:bg-white dark:text-black'
? 'bg-[var(--color-surface-strong)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:bg-black/[0.04] hover:text-[var(--color-text)] dark:hover:bg-white/10',
)}
>

View File

@@ -9,7 +9,7 @@ interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
const toneClass: Record<BadgeTone, string> = {
neutral: 'border-black/10 bg-black/[0.04] text-[var(--color-text-muted)] dark:border-white/10 dark:bg-white/10',
info: 'border-blue-500/15 bg-blue-500/10 text-blue-700 dark:text-blue-300',
info: 'border-blue-500/15 bg-[var(--color-accent-soft)] text-[var(--color-accent)]',
success: 'border-emerald-500/15 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
danger: 'border-red-500/15 bg-red-500/10 text-red-700 dark:text-red-300',

View File

@@ -21,7 +21,7 @@ export default function Surface({
className={clsx(
'rounded-lg border border-[var(--color-line)] backdrop-blur-xl',
strong ? 'bg-[var(--color-surface-strong)] shadow-[var(--shadow-panel)]' : 'bg-[var(--color-surface)] shadow-[var(--shadow-card)]',
interactive && 'transition duration-150 hover:border-black/10 hover:bg-white/90 hover:shadow-[var(--shadow-panel)] dark:hover:border-white/15 dark:hover:bg-white/10',
interactive && 'transition duration-150 hover:border-[var(--color-line)] hover:bg-[var(--color-surface-strong)] hover:shadow-[var(--shadow-panel)]',
className,
)}
{...props}