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 "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}