summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--AGENTS.md39
-rw-r--r--CLAUDE.md24
-rw-r--r--frontend/app/design-tokens.css206
-rw-r--r--frontend/app/globals.css647
-rw-r--r--frontend/app/page.tsx581
-rw-r--r--frontend/app/prism-shell.css1004
-rw-r--r--frontend/components/PriceChart.tsx31
-rw-r--r--frontend/components/prism/AppShell.tsx20
-rw-r--r--frontend/components/prism/ChartCard.tsx55
-rw-r--r--frontend/components/prism/KPIStrip.tsx16
-rw-r--r--frontend/components/prism/Sidebar.tsx101
-rw-r--r--frontend/components/prism/TickerHeader.tsx51
-rw-r--r--frontend/components/prism/TopBar.tsx61
-rw-r--r--frontend/lib/overview.ts134
-rw-r--r--frontend/next-env.d.ts2
-rw-r--r--frontend/public/design-system/colors_and_type.css354
-rw-r--r--frontend/public/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttfbin0 -> 811012 bytes
-rw-r--r--frontend/public/design-system/fonts/EBGaramond-VariableFont_wght.ttfbin0 -> 934420 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttfbin0 -> 142108 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttfbin0 -> 133468 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttfbin0 -> 141596 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttfbin0 -> 134956 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttfbin0 -> 142204 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttfbin0 -> 133796 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttfbin0 -> 138448 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttfbin0 -> 145528 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttfbin0 -> 594116 bytes
-rw-r--r--frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttfbin0 -> 532740 bytes
-rw-r--r--frontend/public/design-system/grain.svg7
-rw-r--r--frontend/public/design-system/icons/chart.svg6
-rw-r--r--frontend/public/design-system/icons/clock.svg4
-rw-r--r--frontend/public/design-system/icons/command.svg4
-rw-r--r--frontend/public/design-system/icons/dollar.svg4
-rw-r--r--frontend/public/design-system/icons/folder.svg3
-rw-r--r--frontend/public/design-system/icons/ledger.svg5
-rw-r--r--frontend/public/design-system/icons/pulse.svg3
-rw-r--r--frontend/public/design-system/icons/search.svg4
-rw-r--r--frontend/public/design-system/icons/terminal.svg6
-rw-r--r--frontend/public/design-system/icons/user.svg4
-rw-r--r--frontend/public/design-system/icons/window.svg5
-rw-r--r--frontend/public/design-system/logo-monogram.svg23
-rw-r--r--frontend/public/design-system/logo-wordmark.svg24
-rw-r--r--frontend/public/design-system/prism.css365
44 files changed, 2856 insertions, 940 deletions
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: `<Sidebar>`, `<TopBar>`, `<TickerHeader>`, `<KPIStrip>`, `<ChartCard>`, `<QuoteTable>`, `<ValuationPanel>`, `<FilingsList>`, `<InsiderRow>`. 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: `<Sidebar>`, `<TopBar>`, `<TickerHeader>`, `<KPIStrip>`, `<ChartCard>`, `<QuoteTable>`, `<ValuationPanel>`, `<FilingsList>`, `<InsiderRow>`. 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 (
- <Suspense fallback={<main className="shell"><section className="content"><div className="empty-panel">Loading Prism...</div></section></main>}>
+ <Suspense fallback={<LoadingShell />}>
<OverviewClient />
</Suspense>
);
@@ -46,24 +44,37 @@ function OverviewClient() {
const [chartState, setChartState] = useState<ChartState>("idle");
const [chartError, setChartError] = useState<string | null>(null);
const [watchlistError, setWatchlistError] = useState<string | null>(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]);
- const selectTicker = useCallback(
+ useEffect(() => {
+ const timer = window.setInterval(() => setClockSnapshot(marketClock()), 60_000);
+ return () => window.clearInterval(timer);
+ }, []);
+
+ 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<HTMLFormElement>) {
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 (
- <main className="shell">
- <aside className="sidebar">
- <div className="brand">
- <div className="brand-mark">P</div>
- <div>
- <div className="brand-name">Prism</div>
- <div className="brand-sub">Overview</div>
- </div>
- </div>
+ async function removeFromWatchlist(symbol: string) {
+ try {
+ const next = await api.removeWatchlist(symbol);
+ setWatchlist(next);
+ setWatchlistError(null);
+ } catch (exc) {
+ setWatchlistError(exc instanceof Error ? exc.message : "Could not update watchlist");
+ }
+ }
- <form className="search-form" onSubmit={onSearchSubmit}>
- <label htmlFor="ticker-search">Ticker Search</label>
- <div className="search-box">
- <Search size={16} aria-hidden />
- <input id="ticker-search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="AAPL, Microsoft..." />
- </div>
- {query.trim().length >= 2 && (
- <div className="search-results">
- {searching && <div className="muted-row">Searching...</div>}
- {!searching && results.length === 0 && <div className="muted-row">No matches</div>}
- {results.map((result) => (
- <button key={`${result.symbol}-${result.exchange}`} type="button" onClick={() => selectTicker(result.symbol)}>
- <span>{result.symbol}</span>
- <small>{result.name}</small>
- </button>
- ))}
+ const shell = (
+ <AppShell
+ sidebar={
+ <Sidebar
+ navItems={OVERVIEW_NAV_ITEMS}
+ selectedKey="overview"
+ currentTicker={selectedTicker}
+ watchlist={watchlist}
+ watchlistError={watchlistError}
+ onSelectTicker={navigateToTicker}
+ onRemoveTicker={removeFromWatchlist}
+ />
+ }
+ topbar={
+ <TopBar
+ query={query}
+ searching={searching}
+ results={results}
+ marketStatus={clockSnapshot}
+ onChangeQuery={setQuery}
+ onSubmit={onSearchSubmit}
+ onSelectTicker={navigateToTicker}
+ />
+ }
+ >
+ <MarketStrip indices={marketCards} />
+ {!selectedTicker ? <EmptyOverviewState watchlist={watchlist} onSelectTicker={navigateToTicker} /> : null}
+ {selectedTicker && overviewState === "loading" ? <LoadingOverviewState symbol={selectedTicker} /> : null}
+ {selectedTicker && overviewState === "invalid" ? <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} /> : null}
+ {selectedTicker && overviewState === "error" ? <ErrorOverviewState message={overviewError || "Ticker data unavailable"} /> : null}
+ {overview && overviewState === "ready" ? (
+ <>
+ <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} />
+ <KPIStrip items={kpis} />
+ <div className="psm-main-grid">
+ <div className="psm-column">
+ <ChartCard symbol={overview.profile.symbol} period={period} points={history} chartState={chartState} chartError={chartError} onChangePeriod={setPeriod} />
+ <SignalCard overview={overview} />
</div>
- )}
- </form>
-
- <section className="selected-summary">
- <div className="section-label">Selected</div>
- <div className="summary-symbol">{summaryTicker || "-"}</div>
- <div className="summary-name">{summaryName}</div>
- {overview ? (
- <>
- <div className="summary-price">
- {fmtCurrency(overview.quote.price)}
- <span className={deltaClass(overview.quote.change_pct)}>{fmtPct(overview.quote.change_pct, 2, true)}</span>
- </div>
- <button className="watch-action" type="button" onClick={toggleWatchlist}>
- {isSaved ? <BookmarkMinus size={16} aria-hidden /> : <BookmarkPlus size={16} aria-hidden />}
- {isSaved ? "Remove" : "Save"}
- </button>
- </>
- ) : selectedTicker ? (
- <div className="empty-copy">{overviewState === "loading" ? "Fetching overview..." : "Search for another ticker."}</div>
- ) : (
- <div className="empty-copy">No ticker selected</div>
- )}
- {watchlistError && <div className="inline-note error-copy">{watchlistError}</div>}
- </section>
-
- <section className="watchlist">
- <div className="section-label">Watchlist</div>
- {watchlist.items.length === 0 && <div className="empty-copy">No saved tickers</div>}
- {watchlist.items.map((item) => (
- <button key={item.symbol} type="button" className="watch-row" onClick={() => selectTicker(item.symbol)}>
- <span>{item.symbol}</span>
- <span>{fmtCurrency(item.quote?.price)}</span>
- <span className={deltaClass(item.quote?.change_pct)}>{fmtPct(item.quote?.change_pct, 2, true)}</span>
- </button>
- ))}
- <div className="watch-count">
- {watchlist.items.length}/{watchlist.limit}
- </div>
- </section>
- </aside>
-
- <section className="content">
- <MarketBar indices={market} />
- {!selectedTicker && <EmptyState watchlist={watchlist} onSelect={selectTicker} />}
- {selectedTicker && overviewState === "loading" && <div className="empty-panel">Loading {selectedTicker}...</div>}
- {selectedTicker && overviewState === "invalid" && <InvalidTickerState symbol={selectedTicker} onClear={clearTicker} />}
- {selectedTicker && overviewState === "error" && <div className="error-panel">{overviewError || "Ticker data unavailable"}</div>}
- {overview && overviewState === "ready" && (
- <article className="overview">
- <header className="company-header">
- <div>
- <div className="eyebrow">{overview.profile.exchange || "Ticker"}</div>
- <div className="header-title-row">
- <h1>{overview.profile.name}</h1>
- {overview.meta.is_partial && <span className="status-chip">Partial Data</span>}
- </div>
- <p>{identityLine(overview)}</p>
- </div>
- <div className="quote-block">
- <span>{fmtCurrency(overview.quote.price)}</span>
- <strong className={deltaClass(overview.quote.change_pct)}>{fmtPct(overview.quote.change_pct, 2, true)}</strong>
- <small>Prev close {fmtCurrency(overview.quote.prev_close)}</small>
- </div>
- </header>
-
- {overview.meta.is_partial && <div className="inline-note">Some fields are unavailable for this ticker. Available data is still shown below.</div>}
-
- <section className="signal-grid">
- {overview.signals.map((signal) => (
- <div className={`signal ${signal.state}`} key={signal.key}>
- <span>{signal.key}</span>
- <strong>{signal.value}</strong>
- <small>{signal.description}</small>
- </div>
- ))}
- </section>
-
- <section className="stat-grid">
- <Stat label="Market Cap" value={fmtLarge(overview.stats.market_cap)} missing={overview.stats.market_cap == null} />
- <Stat label="P/E TTM" value={fmtNumber(overview.stats.trailing_pe)} missing={overview.stats.trailing_pe == null} />
- <Stat label="EPS TTM" value={fmtCurrency(overview.stats.trailing_eps)} missing={overview.stats.trailing_eps == null} />
- <Stat label="Volume" value={fmtNumber(overview.stats.volume, 0)} missing={overview.stats.volume == null} />
- <Stat label="Avg Volume" value={fmtNumber(overview.stats.average_volume, 0)} missing={overview.stats.average_volume == null} />
- <Stat label="Beta" value={fmtNumber(overview.stats.beta)} missing={overview.stats.beta == null} />
- </section>
-
- <section className="split-row">
- <RangeCard overview={overview} />
- <ShortInterest overview={overview} />
- </section>
-
- <section className="split-row">
+ <div className="psm-column">
+ <DataStatusCard overview={overview} missingFields={missingFields} />
<ProfileCard overview={overview} />
- <SourceCard overview={overview} />
- </section>
-
- <section className="chart-shell">
- <div className="chart-head">
- <div>
- <div className="section-label">Price History</div>
- <strong>{overview.profile.symbol}</strong>
- </div>
- <div className="segmented">
- {PERIODS.map((option) => (
- <button key={option.key} type="button" className={period === option.key ? "active" : ""} onClick={() => setPeriod(option.key)}>
- {option.label}
- </button>
- ))}
- </div>
- </div>
- {chartState === "loading" && <div className="empty-panel compact-panel">Loading {period.toUpperCase()} history...</div>}
- {chartState === "error" && <div className="error-panel compact-panel">{chartError || "Could not load chart history"}</div>}
- {chartState === "ready" && <PriceChart symbol={overview.profile.symbol} points={history} />}
- </section>
- </article>
- )}
- </section>
- </main>
+ <ShortInterestCard overview={overview} />
+ <StatsCard overview={overview} />
+ </div>
+ </div>
+ </>
+ ) : null}
+ </AppShell>
);
+
+ return shell;
}
-function MarketBar({ indices }: { indices: MarketIndex[] }) {
- if (!indices.length) return <div className="market-bar empty">Market data unavailable</div>;
+function MarketStrip({ indices }: { indices: MarketIndex[] }) {
+ if (!indices.length) {
+ return <section className="psm-card-empty psm-market-card">Market data is temporarily unavailable.</section>;
+ }
+
return (
- <section className="market-bar">
+ <section className="psm-market-strip" aria-label="Market indices">
{indices.map((index) => (
- <div className="market-item" key={index.name}>
- <span>{index.name}</span>
- <strong>{fmtNumber(index.price)}</strong>
- <small className={deltaClass(index.change_pct)}>{fmtPct(index.change_pct, 2, true)}</small>
- </div>
+ <article key={index.name} className="psm-market-card">
+ <span className="psm-market-name">{index.name}</span>
+ <span className="psm-market-price">{fmtNumber(index.price)}</span>
+ <span className={`psm-market-change ${deltaClass(index.change_pct)}`}>{fmtPct(index.change_pct, 2, true)}</span>
+ </article>
))}
</section>
);
}
-function EmptyState({ watchlist, onSelect }: { watchlist: WatchlistResponse; onSelect: (symbol: string) => void }) {
+function EmptyOverviewState({ watchlist, onSelectTicker }: { watchlist: WatchlistResponse; onSelectTicker: (symbol: string) => void }) {
return (
- <section className="empty-state">
- <h1>Overview</h1>
+ <section className="psm-state-panel">
+ <div className="psm-state-title">Overview</div>
+ <h1>Choose a ticker to enter the workbench.</h1>
+ <p>Search from the top bar or jump into one of your saved symbols from the sidebar watchlist.</p>
{watchlist.items.length ? (
- <>
- <p>Select a saved ticker to load its company profile, quote, stats, and chart.</p>
- <div className="watch-table">
- {watchlist.items.map((item) => (
- <button key={item.symbol} type="button" onClick={() => onSelect(item.symbol)}>
- <span>{item.symbol}</span>
- <span>{fmtCurrency(item.quote?.price)}</span>
- <span className={deltaClass(item.quote?.change_pct)}>{fmtPct(item.quote?.change_pct, 2, true)}</span>
- </button>
- ))}
- </div>
- </>
- ) : (
- <p>Search for a ticker to begin.</p>
- )}
+ <div className="psm-stack">
+ {watchlist.items.map((item) => (
+ <button key={item.symbol} type="button" className="psm-ghost-action" onClick={() => onSelectTicker(item.symbol)}>
+ {item.symbol}
+ </button>
+ ))}
+ </div>
+ ) : null}
</section>
);
}
+function LoadingOverviewState({ symbol }: { symbol: string }) {
+ return (
+ <div className="psm-loading-shell">
+ <section className="psm-state-panel">
+ <div className="psm-state-title">Loading</div>
+ <h1>{symbol}</h1>
+ <p>Fetching quote, profile, signals, and supporting metrics.</p>
+ </section>
+ <section className="psm-card psm-skeleton" />
+ <section className="psm-card psm-skeleton" />
+ </div>
+ );
+}
+
function InvalidTickerState({ symbol, onClear }: { symbol: string; onClear: () => void }) {
return (
- <section className="error-panel invalid-state">
+ <section className="psm-state-panel">
+ <span className="psm-status-chip invalid">Invalid Ticker</span>
<h1>{symbol}</h1>
- <p>This ticker could not be resolved to usable market data.</p>
- <button type="button" className="ghost-action" onClick={onClear}>
- Back to Search
+ <p>This symbol could not be resolved into usable market data. Try another search or return to the empty workspace.</p>
+ <button type="button" className="psm-ghost-action" onClick={onClear}>
+ Clear Selection
</button>
</section>
);
}
-function Stat({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) {
+function ErrorOverviewState({ message }: { message: string }) {
return (
- <div className="stat">
- <span>{label}</span>
- <strong className={missing ? "missing-value" : undefined}>{missing ? "Unavailable" : value}</strong>
- </div>
+ <section className="psm-state-panel">
+ <span className="psm-status-chip invalid">Data Error</span>
+ <h1>Overview unavailable</h1>
+ <p>{message}</p>
+ </section>
);
}
-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 (
- <div className="detail-panel">
- <div className="section-label">52 Week Range</div>
- {pct === null ? (
- <div className="empty-copy">Range unavailable</div>
- ) : (
- <>
- <div className="range-values">
- <span>{fmtCurrency(low)}</span>
- <strong>{fmtCurrency(price)}</strong>
- <span>{fmtCurrency(high)}</span>
- </div>
- <div className="range-rail">
- <span style={{ width: `${pct}%` }} />
- <i style={{ left: `${pct}%` }} />
- </div>
- </>
- )}
- </div>
+ <section className="psm-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Signals</div>
+ <h2 className="psm-card-title">Readthrough</h2>
+ </div>
+ </div>
+ <div className="psm-signal-grid">
+ {overview.signals.map((signal) => (
+ <article key={signal.key} className={`psm-signal ${signalTone(signal.state)}`}>
+ <span className="psm-signal-key">{signal.key}</span>
+ <span className="psm-signal-value">{signal.value}</span>
+ <span className="psm-signal-copy">{signal.description}</span>
+ </article>
+ ))}
+ </div>
+ </section>
);
}
-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 (
- <div className="detail-panel">
- <div className="section-label">Short Interest</div>
- <div className="mini-grid">
- <Stat label="Short Float" value={fmtPct(short.short_percent_of_float)} missing={short.short_percent_of_float == null} />
- <Stat label="Days Cover" value={fmtNumber(short.short_ratio)} missing={short.short_ratio == null} />
- <Stat label="Shares Short" value={fmtNumber(short.shares_short, 0)} missing={short.shares_short == null} />
- <Stat label="Prior Delta" value={fmtPct(short.shares_short_delta_pct, 1, true)} missing={short.shares_short_delta_pct == null} />
+ <section className="psm-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Data Quality</div>
+ <h2 className="psm-card-title">Coverage</h2>
+ </div>
+ <span className={`psm-status-chip${overview.meta.is_partial ? " partial" : ""}`}>{overview.meta.status}</span>
</div>
- </div>
+ <p className="psm-quality-copy">{availableFieldSummary(overview)}</p>
+ {overview.meta.is_partial ? (
+ <div className="psm-stack">
+ {missingFields.length ? missingFields.slice(0, 8).map((field) => <span key={field} className="psm-field-tag missing">{field}</span>) : null}
+ </div>
+ ) : null}
+ <div className="psm-source-list">
+ {entries.length ? (
+ entries.map(([field, source]) => (
+ <div className="psm-source-row" key={field}>
+ <span className="psm-source-key">{field}</span>
+ <span className="psm-source-value">{source}</span>
+ </div>
+ ))
+ ) : (
+ <div className="psm-source-row">
+ <span className="psm-source-key">Sources</span>
+ <span className="psm-source-value">Unavailable</span>
+ </div>
+ )}
+ </div>
+ </section>
);
}
function ProfileCard({ overview }: { overview: TickerOverview }) {
return (
- <div className="detail-panel">
- <div className="section-label">Company Profile</div>
- <div className="profile-list">
+ <section className="psm-card">
+ <div className="psm-card-head">
<div>
- <span>Sector</span>
- <strong className={!overview.profile.sector ? "missing-value" : undefined}>{overview.profile.sector || "Unavailable"}</strong>
+ <div className="psm-eyebrow">Company Profile</div>
+ <h2 className="psm-card-title">Context</h2>
</div>
- <div>
- <span>Industry</span>
- <strong className={!overview.profile.industry ? "missing-value" : undefined}>{overview.profile.industry || "Unavailable"}</strong>
+ </div>
+ <div className="psm-profile-list">
+ <div className="psm-profile-row">
+ <span className="psm-profile-key">Sector</span>
+ <span className="psm-profile-value">{overview.profile.sector || "Unavailable"}</span>
+ </div>
+ <div className="psm-profile-row">
+ <span className="psm-profile-key">Industry</span>
+ <span className="psm-profile-value">{overview.profile.industry || "Unavailable"}</span>
+ </div>
+ <div className="psm-profile-row">
+ <span className="psm-profile-key">Exchange</span>
+ <span className="psm-profile-value">{overview.profile.exchange || "Unavailable"}</span>
</div>
+ <div className="psm-profile-row">
+ <span className="psm-profile-key">Website</span>
+ <span className="psm-profile-value">
+ {overview.profile.website ? (
+ <a href={overview.profile.website} target="_blank" rel="noreferrer" className="psm-link">
+ {overview.profile.website}
+ </a>
+ ) : (
+ "Unavailable"
+ )}
+ </span>
+ </div>
+ </div>
+ <p className="psm-profile-summary">{overview.profile.summary || "Business summary unavailable."}</p>
+ </section>
+ );
+}
+
+function ShortInterestCard({ overview }: { overview: TickerOverview }) {
+ const short = overview.short_interest;
+ return (
+ <section className="psm-card">
+ <div className="psm-card-head">
<div>
- <span>Website</span>
- {overview.profile.website ? (
- <a href={overview.profile.website} target="_blank" rel="noreferrer">
- {overview.profile.website}
- </a>
- ) : (
- <strong className="missing-value">Unavailable</strong>
- )}
+ <div className="psm-eyebrow">Short Interest</div>
+ <h2 className="psm-card-title">Pressure</h2>
</div>
</div>
- <p className={overview.profile.summary ? "profile-summary" : "profile-summary missing-value"}>
- {overview.profile.summary || "Business summary unavailable."}
- </p>
- </div>
+ <div className="psm-detail-grid">
+ <DetailItem label="Short Float" value={fmtPct(short.short_percent_of_float)} missing={short.short_percent_of_float == null} />
+ <DetailItem label="Days Cover" value={fmtNumber(short.short_ratio)} missing={short.short_ratio == null} />
+ <DetailItem label="Shares Short" value={fmtNumber(short.shares_short, 0)} missing={short.shares_short == null} />
+ <DetailItem label="Prior Delta" value={fmtPct(short.shares_short_delta_pct, 1, true)} missing={short.shares_short_delta_pct == null} />
+ </div>
+ </section>
);
}
-function SourceCard({ overview }: { overview: TickerOverview }) {
- const fields = Object.entries(overview.meta.sources).slice(0, 6);
+function StatsCard({ overview }: { overview: TickerOverview }) {
return (
- <div className="detail-panel">
- <div className="section-label">Data Quality</div>
- <div className="quality-copy">
- Status <strong>{overview.meta.status}</strong>
+ <section className="psm-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Overview Stats</div>
+ <h2 className="psm-card-title">Reference</h2>
+ </div>
</div>
- <div className="profile-list compact-list">
- {fields.length ? (
- fields.map(([field, source]) => (
- <div key={field}>
- <span>{field}</span>
- <strong>{source}</strong>
- </div>
- ))
- ) : (
- <div>
- <span>Sources</span>
- <strong className="missing-value">Unavailable</strong>
- </div>
- )}
+ <div className="psm-stat-list">
+ <StatRow label="Market Cap" value={fmtLarge(overview.stats.market_cap)} missing={overview.stats.market_cap == null} />
+ <StatRow label="P/E TTM" value={fmtNumber(overview.stats.trailing_pe)} missing={overview.stats.trailing_pe == null} />
+ <StatRow label="EPS TTM" value={fmtCurrency(overview.stats.trailing_eps)} missing={overview.stats.trailing_eps == null} />
+ <StatRow label="Volume" value={fmtNumber(overview.stats.volume, 0)} missing={overview.stats.volume == null} />
+ <StatRow label="Avg Volume" value={fmtNumber(overview.stats.average_volume, 0)} missing={overview.stats.average_volume == null} />
+ <StatRow label="Beta" value={fmtNumber(overview.stats.beta)} missing={overview.stats.beta == null} />
</div>
+ </section>
+ );
+}
+
+function DetailItem({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) {
+ return (
+ <article className="psm-detail-item">
+ <span className="psm-stat-label">{label}</span>
+ <span className={`psm-detail-value${missing ? " missing" : ""}`}>{missing ? "Unavailable" : value}</span>
+ </article>
+ );
+}
+
+function StatRow({ label, value, missing = false }: { label: string; value: string; missing?: boolean }) {
+ return (
+ <div className="psm-stat-row">
+ <span className="psm-stat-label">{label}</span>
+ <span className={`psm-stat-value${missing ? " missing" : ""}`}>{missing ? "Unavailable" : value}</span>
</div>
);
}
-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 (
+ <AppShell
+ sidebar={<aside className="psm-side"><div className="psm-brand"><Image className="psm-brand-mark" src="/design-system/logo-monogram.svg" alt="" width={34} height={34} /><div className="psm-brand-copy"><div className="psm-brand-name">Prism</div><div className="psm-brand-sub">Loading</div></div></div></aside>}
+ topbar={<header className="psm-top"><div className="psm-search-form"><span className="psm-icon icon-search" aria-hidden /><span className="psm-muted-copy">Loading Prism…</span></div></header>}
+ >
+ <div className="psm-loading-shell">
+ <section className="psm-card psm-skeleton" />
+ <section className="psm-card psm-skeleton" />
+ </div>
+ </AppShell>
+ );
}
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 <div className="psm-card-empty">No price history available for this range.</div>;
+ }
+
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}<br>$%{y:.2f}<extra></extra>"
}
];
+
const layout: Partial<Layout> = {
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 <div className="empty-panel">No price history available for this period.</div>;
- }
-
return <Plot data={data} layout={layout} config={{ displayModeBar: false, responsive: true }} className="chart" useResizeHandler />;
}
+
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 (
+ <main className="prism-app">
+ {sidebar}
+ <div className="psm-main">
+ {topbar}
+ <section className="psm-content">{children}</section>
+ </div>
+ </main>
+ );
+}
+
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 (
+ <section className="psm-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Price History</div>
+ <h2 className="psm-card-title">{symbol}</h2>
+ </div>
+ <div className="psm-tabs" role="tablist" aria-label="Chart range">
+ {PERIODS.map((option) => (
+ <button
+ key={option.key}
+ type="button"
+ role="tab"
+ className={`psm-tab${period === option.key ? " active" : ""}`}
+ aria-selected={period === option.key}
+ onClick={() => onChangePeriod(option.key)}
+ >
+ {option.label}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ <p className="psm-chart-meta">Chart loading is isolated from the rest of Overview. A history miss only affects this card.</p>
+
+ <div className="psm-chart-frame">
+ {chartState === "loading" ? <div className="psm-card-empty">Loading {period.toUpperCase()} history…</div> : null}
+ {chartState === "error" ? <div className="psm-card-empty psm-error-copy">{chartError || "Could not load chart history."}</div> : null}
+ {chartState === "ready" ? <PriceChart symbol={symbol} points={points} /> : null}
+ </div>
+ </section>
+ );
+}
+
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 (
+ <section className="psm-card psm-kpis" aria-label="Key metrics">
+ {items.map((item) => (
+ <div className="psm-kpi" key={item.key}>
+ <span className="psm-kpi-key">{item.key}</span>
+ <span className={`psm-kpi-value${item.missing ? " missing" : ""}`}>{item.value}</span>
+ <span className="psm-kpi-sub">{item.sublabel}</span>
+ </div>
+ ))}
+ </section>
+ );
+}
+
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 (
+ <aside className="psm-side">
+ <div className="psm-brand">
+ <Image className="psm-brand-mark" src="/design-system/logo-monogram.svg" alt="" width={34} height={34} />
+ <div className="psm-brand-copy">
+ <div className="psm-brand-name">Prism</div>
+ <div className="psm-brand-sub">Market Workbench</div>
+ </div>
+ </div>
+
+ <div className="psm-side-section">
+ <div className="psm-side-label">Workspace</div>
+ </div>
+
+ <nav className="psm-nav" aria-label="Primary">
+ {navItems.map((item) => {
+ const active = item.key === selectedKey;
+ return (
+ <button
+ key={item.key}
+ type="button"
+ className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`}
+ aria-disabled={item.disabled ? "true" : undefined}
+ >
+ <span className={`psm-icon icon-${item.icon}`} aria-hidden />
+ <span className="psm-nav-copy">
+ <span>{item.label}</span>
+ {item.disabled ? <span className="psm-nav-coming">Soon</span> : null}
+ </span>
+ </button>
+ );
+ })}
+ </nav>
+
+ <div className="psm-side-section">
+ <div className="psm-side-label">Watchlist</div>
+ </div>
+
+ <div className="psm-watch">
+ <div className="psm-watch-toolbar">
+ <div className="psm-watch-limit">
+ {watchlist.items.length}/{watchlist.limit}
+ </div>
+ </div>
+
+ {watchlist.items.length === 0 ? <div className="psm-watch-empty">Saved tickers will appear here.</div> : null}
+
+ {watchlist.items.map((item) => {
+ const active = item.symbol === currentTicker;
+ return (
+ <div key={item.symbol} className={`psm-watch-row${active ? " active" : ""}`}>
+ <button type="button" className="psm-watch-select" onClick={() => onSelectTicker(item.symbol)}>
+ <span className="psm-watch-main">
+ <span className="psm-watch-symbol">{item.symbol}</span>
+ <span className="psm-watch-date">{watchlistSubtitle(item)}</span>
+ </span>
+ <span className="psm-watch-price">{fmtCurrency(item.quote?.price)}</span>
+ <span className={`psm-watch-change ${deltaClass(item.quote?.change_pct)}`}>{fmtPct(item.quote?.change_pct, 2, true)}</span>
+ </button>
+ <button
+ type="button"
+ aria-label={`Remove ${item.symbol} from watchlist`}
+ className="psm-watch-remove"
+ onClick={() => onRemoveTicker(item.symbol)}
+ >
+ ×
+ </button>
+ </div>
+ );
+ })}
+
+ {watchlistError ? <p className="psm-muted-copy psm-error-copy">{watchlistError}</p> : null}
+ </div>
+ </aside>
+ );
+}
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 (
+ <header className="psm-ticker-head">
+ <div className="psm-header-left">
+ <span className="psm-identity-line psm-eyebrow">{overview.profile.symbol}</span>
+ <div className="psm-heading-row">
+ <span className="psm-symbol">{overview.profile.symbol}</span>
+ <span className="psm-company-name">{overview.profile.name || "Name unavailable"}</span>
+ {overview.meta.is_partial ? <span className="psm-partial-chip">Partial Data</span> : null}
+ </div>
+ <p className="psm-subline">{buildIdentityLine(overview)}</p>
+ </div>
+
+ <div className="psm-range">
+ <div className="psm-eyebrow">52 Week Range</div>
+ <div className="psm-range-values">
+ <span>{fmtCurrency(overview.range_52w.low)}</span>
+ <span>{fmtCurrency(overview.range_52w.price ?? overview.quote.price)}</span>
+ <span>{fmtCurrency(overview.range_52w.high)}</span>
+ </div>
+ <div className="psm-range-rail" aria-hidden>
+ {pct != null ? <span className="psm-range-indicator" style={{ left: `${pct}%` }} /> : null}
+ </div>
+ </div>
+
+ <div className="psm-price-stack">
+ <span className="psm-price">{fmtCurrency(overview.quote.price)}</span>
+ <span className={`psm-change ${deltaClass(overview.quote.change_pct)}`}>
+ {fmtCurrency(overview.quote.change)} · {fmtPct(overview.quote.change_pct, 2, true)}
+ </span>
+ <span className="psm-quote-line">Prev close {fmtCurrency(overview.quote.prev_close)}</span>
+ <button type="button" className={`psm-primary-action${isSaved ? " subtle" : ""}`} onClick={onToggleWatchlist}>
+ {isSaved ? "Remove From Watchlist" : "Save To Watchlist"}
+ </button>
+ </div>
+ </header>
+ );
+}
+
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<HTMLFormElement>) => void;
+ onSelectTicker: (symbol: string) => void;
+};
+
+export function TopBar({ query, searching, results, marketStatus, onChangeQuery, onSubmit, onSelectTicker }: Props) {
+ const showDropdown = query.trim().length >= 2;
+
+ return (
+ <header className="psm-top">
+ <div className="psm-search-shell">
+ <form className="psm-search-form" onSubmit={onSubmit}>
+ <span className="psm-icon icon-search" aria-hidden />
+ <input value={query} onChange={(event) => onChangeQuery(event.target.value)} placeholder="Search ticker or company" aria-label="Search ticker" />
+ <span className="psm-kbd">Enter</span>
+ </form>
+
+ {showDropdown ? (
+ <div className="psm-search-dropdown">
+ {searching ? <div className="psm-search-status">Searching...</div> : null}
+ {!searching && results.length === 0 ? <div className="psm-search-status">No matches</div> : null}
+ {!searching
+ ? results.map((result) => (
+ <button key={`${result.symbol}-${result.exchange}`} type="button" className="psm-search-result" onClick={() => onSelectTicker(result.symbol)}>
+ <span className="psm-search-result-symbol">{result.symbol}</span>
+ <span className="psm-search-result-copy">
+ {result.name}
+ {result.exchange ? ` · ${result.exchange}` : ""}
+ </span>
+ </button>
+ ))
+ : null}
+ </div>
+ ) : null}
+ </div>
+
+ <div className="psm-clock-group">
+ <div className="psm-market-status">
+ <span className={`psm-market-dot${marketStatus.isOpen ? " open" : ""}`} aria-hidden />
+ <span>{marketStatus.status}</span>
+ <span>{marketStatus.time}</span>
+ </div>
+
+ <div className="psm-account">
+ <span className="psm-icon icon-user" aria-hidden />
+ <span className="psm-account-avatar">T</span>
+ <span>Local Profile</span>
+ </div>
+ </div>
+ </header>
+ );
+}
+
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 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
-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
--- /dev/null
+++ b/frontend/public/design-system/fonts/EBGaramond-Italic-VariableFont_wght.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/EBGaramond-VariableFont_wght.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Italic.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Light.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-LightItalic.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Medium.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-MediumItalic.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-Regular.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBold.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Mono/IBMPlexMono-SemiBoldItalic.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf
Binary files 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
--- /dev/null
+++ b/frontend/public/design-system/fonts/IBM_Plex_Sans/IBMPlexSans-VariableFont_wdth,wght.ttf
Binary files 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
+ <filter id="n">
+ <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"></feTurbulence>
+ <feColorMatrix values="0 0 0 0 0.95&#xA; 0 0 0 0 0.92&#xA; 0 0 0 0 0.86&#xA; 0 0 0 0.06 0"></feColorMatrix>
+ </filter>
+ <rect width="200" height="200" filter="url(#n)"></rect>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M3 19h18"></path>
+ <path d="M5 19V11l3-2 3 2v8"></path>
+ <path d="M13 19V7l3-2 3 2v12"></path>
+ <path d="M5 19V13"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <circle cx="12" cy="12" r="9"></circle>
+ <path d="M12 7v5l3 2"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M5 17l5-5-5-5"></path>
+ <path d="M12 19h7"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M12 3v18"></path>
+ <path d="M17 7H10a3 3 0 100 6h4a3 3 0 110 6H6"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M4 4h8l2 3h6v11a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2z" transform="translate(1 0)"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M4 7h16"></path>
+ <path d="M4 12h16"></path>
+ <path d="M4 17h10"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M3 12h4l3-8 4 16 3-8h4"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <circle cx="11" cy="11" r="6"></circle>
+ <path d="M15.5 15.5L20 20"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <rect x="3" y="4" width="18" height="14" rx="1"></rect>
+ <path d="M3 8h18"></path>
+ <path d="M8 20h8"></path>
+ <path d="M12 18v2"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <circle cx="12" cy="8" r="4"></circle>
+ <path d="M4 21c0-4 4-7 8-7s8 3 8 7"></path>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
+ <rect x="3" y="5" width="18" height="14" rx="1"></rect>
+ <path d="M3 9h18"></path>
+ <circle cx="7" cy="7" r=".5" fill="currentColor"></circle>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
+ <defs>
+ <linearGradient id="brass" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#DCC79E"></stop>
+ <stop offset="55%" stop-color="#C2AA7A"></stop>
+ <stop offset="100%" stop-color="#8F7A50"></stop>
+ </linearGradient>
+ </defs>
+
+ <circle cx="100" cy="100" r="92" fill="none" stroke="url(#brass)" stroke-width="2"></circle>
+ <circle cx="100" cy="100" r="86" fill="none" stroke="url(#brass)" stroke-width="0.75" opacity="0.55"></circle>
+
+ <g fill="url(#brass)" font-family="&#39;EB Garamond&#39;, &#39;Source Serif Pro&#39;, Georgia, serif" font-style="italic" font-weight="500" text-anchor="middle">
+ <text x="100" y="128" font-size="110" letter-spacing="-6">TH</text>
+ </g>
+
+ <g stroke="url(#brass)" stroke-width="1.2">
+ <line x1="100" y1="6" x2="100" y2="14"></line>
+ <line x1="100" y1="186" x2="100" y2="194"></line>
+ <line x1="6" y1="100" x2="14" y2="100"></line>
+ <line x1="186" y1="100" x2="194" y2="100"></line>
+ </g>
+</svg> \ 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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 560 120" width="560" height="120">
+ <defs>
+ <linearGradient id="brass2" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#DCC79E"></stop>
+ <stop offset="55%" stop-color="#C2AA7A"></stop>
+ <stop offset="100%" stop-color="#8F7A50"></stop>
+ </linearGradient>
+ </defs>
+
+ <g transform="translate(60 60)">
+ <circle r="46" fill="none" stroke="url(#brass2)" stroke-width="1.4"></circle>
+ <circle r="42" fill="none" stroke="url(#brass2)" stroke-width="0.5" opacity="0.55"></circle>
+ <text x="0" y="14" font-family="&#39;EB Garamond&#39;,&#39;Source Serif Pro&#39;,Georgia,serif" font-style="italic" font-weight="500" font-size="56" letter-spacing="-3" text-anchor="middle" fill="url(#brass2)">TH</text>
+ </g>
+
+ <g transform="translate(135 60)">
+ <g font-family="&#39;EB Garamond&#39;,Georgia,serif" font-size="36" fill="#F2ECDC">
+ <text x="0" y="-4" font-weight="500">Thuy</text>
+ <text x="78" y="-4" font-weight="400" font-style="italic" fill="#C2AA7A">(Tyler)</text>
+ <text x="186" y="-4" font-weight="500">Hoang</text>
+ </g>
+ <text x="2" y="26" font-family="&#39;IBM Plex Sans&#39;,sans-serif" font-weight="500" font-size="11" fill="#8E8676" letter-spacing="3.2">FINANCE · ANALYTICS · CFA LEVEL I CANDIDATE</text>
+ </g>
+</svg> \ 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;
+}