18.7 KB
raw
@use "variables" as *;
@use "mixins" as *;
/* ---------- runtime theme tokens ---------- */
/* "Paper Ledger": an accounting ledger reimagined modern. Warm paper, ink
text, hairline rules, monospace figures. Color is semantic only —
green/amber/red mean good/ok/bad, never decoration. */
:root {
--paper: #f0ece1; /* page background — warm light */
--paper-edge: #e7e1d2; /* topbar/footer wash, slightly deeper */
--surface: #faf8f1; /* raised cards — a lighter sheet */
--well: #e6e0d0; /* sunken wells: meter tracks, inputs */
--ink: #211f1a; /* primary text — warm near-black */
--ink-dim: #6b6456; /* secondary text */
--ink-faint: #978f7c; /* tertiary text, eyebrows, placeholders */
--rule: rgba(33, 31, 26, 0.14); /* hairline */
--rule-strong: rgba(33, 31, 26, 0.32); /* emphasised divider */
/* semantic — good / ok / bad. The app's only colours apart from ink:
up = positive / strong, down = negative / weak, warn = caution.
`--ok` / `--bad` are aliases retained because earlier SCSS used them;
they map to up / down so anything still spelling those words renders
in the right hue. */
--up: #2f7d4f;
--down: #b23b32;
--warn: #b3801f;
--up-soft: rgba(47, 125, 79, 0.14);
--down-soft: rgba(178, 59, 50, 0.14);
--warn-soft: rgba(179, 128, 31, 0.14);
--ok: var(--up);
--bad: var(--down);
--ok-soft: var(--up-soft);
--bad-soft: var(--down-soft);
--lift: 0 1px 2px rgba(33, 31, 26, 0.05);
--lift-hover: 0 5px 16px rgba(33, 31, 26, 0.11);
--radius: 8px;
--radius-sm: 6px;
/* type scale (rem). Phase 31 tightened ~30 ad-hoc sizes down to this. The
scale is geometric-ish (≈1.18×) so steps read distinct at a glance. */
--fs-2xs: 0.68rem; /* fine print, table captions, sub-meta */
--fs-xs: 0.76rem; /* secondary meta, chip captions, table cells */
--fs-sm: 0.84rem; /* body-small, eyebrows-large, dense lists */
--fs-md: 0.94rem; /* default body in panels */
--fs-lg: 1.06rem; /* prominent figures inside a row */
--fs-xl: 1.32rem; /* card statistic */
--fs-2xl: 1.65rem; /* section figure */
--fs-3xl: 2.05rem; /* page-head price / ticker */
/* eyebrow type — three sizes, matching the three places eyebrows ride:
a small pill / badge, a default eyebrow caption, a section-header label */
--eye-chip: 0.56rem;
--eye-cap: 0.64rem;
--eye-lbl: 0.74rem;
/* spacing scale (px). Used for paddings and gaps so vertical rhythm holds
across the whole app rather than drifting per component. */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 28px;
--sp-7: 40px;
--font-serif: "Source Serif 4", Georgia, "Times New Roman", serif;
--font-ui: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
}
/* ---------- reset ---------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-ui);
color: var(--ink);
background: var(--paper);
/* flex column so the footer pins to the bottom of the viewport on short
pages (404, empty states) instead of floating up with paper beneath it */
min-height: 100dvh;
display: flex;
flex-direction: column;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3 {
@include serif;
line-height: 1.2;
}
a {
color: var(--ink);
text-decoration: none;
}
a:hover {
color: var(--ink-dim);
}
input,
button {
font-family: inherit;
font-size: inherit;
}
code {
font-family: var(--font-mono);
font-size: 0.86em;
background: var(--well);
padding: 0.12em 0.4em;
border-radius: 4px;
}
/* ---------- shared bits ---------- */
.wrap {
max-width: $bp-lg;
margin: 0 auto;
padding: 20px 16px 0;
}
.num {
@include mono;
}
/* semantic price-move colors */
.is-up {
color: var(--up);
}
.is-down {
color: var(--down);
}
.is-flat {
color: var(--ink-dim);
}
.eyebrow {
@include eyebrow;
}
.panel {
@include card;
padding: 18px;
}
/* ---------- meter primitives (shared) ---------- */
/* A sunken rail used for range bars and proportion bars across the app:
.track holds an optional left-anchored .track__fill and absolutely
positioned .track__pip markers. Reused by later phases. */
.track {
position: relative;
background: var(--well);
border: 1px solid var(--rule);
border-radius: 999px;
}
.track__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
background: var(--ink-dim);
border-radius: 999px;
}
.track__pip {
position: absolute;
top: 50%;
width: 2px;
height: 168%;
transform: translate(-50%, -50%);
background: var(--ink);
border-radius: 1px;
}
/* a labelled tick: small dot head so a marker reads even on a thin rail */
.track__pip::before {
content: "";
position: absolute;
left: 50%;
top: -5px;
width: 9px;
height: 9px;
transform: translateX(-50%);
border-radius: 50%;
background: inherit;
border: 2px solid var(--surface);
}
.track__pip--ghost {
background: var(--ink-faint);
}
.track__pip--up {
background: var(--up);
}
.track__pip--down {
background: var(--down);
}
/* ---------- buttons ---------- */
.btn {
@include tap;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--rule-strong);
background: var(--surface);
color: var(--ink);
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.btn:hover {
border-color: var(--ink);
color: var(--ink);
}
.btn--accent {
border-color: var(--ink);
background: var(--ink);
color: var(--paper);
font-weight: 600;
}
.btn--accent:hover {
background: var(--ink-dim);
border-color: var(--ink-dim);
color: var(--paper);
}
/* ---------- topbar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(240, 236, 225, 0.92);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--rule-strong);
}
.topbar__inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
max-width: $bp-lg;
margin: 0 auto;
padding: 11px 16px;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--ink);
}
.brand:hover {
color: var(--ink);
}
.brand svg {
width: 27px;
height: 27px;
color: var(--ink);
}
.brand__name {
@include serif;
font-size: 1.14rem;
letter-spacing: -0.01em;
}
.search {
order: 3;
flex: 1 1 100%;
}
.search input {
width: 100%;
@include tap;
padding: 0 14px;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--rule-strong);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search input::placeholder {
color: var(--ink-faint);
}
.search input:focus {
border-color: var(--ink);
box-shadow: 0 0 0 3px var(--well);
}
.topnav {
display: none;
gap: 2px;
}
.topnav a {
padding: 7px 12px;
border-radius: var(--radius-sm);
color: var(--ink-dim);
font-weight: 500;
border-bottom: 2px solid transparent;
}
.topnav a:hover {
color: var(--ink);
background: var(--paper-edge);
}
.topnav a.is-active {
color: var(--ink);
border-bottom-color: var(--ink);
border-radius: 0;
}
/* ---------- status pill ---------- */
.statuspill {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--rule-strong);
background: var(--surface);
font-size: 0.78rem;
color: var(--ink-dim);
white-space: nowrap;
}
.statuspill__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink-faint);
}
.statuspill[data-state="ok"] .statuspill__dot,
.statuspill[data-state="connected"] .statuspill__dot {
background: var(--up);
}
.statuspill[data-state="fetching"] .statuspill__dot {
background: var(--warn);
animation: pulse 1.2s ease-in-out infinite;
}
.statuspill[data-state="stale"] .statuspill__dot {
background: var(--warn);
}
.statuspill[data-state="error"] .statuspill__dot {
background: var(--down);
}
@keyframes pulse {
50% {
opacity: 0.25;
}
}
/* ---------- bottom nav (phones) ---------- */
.bottomnav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
display: flex;
background: rgba(231, 225, 210, 0.97);
backdrop-filter: blur(10px);
border-top: 1px solid var(--rule-strong);
padding-bottom: env(safe-area-inset-bottom);
}
.bottomnav a {
flex: 1;
@include tap;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 8px 0;
color: var(--ink-faint);
font-size: 0.68rem;
font-weight: 500;
}
.bottomnav a svg {
width: 21px;
height: 21px;
}
.bottomnav a.is-active {
color: var(--ink);
}
/* ---------- main + footer ---------- */
/* main grows to fill the flex column, pushing the footer to the bottom. */
main {
flex: 1 0 auto;
}
/* Two-tier footer (Phase 7): a multi-column upper block (About / Pages /
Elsewhere) over a slim copyright bar. Mirrors the sibling apps' shape in
Paper Ledger tokens — warm-paper wash, hairline rules, `// LABEL` mono
column headers. */
.footer {
border-top: 1px solid var(--rule);
background: var(--paper-edge);
margin-top: 56px;
padding-top: 40px;
padding-bottom: 36px;
}
.footer__grid {
display: grid;
gap: 36px;
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr);
@media (max-width: 640px) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 28px 24px;
}
}
.footer__label {
font-family: var(--font-mono);
font-size: var(--fs-2xs);
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-faint);
margin-bottom: 14px;
}
.footer__about {
/* the lede column spans the full width before the link columns wrap under it */
@media (max-width: 640px) {
grid-column: 1 / -1;
}
p {
color: var(--ink-dim);
font-size: var(--fs-sm);
line-height: 1.65;
margin: 0 0 12px;
max-width: 52ch;
}
}
.footer__attrib {
color: var(--ink-faint);
font-size: var(--fs-xs);
span { color: var(--ink-dim); }
strong { color: var(--ink-dim); font-weight: 600; }
}
.footer__col {
ul {
list-style: none;
margin: 0;
padding: 0;
}
li { margin-bottom: 9px; }
a {
color: var(--ink-dim);
font-size: var(--fs-sm);
text-decoration: none;
transition: color 150ms ease;
&:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 2px; }
}
}
/* Slim copyright bar, like blog/status/analytics/repos. It is the last element
before the fixed mobile bottom-nav, so it carries the nav clearance on
phones; the desktop block below trims it back. */
.footer-bar {
border-top: 1px solid var(--rule);
background: var(--paper-edge);
padding: 14px 0 calc(14px + 64px + env(safe-area-inset-bottom));
color: var(--ink-faint);
font-size: var(--fs-xs);
}
.footer-bar__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
@media (max-width: 480px) {
flex-direction: column;
gap: 10px;
text-align: center;
}
}
.footer-bar__link {
display: inline-flex;
color: var(--ink-faint);
transition: color 150ms ease;
&:hover { color: var(--ink); }
}
/* ---------- empty state ---------- */
.empty {
@include card;
text-align: center;
padding: 56px 24px;
margin-top: 28px;
}
.empty svg {
width: 52px;
height: 52px;
color: var(--ink-faint);
}
.empty h1 {
font-size: 1.5rem;
margin-top: 16px;
}
.empty p {
color: var(--ink-dim);
max-width: 48ch;
margin: 10px auto 0;
}
.empty p a {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Render-error detail block: the raw error from the template engine,
shown to the single operator so a broken page is debuggable in place. */
.empty__detail {
margin: 18px auto 0;
max-width: 60ch;
padding: 10px 14px;
background: var(--paper-edge);
border: 1px solid var(--rule);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--ink-dim);
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
/* ---------- page heading ---------- */
/* The page's h1. Sits above the first section, with a strong rule below.
The new `.section-title` is the second-tier heading; the page-head's
font-size stays distinctly larger so the hierarchy reads h1 → h2. */
.page-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px 14px;
margin: var(--sp-1) 0 0;
padding-bottom: var(--sp-3);
border-bottom: 2px solid var(--rule-strong);
}
.page-head h1 {
font-size: var(--fs-2xl);
}
@media (min-width: $bp-md) {
.page-head h1 {
font-size: 1.9rem;
}
}
/* ---------- section title ---------- */
/* A proper readable heading, not the uppercase eyebrow it used to be.
The serif label sits left, a small "as of" caption sits right; a
double hairline rule below (a ledger column foot) anchors the section.
Phase 31: tightened hierarchy so eight stacked sections no longer blur. */
.section-title {
@include serif;
display: flex;
align-items: baseline;
/* wrap is a safety net: if a freshness caption cannot fit beside the rule
on a narrow phone it drops to its own line rather than overflowing. */
flex-wrap: wrap;
gap: 6px 14px;
margin: var(--sp-7) 0 var(--sp-3);
padding-bottom: 7px;
font-size: var(--fs-xl);
letter-spacing: -0.01em;
border-bottom: 1px solid var(--rule-strong);
/* the second hairline of the ledger "double underline", echoing the
brand mark. Sits 3px below the first. */
box-shadow: 0 3px 0 -2px var(--rule);
}
/* A quiet data-freshness caption riding at the right end of a section title.
margin-left:auto pushes it past the heading text. */
.section-title__asof {
margin-left: auto;
flex: none;
font-family: var(--font-ui);
font-weight: 400;
font-size: var(--fs-xs);
letter-spacing: normal;
color: var(--ink-faint);
white-space: nowrap;
}
/* a link inside the asof slot (e.g. the Top picks "Backtest these →" CTA)
reads as a quiet annotation, not a body link. Hover ramps to ink. */
.section-title__asof a {
color: var(--ink-dim);
}
.section-title__asof a:hover {
color: var(--ink);
text-decoration: underline;
text-underline-offset: 2px;
}
/* ---------- ticker grid + card ---------- */
.ticker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 11px;
}
.ticker-card {
@include card;
display: flex;
flex-direction: column;
gap: 8px;
padding: 13px 14px;
color: var(--ink);
transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;
}
.ticker-card:hover {
color: var(--ink);
border-color: var(--rule-strong);
transform: translateY(-2px);
box-shadow: var(--lift-hover);
}
.ticker-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ticker-card__sym {
@include mono;
font-weight: 700;
font-size: 1rem;
}
/* the standing badge and the kind eyebrow, grouped at the head's right edge */
.ticker-card__tags {
display: flex;
align-items: center;
gap: 6px;
}
.ticker-card__kind {
@include eyebrow;
font-size: 0.55rem;
}
.ticker-card__name {
font-size: 0.79rem;
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ticker-card__foot {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--rule);
}
.ticker-card__price {
font-size: 0.96rem;
font-weight: 600;
}
.ticker-card__chg {
font-size: 0.81rem;
font-weight: 600;
}
/* update flash, toggled by the live stream client (Phase 5) */
.ticker-card.flash-up {
animation: flash-up 0.7s ease-out;
}
.ticker-card.flash-down {
animation: flash-down 0.7s ease-out;
}
@keyframes flash-up {
0% {
background: var(--up-soft);
}
100% {
background: var(--surface);
}
}
@keyframes flash-down {
0% {
background: var(--down-soft);
}
100% {
background: var(--surface);
}
}
/* ---------- verdict badge (Phase 20) ---------- */
/* The rolled-up strong / fair / weak company standing, shown as one pill
across the app: on a ticker card, a mover row, a strongest / weakest row,
and above the symbol page's ratio cards. Carries the semantic colour, like
the per-ratio .ratio__badge it sits alongside. */
.vbadge {
@include eyebrow;
flex: none;
padding: 3px 7px;
border-radius: 999px;
font-size: 0.52rem;
white-space: nowrap;
}
.vbadge--good {
color: var(--up);
background: var(--up-soft);
}
.vbadge--ok {
color: var(--warn);
background: var(--warn-soft);
}
.vbadge--bad {
color: var(--down);
background: var(--down-soft);
}
/* ---------- intro note under a section title ---------- */
/* A quiet ink-dim line, scoped wider than a chip caption. Used on the home
panels and the symbol-page Stock health panel (Phase 17) to introduce a
section in one short sentence without crowding the title. */
.section-note {
margin: var(--sp-1) 0 var(--sp-3);
max-width: 68ch;
color: var(--ink-dim);
font-size: var(--fs-sm);
line-height: 1.55;
}
/* ---------- disclaimer line ---------- */
/* A quiet ink-faint line used on the home quality leaderboard. Always smaller
than body text; never carries a semantic colour — the read is explicitly
"not investment advice". */
.disclaimer {
margin: calc(var(--sp-2) * -1) 0 var(--sp-3);
font-size: var(--fs-xs);
color: var(--ink-faint);
font-style: italic;
line-height: 1.5;
}
/* ---------- focus-visible ring (Phase 31) ---------- */
/* A single quiet keyboard-focus ring across the app: a thin ink outline
plus a faint warm halo, so tab navigation lands legibly on every link,
button, and input without needing per-component focus rules. */
:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* inputs already carry their own focus border ring; suppress the global
outline there so it doesn't double up */
input:focus-visible,
button:focus-visible {
outline-offset: 1px;
}
/* ---------- desktop layering ---------- */
@media (min-width: $bp-md) {
.search {
order: 0;
flex: 1 1 auto;
max-width: 420px;
margin: 0 6px;
}
.topnav {
display: flex;
}
.bottomnav {
display: none;
}
/* no bottom nav on desktop, so the copyright bar needs no clearance for it */
.footer-bar {
padding-bottom: 14px;
}
.wrap {
padding-top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}