diff --git a/DESIGN.md b/DESIGN.md index 76b9b2c..360c1b6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,261 +1,246 @@ -# Design System Inspiration of Mistral AI +# Design System Inspiration of Spotify ## 1. Visual Theme & Atmosphere -Mistral AI's interface is a sun-drenched landscape rendered in code — a warm, bold, unapologetically European design that trades the typical blue-screen AI aesthetic for golden amber, burnt orange, and the feeling of late-afternoon light in southern France. Every surface glows with warmth: backgrounds fade from pale cream to deep amber, shadows carry golden undertones (`rgba(127, 99, 21, ...)`), and the brand's signature orange (`#fa520f`) burns through the page like a signal fire. +Spotify's web interface is a dark, immersive music player that wraps listeners in a near-black cocoon (`#121212`, `#181818`, `#1f1f1f`) where album art and content become the primary source of color. The design philosophy is "content-first darkness" — the UI recedes into shadow so that music, podcasts, and playlists can glow. Every surface is a shade of charcoal, creating a theater-like environment where the only true color comes from the iconic Spotify Green (`#1ed760`) and the album artwork itself. -The design language is maximalist in its warmth but minimalist in its structure. Huge display headlines (82px) crash into the viewport with aggressive negative tracking (-2.05px), creating text blocks that feel like billboards or protest posters — declarations rather than descriptions. The typography uses Arial (likely a custom font with Arial as fallback) at extreme sizes, creating a raw, unadorned voice that says "we build frontier AI" with no decoration needed. +The typography uses SpotifyMixUI and SpotifyMixUITitle — proprietary fonts from the CircularSp family (Circular by Lineto, customized for Spotify) with an extensive fallback stack that includes Arabic, Hebrew, Cyrillic, Greek, Devanagari, and CJK fonts, reflecting Spotify's global reach. The type system is compact and functional: 700 (bold) for emphasis and navigation, 600 (semibold) for secondary emphasis, and 400 (regular) for body. Buttons use uppercase with positive letter-spacing (1.4px–2px) for a systematic, label-like quality. -What makes Mistral distinctive is the complete commitment to a warm color temperature. The signature "block" identity — a gradient system flowing from bright yellow (`#ffd900`) through amber (`#ffa110`) to burnt orange (`#fa520f`) — creates a visual identity that's immediately recognizable. Even the shadows are warm, using amber-tinted blacks instead of cool grays. Combined with dramatic landscape photography in golden tones, the design feels less like a tech company and more like a European luxury brand that happens to build language models. +What distinguishes Spotify is its pill-and-circle geometry. Primary buttons use 500px–9999px radius (full pill), circular play buttons use 50% radius, and search inputs are 500px pills. Combined with heavy shadows (`rgba(0,0,0,0.5) 0px 8px 24px`) on elevated elements and a unique inset border-shadow combo (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`), the result is an interface that feels like a premium audio device — tactile, rounded, and built for touch. **Key Characteristics:** -- Golden-amber color universe: every tone from pale cream (#fffaeb) to burnt orange (#fa520f) -- Massive display typography (82px) with aggressive negative letter-spacing (-2.05px) -- Warm golden shadow system using amber-tinted rgba values -- The Mistral "M" block identity — a gradient from yellow to orange -- Dramatic landscape photography in warm golden tones -- Uppercase typography used strategically for section labels and CTAs -- Near-zero border-radius — sharp, architectural geometry -- French-European confidence: bold, warm, declarative +- Near-black immersive dark theme (`#121212`–`#1f1f1f`) — UI disappears behind content +- Spotify Green (`#1ed760`) as singular brand accent — never decorative, always functional +- SpotifyMixUI/CircularSp font family with global script support +- Pill buttons (500px–9999px) and circular controls (50%) — rounded, touch-optimized +- Uppercase button labels with wide letter-spacing (1.4px–2px) +- Heavy shadows on elevated elements (`rgba(0,0,0,0.5) 0px 8px 24px`) +- Semantic colors: negative red (`#f3727f`), warning orange (`#ffa42b`), announcement blue (`#539df5`) +- Album art as the primary color source — the UI is achromatic by design ## 2. Color Palette & Roles -### Primary -- **Mistral Orange** (`#fa520f`): The core brand color — a vivid, saturated orange-red that anchors the entire identity. Used for primary emphasis, the brand block, and the highest-signal moments. -- **Mistral Flame** (`#fb6424`): A slightly warmer, lighter variant of the brand orange used for secondary brand moments and hover states. -- **Block Orange** (`#ff8105`): A pure orange used in the gradient block system — warmer and less red than Mistral Orange. - -### Secondary & Accent -- **Sunshine 900** (`#ff8a00`): Deep golden amber — the darkest sunshine tone, used for strong accent moments. -- **Sunshine 700** (`#ffa110`): Warm amber-gold — the core sunshine accent for backgrounds and interactive elements. -- **Sunshine 500** (`#ffb83e`): Medium golden — balanced warmth for mid-level emphasis. -- **Sunshine 300** (`#ffd06a`): Light golden — for subtle warm tints and secondary backgrounds. -- **Block Gold** (`#ffe295`): Pale gold — soft background accents and gentle warmth. -- **Bright Yellow** (`#ffd900`): The brightest tone in the gradient — used at the "top" of the block identity. - -### Surface & Background -- **Warm Ivory** (`#fffaeb`): The lightest page background — barely tinted with warmth, the foundation canvas. -- **Cream** (`#fff0c2`): The primary warm surface and secondary button background — noticeably golden. -- **Pure White** (`#ffffff`): Used for maximum contrast elements and popover surfaces. -- **Mistral Black** (`#1f1f1f`): The primary dark surface for buttons, text, and dark sections. -- **Accent Orange** (defined as `hsl(17, 96%, 52%)`): The functional accent color for interactive states. - -### Neutrals & Text -- **Mistral Black** (`#1f1f1f`): Primary text color and dark button backgrounds — a near-black that's warmer than pure #000. -- **Black Tint** (defined as `hsl(0, 0%, 24%)`): A medium dark gray for secondary text on light backgrounds. -- **Pure White** (`#ffffff`): Text on dark surfaces and CTA labels. - -### Semantic & Accent -- **Input Border** (defined as `hsl(240, 5.9%, 90%)`): A cool-tinted light gray for form borders — one of the few cool tones in the system. -- **White Overlay** (`oklab(1, 0, 0 / 0.088–0.1)`): Semi-transparent white for frosted glass effects and button overlays. - -### Gradient System -- **Mistral Block Gradient**: The signature identity — a multi-step gradient flowing through Yellow (`#ffd900`) → Gold (`#ffe295`) → Amber (`#ffa110`) → Orange (`#ff8105`) → Flame (`#fb6424`) → Mistral Orange (`#fa520f`). This gradient appears in the logo blocks, section backgrounds, and decorative elements. -- **Golden Landscape Wash**: Photography and backgrounds use warm amber overlays creating a consistent golden temperature across the page. -- **Warm Shadow Cascade**: Multi-layered golden shadows that build depth with amber-tinted transparency rather than gray. +### Primary Brand +- **Spotify Green** (`#1ed760`): Primary brand accent — play buttons, active states, CTAs +- **Near Black** (`#121212`): Deepest background surface +- **Dark Surface** (`#181818`): Cards, containers, elevated surfaces +- **Mid Dark** (`#1f1f1f`): Button backgrounds, interactive surfaces + +### Text +- **White** (`#ffffff`): `--text-base`, primary text +- **Silver** (`#b3b3b3`): Secondary text, muted labels, inactive nav +- **Near White** (`#cbcbcb`): Slightly brighter secondary text +- **Light** (`#fdfdfd`): Near-pure white for maximum emphasis + +### Semantic +- **Negative Red** (`#f3727f`): `--text-negative`, error states +- **Warning Orange** (`#ffa42b`): `--text-warning`, warning states +- **Announcement Blue** (`#539df5`): `--text-announcement`, info states + +### Surface & Border +- **Dark Card** (`#252525`): Elevated card surface +- **Mid Card** (`#272727`): Alternate card surface +- **Border Gray** (`#4d4d4d`): Button borders on dark +- **Light Border** (`#7c7c7c`): Outlined button borders, muted links +- **Separator** (`#b3b3b3`): Divider lines +- **Light Surface** (`#eeeeee`): Light-mode buttons (rare) +- **Spotify Green Border** (`#1db954`): Green accent border variant + +### Shadows +- **Heavy** (`rgba(0,0,0,0.5) 0px 8px 24px`): Dialogs, menus, elevated panels +- **Medium** (`rgba(0,0,0,0.3) 0px 8px 8px`): Cards, dropdowns +- **Inset Border** (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`): Input border-shadow combo ## 3. Typography Rules -### Font Family -- **Primary**: Likely a custom font (Font Source detected) with `Arial` as fallback, and extended stack: `ui-sans-serif, system-ui, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji` +### Font Families +- **Title**: `SpotifyMixUITitle`, fallbacks: `CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, Helvetica Neue, helvetica, arial, Hiragino Sans, Hiragino Kaku Gothic ProN, Meiryo, MS Gothic` +- **UI / Body**: `SpotifyMixUI`, same fallback stack ### Hierarchy | Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | |------|------|------|--------|-------------|----------------|-------| -| Display / Hero | Arial (custom) | 82px (5.13rem) | 400 | 1.00 (tight) | -2.05px | Maximum impact, billboard scale | -| Section Heading | Arial (custom) | 56px (3.5rem) | 400 | 0.95 (ultra-tight) | normal | Feature section anchors | -| Sub-heading Large | Arial (custom) | 48px (3rem) | 400 | 0.95 (ultra-tight) | normal | Secondary section titles | -| Sub-heading | Arial (custom) | 32px (2rem) | 400 | 1.15 (tight) | normal | Card headings, feature names | -| Card Title | Arial (custom) | 30px (1.88rem) | 400 | 1.20 (tight) | normal | Mid-level headings | -| Feature Title | Arial (custom) | 24px (1.5rem) | 400 | 1.33 | normal | Small headings | -| Body / Button | Arial (custom) | 16px (1rem) | 400 | 1.50 | normal | Standard body, button text | -| Button Uppercase | Arial (custom) | 16px (1rem) | 400 | 1.50 | normal | Uppercase CTA labels | -| Caption / Link | Arial (custom) | 14px (0.88rem) | 400 | 1.43 | normal | Metadata, secondary links | +| Section Title | SpotifyMixUITitle | 24px (1.50rem) | 700 | normal | normal | Bold title weight | +| Feature Heading | SpotifyMixUI | 18px (1.13rem) | 600 | 1.30 (tight) | normal | Semibold section heads | +| Body Bold | SpotifyMixUI | 16px (1.00rem) | 700 | normal | normal | Emphasized text | +| Body | SpotifyMixUI | 16px (1.00rem) | 400 | normal | normal | Standard body | +| Button Uppercase | SpotifyMixUI | 14px (0.88rem) | 600–700 | 1.00 (tight) | 1.4px–2px | `text-transform: uppercase` | +| Button | SpotifyMixUI | 14px (0.88rem) | 700 | normal | 0.14px | Standard button | +| Nav Link Bold | SpotifyMixUI | 14px (0.88rem) | 700 | normal | normal | Navigation | +| Nav Link | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Inactive nav | +| Caption Bold | SpotifyMixUI | 14px (0.88rem) | 700 | 1.50–1.54 | normal | Bold metadata | +| Caption | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Metadata | +| Small Bold | SpotifyMixUI | 12px (0.75rem) | 700 | 1.50 | normal | Tags, counts | +| Small | SpotifyMixUI | 12px (0.75rem) | 400 | normal | normal | Fine print | +| Badge | SpotifyMixUI | 10.5px (0.66rem) | 600 | 1.33 | normal | `text-transform: capitalize` | +| Micro | SpotifyMixUI | 10px (0.63rem) | 400 | normal | normal | Smallest text | ### Principles -- **Single weight, maximum impact**: The entire system uses weight 400 (regular) — even at 82px. This creates a surprisingly elegant effect where the size alone carries authority without needing bold weight. -- **Ultra-tight at scale**: Line-heights of 0.95–1.00 at display sizes create text blocks where ascenders nearly touch descenders from the line above — creating dense, poster-like composition. -- **Aggressive tracking on display**: -2.05px letter-spacing at 82px compresses the hero text into a monolithic block. -- **Uppercase as emphasis**: Strategic `text-transform: uppercase` on button labels and section markers creates a formal, European signage quality. -- **No weight variation**: Unlike most systems that use 300–700 weight range, Mistral uses 400 everywhere. Hierarchy comes from size and color, never weight. +- **Bold/regular binary**: Most text is either 700 (bold) or 400 (regular), with 600 used sparingly. This creates a clear visual hierarchy through weight contrast rather than size variation. +- **Uppercase buttons as system**: Button labels use uppercase + wide letter-spacing (1.4px–2px), creating a systematic "label" voice distinct from content text. +- **Compact sizing**: The range is 10px–24px — narrower than most systems. Spotify's type is compact and functional, designed for scanning playlists, not reading articles. +- **Global script support**: The extensive fallback stack (Arabic, Hebrew, Cyrillic, Greek, Devanagari, CJK) reflects Spotify's 180+ market reach. ## 4. Component Stylings ### Buttons -**Cream Surface** -- Background: Cream (`#fff0c2`) -- Text: Mistral Black (`#1f1f1f`) -- No visible border -- The warm, inviting secondary CTA - -**Dark Solid** -- Background: Mistral Black (`#1f1f1f`) -- Text: Pure White (`#ffffff`) -- Padding: 12px (all sides) -- No visible border -- The primary action button — dark on warm - -**Ghost / Transparent** -- Background: transparent with slight dark overlay (`oklab(0, 0, 0 / 0.1)`) -- Text: Mistral Black (`#1f1f1f`) -- Opacity: 0.4 -- For secondary/de-emphasized actions - -**Text / Underline** +**Dark Pill** +- Background: `#1f1f1f` +- Text: `#ffffff` or `#b3b3b3` +- Padding: 8px 16px +- Radius: 9999px (full pill) +- Use: Navigation pills, secondary actions + +**Dark Large Pill** +- Background: `#181818` +- Text: `#ffffff` +- Padding: 0px 43px +- Radius: 500px +- Use: Primary app navigation buttons + +**Light Pill** +- Background: `#eeeeee` +- Text: `#181818` +- Radius: 500px +- Use: Light-mode CTAs (cookie consent, marketing) + +**Outlined Pill** - Background: transparent -- Text: Mistral Black (`#1f1f1f`) -- Padding: 8px 0px 0px (top-only) -- Minimal styling — text link as button -- For tertiary navigation actions +- Text: `#ffffff` +- Border: `1px solid #7c7c7c` +- Padding: 4px 16px 4px 36px (asymmetric for icon) +- Radius: 9999px +- Use: Follow buttons, secondary actions + +**Circular Play** +- Background: `#1f1f1f` +- Text: `#ffffff` +- Padding: 12px +- Radius: 50% (circle) +- Use: Play/pause controls ### Cards & Containers -- Background: Warm Ivory (`#fffaeb`), Cream (`#fff0c2`), or Pure White -- Border: minimal to none — containers defined by background color -- Radius: near-zero — sharp, architectural corners -- Shadow: warm golden multi-layer (`rgba(127, 99, 21, 0.12) -8px 16px 39px, rgba(127, 99, 21, 0.1) -33px 64px 72px, rgba(127, 99, 21, 0.06) -73px 144px 97px, ...`) — a dramatic, cascading warm shadow -- Distinctive: the golden shadow creates a "golden hour" lighting effect - -### Inputs & Forms -- Border: `hsl(240, 5.9%, 90%)` — the sole cool-toned element -- Focus: accent color ring -- Minimal styling consistent with sparse aesthetic +- Background: `#181818` or `#1f1f1f` +- Radius: 6px–8px +- No visible borders on most cards +- Hover: slight background lightening +- Shadow: `rgba(0,0,0,0.3) 0px 8px 8px` on elevated + +### Inputs +- Search input: `#1f1f1f` background, `#ffffff` text +- Radius: 500px (pill) +- Padding: 12px 96px 12px 48px (icon-aware) +- Focus: border becomes `#000000`, outline `1px solid` ### Navigation -- Transparent nav overlaying the warm hero -- Logo: Mistral "M" wordmark -- Links: Dark text (white on dark sections) -- CTA: Dark solid button or cream surface button -- Minimal, wide-spaced layout - -### Image Treatment -- Dramatic landscape photography in warm golden tones -- The winding road through golden hills — a recurring visual motif -- The Mistral "M" rendered at large scale on golden backgrounds -- Warm color grading on all photography -- Full-bleed sections with photography - -### Distinctive Components - -**Mistral Block Identity** -- A row of colored blocks forming the gradient: yellow → amber → orange → burnt orange -- Each block gets progressively more orange/red -- The visual DNA of the brand — recognizable at any size - -**Golden Shadow Cards** -- Cards elevated with warm amber multi-layered shadows -- 5 layers of shadow from 16px to 400px offset -- Creates a "floating in golden light" effect unique to Mistral - -**Dark Footer Gradient** -- Footer transitions from warm amber to dark through a dramatic gradient -- Creates a "sunset" effect as the page ends +- Dark sidebar with SpotifyMixUI 14px weight 700 for active, 400 for inactive +- `#b3b3b3` muted color for inactive items, `#ffffff` for active +- Circular icon buttons (50% radius) +- Spotify logo top-left in green ## 5. Layout Principles ### Spacing System - Base unit: 8px -- Scale: 2px, 4px, 8px, 10px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px, 80px, 98px, 100px -- Button padding: 12px or 8px 0px (compact) -- Section vertical spacing: very generous (80px–100px) +- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 15px, 16px, 20px ### Grid & Container -- Max container width: approximately 1280px, centered -- Hero: full-width with massive typography overlaying warm backgrounds -- Feature sections: wide-format layouts with dramatic imagery -- Card grids: 2–3 column layouts +- Sidebar (fixed) + main content area +- Grid-based album/playlist cards +- Full-width now-playing bar at bottom +- Responsive content area fills remaining space ### Whitespace Philosophy -- **Bold declarations**: Huge headlines surrounded by generous whitespace create billboard-like impact — each statement gets its own breathing space. -- **Warm void**: Empty space itself feels warm because the backgrounds are tinted ivory/cream rather than pure white. -- **Photography as space-filler**: Large landscape images serve double duty as content and decorative whitespace. +- **Dark compression**: Spotify packs content densely — playlist grids, track lists, and navigation are all tightly spaced. The dark background provides visual rest between elements without needing large gaps. +- **Content density over breathing room**: This is an app, not a marketing site. Every pixel serves the listening experience. ### Border Radius Scale -- Near-zero: The dominant radius — sharp, architectural corners on most elements -- This extreme sharpness contrasts with the warmth of the colors, creating a tension between soft color and hard geometry. +- Minimal (2px): Badges, explicit tags +- Subtle (4px): Inputs, small elements +- Standard (6px): Album art containers, cards +- Comfortable (8px): Sections, dialogs +- Medium (10px–20px): Panels, overlay elements +- Large (100px): Large pill buttons +- Pill (500px): Primary buttons, search input +- Full Pill (9999px): Navigation pills, search +- Circle (50%): Play buttons, avatars, icons ## 6. Depth & Elevation | Level | Treatment | Use | |-------|-----------|-----| -| Flat (Level 0) | No shadow | Page backgrounds, text blocks | -| Golden Float (Level 1) | Multi-layer warm shadow (5 layers, 12%→0% opacity, amber-tinted) | Feature cards, product showcases, elevated content | +| Base (Level 0) | `#121212` background | Deepest layer, page background | +| Surface (Level 1) | `#181818` or `#1f1f1f` | Cards, sidebar, containers | +| Elevated (Level 2) | `rgba(0,0,0,0.3) 0px 8px 8px` | Dropdown menus, hover cards | +| Dialog (Level 3) | `rgba(0,0,0,0.5) 0px 8px 24px` | Modals, overlays, menus | +| Inset (Border) | `rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset` | Input borders | -**Shadow Philosophy**: Mistral uses a single but extraordinarily complex shadow — **five cascading layers** of amber-tinted shadow (`rgba(127, 99, 21, ...)`) that build from a close 16px offset to a distant 400px offset. The result is a rich, warm, "golden hour" lighting effect that makes elevated elements look like they're bathed in afternoon sunlight. This is the most distinctive shadow system in any major AI brand. +**Shadow Philosophy**: Spotify uses notably heavy shadows for a dark-themed app. The 0.5 opacity shadow at 24px blur creates a dramatic "floating in darkness" effect for dialogs and menus, while the 0.3 opacity at 8px blur provides a more subtle card lift. The unique inset border-shadow combination on inputs creates a recessed, tactile quality. ## 7. Do's and Don'ts ### Do -- Use the warm color spectrum exclusively: ivory, cream, amber, gold, orange -- Keep display typography at 82px+ with -2.05px letter-spacing for hero sections -- Use the Mistral block gradient (yellow → amber → orange) for brand moments -- Apply warm golden shadows (amber-tinted rgba) for elevated elements -- Use Mistral Black (#1f1f1f) for text — never pure #000000 -- Keep font weight at 400 throughout — let size and color carry hierarchy -- Use sharp, architectural corners — near-zero border-radius -- Apply uppercase on button labels and section markers for European formality -- Use warm landscape photography with golden color grading +- Use near-black backgrounds (`#121212`–`#1f1f1f`) — depth through shade variation +- Apply Spotify Green (`#1ed760`) only for play controls, active states, and primary CTAs +- Use pill shape (500px–9999px) for all buttons — circular (50%) for play controls +- Apply uppercase + wide letter-spacing (1.4px–2px) on button labels +- Keep typography compact (10px–24px range) — this is an app, not a magazine +- Use heavy shadows (`0.3–0.5 opacity`) for elevated elements on dark backgrounds +- Let album art provide color — the UI itself is achromatic ### Don't -- Don't introduce cool colors (blue, green, purple) — the palette is exclusively warm -- Don't use bold (700+) weight — 400 is the only weight -- Don't round corners — the sharp geometry is intentional -- Don't use cool-toned shadows — shadows must carry amber warmth -- Don't use pure white as a page background — always warm-tinted (#fffaeb minimum) -- Don't reduce hero text below 48px on desktop — the billboard scale is core -- Don't use more than 2 font weights — size variation replaces weight variation -- Don't add gradients outside the warm spectrum — no blue-to-purple, no cool transitions -- Don't use generic gray for text — even neutrals should be warm-tinted +- Don't use Spotify Green decoratively or on backgrounds — it's functional only +- Don't use light backgrounds for primary surfaces — the dark immersion is core +- Don't skip the pill/circle geometry on buttons — square buttons break the identity +- Don't use thin/subtle shadows — on dark backgrounds, shadows need to be heavy to be visible +- Don't add additional brand colors — green + achromatic grays is the complete palette +- Don't use relaxed line-heights — Spotify's typography is compact and dense +- Don't expose raw gray borders — use shadow-based or inset borders instead ## 8. Responsive Behavior ### Breakpoints | Name | Width | Key Changes | |------|-------|-------------| -| Mobile | <640px | Single column, stacked everything, hero text reduces to ~32px | -| Tablet | 640–768px | Minor layout adjustments | -| Small Desktop | 768–1024px | 2-column layouts begin | -| Desktop | 1024–1280px | Full layout with maximum typography scale | - -### Touch Targets -- Buttons use generous padding (12px minimum) -- Navigation elements adequately spaced -- Cards serve as large touch targets +| Mobile Small | <425px | Compact mobile layout | +| Mobile | 425–576px | Standard mobile | +| Tablet | 576–768px | 2-column grid | +| Tablet Large | 768–896px | Expanded layout | +| Desktop Small | 896–1024px | Sidebar visible | +| Desktop | 1024–1280px | Full desktop layout | +| Large Desktop | >1280px | Expanded grid | ### Collapsing Strategy -- **Navigation**: Collapses to hamburger on mobile -- **Hero text**: 82px → 56px → 48px → 32px progressive scaling -- **Feature sections**: Multi-column → stacked -- **Photography**: Scales proportionally, may crop on mobile -- **Block identity**: Scales down proportionally - -### Image Behavior -- Landscape photography scales proportionally -- Warm color grading maintained at all sizes -- Block gradient elements resize fluidly -- No art direction changes — same warm composition at all sizes +- Sidebar: full → collapsed → hidden +- Album grid: 5 columns → 3 → 2 → 1 +- Now-playing bar: maintained at all sizes +- Search: pill input maintained, width adjusts +- Navigation: sidebar → bottom bar on mobile ## 9. Agent Prompt Guide ### Quick Color Reference -- Brand Orange: "Mistral Orange (#fa520f)" -- Page Background: "Warm Ivory (#fffaeb)" -- Warm Surface: "Cream (#fff0c2)" -- Primary Text: "Mistral Black (#1f1f1f)" -- Sunshine Amber: "Sunshine 700 (#ffa110)" -- Bright Gold: "Bright Yellow (#ffd900)" -- Text on Dark: "Pure White (#ffffff)" +- Background: Near Black (`#121212`) +- Surface: Dark Card (`#181818`) +- Text: White (`#ffffff`) +- Secondary text: Silver (`#b3b3b3`) +- Accent: Spotify Green (`#1ed760`) +- Border: `#4d4d4d` +- Error: Negative Red (`#f3727f`) ### Example Component Prompts -- "Create a hero section on Warm Ivory (#fffaeb) with a massive headline at 82px Arial weight 400, line-height 1.0, letter-spacing -2.05px. Mistral Black (#1f1f1f) text. Add a dark solid CTA button (#1f1f1f bg, white text, 12px padding, sharp corners) and a cream secondary button (#fff0c2 bg)." -- "Design a feature card on Cream (#fff0c2) with sharp corners (no border-radius). Apply the golden shadow system: rgba(127, 99, 21, 0.12) -8px 16px 39px as the primary layer. Title at 32px weight 400, body at 16px." -- "Build the Mistral block identity: a row of colored blocks from Bright Yellow (#ffd900) through Sunshine 700 (#ffa110) to Mistral Orange (#fa520f). Sharp corners, no gaps." -- "Create a dark footer section on Mistral Black (#1f1f1f) with Pure White (#ffffff) text. Footer links at 14px. Add a warm gradient from Sunshine 700 (#ffa110) at the top fading to Mistral Black." +- "Create a dark card: #181818 background, 8px radius. Title at 16px SpotifyMixUI weight 700, white text. Subtitle at 14px weight 400, #b3b3b3. Shadow rgba(0,0,0,0.3) 0px 8px 8px on hover." +- "Design a pill button: #1f1f1f background, white text, 9999px radius, 8px 16px padding. 14px SpotifyMixUI weight 700, uppercase, letter-spacing 1.4px." +- "Build a circular play button: Spotify Green (#1ed760) background, #000000 icon, 50% radius, 12px padding." +- "Create search input: #1f1f1f background, white text, 500px radius, 12px 48px padding. Inset border: rgb(124,124,124) 0px 0px 0px 1px inset." +- "Design navigation sidebar: #121212 background. Active items: 14px weight 700, white. Inactive: 14px weight 400, #b3b3b3." ### Iteration Guide -1. Keep the warm temperature — "shift toward amber" not "shift toward gray" -2. Use size for hierarchy — 82px → 56px → 48px → 32px → 24px → 16px -3. Never add border-radius — sharp corners only -4. Shadows are always warm: "golden shadow with amber tones" -5. Font weight is always 400 — describe emphasis through size and color +1. Start with #121212 — everything lives in near-black darkness +2. Spotify Green for functional highlights only (play, active, CTA) +3. Pill everything — 500px for large, 9999px for small, 50% for circular +4. Uppercase + wide tracking on buttons — the systematic label voice +5. Heavy shadows (0.3–0.5 opacity) for elevation — light shadows are invisible on dark +6. Album art provides all the color — the UI stays achromatic diff --git a/apps/web/app/(pages)/admin/page.tsx b/apps/web/app/(pages)/admin/page.tsx new file mode 100644 index 0000000..eaff3a1 --- /dev/null +++ b/apps/web/app/(pages)/admin/page.tsx @@ -0,0 +1,81 @@ +import { api } from "@/lib/api"; +import { Badge } from "@/components/shared/badge"; +import { Card, CardHeader } from "@/components/shared/card"; +import { JobControls } from "@/components/shared/job-controls"; + +export default async function AdminPage() { + const data = await api.admin(); + + return ( +
+ + +
+ +

