repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

14.5 KB · 675 lines · SCSS Raw History
  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}