Close out the widescreen report redesign: merge the full frontend stack - #18
Merged
Merged
Conversation
frontend/app.js (new): a pending/applied state machine — sel, period
and rfIdx are what the rail shows; appliedSel/appliedPeriod/appliedRfIdx
are what the current result was actually solved from. They start equal
(the app auto-runs once on load with the default universe, so there's
always a report on screen rather than an empty first-load state) and
diverge the moment a control changes, which is what flips the run
button between "Run analysis" and "Re-run analysis".
Wired up:
- Universe chips (toggle, minimum-2 floor enforced as a no-op below it)
- History window segment + risk-free slider (live-updating display)
- Run -> POST /api/analyze, running overlay, and an error card with a
Retry action for 400/500s (no design exists for this yet — see the
handoff's own Gaps list — so this reuses the card primitive plus the
semantic error color rather than inventing new visual language)
- Header tags/status/units toggle, reflecting the applied run once one
exists
- The KPI band: live headline/lede and the 5 KPI cards, monthly/annual
unit conversion threaded through all of them
The frontier selection (iF) and cash blend aren't wired yet — both
default to "no selection" (tangency, 0% cash), so the KPI band always
reads max_sharpe_portfolio for now. Both land with their own sections
in upcoming PRs and this file already reads through that same state
shape so they slot in without a rework.
Also: .mw-overlay's own `display: grid` outranked the UA stylesheet's
`[hidden]{display:none}` at equal specificity (source order), so a
"hidden" overlay still intercepted clicks — found by a headless-browser
click test, fixed with an explicit `.mw-overlay[hidden]{display:none}`.
Verified with a headless Chromium against a mocked /api/analyze
(matching how tests/test_api.py mocks fetch_prices): happy path KPI
values, monthly<->annual conversion, the chip minimum-2 floor, and the
error/retry path all checked by script, plus visual screenshots.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The signature interaction of the whole redesign: an SVG chart in
absolute risk/return space (both axes start at zero, not min-max
normalized) with the frontier curve, single-asset points, the capital
allocation line, minimum-variance/tangency markers, and a selection
that tracks pointer drag 1:1, plus a stats panel that follows it.
frontend/app.js:
- resolveSelection(): the shared "which frontier point is selected, and
what do we call it" logic, used by both the frontier stats panel and
the KPI headline (which no longer hardcodes the tangency copy — it
now shows the same position-relative label the frontier does when a
non-default point is selected).
- computeFrontierGeometry(): scale functions and point coordinates,
derived from the real /api/analyze response — no client-side solver,
per the handoff ("do not port the prototype's solver").
- Base vs. selection rendering split: the SVG and its static layers
(grid, ticks, frontier curve, CAL, per-asset dots, min-var/tangency
markers) are only rebuilt when the result or the units changes;
dragging only updates the crosshair/marker attributes and the stats
panel text. This matters because pointerdown captures the pointer on
the SVG element itself — rebuilding it mid-drag (e.g. from an
innerHTML replace on every pointermove) would silently drop that
capture and break the gesture.
- pickFrontierPoint(): maps a pointer x-position to the nearest
frontier point by risk, accounting for the plot box's left inset
rather than a naive fraction of element width — same as the handoff.
Two real bugs found by headless-browser testing, not by inspection:
- Toggling units (monthly/annual) didn't update the chart's axis
titles or tick labels, only the stats panel — because those are part
of the "base" HTML, which is keyed only on the result reference, and
toggling units doesn't change that reference. Fixed by keying the
base rebuild on (result, units) together. Safe to do because a units
toggle is a discrete click, never a pointermove mid-drag.
- (Carried a class of risk in from PR 3, re-confirmed here): any bug
that forces a full base rebuild during a drag would drop pointer
capture. Verified this isn't happening by dragging, then toggling
units, and confirming the selection survives the rebuild unchanged.
Verified with headless Chromium against a synthetic /api/analyze
response shaped like a real efficient frontier (concave, tangency
mid-curve): mouse-drag selection, the Min variance / Max Sharpe jump
buttons, and the monthly/annual toggle, each checked against the
rendered DOM plus screenshots.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
frontend/app.js: - The donut (stroke-dasharray arcs + an HTML center overlay — SVG <text> at this weight shapes to zero width, per the handoff), the cash-blend slider, and a weights table that keeps two frames of the same numbers clearly separate: "in risky mix" (sums to 100%) and "of capital" (sums to 1 - cash) — a mixing-up of these two was called out in the handoff as a real review bug on this project, so both the percentage *and* the bar-fill width for a row always come from the same frame. - cashBlend(): a plain weighted average of the selected portfolio and cash. This is a deliberate departure from the handoff's own suggestion to prefer the API's capital_allocation_line[] — that array is anchored to the tangency portfolio specifically (that's what makes it a capital allocation line: blending cash with any other point doesn't dominate the frontier). Now that PR 4 lets the frontier selection move off tangency, the API array can't answer "blend cash with *this* selected point" for a non-tangency selection — but the handoff's own interactive prototype already does exactly this local blend for whatever point is selected, so this follows the prototype's actual behavior over the README's simplifying prose. - Fixed a real inconsistency this surfaced: the KPI band's Expected return/Volatility are supposed to be the cash-blended figures (only Sharpe is unaffected by cash, per its own "unchanged by the cash blend" note) — PR 3 had them reading the raw portfolio figures instead, which happened to be harmless while cash was always 0. Fixed now that cash is live, using the same cashBlend() helper. - Same base/update split as the frontier chart, for the same reason: the cash `<input type=range>` fires `input` continuously while being dragged, and rebuilding it mid-drag (e.g. from an innerHTML replace on every tick) would interrupt the browser's own drag gesture on it. Only the row identity (tickers/names/colors) is "base"; every number, bar width, and the donut are refreshed on every render without touching the slider element itself. frontend/style.css: adds the shared "not a <table>" grid-row primitives (.mw-table, .mw-grid-row, .mw-swatch, .mw-bar-track/.mw-bar-fill) that the correlation, per-asset-statistics and saved-runs sections will reuse in upcoming PRs, plus the allocation-specific layout. Verified with headless Chromium against a synthetic /api/analyze response: donut/table/KPI band agree at cash=0% and cash=50% (checked the exact blended numbers, not just that something rendered), the cash-row and per-asset rows sum correctly in both frames, and blending cash off a dragged (non-tangency) frontier selection reads that selection's own figures rather than silently falling back to tangency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both bundled in one PR since they share the grid-row table pattern PR 5 already built (.mw-table/.mw-grid-row/.mw-num), and neither has any interactive state of its own — no drag, no slider — so both are plain full-rebuild-on-render sections, unlike frontier/allocation. frontend/app.js: - correlationSectionHtml(): a CSS-grid matrix (not a <table>, matching the rest of the report), cell color linearly interpolated in RGB from neutral-900 to accent-600. Text stays ink-dark at every value — this ramp (unlike the mobile design's) never needs a light/dark contrast threshold, since it tops out light enough that dark text stays correct throughout. The least/most-correlated-pair callout scans only the upper triangle so each pair is considered once. - assetStatsSectionHtml(): standalone per-asset expected return/ volatility (from asset_statistics — independent of any portfolio), a client-computed standalone Sharpe, and the Weight column reads through selectedPortfolio() — so, matching the handoff, it updates live as the frontier selection is dragged, not just at the tangency default. Zero/near-zero weights dim to neutral-600 rather than being hidden, so an asset the optimizer declined stays visible. Verified with headless Chromium against a synthetic /api/analyze response: the matrix diagonal is 1.00 everywhere, the least/most correlated pairs match a manual scan of the input matrix, and the per-asset Weight column changes when jumping to the minimum-variance point on the frontier (confirming it tracks selection, not a fixed snapshot) — plus a full-page screenshot checked against the handoff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This is the section the handoff explicitly leaves undesigned ("saving
is not implemented — the three rows are fixtures... see Gaps"). Per
the earlier agreed decision, v1 persists to this browser's
localStorage — no backend changes, no new endpoints.
Everything in the "Saved runs" comment block in frontend/app.js is
this project's own invention, not from the handoff:
- A "Save this run" button (disabled until a run has succeeded),
prompting for a name pre-filled with a sensible default (`"{n}
assets · {period}"`) so accepting the default is a single Enter.
- Storage as a JSON array under `markowizard.savedRuns`, one entry per
save: the reload parameters (sel/period/rfIdx) plus the raw
(pre-cash) selected-portfolio figures at save time. Reads/writes are
wrapped in try/catch — private browsing or a full quota degrades to
"didn't persist" rather than breaking the report.
- Load replaces sel/period/rfIdx from the saved entry (discarding any
unrelated pending edits in the rail) and re-runs the analysis, same
as the handoff describes ("loading a run replaces the universe,
window and selection").
- Delete, and an empty state for first-time users — neither specified,
but both necessary for this to be a usable feature rather than a
write-only list.
The saved-runs list itself isn't gated on the current result the way
every other section is: it stays visible and usable (Load included)
even after a failed re-run, so a bad refresh doesn't strand the user
away from a previously-good report.
Also adds the closing disclaimer paragraph from the handoff's
"Disclaimer" spec, the one static piece of the report not yet in the
tree. With this PR every section in the layout shell (#12) is now
built: the widescreen report redesign is complete end to end.
Verified with headless Chromium: save (with a custom name) persists to
localStorage with the correct reload parameters; loading a saved run
triggers a fresh /api/analyze call with *that run's* tickers/period
even when the rail's pending controls had since been changed to
something else; delete and the empty state both render correctly; and
cancelling the name prompt saves nothing.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this PR exists
PRs #13–17 were stacked (each targeting the previous feature branch, not
main) and squash-merged in sequence — correctly, cumulatively, all the way up to thecorrelation-assets-sectionsbranch. But the final step, merging that branch up intofrontend-foundation/main, never happened, so none of that work actually reachedmaindespite #14–17 showing as "Merged" on GitHub. (#13 also shows "Closed" rather than "Merged" — cosmetic only; its content flowed through via #14's squash ontocontrol-rail-kpi, nothing was lost.)Separately, the branch these PRs were stacked on (
frontend-foundation) was cut before #11 merged, so it was also missingasset_statistics/min_variance_portfolioonAnalyzeResponse— the same gap I had to patch locally to demo this to you.This PR is
correlation-assets-sections(the fully cumulative tip — verified byte-identical tosaved-runsonfrontend/{app.js,index.html,style.css}) rebased onto currentmain. Git recognized thefrontend-foundationcommit's content as already upstream (it's PR #12, squash-merged with a different hash) and dropped it automatically — the diff below is exactly the previously-missing PR #13–17 content, nothing duplicated.What lands
The complete widescreen report: control rail + KPI band, efficient frontier with drag selection, optimal allocation (donut + cash blend), correlation matrix, per-asset statistics, and saved runs — everything from #13 through #17, described in each of those PRs.
Verification
uv run pytest(27 passed) anduv run ruff check(clean) on this exact branch state. Also ran the combined app locally against liveyfinancedata (not mocked) end-to-end before opening this.🤖 Generated with Claude Code