From 62bdd79b3473262dde5fb0a90eab34fe7bf344fd Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sun, 17 May 2026 13:07:40 -0700 Subject: 'UI Shell and General Architecture' --- .gitignore | 3 + AGENTS.md | 39 + CLAUDE.md | 24 + frontend/app/design-tokens.css | 206 ++++ frontend/app/globals.css | 647 +------------ frontend/app/page.tsx | 581 +++++------ frontend/app/prism-shell.css | 1004 ++++++++++++++++++++ frontend/components/PriceChart.tsx | 31 +- frontend/components/prism/AppShell.tsx | 20 + frontend/components/prism/ChartCard.tsx | 55 ++ frontend/components/prism/KPIStrip.tsx | 16 + frontend/components/prism/Sidebar.tsx | 101 ++ frontend/components/prism/TickerHeader.tsx | 51 + frontend/components/prism/TopBar.tsx | 61 ++ frontend/lib/overview.ts | 134 +++ frontend/next-env.d.ts | 2 +- frontend/public/design-system/colors_and_type.css | 354 +++++++ .../fonts/EBGaramond-Italic-VariableFont_wght.ttf | Bin 0 -> 811012 bytes .../fonts/EBGaramond-VariableFont_wght.ttf | Bin 0 -> 934420 bytes .../fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf | Bin 0 -> 142108 bytes .../fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf | Bin 0 -> 133468 bytes .../IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf | Bin 0 -> 141596 bytes .../fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf | Bin 0 -> 134956 bytes .../IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf | Bin 0 -> 142204 bytes .../fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf | Bin 0 -> 133796 bytes .../fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf | Bin 0 -> 138448 bytes .../IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf | Bin 0 -> 145528 bytes .../IBMPlexSans-Italic-VariableFont_wdth,wght.ttf | Bin 0 -> 594116 bytes .../IBMPlexSans-VariableFont_wdth,wght.ttf | Bin 0 -> 532740 bytes frontend/public/design-system/grain.svg | 7 + frontend/public/design-system/icons/chart.svg | 6 + frontend/public/design-system/icons/clock.svg | 4 + frontend/public/design-system/icons/command.svg | 4 + frontend/public/design-system/icons/dollar.svg | 4 + frontend/public/design-system/icons/folder.svg | 3 + frontend/public/design-system/icons/ledger.svg | 5 + frontend/public/design-system/icons/pulse.svg | 3 + frontend/public/design-system/icons/search.svg | 4 + frontend/public/design-system/icons/terminal.svg | 6 + frontend/public/design-system/icons/user.svg | 4 + frontend/public/design-system/icons/window.svg | 5 + frontend/public/design-system/logo-monogram.svg | 23 + frontend/public/design-system/logo-wordmark.svg | 24 + frontend/public/design-system/prism.css | 365 +++++++ 44 files changed, 2856 insertions(+), 940 deletions(-) create mode 100644 frontend/app/design-tokens.css create mode 100644 frontend/app/prism-shell.css create mode 100644 frontend/components/prism/AppShell.tsx create mode 100644 frontend/components/prism/ChartCard.tsx create mode 100644 frontend/components/prism/KPIStrip.tsx create mode 100644 frontend/components/prism/Sidebar.tsx create mode 100644 frontend/components/prism/TickerHeader.tsx create mode 100644 frontend/components/prism/TopBar.tsx create mode 100644 frontend/lib/overview.ts create mode 100644 frontend/public/design-system/colors_and_type.css create mode 100644 frontend/public/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf create mode 100644 frontend/public/design-system/fonts/EBGaramond-VariableFont_wght.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf create mode 100644 frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf create mode 100644 frontend/public/design-system/grain.svg create mode 100644 frontend/public/design-system/icons/chart.svg create mode 100644 frontend/public/design-system/icons/clock.svg create mode 100644 frontend/public/design-system/icons/command.svg create mode 100644 frontend/public/design-system/icons/dollar.svg create mode 100644 frontend/public/design-system/icons/folder.svg create mode 100644 frontend/public/design-system/icons/ledger.svg create mode 100644 frontend/public/design-system/icons/pulse.svg create mode 100644 frontend/public/design-system/icons/search.svg create mode 100644 frontend/public/design-system/icons/terminal.svg create mode 100644 frontend/public/design-system/icons/user.svg create mode 100644 frontend/public/design-system/icons/window.svg create mode 100644 frontend/public/design-system/logo-monogram.svg create mode 100644 frontend/public/design-system/logo-wordmark.svg create mode 100644 frontend/public/design-system/prism.css diff --git a/.gitignore b/.gitignore index 2e5e10b..6e3fa68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .env .venv/ +/design-system/ +!frontend/public/design-system/ +!frontend/public/design-system/** __pycache__/ .pytest_cache/ .run/ diff --git a/AGENTS.md b/AGENTS.md index f3503e1..96532df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,45 @@ This checkout has no existing commit history. Use concise, imperative commit sub Pull requests should include a short summary, test results (`pytest`, `npm run lint`, `npm run build` when relevant), linked issues if applicable, and screenshots or screen recordings for visible UI changes. Note any required environment variables such as `FMP_API_KEY` or `FINNHUB_API_KEY`. +## Design System (`design-system/`) + +The repo includes a full personal-brand design system at `design-system/`. All frontend work must follow it — never invent colors, radii, or type choices. + +**Token entry point:** `design-system/colors_and_type.css` (also mirrored into `frontend/public/design-system/` for serving). Import it for all new components; it declares every CSS custom property: ink surfaces, fg tints, brass/champagne accent, oxford navy, burgundy, semantic gain/loss/caution/info colors, spacing scale, radii, shadows, and font stacks. + +**Typefaces:** +- `--font-serif` — EB Garamond (variable). Display + body serif; italic at large sizes is the signature move. +- `--font-sans` — IBM Plex Sans. UI labels, eyebrows, buttons, badges. +- `--font-mono` — IBM Plex Mono. All prices, percentages, and tabular numerics (`font-variant-numeric: tabular-nums`). + +**Visual rules that must not be broken:** +- Background is `#0B0E13` (ink-0), never pure black. Text is `#F2ECDC` (fg-0), never pure white. +- Primary accent is champagne `#C2AA7A` — for links, focus rings, eyebrows, button fills. Never as a large background fill. +- Semantic colors: gain `#4F8C5E`, loss `#B5494B`, caution `#C49545`, info `#4A78B5`. +- Card radius is 6px; buttons are 2px. No radius above 6px except capsule chips (999px). No bubbly rounding. +- Hairline `1px` borders only (`#232934`). No gradients except the chart area fill. No glass effects. +- Transitions are 150ms ease. No bounces, springs, or scroll-jacking. +- No emoji — ever. Use SVG icons from `design-system/assets/icons/` or unicode geometrics (`▲ ▼ ◈ ✦ ↗ ·`). + +**Prism UI kit:** `design-system/ui_kits/prism/` is a forward-looking redesign showing the target component shapes: ``, ``, ``, ``, ``, ``, ``, ``, ``. Read `parts{1,2,3}.jsx` and `prism.css` before building new dashboard surfaces. + +## Reference: Prism v1 (`../prism`) + +The old Streamlit-based app at `../prism` is the canonical reference for finance logic being ported into v2. Do not import from it at runtime, but read it when implementing new financial features. + +Key source files to consult: + +- **`services/data_service.py`** — yfinance wrappers for price history, financials (income/balance/cash flow), options chain, insider transactions, SEC filings, market indices, and analyst targets. Contains `compute_ttm_ratios()` which self-computes P/E, P/B, P/S, EV/EBITDA, margins, ROE/ROA/ROIC, leverage ratios, and dividend metrics from raw quarterly statements (avoiding FMP quota). Includes outlier safeguards (e.g. P/B and EV/Sales capped at 100). +- **`services/valuation_service.py`** — DCF engine (`run_dcf()`) using Gordon Growth Model: projects FCF with median historical growth (capped ±50%, skips sign flips), computes terminal value, bridges enterprise value to equity per share via net debt/preferred equity/minority interest. Also has `run_ev_ebitda()`, `run_ev_revenue()`, `run_price_to_book()`. +- **`services/fmp_service.py`** — FMP REST calls for peers, forward analyst estimates, historical ratios, and news. Falls back to yfinance when FMP is unavailable. +- **`services/news_service.py`** — Finnhub sentiment wrapper. +- **`components/overview.py`** — signal badge logic (6 signals), 52-week range bar, short interest rendering. +- **`utils/formatters.py`** — number formatting helpers (market cap abbreviations, percent display, etc.). + +**Caching strategy in v1:** `@st.cache_data` with staggered TTLs — financials at 1 h, indices at 5 min, search at 60 s. Mirror these TTLs when adding new cached endpoints in v2's `data_service.py`. + +**Data source hierarchy in v1:** yfinance primary → FMP fallback → Finnhub for news/sentiment. FMP free tier is 250 req/day; `FMP_API_KEY` and `FINNHUB_API_KEY` are set via `.env`. + ## Security & Configuration Tips Do not commit API keys, local virtual environments, `node_modules/`, or generated SQLite data files. Optional market-data keys should be provided through the environment or a local `.env` file. diff --git a/CLAUDE.md b/CLAUDE.md index 9e7c256..ea7a26f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,30 @@ Selected ticker lives in the URL query param `?ticker=SYMBOL`. Types are shared ### Testing approach Backend tests use `monkeypatch` to stub `data_service` functions — never make live yfinance calls in tests. Use a `tmp_path` fixture for any test that touches SQLite. There is no frontend test runner; use `npm run lint` and `npm run build` for frontend validation. +## Design System (`design-system/`) + +The repo includes a full personal-brand design system at `design-system/`. All frontend work must follow it — never invent colors, radii, or type choices. + +**Token entry point:** `design-system/colors_and_type.css` (also mirrored into `frontend/public/design-system/` for serving). Import it for all new components; it declares every CSS custom property: ink surfaces, fg tints, brass/champagne accent, oxford navy, burgundy, semantic gain/loss/caution/info colors, spacing scale, radii, shadows, and font stacks. + +**Typefaces:** +- `--font-serif` — EB Garamond (variable). Display + body serif; italic at large sizes is the signature move. +- `--font-sans` — IBM Plex Sans. UI labels, eyebrows, buttons, badges. +- `--font-mono` — IBM Plex Mono. All prices, percentages, and tabular numerics (`font-variant-numeric: tabular-nums`). + +**Visual rules that must not be broken:** +- Background is `#0B0E13` (ink-0), never pure black. Text is `#F2ECDC` (fg-0), never pure white. +- Primary accent is champagne `#C2AA7A` — for links, focus rings, eyebrows, button fills. Never as a large background fill. +- Semantic colors: gain `#4F8C5E`, loss `#B5494B`, caution `#C49545`, info `#4A78B5`. +- Card radius is 6px; buttons are 2px. No radius above 6px except capsule chips (999px). No bubbly rounding. +- Hairline `1px` borders only (`#232934`). No gradients except the chart area fill. No glass effects. +- Transitions are 150ms ease. No bounces, springs, or scroll-jacking. +- No emoji — ever. Use SVG icons from `design-system/assets/icons/` or unicode geometrics (`▲ ▼ ◈ ✦ ↗ ·`). + +**Prism UI kit:** `design-system/ui_kits/prism/` is a forward-looking redesign showing the target component shapes: ``, ``, ``, ``, ``, ``, ``, ``, ``. Read `parts{1,2,3}.jsx` and `prism.css` before building new dashboard surfaces. + +**Skill:** `design-system/SKILL.md` is a Claude Code skill (`tyler-hoang-design`) — invoke it when doing design-heavy frontend work. + ## Reference: Prism v1 (`../prism`) The old Streamlit-based app at `../prism` is the canonical reference for finance logic being ported into v2. Do not import from it at runtime, but read it when implementing new financial features. diff --git a/frontend/app/design-tokens.css b/frontend/app/design-tokens.css new file mode 100644 index 0000000..3a70aea --- /dev/null +++ b/frontend/app/design-tokens.css @@ -0,0 +1,206 @@ +@font-face { + font-family: "EB Garamond"; + src: + url("/design-system/fonts/EBGaramond-VariableFont_wght.ttf") format("truetype-variations"), + url("/design-system/fonts/EBGaramond-VariableFont_wght.ttf") format("truetype"); + font-weight: 400 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "EB Garamond"; + src: + url("/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf") format("truetype-variations"), + url("/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf") format("truetype"); + font-weight: 400 800; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Sans"; + src: + url("/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf") format("truetype-variations"), + url("/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf") format("truetype"); + font-weight: 100 700; + font-stretch: 85% 100%; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Sans"; + src: + url("/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf") format("truetype-variations"), + url("/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf") format("truetype"); + font-weight: 100 700; + font-stretch: 85% 100%; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf") format("truetype"); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf") format("truetype"); + font-weight: 300; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf") format("truetype"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf") format("truetype"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf") format("truetype"); + font-weight: 500; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf") format("truetype"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "IBM Plex Mono"; + src: url("/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf") format("truetype"); + font-weight: 600; + font-style: italic; + font-display: swap; +} + +:root { + --ink-0: #0b0e13; + --ink-1: #11151c; + --ink-2: #181d26; + --ink-3: #222934; + --ink-4: #2c3340; + --line-1: #232934; + --line-2: #2e3645; + --line-3: #3d4658; + --fg-1: #f2ecdc; + --fg-2: #c7c0ae; + --fg-3: #8e8676; + --fg-4: #5e5849; + --brass: #c2aa7a; + --brass-bright: #dcc79e; + --brass-deep: #8f7a50; + --brass-ink: #17120a; + --positive: #4f8c5e; + --positive-bg: #15241a; + --negative: #b5494b; + --negative-bg: #2a1517; + --warning: #c49545; + --warning-bg: #2a1f0f; + --info: #4a78b5; + --info-bg: #11202e; + --focus-ring: rgba(194, 170, 122, 0.55); + --selection-bg: rgba(194, 170, 122, 0.25); + --font-display: "EB Garamond", Georgia, serif; + --font-sans: "IBM Plex Sans", system-ui, sans-serif; + --font-mono: "IBM Plex Mono", monospace; + --fs-12: 0.75rem; + --fs-13: 0.8125rem; + --fs-14: 0.875rem; + --fs-16: 1rem; + --fs-18: 1.125rem; + --fs-20: 1.25rem; + --fs-24: 1.5rem; + --fs-30: 1.875rem; + --fs-38: 2.375rem; + --fs-48: 3rem; + --fs-64: 4rem; + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + --sp-9: 96px; + --r-1: 2px; + --r-2: 4px; + --r-3: 6px; + --r-4: 8px; + --r-full: 999px; + --tr-wide: 0.04em; + --tr-wider: 0.12em; + --shadow-1: 0 1px 0 rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-2: 0 1px 0 rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.45); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; + background: var(--ink-0); + color: var(--fg-2); + font-family: var(--font-sans); +} + +body { + background-image: + linear-gradient(to bottom, rgba(255, 255, 255, 0.01), rgba(255, 255, 255, 0)), + url("/design-system/grain.svg"); + background-size: auto, 240px 240px; +} + +::selection { + background: var(--selection-bg); +} + +a { + color: inherit; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2756364..7c4ad44 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,646 +1,3 @@ -@import url("https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;500;600&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap"); +@import "./design-tokens.css"; +@import "./prism-shell.css"; -:root { - --ink-0: #0b0e13; - --ink-1: #11151c; - --ink-2: #181d26; - --ink-3: #222934; - --line-1: #232934; - --line-2: #2e3645; - --fg-1: #f2ecdc; - --fg-2: #c7c0ae; - --fg-3: #8e8676; - --fg-4: #5e5849; - --brass: #c2aa7a; - --brass-bright: #dcc79e; - --brass-ink: #17120a; - --positive: #4f8c5e; - --negative: #b5494b; - --warning: #c49545; - --info: #4a78b5; - --font-display: "EB Garamond", Georgia, serif; - --font-sans: "IBM Plex Sans", system-ui, sans-serif; - --font-mono: "IBM Plex Mono", monospace; -} - -* { - box-sizing: border-box; -} - -html, -body { - min-height: 100%; - margin: 0; - background: var(--ink-0); - color: var(--fg-2); - font-family: var(--font-sans); -} - -button, -input { - font: inherit; -} - -button { - cursor: pointer; -} - -.shell { - display: grid; - grid-template-columns: 320px minmax(0, 1fr); - min-height: 100vh; -} - -.sidebar { - position: sticky; - top: 0; - height: 100vh; - overflow: auto; - border-right: 1px solid var(--line-1); - background: var(--ink-1); - padding: 22px 18px; -} - -.brand { - display: flex; - align-items: center; - gap: 12px; - padding-bottom: 22px; - border-bottom: 1px solid var(--line-1); -} - -.brand-mark { - display: grid; - width: 36px; - height: 36px; - place-items: center; - border: 1px solid var(--brass); - color: var(--brass-bright); - font-family: var(--font-display); - font-size: 24px; -} - -.brand-name { - color: var(--fg-1); - font-family: var(--font-display); - font-size: 24px; -} - -.brand-sub, -.section-label, -.search-form label { - color: var(--fg-4); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.search-form, -.selected-summary, -.watchlist { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 24px; -} - -.search-box { - display: flex; - align-items: center; - gap: 8px; - border: 1px solid var(--line-2); - background: var(--ink-2); - padding: 10px 12px; -} - -.search-box input { - min-width: 0; - width: 100%; - border: 0; - outline: 0; - background: transparent; - color: var(--fg-1); - font-family: var(--font-mono); -} - -.search-results { - border: 1px solid var(--line-1); - background: var(--ink-2); -} - -.search-results button, -.watch-row, -.watch-table button { - display: grid; - width: 100%; - grid-template-columns: 72px minmax(0, 1fr); - gap: 8px; - border: 0; - border-bottom: 1px solid var(--line-1); - background: transparent; - color: var(--fg-2); - padding: 9px 10px; - text-align: left; -} - -.search-results button:hover, -.watch-row:hover, -.watch-table button:hover { - background: var(--ink-3); -} - -.search-results span, -.watch-row span:first-child, -.watch-table span:first-child { - color: var(--fg-1); - font-family: var(--font-mono); -} - -.search-results small { - overflow: hidden; - color: var(--fg-3); - text-overflow: ellipsis; - white-space: nowrap; -} - -.muted-row, -.empty-copy { - color: var(--fg-4); - font-size: 13px; - padding: 8px 0; -} - -.summary-symbol { - color: var(--fg-1); - font-family: var(--font-display); - font-size: 32px; -} - -.summary-name { - color: var(--fg-3); -} - -.summary-price { - display: flex; - justify-content: space-between; - color: var(--fg-1); - font-family: var(--font-mono); -} - -.watch-action { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - border: 0; - background: var(--brass); - color: var(--brass-ink); - padding: 9px 12px; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.watch-row { - grid-template-columns: 1fr auto auto; - font-family: var(--font-mono); - padding-left: 0; - padding-right: 0; -} - -.watch-count { - color: var(--fg-4); - font-family: var(--font-mono); - font-size: 12px; - text-align: right; -} - -.content { - min-width: 0; - padding: 24px; -} - -.market-bar { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; - margin-bottom: 22px; -} - -.market-item, -.stat, -.signal, -.detail-panel, -.chart-shell, -.empty-state, -.empty-panel, -.error-panel { - border: 1px solid var(--line-1); - background: var(--ink-1); -} - -.market-item { - display: flex; - flex-direction: column; - gap: 4px; - padding: 14px 16px; -} - -.market-item span, -.stat span, -.signal span { - color: var(--fg-4); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.market-item strong, -.stat strong { - color: var(--fg-1); - font-family: var(--font-mono); - font-size: 20px; - font-weight: 500; -} - -.positive { - color: var(--positive) !important; -} - -.negative { - color: var(--negative) !important; -} - -.neutral { - color: var(--fg-4) !important; -} - -.overview { - display: flex; - flex-direction: column; - gap: 16px; -} - -.company-header { - display: flex; - justify-content: space-between; - gap: 24px; - padding: 22px 0 12px; - border-bottom: 1px solid var(--line-1); -} - -.header-title-row { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.eyebrow { - color: var(--brass); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -h1 { - margin: 4px 0; - color: var(--fg-1); - font-family: var(--font-display); - font-size: 42px; - font-weight: 500; - line-height: 1.05; -} - -.company-header p { - margin: 0; - color: var(--fg-3); -} - -.quote-block { - display: flex; - min-width: 180px; - flex-direction: column; - align-items: flex-end; - gap: 6px; - font-family: var(--font-mono); -} - -.quote-block span { - color: var(--fg-1); - font-size: 28px; -} - -.quote-block small { - color: var(--fg-3); -} - -.status-chip { - border: 1px solid var(--warning); - color: var(--warning); - padding: 5px 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.signal-grid, -.stat-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} - -.stat-grid { - grid-template-columns: repeat(6, minmax(0, 1fr)); -} - -.signal, -.stat { - display: flex; - min-height: 92px; - flex-direction: column; - gap: 6px; - justify-content: center; - padding: 14px; -} - -.signal strong { - color: var(--fg-1); - font-family: var(--font-mono); - font-size: 17px; -} - -.signal small { - color: var(--fg-3); -} - -.inline-note { - border: 1px solid var(--line-1); - background: var(--ink-2); - color: var(--fg-3); - padding: 12px 14px; -} - -.error-copy { - border-color: rgba(181, 73, 75, 0.35); - color: var(--negative); -} - -.signal.pos strong { - color: var(--positive); -} - -.signal.warn strong { - color: var(--warning); -} - -.signal.neg strong { - color: var(--negative); -} - -.split-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; -} - -.detail-panel { - padding: 16px; -} - -.range-values { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 12px; - margin-top: 18px; - color: var(--fg-3); - font-family: var(--font-mono); -} - -.range-values strong { - color: var(--brass-bright); -} - -.range-values span:last-child { - text-align: right; -} - -.range-rail { - position: relative; - height: 4px; - margin-top: 16px; - background: var(--line-2); -} - -.range-rail span { - position: absolute; - inset: 0 auto 0 0; - background: var(--brass); -} - -.range-rail i { - position: absolute; - top: -4px; - width: 2px; - height: 12px; - background: var(--brass-bright); -} - -.mini-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - margin-top: 12px; -} - -.mini-grid .stat { - min-height: 72px; - background: var(--ink-2); -} - -.chart-shell { - padding: 16px; -} - -.chart-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - margin-bottom: 10px; -} - -.chart-head strong { - color: var(--fg-1); - font-family: var(--font-display); - font-size: 24px; - font-weight: 500; -} - -.segmented { - display: flex; - gap: 6px; - flex-wrap: wrap; -} - -.segmented button { - min-width: 42px; - border: 1px solid var(--line-2); - background: var(--ink-2); - color: var(--fg-3); - padding: 7px 10px; - font-size: 12px; -} - -.segmented button.active { - border-color: var(--brass); - color: var(--brass-bright); -} - -.chart { - width: 100%; -} - -.compact-panel { - padding: 18px; -} - -.empty-state, -.empty-panel, -.error-panel { - padding: 32px; -} - -.empty-state p, -.empty-panel, -.error-panel { - color: var(--fg-3); -} - -.error-panel { - border-color: var(--negative); - color: var(--negative); -} - -.invalid-state p { - margin: 12px 0 20px; -} - -.ghost-action { - border: 1px solid var(--line-2); - background: transparent; - color: var(--fg-1); - padding: 10px 14px; - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 12px; - font-weight: 700; -} - -.missing-value { - color: var(--fg-4) !important; -} - -.profile-list { - display: grid; - gap: 12px; - margin-top: 12px; -} - -.profile-list div { - display: grid; - gap: 4px; -} - -.profile-list span { - color: var(--fg-4); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.profile-list strong, -.profile-list a, -.quality-copy strong { - color: var(--fg-1); - font-family: var(--font-mono); - font-weight: 500; - word-break: break-word; -} - -.profile-list a { - text-decoration: none; -} - -.profile-summary { - margin: 18px 0 0; - color: var(--fg-3); - line-height: 1.6; -} - -.quality-copy { - margin-top: 12px; - color: var(--fg-3); -} - -.compact-list { - margin-top: 16px; -} - -.watch-table { - max-width: 520px; - margin-top: 18px; - border: 1px solid var(--line-1); -} - -.watch-table button { - grid-template-columns: 1fr auto auto; -} - -@media (max-width: 1100px) { - .shell { - grid-template-columns: 1fr; - } - - .sidebar { - position: relative; - height: auto; - } - - .market-bar, - .signal-grid, - .stat-grid, - .split-row { - grid-template-columns: 1fr 1fr; - } -} - -@media (max-width: 680px) { - .content { - padding: 16px; - } - - .market-bar, - .signal-grid, - .stat-grid, - .split-row { - grid-template-columns: 1fr; - } - - .company-header, - .chart-head { - align-items: flex-start; - flex-direction: column; - } - - .quote-block { - align-items: flex-start; - } -} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0658929..f47d49a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,27 +1,25 @@ "use client"; -import { FormEvent, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { BookmarkMinus, BookmarkPlus, Search } from "lucide-react"; +import { FormEvent, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { PriceChart } from "@/components/PriceChart"; +import { AppShell } from "@/components/prism/AppShell"; +import { ChartCard } from "@/components/prism/ChartCard"; +import { KPIStrip } from "@/components/prism/KPIStrip"; +import { Sidebar } from "@/components/prism/Sidebar"; +import { TickerHeader } from "@/components/prism/TickerHeader"; +import { TopBar } from "@/components/prism/TopBar"; import { ApiError, api } from "@/lib/api"; import { deltaClass, fmtCurrency, fmtLarge, fmtNumber, fmtPct } from "@/lib/format"; +import { availableFieldSummary, buildKpis, marketClock, OVERVIEW_NAV_ITEMS, signalTone, sortIndices, unavailableFields } from "@/lib/overview"; import type { HistoryPoint, MarketIndex, SearchResult, TickerOverview, WatchlistResponse } from "@/types/api"; -const PERIODS = [ - { key: "1m", label: "1M" }, - { key: "3m", label: "3M" }, - { key: "6m", label: "6M" }, - { key: "1y", label: "1Y" }, - { key: "5y", label: "5Y" } -]; - type LoadState = "idle" | "loading" | "ready" | "invalid" | "error"; type ChartState = "idle" | "loading" | "ready" | "error"; export default function OverviewPage() { return ( -
Loading Prism...
}> + }> ); @@ -46,24 +44,37 @@ function OverviewClient() { const [chartState, setChartState] = useState("idle"); const [chartError, setChartError] = useState(null); const [watchlistError, setWatchlistError] = useState(null); + const [clockSnapshot, setClockSnapshot] = useState(() => marketClock()); const watchlistSymbols = useMemo(() => new Set(watchlist.items.map((item) => item.symbol)), [watchlist]); const isSaved = selectedTicker ? watchlistSymbols.has(selectedTicker) : false; + const marketCards = useMemo(() => sortIndices(market), [market]); + const kpis = useMemo(() => (overview ? buildKpis(overview) : []), [overview]); + const missingFields = useMemo(() => (overview ? unavailableFields(overview) : []), [overview]); + + useEffect(() => { + const timer = window.setInterval(() => setClockSnapshot(marketClock()), 60_000); + return () => window.clearInterval(timer); + }, []); - const selectTicker = useCallback( + const navigateToTicker = useCallback( (symbol: string) => { const normalized = symbol.trim().toUpperCase(); if (!normalized) return; + setResults([]); setQuery(""); + setOverview(null); + setHistory([]); setOverviewError(null); setChartError(null); setWatchlistError(null); setOverviewState("loading"); setChartState("loading"); - setOverview(null); - setHistory([]); - router.push(`/?ticker=${encodeURIComponent(normalized)}`); + + startTransition(() => { + router.push(`/?ticker=${encodeURIComponent(normalized)}`); + }); }, [router] ); @@ -76,7 +87,9 @@ function OverviewClient() { setWatchlistError(null); setOverviewState("idle"); setChartState("idle"); - router.push("/"); + startTransition(() => { + router.push("/"); + }); }, [router]); const refreshWatchlist = useCallback(async () => { @@ -95,10 +108,13 @@ function OverviewClient() { useEffect(() => { if (query.trim().length < 2) { setResults([]); + setSearching(false); return; } + let cancelled = false; setSearching(true); + const timer = window.setTimeout(() => { api .search(query) @@ -112,6 +128,7 @@ function OverviewClient() { if (!cancelled) setSearching(false); }); }, 250); + return () => { cancelled = true; window.clearTimeout(timer); @@ -125,18 +142,17 @@ function OverviewClient() { setHistory([]); setOverviewError(null); setChartError(null); - setWatchlistError(null); setOverviewState("idle"); setChartState("idle"); return; } + if (lastTickerRef.current === selectedTicker) return; let cancelled = false; lastTickerRef.current = selectedTicker; setOverviewState("loading"); setOverviewError(null); - setWatchlistError(null); setOverview(null); api @@ -149,13 +165,15 @@ function OverviewClient() { .catch((exc: Error) => { if (cancelled) return; setOverview(null); + setChartState("idle"); + setHistory([]); + if (exc instanceof ApiError && exc.status === 404) { setOverviewState("invalid"); setOverviewError("Ticker not found"); - setChartState("idle"); - setHistory([]); return; } + setOverviewState("error"); setOverviewError(exc.message || "Ticker data unavailable"); }); @@ -167,6 +185,7 @@ function OverviewClient() { useEffect(() => { if (!selectedTicker) return; + let cancelled = false; setChartState("loading"); setChartError(null); @@ -193,11 +212,14 @@ function OverviewClient() { async function onSearchSubmit(event: FormEvent) { event.preventDefault(); - if (results[0]) selectTicker(results[0].symbol); - else if (query.trim()) selectTicker(query); + if (results[0]) { + navigateToTicker(results[0].symbol); + return; + } + if (query.trim()) navigateToTicker(query); } - async function toggleWatchlist() { + async function addOrRemoveCurrentTicker() { if (!selectedTicker) return; try { const next = isSaved ? await api.removeWatchlist(selectedTicker) : await api.addWatchlist(selectedTicker); @@ -208,323 +230,312 @@ function OverviewClient() { } } - const summaryTicker = overview?.profile.symbol || selectedTicker; - const summaryName = - overviewState === "invalid" - ? "Ticker not found" - : overview?.profile.name || (selectedTicker ? "Loading ticker..." : "No ticker selected"); - - return ( -
- - -
- - {!selectedTicker && } - {selectedTicker && overviewState === "loading" &&
Loading {selectedTicker}...
} - {selectedTicker && overviewState === "invalid" && } - {selectedTicker && overviewState === "error" &&
{overviewError || "Ticker data unavailable"}
} - {overview && overviewState === "ready" && ( -
-
-
-
{overview.profile.exchange || "Ticker"}
-
-

{overview.profile.name}

- {overview.meta.is_partial && Partial Data} -
-

{identityLine(overview)}

-
-
- {fmtCurrency(overview.quote.price)} - {fmtPct(overview.quote.change_pct, 2, true)} - Prev close {fmtCurrency(overview.quote.prev_close)} -
-
- - {overview.meta.is_partial &&
Some fields are unavailable for this ticker. Available data is still shown below.
} - -
- {overview.signals.map((signal) => ( -
- {signal.key} - {signal.value} - {signal.description} -
- ))} -
- -
- - - - - - -
- -
- - -
- -
+
+ - -
- -
-
-
-
Price History
- {overview.profile.symbol} -
-
- {PERIODS.map((option) => ( - - ))} -
-
- {chartState === "loading" &&
Loading {period.toUpperCase()} history...
} - {chartState === "error" &&
{chartError || "Could not load chart history"}
} - {chartState === "ready" && } -
-
- )} -
-
+ + + + + + ) : null} + ); + + return shell; } -function MarketBar({ indices }: { indices: MarketIndex[] }) { - if (!indices.length) return
Market data unavailable
; +function MarketStrip({ indices }: { indices: MarketIndex[] }) { + if (!indices.length) { + return
Market data is temporarily unavailable.
; + } + return ( -
+
{indices.map((index) => ( -
- {index.name} - {fmtNumber(index.price)} - {fmtPct(index.change_pct, 2, true)} -
+
+ {index.name} + {fmtNumber(index.price)} + {fmtPct(index.change_pct, 2, true)} +
))}
); } -function EmptyState({ watchlist, onSelect }: { watchlist: WatchlistResponse; onSelect: (symbol: string) => void }) { +function EmptyOverviewState({ watchlist, onSelectTicker }: { watchlist: WatchlistResponse; onSelectTicker: (symbol: string) => void }) { return ( -
-

Overview

+
+
Overview
+

Choose a ticker to enter the workbench.

+

Search from the top bar or jump into one of your saved symbols from the sidebar watchlist.

{watchlist.items.length ? ( - <> -

Select a saved ticker to load its company profile, quote, stats, and chart.

-
- {watchlist.items.map((item) => ( - - ))} -
- - ) : ( -

Search for a ticker to begin.

- )} +
+ {watchlist.items.map((item) => ( + + ))} +
+ ) : null}
); } +function LoadingOverviewState({ symbol }: { symbol: string }) { + return ( +
+
+
Loading
+

{symbol}

+

Fetching quote, profile, signals, and supporting metrics.

+
+
+
+
+ ); +} + function InvalidTickerState({ symbol, onClear }: { symbol: string; onClear: () => void }) { return ( -
+
+ Invalid Ticker

{symbol}

-

This ticker could not be resolved to usable market data.

-
); } -function Stat({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { +function ErrorOverviewState({ message }: { message: string }) { return ( -
- {label} - {missing ? "Unavailable" : value} -
+
+ Data Error +

Overview unavailable

+

{message}

+
); } -function RangeCard({ overview }: { overview: TickerOverview }) { - const low = overview.range_52w.low; - const high = overview.range_52w.high; - const price = overview.range_52w.price; - const pct = low != null && high != null && price != null && high > low ? Math.max(0, Math.min(100, ((price - low) / (high - low)) * 100)) : null; +function SignalCard({ overview }: { overview: TickerOverview }) { return ( -
-
52 Week Range
- {pct === null ? ( -
Range unavailable
- ) : ( - <> -
- {fmtCurrency(low)} - {fmtCurrency(price)} - {fmtCurrency(high)} -
-
- - -
- - )} -
+
+
+
+
Signals
+

Readthrough

+
+
+
+ {overview.signals.map((signal) => ( +
+ {signal.key} + {signal.value} + {signal.description} +
+ ))} +
+
); } -function ShortInterest({ overview }: { overview: TickerOverview }) { - const short = overview.short_interest; +function DataStatusCard({ overview, missingFields }: { overview: TickerOverview; missingFields: string[] }) { + const entries = Object.entries(overview.meta.sources).slice(0, 6); + return ( -
-
Short Interest
-
- - - - +
+
+
+
Data Quality
+

Coverage

+
+ {overview.meta.status}
-
+

{availableFieldSummary(overview)}

+ {overview.meta.is_partial ? ( +
+ {missingFields.length ? missingFields.slice(0, 8).map((field) => {field}) : null} +
+ ) : null} +
+ {entries.length ? ( + entries.map(([field, source]) => ( +
+ {field} + {source} +
+ )) + ) : ( +
+ Sources + Unavailable +
+ )} +
+
); } function ProfileCard({ overview }: { overview: TickerOverview }) { return ( -
-
Company Profile
-
+
+
- Sector - {overview.profile.sector || "Unavailable"} +
Company Profile
+

Context

-
- Industry - {overview.profile.industry || "Unavailable"} +
+
+
+ Sector + {overview.profile.sector || "Unavailable"} +
+
+ Industry + {overview.profile.industry || "Unavailable"} +
+
+ Exchange + {overview.profile.exchange || "Unavailable"}
+
+ Website + + {overview.profile.website ? ( + + {overview.profile.website} + + ) : ( + "Unavailable" + )} + +
+
+

{overview.profile.summary || "Business summary unavailable."}

+
+ ); +} + +function ShortInterestCard({ overview }: { overview: TickerOverview }) { + const short = overview.short_interest; + return ( +
+
- Website - {overview.profile.website ? ( - - {overview.profile.website} - - ) : ( - Unavailable - )} +
Short Interest
+

Pressure

-

- {overview.profile.summary || "Business summary unavailable."} -

-
+
+ + + + +
+
); } -function SourceCard({ overview }: { overview: TickerOverview }) { - const fields = Object.entries(overview.meta.sources).slice(0, 6); +function StatsCard({ overview }: { overview: TickerOverview }) { return ( -
-
Data Quality
-
- Status {overview.meta.status} +
+
+
+
Overview Stats
+

Reference

+
-
- {fields.length ? ( - fields.map(([field, source]) => ( -
- {field} - {source} -
- )) - ) : ( -
- Sources - Unavailable -
- )} +
+ + + + + +
+
+ ); +} + +function DetailItem({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { + return ( +
+ {label} + {missing ? "Unavailable" : value} +
+ ); +} + +function StatRow({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) { + return ( +
+ {label} + {missing ? "Unavailable" : value}
); } -function identityLine(overview: TickerOverview) { - const parts = [overview.profile.symbol, overview.profile.sector, overview.profile.industry].filter(Boolean); - return parts.length ? parts.join(" · ") : overview.profile.symbol; +function LoadingShell() { + return ( +
Prism
Loading
} + topbar={
Loading Prism…
} + > +
+
+
+
+
+ ); } diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css new file mode 100644 index 0000000..c198479 --- /dev/null +++ b/frontend/app/prism-shell.css @@ -0,0 +1,1004 @@ +.prism-app { + display: grid; + grid-template-columns: 256px minmax(0, 1fr); + min-height: 100vh; +} + +.psm-side { + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + border-right: 1px solid var(--line-1); + background: var(--ink-1); +} + +.psm-brand { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-5); + border-bottom: 1px solid var(--line-1); +} + +.psm-brand-mark { + width: 34px; + height: 34px; +} + +.psm-brand-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.psm-brand-name { + color: var(--fg-1); + font-family: var(--font-display); + font-size: 1.4rem; + line-height: 1; +} + +.psm-brand-sub, +.psm-side-label, +.psm-eyebrow, +.psm-state-title, +.psm-stat-label, +.psm-note-label, +.psm-kpi-key { + color: var(--fg-3); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-side-section { + padding: var(--sp-5) var(--sp-4) var(--sp-2); +} + +.psm-nav { + display: flex; + flex-direction: column; + padding: 0 var(--sp-2); +} + +.psm-nav-item { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: var(--fg-2); + padding: 10px var(--sp-3); + text-align: left; +} + +.psm-nav-item:hover { + background: var(--ink-2); + color: var(--fg-1); +} + +.psm-nav-item.active { + border-left-color: var(--brass); + background: var(--ink-2); + color: var(--fg-1); +} + +.psm-nav-item.disabled { + opacity: 0.7; +} + +.psm-nav-copy { + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); +} + +.psm-nav-coming { + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + background: currentColor; + opacity: 0.8; + -webkit-mask-image: var(--icon-url); + mask-image: var(--icon-url); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-size: contain; +} + +.icon-chart { + --icon-url: url("/design-system/icons/chart.svg"); +} + +.icon-folder { + --icon-url: url("/design-system/icons/folder.svg"); +} + +.icon-ledger { + --icon-url: url("/design-system/icons/ledger.svg"); +} + +.icon-pulse { + --icon-url: url("/design-system/icons/pulse.svg"); +} + +.icon-window { + --icon-url: url("/design-system/icons/window.svg"); +} + +.icon-dollar { + --icon-url: url("/design-system/icons/dollar.svg"); +} + +.icon-terminal { + --icon-url: url("/design-system/icons/terminal.svg"); +} + +.icon-search { + --icon-url: url("/design-system/icons/search.svg"); +} + +.icon-command { + --icon-url: url("/design-system/icons/command.svg"); +} + +.icon-user { + --icon-url: url("/design-system/icons/user.svg"); +} + +.icon-clock { + --icon-url: url("/design-system/icons/clock.svg"); +} + +.psm-watch { + margin: 0 var(--sp-4); + border-top: 1px solid var(--line-1); +} + +.psm-watch-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) 0; +} + +.psm-watch-limit { + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-watch-empty { + color: var(--fg-4); + font-size: var(--fs-14); + padding: 0 0 var(--sp-4); +} + +.psm-watch-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--sp-2); + align-items: center; + border-bottom: 1px solid var(--line-1); +} + +.psm-watch-select { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: var(--sp-2); + align-items: center; + width: 100%; + border: 0; + background: transparent; + color: var(--fg-2); + padding: 10px 0; +} + +.psm-watch-select:hover, +.psm-search-result:hover { + background: rgba(194, 170, 122, 0.04); +} + +.psm-watch-row.active .psm-watch-select { + color: var(--fg-1); +} + +.psm-watch-main { + min-width: 0; + text-align: left; +} + +.psm-watch-symbol { + color: var(--fg-1); + font-size: var(--fs-14); + font-weight: 500; +} + +.psm-watch-date, +.psm-search-result-copy, +.psm-muted-copy, +.psm-profile-copy, +.psm-quality-copy, +.psm-placeholder { + color: var(--fg-3); + font-size: var(--fs-13); +} + +.psm-watch-price, +.psm-watch-change, +.psm-quote-line, +.psm-price, +.psm-change, +.psm-kpi-value, +.psm-detail-value, +.psm-stat-value, +.psm-market-price, +.psm-market-change { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.psm-watch-price, +.psm-watch-change { + text-align: right; + white-space: nowrap; +} + +.psm-watch-remove { + width: 26px; + height: 26px; + border: 1px solid var(--line-2); + border-radius: var(--r-full); + background: transparent; + color: var(--fg-4); + margin-right: 2px; +} + +.psm-watch-remove:hover { + color: var(--negative); + border-color: rgba(181, 73, 75, 0.45); + background: var(--negative-bg); +} + +.psm-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.psm-top { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: var(--sp-4); + padding: var(--sp-3) var(--sp-6); + border-bottom: 1px solid var(--line-1); + background: rgba(11, 14, 19, 0.94); + backdrop-filter: blur(16px); +} + +.psm-search-shell { + position: relative; + flex: 1; + max-width: 520px; +} + +.psm-search-form { + display: flex; + align-items: center; + gap: var(--sp-2); + border: 1px solid var(--line-2); + border-radius: var(--r-2); + background: var(--ink-2); + padding: 9px var(--sp-3); +} + +.psm-search-form:focus-within { + border-color: var(--brass); + box-shadow: 0 0 0 1px var(--focus-ring); +} + +.psm-search-form input { + width: 100%; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--fg-1); + font-family: var(--font-mono); + font-size: var(--fs-13); +} + +.psm-search-form input::placeholder { + color: var(--fg-3); +} + +.psm-kbd { + border: 1px solid var(--line-2); + border-radius: var(--r-1); + background: var(--ink-1); + color: var(--fg-4); + padding: 2px 6px; + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-search-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + overflow: hidden; + border: 1px solid var(--line-1); + border-radius: var(--r-2); + background: var(--ink-1); + box-shadow: var(--shadow-2); +} + +.psm-search-result, +.psm-search-status { + width: 100%; + border: 0; + border-bottom: 1px solid var(--line-1); + background: transparent; + color: var(--fg-2); + padding: 10px var(--sp-3); + text-align: left; +} + +.psm-search-status:last-child, +.psm-search-result:last-child { + border-bottom: 0; +} + +.psm-search-result { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: var(--sp-2); +} + +.psm-search-result-symbol { + color: var(--fg-1); + font-family: var(--font-mono); +} + +.psm-clock-group { + display: flex; + align-items: center; + gap: var(--sp-5); + margin-left: auto; +} + +.psm-market-status { + display: flex; + align-items: center; + gap: var(--sp-3); + color: var(--fg-2); + font-family: var(--font-mono); + font-size: var(--fs-13); +} + +.psm-market-dot { + width: 8px; + height: 8px; + border-radius: var(--r-full); + background: var(--warning); + box-shadow: 0 0 10px rgba(196, 149, 69, 0.45); +} + +.psm-market-dot.open { + background: var(--positive); + box-shadow: 0 0 10px rgba(79, 140, 94, 0.45); +} + +.psm-account { + display: flex; + align-items: center; + gap: var(--sp-2); + border: 1px solid var(--line-2); + border-radius: var(--r-full); + padding: 6px 12px; + color: var(--fg-1); +} + +.psm-account-avatar { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border-radius: var(--r-full); + background: var(--brass); + color: var(--brass-ink); + font-family: var(--font-display); + font-style: italic; +} + +.psm-content { + display: flex; + flex-direction: column; + gap: var(--sp-5); + padding: var(--sp-5) var(--sp-6) var(--sp-8); +} + +.psm-market-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.psm-market-card, +.psm-card, +.psm-state-panel { + border: 1px solid var(--line-1); + border-radius: var(--r-3); + background: var(--ink-1); + box-shadow: var(--shadow-1); +} + +.psm-market-card { + padding: var(--sp-4); +} + +.psm-market-name { + color: var(--fg-4); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-market-price { + display: block; + margin-top: 6px; + color: var(--fg-1); + font-size: var(--fs-18); +} + +.positive { + color: var(--positive); +} + +.negative { + color: var(--negative); +} + +.neutral { + color: var(--fg-3); +} + +.psm-ticker-head { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.75fr) auto; + gap: var(--sp-5); + align-items: end; + padding-bottom: var(--sp-4); + border-bottom: 1px solid var(--line-1); +} + +.psm-header-left { + min-width: 0; +} + +.psm-identity-line { + color: var(--brass); + display: block; + margin-bottom: var(--sp-2); +} + +.psm-heading-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--sp-4); +} + +.psm-symbol { + color: var(--fg-1); + font-family: var(--font-display); + font-size: clamp(3rem, 6vw, var(--fs-64)); + line-height: 0.95; + letter-spacing: -0.03em; +} + +.psm-company-name { + color: var(--fg-2); + font-family: var(--font-display); + font-size: var(--fs-24); + font-style: italic; +} + +.psm-partial-chip, +.psm-status-chip, +.psm-tag { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: var(--r-full); + padding: 5px 10px; + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-partial-chip { + border: 1px solid rgba(196, 149, 69, 0.4); + background: var(--warning-bg); + color: var(--warning); +} + +.psm-status-chip { + border: 1px solid rgba(79, 140, 94, 0.35); + background: var(--positive-bg); + color: var(--positive); +} + +.psm-status-chip.partial { + border-color: rgba(196, 149, 69, 0.4); + background: var(--warning-bg); + color: var(--warning); +} + +.psm-status-chip.invalid { + border-color: rgba(181, 73, 75, 0.4); + background: var(--negative-bg); + color: var(--negative); +} + +.psm-subline { + margin-top: var(--sp-2); + color: var(--fg-3); + font-size: var(--fs-14); +} + +.psm-price-stack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.psm-price { + color: var(--fg-1); + font-size: clamp(2.4rem, 4vw, var(--fs-48)); + line-height: 1; +} + +.psm-change { + font-size: var(--fs-16); +} + +.psm-quote-line { + color: var(--fg-3); + font-size: var(--fs-12); +} + +.psm-primary-action, +.psm-ghost-action { + border-radius: var(--r-2); + padding: 10px 14px; + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-primary-action { + border: 1px solid var(--brass); + background: var(--brass); + color: var(--brass-ink); +} + +.psm-primary-action.subtle { + background: transparent; + color: var(--brass); +} + +.psm-ghost-action { + border: 1px solid var(--line-2); + background: transparent; + color: var(--fg-2); +} + +.psm-range { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.psm-range-values { + display: flex; + justify-content: space-between; + gap: var(--sp-2); + color: var(--fg-3); + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-range-rail { + position: relative; + height: 4px; + border-radius: var(--r-full); + background: var(--ink-3); +} + +.psm-range-indicator { + position: absolute; + top: -4px; + width: 2px; + height: 12px; + background: var(--brass); +} + +.psm-kpis { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + overflow: hidden; +} + +.psm-kpi { + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--sp-4) var(--sp-5); + border-right: 1px solid var(--line-1); +} + +.psm-kpi:last-child { + border-right: 0; +} + +.psm-kpi-value { + color: var(--fg-1); + font-size: var(--fs-24); +} + +.psm-kpi-sub { + color: var(--fg-3); + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-kpi-value.missing, +.psm-detail-value.missing, +.psm-stat-value.missing { + color: var(--fg-4); +} + +.psm-main-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); + gap: var(--sp-5); +} + +.psm-column { + display: flex; + flex-direction: column; + gap: var(--sp-5); + min-width: 0; +} + +.psm-card { + padding: var(--sp-5); +} + +.psm-card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--sp-3); + margin-bottom: var(--sp-3); +} + +.psm-card-title { + margin: 0; + color: var(--fg-1); + font-family: var(--font-display); + font-size: var(--fs-24); +} + +.psm-tabs { + display: flex; + gap: 4px; + border: 1px solid var(--line-2); + border-radius: var(--r-1); + background: var(--ink-2); + padding: 2px; +} + +.psm-tab { + border: 0; + border-radius: var(--r-1); + background: transparent; + color: var(--fg-3); + padding: 5px 10px; + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-tab.active { + background: var(--ink-3); + color: var(--fg-1); +} + +.psm-chart-frame { + overflow: hidden; + border: 1px solid var(--line-1); + border-radius: var(--r-2); + background: linear-gradient(180deg, rgba(194, 170, 122, 0.03), rgba(194, 170, 122, 0)); +} + +.psm-chart-meta { + color: var(--fg-3); + font-size: var(--fs-13); + margin-bottom: var(--sp-3); +} + +.psm-state-panel, +.psm-card-empty { + padding: var(--sp-5); +} + +.psm-state-panel h1, +.psm-state-panel h2 { + margin: 0 0 var(--sp-2); + color: var(--fg-1); + font-family: var(--font-display); + font-size: var(--fs-38); + font-weight: 500; +} + +.psm-state-panel p { + margin: 0 0 var(--sp-4); + color: var(--fg-3); + font-size: var(--fs-14); + line-height: 1.5; +} + +.psm-signal-grid, +.psm-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.psm-signal, +.psm-detail-item { + border: 1px solid var(--line-1); + border-radius: var(--r-2); + background: var(--ink-2); + padding: var(--sp-3); +} + +.psm-signal-key { + color: var(--fg-4); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-signal-value, +.psm-detail-value, +.psm-stat-value { + display: block; + margin-top: 8px; + color: var(--fg-1); + font-size: var(--fs-18); +} + +.psm-signal-copy, +.psm-detail-copy { + display: block; + margin-top: 6px; + color: var(--fg-3); + font-size: var(--fs-13); + line-height: 1.45; +} + +.psm-signal.pos { + border-color: rgba(79, 140, 94, 0.35); +} + +.psm-signal.warn { + border-color: rgba(196, 149, 69, 0.35); +} + +.psm-signal.neg { + border-color: rgba(181, 73, 75, 0.35); +} + +.psm-profile-list, +.psm-stat-list, +.psm-source-list { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.psm-stat-row, +.psm-profile-row, +.psm-source-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--sp-3); + align-items: start; + padding-bottom: var(--sp-3); + border-bottom: 1px solid var(--line-1); +} + +.psm-stat-row:last-child, +.psm-profile-row:last-child, +.psm-source-row:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.psm-profile-key, +.psm-source-key { + color: var(--fg-4); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-profile-value, +.psm-source-value { + color: var(--fg-1); + text-align: right; + word-break: break-word; +} + +.psm-source-value { + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-profile-summary { + margin: var(--sp-4) 0 0; + color: var(--fg-3); + font-size: var(--fs-14); + line-height: 1.55; +} + +.psm-stack { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} + +.psm-field-tag { + border: 1px solid var(--line-2); + border-radius: var(--r-full); + padding: 5px 10px; + color: var(--fg-3); + font-family: var(--font-mono); + font-size: var(--fs-12); +} + +.psm-field-tag.missing { + color: var(--warning); + border-color: rgba(196, 149, 69, 0.35); + background: var(--warning-bg); +} + +.psm-loading-shell { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.psm-skeleton { + position: relative; + overflow: hidden; + min-height: 160px; +} + +.psm-skeleton::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent); + transform: translateX(-100%); + animation: prism-sweep 1.4s infinite; +} + +.psm-card-empty { + color: var(--fg-3); + font-size: var(--fs-14); +} + +.psm-error-copy { + color: var(--negative); +} + +.psm-link { + color: var(--brass-bright); + text-decoration-color: rgba(220, 199, 158, 0.4); +} + +@keyframes prism-sweep { + 100% { + transform: translateX(100%); + } +} + +@media (max-width: 1200px) { + .psm-ticker-head, + .psm-main-grid, + .psm-market-strip, + .psm-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .psm-ticker-head { + align-items: start; + } + + .psm-price-stack, + .psm-top-action { + justify-self: start; + } +} + +@media (max-width: 920px) { + .prism-app { + grid-template-columns: 1fr; + } + + .psm-side { + position: static; + height: auto; + } + + .psm-top { + flex-wrap: wrap; + padding-inline: var(--sp-4); + } + + .psm-clock-group { + width: 100%; + justify-content: space-between; + } + + .psm-content { + padding-inline: var(--sp-4); + } + + .psm-market-strip, + .psm-kpis, + .psm-main-grid, + .psm-detail-grid, + .psm-signal-grid, + .psm-ticker-head { + grid-template-columns: 1fr; + } + + .psm-heading-row { + align-items: start; + flex-direction: column; + gap: var(--sp-2); + } + + .psm-price-stack { + align-items: start; + } +} diff --git a/frontend/components/PriceChart.tsx b/frontend/components/PriceChart.tsx index 8ce4ea9..3db55ce 100644 --- a/frontend/components/PriceChart.tsx +++ b/frontend/components/PriceChart.tsx @@ -12,6 +12,10 @@ type Props = { }; export function PriceChart({ symbol, points }: Props) { + if (!points.length) { + return
No price history available for this range.
; + } + const x = points.map((point) => point.date); const y = points.map((point) => point.close ?? null); const data: Data[] = [ @@ -21,26 +25,35 @@ export function PriceChart({ symbol, points }: Props) { type: "scatter", mode: "lines", name: symbol, - line: { color: "#C2AA7A", width: 2.4 }, + line: { color: "#C2AA7A", width: 2.5 }, fill: "tozeroy", - fillcolor: "rgba(194,170,122,0.10)" + fillcolor: "rgba(194,170,122,0.08)", + hovertemplate: "%{x}
$%{y:.2f}" } ]; + const layout: Partial = { autosize: true, height: 360, - margin: { l: 52, r: 18, t: 18, b: 38 }, + margin: { l: 52, r: 24, t: 20, b: 42 }, paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", hovermode: "x unified", font: { family: "IBM Plex Mono, monospace", color: "#8E8676" }, - xaxis: { showgrid: false, zeroline: false }, - yaxis: { showgrid: true, gridcolor: "#232934", tickprefix: "$", tickformat: ",.2f" } + xaxis: { + showgrid: false, + zeroline: false, + color: "#8E8676" + }, + yaxis: { + showgrid: true, + gridcolor: "#232934", + color: "#8E8676", + tickprefix: "$", + tickformat: ",.2f" + } }; - if (!points.length) { - return
No price history available for this period.
; - } - return ; } + diff --git a/frontend/components/prism/AppShell.tsx b/frontend/components/prism/AppShell.tsx new file mode 100644 index 0000000..63fd62b --- /dev/null +++ b/frontend/components/prism/AppShell.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +type Props = { + sidebar: ReactNode; + topbar: ReactNode; + children: ReactNode; +}; + +export function AppShell({ sidebar, topbar, children }: Props) { + return ( +
+ {sidebar} +
+ {topbar} +
{children}
+
+
+ ); +} + diff --git a/frontend/components/prism/ChartCard.tsx b/frontend/components/prism/ChartCard.tsx new file mode 100644 index 0000000..bc650d7 --- /dev/null +++ b/frontend/components/prism/ChartCard.tsx @@ -0,0 +1,55 @@ +import { PriceChart } from "@/components/PriceChart"; +import type { HistoryPoint } from "@/types/api"; + +const PERIODS = [ + { key: "1m", label: "1M" }, + { key: "3m", label: "3M" }, + { key: "6m", label: "6M" }, + { key: "1y", label: "1Y" }, + { key: "5y", label: "5Y" } +]; + +type Props = { + symbol: string; + period: string; + points: HistoryPoint[]; + chartState: "idle" | "loading" | "ready" | "error"; + chartError: string | null; + onChangePeriod: (period: string) => void; +}; + +export function ChartCard({ symbol, period, points, chartState, chartError, onChangePeriod }: Props) { + return ( +
+
+
+
Price History
+

{symbol}

+
+
+ {PERIODS.map((option) => ( + + ))} +
+
+ +

Chart loading is isolated from the rest of Overview. A history miss only affects this card.

+ +
+ {chartState === "loading" ?
Loading {period.toUpperCase()} history…
: null} + {chartState === "error" ?
{chartError || "Could not load chart history."}
: null} + {chartState === "ready" ? : null} +
+
+ ); +} + diff --git a/frontend/components/prism/KPIStrip.tsx b/frontend/components/prism/KPIStrip.tsx new file mode 100644 index 0000000..b3cd34c --- /dev/null +++ b/frontend/components/prism/KPIStrip.tsx @@ -0,0 +1,16 @@ +import type { KpiItem } from "@/lib/overview"; + +export function KPIStrip({ items }: { items: KpiItem[] }) { + return ( +
+ {items.map((item) => ( +
+ {item.key} + {item.value} + {item.sublabel} +
+ ))} +
+ ); +} + diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx new file mode 100644 index 0000000..15a2947 --- /dev/null +++ b/frontend/components/prism/Sidebar.tsx @@ -0,0 +1,101 @@ +import Image from "next/image"; +import type { NavItem } from "@/lib/overview"; +import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format"; +import { watchlistSubtitle } from "@/lib/overview"; +import type { WatchlistResponse } from "@/types/api"; + +type Props = { + navItems: NavItem[]; + selectedKey: string; + currentTicker: string; + watchlist: WatchlistResponse; + watchlistError: string | null; + onSelectTicker: (symbol: string) => void; + onRemoveTicker: (symbol: string) => void; +}; + +export function Sidebar({ + navItems, + selectedKey, + currentTicker, + watchlist, + watchlistError, + onSelectTicker, + onRemoveTicker +}: Props) { + return ( + + ); +} diff --git a/frontend/components/prism/TickerHeader.tsx b/frontend/components/prism/TickerHeader.tsx new file mode 100644 index 0000000..23254f8 --- /dev/null +++ b/frontend/components/prism/TickerHeader.tsx @@ -0,0 +1,51 @@ +import { deltaClass, fmtCurrency, fmtPct } from "@/lib/format"; +import { buildIdentityLine, rangePercent } from "@/lib/overview"; +import type { TickerOverview } from "@/types/api"; + +type Props = { + overview: TickerOverview; + onToggleWatchlist: () => void; + isSaved: boolean; +}; + +export function TickerHeader({ overview, onToggleWatchlist, isSaved }: Props) { + const pct = rangePercent(overview); + + return ( +
+
+ {overview.profile.symbol} +
+ {overview.profile.symbol} + {overview.profile.name || "Name unavailable"} + {overview.meta.is_partial ? Partial Data : null} +
+

{buildIdentityLine(overview)}

+
+ +
+
52 Week Range
+
+ {fmtCurrency(overview.range_52w.low)} + {fmtCurrency(overview.range_52w.price ?? overview.quote.price)} + {fmtCurrency(overview.range_52w.high)} +
+
+ {pct != null ? : null} +
+
+ +
+ {fmtCurrency(overview.quote.price)} + + {fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)} + + Prev close {fmtCurrency(overview.quote.prev_close)} + +
+
+ ); +} + diff --git a/frontend/components/prism/TopBar.tsx b/frontend/components/prism/TopBar.tsx new file mode 100644 index 0000000..38e8447 --- /dev/null +++ b/frontend/components/prism/TopBar.tsx @@ -0,0 +1,61 @@ +import type { FormEvent } from "react"; +import type { SearchResult } from "@/types/api"; + +type Props = { + query: string; + searching: boolean; + results: SearchResult[]; + marketStatus: { isOpen: boolean; status: string; time: string }; + onChangeQuery: (value: string) => void; + onSubmit: (event: FormEvent) => void; + onSelectTicker: (symbol: string) => void; +}; + +export function TopBar({ query, searching, results, marketStatus, onChangeQuery, onSubmit, onSelectTicker }: Props) { + const showDropdown = query.trim().length >= 2; + + return ( +
+
+
+ + onChangeQuery(event.target.value)} placeholder="Search ticker or company" aria-label="Search ticker" /> + Enter + + + {showDropdown ? ( +
+ {searching ?
Searching...
: null} + {!searching && results.length === 0 ?
No matches
: null} + {!searching + ? results.map((result) => ( + + )) + : null} +
+ ) : null} +
+ +
+
+ + {marketStatus.status} + {marketStatus.time} +
+ +
+ + T + Local Profile +
+
+
+ ); +} + diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts new file mode 100644 index 0000000..db30050 --- /dev/null +++ b/frontend/lib/overview.ts @@ -0,0 +1,134 @@ +import { fmtCurrency, fmtLarge, fmtNumber, fmtPct } from "@/lib/format"; +import type { MarketIndex, Signal, TickerOverview, WatchlistItem } from "@/types/api"; + +export type KpiItem = { + key: string; + value: string; + sublabel: string; + missing?: boolean; +}; + +export type NavItem = { + key: string; + label: string; + icon: string; + disabled?: boolean; +}; + +export const OVERVIEW_NAV_ITEMS: NavItem[] = [ + { key: "overview", label: "Overview", icon: "chart" }, + { key: "financials", label: "Financials", icon: "ledger", disabled: true }, + { key: "valuation", label: "Valuation", icon: "dollar", disabled: true }, + { key: "options", label: "Options", icon: "window", disabled: true }, + { key: "insiders", label: "Insiders", icon: "pulse", disabled: true }, + { key: "filings", label: "Filings", icon: "folder", disabled: true }, + { key: "news", label: "News", icon: "terminal", disabled: true } +]; + +export function buildIdentityLine(overview: TickerOverview): string { + const parts = [overview.profile.sector, overview.profile.industry, overview.profile.exchange].filter(Boolean); + return parts.length ? parts.join(" · ") : "Profile details unavailable"; +} + +export function buildKpis(overview: TickerOverview): KpiItem[] { + return [ + { + key: "Market Cap", + value: fmtLarge(overview.stats.market_cap), + sublabel: `${fmtNumber(overview.stats.volume, 0)} volume`, + missing: overview.stats.market_cap == null + }, + { + key: "P / E", + value: overview.stats.trailing_pe == null ? "Unavailable" : `${fmtNumber(overview.stats.trailing_pe)}x`, + sublabel: `EPS ${fmtCurrency(overview.stats.trailing_eps)}`, + missing: overview.stats.trailing_pe == null + }, + { + key: "EPS · TTM", + value: fmtCurrency(overview.stats.trailing_eps), + sublabel: `Prev close ${fmtCurrency(overview.quote.prev_close)}`, + missing: overview.stats.trailing_eps == null + }, + { + key: "52W Position", + value: formatRangePosition(overview), + sublabel: `${fmtCurrency(overview.range_52w.low)} to ${fmtCurrency(overview.range_52w.high)}`, + missing: rangePercent(overview) == null + }, + { + key: "Short Float", + value: fmtPct(overview.short_interest.short_percent_of_float), + sublabel: `${fmtNumber(overview.short_interest.short_ratio)} days cover`, + missing: overview.short_interest.short_percent_of_float == null + }, + { + key: "Beta", + value: fmtNumber(overview.stats.beta), + sublabel: `Avg vol ${fmtNumber(overview.stats.average_volume, 0)}`, + missing: overview.stats.beta == null + } + ]; +} + +export function formatRangePosition(overview: TickerOverview): string { + const pct = rangePercent(overview); + if (pct == null) return "Unavailable"; + return `${pct.toFixed(0)}%`; +} + +export function rangePercent(overview: TickerOverview): number | null { + const low = overview.range_52w.low; + const high = overview.range_52w.high; + const price = overview.range_52w.price ?? overview.quote.price; + if (low == null || high == null || price == null || high <= low) return null; + return Math.max(0, Math.min(100, ((price - low) / (high - low)) * 100)); +} + +export function unavailableFields(overview: TickerOverview): string[] { + return Object.entries(overview.meta.field_availability) + .filter(([, available]) => !available) + .map(([field]) => field); +} + +export function availableFieldSummary(overview: TickerOverview): string { + const fields = Object.values(overview.meta.field_availability); + if (!fields.length) return "Availability metadata unavailable"; + const available = fields.filter(Boolean).length; + return `${available}/${fields.length} tracked fields available`; +} + +export function watchlistSubtitle(item: WatchlistItem): string { + return new Date(item.created_at).toLocaleDateString(undefined, { + month: "short", + day: "numeric" + }); +} + +export function sortIndices(indices: MarketIndex[]): MarketIndex[] { + return [...indices].slice(0, 4); +} + +export function signalTone(signal: Signal["state"]): "pos" | "warn" | "neg" | "neu" { + return signal; +} + +export function marketClock(now = new Date()) { + const eastern = new Date(now.toLocaleString("en-US", { timeZone: "America/New_York" })); + const day = eastern.getDay(); + const minutes = eastern.getHours() * 60 + eastern.getMinutes(); + const isWeekday = day >= 1 && day <= 5; + const open = isWeekday && minutes >= 570 && minutes < 960; + + return { + isOpen: open, + status: open ? "US Market Open" : "US Market Closed", + time: new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + timeZone: "America/New_York", + timeZoneName: "short" + }).format(now) + }; +} + diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/public/design-system/colors_and_type.css b/frontend/public/design-system/colors_and_type.css new file mode 100644 index 0000000..59877f1 --- /dev/null +++ b/frontend/public/design-system/colors_and_type.css @@ -0,0 +1,354 @@ +/* ========================================================================= + Tyler Hoang — Colors & Type + Dark, traditional, polished. Finance-forward. + ========================================================================= */ + +/* ========================================================================= + LOCAL BRAND FONTS — all self-hosted, no network requests + ========================================================================= */ + +/* EB Garamond — variable, both axes */ +@font-face { + font-family: 'EB Garamond'; + src: url('fonts/EBGaramond-VariableFont_wght.ttf') format('truetype-variations'), + url('fonts/EBGaramond-VariableFont_wght.ttf') format('truetype'); + font-weight: 400 800; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'EB Garamond'; + src: url('fonts/EBGaramond-Italic-VariableFont_wght.ttf') format('truetype-variations'), + url('fonts/EBGaramond-Italic-VariableFont_wght.ttf') format('truetype'); + font-weight: 400 800; + font-style: italic; + font-display: swap; +} + +/* IBM Plex Sans — variable, both axes (wdth + wght) */ +@font-face { + font-family: 'IBM Plex Sans'; + src: url('fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf') format('truetype-variations'), + url('fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf') format('truetype'); + font-weight: 100 700; + font-stretch: 85% 100%; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Sans'; + src: url('fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf') format('truetype-variations'), + url('fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf') format('truetype'); + font-weight: 100 700; + font-stretch: 85% 100%; + font-style: italic; + font-display: swap; +} + +/* IBM Plex Mono — static weights 300 / 400 / 500 / 600 (roman + italic) */ +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf') format('truetype'); + font-weight: 300; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf') format('truetype'); + font-weight: 300; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf') format('truetype'); + font-weight: 400; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf') format('truetype'); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf') format('truetype'); + font-weight: 500; + font-style: italic; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf') format('truetype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'IBM Plex Mono'; + src: url('fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf') format('truetype'); + font-weight: 600; + font-style: italic; + font-display: swap; +} + +:root { + /* ---------- COLOR — Surfaces (warm-cool ink, never pure black) ---------- */ + --ink-0: #0B0E13; /* page background — deep midnight, hint of blue */ + --ink-1: #11151C; /* card / surface 1 */ + --ink-2: #181D26; /* surface 2 — popovers, raised */ + --ink-3: #222934; /* surface 3 — input fills */ + --ink-4: #2C3340; /* surface 4 — hover */ + + /* ---------- COLOR — Lines ---------- */ + --line-1: #232934; /* hairline divider */ + --line-2: #2E3645; /* default border */ + --line-3: #3D4658; /* prominent border / focus ring base */ + + /* ---------- COLOR — Foreground (warm cream — never pure white) ---------- */ + --fg-1: #F2ECDC; /* high — headings, primary text */ + --fg-2: #C7C0AE; /* mid — body text */ + --fg-3: #8E8676; /* low — secondary, captions */ + --fg-4: #5E5849; /* lowest — disabled, tertiary */ + + /* ---------- COLOR — Brand accents (restrained, traditional) ---------- */ + /* Champagne — soft, muted, never honey-gold. Confirmed by user + against the "Lumière" gold which read too yellow. */ + --brass: #C2AA7A; /* primary accent — muted champagne */ + --brass-bright: #DCC79E; /* hover / highlight — pale champagne */ + --brass-deep: #8F7A50; /* pressed — deeper champagne */ + --brass-ink: #17120A; /* on-brass foreground */ + + --oxford: #1F3D5C; /* secondary — oxford navy */ + --oxford-light: #2E5A87; + --burgundy: #6E2A2E; /* tertiary — bound-leather burgundy */ + --burgundy-light:#8B3A3F; + + /* ---------- COLOR — Semantic (financial; never neon) ---------- */ + --positive: #4F8C5E; /* gain — forest green */ + --positive-bg: #15241A; + --negative: #B5494B; /* loss — muted vermilion */ + --negative-bg: #2A1517; + --warning: #C49545; /* caution — amber */ + --warning-bg: #2A1F0F; + --info: #4A78B5; /* info — slate blue */ + --info-bg: #11202E; + + /* ---------- COLOR — Selection / focus ---------- */ + --focus-ring: rgba(194, 170, 122, 0.55); /* champagne at 55% */ + --selection-bg: rgba(194, 170, 122, 0.25); + + /* ---------- TYPE — Families ---------- */ + --font-display: 'EB Garamond', 'Source Serif Pro', 'Georgia', serif; + --font-sans: 'IBM Plex Sans', 'Helvetica Neue', system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', 'SF Mono', Menlo, monospace; + + /* ---------- TYPE — Scale (modular, 1.250 — major third) ---------- */ + --fs-12: 0.75rem; /* 12 — micro / labels */ + --fs-13: 0.8125rem; /* 13 — caption */ + --fs-14: 0.875rem; /* 14 — small */ + --fs-16: 1rem; /* 16 — body */ + --fs-18: 1.125rem; /* 18 — body lg */ + --fs-20: 1.25rem; /* 20 — h6 */ + --fs-24: 1.5rem; /* 24 — h5 */ + --fs-30: 1.875rem; /* 30 — h4 */ + --fs-38: 2.375rem; /* 38 — h3 */ + --fs-48: 3rem; /* 48 — h2 */ + --fs-64: 4rem; /* 64 — h1 */ + --fs-88: 5.5rem; /* 88 — display */ + + /* ---------- TYPE — Weights ---------- */ + --w-light: 300; + --w-regular: 400; + --w-medium: 500; + --w-semibold: 600; + --w-bold: 700; + + /* ---------- TYPE — Line heights ---------- */ + --lh-tight: 1.05; + --lh-snug: 1.2; + --lh-normal: 1.45; + --lh-relaxed: 1.6; + + /* ---------- TYPE — Tracking ---------- */ + --tr-tight: -0.02em; + --tr-snug: -0.01em; + --tr-normal: 0; + --tr-wide: 0.04em; + --tr-wider: 0.12em; /* small-caps labels */ + + /* ---------- SPACING (4px base) ---------- */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + --sp-9: 96px; + --sp-10: 128px; + + /* ---------- RADII — small, never bubbly ---------- */ + --r-0: 0; + --r-1: 2px; + --r-2: 4px; + --r-3: 6px; /* cards default */ + --r-4: 8px; /* large surfaces */ + --r-full: 999px; + + /* ---------- ELEVATION — long, low, warm ---------- */ + --shadow-1: 0 1px 0 rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3); + --shadow-2: 0 1px 0 rgba(0,0,0,.4), 0 4px 12px rgba(0,0,0,.45); + --shadow-3: 0 2px 0 rgba(0,0,0,.5), 0 12px 32px rgba(0,0,0,.55); + --shadow-inset: inset 0 1px 0 rgba(255,255,255,.04); + --shadow-brass: 0 0 0 1px rgba(194,170,122,.35), 0 6px 20px rgba(194,170,122,.18); +} + +/* ========================================================================= + SEMANTIC TYPE STYLES + ========================================================================= */ + +body { + font-family: var(--font-sans); + font-size: var(--fs-16); + line-height: var(--lh-normal); + color: var(--fg-2); + background: var(--ink-0); + font-weight: var(--w-regular); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +::selection { background: var(--selection-bg); color: var(--fg-1); } + +.display, h1.display { + font-family: var(--font-display); + font-size: var(--fs-88); + font-weight: var(--w-regular); + line-height: var(--lh-tight); + letter-spacing: var(--tr-tight); + color: var(--fg-1); + font-style: italic; /* EB Garamond italics are the signature move */ +} + +h1, .h1 { + font-family: var(--font-display); + font-size: var(--fs-64); + font-weight: var(--w-regular); + line-height: var(--lh-tight); + letter-spacing: var(--tr-tight); + color: var(--fg-1); + margin: 0; +} + +h2, .h2 { + font-family: var(--font-display); + font-size: var(--fs-48); + font-weight: var(--w-regular); + line-height: var(--lh-snug); + letter-spacing: var(--tr-snug); + color: var(--fg-1); + margin: 0; +} + +h3, .h3 { + font-family: var(--font-display); + font-size: var(--fs-30); + font-weight: var(--w-medium); + line-height: var(--lh-snug); + color: var(--fg-1); + margin: 0; +} + +h4, .h4 { + font-family: var(--font-sans); + font-size: var(--fs-20); + font-weight: var(--w-semibold); + line-height: var(--lh-snug); + letter-spacing: var(--tr-snug); + color: var(--fg-1); + margin: 0; +} + +h5, .h5 { + font-family: var(--font-sans); + font-size: var(--fs-16); + font-weight: var(--w-semibold); + line-height: var(--lh-snug); + color: var(--fg-1); + margin: 0; +} + +h6, .eyebrow { + font-family: var(--font-sans); + font-size: var(--fs-12); + font-weight: var(--w-semibold); + text-transform: uppercase; + letter-spacing: var(--tr-wider); + color: var(--fg-3); + margin: 0; +} + +p, .p { + font-family: var(--font-sans); + font-size: var(--fs-16); + line-height: var(--lh-relaxed); + color: var(--fg-2); + margin: 0; +} + +.lead { + font-family: var(--font-display); + font-size: var(--fs-24); + font-weight: var(--w-regular); + line-height: var(--lh-relaxed); + color: var(--fg-1); + font-style: italic; +} + +.caption { + font-family: var(--font-sans); + font-size: var(--fs-13); + line-height: var(--lh-normal); + color: var(--fg-3); +} + +.micro { + font-family: var(--font-sans); + font-size: var(--fs-12); + text-transform: uppercase; + letter-spacing: var(--tr-wider); + color: var(--fg-3); +} + +code, .code { + font-family: var(--font-mono); + font-size: 0.92em; + background: var(--ink-3); + color: var(--fg-1); + padding: 2px 6px; + border-radius: var(--r-1); + border: 1px solid var(--line-1); +} + +.tabular { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } + +a { + color: var(--brass-bright); + text-decoration: none; + border-bottom: 1px solid var(--brass-deep); + transition: color .15s ease, border-color .15s ease; +} +a:hover { color: var(--fg-1); border-color: var(--brass-bright); } diff --git a/frontend/public/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf b/frontend/public/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf new file mode 100644 index 0000000..9cb1376 Binary files /dev/null and b/frontend/public/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf differ diff --git a/frontend/public/design-system/fonts/EBGaramond-VariableFont_wght.ttf b/frontend/public/design-system/fonts/EBGaramond-VariableFont_wght.ttf new file mode 100644 index 0000000..baf64b2 Binary files /dev/null and b/frontend/public/design-system/fonts/EBGaramond-VariableFont_wght.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf new file mode 100644 index 0000000..e259e84 Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf new file mode 100644 index 0000000..0dcb2fb Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf new file mode 100644 index 0000000..f4a5fea Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf new file mode 100644 index 0000000..8253c5f Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf new file mode 100644 index 0000000..528b13b Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..601ae94 Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf new file mode 100644 index 0000000..5e0b41d Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf new file mode 100644 index 0000000..58243dd Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf b/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..6232aaa Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf differ diff --git a/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf b/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..9add875 Binary files /dev/null and b/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf differ diff --git a/frontend/public/design-system/grain.svg b/frontend/public/design-system/grain.svg new file mode 100644 index 0000000..d62ad09 --- /dev/null +++ b/frontend/public/design-system/grain.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/chart.svg b/frontend/public/design-system/icons/chart.svg new file mode 100644 index 0000000..830d53d --- /dev/null +++ b/frontend/public/design-system/icons/chart.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/clock.svg b/frontend/public/design-system/icons/clock.svg new file mode 100644 index 0000000..42a244b --- /dev/null +++ b/frontend/public/design-system/icons/clock.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/command.svg b/frontend/public/design-system/icons/command.svg new file mode 100644 index 0000000..19dd2af --- /dev/null +++ b/frontend/public/design-system/icons/command.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/dollar.svg b/frontend/public/design-system/icons/dollar.svg new file mode 100644 index 0000000..83e4a40 --- /dev/null +++ b/frontend/public/design-system/icons/dollar.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/folder.svg b/frontend/public/design-system/icons/folder.svg new file mode 100644 index 0000000..cb8ea41 --- /dev/null +++ b/frontend/public/design-system/icons/folder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/ledger.svg b/frontend/public/design-system/icons/ledger.svg new file mode 100644 index 0000000..989e7a8 --- /dev/null +++ b/frontend/public/design-system/icons/ledger.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/pulse.svg b/frontend/public/design-system/icons/pulse.svg new file mode 100644 index 0000000..bfa6e5b --- /dev/null +++ b/frontend/public/design-system/icons/pulse.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/search.svg b/frontend/public/design-system/icons/search.svg new file mode 100644 index 0000000..4c561f8 --- /dev/null +++ b/frontend/public/design-system/icons/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/terminal.svg b/frontend/public/design-system/icons/terminal.svg new file mode 100644 index 0000000..a36c7bc --- /dev/null +++ b/frontend/public/design-system/icons/terminal.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/user.svg b/frontend/public/design-system/icons/user.svg new file mode 100644 index 0000000..45b7766 --- /dev/null +++ b/frontend/public/design-system/icons/user.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/public/design-system/icons/window.svg b/frontend/public/design-system/icons/window.svg new file mode 100644 index 0000000..1c37c86 --- /dev/null +++ b/frontend/public/design-system/icons/window.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/public/design-system/logo-monogram.svg b/frontend/public/design-system/logo-monogram.svg new file mode 100644 index 0000000..ddb4c58 --- /dev/null +++ b/frontend/public/design-system/logo-monogram.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + TH + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/design-system/logo-wordmark.svg b/frontend/public/design-system/logo-wordmark.svg new file mode 100644 index 0000000..2449e19 --- /dev/null +++ b/frontend/public/design-system/logo-wordmark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + TH + + + + + Thuy + (Tyler) + Hoang + + FINANCE · ANALYTICS · CFA LEVEL I CANDIDATE + + \ No newline at end of file diff --git a/frontend/public/design-system/prism.css b/frontend/public/design-system/prism.css new file mode 100644 index 0000000..f4bca93 --- /dev/null +++ b/frontend/public/design-system/prism.css @@ -0,0 +1,365 @@ +/* Prism dashboard — kit-specific layout (tokens from ../../colors_and_type.css) */ + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; background: var(--ink-0); height: 100%; } +.prism-app { + display: grid; + grid-template-columns: 256px 1fr; + min-height: 100vh; +} + +/* ----- Sidebar ----- */ +.psm-side { + background: var(--ink-1); + border-right: 1px solid var(--line-1); + display: flex; flex-direction: column; + position: sticky; top: 0; height: 100vh; + overflow-y: auto; +} +.psm-brand { + padding: var(--sp-5) var(--sp-5) var(--sp-4); + border-bottom: 1px solid var(--line-1); + display: flex; align-items: center; gap: var(--sp-3); +} +.psm-brand .mark { + width: 32px; height: 32px; border-radius: 50%; + border: 1px solid var(--brass); + display: flex; align-items: center; justify-content: center; + font-family: var(--font-display); font-style: italic; font-size: 18px; + color: var(--brass); letter-spacing: -0.05em; +} +.psm-brand .meta { display: flex; flex-direction: column; gap: 2px; } +.psm-brand .name { + font-family: var(--font-display); font-size: 20px; color: var(--fg-1); + font-weight: 500; letter-spacing: -0.01em; line-height: 1; +} +.psm-brand .sub { + font-family: var(--font-mono); font-size: 10px; + color: var(--fg-3); letter-spacing: 0.18em; text-transform: uppercase; +} + +.psm-section { + padding: var(--sp-5) var(--sp-4) var(--sp-2); + font-family: var(--font-sans); font-size: var(--fs-12); + font-weight: 600; text-transform: uppercase; letter-spacing: var(--tr-wider); + color: var(--fg-3); +} +.psm-nav { display: flex; flex-direction: column; } +.psm-nav a { + display: flex; align-items: center; gap: var(--sp-3); + padding: 8px var(--sp-4); + font-family: var(--font-sans); font-size: var(--fs-14); + color: var(--fg-2); text-decoration: none; border-bottom: none; + border-left: 2px solid transparent; + cursor: pointer; +} +.psm-nav a:hover { background: var(--ink-2); color: var(--fg-1); } +.psm-nav a.active { + background: var(--ink-2); color: var(--fg-1); + border-left-color: var(--brass); +} +.psm-nav a .ico { + width: 16px; height: 16px; background: currentColor; + -webkit-mask-size: contain; mask-size: contain; + -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; + -webkit-mask-position: center; mask-position: center; + opacity: 0.7; +} + +.psm-watch { + margin: 0 var(--sp-4); + border-top: 1px solid var(--line-1); +} +.psm-watch-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: var(--sp-2); + align-items: baseline; + padding: 8px 0; + border-bottom: 1px solid var(--line-1); + cursor: pointer; +} +.psm-watch-row:hover { background: rgba(194,170,122,0.04); } +.psm-watch-row .sym { font-family: var(--font-sans); font-size: var(--fs-14); font-weight: 500; color: var(--fg-1); } +.psm-watch-row .px { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--fs-13); color: var(--fg-1); text-align: right; } +.psm-watch-row .chg { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--fs-12); text-align: right; min-width: 56px; } +.pos { color: var(--positive); } +.neg { color: var(--negative); } + +/* ----- Main column ----- */ +.psm-main { + display: flex; flex-direction: column; + min-width: 0; +} + +/* Top bar */ +.psm-top { + background: var(--ink-0); + border-bottom: 1px solid var(--line-1); + padding: var(--sp-3) var(--sp-6); + display: flex; align-items: center; gap: var(--sp-4); + position: sticky; top: 0; z-index: 5; +} +.psm-search { + flex: 1; max-width: 480px; + display: flex; align-items: center; gap: var(--sp-2); + background: var(--ink-2); border: 1px solid var(--line-2); + border-radius: var(--r-2); padding: 8px var(--sp-3); +} +.psm-search:focus-within { border-color: var(--brass); } +.psm-search input { + flex: 1; background: transparent; border: none; outline: none; + font-family: var(--font-mono); font-size: var(--fs-13); color: var(--fg-1); +} +.psm-search input::placeholder { color: var(--fg-3); } +.psm-search .kbd { + font-family: var(--font-mono); font-size: var(--fs-12); + color: var(--fg-3); border: 1px solid var(--line-2); + padding: 1px 6px; border-radius: var(--r-1); + background: var(--ink-1); +} +.psm-clock { + font-family: var(--font-mono); font-size: var(--fs-13); + color: var(--fg-2); display: flex; gap: var(--sp-3); align-items: center; +} +.psm-clock .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--positive); box-shadow: 0 0 8px var(--positive); } +.psm-account { + display: flex; align-items: center; gap: 8px; + padding: 6px 12px; border: 1px solid var(--line-2); border-radius: var(--r-full); + font-family: var(--font-sans); font-size: var(--fs-13); color: var(--fg-1); +} +.psm-account .avatar { + width: 22px; height: 22px; border-radius: 50%; + background: var(--brass); color: var(--brass-ink); + display: flex; align-items: center; justify-content: center; + font-family: var(--font-display); font-style: italic; font-size: 12px; font-weight: 500; +} + +/* Content area */ +.psm-content { padding: var(--sp-6) var(--sp-6) var(--sp-9); display: flex; flex-direction: column; gap: var(--sp-5); } + +.psm-ticker-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: var(--sp-5); + align-items: end; + padding-bottom: var(--sp-4); + border-bottom: 1px solid var(--line-1); +} +.psm-ticker-head .left { display: flex; align-items: baseline; gap: var(--sp-4); } +.psm-ticker-head .sym { + font-family: var(--font-display); font-size: var(--fs-64); + font-weight: 500; color: var(--fg-1); line-height: 0.95; + letter-spacing: -0.025em; +} +.psm-ticker-head .name { + font-family: var(--font-display); font-style: italic; + font-size: var(--fs-24); color: var(--fg-2); font-weight: 400; +} +.psm-ticker-head .sector { + font-family: var(--font-sans); font-size: var(--fs-12); + text-transform: uppercase; letter-spacing: var(--tr-wider); + color: var(--brass); font-weight: 600; + display: block; margin-bottom: var(--sp-2); +} +.psm-ticker-head .px-block { + display: flex; flex-direction: column; align-items: flex-end; gap: 4px; +} +.psm-ticker-head .px { + font-family: var(--font-mono); font-variant-numeric: tabular-nums; + font-size: var(--fs-48); color: var(--fg-1); line-height: 1; font-weight: 500; +} +.psm-ticker-head .chg { + font-family: var(--font-mono); font-variant-numeric: tabular-nums; + font-size: var(--fs-16); +} + +.psm-range { + font-family: var(--font-mono); font-size: var(--fs-12); + color: var(--fg-3); display: flex; gap: var(--sp-4); +} +.psm-range .marker { + position: relative; + width: 140px; height: 4px; + background: var(--ink-3); border-radius: 999px; +} +.psm-range .marker::after { + content: ""; position: absolute; top: -3px; + width: 2px; height: 10px; background: var(--brass); +} + +/* KPI strip */ +.psm-kpis { + display: grid; + grid-template-columns: repeat(6, 1fr); + border: 1px solid var(--line-1); + border-radius: var(--r-3); + background: var(--ink-1); + overflow: hidden; +} +.psm-kpi { + padding: var(--sp-4) var(--sp-5); + border-right: 1px solid var(--line-1); + display: flex; flex-direction: column; gap: 4px; +} +.psm-kpi:last-child { border-right: none; } +.psm-kpi .k { + font-family: var(--font-sans); font-size: var(--fs-12); + text-transform: uppercase; letter-spacing: var(--tr-wider); + color: var(--fg-3); font-weight: 600; +} +.psm-kpi .v { + font-family: var(--font-mono); font-variant-numeric: tabular-nums; + font-size: var(--fs-24); color: var(--fg-1); font-weight: 500; +} +.psm-kpi .sub { font-family: var(--font-mono); font-size: var(--fs-12); color: var(--fg-3); } + +/* Two-column grid below */ +.psm-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: var(--sp-5); +} +.psm-card { + background: var(--ink-1); + border: 1px solid var(--line-1); + border-radius: var(--r-3); + padding: var(--sp-5); + display: flex; flex-direction: column; gap: var(--sp-3); + box-shadow: var(--shadow-1); +} +.psm-card-head { + display: flex; justify-content: space-between; align-items: baseline; + margin-bottom: var(--sp-2); +} +.psm-card-head h3 { + font-family: var(--font-display); font-size: var(--fs-24); + color: var(--fg-1); font-weight: 500; margin: 0; +} +.psm-card-head .eyebrow { + font-family: var(--font-sans); font-size: var(--fs-12); + text-transform: uppercase; letter-spacing: var(--tr-wider); + color: var(--fg-3); font-weight: 600; +} + +.psm-tabs { + display: flex; gap: 4px; + border: 1px solid var(--line-2); + background: var(--ink-2); + border-radius: var(--r-1); + padding: 2px; +} +.psm-tab { + font-family: var(--font-mono); font-size: var(--fs-12); + background: transparent; border: none; color: var(--fg-3); + padding: 4px 10px; cursor: pointer; border-radius: var(--r-1); +} +.psm-tab.active { background: var(--ink-3); color: var(--fg-1); } +.psm-tab:hover:not(.active) { color: var(--fg-1); } + +/* Chart placeholder */ +.psm-chart { + height: 260px; position: relative; + background: var(--ink-0); + border: 1px solid var(--line-1); + border-radius: var(--r-2); + overflow: hidden; +} + +/* Quote table */ +.psm-table { width: 100%; border-collapse: collapse; } +.psm-table th { + font-family: var(--font-sans); font-size: var(--fs-12); + font-weight: 600; color: var(--fg-3); + text-transform: uppercase; letter-spacing: var(--tr-wide); + text-align: left; padding: 8px var(--sp-3); + border-bottom: 1px solid var(--line-2); +} +.psm-table th.r, .psm-table td.r { text-align: right; } +.psm-table td { + font-family: var(--font-mono); font-variant-numeric: tabular-nums; + font-size: var(--fs-14); color: var(--fg-1); + padding: 10px var(--sp-3); + border-bottom: 1px solid var(--line-1); +} +.psm-table td.lbl { font-family: var(--font-sans); color: var(--fg-1); font-weight: 500; } +.psm-table tbody tr:hover { background: rgba(194,170,122,0.04); } + +/* Valuation strip */ +.psm-val { + display: flex; flex-direction: column; gap: var(--sp-3); +} +.psm-val-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--line-1); +} +.psm-val-row:last-child { border-bottom: none; } +.psm-val-row .label { + font-family: var(--font-sans); font-size: var(--fs-14); color: var(--fg-2); +} +.psm-val-row .num { + font-family: var(--font-mono); font-variant-numeric: tabular-nums; + font-size: var(--fs-18); color: var(--fg-1); font-weight: 500; +} +.psm-verdict { + margin-top: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border: 1px solid rgba(79,140,94,0.35); + background: var(--positive-bg); + border-radius: var(--r-2); + font-family: var(--font-sans); font-size: var(--fs-14); + color: var(--positive); + display: flex; justify-content: space-between; align-items: baseline; +} +.psm-verdict .v { + font-family: var(--font-display); font-style: italic; + color: var(--fg-1); font-size: var(--fs-18); +} + +/* Filings list */ +.psm-filing { + display: grid; + grid-template-columns: 64px 1fr auto; + gap: var(--sp-3); + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--line-1); +} +.psm-filing:last-child { border-bottom: none; } +.psm-filing .type { + font-family: var(--font-mono); font-size: var(--fs-12); + color: var(--brass); background: rgba(194,170,122,0.08); + border: 1px solid rgba(194,170,122,0.3); + padding: 2px 6px; border-radius: var(--r-1); + text-align: center; +} +.psm-filing .ttl { font-family: var(--font-sans); font-size: var(--fs-13); color: var(--fg-1); } +.psm-filing .date { font-family: var(--font-mono); font-size: var(--fs-12); color: var(--fg-3); } + +/* Insider rows */ +.psm-insider { + display: grid; + grid-template-columns: 1fr auto auto; + gap: var(--sp-3); + align-items: center; + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--line-1); +} +.psm-insider:last-child { border-bottom: none; } +.psm-insider .who { display: flex; flex-direction: column; gap: 2px; } +.psm-insider .name { font-family: var(--font-sans); font-size: var(--fs-14); color: var(--fg-1); } +.psm-insider .role { font-family: var(--font-sans); font-size: var(--fs-12); color: var(--fg-3); } +.psm-insider .pill { + font-family: var(--font-mono); font-size: var(--fs-12); + padding: 2px 8px; border-radius: var(--r-1); +} +.psm-insider .pill.buy { background: var(--positive-bg); color: var(--positive); border: 1px solid rgba(79,140,94,0.35); } +.psm-insider .pill.sell { background: var(--negative-bg); color: var(--negative); border: 1px solid rgba(181,73,75,0.35); } +.psm-insider .val { + font-family: var(--font-mono); font-variant-numeric: tabular-nums; + font-size: var(--fs-14); color: var(--fg-1); text-align: right; + min-width: 96px; +} -- cgit v1.3-2-g0d8e