활성 소스

+
    + {data.sources.map((source) => ( +
  • + {source.name} · {source.kind} · 신뢰도 {source.reliabilityScore.toFixed(2)} +
  • + ))} +
+
+ +

역할별 모델 라우팅

+
    + {data.modelRouting.map((route) => ( +
  • + {route.role} → {route.model} +
  • + ))} +
+
+
+
+ + + + + + +
+ +

비용 가드레일

+

+ 일일 최대 ${data.costGuardrails.dailyBudgetUsd.toFixed(2)} / 작업당 최대 $ + {data.costGuardrails.perJobBudgetUsd.toFixed(2)} +

+
+ +

스케줄러

+

+ 프리마켓 {data.scheduler.premarketCron} +
+ 포스트마켓 {data.scheduler.postmarketCron} +

+
+ +
+

승격 승인

+ + {data.promotion.manualApprovalRequired ? "수동 승인 필요" : "자동 아님"} + +
+

+ 후보 승격은 평가 아티팩트 저장, 롤링 OOS 개선 확인, 사용자 수동 승인 전까지 + 실운영 라우팅에 반영되지 않습니다. +

+
+
+
+ ); +} diff --git a/apps/web/app/(pages)/dashboard/page.tsx b/apps/web/app/(pages)/dashboard/page.tsx new file mode 100644 index 0000000..9678e63 --- /dev/null +++ b/apps/web/app/(pages)/dashboard/page.tsx @@ -0,0 +1,195 @@ +import { api } from "@/lib/api"; +import { formatPct, formatScore } from "@/lib/format"; +import { Badge } from "@/components/shared/badge"; +import { Card, CardHeader } from "@/components/shared/card"; +import { JobControls } from "@/components/shared/job-controls"; +import { SectionList } from "@/components/shared/section-list"; +import { MetricCard } from "@/components/dashboard/metric-card"; + +export default async function DashboardPage() { + const data = await api.dashboard(); + + return ( +
+ +
+
+
+
+
+
+

Premarket Briefing

+
+

+ 미국 반응을 검증해 내일 한국장 테마와 주도주 후보를 빠르게 압축합니다. +

+

+ 공식 원문, 실측 반응, 아날로그 검색, 역할별 에이전트 논증을 하나의 + 증거 사슬로 묶어 다음 영업일 한국장 해석을 빠르게 압축합니다. +

+
+
+ + U.S. Macro + + + Market Reaction + + + Korea Themes + +
+
+ {data.riskFlags.map((flag) => ( + + {flag} + + ))} +
+
+
+
+
+

오늘의 핵심 결론

+
+

우선 테마

+

+ {data.topTheme?.name ?? "데이터 대기"} +

+
+
+

주도주 후보

+

+ {data.topLeader?.ticker ?? "-"} {data.topLeader?.name ?? ""} +

+
+
+
+
+

+ 종합 신뢰도 +

+

+ {data.topTheme ? formatScore(data.topTheme.confidence) : "-"} +

+
+
+

+ 미국 반응 +

+

+ {formatPct(data.usReactionSummary.totalMovePct)} +

+
+
+
+
+ + +
+ + + + +
+ + + + + + +
+ + + ({ + title: `${event.categoryLabel} · ${event.title}`, + subtitle: `${event.sourceName} | 직접성 ${formatScore(event.directnessScore)} | 시장확인 ${formatScore(event.marketConfirmationScore)}`, + meta: event.publishedAtLabel + }))} + /> + + + +
+ {data.usReactionSummary.items.map((item) => ( +
+
+

{item.label}

+ = 0 ? "ok" : "danger"}> + {formatPct(item.movePct)} + +
+

+ {item.commentary} +

+
+ ))} +
+
+
+ +
+ + + ({ + title: `${theme.name} · ${formatScore(theme.confidence)}`, + subtitle: `${theme.rationale} | 거래용이성 ${formatScore(theme.tradabilityScore)} | 갭페이드 위험 ${formatScore(theme.gapFadeRisk)}`, + meta: theme.marketView + }))} + /> + + + + ({ + title: `${stock.ticker} ${stock.name}`, + subtitle: `${stock.tierLabel} | ${stock.rationale} | 선반영 위험 ${formatScore(stock.pricedInRisk)}`, + meta: formatScore(stock.score) + }))} + /> + +
+
+ ); +} diff --git a/apps/web/app/(pages)/evaluation/page.tsx b/apps/web/app/(pages)/evaluation/page.tsx new file mode 100644 index 0000000..da77630 --- /dev/null +++ b/apps/web/app/(pages)/evaluation/page.tsx @@ -0,0 +1,91 @@ +import { api } from "@/lib/api"; +import { Card, CardHeader } from "@/components/shared/card"; +import { Badge } from "@/components/shared/badge"; +import { formatPct, formatScore } from "@/lib/format"; + +export default async function EvaluationPage() { + const data = await api.evaluation(); + + return ( +
+ + +
+ +

테마 적중률

+

{formatPct(data.rollingMetrics.themeHitRate * 100)}

+
+ +

주도주 적중률

+

{formatPct(data.rollingMetrics.leaderHitRate * 100)}

+
+ +

오탐 비율

+

{formatPct(data.rollingMetrics.falsePositiveRate * 100)}

+
+ +

갭페이드 실패

+

{formatPct(data.rollingMetrics.gapFadeMissRate * 100)}

+
+
+
+ +
+ + +
+ {data.promptLeaderboard.map((item) => ( +
+
+
+

{item.version}

+

+ {item.description} +

+
+ + {item.promotable ? "승격 가능" : "보류"} + +
+
+

종합 점수 {formatScore(item.score)}

+

테마 적중률 {formatPct(item.themeHitRate * 100)}

+

주도주 적중률 {formatPct(item.leaderHitRate * 100)}

+
+
+ ))} +
+
+ + +
+ {data.modelRoleLeaderboard.map((item) => ( +
+
+
+

{item.role}

+

{item.model}

+
+ {formatScore(item.score)} +
+

+ 평균 지연 {item.avgLatencyMs}ms | 평균 비용 ${item.avgCostUsd.toFixed(4)} +

+
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/app/(pages)/events/page.tsx b/apps/web/app/(pages)/events/page.tsx new file mode 100644 index 0000000..549ec40 --- /dev/null +++ b/apps/web/app/(pages)/events/page.tsx @@ -0,0 +1,93 @@ +import { api } from "@/lib/api"; +import { Badge } from "@/components/shared/badge"; +import { Card, CardHeader } from "@/components/shared/card"; +import { formatPct, formatScore } from "@/lib/format"; + +export default async function EventsPage() { + const data = await api.events(); + + return ( +
+ + +
+ {data.clusters.map((cluster) => ( +
+
+
+
+ {cluster.categoryLabel} + + {cluster.marketConfirmed ? "시장 확인" : "시장 미확인"} + +
+

+ {cluster.title} +

+

+ {cluster.summary} +

+
+
+

새로움 {formatScore(cluster.noveltyScore)}

+

직접성 {formatScore(cluster.directnessScore)}

+

서프라이즈 {formatScore(cluster.surpriseScore)}

+

지속성 {formatScore(cluster.persistenceScore)}

+
+
+
+ +

원문 출처

+
    + {cluster.sources.map((source) => ( +
  • + + {source.name} + + 수집 {source.fetchedAtLabel} +
  • + ))} +
+
+ +

미국 반응

+
    + {cluster.reactions.map((reaction) => ( +
  • + {reaction.label}: {formatPct(reaction.movePct)} / {reaction.window} +
  • + ))} +
+
+ +

에이전트 코멘터리

+
    + {cluster.agentNotes.map((note) => ( +
  • + {note.role} + {" · "} + {note.note} +
  • + ))} +
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/app/(pages)/replay/page.tsx b/apps/web/app/(pages)/replay/page.tsx new file mode 100644 index 0000000..8037fee --- /dev/null +++ b/apps/web/app/(pages)/replay/page.tsx @@ -0,0 +1,210 @@ +import { api } from "@/lib/api"; +import { formatPct, formatScore } from "@/lib/format"; +import { Badge } from "@/components/shared/badge"; +import { Card, CardHeader } from "@/components/shared/card"; + +function outcomeVariant(label: string): "ok" | "warn" | "danger" { + if (label === "강한 적중" || label === "양호") { + return "ok"; + } + if (label === "혼조") { + return "warn"; + } + return "danger"; +} + +export default async function ReplayPage() { + const data = await api.weeklyReplay(); + + return ( +
+ + +
+ +

Window

+

{data.windowLabel}

+

+ 현재 추론 규칙과 동일한 로직을 과거 구간에 그대로 적용합니다. +

+
+ +

Hit Days

+

+ {data.aggregate.positiveHitDays} / {data.aggregate.daysAnalyzed} +

+

+ 상위 테마 주도주 평균 수익률이 양호 이상이었던 날짜 수입니다. +

+
+ +

Avg Leader Return

+

+ {formatPct(data.aggregate.avgLeaderCloseReturnPct)} +

+

+ 상위 테마 기준 주도주 평균 종가 수익률입니다. +

+
+
+
+ +
+ {data.days.map((day) => ( + +
+
+

Replay Day

+

+ {day.marketDateLabel} 한국장 +

+

+ 추론 기준 시각 {day.asOfLabel} / evidence hash {day.evidencePackHash.slice(0, 12)} +

+
+
+

KOSPI {formatPct(day.marketContext.kospiCloseReturnPct)}

+

KOSDAQ {formatPct(day.marketContext.kosdaqCloseReturnPct)}

+

{day.marketContext.summary}

+
+
+ +
+

{day.summary}

+
+ +
+
+ {day.predictedThemes.length ? ( + day.predictedThemes.map((theme) => ( +
+
+
+
+ {theme.name} + + {theme.actualOutcome.outcomeLabel} + + = 0.7 ? "ok" : "warn"}> + 확신 {formatScore(theme.confidence)} + +
+

+ {theme.rationale} +

+
+
+

주도주 평균 {formatPct(theme.actualOutcome.avgLeaderCloseReturnPct)}

+

+ 최고 수익 {theme.actualOutcome.bestStockLabel ?? "-"} + {theme.actualOutcome.bestCloseReturnPct !== null + ? ` / ${formatPct(theme.actualOutcome.bestCloseReturnPct)}` + : ""} +

+
+
+ +
+
+

예상 주도주 / 2등주

+

+ 주도주: {theme.leaders.map((item) => `${item.ticker} ${item.name}`).join(", ") || "-"} +

+

+ 2등주: {theme.secondTier.map((item) => `${item.ticker} ${item.name}`).join(", ") || "-"} +

+

+ 무효화 조건: {theme.invalidationCondition} +

+
+
+

실제 종목 반응

+
+ {theme.actualOutcome.stockResults.length ? ( + theme.actualOutcome.stockResults.map((stock) => ( +
+
+

+ {stock.ticker} {stock.name} +

+ = 0 ? "ok" : "danger"}> + {stock.tier === "leader" ? "주도주" : "2등주"} + +
+

+ 시가 갭 {formatPct(stock.openGapPct)} / 종가 수익률 {formatPct(stock.closeReturnPct)} / + 장중 변화 {formatPct(stock.intradayMovePct)} +

+
+ )) + ) : ( +

+ 실제 종목 데이터를 불러오지 못했습니다. +

+ )} +
+
+
+
+ )) + ) : ( +
+ 해당 날짜에는 현재 규칙 기준으로 상위 한국 테마 예측이 생성되지 않았습니다. +
+ )} +
+ +
+ +

근거 이벤트

+
+ {day.evidenceItems.map((item) => ( +
+
+

+ {item.categoryLabel} +

+ = 0.35 ? "ok" : "warn"}> + 시장 확인 {formatScore(item.marketConfirmationScore)} + +
+

{item.title}

+

+ {item.summary} +

+

+ {item.sourceName} / {item.publishedAtLabel} +

+ + 원문 보기 + +
+ ))} +
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/(pages)/themes/page.tsx b/apps/web/app/(pages)/themes/page.tsx new file mode 100644 index 0000000..03c5773 --- /dev/null +++ b/apps/web/app/(pages)/themes/page.tsx @@ -0,0 +1,87 @@ +import { api } from "@/lib/api"; +import { Badge } from "@/components/shared/badge"; +import { Card, CardHeader } from "@/components/shared/card"; +import { formatScore } from "@/lib/format"; + +export default async function ThemesPage() { + const data = await api.themes(); + + return ( +
+ + +
+ {data.themes.map((theme) => ( +
+
+
+
+ {theme.name} + = 0.7 ? "ok" : "warn"}> + 확신 {formatScore(theme.confidence)} + +
+

+ {theme.rationale} +

+
+
+

테마 적합도 {formatScore(theme.themeFitScore)}

+

거래 용이성 {formatScore(theme.tradabilityScore)}

+

갭페이드 위험 {formatScore(theme.gapFadeRisk)}

+

선반영 위험 {formatScore(theme.pricedInRisk)}

+
+
+
+ +

주도주 후보

+
    + {theme.leaders.map((stock) => ( +
  • + + {stock.ticker} {stock.name} + + {" · "} + {stock.rationale} +
  • + ))} +
+
+ +

2등주 후보

+
    + {theme.secondTier.map((stock) => ( +
  • + + {stock.ticker} {stock.name} + + {" · "} + {stock.rationale} +
  • + ))} +
+
+ +

무효화 / 추적 메모

+
    +
  • 무효화 조건: {theme.invalidationCondition}
  • +
  • 시가 메모: {theme.openNote}
  • +
  • 15분 메모: {theme.fifteenMinuteNote}
  • +
  • 종가 메모: {theme.closeNote}
  • +
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..a1b08e6 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,120 @@ +@import "tailwindcss"; + +:root { + --bg: #121212; + --bg-muted: #181818; + --bg-deep: #1f1f1f; + --surface: rgba(24, 24, 24, 0.94); + --surface-strong: rgba(31, 31, 31, 0.98); + --surface-white: rgba(39, 39, 39, 0.98); + --text: #ffffff; + --text-muted: #b3b3b3; + --primary: #1ed760; + --primary-strong: #1db954; + --accent: #1ed760; + --ok: #1ed760; + --warn: #ffa42b; + --danger: #f3727f; + --border: rgba(255, 255, 255, 0.08); + --shadow-1: rgba(0, 0, 0, 0.45); + --shadow-2: rgba(0, 0, 0, 0.3); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + font-family: + "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", Arial, + ui-sans-serif, system-ui, sans-serif; + background: + radial-gradient(circle at top, rgba(30, 215, 96, 0.14), transparent 18%), + linear-gradient(180deg, #121212 0%, #151515 100%); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} + +.glass { + backdrop-filter: blur(18px); + background: var(--surface); +} + +.gold-shadow { + box-shadow: + 0 8px 24px var(--shadow-1), + 0 2px 8px var(--shadow-2); +} + +.page-shell { + min-height: 100vh; + padding: 14px; +} + +.display-title { + font-weight: 700; + letter-spacing: -0.04em; + line-height: 1.08; + word-break: keep-all; + overflow-wrap: anywhere; +} + +.metric-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.section-eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} + +.block-gradient { + background: linear-gradient(135deg, #1ed760 0%, #1db954 100%); +} + +.ink-panel { + background: #181818; + color: #ffffff; +} + +.mistral-link { + text-decoration: underline; + text-decoration-color: var(--accent); + text-decoration-thickness: 2px; + text-underline-offset: 5px; +} + +.keep-korean { + word-break: keep-all; + overflow-wrap: anywhere; +} + +.ui-pill { + border-radius: 18px; +} + +@media (max-width: 1024px) { + .page-shell { + padding: 12px; + } + + .display-title { + letter-spacing: -0.03em; + line-height: 1.12; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..1ce210f --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { AppShell } from "@/components/layout/app-shell"; + +export const metadata: Metadata = { + title: "K-테마 리서치 허브", + description: + "미국 이벤트를 기반으로 다음 영업일 한국 시장 테마와 주도주를 예측하는 개인용 이벤트 드리븐 리서치 플랫폼" +}; + +export default function RootLayout({ + children +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..3c628d5 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function HomePage() { + redirect("/dashboard"); +} + diff --git a/apps/web/components/dashboard/metric-card.tsx b/apps/web/components/dashboard/metric-card.tsx new file mode 100644 index 0000000..a865d4c --- /dev/null +++ b/apps/web/components/dashboard/metric-card.tsx @@ -0,0 +1,25 @@ +import { Card } from "@/components/shared/card"; + +export function MetricCard({ + label, + value, + helper +}: { + label: string; + value: string; + helper: string; +}) { + return ( + +

+ {label} +

+

+ {value} +

+

+ {helper} +

+
+ ); +} diff --git a/apps/web/components/layout/app-shell.tsx b/apps/web/components/layout/app-shell.tsx new file mode 100644 index 0000000..e415450 --- /dev/null +++ b/apps/web/components/layout/app-shell.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { Activity, Database, Gauge, LayoutGrid, ShieldAlert, Sparkles } from "lucide-react"; +import { cn } from "@/components/shared/cn"; + +const navItems = [ + { href: "/dashboard", label: "대시보드", icon: LayoutGrid }, + { href: "/events", label: "이벤트 탐색기", icon: Activity }, + { href: "/themes", label: "한국 테마 보드", icon: Sparkles }, + { href: "/replay", label: "리플레이 랩", icon: Gauge }, + { href: "/evaluation", label: "평가 센터", icon: Database }, + { href: "/admin", label: "관리자", icon: ShieldAlert } +]; + +export function AppShell({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/components/shared/badge.tsx b/apps/web/components/shared/badge.tsx new file mode 100644 index 0000000..cbd063b --- /dev/null +++ b/apps/web/components/shared/badge.tsx @@ -0,0 +1,27 @@ +import { cn } from "./cn"; + +const variantMap = { + default: "bg-[rgba(30,215,96,0.16)] text-[color:var(--primary)]", + ok: "bg-[rgba(30,215,96,0.16)] text-[color:var(--ok)]", + warn: "bg-[rgba(255,164,43,0.16)] text-[color:var(--warn)]", + danger: "bg-[rgba(243,114,127,0.16)] text-[color:var(--danger)]" +} as const; + +export function Badge({ + children, + variant = "default" +}: { + children: React.ReactNode; + variant?: keyof typeof variantMap; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/components/shared/card.tsx b/apps/web/components/shared/card.tsx new file mode 100644 index 0000000..2639de6 --- /dev/null +++ b/apps/web/components/shared/card.tsx @@ -0,0 +1,50 @@ +import { cn } from "./cn"; + +export function Card({ + className, + children +}: { + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function CardHeader({ + eyebrow, + title, + description +}: { + eyebrow?: string; + title: string; + description?: string; +}) { + return ( +
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +
+

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/components/shared/cn.ts b/apps/web/components/shared/cn.ts new file mode 100644 index 0000000..6fcf374 --- /dev/null +++ b/apps/web/components/shared/cn.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + diff --git a/apps/web/components/shared/job-controls.tsx b/apps/web/components/shared/job-controls.tsx new file mode 100644 index 0000000..04ee169 --- /dev/null +++ b/apps/web/components/shared/job-controls.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1"; + +type JobKind = "refresh" | "ingest" | "analyze"; + +type JobRunItem = { + id: string; + jobId: string; + jobName: string; + triggerKind: string; + status: "queued" | "running" | "completed" | "failed" | string; + requestedAtLabel: string; + startedAtLabel?: string | null; + finishedAtLabel?: string | null; + resultSummary?: string | null; + errorMessage?: string | null; +}; + +const jobConfig: Record< + JobKind, + { + path: string; + label: string; + description: string; + tone: string; + } +> = { + refresh: { + path: "/jobs/refresh", + label: "수집 후 분석 실행", + description: "원문 수집부터 이벤트/미국 반응/한국장 번역까지 한 번에 실행합니다.", + tone: "bg-[#1ed760] text-[#121212] hover:bg-[#3be477]", + }, + ingest: { + path: "/jobs/ingest", + label: "수집만 실행", + description: "공식 소스와 피드를 다시 읽어 원문 문서를 적재합니다.", + tone: "bg-[color:var(--bg-deep)] text-[color:var(--text)] hover:bg-[#2a2a2a]", + }, + analyze: { + path: "/jobs/analyze", + label: "분석만 실행", + description: "이미 수집된 문서를 이벤트/시장 반응/한국 테마로 변환합니다.", + tone: "bg-[color:var(--surface-white)] text-[color:var(--text)] hover:bg-[#313131]", + }, +}; + +async function triggerJob(kind: JobKind) { + const response = await fetch(`${API_BASE_URL}${jobConfig[kind].path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error(`작업 실행 실패: ${response.status}`); + } + + return response.json() as Promise<{ jobId?: string; jobName: string; status: string }>; +} + +async function fetchRecentJobs() { + const response = await fetch(`${API_BASE_URL}/jobs/recent`, { + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`작업 상태 조회 실패: ${response.status}`); + } + + return response.json() as Promise<{ items: JobRunItem[] }>; +} + +function statusLabel(status: JobRunItem["status"]) { + switch (status) { + case "queued": + return "대기 중"; + case "running": + return "작업 중"; + case "completed": + return "완료"; + case "failed": + return "실패"; + default: + return status; + } +} + +function statusTone(status: JobRunItem["status"]) { + switch (status) { + case "running": + return "bg-[rgba(255,161,16,0.18)] text-[color:var(--warn)]"; + case "completed": + return "bg-[rgba(23,93,64,0.12)] text-[color:var(--ok)]"; + case "failed": + return "bg-[rgba(250,82,15,0.18)] text-[color:var(--danger)]"; + default: + return "bg-[rgba(31,31,31,0.08)] text-[color:var(--text-muted)]"; + } +} + +export function JobControls({ compact = false }: { compact?: boolean }) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [message, setMessage] = useState( + "워커가 실행 중이면 버튼으로 바로 수집/분석 파이프라인을 시작할 수 있습니다.", + ); + const [jobs, setJobs] = useState([]); + const [isPolling, setIsPolling] = useState(true); + + const loadJobs = async (refreshPage = false) => { + try { + const data = await fetchRecentJobs(); + setJobs(data.items); + const hasRunning = data.items.some((job) => job.status === "queued" || job.status === "running"); + setIsPolling(hasRunning); + if (refreshPage && !hasRunning) { + router.refresh(); + } + } catch (error) { + setMessage( + error instanceof Error ? error.message : "작업 상태를 불러오지 못했습니다.", + ); + } + }; + + useEffect(() => { + void loadJobs(); + }, []); + + useEffect(() => { + const timer = window.setInterval(() => { + void loadJobs(true); + }, isPolling ? 5000 : 15000); + + return () => { + window.clearInterval(timer); + }; + }, [isPolling]); + + const run = (kind: JobKind) => { + startTransition(async () => { + try { + const result = await triggerJob(kind); + setMessage( + `${jobConfig[kind].label} 요청을 큐에 넣었습니다. jobId: ${result.jobId ?? "-"} / 진행 상태는 아래 패널에서 자동 갱신됩니다.`, + ); + setIsPolling(true); + await loadJobs(); + window.setTimeout(() => { + void loadJobs(true); + }, 1000); + } catch (error) { + setMessage( + error instanceof Error + ? `${error.message}. API, Redis, 워커가 실행 중인지 확인해 주세요.` + : "작업 실행에 실패했습니다.", + ); + } + }); + }; + + return ( +
+
+ {(["refresh", "ingest", "analyze"] as JobKind[]).map((kind) => ( + + ))} +
+ +
+ {isPending ? "작업 요청을 보내는 중입니다..." : message} +
+ +
+
+
+

Recent Runs

+

+ 작업 중 표시와 최근 실행 결과 +

+
+
+ {isPolling ? "자동 새로고침 On" : "자동 새로고침 Slow"} +
+
+ +
+ {jobs.length ? ( + jobs.slice(0, 2).map((job) => ( +
+
+
+

+ {job.triggerKind === "refresh" + ? "전체 파이프라인" + : job.triggerKind === "ingest" + ? "수집 작업" + : "분석 작업"} +

+

+ 요청 {job.requestedAtLabel} + {job.startedAtLabel ? ` / 시작 ${job.startedAtLabel}` : ""} + {job.finishedAtLabel ? ` / 종료 ${job.finishedAtLabel}` : ""} +

+
+
+ {statusLabel(job.status)} +
+
+ +
+

jobId: {job.jobId}

+ {job.resultSummary ?

결과: {job.resultSummary}

: null} + {job.errorMessage ?

오류: {job.errorMessage}

: null} +
+
+ )) + ) : ( +
+ 아직 실행 기록이 없습니다. 위 버튼으로 첫 작업을 시작해 주세요. +
+ )} +
+
+
+ ); +} diff --git a/apps/web/components/shared/section-list.tsx b/apps/web/components/shared/section-list.tsx new file mode 100644 index 0000000..6d74fc5 --- /dev/null +++ b/apps/web/components/shared/section-list.tsx @@ -0,0 +1,46 @@ +import { ArrowRight } from "lucide-react"; +import { cn } from "./cn"; + +export function SectionList({ + items, + className +}: { + items: Array<{ + title: string; + subtitle?: string; + meta?: string; + }>; + className?: string; +}) { + return ( +
+ {items.length ? ( + items.map((item) => ( +
+
+

+ {item.title} +

+ {item.subtitle ? ( +

+ {item.subtitle} +

+ ) : null} +
+
+ {item.meta ? {item.meta} : null} + +
+
+ )) + ) : ( +
+ 아직 표시할 데이터가 없습니다. 수집 작업과 분석 작업을 먼저 실행해 주세요. +
+ )} +
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts new file mode 100644 index 0000000..755e9f9 --- /dev/null +++ b/apps/web/lib/api.ts @@ -0,0 +1,37 @@ +import { + AdminSettingsSchema, + DashboardPayloadSchema, + EvaluationSummarySchema, + EventExplorerPayloadSchema, + ReplayPayloadSchema, + ThemeBoardPayloadSchema, + WeeklyReplayPayloadSchema +} from "@finance-helper/contracts"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000/api/v1"; + +async function fetchJson(path: string, schema: { parse: (value: unknown) => T }): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + cache: "no-store", + headers: { + "Content-Type": "application/json" + } + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + return schema.parse(await response.json()); +} + +export const api = { + dashboard: () => fetchJson("/dashboard", DashboardPayloadSchema), + events: () => fetchJson("/events", EventExplorerPayloadSchema), + themes: () => fetchJson("/themes", ThemeBoardPayloadSchema), + replay: (date = "latest") => fetchJson(`/replay/${date}`, ReplayPayloadSchema), + weeklyReplay: () => fetchJson("/replay/weekly", WeeklyReplayPayloadSchema), + evaluation: () => fetchJson("/evaluations/summary", EvaluationSummarySchema), + admin: () => fetchJson("/admin/settings", AdminSettingsSchema) +}; diff --git a/apps/web/lib/format.ts b/apps/web/lib/format.ts new file mode 100644 index 0000000..b374558 --- /dev/null +++ b/apps/web/lib/format.ts @@ -0,0 +1,9 @@ +export function formatPct(value: number) { + const sign = value > 0 ? "+" : ""; + return `${sign}${value.toFixed(1)}%`; +} + +export function formatScore(value: number) { + return `${Math.round(value * 100)}점`; +} + diff --git a/apps/web/tests/format.test.ts b/apps/web/tests/format.test.ts new file mode 100644 index 0000000..2654a4c --- /dev/null +++ b/apps/web/tests/format.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { formatPct, formatScore } from "@/lib/format"; + +describe("format helpers", () => { + it("formats percentages with sign", () => { + expect(formatPct(1.23)).toBe("+1.2%"); + expect(formatPct(-0.4)).toBe("-0.4%"); + }); + + it("formats scores on a 100-point scale", () => { + expect(formatScore(0.76)).toBe("76점"); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..dbd80f9 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,281 @@ +import { z } from "zod"; + +export const EventSummarySchema = z.object({ + id: z.string(), + title: z.string(), + categoryLabel: z.string(), + sourceName: z.string(), + publishedAtLabel: z.string(), + directnessScore: z.number(), + marketConfirmationScore: z.number() +}); + +export const ReactionItemSchema = z.object({ + label: z.string(), + movePct: z.number(), + commentary: z.string() +}); + +export const ThemeSummarySchema = z.object({ + id: z.string(), + name: z.string(), + rationale: z.string(), + confidence: z.number(), + tradabilityScore: z.number(), + gapFadeRisk: z.number(), + marketView: z.string() +}); + +export const StockCandidateSchema = z.object({ + ticker: z.string(), + name: z.string(), + tierLabel: z.string(), + score: z.number(), + pricedInRisk: z.number(), + rationale: z.string() +}); + +export const DashboardPayloadSchema = z.object({ + metrics: z.object({ + documentsIngested: z.number(), + clusteredEvents: z.number(), + activeThemes: z.number(), + recentThemeHitRate: z.number() + }), + keyEvents: z.array(EventSummarySchema), + usReactionSummary: z.object({ + totalMovePct: z.number(), + items: z.array(ReactionItemSchema) + }), + themeBoard: z.array(ThemeSummarySchema), + leaderBoard: z.array(StockCandidateSchema), + riskFlags: z.array(z.string()), + topTheme: ThemeSummarySchema.nullable(), + topLeader: StockCandidateSchema.nullable() +}); + +export const EventExplorerPayloadSchema = z.object({ + clusters: z.array( + z.object({ + id: z.string(), + title: z.string(), + categoryLabel: z.string(), + summary: z.string(), + noveltyScore: z.number(), + directnessScore: z.number(), + surpriseScore: z.number(), + persistenceScore: z.number(), + marketConfirmed: z.boolean(), + sources: z.array( + z.object({ + name: z.string(), + url: z.string(), + fetchedAtLabel: z.string() + }) + ), + reactions: z.array( + z.object({ + label: z.string(), + movePct: z.number(), + window: z.string() + }) + ), + agentNotes: z.array( + z.object({ + role: z.string(), + note: z.string() + }) + ) + }) + ) +}); + +export const ThemeBoardPayloadSchema = z.object({ + themes: z.array( + z.object({ + id: z.string(), + name: z.string(), + rationale: z.string(), + confidence: z.number(), + themeFitScore: z.number(), + tradabilityScore: z.number(), + gapFadeRisk: z.number(), + pricedInRisk: z.number(), + invalidationCondition: z.string(), + openNote: z.string(), + fifteenMinuteNote: z.string(), + closeNote: z.string(), + leaders: z.array( + z.object({ + ticker: z.string(), + name: z.string(), + rationale: z.string() + }) + ), + secondTier: z.array( + z.object({ + ticker: z.string(), + name: z.string(), + rationale: z.string() + }) + ) + }) + ) +}); + +export const ReplayPayloadSchema = z.object({ + asOfLabel: z.string(), + promptVersion: z.string(), + evidencePackHash: z.string(), + predictionSummary: z.string(), + actualSummary: z.string(), + outcomeSummary: z.string(), + evidenceItems: z.array( + z.object({ + title: z.string(), + summary: z.string() + }) + ), + agentDebate: z.array( + z.object({ + role: z.string(), + thesis: z.string(), + confidence: z.number() + }) + ) +}); + +export const WeeklyReplayPayloadSchema = z.object({ + windowLabel: z.string(), + promptVersion: z.string(), + aggregate: z.object({ + daysAnalyzed: z.number(), + positiveHitDays: z.number(), + avgLeaderCloseReturnPct: z.number() + }), + days: z.array( + z.object({ + marketDateLabel: z.string(), + asOfLabel: z.string(), + promptVersion: z.string(), + evidencePackHash: z.string(), + marketContext: z.object({ + marketDateLabel: z.string(), + kospiCloseReturnPct: z.number(), + kosdaqCloseReturnPct: z.number(), + summary: z.string() + }), + predictedThemes: z.array( + z.object({ + name: z.string(), + confidence: z.number(), + rationale: z.string(), + invalidationCondition: z.string(), + leaders: z.array( + z.object({ + ticker: z.string(), + name: z.string() + }) + ), + secondTier: z.array( + z.object({ + ticker: z.string(), + name: z.string() + }) + ), + actualOutcome: z.object({ + avgLeaderCloseReturnPct: z.number(), + outcomeLabel: z.string(), + bestStockLabel: z.string().nullable(), + bestCloseReturnPct: z.number().nullable(), + stockResults: z.array( + z.object({ + ticker: z.string(), + name: z.string(), + tier: z.string(), + openGapPct: z.number(), + closeReturnPct: z.number(), + intradayMovePct: z.number() + }) + ) + }) + }) + ), + evidenceItems: z.array( + z.object({ + title: z.string(), + categoryLabel: z.string(), + summary: z.string(), + publishedAtLabel: z.string(), + marketConfirmationScore: z.number(), + sourceName: z.string(), + url: z.string() + }) + ), + summary: z.string() + }) + ) +}); + +export const EvaluationSummarySchema = z.object({ + rollingMetrics: z.object({ + themeHitRate: z.number(), + leaderHitRate: z.number(), + falsePositiveRate: z.number(), + gapFadeMissRate: z.number() + }), + promptLeaderboard: z.array( + z.object({ + version: z.string(), + description: z.string(), + score: z.number(), + themeHitRate: z.number(), + leaderHitRate: z.number(), + promotable: z.boolean() + }) + ), + modelRoleLeaderboard: z.array( + z.object({ + role: z.string(), + model: z.string(), + score: z.number(), + avgLatencyMs: z.number(), + avgCostUsd: z.number() + }) + ) +}); + +export const AdminSettingsSchema = z.object({ + sources: z.array( + z.object({ + name: z.string(), + kind: z.string(), + reliabilityScore: z.number() + }) + ), + modelRouting: z.array( + z.object({ + role: z.string(), + model: z.string() + }) + ), + costGuardrails: z.object({ + dailyBudgetUsd: z.number(), + perJobBudgetUsd: z.number() + }), + scheduler: z.object({ + premarketCron: z.string(), + postmarketCron: z.string() + }), + promotion: z.object({ + manualApprovalRequired: z.boolean() + }) +}); + +export type DashboardPayload = z.infer; +export type EventExplorerPayload = z.infer; +export type ThemeBoardPayload = z.infer; +export type ReplayPayload = z.infer; +export type WeeklyReplayPayload = z.infer; +export type EvaluationSummary = z.infer; +export type AdminSettings = z.infer;