@@ -32,8 +32,14 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-23_**Current phase: Phase 15 (industry trends) complete, verified anddeployed to production 2026-05-23 (commit `6043657`).** Yahoo**Current phase: Phase 31 (full UI polish pass) complete and verifiedlocally 2026-05-23; not yet deployed.** Driven by the user's"really do a full pass" steer. The Paper Ledger look is preserved; theplumbing under it now provides hierarchy, rhythm, and aligned mobile +desktop layouts. See the Phase 31 Done entry for the full list of changes.**Last deployed to production: Phase 15 (industry trends), 2026-05-23(commit `6043657`).** Yahoo`quoteSummary.assetProfile` is the source(clean GICS-style names); migration `0012` adds`symbols.asset_profile_synced_at` (the long-existing-but-unpopulated
@@ -168,6 +174,89 @@ backlog as Phase 19. The `watchlists` / `watchlist_items` tables stay in theschema, unused for now.**Done**- **Phase 31 full UI polish pass.** Complete and verified locally 2026-05-23. The Paper Ledger look stays; what changes is the design plumbing under it. Touches every page; no schema change, no new network calls, no new endpoint guard. - **Design tokens (base.scss).** Added a fluid type scale (`--fs-2xs` … `--fs-3xl`), three eyebrow sizes (`--eye-chip / --eye-cap / --eye-lbl`), and a spacing scale (`--sp-1` … `--sp-7`) on `:root`. Replaced ad-hoc font-sizes and paddings across the SCSS with the new tokens. - **Broken token bugs fixed.** `symbol.scss` had used `var(--ok)` / `var(--bad)` in `.ret-cell--up/down` and `.fund-about__sub`; those vars never existed so the ETF trailing-returns column and the NAV premium pill rendered grey. Aliased `--ok` / `--bad` / `--ok-soft` / `--bad-soft` to the real `--up` / `--down` tokens in `:root` so any rule still spelling those words renders in the right hue, and rewrote the symbol-page call sites to the canonical `--up` / `--down`. - **Section title hierarchy (base.scss).** `.section-title` is now a proper serif heading with a ledger "double underline" (one `border-bottom` + a 1px `box-shadow` offset 3px), not the uppercase eyebrow + trailing hairline it was before. The home page's nine sections and the symbol page's twelve now read as a sequence instead of one wash. `.section-title__asof` pushed right via `margin-left:auto` with a tighter quiet caption. - **Ranked-row primitive (`_mixins.scss`).** New `@mixin ranked-row` consolidates the four near-duplicate row chromes (`.mover`, `.standing`, `.hrow`, `.industry-row`) into one shared grid + magnitude-tint pattern. Each row class now only owns its grid template and per-column type; the chrome rides in via `@include ranked-row`. Cut ~120 lines of duplicated CSS. - **Focus state (base.scss).** Single `:focus-visible` ring across the app so keyboard nav lands legibly on links, buttons and inputs without per-component focus rules. Inputs trim the offset to 1px so the existing focus border ring doesn't double up. - **Home page.** Picks panel rebuilt: the desktop layout dropped the `.pick__name` empty slot, badges align cell-for-cell across the four horizon cards via a tighter grid template. Movers / standings / health / industries rows now share the ranked-row chrome with a consistent type scale (sym `fs-sm`, name `fs-xs`, chg `fs-sm` mono bold) so the eye lands the same way down every panel. Spark cards tightened to the new tokens. - **Symbol page.** Header is now a 2-row layout: identity (ticker + tags) on top, name on a second line, price + change + asof on a third row with a hairline divider before the "At close" caption. The ledger double-rule under the header echoes the section titles. Stock-health row rebuilt on a two-column `label | value` grid with the note on its own second line at every width (the 7.5rem fixed label column on desktop wrong-sized longer labels and clipped the note). Fundamentals ratio cards muted the "how to read it" explainer to `--fs-2xs` and a quieter colour so 9 cards no longer dominate the page. ETF holdings now render 2-up on desktop via CSS columns (`columns: 2; break-inside: avoid`) so a 25-row list doesn't reach off the panel. - **Search.** `.search-count` shed its eyebrow voice for a quieter mono caption beside the heading. `.kind-pill` reads larger (`fs-sm` + 36px min-height) so the filter row is easier to tap. - **Industries.** Replaced the bare "No stocks classified yet" line with a proper `.empty` card carrying a Paper Ledger illustration (stacked sector columns over a ledger underline) mirroring the home empty state, so a fresh DB looks intentional rather than broken. - **Backtest.** Replaced bare "Loading backtest…" text with a skeleton (4 stat bones + a chart bone) that shimmers while the JSON loads, both server-rendered on first paint and re-created by the page script on every tab switch. Tabs gained the new serif voice. Disclaimer text tightened from a five-line paragraph to one sentence. - **Health.** Endpoint grid pinned to 1 / 2 / 3 columns at phone / tablet / desktop, replacing the previous `auto-fit` that occasionally dropped to 1-up at narrow desktops. Activity log rows mute the routine `ok` ones to `ink-faint` so a `bad` row pops; the job-id slot reads as a small mono uppercase pill. - **Verification.** `cargo check` clean. `bun run build` clean (CSS bundle: base 56.16 → 57.25 kB, symbol 20.93 → 20.51 kB, home 7.94 → 7.99 kB — net +0.5 kB across the whole frontend for the new tokens and the ranked-row mixin). Every page screenshotted at 1280px desktop and 390px phone with no horizontal overflow, no broken layout, and only the expected SSE-reconnect console errors during the dev-server restart window. Industries empty state, backtest skeleton, ETF holdings 2-up grid, ETF trailing-returns colour, and the new section title hierarchy all confirmed visually before / after.- **Phase 15 industry trends.** Complete, verified, and deployed to production 2026-05-23 (commit `6043657`). New `asset_profile` Yahoo scheduler section populates each stock's
@@ -1774,6 +1863,107 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data). fund category were dropped (not in SEC structured data — user decision). Commodity grantor trusts (GLD, SLV) get a minimal AUM-only profile.- [x] **Phase 31: Full UI polish pass.** Complete and verified locally 2026-05-23. See the Phase 31 entry in the Done list above and the decisions log for the full set of changes. (Captured 2026-05-23 from the user's "really do a full pass on the entire UI" steer.) Paper Ledger stays — what changes is the plumbing under it so the same visual vocabulary reads cleaner, skims faster, and aligns on phone and desktop. Audit-driven; the findings below are the work. **Design system (`base.scss`, `_mixins.scss`, `_variables.scss`).** - Fix broken tokens. `symbol.scss` uses `var(--ok)` and `var(--bad)` in `.ret-cell--up/down` and `.fund-about__sub--good/ok/bad`; those vars do not exist (the real ones are `--up`/`--down`/`--warn`), so the ETF trailing-returns column and the NAV premium pill currently render grey. Replace with the real tokens, or alias `--ok` / `--bad` / `--ok-soft` / `--bad-soft` in `:root` so the existing rules just work. - One type scale. Replace the ~30 ad-hoc font-sizes scattered across SCSS with a small fluid scale on `:root` — `--fs-xs`, `--fs-sm`, `--fs-md`, `--fs-lg`, `--fs-xl`, `--fs-2xl`, `--fs-3xl` — and roll every existing size to its nearest step. Same for the eyebrow voice: two steps (`--eyebrow-xs`, `--eyebrow-sm`), not the six that exist today. - One spacing scale. `--sp-1` … `--sp-6` (4 / 8 / 12 / 16 / 24 / 32) replacing the per-component pixel paddings. - Section title hierarchy. The current `.section-title` is the same uppercase eyebrow with a trailing hairline on every section, on every page. Make it a proper readable heading (serif, small caps optional, with the "as of" caption right-aligned in a fixed slot), so the home page's nine sections and the symbol page's twelve read as a sequence instead of a wash. - Focus state. Add a single `:focus-visible` ring on inputs and buttons. **Home page.** - Top picks rebuild. On desktop the `pick__name` is hidden, leaving a sparse `rank | ticker | empty | badge | score` row. Either show the name (with truncation) or collapse the grid to `rank | ticker | badge | score`. Pick at build time; tighter grid wins on a narrow desktop card. - Row consistency. `mover`, `standing`, `hrow` and `industry-row` are near-duplicates with slightly different font sizes (0.85 / 0.86 / 0.92 / 0.95). Promote them to one `.row` primitive with a magnitude tint, then layer the per-section bits on top. - Section pacing. Eight movers-shaped panels in a row blur; introduce a gentle visual differentiator between the picks block, the movers block, and the rankings (health / strongest / weakest) — same framing, different eyebrow voice, or a `.section--rank` wrapper. **Symbol page.** - Header. Promote name to its own line at desktop, drop the tag chip font-size to a quieter pill, push the live quote block to the right-hand half so the identity and the price are visually separate, not vertically stacked. - Chart toolbar. Split the cramped one-row bar into two: ranges left + summary chip right on top, indicator toggles below. - Stock health. The 7.5rem fixed-width label column wrong-sizes longer labels; rebuild on a `grid-template-columns: minmax(0,1fr) auto 1fr` so label / value / note each get their share and the note never wraps under the value awkwardly. - Fundamentals ratio cards. The "how to read it" explainer doubles every card. Tighten: shrink it to `--fs-xs`, mute it harder, and optionally collapse behind `<details>` so a curious reader can pop it but the default scroll is half the height. - Holdings list. Render 2-up on desktop (`md` breakpoint), one column on phone. The current single tall column wastes the panel's width on an ETF page. - Dividends, earnings, leadership, anomalies, filings. All carry a near-identical row shape (date · label · meta · external icon). Promote to one `.timeline-row` primitive and consume it across the four sections. **Search page.** - Filter pills: slightly larger, sticky to the top of the results. - Result count beside the heading: lift to the page-head right slot, not the eyebrow. **Industries empty state.** - Add a Paper Ledger illustration mirroring the home empty state, so a fresh DB looks intentional rather than broken. **Backtest.** - Better loading skeleton (a card outline + bone bar for stats and chart), not bare "Loading…". - Disclaimer wording: tighten to one sentence under the page intro, move the methodology detail into the chart-panel provenance line. **Health page.** - Endpoint grid: 2-up on tablet, 3-up on desktop. - Activity log: mute routine rows, brighten failures; group by minute or by job, so the eye finds anomalies fast. **Verification.** Screenshot every page at 390 / 1024 / 1440 before and after; confirm no horizontal overflow at 320px, zero new console errors, every tap target stays ≥44px on phones. Each round of changes goes through `cargo check` + `bun run build` clean. **Non-goals.** No new features, no schema change, no new endpoint guard. Pure presentation work over what already ships. The "polish last, not mid-build" memory applies — this is the deliberate polish pass after the long backlog of feature phases, exactly the slot Phase 31 was meant for.- [ ] **Phase 19: Watchlists.** Named lists of symbols the user curates: watchlist and per-list pages plus mutation APIs (create / delete / rename lists, add / remove symbols). The schema already carries the `watchlists`
@@ -3377,6 +3567,27 @@ finance/ reason to start). Anti-spam: ~512 stocks × monthly refresh = ~17/day on the existing `yahoo` 1000/hr guard, no new endpoint.- **2026-05-23 — Phase 31 (full UI polish pass) picked next; scope and approach settled in one audit.** Trigger: user said "I'd like to do a clean pass on the entire UI to really clean things up, make them clearer to read and skim with nice UI elements and make things aligned better on mobile and desktop … I like the overall styling but some elements on pages are just poorly designed." The pre-pass audit found nine concrete issues (two broken color tokens, ~30 ad-hoc font-sizes, flat section hierarchy across every page, the symbol page running 5,000px tall on phone, picks rows sparse on desktop, ETF holdings one-column at desktop, bare industries empty state, dense health log, bare backtest loading text). Approach settled: keep the Paper Ledger look, change the plumbing under it. Build it as four staged tranches — design tokens, home page, symbol page, then search / industries / backtest / health — each built mobile-first and verified via screenshot at 1280px and 390px. No new features, no schema change, no new endpoint guard; scope is purely presentation. This is the "polish last" slot the PLAN budgeted from the start (Phase 12 deferred the polish pass to the post-MVP backlog), now spent in one focused phase. Verified end-to-end; see the Phase 31 Done entry for the per-page work.---## Verification
modified
frontend/static_src/backtest/index.js
@@ -51,9 +51,32 @@ function setActive(horizon) { });}// Phase 31: a small skeleton (4 stat bones + a chart bone + status text)// shown while the JSON loads, instead of bare "Loading backtest…" text on// empty paper. The first render keeps the server-rendered skeleton; later// tab switches re-create it so each horizon change shows the skeleton too.function skeletonHtml() { return ` <div class="bt-skeleton" data-role="skeleton"> <div class="bt-skeleton__stats"> <div class="bt-skeleton__stat"></div> <div class="bt-skeleton__stat"></div> <div class="bt-skeleton__stat"></div> <div class="bt-skeleton__stat"></div> </div> <div class="bt-skeleton__chart"></div> <p class="bt-status">Loading backtest…</p> </div>`;}function load(horizon) { setActive(horizon); panel.innerHTML = '<p class="bt-status">Loading backtest…</p>'; // Reuse the existing server-rendered skeleton on the first load (no // flicker / paint cost); regenerate it on each subsequent horizon // change so the loading affordance is consistent. if (!panel.querySelector("[data-role=skeleton]")) { panel.innerHTML = skeletonHtml(); } fetch(`/api/backtest?horizon=${encodeURIComponent(horizon)}`) .then((res) => { if (!res.ok) throw new Error(`backtest ${res.status}`);
modified
frontend/static_src/backtest/styles/backtest.scss
@@ -10,27 +10,31 @@ ========================================================================== */.bt-blurb { margin: 0 0 8px; margin: var(--sp-3) 0 var(--sp-1); max-width: 70ch; color: var(--ink-dim); font-size: 0.86rem; font-size: var(--fs-md); line-height: 1.55;}/* horizon tabs (Day / Week / Month / Year) */.bt-tabs { display: flex; gap: 4px; margin: 18px 0 14px; border-bottom: 1px solid var(--rule); gap: var(--sp-1); margin: var(--sp-4) 0 var(--sp-3); border-bottom: 1px solid var(--rule-strong);}.bt-tab { @include eyebrow; padding: 9px 14px; font-family: var(--font-serif); font-weight: 600; font-size: var(--fs-md); letter-spacing: -0.01em; padding: var(--sp-2) var(--sp-3); background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--ink-dim); color: var(--ink-faint); cursor: pointer; margin-bottom: -1px; transition: color 0.1s, border-color 0.1s;
@@ -47,15 +51,65 @@/* loading / error state */.bt-status { padding: 30px 4px; padding: var(--sp-5) 4px; color: var(--ink-faint); font-size: 0.88rem; font-size: var(--fs-sm); text-align: center;}.bt-status--err { color: var(--down);}/* loading skeleton — a 4-up stat strip + chart-sized bone, shown before the JSON snapshot lands so the page doesn't collapse into bare text. A subtle shimmer hints at activity; the bones share the same geometry as the real layout so the page doesn't reflow when data arrives. */.bt-skeleton { display: grid; gap: var(--sp-4); margin: var(--sp-3) 0;}.bt-skeleton__stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3);}@media (min-width: $bp-md) { .bt-skeleton__stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }}.bt-skeleton__stat,.bt-skeleton__chart { background: linear-gradient(90deg, var(--well) 0%, rgba(231, 225, 210, 0.55) 50%, var(--well) 100%); background-size: 200% 100%; border-radius: var(--radius); animation: bt-shimmer 1.4s ease-in-out infinite;}.bt-skeleton__stat { height: 78px; }.bt-skeleton__chart { height: 360px; }@keyframes bt-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; }}@media (prefers-reduced-motion: reduce) { .bt-skeleton__stat, .bt-skeleton__chart { animation: none; }}.bt-head { margin: 12px 0 18px;}
modified
frontend/static_src/base/styles/_mixins.scss
@@ -34,7 +34,64 @@@mixin eyebrow { text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem; font-size: var(--eye-cap); font-weight: 600; color: var(--ink-faint);}// Shared ranked-row chrome (Phase 31). Used by `.mover`, `.standing`,// `.hrow`, `.industry-row`, and `.pick__row`. Each row is a grid with a// magnitude-tinted ::before sized by the route via the --bar custom// property; hover swaps in the well. The grid template is the row's job —// this mixin only owns the chrome.@mixin ranked-row { position: relative; display: grid; align-items: center; gap: var(--sp-3); padding: 10px var(--sp-2); color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--rule); transition: background 0.12s; &::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--bar, 0%); border-radius: var(--radius-sm); background: transparent; pointer-events: none; } > * { position: relative; // ride above the magnitude tint } &:last-child { border-bottom: 0; } &:hover { color: var(--ink); background: var(--well); }}// Pill: a small rounded label used for badges and chips across the app.// Centralises the radius / padding / type so vbadge / ratio__badge / chip// stay in lockstep.@mixin pill($size: var(--eye-chip)) { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: $size; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600; white-space: nowrap;}
modified
frontend/static_src/base/styles/base.scss
@@ -18,13 +18,21 @@ --rule: rgba(33, 31, 26, 0.14); /* hairline */ --rule-strong: rgba(33, 31, 26, 0.32); /* emphasised divider */ /* semantic — good / ok / bad */ /* 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);
@@ -32,6 +40,33 @@ --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;
@@ -451,51 +486,62 @@ main {}/* ---------- 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: 8px; margin: 6px 0 4px; padding-bottom: 12px; border-bottom: 1px solid var(--rule-strong); 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: 1.6rem; font-size: var(--fs-2xl);}@media (min-width: $bp-md) { .page-head h1 { font-size: 1.9rem; }}/* ---------- section title ---------- *//* A ledger column-header: eyebrow text with a hairline rule trailing off. *//* 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 eyebrow; @include serif; display: flex; align-items: center; 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: 12px; margin: 30px 0 13px;}.section-title::after { content: ""; flex: 1; height: 1px; background: var(--rule); 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 (PLAN.md Phase 22). order:1 places it past the ::after hairline rule; the eyebrow's uppercase voice is dropped for a calmer annotation. */ (PLAN.md Phase 22). margin-left:auto pushes it past the heading text. */.section-title__asof { order: 1; margin-left: auto; flex: none; text-transform: none; letter-spacing: normal; font-size: 0.7rem; font-family: var(--font-ui); font-weight: 400; font-size: var(--fs-xs); letter-spacing: normal; color: var(--ink-faint); white-space: nowrap;}
@@ -646,14 +692,15 @@ main {}/* ---------- intro note under a section title ---------- *//* A quiet ink-faint line, scoped wider than a chip caption. Used on the home/* 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: -2px 0 14px; max-width: 64ch; color: var(--ink-faint); font-size: 0.82rem; 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 (Phase 30) ---------- */
@@ -661,10 +708,28 @@ main { page header. Always smaller than body text; never carries a semantic colour — the picks themselves are explicitly "for fun and testing". */.disclaimer { margin: -8px 0 14px; font-size: 0.72rem; 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 ---------- */
modified
frontend/static_src/health/styles/health.scss
@@ -72,16 +72,31 @@ background: var(--well);}/* ---------- endpoints ---------- *//* ---------- endpoints ---------- Phase 31: an explicit 3-up at desktop, 2-up at tablet, 1-up at phone, so the SEC / Stooq / Yahoo cards always read in a tidy grid rather than the previous auto-fit which dropped to 1-up at narrow desktops. */.endpoint-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; grid-template-columns: 1fr; gap: var(--sp-3);}@media (min-width: $bp-sm) { .endpoint-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }}@media (min-width: $bp-md) { .endpoint-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }}.endpoint { @include card; padding: 16px; padding: var(--sp-4);}.endpoint__head,
@@ -214,10 +229,14 @@ display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; padding: 9px 0; gap: var(--sp-1) var(--sp-3); padding: 8px 0; border-top: 1px solid var(--rule); font-size: 0.84rem; font-size: var(--fs-sm); /* routine `ok` rows mute their entire body; the time, job, detail and meta all drop a tier so the eye glides past them and lands on anything coloured. */ color: var(--ink-faint);}.logrow:first-child {
@@ -225,13 +244,19 @@}.logrow__time { @include mono; color: var(--ink-faint); font-size: 0.78rem; font-size: var(--fs-xs);}.logrow__job { @include mono; min-width: 6ch; font-weight: 600; color: var(--ink-dim); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.05em;}.logrow__detail {
@@ -240,14 +265,20 @@ color: var(--ink-dim);}/* a failed fetch should catch the eye down the tail *//* a failed fetch should catch the eye down the tail; the row's job pill and detail both pop, the time/meta stay quiet. */.logrow--bad .logrow__job { color: var(--down);}.logrow--bad .logrow__detail { color: var(--down); font-weight: 500;}.logrow__meta { color: var(--ink-faint); font-size: 0.76rem; font-size: var(--fs-2xs);}/* ---------- empty states ---------- */
modified
frontend/static_src/home/styles/home.scss
@@ -62,21 +62,22 @@.spark-card__head { display: flex; align-items: baseline; gap: 8px; gap: var(--sp-2);}.spark-card__sym { @include mono; font-weight: 700; font-size: 0.95rem; font-size: var(--fs-md);}.spark-card__name { font-size: 0.73rem; font-size: var(--fs-2xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;}/* the sparkline svg is stretched to the card width (preserveAspectRatio
@@ -140,13 +141,13 @@}.spark-card__price { font-size: 0.95rem; font-weight: 600; font-size: var(--fs-md); font-weight: 700;}.spark-card__chg { font-size: 0.81rem; font-weight: 600; font-size: var(--fs-sm); font-weight: 700;}/* update flash, toggled by the live stream client (mirrors .ticker-card) */
@@ -166,74 +167,48 @@.movers__panel { @include card; padding: 6px 14px 8px; padding: 4px var(--sp-3) var(--sp-2);}/* Panel header: a small serif label (echoes the page-level section titles one tier down) with a hairline rule below. */.movers__title { @include eyebrow; font-family: var(--font-serif); font-weight: 600; font-size: var(--fs-md); letter-spacing: -0.01em; display: block; padding: 12px 4px 8px; border-bottom: 1px solid var(--rule-strong); padding: var(--sp-3) 2px var(--sp-2); border-bottom: 1px solid var(--rule); color: var(--ink);}.movers__empty { color: var(--ink-faint); font-size: 0.82rem; padding: 16px 4px; font-size: var(--fs-sm); padding: var(--sp-4) 4px;}/* The four ranked-row classes (mover / standing / hrow / industry-row) share the chrome via the `ranked-row` mixin; each only owns its grid template and per-column type. */.mover { position: relative; display: grid; @include ranked-row; /* sym, name, standing badge, price, day change */ grid-template-columns: auto minmax(0, 1fr) auto auto auto; align-items: center; gap: 12px; padding: 10px 6px; color: var(--ink); border-bottom: 1px solid var(--rule);}.mover:last-child { border-bottom: 0;}/* a soft magnitude tint behind the row: width set by the route via --bar. A pseudo-element keeps it independent of the hover background. */.mover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--bar, 0%); border-radius: var(--radius-sm);}.mover--up::before { background: var(--up-soft);}.mover--down::before { background: var(--down-soft);}.mover > * { position: relative; /* sit above the magnitude tint */}.mover:hover { color: var(--ink); background: var(--well);}.mover--up::before { background: var(--up-soft); }.mover--down::before { background: var(--down-soft); }.mover__sym { font-family: var(--font-mono); font-weight: 700; font-size: 0.86rem; font-size: var(--fs-sm);}.mover__name { font-size: 0.78rem; font-size: var(--fs-xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden;
@@ -241,13 +216,15 @@}.mover__price { font-size: 0.82rem; color: var(--ink-dim); font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--ink-faint);}.mover__chg { font-size: 0.85rem; font-weight: 600; font-family: var(--font-mono); font-size: var(--fs-sm); font-weight: 700; text-align: right; min-width: 4.4ch;}
@@ -259,61 +236,25 @@}/* ---------- strongest / weakest panels (Phase 20) ---------- *//* Rows reuse the .movers panel chrome but rank on the rolled-up standing rather than the day's price move. Each carries a soft magnitude tint sized by --bar, mirroring .mover. *//* Same ranked-row chrome as .mover; rank is the rolled-up standing rather than the day's price move. */.standing { position: relative; display: grid; @include ranked-row; /* sym, name, trailing-year return, verdict badge */ grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 10px 6px; color: var(--ink); border-bottom: 1px solid var(--rule);}.standing:last-child { border-bottom: 0;}/* the magnitude tint behind the row; a pseudo-element keeps it independent of the hover background */.standing::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--bar, 0%); border-radius: var(--radius-sm);}.standing--up::before { background: var(--up-soft);}.standing--down::before { background: var(--down-soft);}.standing > * { position: relative; /* sit above the magnitude tint */}.standing:hover { color: var(--ink); background: var(--well);}.standing--up::before { background: var(--up-soft); }.standing--down::before { background: var(--down-soft); }.standing__sym { font-family: var(--font-mono); font-weight: 700; font-size: 0.86rem; font-size: var(--fs-sm);}.standing__name { font-size: 0.78rem; font-size: var(--fs-xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden;
@@ -322,8 +263,9 @@/* the trailing-year return, a quiet context figure beside the verdict */.standing__ret { font-size: 0.8rem; color: var(--ink-dim); font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--ink-faint); text-align: right; min-width: 5ch;}
@@ -334,62 +276,28 @@ Healthy / Mixed / Concerning verdict on the right. The chips read as coloured pills so the synthesis is legible without expanding. */.hrow { position: relative; display: grid; @include ranked-row; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 6px; color: var(--ink); border-bottom: 1px solid var(--rule);}.hrow:last-child { border-bottom: 0;}/* the magnitude tint behind the row, mirroring .standing */.hrow::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--bar, 0%); border-radius: var(--radius-sm);}.hrow--up::before { background: var(--up-soft);}.hrow--down::before { background: var(--down-soft);}.hrow > * { position: relative;}.hrow:hover { color: var(--ink); background: var(--well);}.hrow--up::before { background: var(--up-soft); }.hrow--down::before { background: var(--down-soft); }.hrow__sym { font-family: var(--font-mono); font-weight: 700; font-size: 0.86rem; font-size: var(--fs-sm);}.hrow__body { display: flex; flex-direction: column; gap: 4px; gap: var(--sp-1); min-width: 0;}.hrow__name { font-size: 0.78rem; font-size: var(--fs-xs); color: var(--ink-dim); white-space: nowrap; overflow: hidden;
@@ -399,36 +307,27 @@.hrow__sub { display: flex; flex-wrap: wrap; gap: 4px; gap: var(--sp-1);}/* the three sub-component pills — fundamentals / trajectory / leadership */.hrow__chip { @include eyebrow; padding: 1px 6px; display: inline-flex; align-items: center; padding: 2px 7px; border-radius: 999px; font-size: 0.48rem;}.hrow__chip--good { color: var(--up); background: var(--up-soft);}.hrow__chip--ok { color: var(--warn); background: var(--warn-soft);}.hrow__chip--bad { color: var(--down); background: var(--down-soft); text-transform: uppercase; letter-spacing: 0.12em; font-weight: 600; font-size: 0.54rem; /* a hair larger than before (0.48 → 0.54): the chips were too small to scan as discrete pills. Still well under the eyebrow voice. */}.hrow__chip--unknown { color: var(--ink-faint); background: var(--well);}.hrow__chip--good { color: var(--up); background: var(--up-soft); }.hrow__chip--ok { color: var(--warn); background: var(--warn-soft); }.hrow__chip--bad { color: var(--down); background: var(--down-soft); }.hrow__chip--unknown { color: var(--ink-faint); background: var(--well); }/* ---------- top picks panel (Phase 30) ---------- *//* Four columns (Day / Week / Month / Year), each carrying 5 ranked picks.
@@ -460,28 +359,33 @@ min-width: 0;}/* The picks-col title is a quiet header inside an already-bordered card, so a serif label without an extra rule (the card border owns that). */.picks-col__title { @include eyebrow; padding-bottom: 6px; border-bottom: 1px solid var(--rule-strong); font-family: var(--font-serif); font-weight: 600; font-size: var(--fs-md); letter-spacing: -0.01em; color: var(--ink); padding-bottom: var(--sp-1);}.picks-col__desc { margin: 0; font-size: 0.7rem; font-size: var(--fs-2xs); color: var(--ink-faint); line-height: 1.35; line-height: 1.4;}.picks-col__list { list-style: none; margin: 4px 0 0; margin: var(--sp-1) 0 0; padding: 0;}.picks-col__empty { margin: 8px 0; font-size: 0.78rem; margin: var(--sp-2) 0; font-size: var(--fs-xs); color: var(--ink-faint);}
@@ -493,18 +397,19 @@ border-bottom: 0;}/* Each card carries its own grid, but pinning rank / sym / badge / score to fixed track widths makes the four horizon cards visually line up cell-for- cell across the page (the eye scans pick-1 → pick-1 → pick-1 cleanly instead of zig-zagging through auto-sized tracks). The name column flexes to fill what's left. *//* The pick row sheds the name column entirely — at desktop the 4-up layout makes a useful name slice impossible (a single-character "M…" reads as noise), and at smaller widths the score figure is the primary read. The four tracks are pinned at fixed widths so rank-1 / rank-2 / ... line up cell-for-cell across the four horizon cards. */.pick__row { display: grid; grid-template-columns: 1.5ch 5ch minmax(0, 1fr) 4.4em 5.6ch; grid-template-columns: 1.5ch minmax(4.5ch, auto) 1fr minmax(5.6ch, auto); align-items: center; gap: 8px; padding: 7px 2px; gap: var(--sp-2); padding: 9px 2px; color: var(--ink); min-height: 36px;}.pick__row:hover {
@@ -514,64 +419,40 @@.pick__rank { text-align: right; font-size: 0.72rem; font-family: var(--font-mono); font-size: var(--fs-2xs); color: var(--ink-faint); font-weight: 600;}.pick__sym { font-family: var(--font-mono); font-weight: 700; font-size: 0.82rem; font-size: var(--fs-sm);}/* The name span exists in the template across all breakpoints but never shows: at every width either the score fits in this slot or the badge does. Reclaim the column for the badge. */.pick__name { font-size: 0.74rem; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: none;}/* center the verdict pill in its fixed-width slot so Strong / Fair / Weak all sit at the same x-axis across cards *//* center the verdict pill in its slot so Strong / Fair / Weak align horizontally across the four cards */.pick__badge { display: flex; justify-content: center; justify-content: flex-start; margin-left: auto;}.pick__score { font-size: 0.78rem; font-weight: 600; font-family: var(--font-mono); font-size: var(--fs-sm); font-weight: 700; text-align: right;}/* The name column only earns its width on the 2-up tablet layout, where each card is wide enough to show a useful slice of it. On the 4-up desktop layout cards are too narrow (a single-character "I…" reads as noise), and on phones we drop name to keep room for the figures. */@media (min-width: $bp-md) { .pick__name { display: none; } .pick__row { grid-template-columns: 1.5ch 5ch 1fr 4.4em 5.6ch; }}/* Mobile: name is hidden but the card itself is too narrow for fixed tracks to fit. Fall back to auto sizing so the score never gets clipped; cross-card alignment matters less here because the eye scans one card at a time. */@media (max-width: $bp-sm) { .pick__name { display: none; } .pick__row { grid-template-columns: 1.5ch auto 1fr auto auto; gap: 6px; }}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) { .movers {
@@ -598,51 +479,18 @@/* ---------- Today's industries panel (Phase 15) ---------- *//* Each row is one sector: name on the left, member count, and the day composite %. Same magnitude-tint pattern as `.mover` and `.standing`. */ composite %. Same chrome as `.mover` and `.standing` via the ranked-row mixin. */.industry-row { position: relative; display: grid; @include ranked-row; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; padding: 12px 6px; color: var(--ink); border-bottom: 1px solid var(--rule); text-decoration: none; &:last-child { border-bottom: 0; } &::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: var(--bar, 0%); border-radius: var(--radius-sm); } > * { position: relative; } &:hover { background: var(--well); }}.industry-row--up::before { background: var(--up-soft);}.industry-row--down::before { background: var(--down-soft);}.industry-row--up::before { background: var(--up-soft); }.industry-row--down::before { background: var(--down-soft); }.industry-row__name { font-size: 0.92rem; font-size: var(--fs-md); font-weight: 500; white-space: nowrap; overflow: hidden;
@@ -650,13 +498,14 @@}.industry-row__count { font-size: 0.74rem; font-size: var(--fs-2xs); color: var(--ink-faint);}.industry-row__chg { font-size: 0.95rem; font-weight: 600; font-family: var(--font-mono); font-size: var(--fs-md); font-weight: 700; text-align: right; min-width: 5.5ch;}
modified
frontend/static_src/industries/styles/industries.scss
@@ -13,23 +13,17 @@ ========================================================================== */.ind-blurb { margin: 0 0 16px; margin: var(--sp-3) 0 var(--sp-4); max-width: 78ch; color: var(--ink-dim); font-size: 0.86rem;}.ind-empty { padding: 60px 0; text-align: center; color: var(--ink-faint); font-size: 0.9rem; font-size: var(--fs-sm); line-height: 1.55;}.ind-crumbs { margin: -6px 0 14px; margin: calc(var(--sp-2) * -1) 0 var(--sp-3); color: var(--ink-faint); font-size: 0.78rem; font-size: var(--fs-xs); a { color: var(--ink-dim);
@@ -37,6 +31,8 @@ } a:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 2px; }}
@@ -44,18 +40,19 @@.ind-table { width: 100%; border-collapse: collapse; margin-bottom: 24px; margin-bottom: var(--sp-6); th, td { padding: 9px 10px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--rule); text-align: right; font-size: 0.86rem; font-size: var(--fs-sm); } thead th { @include eyebrow; font-size: var(--eye-cap); color: var(--ink-faint); text-align: right; border-bottom: 1px solid var(--rule-strong);
modified
frontend/static_src/search/styles/search.scss
@@ -2,23 +2,26 @@@use "../../base/styles/mixins" as *;/* ---------- count beside the page heading ---------- *//* Phase 31: drop the eyebrow voice — the page-head already announces the page, the count just needs to read as a quiet annotation. */.search-count { @include eyebrow; font-size: 0.6rem; font-size: var(--fs-sm); color: var(--ink-faint); font-family: var(--font-mono);}/* ---------- data-freshness caption above the results grid (Phase 22) ---------- */.results-asof { margin: 4px 0 12px; font-size: 0.76rem; margin: var(--sp-1) 0 var(--sp-3); font-size: var(--fs-xs); color: var(--ink-faint);}/* ---------- the on-page search field ---------- */.searchbar { display: flex; gap: 10px; margin: 14px 0 2px; gap: var(--sp-2); margin: var(--sp-3) 0 2px;}.searchbar input {
@@ -56,17 +59,15 @@}.kind-pill { @include tap; display: inline-flex; align-items: center; min-height: 0; min-width: 0; padding: 7px 14px; padding: 8px 14px; min-height: 36px; border-radius: 999px; border: 1px solid var(--rule-strong); background: var(--surface); color: var(--ink-dim); font-size: 0.82rem; font-size: var(--fs-sm); font-weight: 500; transition: border-color 0.14s, color 0.14s, background 0.14s;}
modified
frontend/static_src/symbol/styles/symbol.scss
@@ -1,92 +1,122 @@@use "../../base/styles/variables" as *;@use "../../base/styles/mixins" as *;/* ---------- header ---------- *//* ---------- header ---------- At desktop the header splits into a 2-column grid: the identity (ticker + name + tag chips) on the left, the live quote block on the right. At phone they stack. The double-rule under the header echoes the new .section-title ledger underline so the page reads as a single accounting register. */.sym-head { margin: 6px 0 20px; padding-bottom: 16px; display: grid; grid-template-columns: 1fr; gap: var(--sp-3) var(--sp-6); margin: var(--sp-1) 0 var(--sp-5); padding-bottom: var(--sp-4); border-bottom: 1px solid var(--rule-strong); box-shadow: 0 3px 0 -2px var(--rule);}.sym-head__id { display: flex; align-items: center; gap: 10px; gap: var(--sp-3); flex-wrap: wrap; min-width: 0;}.sym-head__ticker { font-size: 1.85rem; @include mono; font-weight: 700; font-size: var(--fs-2xl); letter-spacing: -0.02em;}.sym-head__tag { @include eyebrow; font-size: 0.58rem; font-size: var(--eye-chip); color: var(--ink-dim); padding: 3px 8px; border: 1px solid var(--rule-strong); border-radius: 4px;}// Phase 15: sector / industry tag, linked to its /industries page. Same shape// as a plain `.sym-head__tag`, with the link colour subdued so it does not// shout among the kind / exchange tags beside it.// as a plain `.sym-head__tag`, with a hover ramp that lifts to ink..sym-head__tag--link { color: var(--ink-dim); text-decoration: none; transition: color 0.1s, border-color 0.1s; transition: color 0.1s, border-color 0.1s, background 0.1s; &:hover { color: var(--ink); border-color: var(--ink); background: var(--well); }}.sym-head__name { grid-column: 1 / -1; color: var(--ink-dim); margin-top: 4px; font-size: var(--fs-md); margin-top: -2px;}.sym-head__quote { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 14px; margin-top: 14px; gap: 4px var(--sp-3); grid-column: 1 / -1;}.sym-head__price { font-size: 1.9rem; @include mono; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -0.01em;}.sym-head__chg { font-size: 1rem; font-weight: 600; @include mono; font-size: var(--fs-md); font-weight: 700;}.sym-head__asof { @include eyebrow; font-size: 0.62rem; font-size: var(--fs-xs); color: var(--ink-faint); letter-spacing: 0; text-transform: none; /* a hairline divider before the asof so the "At close · quoted Nov 14" reads as a separate annotation, not part of the % change figure */ padding-left: var(--sp-3); border-left: 1px solid var(--rule); margin-left: 2px;}/* ---------- chart panel ---------- */.chart-panel { padding: 14px; padding: var(--sp-3) var(--sp-3) var(--sp-2);}/* range buttons on the left, the range-change chip pushed to the right *//* Phase 31: a stricter two-row toolbar. Row 1 is the time-range bar with the range-summary chip on the right; row 2 is the indicator toggle row on a quieter hairline. Splitting them stops the crammed-into-one-row look that earlier crowded the chart-bar at narrow widths. */.chart-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin-bottom: 10px; justify-content: space-between; gap: var(--sp-2) var(--sp-4); margin-bottom: var(--sp-2);}.range-bar { display: flex; flex-wrap: wrap; gap: 6px; gap: var(--sp-1);}/* the range-change chip: % and absolute move over the whole visible range */
@@ -401,62 +431,57 @@ semantic colour through a left border and a coloured value, keeping the panel readable at a glance without overwhelming the page. */.health { padding: 14px 16px; padding: var(--sp-3) var(--sp-4) var(--sp-4);}.health__overall { display: flex; align-items: center; gap: 12px; padding-bottom: 12px; margin-bottom: 10px; gap: var(--sp-3); padding-bottom: var(--sp-3); margin-bottom: var(--sp-3); border-bottom: 1px solid var(--rule);}/* the overall verdict reads larger than the inline app-wide pill */.health__overall .vbadge { font-size: 0.74rem; font-size: var(--eye-lbl); padding: 7px 14px;}.health__overall-text { max-width: 70ch; color: var(--ink-dim); font-size: 0.92rem; font-size: var(--fs-md);}.health__breakdown { display: flex; flex-direction: column; gap: 8px; display: grid; gap: var(--sp-2);}/* Phase 31: the three health sub-rows now stack as fluid two-column rows: `label | value` on a single line, with the note on a second line. That was the phone fallback before — the desktop "label | value | note" layout fell apart for the longer notes ("X reported officer or director changes in the last 2 years") which wrapped into a tight third column and clipped on the right. The two-line shape is the same at every width; the left border and coloured value carry the semantic read. */.health-row { display: grid; grid-template-columns: 7.5rem auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 14px; padding: 8px 12px; gap: 2px var(--sp-3); padding: var(--sp-2) var(--sp-3); border-left: 3px solid var(--rule); background: var(--well); border-radius: var(--radius-sm);}.health-row--good { border-left-color: var(--up);}.health-row--ok { border-left-color: var(--warn);}.health-row--bad { border-left-color: var(--down);}.health-row--unknown { border-left-color: var(--ink-faint);}.health-row--good { border-left-color: var(--up); }.health-row--ok { border-left-color: var(--warn); }.health-row--bad { border-left-color: var(--down); }.health-row--unknown { border-left-color: var(--ink-faint); }.health-row__label { @include eyebrow;
@@ -464,40 +489,21 @@}.health-row__value { @include mono; font-weight: 700; font-size: 0.95rem; font-size: var(--fs-md);}.health-row--good .health-row__value { color: var(--up);}.health-row--ok .health-row__value { color: var(--warn);}.health-row--bad .health-row__value { color: var(--down);}.health-row--unknown .health-row__value { color: var(--ink-faint);}.health-row--good .health-row__value { color: var(--up); }.health-row--ok .health-row__value { color: var(--warn); }.health-row--bad .health-row__value { color: var(--down); }.health-row--unknown .health-row__value { color: var(--ink-faint); }.health-row__note { font-size: 0.8rem; grid-column: 1 / -1; font-size: var(--fs-xs); color: var(--ink-faint);}@media (max-width: 480px) { .health-row { grid-template-columns: 1fr auto; row-gap: 4px; } .health-row__note { grid-column: 1 / -1; } line-height: 1.45;}/* ---------- fundamentals: graded ratio cards ---------- */
@@ -530,22 +536,22 @@.ratios { display: grid; grid-template-columns: repeat(auto-fit, minmax(258px, 1fr)); gap: 12px; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: var(--sp-3);}.ratio { @include card; display: flex; flex-direction: column; padding: 14px 16px; padding: var(--sp-3) var(--sp-4) var(--sp-3);}.ratio__head { display: flex; align-items: baseline; align-items: center; justify-content: space-between; gap: 10px; gap: var(--sp-2);}.ratio__label {
@@ -557,66 +563,48 @@.ratio__badge { @include eyebrow; flex: none; padding: 3px 8px; padding: 2px 8px; border-radius: 999px; font-size: 0.54rem;}.ratio__badge--good { color: var(--up); background: var(--up-soft); font-size: var(--eye-chip);}.ratio__badge--ok { color: var(--warn); background: var(--warn-soft);}.ratio__badge--bad { color: var(--down); background: var(--down-soft);}.ratio__badge--unknown { color: var(--ink-faint); background: var(--well);}.ratio__badge--good { color: var(--up); background: var(--up-soft); }.ratio__badge--ok { color: var(--warn); background: var(--warn-soft); }.ratio__badge--bad { color: var(--down); background: var(--down-soft); }.ratio__badge--unknown { color: var(--ink-faint); background: var(--well); }/* the figure itself, colored to match the grade */.ratio__value { font-size: 1.7rem; @include mono; font-size: var(--fs-xl); font-weight: 700; margin: 9px 0 3px; letter-spacing: -0.02em; margin: var(--sp-2) 0 2px; line-height: 1.05;}.ratio--good .ratio__value { color: var(--up);}.ratio--ok .ratio__value { color: var(--warn);}.ratio--bad .ratio__value { color: var(--down);}.ratio--unknown .ratio__value { color: var(--ink-faint);}.ratio--good .ratio__value { color: var(--up); }.ratio--ok .ratio__value { color: var(--warn); }.ratio--bad .ratio__value { color: var(--down); }.ratio--unknown .ratio__value { color: var(--ink-faint); }.ratio__reading { font-size: 0.83rem; font-size: var(--fs-xs); color: var(--ink-dim); line-height: 1.45;}/* the static "how to read it" note, set off below a hairline *//* the static "how to read it" note, set off below a hairline. Phase 31 muted it further (smaller + tighter) — 9 ratios × the old explainer block dominated the page; the value + verdict + reading carry the primary signal and the explainer reads as a footnote. */.ratio__explain { margin-top: 9px; padding-top: 9px; margin-top: var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--rule); font-size: 0.74rem; font-size: var(--fs-2xs); color: var(--ink-faint); line-height: 1.4;}/* shown while the SEC sweep has not yet reached this symbol */
@@ -759,9 +747,13 @@ content: "\25BE";}/* ---------- recent SEC filings ---------- *//* ---------- recent SEC filings ---------- The .filing / .lead-change / .anomaly rows all share the shape "left-anchored tag · body · meta · external icon". The styles here are the canonical version; .lead-change and .anomaly only override what differs from a filing row. */.filings { padding: 2px 16px; padding: 2px var(--sp-4);}.filing-list {
@@ -781,24 +773,28 @@.filing__link { display: flex; align-items: center; gap: 12px; padding: 11px 0; gap: var(--sp-3); padding: 10px 0; color: var(--ink); transition: color 0.1s;}.filing__link:hover { color: var(--ink); color: var(--ink-dim);}.filing__form { @include mono; flex: none; min-width: 7ch; text-align: center; padding: 5px 7px; padding: 4px 7px; border: 1px solid var(--rule-strong); border-radius: 4px; font-size: 0.71rem; font-size: var(--fs-2xs); font-weight: 700; letter-spacing: 0.02em; color: var(--ink);}.filing__body {
@@ -810,29 +806,31 @@}.filing__desc { font-size: 0.88rem; font-size: var(--fs-sm); font-weight: 500;}.filing__link:hover .filing__desc { color: var(--ink-dim);}.filing__meta { font-size: 0.74rem; font-size: var(--fs-xs); color: var(--ink-faint);}.filing__ext { flex: none; width: 15px; height: 15px; width: 14px; height: 14px; color: var(--ink-faint); transition: color 0.1s, transform 0.1s;}.filing__link:hover .filing__ext { color: var(--ink-dim); transform: translate(2px, -2px);}/* ---------- leadership: roster + change feed ---------- */.leadership { padding: 14px 16px; padding: var(--sp-3) var(--sp-4);}.roster {
@@ -846,8 +844,8 @@ flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 16px; padding: 9px 0; gap: 2px var(--sp-4); padding: var(--sp-2) 0; border-top: 1px solid var(--rule);}
@@ -856,36 +854,40 @@}.roster__name { font-size: 0.9rem; font-size: var(--fs-md); font-weight: 600;}.roster__role { font-size: 0.8rem; font-size: var(--fs-xs); color: var(--ink-dim); text-align: right;}/* the provenance note, set off below a hairline like .ratio__explain */.roster__src { margin-top: 12px; padding-top: 11px; margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--rule); font-size: 0.74rem; font-size: var(--fs-xs); color: var(--ink-faint); line-height: 1.45;}/* recent officer/director changes, from 8-K item 5.02 */.lead-changes { margin-top: 16px; padding-top: 14px; margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--rule-strong);}.lead-changes__title { @include eyebrow; font-family: var(--font-serif); font-weight: 600; font-size: var(--fs-md); letter-spacing: -0.01em; color: var(--ink); margin-bottom: 2px; margin-bottom: var(--sp-1);}.lead-changes__list {
@@ -905,9 +907,14 @@.lead-change__link { display: flex; align-items: center; gap: 12px; padding: 11px 0; gap: var(--sp-3); padding: 10px 0; color: var(--ink); transition: color 0.1s;}.lead-change__link:hover { color: var(--ink-dim);}.lead-change__body {
@@ -919,22 +926,23 @@}.lead-change__desc { font-size: 0.88rem; font-size: var(--fs-sm); font-weight: 500;}.lead-change__link:hover .lead-change__desc { color: var(--ink-dim);}.lead-change__meta { font-size: 0.74rem; font-size: var(--fs-xs); color: var(--ink-faint);}.lead-change__link:hover .filing__ext { color: var(--ink-dim); transform: translate(2px, -2px);}/* ---------- per-ticker anomaly feed (Phase 16) ---------- */.anomalies { padding: 14px 16px; padding: var(--sp-3) var(--sp-4);}.anomaly-list {
@@ -954,10 +962,11 @@.anomaly__link { display: flex; align-items: center; gap: 12px; padding: 9px 0; gap: var(--sp-3); padding: var(--sp-2) 0; color: var(--ink); text-decoration: none; transition: color 0.1s;}a.anomaly__link:hover .anomaly__body {
@@ -965,9 +974,10 @@ a.anomaly__link:hover .anomaly__body {}.anomaly__date { @include mono; flex: none; min-width: 7ch; font-size: 0.78rem; font-size: var(--fs-xs); color: var(--ink-faint);}
@@ -976,7 +986,7 @@ a.anomaly__link:hover .anomaly__body { width: 1.4em; text-align: center; font-weight: 700; font-size: 0.95rem; font-size: var(--fs-md); color: var(--ink-faint);}
@@ -990,14 +1000,18 @@ a.anomaly__link:hover .anomaly__body {.anomaly__body { flex: 1; min-width: 0; font-size: 0.88rem; font-size: var(--fs-sm); line-height: 1.4;}.anomaly-list__src { margin: 12px 0 0; font-size: 0.74rem; margin: var(--sp-3) 0 0; padding-top: var(--sp-2); border-top: 1px solid var(--rule); font-size: var(--fs-xs); color: var(--ink-faint); font-style: italic; line-height: 1.45;}/* ---------- ETF fund profile ---------- */
@@ -1130,15 +1144,27 @@ a.anomaly__link:hover .anomaly__body {/* ---------- top holdings ---------- */.holdings { padding: 2px 16px; padding: 2px var(--sp-4);}/* Phase 31: 2-up on desktop so a 25-row holdings list doesn't reach off the page when the panel has the width to spare. The CSS columns layout keeps the existing single-list markup intact (no template change) and preserves reading order down-then-across. The break-inside avoids a row splitting across a column boundary. */.hold-list { margin: 0; padding: 0; list-style: none;}@media (min-width: $bp-md) { .hold-list { columns: 2; column-gap: var(--sp-6); }}/* each row carries a faint left-anchored fill sized to the holding's weight, scaled so the largest holding shown fills the rail */.hold {
@@ -1146,15 +1172,24 @@ a.anomaly__link:hover .anomaly__body { display: grid; grid-template-columns: 2.6ch 1fr auto auto; align-items: baseline; gap: 2px 12px; padding: 10px 7px; gap: 2px var(--sp-3); padding: 9px 7px; border-top: 1px solid var(--rule); break-inside: avoid;}.hold:first-child { border-top: none;}/* with the 2-column layout the visual "first child" rule needs to apply to whichever row starts each column — easiest via CSS column-rule */@media (min-width: $bp-md) { .hold-list { column-rule: 1px solid var(--rule); }}.hold__fill { position: absolute; left: 0;
@@ -1243,9 +1278,9 @@ a.anomaly__link:hover .anomaly__body { background: var(--well); color: var(--ink-dim); &--good { color: var(--ok); background: rgba(47, 125, 79, 0.10); } &--ok { color: var(--warn); background: rgba(178, 121, 50, 0.12); } &--bad { color: var(--bad); background: rgba(178, 59, 50, 0.10); } &--good { color: var(--up); background: var(--up-soft); } &--ok { color: var(--warn); background: var(--warn-soft); } &--bad { color: var(--down); background: var(--down-soft); }}.fund-about__summary {
@@ -1302,8 +1337,8 @@ a.anomaly__link:hover .anomaly__body { border-bottom: 1px solid var(--rule-strong);}.ret-cell--up { color: var(--ok); }.ret-cell--down { color: var(--bad); }.ret-cell--up { color: var(--up); }.ret-cell--down { color: var(--down); }.ret-cell--ann { color: var(--ink-dim); font-weight: 500; }.ret-panel__src {
modified
templates/pages/backtest.html
@@ -9,17 +9,15 @@ <a class="page-head__link" href="/">Back to home</a> </div> <p class="bt-blurb">Walks the picker over historical daily prices and simulates following its top 5 picks at each rebalance. Each strategy shown is rebalanced equal-weight at the end of every horizon period and compared to <code>^SPX</code> over the same window.</p> <p class="bt-blurb">Walks the picker over historical daily prices, rebalancing equal-weight into its top 5 picks every horizon, and compares the simulated capital to <code>^SPX</code> over the same window.</p> <p class="disclaimer">For fun and testing. Not financial advice. At each rebalance the picker grades a stock only against fundamentals that would actually have been filed by then (latest annual report at least 90 days before the rebalance) and only against closes up to that date, so the backtest is genuinely out-of-sample. Frictionless: no transaction costs, slippage, or taxes modelled.</p> <p class="disclaimer">For fun and testing, not financial advice. The picker grades a stock only against fundamentals filed at least 90 days before each rebalance and against closes up to that date, so the result is genuinely out-of-sample. Frictionless: no costs, slippage, or taxes modelled.</p> {# Horizon tabs: clicking re-runs the backtest in place via the JSON endpoint, so the URL stays /backtest while the rendered horizon
@@ -32,7 +30,19 @@ </div> <div id="bt-panel" class="bt-panel" role="tabpanel"> <p class="bt-status">Loading backtest…</p> {# A bone skeleton while the JSON endpoint loads, so the page does not collapse into bare "Loading…" text. Replaced in place by the JS once the snapshot arrives. #} <div class="bt-skeleton" data-role="skeleton"> <div class="bt-skeleton__stats"> <div class="bt-skeleton__stat"></div> <div class="bt-skeleton__stat"></div> <div class="bt-skeleton__stat"></div> <div class="bt-skeleton__stat"></div> </div> <div class="bt-skeleton__chart"></div> <p class="bt-status">Loading backtest…</p> </div> </div></div>
modified
templates/pages/industries_index.html
@@ -11,10 +11,22 @@ </div> {% if empty %} <p class="ind-empty">No stocks have been classified yet. The Yahoo <code>assetProfile</code> sweep runs in the background and fills in over time — check the <a href="/health">data health page</a> to follow its progress.</p> <section class="empty"> <svg viewBox="0 0 64 64" fill="none" aria-hidden="true"> {# stacked ledger columns — a quiet hint at the per-sector composition this page will show once classifications land. #} <rect x="6" y="40" width="8" height="18" stroke="currentColor" stroke-width="2.4" rx="1.5"/> <rect x="18" y="28" width="8" height="30" stroke="currentColor" stroke-width="2.4" rx="1.5"/> <rect x="30" y="18" width="8" height="40" stroke="currentColor" stroke-width="2.4" rx="1.5"/> <rect x="42" y="32" width="8" height="26" stroke="currentColor" stroke-width="2.4" rx="1.5"/> <rect x="54" y="44" width="8" height="14" stroke="currentColor" stroke-width="2.4" rx="1.5"/> <line x1="2" y1="60" x2="62" y2="60" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> </svg> <h1>Industries are still classifying</h1> <p>The Yahoo <code>assetProfile</code> sweep runs in the background and fills in over time. Once a stock has a sector and industry it shows up here; follow the sweep on the <a href="/health">data health page</a>.</p> </section> {% else %} <p class="ind-blurb">{{ total }} curated stocks across {{ sectors|length }} sectors and {{ total_industries }} industries.