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

18.7 KB · 874 lines · SCSS Raw History
  1@use "variables" as *;
  2@use "mixins" as *;
  3
  4/* ---------- runtime theme tokens ---------- */
  5/* "Paper Ledger": an accounting ledger reimagined modern. Warm paper, ink
  6   text, hairline rules, monospace figures. Color is semantic only —
  7   green/amber/red mean good/ok/bad, never decoration. */
  8:root {
  9  --paper:      #f0ece1; /* page background — warm light */
 10  --paper-edge: #e7e1d2; /* topbar/footer wash, slightly deeper */
 11  --surface:    #faf8f1; /* raised cards — a lighter sheet */
 12  --well:       #e6e0d0; /* sunken wells: meter tracks, inputs */
 13
 14  --ink:       #211f1a; /* primary text — warm near-black */
 15  --ink-dim:   #6b6456; /* secondary text */
 16  --ink-faint: #978f7c; /* tertiary text, eyebrows, placeholders */
 17
 18  --rule:        rgba(33, 31, 26, 0.14); /* hairline */
 19  --rule-strong: rgba(33, 31, 26, 0.32); /* emphasised divider */
 20
 21  /* semantic — good / ok / bad. The app's only colours apart from ink:
 22     up = positive / strong, down = negative / weak, warn = caution.
 23     `--ok` / `--bad` are aliases retained because earlier SCSS used them;
 24     they map to up / down so anything still spelling those words renders
 25     in the right hue. */
 26  --up:        #2f7d4f;
 27  --down:      #b23b32;
 28  --warn:      #b3801f;
 29  --up-soft:   rgba(47, 125, 79, 0.14);
 30  --down-soft: rgba(178, 59, 50, 0.14);
 31  --warn-soft: rgba(179, 128, 31, 0.14);
 32  --ok:        var(--up);
 33  --bad:       var(--down);
 34  --ok-soft:   var(--up-soft);
 35  --bad-soft:  var(--down-soft);
 36
 37  --lift:       0 1px 2px rgba(33, 31, 26, 0.05);
 38  --lift-hover: 0 5px 16px rgba(33, 31, 26, 0.11);
 39
 40  --radius:    8px;
 41  --radius-sm: 6px;
 42
 43  /* type scale (rem). Phase 31 tightened ~30 ad-hoc sizes down to this. The
 44     scale is geometric-ish (≈1.18×) so steps read distinct at a glance. */
 45  --fs-2xs:  0.68rem;  /* fine print, table captions, sub-meta */
 46  --fs-xs:   0.76rem;  /* secondary meta, chip captions, table cells */
 47  --fs-sm:   0.84rem;  /* body-small, eyebrows-large, dense lists */
 48  --fs-md:   0.94rem;  /* default body in panels */
 49  --fs-lg:   1.06rem;  /* prominent figures inside a row */
 50  --fs-xl:   1.32rem;  /* card statistic */
 51  --fs-2xl:  1.65rem;  /* section figure */
 52  --fs-3xl:  2.05rem;  /* page-head price / ticker */
 53
 54  /* eyebrow type — three sizes, matching the three places eyebrows ride:
 55     a small pill / badge, a default eyebrow caption, a section-header label */
 56  --eye-chip: 0.56rem;
 57  --eye-cap:  0.64rem;
 58  --eye-lbl:  0.74rem;
 59
 60  /* spacing scale (px). Used for paddings and gaps so vertical rhythm holds
 61     across the whole app rather than drifting per component. */
 62  --sp-1:  4px;
 63  --sp-2:  8px;
 64  --sp-3:  12px;
 65  --sp-4:  16px;
 66  --sp-5:  20px;
 67  --sp-6:  28px;
 68  --sp-7:  40px;
 69
 70  --font-serif: "Source Serif 4", Georgia, "Times New Roman", serif;
 71  --font-ui:    "Inter", system-ui, -apple-system, sans-serif;
 72  --font-mono:  "JetBrains Mono", ui-monospace, "SF Mono", monospace;
 73}
 74
 75/* ---------- reset ---------- */
 76*,
 77*::before,
 78*::after {
 79  box-sizing: border-box;
 80}
 81
 82* {
 83  margin: 0;
 84}
 85
 86html {
 87  -webkit-text-size-adjust: 100%;
 88}
 89
 90body {
 91  font-family: var(--font-ui);
 92  color: var(--ink);
 93  background: var(--paper);
 94  /* flex column so the footer pins to the bottom of the viewport on short
 95     pages (404, empty states) instead of floating up with paper beneath it */
 96  min-height: 100dvh;
 97  display: flex;
 98  flex-direction: column;
 99  line-height: 1.5;
100  -webkit-font-smoothing: antialiased;
101  text-rendering: optimizeLegibility;
102}
103
104h1,
105h2,
106h3 {
107  @include serif;
108  line-height: 1.2;
109}
110
111a {
112  color: var(--ink);
113  text-decoration: none;
114}
115
116a:hover {
117  color: var(--ink-dim);
118}
119
120input,
121button {
122  font-family: inherit;
123  font-size: inherit;
124}
125
126code {
127  font-family: var(--font-mono);
128  font-size: 0.86em;
129  background: var(--well);
130  padding: 0.12em 0.4em;
131  border-radius: 4px;
132}
133
134/* ---------- shared bits ---------- */
135.wrap {
136  max-width: $bp-lg;
137  margin: 0 auto;
138  padding: 20px 16px 0;
139}
140
141.num {
142  @include mono;
143}
144
145/* semantic price-move colors */
146.is-up {
147  color: var(--up);
148}
149
150.is-down {
151  color: var(--down);
152}
153
154.is-flat {
155  color: var(--ink-dim);
156}
157
158.eyebrow {
159  @include eyebrow;
160}
161
162.panel {
163  @include card;
164  padding: 18px;
165}
166
167/* ---------- meter primitives (shared) ---------- */
168/* A sunken rail used for range bars and proportion bars across the app:
169   .track holds an optional left-anchored .track__fill and absolutely
170   positioned .track__pip markers. Reused by later phases. */
171.track {
172  position: relative;
173  background: var(--well);
174  border: 1px solid var(--rule);
175  border-radius: 999px;
176}
177
178.track__fill {
179  position: absolute;
180  top: 0;
181  bottom: 0;
182  left: 0;
183  background: var(--ink-dim);
184  border-radius: 999px;
185}
186
187.track__pip {
188  position: absolute;
189  top: 50%;
190  width: 2px;
191  height: 168%;
192  transform: translate(-50%, -50%);
193  background: var(--ink);
194  border-radius: 1px;
195}
196
197/* a labelled tick: small dot head so a marker reads even on a thin rail */
198.track__pip::before {
199  content: "";
200  position: absolute;
201  left: 50%;
202  top: -5px;
203  width: 9px;
204  height: 9px;
205  transform: translateX(-50%);
206  border-radius: 50%;
207  background: inherit;
208  border: 2px solid var(--surface);
209}
210
211.track__pip--ghost {
212  background: var(--ink-faint);
213}
214
215.track__pip--up {
216  background: var(--up);
217}
218
219.track__pip--down {
220  background: var(--down);
221}
222
223/* ---------- buttons ---------- */
224.btn {
225  @include tap;
226  display: inline-flex;
227  align-items: center;
228  justify-content: center;
229  gap: 7px;
230  padding: 9px 16px;
231  border-radius: var(--radius-sm);
232  border: 1px solid var(--rule-strong);
233  background: var(--surface);
234  color: var(--ink);
235  font-weight: 500;
236  cursor: pointer;
237  transition: border-color 0.15s, background 0.15s;
238}
239
240.btn:hover {
241  border-color: var(--ink);
242  color: var(--ink);
243}
244
245.btn--accent {
246  border-color: var(--ink);
247  background: var(--ink);
248  color: var(--paper);
249  font-weight: 600;
250}
251
252.btn--accent:hover {
253  background: var(--ink-dim);
254  border-color: var(--ink-dim);
255  color: var(--paper);
256}
257
258/* ---------- topbar ---------- */
259.topbar {
260  position: sticky;
261  top: 0;
262  z-index: 50;
263  background: rgba(240, 236, 225, 0.92);
264  backdrop-filter: blur(10px);
265  border-bottom: 1px solid var(--rule-strong);
266}
267
268.topbar__inner {
269  display: flex;
270  flex-wrap: wrap;
271  align-items: center;
272  gap: 10px 14px;
273  max-width: $bp-lg;
274  margin: 0 auto;
275  padding: 11px 16px;
276}
277
278.brand {
279  display: flex;
280  align-items: center;
281  gap: 9px;
282  color: var(--ink);
283}
284
285.brand:hover {
286  color: var(--ink);
287}
288
289.brand svg {
290  width: 27px;
291  height: 27px;
292  color: var(--ink);
293}
294
295.brand__name {
296  @include serif;
297  font-size: 1.14rem;
298  letter-spacing: -0.01em;
299}
300
301.search {
302  order: 3;
303  flex: 1 1 100%;
304}
305
306.search input {
307  width: 100%;
308  @include tap;
309  padding: 0 14px;
310  color: var(--ink);
311  background: var(--surface);
312  border: 1px solid var(--rule-strong);
313  border-radius: var(--radius-sm);
314  outline: none;
315  transition: border-color 0.15s, box-shadow 0.15s;
316}
317
318.search input::placeholder {
319  color: var(--ink-faint);
320}
321
322.search input:focus {
323  border-color: var(--ink);
324  box-shadow: 0 0 0 3px var(--well);
325}
326
327.topnav {
328  display: none;
329  gap: 2px;
330}
331
332.topnav a {
333  padding: 7px 12px;
334  border-radius: var(--radius-sm);
335  color: var(--ink-dim);
336  font-weight: 500;
337  border-bottom: 2px solid transparent;
338}
339
340.topnav a:hover {
341  color: var(--ink);
342  background: var(--paper-edge);
343}
344
345.topnav a.is-active {
346  color: var(--ink);
347  border-bottom-color: var(--ink);
348  border-radius: 0;
349}
350
351/* ---------- status pill ---------- */
352.statuspill {
353  margin-left: auto;
354  display: inline-flex;
355  align-items: center;
356  gap: 8px;
357  padding: 6px 12px;
358  border-radius: 999px;
359  border: 1px solid var(--rule-strong);
360  background: var(--surface);
361  font-size: 0.78rem;
362  color: var(--ink-dim);
363  white-space: nowrap;
364}
365
366.statuspill__dot {
367  width: 8px;
368  height: 8px;
369  border-radius: 50%;
370  background: var(--ink-faint);
371}
372
373.statuspill[data-state="ok"] .statuspill__dot,
374.statuspill[data-state="connected"] .statuspill__dot {
375  background: var(--up);
376}
377
378.statuspill[data-state="fetching"] .statuspill__dot {
379  background: var(--warn);
380  animation: pulse 1.2s ease-in-out infinite;
381}
382
383.statuspill[data-state="stale"] .statuspill__dot {
384  background: var(--warn);
385}
386
387.statuspill[data-state="error"] .statuspill__dot {
388  background: var(--down);
389}
390
391@keyframes pulse {
392  50% {
393    opacity: 0.25;
394  }
395}
396
397/* ---------- bottom nav (phones) ---------- */
398.bottomnav {
399  position: fixed;
400  bottom: 0;
401  left: 0;
402  right: 0;
403  z-index: 50;
404  display: flex;
405  background: rgba(231, 225, 210, 0.97);
406  backdrop-filter: blur(10px);
407  border-top: 1px solid var(--rule-strong);
408  padding-bottom: env(safe-area-inset-bottom);
409}
410
411.bottomnav a {
412  flex: 1;
413  @include tap;
414  display: flex;
415  flex-direction: column;
416  align-items: center;
417  justify-content: center;
418  gap: 3px;
419  padding: 8px 0;
420  color: var(--ink-faint);
421  font-size: 0.68rem;
422  font-weight: 500;
423}
424
425.bottomnav a svg {
426  width: 21px;
427  height: 21px;
428}
429
430.bottomnav a.is-active {
431  color: var(--ink);
432}
433
434/* ---------- main + footer ---------- */
435/* main grows to fill the flex column, pushing the footer to the bottom. */
436main {
437  flex: 1 0 auto;
438}
439
440/* Two-tier footer (Phase 7): a multi-column upper block (About / Pages /
441   Elsewhere) over a slim copyright bar. Mirrors the sibling apps' shape in
442   Paper Ledger tokens — warm-paper wash, hairline rules, `// LABEL` mono
443   column headers. */
444.footer {
445  border-top: 1px solid var(--rule);
446  background: var(--paper-edge);
447  margin-top: 56px;
448  padding-top: 40px;
449  padding-bottom: 36px;
450}
451
452.footer__grid {
453  display: grid;
454  gap: 36px;
455  grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr);
456
457  @media (max-width: 640px) {
458    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
459    gap: 28px 24px;
460  }
461}
462
463.footer__label {
464  font-family: var(--font-mono);
465  font-size: var(--fs-2xs);
466  font-weight: 600;
467  letter-spacing: 0.1em;
468  text-transform: uppercase;
469  color: var(--ink-faint);
470  margin-bottom: 14px;
471}
472
473.footer__about {
474  /* the lede column spans the full width before the link columns wrap under it */
475  @media (max-width: 640px) {
476    grid-column: 1 / -1;
477  }
478
479  p {
480    color: var(--ink-dim);
481    font-size: var(--fs-sm);
482    line-height: 1.65;
483    margin: 0 0 12px;
484    max-width: 52ch;
485  }
486}
487
488.footer__attrib {
489  color: var(--ink-faint);
490  font-size: var(--fs-xs);
491
492  span { color: var(--ink-dim); }
493  strong { color: var(--ink-dim); font-weight: 600; }
494}
495
496.footer__col {
497  ul {
498    list-style: none;
499    margin: 0;
500    padding: 0;
501  }
502
503  li { margin-bottom: 9px; }
504
505  a {
506    color: var(--ink-dim);
507    font-size: var(--fs-sm);
508    text-decoration: none;
509    transition: color 150ms ease;
510
511    &:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 2px; }
512  }
513}
514
515/* Slim copyright bar, like blog/status/analytics/repos. It is the last element
516   before the fixed mobile bottom-nav, so it carries the nav clearance on
517   phones; the desktop block below trims it back. */
518.footer-bar {
519  border-top: 1px solid var(--rule);
520  background: var(--paper-edge);
521  padding: 14px 0 calc(14px + 64px + env(safe-area-inset-bottom));
522  color: var(--ink-faint);
523  font-size: var(--fs-xs);
524}
525
526.footer-bar__row {
527  display: flex;
528  align-items: center;
529  justify-content: space-between;
530  gap: 16px;
531
532  @media (max-width: 480px) {
533    flex-direction: column;
534    gap: 10px;
535    text-align: center;
536  }
537}
538
539.footer-bar__link {
540  display: inline-flex;
541  color: var(--ink-faint);
542  transition: color 150ms ease;
543
544  &:hover { color: var(--ink); }
545}
546
547/* ---------- empty state ---------- */
548.empty {
549  @include card;
550  text-align: center;
551  padding: 56px 24px;
552  margin-top: 28px;
553}
554
555.empty svg {
556  width: 52px;
557  height: 52px;
558  color: var(--ink-faint);
559}
560
561.empty h1 {
562  font-size: 1.5rem;
563  margin-top: 16px;
564}
565
566.empty p {
567  color: var(--ink-dim);
568  max-width: 48ch;
569  margin: 10px auto 0;
570}
571
572.empty p a {
573  text-decoration: underline;
574  text-underline-offset: 2px;
575}
576
577/* Render-error detail block: the raw error from the template engine,
578   shown to the single operator so a broken page is debuggable in place. */
579.empty__detail {
580  margin: 18px auto 0;
581  max-width: 60ch;
582  padding: 10px 14px;
583  background: var(--paper-edge);
584  border: 1px solid var(--rule);
585  border-radius: 4px;
586  font-family: var(--font-mono);
587  font-size: 0.85rem;
588  color: var(--ink-dim);
589  text-align: left;
590  white-space: pre-wrap;
591  word-break: break-word;
592}
593
594/* ---------- page heading ---------- */
595/* The page's h1. Sits above the first section, with a strong rule below.
596   The new `.section-title` is the second-tier heading; the page-head's
597   font-size stays distinctly larger so the hierarchy reads h1 → h2. */
598.page-head {
599  display: flex;
600  align-items: baseline;
601  justify-content: space-between;
602  flex-wrap: wrap;
603  gap: 6px 14px;
604  margin: var(--sp-1) 0 0;
605  padding-bottom: var(--sp-3);
606  border-bottom: 2px solid var(--rule-strong);
607}
608
609.page-head h1 {
610  font-size: var(--fs-2xl);
611}
612
613@media (min-width: $bp-md) {
614  .page-head h1 {
615    font-size: 1.9rem;
616  }
617}
618
619/* ---------- section title ---------- */
620/* A proper readable heading, not the uppercase eyebrow it used to be.
621   The serif label sits left, a small "as of" caption sits right; a
622   double hairline rule below (a ledger column foot) anchors the section.
623   Phase 31: tightened hierarchy so eight stacked sections no longer blur. */
624.section-title {
625  @include serif;
626  display: flex;
627  align-items: baseline;
628  /* wrap is a safety net: if a freshness caption cannot fit beside the rule
629     on a narrow phone it drops to its own line rather than overflowing. */
630  flex-wrap: wrap;
631  gap: 6px 14px;
632  margin: var(--sp-7) 0 var(--sp-3);
633  padding-bottom: 7px;
634  font-size: var(--fs-xl);
635  letter-spacing: -0.01em;
636  border-bottom: 1px solid var(--rule-strong);
637  /* the second hairline of the ledger "double underline", echoing the
638     brand mark. Sits 3px below the first. */
639  box-shadow: 0 3px 0 -2px var(--rule);
640}
641
642/* A quiet data-freshness caption riding at the right end of a section title.
643   margin-left:auto pushes it past the heading text. */
644.section-title__asof {
645  margin-left: auto;
646  flex: none;
647  font-family: var(--font-ui);
648  font-weight: 400;
649  font-size: var(--fs-xs);
650  letter-spacing: normal;
651  color: var(--ink-faint);
652  white-space: nowrap;
653}
654
655/* a link inside the asof slot (e.g. the Top picks "Backtest these →" CTA)
656   reads as a quiet annotation, not a body link. Hover ramps to ink. */
657.section-title__asof a {
658  color: var(--ink-dim);
659}
660
661.section-title__asof a:hover {
662  color: var(--ink);
663  text-decoration: underline;
664  text-underline-offset: 2px;
665}
666
667/* ---------- ticker grid + card ---------- */
668.ticker-grid {
669  display: grid;
670  grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
671  gap: 11px;
672}
673
674.ticker-card {
675  @include card;
676  display: flex;
677  flex-direction: column;
678  gap: 8px;
679  padding: 13px 14px;
680  color: var(--ink);
681  transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;
682}
683
684.ticker-card:hover {
685  color: var(--ink);
686  border-color: var(--rule-strong);
687  transform: translateY(-2px);
688  box-shadow: var(--lift-hover);
689}
690
691.ticker-card__head {
692  display: flex;
693  align-items: center;
694  justify-content: space-between;
695  gap: 8px;
696}
697
698.ticker-card__sym {
699  @include mono;
700  font-weight: 700;
701  font-size: 1rem;
702}
703
704/* the standing badge and the kind eyebrow, grouped at the head's right edge */
705.ticker-card__tags {
706  display: flex;
707  align-items: center;
708  gap: 6px;
709}
710
711.ticker-card__kind {
712  @include eyebrow;
713  font-size: 0.55rem;
714}
715
716.ticker-card__name {
717  font-size: 0.79rem;
718  color: var(--ink-dim);
719  white-space: nowrap;
720  overflow: hidden;
721  text-overflow: ellipsis;
722}
723
724.ticker-card__foot {
725  display: flex;
726  align-items: baseline;
727  justify-content: space-between;
728  gap: 8px;
729  margin-top: auto;
730  padding-top: 8px;
731  border-top: 1px solid var(--rule);
732}
733
734.ticker-card__price {
735  font-size: 0.96rem;
736  font-weight: 600;
737}
738
739.ticker-card__chg {
740  font-size: 0.81rem;
741  font-weight: 600;
742}
743
744/* update flash, toggled by the live stream client (Phase 5) */
745.ticker-card.flash-up {
746  animation: flash-up 0.7s ease-out;
747}
748
749.ticker-card.flash-down {
750  animation: flash-down 0.7s ease-out;
751}
752
753@keyframes flash-up {
754  0% {
755    background: var(--up-soft);
756  }
757  100% {
758    background: var(--surface);
759  }
760}
761
762@keyframes flash-down {
763  0% {
764    background: var(--down-soft);
765  }
766  100% {
767    background: var(--surface);
768  }
769}
770
771/* ---------- verdict badge (Phase 20) ---------- */
772/* The rolled-up strong / fair / weak company standing, shown as one pill
773   across the app: on a ticker card, a mover row, a strongest / weakest row,
774   and above the symbol page's ratio cards. Carries the semantic colour, like
775   the per-ratio .ratio__badge it sits alongside. */
776.vbadge {
777  @include eyebrow;
778  flex: none;
779  padding: 3px 7px;
780  border-radius: 999px;
781  font-size: 0.52rem;
782  white-space: nowrap;
783}
784
785.vbadge--good {
786  color: var(--up);
787  background: var(--up-soft);
788}
789
790.vbadge--ok {
791  color: var(--warn);
792  background: var(--warn-soft);
793}
794
795.vbadge--bad {
796  color: var(--down);
797  background: var(--down-soft);
798}
799
800/* ---------- intro note under a section title ---------- */
801/* A quiet ink-dim line, scoped wider than a chip caption. Used on the home
802   panels and the symbol-page Stock health panel (Phase 17) to introduce a
803   section in one short sentence without crowding the title. */
804.section-note {
805  margin: var(--sp-1) 0 var(--sp-3);
806  max-width: 68ch;
807  color: var(--ink-dim);
808  font-size: var(--fs-sm);
809  line-height: 1.55;
810}
811
812/* ---------- disclaimer line ---------- */
813/* A quiet ink-faint line used on the home quality leaderboard. Always smaller
814   than body text; never carries a semantic colour — the read is explicitly
815   "not investment advice". */
816.disclaimer {
817  margin: calc(var(--sp-2) * -1) 0 var(--sp-3);
818  font-size: var(--fs-xs);
819  color: var(--ink-faint);
820  font-style: italic;
821  line-height: 1.5;
822}
823
824/* ---------- focus-visible ring (Phase 31) ---------- */
825/* A single quiet keyboard-focus ring across the app: a thin ink outline
826   plus a faint warm halo, so tab navigation lands legibly on every link,
827   button, and input without needing per-component focus rules. */
828:focus-visible {
829  outline: 2px solid var(--ink);
830  outline-offset: 2px;
831  border-radius: var(--radius-sm);
832}
833
834/* inputs already carry their own focus border ring; suppress the global
835   outline there so it doesn't double up */
836input:focus-visible,
837button:focus-visible {
838  outline-offset: 1px;
839}
840
841/* ---------- desktop layering ---------- */
842@media (min-width: $bp-md) {
843  .search {
844    order: 0;
845    flex: 1 1 auto;
846    max-width: 420px;
847    margin: 0 6px;
848  }
849
850  .topnav {
851    display: flex;
852  }
853
854  .bottomnav {
855    display: none;
856  }
857
858  /* no bottom nav on desktop, so the copyright bar needs no clearance for it */
859  .footer-bar {
860    padding-bottom: 14px;
861  }
862
863  .wrap {
864    padding-top: 26px;
865  }
866}
867
868@media (prefers-reduced-motion: reduce) {
869  * {
870    animation-duration: 0.01ms !important;
871    transition-duration: 0.01ms !important;
872  }
873}