diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 13:07:40 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-17 13:07:40 -0700 |
| commit | 62bdd79b3473262dde5fb0a90eab34fe7bf344fd (patch) | |
| tree | 84f75baf7503e1df77c8335750650a72b088468a /frontend/app | |
| parent | 1482422f2f5b236cdcdff4429ae06bb55dca4083 (diff) | |
'UI Shell and General Architecture'
Diffstat (limited to 'frontend/app')
| -rw-r--r-- | frontend/app/design-tokens.css | 206 | ||||
| -rw-r--r-- | frontend/app/globals.css | 647 | ||||
| -rw-r--r-- | frontend/app/page.tsx | 581 | ||||
| -rw-r--r-- | frontend/app/prism-shell.css | 1004 |
4 files changed, 1508 insertions, 930 deletions
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; + } +} |
