Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
1@use "../../base/styles/variables" as *;
2@use "../../base/styles/mixins" as *;
3
4/* ===========================================================================
5 Markets dashboard (Phase C): a market-hours banner, the normalized
6 %-vs-S&P-500 day graph, the headline market reads, and the editable
7 session watchlist. The sparkline card styles at the bottom are shared by the
8 watchlist cards and the base stream client's live repaint.
9 ======================================================================== */
10
11.dash {
12 display: flex;
13 flex-direction: column;
14 gap: var(--sp-5);
15}
16
17/* ---------- market-hours banner ---------- */
18.dash-banner {
19 display: flex;
20 align-items: center;
21 gap: var(--sp-2);
22 padding: var(--sp-2) var(--sp-3);
23 border: 1px solid var(--rule);
24 border-radius: var(--radius);
25 background: var(--surface);
26 font-size: var(--fs-sm);
27}
28
29.dash-banner__dot {
30 width: 9px;
31 height: 9px;
32 border-radius: 50%;
33 background: var(--ink-faint);
34 flex: none;
35}
36.dash-banner[data-session="regular"] .dash-banner__dot {
37 background: var(--up);
38 box-shadow: 0 0 0 3px var(--up-soft);
39}
40.dash-banner[data-session="pre"] .dash-banner__dot,
41.dash-banner[data-session="post"] .dash-banner__dot {
42 background: var(--warn);
43 box-shadow: 0 0 0 3px var(--warn-soft);
44}
45
46.dash-banner__label {
47 @include mono;
48 font-weight: 700;
49}
50.dash-banner__sub {
51 color: var(--ink-faint);
52 font-size: var(--fs-xs);
53}
54
55/* ---------- hero day graph ---------- */
56.hero-graph {
57 @include card;
58 padding: var(--sp-3) var(--sp-3) var(--sp-2);
59 display: flex;
60 flex-direction: column;
61 gap: var(--sp-2);
62}
63
64.hero-graph__head {
65 display: flex;
66 align-items: baseline;
67 justify-content: space-between;
68 flex-wrap: wrap;
69 gap: var(--sp-1) var(--sp-3);
70}
71
72.hero-graph__title {
73 @include serif;
74 font-size: var(--fs-lg);
75 font-weight: 600;
76}
77
78.hero-graph__note {
79 font-size: var(--fs-xs);
80 color: var(--ink-faint);
81}
82
83/* The overview + watchlist grids: one mini chart per instrument. One column on
84 a phone, two on a tablet, three on desktop. Explicit columns (rather than
85 auto-fill) so the overview (inside the padded hero card) and the watchlist
86 (full width) both land on the same 3-up on desktop. */
87.overview-grid {
88 position: relative;
89 display: grid;
90 grid-template-columns: 1fr;
91 gap: 11px;
92 min-height: 120px;
93}
94@media (min-width: $bp-md) {
95 .overview-grid {
96 grid-template-columns: repeat(2, minmax(0, 1fr));
97 }
98}
99@media (min-width: $bp-lg) {
100 .overview-grid {
101 grid-template-columns: repeat(3, minmax(0, 1fr));
102 }
103}
104
105/* ---------- loading skeletons ---------- */
106/* Before /api/dashboard answers, the overview grid shows one skeleton card per
107 slot. display: contents makes the wrapper vanish so each skeleton is a real
108 grid item — the grid lays out exactly as it will with live cards, no jump. */
109.ov-skels {
110 display: contents;
111}
112/* Re-assert [hidden] — display:contents otherwise beats the UA rule, so the
113 skeletons would linger behind the drawn charts (cf. the symbol page's
114 .ind-btn[hidden] fix). */
115.ov-skels[hidden] {
116 display: none;
117}
118
119/* One shimmering placeholder block (a name line, a figure line, the chart
120 area). The shimmer is a slow ink wash, not a flashy gradient sweep —
121 restrained enough for the ledger aesthetic. */
122.ov-skel {
123 border-radius: var(--radius-sm);
124 background: rgba(33, 31, 26, 0.07);
125 animation: ov-skel-pulse 1.6s ease-in-out infinite;
126}
127.ov-skel--name {
128 width: 7rem;
129 height: 1rem;
130}
131.ov-skel--num {
132 width: 4.5rem;
133 height: 1rem;
134}
135.ov-skel--chart {
136 animation-delay: 0.2s;
137}
138@keyframes ov-skel-pulse {
139 0%,
140 100% {
141 opacity: 1;
142 }
143 50% {
144 opacity: 0.45;
145 }
146}
147@media (prefers-reduced-motion: reduce) {
148 .ov-skel {
149 animation: none;
150 }
151}
152
153/* A watchlist card's chart mount is server-rendered empty and only fills once
154 hero.js draws into it; until then show the same quiet placeholder wash so
155 the card doesn't read as broken. :empty stops matching the moment the chart
156 library mounts. */
157.ov-card--watch .ov-card__chart:empty {
158 border-radius: var(--radius-sm);
159 background: rgba(33, 31, 26, 0.07);
160 animation: ov-skel-pulse 1.6s ease-in-out infinite;
161}
162@media (prefers-reduced-motion: reduce) {
163 .ov-card--watch .ov-card__chart:empty {
164 animation: none;
165 }
166}
167
168/* ---------- one instrument's chart card (overview + watchlist) ---------- */
169.ov-card {
170 @include card;
171 position: relative;
172 display: flex;
173 flex-direction: column;
174 gap: 10px;
175 padding: 13px 14px 10px;
176 transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;
177}
178
179/* The card's inner stack. Overview cards stack directly; watchlist cards wrap
180 the same stack in a navigating link. */
181.ov-card__link {
182 display: flex;
183 flex-direction: column;
184 gap: 10px;
185 color: inherit;
186}
187
188.ov-card__name {
189 @include serif;
190 font-size: var(--fs-md);
191 font-weight: 600;
192 line-height: 1.15;
193 white-space: nowrap;
194 overflow: hidden;
195 text-overflow: ellipsis;
196}
197.ov-card__sub {
198 font-size: var(--fs-2xs);
199 color: var(--ink-dim);
200 white-space: nowrap;
201 overflow: hidden;
202 text-overflow: ellipsis;
203}
204/* Watchlist card name: ticker bold with the company name trailing small on one
205 line, so the card head matches the overview cards' single-line name. */
206.ov-card__name--watch {
207 display: flex;
208 align-items: baseline;
209 gap: 6px;
210 min-width: 0;
211 color: var(--ink);
212}
213.ov-card__name--watch .ov-card__sub {
214 font-weight: 400;
215 flex: 0 1 auto;
216}
217
218/* Value + % on one baseline-aligned row — the at-a-glance read. */
219.ov-card__nums {
220 display: flex;
221 align-items: baseline;
222 justify-content: space-between;
223 gap: var(--sp-2);
224}
225.ov-card__value {
226 font-size: var(--fs-lg);
227 font-weight: 700;
228 line-height: 1;
229}
230/* The % move as a tinted pill — the card's at-a-glance good/bad signal. */
231.ov-card__chg {
232 font-size: var(--fs-2xs);
233 font-weight: 700;
234 line-height: 1;
235 padding: 3px 7px;
236 border-radius: 999px;
237 background: var(--ink-wash, rgba(33, 31, 26, 0.06));
238 color: var(--ink-dim);
239}
240.ov-card__chg.is-up {
241 color: var(--up);
242 background: var(--up-soft);
243}
244.ov-card__chg.is-down {
245 color: var(--down);
246 background: var(--down-soft);
247}
248
249/* A real value move flashes the card briefly in the move's direction. */
250.ov-card.ov-flash-up {
251 animation: ov-flash-up 0.6s ease-out;
252}
253.ov-card.ov-flash-down {
254 animation: ov-flash-down 0.6s ease-out;
255}
256@keyframes ov-flash-up {
257 0% {
258 box-shadow: inset 0 0 0 2px var(--up);
259 }
260 100% {
261 box-shadow: inset 0 0 0 2px transparent;
262 }
263}
264@keyframes ov-flash-down {
265 0% {
266 box-shadow: inset 0 0 0 2px var(--down);
267 }
268 100% {
269 box-shadow: inset 0 0 0 2px transparent;
270 }
271}
272@media (prefers-reduced-motion: reduce) {
273 .ov-card.ov-flash-up,
274 .ov-card.ov-flash-down {
275 animation: none;
276 }
277}
278
279/* The sparkline mount: a short, full-width SVG of the day's path. */
280.ov-card__chart {
281 position: relative;
282 width: 100%;
283 height: 42px;
284}
285.spark {
286 display: block;
287 width: 100%;
288 height: 100%;
289}
290
291/* ---------- watchlist card extras (link + remove) ---------- */
292.ov-card--watch:hover {
293 border-color: var(--rule-strong);
294 transform: translateY(-2px);
295 box-shadow: var(--lift-hover);
296}
297.ov-card__remove {
298 position: absolute;
299 top: 8px;
300 right: 8px;
301 z-index: 2;
302 width: 22px;
303 height: 22px;
304 display: flex;
305 align-items: center;
306 justify-content: center;
307 font-size: 1.05rem;
308 line-height: 1;
309 color: var(--ink-faint);
310 background: var(--surface);
311 border: 1px solid transparent;
312 border-radius: 50%;
313 cursor: pointer;
314 opacity: 0;
315 transition: opacity 0.12s, color 0.12s, border-color 0.12s;
316}
317.ov-card--watch:hover .ov-card__remove,
318.ov-card__remove:focus-visible {
319 opacity: 1;
320}
321.ov-card__remove:hover {
322 color: var(--down);
323 border-color: var(--down);
324}
325.ov-card__remove:disabled {
326 cursor: default;
327 opacity: 0.4;
328}
329
330
331/* ---------- sector heatmap ("what's moving") ---------- */
332.sectors {
333 margin-top: var(--sp-4);
334}
335.sectors__head {
336 display: flex;
337 align-items: baseline;
338 justify-content: space-between;
339 gap: var(--sp-2);
340 margin-bottom: var(--sp-2);
341}
342.sectors__note {
343 font-size: var(--fs-2xs);
344 color: var(--ink-faint);
345}
346.sectors__grid {
347 display: grid;
348 grid-template-columns: repeat(2, minmax(0, 1fr));
349 gap: 7px;
350 min-height: 2.5em;
351}
352@media (min-width: $bp-sm) {
353 .sectors__grid {
354 /* 11 sector tiles flow into as many columns as fit, so the strip stays one
355 or two tidy rows on desktop without hard-coding a count. */
356 grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
357 }
358}
359.sector-tile {
360 display: flex;
361 flex-direction: column;
362 gap: 2px;
363 padding: 8px 10px;
364 border-radius: var(--radius-sm, 6px);
365 border: 1px solid var(--rule);
366 text-decoration: none;
367 color: var(--ink);
368 transition:
369 transform 0.12s ease,
370 box-shadow 0.12s ease;
371}
372.sector-tile:hover {
373 transform: translateY(-1px);
374 box-shadow: var(--lift-hover);
375 border-color: var(--rule-strong);
376}
377.sector-tile__name {
378 font-size: var(--fs-2xs);
379 font-weight: 600;
380 white-space: nowrap;
381 overflow: hidden;
382 text-overflow: ellipsis;
383}
384.sector-tile__pct {
385 font-size: var(--fs-sm);
386 font-weight: 700;
387 line-height: 1;
388}
389
390/* ---------- headline market reads ---------- */
391.reads-head {
392 display: flex;
393 align-items: center;
394 justify-content: flex-end;
395 gap: var(--sp-2);
396 margin-bottom: calc(-1 * var(--sp-3) + 4px);
397}
398.reads-head__asof {
399 font-size: var(--fs-xs);
400 color: var(--ink-faint);
401}
402.reads-head__refresh {
403 display: inline-flex;
404 align-items: center;
405 gap: 5px;
406 font-size: var(--fs-xs);
407 color: var(--ink-dim);
408}
409.reads-head__spinner {
410 width: 10px;
411 height: 10px;
412 border-radius: 50%;
413 border: 1.5px solid var(--rule-strong);
414 border-top-color: var(--ink-dim);
415 animation: reads-spin 0.7s linear infinite;
416}
417@keyframes reads-spin {
418 to {
419 transform: rotate(360deg);
420 }
421}
422@media (prefers-reduced-motion: reduce) {
423 .reads-head__spinner {
424 animation: none;
425 }
426}
427
428.reads {
429 display: grid;
430 grid-template-columns: repeat(2, minmax(0, 1fr));
431 gap: 11px;
432}
433@media (min-width: $bp-sm) {
434 .reads {
435 // Five reads: the crash-response row (S&P-from-high, VIX, credit) leads, then
436 // market volume + the S&P 50/200 trend.
437 grid-template-columns: repeat(3, minmax(0, 1fr));
438 }
439}
440
441.read {
442 @include card;
443 padding: var(--sp-2) var(--sp-3);
444 display: flex;
445 flex-direction: column;
446 gap: 3px;
447}
448/* The crash-response lead read carries a hairline accent so the at-a-glance
449 "how far from the high" number reads as the headline of the pulse row. */
450.read--lead {
451 border-left: 2px solid var(--rule-strong);
452}
453
454.read__label {
455 @include eyebrow;
456}
457
458.read__value {
459 font-size: var(--fs-lg);
460 font-weight: 700;
461}
462.read__value--text {
463 font-size: var(--fs-sm);
464 font-weight: 600;
465 line-height: 1.25;
466}
467
468.read__sub {
469 font-size: var(--fs-xs);
470 color: var(--ink-dim);
471}
472
473/* tone classes for the VIX / trend reads (semantic, sparing) */
474.read__tone--calm,
475.read__tone--up {
476 color: var(--up);
477}
478.read__tone--steady {
479 color: var(--ink-dim);
480}
481.read__tone--elevated,
482.read__tone--warn {
483 color: var(--warn);
484}
485.read__tone--stressed,
486.read__tone--down {
487 color: var(--down);
488}
489
490/* ---------- watchlist ---------- */
491.watch__head {
492 display: flex;
493 align-items: center;
494 justify-content: space-between;
495 flex-wrap: wrap;
496 gap: var(--sp-2) var(--sp-3);
497}
498
499.watch-add {
500 display: flex;
501 gap: var(--sp-1);
502}
503
504.watch-add__input {
505 width: 14rem;
506 max-width: 48vw;
507 padding: 0.4rem 0.6rem;
508 font: inherit;
509 font-size: var(--fs-sm);
510 color: var(--ink);
511 background: var(--surface);
512 border: 1px solid var(--rule-strong);
513 border-radius: var(--radius-sm);
514}
515.watch-add__input:focus-visible {
516 outline: 2px solid var(--ink);
517 outline-offset: 1px;
518}
519
520.watch-add__btn {
521 @include tap;
522 padding: 0.4rem 0.9rem;
523 font: 500 var(--fs-sm) / 1 inherit;
524 color: var(--surface);
525 background: var(--ink);
526 border: 1px solid var(--ink);
527 border-radius: var(--radius-sm);
528 cursor: pointer;
529}
530.watch-add__btn:disabled {
531 opacity: 0.6;
532 cursor: default;
533}
534
535.watch-msg {
536 margin-top: var(--sp-2);
537 font-size: var(--fs-sm);
538 color: var(--ink-dim);
539}
540.watch-msg.is-error {
541 color: var(--down);
542}
543
544.watch-empty {
545 @include card;
546 padding: var(--sp-4);
547 text-align: center;
548 color: var(--ink-dim);
549 font-size: var(--fs-sm);
550}
551
552/* The watchlist reuses the overview grid + chart cards (`.overview-grid` +
553 `.ov-card--watch`); it only needs the top margin under its header. */
554.watch-grid {
555 margin-top: var(--sp-3);
556}
557
558/* ---------- market movers ---------- */
559.movers {
560 margin-top: var(--sp-5);
561}
562.movers__head {
563 display: flex;
564 align-items: baseline;
565 justify-content: space-between;
566 gap: var(--sp-2);
567 margin-bottom: var(--sp-2);
568}
569.movers__asof {
570 font-size: var(--fs-2xs);
571 color: var(--ink-faint);
572}
573/* "S&P 500" scope chip beside the Market movers title. */
574.movers__scope {
575 margin-left: 6px;
576 font-size: var(--fs-2xs);
577 font-weight: 600;
578 letter-spacing: 0.02em;
579 vertical-align: middle;
580 padding: 2px 7px;
581 border-radius: 999px;
582 background: var(--ink-wash, rgba(33, 31, 26, 0.06));
583 color: var(--ink-dim);
584}
585.movers__cols {
586 display: grid;
587 grid-template-columns: 1fr;
588 gap: var(--sp-3);
589}
590@media (min-width: $bp-sm) {
591 .movers__cols {
592 grid-template-columns: repeat(3, minmax(0, 1fr));
593 }
594}
595.movers__col {
596 @include card;
597 padding: var(--sp-2) var(--sp-3) var(--sp-1);
598}
599.movers__title {
600 @include eyebrow;
601 margin-bottom: 4px;
602}
603.movers__title--up {
604 color: var(--up);
605}
606.movers__title--down {
607 color: var(--down);
608}
609.movers__empty {
610 font-size: var(--fs-xs);
611 color: var(--ink-faint);
612 padding: var(--sp-2) 0;
613}
614.mv-row {
615 display: flex;
616 align-items: center;
617 justify-content: space-between;
618 gap: var(--sp-2);
619 padding: 7px 0;
620 border-top: 1px solid var(--rule);
621 text-decoration: none;
622 color: var(--ink);
623}
624.movers__list .mv-row:first-child {
625 border-top: none;
626}
627.mv-row:hover {
628 color: var(--ink);
629 background: var(--ink-wash, rgba(33, 31, 26, 0.04));
630 margin: 0 calc(-1 * var(--sp-2));
631 padding-left: var(--sp-2);
632 padding-right: var(--sp-2);
633}
634.mv-row__id {
635 display: flex;
636 flex-direction: column;
637 gap: 1px;
638 min-width: 0;
639}
640.mv-row__sym {
641 font-weight: 700;
642 font-size: var(--fs-sm);
643 line-height: 1.1;
644}
645.mv-row__name {
646 font-size: var(--fs-2xs);
647 color: var(--ink-dim);
648 white-space: nowrap;
649 overflow: hidden;
650 text-overflow: ellipsis;
651 max-width: 13ch;
652}
653.mv-row__nums {
654 display: flex;
655 flex-direction: column;
656 align-items: flex-end;
657 gap: 2px;
658 flex: none;
659}
660.mv-row__pct {
661 font-weight: 700;
662 font-size: var(--fs-sm);
663 line-height: 1;
664}
665.mv-row__pct.is-up {
666 color: var(--up);
667}
668.mv-row__pct.is-down {
669 color: var(--down);
670}
671.mv-row__sub {
672 font-size: var(--fs-2xs);
673 color: var(--ink-faint);
674}