14.5 KB
raw
@use "../../base/styles/variables" as *;
@use "../../base/styles/mixins" as *;
/* ===========================================================================
Markets dashboard (Phase C): a market-hours banner, the normalized
%-vs-S&P-500 day graph, the headline market reads, and the editable
session watchlist. The sparkline card styles at the bottom are shared by the
watchlist cards and the base stream client's live repaint.
======================================================================== */
.dash {
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
/* ---------- market-hours banner ---------- */
.dash-banner {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--surface);
font-size: var(--fs-sm);
}
.dash-banner__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ink-faint);
flex: none;
}
.dash-banner[data-session="regular"] .dash-banner__dot {
background: var(--up);
box-shadow: 0 0 0 3px var(--up-soft);
}
.dash-banner[data-session="pre"] .dash-banner__dot,
.dash-banner[data-session="post"] .dash-banner__dot {
background: var(--warn);
box-shadow: 0 0 0 3px var(--warn-soft);
}
.dash-banner__label {
@include mono;
font-weight: 700;
}
.dash-banner__sub {
color: var(--ink-faint);
font-size: var(--fs-xs);
}
/* ---------- hero day graph ---------- */
.hero-graph {
@include card;
padding: var(--sp-3) var(--sp-3) var(--sp-2);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.hero-graph__head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-3);
}
.hero-graph__title {
@include serif;
font-size: var(--fs-lg);
font-weight: 600;
}
.hero-graph__note {
font-size: var(--fs-xs);
color: var(--ink-faint);
}
/* The overview + watchlist grids: one mini chart per instrument. One column on
a phone, two on a tablet, three on desktop. Explicit columns (rather than
auto-fill) so the overview (inside the padded hero card) and the watchlist
(full width) both land on the same 3-up on desktop. */
.overview-grid {
position: relative;
display: grid;
grid-template-columns: 1fr;
gap: 11px;
min-height: 120px;
}
@media (min-width: $bp-md) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: $bp-lg) {
.overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* ---------- loading skeletons ---------- */
/* Before /api/dashboard answers, the overview grid shows one skeleton card per
slot. display: contents makes the wrapper vanish so each skeleton is a real
grid item — the grid lays out exactly as it will with live cards, no jump. */
.ov-skels {
display: contents;
}
/* Re-assert [hidden] — display:contents otherwise beats the UA rule, so the
skeletons would linger behind the drawn charts (cf. the symbol page's
.ind-btn[hidden] fix). */
.ov-skels[hidden] {
display: none;
}
/* One shimmering placeholder block (a name line, a figure line, the chart
area). The shimmer is a slow ink wash, not a flashy gradient sweep —
restrained enough for the ledger aesthetic. */
.ov-skel {
border-radius: var(--radius-sm);
background: rgba(33, 31, 26, 0.07);
animation: ov-skel-pulse 1.6s ease-in-out infinite;
}
.ov-skel--name {
width: 7rem;
height: 1rem;
}
.ov-skel--num {
width: 4.5rem;
height: 1rem;
}
.ov-skel--chart {
animation-delay: 0.2s;
}
@keyframes ov-skel-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.ov-skel {
animation: none;
}
}
/* A watchlist card's chart mount is server-rendered empty and only fills once
hero.js draws into it; until then show the same quiet placeholder wash so
the card doesn't read as broken. :empty stops matching the moment the chart
library mounts. */
.ov-card--watch .ov-card__chart:empty {
border-radius: var(--radius-sm);
background: rgba(33, 31, 26, 0.07);
animation: ov-skel-pulse 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.ov-card--watch .ov-card__chart:empty {
animation: none;
}
}
/* ---------- one instrument's chart card (overview + watchlist) ---------- */
.ov-card {
@include card;
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 13px 14px 10px;
transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;
}
/* The card's inner stack. Overview cards stack directly; watchlist cards wrap
the same stack in a navigating link. */
.ov-card__link {
display: flex;
flex-direction: column;
gap: 10px;
color: inherit;
}
.ov-card__name {
@include serif;
font-size: var(--fs-md);
font-weight: 600;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ov-card__sub {
font-size: var(--fs-2xs);
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Watchlist card name: ticker bold with the company name trailing small on one
line, so the card head matches the overview cards' single-line name. */
.ov-card__name--watch {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
color: var(--ink);
}
.ov-card__name--watch .ov-card__sub {
font-weight: 400;
flex: 0 1 auto;
}
/* Value + % on one baseline-aligned row — the at-a-glance read. */
.ov-card__nums {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-2);
}
.ov-card__value {
font-size: var(--fs-lg);
font-weight: 700;
line-height: 1;
}
/* The % move as a tinted pill — the card's at-a-glance good/bad signal. */
.ov-card__chg {
font-size: var(--fs-2xs);
font-weight: 700;
line-height: 1;
padding: 3px 7px;
border-radius: 999px;
background: var(--ink-wash, rgba(33, 31, 26, 0.06));
color: var(--ink-dim);
}
.ov-card__chg.is-up {
color: var(--up);
background: var(--up-soft);
}
.ov-card__chg.is-down {
color: var(--down);
background: var(--down-soft);
}
/* A real value move flashes the card briefly in the move's direction. */
.ov-card.ov-flash-up {
animation: ov-flash-up 0.6s ease-out;
}
.ov-card.ov-flash-down {
animation: ov-flash-down 0.6s ease-out;
}
@keyframes ov-flash-up {
0% {
box-shadow: inset 0 0 0 2px var(--up);
}
100% {
box-shadow: inset 0 0 0 2px transparent;
}
}
@keyframes ov-flash-down {
0% {
box-shadow: inset 0 0 0 2px var(--down);
}
100% {
box-shadow: inset 0 0 0 2px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.ov-card.ov-flash-up,
.ov-card.ov-flash-down {
animation: none;
}
}
/* The sparkline mount: a short, full-width SVG of the day's path. */
.ov-card__chart {
position: relative;
width: 100%;
height: 42px;
}
.spark {
display: block;
width: 100%;
height: 100%;
}
/* ---------- watchlist card extras (link + remove) ---------- */
.ov-card--watch:hover {
border-color: var(--rule-strong);
transform: translateY(-2px);
box-shadow: var(--lift-hover);
}
.ov-card__remove {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.05rem;
line-height: 1;
color: var(--ink-faint);
background: var(--surface);
border: 1px solid transparent;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s, color 0.12s, border-color 0.12s;
}
.ov-card--watch:hover .ov-card__remove,
.ov-card__remove:focus-visible {
opacity: 1;
}
.ov-card__remove:hover {
color: var(--down);
border-color: var(--down);
}
.ov-card__remove:disabled {
cursor: default;
opacity: 0.4;
}
/* ---------- sector heatmap ("what's moving") ---------- */
.sectors {
margin-top: var(--sp-4);
}
.sectors__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.sectors__note {
font-size: var(--fs-2xs);
color: var(--ink-faint);
}
.sectors__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
min-height: 2.5em;
}
@media (min-width: $bp-sm) {
.sectors__grid {
/* 11 sector tiles flow into as many columns as fit, so the strip stays one
or two tidy rows on desktop without hard-coding a count. */
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
}
}
.sector-tile {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--rule);
text-decoration: none;
color: var(--ink);
transition:
transform 0.12s ease,
box-shadow 0.12s ease;
}
.sector-tile:hover {
transform: translateY(-1px);
box-shadow: var(--lift-hover);
border-color: var(--rule-strong);
}
.sector-tile__name {
font-size: var(--fs-2xs);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sector-tile__pct {
font-size: var(--fs-sm);
font-weight: 700;
line-height: 1;
}
/* ---------- headline market reads ---------- */
.reads-head {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--sp-2);
margin-bottom: calc(-1 * var(--sp-3) + 4px);
}
.reads-head__asof {
font-size: var(--fs-xs);
color: var(--ink-faint);
}
.reads-head__refresh {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--fs-xs);
color: var(--ink-dim);
}
.reads-head__spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--rule-strong);
border-top-color: var(--ink-dim);
animation: reads-spin 0.7s linear infinite;
}
@keyframes reads-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.reads-head__spinner {
animation: none;
}
}
.reads {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 11px;
}
@media (min-width: $bp-sm) {
.reads {
// Five reads: the crash-response row (S&P-from-high, VIX, credit) leads, then
// market volume + the S&P 50/200 trend.
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.read {
@include card;
padding: var(--sp-2) var(--sp-3);
display: flex;
flex-direction: column;
gap: 3px;
}
/* The crash-response lead read carries a hairline accent so the at-a-glance
"how far from the high" number reads as the headline of the pulse row. */
.read--lead {
border-left: 2px solid var(--rule-strong);
}
.read__label {
@include eyebrow;
}
.read__value {
font-size: var(--fs-lg);
font-weight: 700;
}
.read__value--text {
font-size: var(--fs-sm);
font-weight: 600;
line-height: 1.25;
}
.read__sub {
font-size: var(--fs-xs);
color: var(--ink-dim);
}
/* tone classes for the VIX / trend reads (semantic, sparing) */
.read__tone--calm,
.read__tone--up {
color: var(--up);
}
.read__tone--steady {
color: var(--ink-dim);
}
.read__tone--elevated,
.read__tone--warn {
color: var(--warn);
}
.read__tone--stressed,
.read__tone--down {
color: var(--down);
}
/* ---------- watchlist ---------- */
.watch__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-3);
}
.watch-add {
display: flex;
gap: var(--sp-1);
}
.watch-add__input {
width: 14rem;
max-width: 48vw;
padding: 0.4rem 0.6rem;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
background: var(--surface);
border: 1px solid var(--rule-strong);
border-radius: var(--radius-sm);
}
.watch-add__input:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 1px;
}
.watch-add__btn {
@include tap;
padding: 0.4rem 0.9rem;
font: 500 var(--fs-sm) / 1 inherit;
color: var(--surface);
background: var(--ink);
border: 1px solid var(--ink);
border-radius: var(--radius-sm);
cursor: pointer;
}
.watch-add__btn:disabled {
opacity: 0.6;
cursor: default;
}
.watch-msg {
margin-top: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-dim);
}
.watch-msg.is-error {
color: var(--down);
}
.watch-empty {
@include card;
padding: var(--sp-4);
text-align: center;
color: var(--ink-dim);
font-size: var(--fs-sm);
}
/* The watchlist reuses the overview grid + chart cards (`.overview-grid` +
`.ov-card--watch`); it only needs the top margin under its header. */
.watch-grid {
margin-top: var(--sp-3);
}
/* ---------- market movers ---------- */
.movers {
margin-top: var(--sp-5);
}
.movers__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.movers__asof {
font-size: var(--fs-2xs);
color: var(--ink-faint);
}
/* "S&P 500" scope chip beside the Market movers title. */
.movers__scope {
margin-left: 6px;
font-size: var(--fs-2xs);
font-weight: 600;
letter-spacing: 0.02em;
vertical-align: middle;
padding: 2px 7px;
border-radius: 999px;
background: var(--ink-wash, rgba(33, 31, 26, 0.06));
color: var(--ink-dim);
}
.movers__cols {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-3);
}
@media (min-width: $bp-sm) {
.movers__cols {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.movers__col {
@include card;
padding: var(--sp-2) var(--sp-3) var(--sp-1);
}
.movers__title {
@include eyebrow;
margin-bottom: 4px;
}
.movers__title--up {
color: var(--up);
}
.movers__title--down {
color: var(--down);
}
.movers__empty {
font-size: var(--fs-xs);
color: var(--ink-faint);
padding: var(--sp-2) 0;
}
.mv-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: 7px 0;
border-top: 1px solid var(--rule);
text-decoration: none;
color: var(--ink);
}
.movers__list .mv-row:first-child {
border-top: none;
}
.mv-row:hover {
color: var(--ink);
background: var(--ink-wash, rgba(33, 31, 26, 0.04));
margin: 0 calc(-1 * var(--sp-2));
padding-left: var(--sp-2);
padding-right: var(--sp-2);
}
.mv-row__id {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.mv-row__sym {
font-weight: 700;
font-size: var(--fs-sm);
line-height: 1.1;
}
.mv-row__name {
font-size: var(--fs-2xs);
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 13ch;
}
.mv-row__nums {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex: none;
}
.mv-row__pct {
font-weight: 700;
font-size: var(--fs-sm);
line-height: 1;
}
.mv-row__pct.is-up {
color: var(--up);
}
.mv-row__pct.is-down {
color: var(--down);
}
.mv-row__sub {
font-size: var(--fs-2xs);
color: var(--ink-faint);
}