diff --git a/frontend/e2e/chain.spec.ts b/frontend/e2e/chain.spec.ts index 41954ed..e90d7c1 100644 --- a/frontend/e2e/chain.spec.ts +++ b/frontend/e2e/chain.spec.ts @@ -213,11 +213,13 @@ test("compares every model on a narrow screen without nesting the disclosure tri await page.getByRole("combobox", { name: "Stock forecast model" }).selectOption("student_t_ewma") const first = page.locator(".mobile-option-row").first() await expect(first.locator(".odds-physical")).toContainText("Student-t EWMA") - expect(await first.evaluate((row) => row.querySelector(".mobile-row-summary")!.contains(row.querySelector(".mobile-model-compare details")))).toBe(false) + expect(await first.evaluate((row) => row.querySelector(".mobile-row-summary")!.contains(row.querySelector(".mobile-model-compare button")))).toBe(false) await first.getByText("Compare models").click() - await expect(first).toContainText("GJR-GARCH Student-t") - await expect(first).toContainText("N=0") - await expect(first).toContainText("sparse_strikes") + const comparison = page.getByRole("dialog", { name: "Compare models" }) + await expect(comparison).toContainText("GJR-GARCH Student-t") + await comparison.locator(".model-result").nth(2).locator("summary.model-result-heading").click() + await expect(comparison).toContainText("N=0") + await expect(comparison).toContainText("sparse strikes") expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) }) diff --git a/frontend/e2e/watchlist.spec.ts b/frontend/e2e/watchlist.spec.ts index ccfee24..dd80896 100644 --- a/frontend/e2e/watchlist.spec.ts +++ b/frontend/e2e/watchlist.spec.ts @@ -117,6 +117,46 @@ test("watches a contract from a mobile disclosure and reports deduplication", as await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) }) +test("compares all models in a bounded dialog on desktop and mobile", async ({ page }, testInfo) => { + const comparisonItem = { + ...item, + physical_models: [ + { method: "lognormal_ewma", status: "available", itm_pct_tenths: 612, otm_pct_tenths: 388, atm_pct_tenths: 0, price_basis: "completed_close", support: 60 }, + { method: "student_t_ewma", status: "pending", reason: "candidate_not_prepared" }, + { method: "gjr_garch_t", status: "unavailable", reason: "gjr_parameters_invalid" }, + ], + market_models: [ + { method: "regimelib", status: "available", itm_pct_tenths: 583, otm_pct_tenths: 417, bound_low_pct_tenths: 452, bound_high_pct_tenths: 753 }, + { method: "constrained_call_curve", status: "unavailable", reason: "clean_strikes_do_not_bracket_contract" }, + ], + } + await page.route("**/api/watchlist**", (route) => route.fulfill({ json: { items: [comparisonItem] } })) + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 800 }) + await page.goto("/watchlist") + await page.getByRole("button", { name: "Compare models" }).click() + const dialog = page.getByRole("dialog", { name: "Compare models" }) + await expect(dialog).toBeVisible() + await expect(dialog.locator(".model-result")).toHaveCount(5) + await expect(dialog.locator(".model-result details[open]")).toHaveCount(0) + await expect(dialog).toContainText("gjr parameters invalid") + const bounds = await dialog.evaluate((element) => { + const rect = element.getBoundingClientRect() + return { width: rect.width, height: rect.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth } + }) + expect(bounds.width).toBeLessThanOrEqual(width - 16) + expect(bounds.height).toBeLessThanOrEqual(800) + expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.clientWidth + 1) + await page.screenshot({ path: testInfo.outputPath(`model-comparison-${width}.png`) }) + await dialog.getByText("EWMA lognormal").click() + await expect(dialog.getByText("60 completed returns")).toBeVisible() + if (width === 390) await page.keyboard.press("Escape") + else await dialog.getByRole("button", { name: "Close model comparison" }).click() + await expect(dialog).toBeHidden() + await expect(page.getByRole("button", { name: "Compare models" })).toBeFocused() + } +}) + test("shows dated market odds and close-based outcomes, then tracks the result check", async ({ page }) => { let completeJob = false await page.route("**/api/watchlist", async (route) => { @@ -217,8 +257,10 @@ test("shows shared model evidence in the mobile watchlist comparison", async ({ await page.goto("/watchlist") const odds = page.getByRole("region", { name: "Odds estimates" }) await odds.getByText("Compare models").click() - await expect(odds).toContainText("N=0") - await expect(odds).toContainText("sparse_strikes") + const comparison = page.getByRole("dialog", { name: "Compare models" }) + await comparison.locator(".model-result").first().locator("summary.model-result-heading").click() + await expect(comparison).toContainText("N=0") + await expect(comparison).toContainText("sparse strikes") expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320) }) diff --git a/frontend/src/ItmChain.browser.test.tsx b/frontend/src/ItmChain.browser.test.tsx index 58e117e..0647464 100644 --- a/frontend/src/ItmChain.browser.test.tsx +++ b/frontend/src/ItmChain.browser.test.tsx @@ -292,11 +292,10 @@ describe("chain interactions", () => { expect(first.textContent).toContain("70.0% ITM") expect(first.textContent).toContain("Student-t EWMA") const trigger = first.querySelector(".mobile-row-summary")! - const compare = first.querySelector(".mobile-model-compare details")! + const compare = first.querySelector(".mobile-model-compare button")! expect(trigger.contains(compare)).toBe(false) - fireEvent.click(compare.querySelector("summary")!) - expect((compare as HTMLDetailsElement).open).toBe(true) - expect(compare.textContent).toContain("quotes_pending") + fireEvent.click(compare) + expect(screen.getByRole("dialog", { name: "Compare models" }).textContent).toContain("quotes_pending") }) it("includes odds in the mobile contract summary before expanding details", async () => { diff --git a/frontend/src/ModelComparison.browser.test.tsx b/frontend/src/ModelComparison.browser.test.tsx index 93673f2..8cb4ac0 100644 --- a/frontend/src/ModelComparison.browser.test.tsx +++ b/frontend/src/ModelComparison.browser.test.tsx @@ -19,19 +19,26 @@ it("shows all physical and market methods, including pending and unavailable res { method: "regimelib", status: "available", itm_pct_tenths: 520, otm_pct_tenths: 480, bound_low_pct_tenths: 400, bound_high_pct_tenths: 600 }, { method: "constrained_call_curve", status: "unavailable", reason: "clean_strikes_do_not_bracket_contract" }, ] - const { container } = render() - const disclosure = container.querySelector("details")! - expect(disclosure.open).toBe(false) - fireEvent.click(screen.getByText("Compare models")) - expect(disclosure.open).toBe(true) - expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("Student-t EWMA") - expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("Student-t EWMASelected") + render() + const trigger = screen.getByRole("button", { name: "Compare models" }) + expect(screen.queryByRole("dialog")).toBeNull() + fireEvent.click(trigger) + expect(screen.getByRole("dialog", { name: "Compare models" })).toBeTruthy() + const physicalSection = screen.getByRole("region", { name: "Physical forecast models" }) + expect(physicalSection.textContent).toContain("Student-t EWMA") + expect(physicalSection.textContent).toContain("Student-t EWMASelected") expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("Model spread: 3.0 percentage points across 2 completed-close models") + expect(physicalSection.querySelectorAll(".model-result details[open]")).toHaveLength(0) + fireEvent.click(screen.getByText("Scaled empirical")) expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("Independent history blocks 35 (minimum 30)") expect(screen.getByRole("region", { name: "Physical forecast models" }).textContent).toContain("needs_500_sessions") expect(screen.getByRole("region", { name: "Risk-neutral market models" }).textContent).toContain("Regimelib") expect(screen.getByRole("region", { name: "Risk-neutral market models" }).textContent).toContain("clean_strikes_do_not_bracket_contract") - expect(disclosure.textContent).toContain("Quote bounds") + fireEvent.click(screen.getByText("Regimelib")) + expect(screen.getByRole("dialog").textContent).toContain("Quote bounds") + fireEvent.click(screen.getByRole("button", { name: "Close model comparison" })) + expect(screen.queryByRole("dialog")).toBeNull() + expect(document.activeElement).toBe(trigger) }) it("reports simulation precision as sampling error rather than forecast confidence", () => { @@ -41,6 +48,7 @@ it("reports simulation precision as sampling error rather than forecast confiden fit_ms: 13, lookup_ms: 2, data_hash: "abc123456789more", }]} market={[]} selected="student_t_ewma" />) fireEvent.click(screen.getByText("Compare models")) + fireEvent.click(screen.getByText("Student-t EWMA")) const physical = screen.getByRole("region", { name: "Physical forecast models" }).textContent expect(physical).toContain("4,096 simulated paths") expect(physical).toContain("Maximum 95% simulation error ±1.6 percentage points; model uncertainty excluded") @@ -65,6 +73,9 @@ it("keeps retrospective and as-issued evidence distinct and reports N=0 without const replay = { ...empty, independent_date_blocks: 20, ticker_origin_horizon_units: 500, tickers: 20, coverage_basis: "recorded_contract_cells_with_baseline_issuance", replay_scheduled_units: 25, replay_baseline_available_units: 20, replay_fit_coverage: 0.8, replay_rejection_reasons: { split_affected: 5 }, brier: { baseline: 0.22, candidate: 0.20, paired_delta: -0.02, bootstrap_95: [-0.03, -0.01] } } render() fireEvent.click(screen.getByText("Compare models")) + fireEvent.click(screen.getByText("Student-t EWMA")) + fireEvent.click(screen.getByText(/Prospective as-issued · N=0/)) + fireEvent.click(screen.getByText(/Retrospective replay · N=500/)) const prospective = screen.getByRole("region", { name: "Prospective as-issued evidence" }) const replayPanel = screen.getByRole("region", { name: "Retrospective replay evidence" }) expect(prospective.textContent).toContain("N=0") diff --git a/frontend/src/ModelComparison.tsx b/frontend/src/ModelComparison.tsx index a296c2d..a397deb 100644 --- a/frontend/src/ModelComparison.tsx +++ b/frontend/src/ModelComparison.tsx @@ -1,4 +1,5 @@ -import { useState } from "react" +import { useEffect, useId, useRef, useState } from "react" +import { createPortal } from "react-dom" import type { MarketOddsView, PredictiveOddsView } from "./generated/types.gen" import { marketModelName, physicalModelName, type PhysicalModel } from "./forecastModels" import { unsignedPercentTenths } from "./format" @@ -41,9 +42,13 @@ function reasonList(value: unknown): string { : "None recorded" } +function reasonLabel(reason: string | null | undefined): string { + return reason ? reason.replaceAll("_", " ") : "No valid estimate" +} + function evidencePanel(title: string, value: unknown) { const report = data(value) - if (!report) return

{title}: no report available. N=0 · Brier N/A · significance not estimable.

+ if (!report) return

{title}: no report available

const brier = data(report.brier) const logLoss = data(report.log_loss) const blocks = number(report.independent_date_blocks) ?? 0 @@ -56,8 +61,9 @@ function evidencePanel(title: string, value: unknown) { ? `[${score(interval[0])}, ${score(interval[1])}]` : "significance not estimable" const calibration = data(report.calibration_by_side) const latency = data(report.latency_ms) - return
-
{title}
+ return
+ {title} · N={count(units)} · Brier {units ? score(brier?.candidate) : "N/A"} +

{title === "Retrospective replay" ? "Current-vintage screening; not an as-issued accuracy claim." : "Prospective as-issued accuracy; descriptive until enough outcomes mature."}

Report {typeof report.generated_at === "string" ? report.generated_at.slice(0, 10) : "date unavailable"} · model {String(report.model_version ?? "unknown")} · input {String(report.input_version ?? "unknown")} · report hash {typeof report.report_hash === "string" ? report.report_hash.slice(0, 12) : "unrecorded"}

{typeof report.audit_session === "string" ?

Frozen audit cohort: {report.audit_session}{typeof report.audit_frozen_at === "string" ? ` · captured ${report.audit_frozen_at.slice(0, 10)}` : ""}

: null} @@ -80,7 +86,8 @@ function evidencePanel(title: string, value: unknown) { : "N=0 · N/A"}

})}

{latency && "p50" in latency ? `Forecast p50/p95 ${milliseconds(latency.p50)}/${milliseconds(latency.p95)}` : `Preparation p50/p95 ${milliseconds(data(latency?.prepare)?.p50)}/${milliseconds(data(latency?.prepare)?.p95)} · lookup p50/p95 ${milliseconds(data(latency?.lookup)?.p50)}/${milliseconds(data(latency?.lookup)?.p95)}`}

-
+ + } function PhysicalResult({ model, selected, evidenceIndex }: { model: PredictiveOddsView; selected: PhysicalModel; evidenceIndex?: Data | null }) { @@ -91,14 +98,16 @@ function PhysicalResult({ model, selected, evidenceIndex }: { model: PredictiveO : model.method === "student_t_ewma" || model.method === "gjr_garch_t" ? `${count(model.support)} simulated paths` : `${count(model.support)} completed returns` - return
  • -
    {physicalModelName(model.method)}{model.method === selected ? Selected : null}
    -

    {valid ? `${unsignedPercentTenths(model.itm_pct_tenths)} ITM · ${unsignedPercentTenths(model.otm_pct_tenths)} OTM` : `${model.status === "pending" ? "Pending" : "Unavailable"}: ${model.reason ?? "No valid estimate"}`}

    + return
  • + {physicalModelName(model.method)}{model.method === selected ? Selected : null}{valid ? `${unsignedPercentTenths(model.itm_pct_tenths)} ITM · ${unsignedPercentTenths(model.otm_pct_tenths)} OTM` : `${model.status === "pending" ? "Pending" : "Unavailable"} · ${reasonLabel(model.reason)}`} +
    + {!valid && model.reason ?

    Reason code: {model.reason}

    : null}

    {model.price_basis === "validated_underlying_quote" ? "Validated stock quote" : model.price_basis === "completed_close" ? "Completed stock close" : "Input basis unavailable"} · input {model.price_as_of ?? model.as_of_session ?? "date unavailable"} · expiry {model.expiry_session ?? "unknown"} · version {model.model_version ?? "unknown"} · {support}

    {model.method === "empirical_scaled" ? `Independent history blocks ${model.independent_blocks ?? "N/A"} (minimum 30)` : model.simulation_error_95_pct_tenths != null ? `Maximum 95% simulation error ±${(model.simulation_error_95_pct_tenths / 10).toFixed(1)} percentage points; model uncertainty excluded` : "Simulation precision N/A"} · fit {milliseconds(model.fit_ms)} · lookup {milliseconds(model.lookup_ms)} · data {model.data_hash?.slice(0, 12) ?? "hash unavailable"}

    {evidencePanel("Prospective as-issued", evidence?.prospective)} {evidencePanel("Retrospective replay", evidence?.retrospective)} -
  • + + } function MarketResult({ model }: { model: MarketOddsView }) { @@ -107,14 +116,16 @@ function MarketResult({ model }: { model: MarketOddsView }) { const heldOut = number(evidence?.held_out_count) ?? 0 const inside = number(evidence?.held_out_inside) ?? 0 const check = (value: unknown) => value === true ? "yes" : value === false ? "no" : "N/A" - return
  • -
    {marketModelName(model.method)} · risk-neutral{model.method === "regimelib" ? Benchmark : null}
    -

    {valid ? `${unsignedPercentTenths(model.itm_pct_tenths)} ITM · ${unsignedPercentTenths(model.otm_pct_tenths)} OTM` : `${model.status === "pending" ? "Pending" : "Unavailable"}: ${model.reason ?? "No valid estimate"}`}

    + return
  • + {marketModelName(model.method)}{model.method === "regimelib" ? Benchmark : null}{valid ? `${unsignedPercentTenths(model.itm_pct_tenths)} ITM · ${unsignedPercentTenths(model.otm_pct_tenths)} OTM` : `${model.status === "pending" ? "Pending" : "Unavailable"} · ${reasonLabel(model.reason)}`} +
    + {!valid && model.reason ?

    Reason code: {model.reason}

    : null}

    {quoteSupportLabel(model) ?? "Quote bounds N/A"} · session {model.session_date ?? "unavailable"} · version {model.model_version ?? "unknown"}

    Held-out quote coverage {heldOut ? `${inside}/${heldOut} (${percent(inside / heldOut)})` : "N=0 · N/A"} · paired quotes {count(evidence?.paired_held_out_count)} · bid/ask fit {check(evidence?.bid_ask_fit)} · one-tick stable {check(evidence?.one_tick_stable)}

    {number(evidence?.paired_shadow_inside) != null && number(evidence?.paired_benchmark_inside) != null ?

    Matched held-out fit: curve {count(evidence?.paired_shadow_inside)} vs benchmark {count(evidence?.paired_benchmark_inside)} of {count(evidence?.paired_held_out_count)} quotes.

    : null}

    Fit {milliseconds(evidence?.fit_ms)} · refresh {milliseconds(evidence?.refresh_ms)} · rejections: {reasonList(evidence?.rejection_reasons)}

    -
  • + + } export default function ModelComparison({ physical, market, selected, evidenceIndex }: { @@ -124,20 +135,29 @@ export default function ModelComparison({ physical, market, selected, evidenceIn evidenceIndex?: Data | null }) { const [open, setOpen] = useState(false) + const titleId = useId() + const dialog = useRef(null) + const trigger = useRef(null) + useEffect(() => { + if (open) dialog.current?.showModal() + }, [open]) const comparable = physical?.filter((model) => predictiveAvailable(model) && model.price_basis === "completed_close" && model.itm_pct_tenths != null) ?? [] const disagreement = comparable.length >= 2 ? ((Math.max(...comparable.map((model) => model.itm_pct_tenths!)) - Math.min(...comparable.map((model) => model.itm_pct_tenths!))) / 10).toFixed(1) : null - return
    - setOpen((current) => !current)}>Compare models - {open ?
    -

    Stock-close forecasts

    Real-world expiry-close odds. Your selection sets the compact odds and hypothetical risk; accuracy evidence remains separate.

    -

    Model spread: {disagreement == null ? "N/A (fewer than two comparable estimates)" : `${disagreement} percentage points across ${comparable.length} completed-close models`}. This shows method sensitivity, not forecast accuracy.

    -
      {physical?.length ? physical.map((model, index) => ) :
    • No physical model results yet.
    • }
    -
    -

    Option-price estimates

    Risk-neutral odds from option quotes. Quote fit does not measure realized forecast accuracy.

    -
      {market?.length ? market.map((model, index) => ) :
    • No market model results yet.
    • }
    -
    -
    : null} -
    + return <> + + {open ? createPortal( { setOpen(false); trigger.current?.focus() }}> +

    Compare models

    Different methods can disagree. Their spread is not an accuracy or confidence score.

    +
    +

    Stock-close forecasts Real-world expiry-close odds

    Your selection sets the compact odds and hypothetical risk.

    +

    Model spread: {disagreement == null ? "N/A (fewer than two comparable estimates)" : `${disagreement} percentage points across ${comparable.length} completed-close models`}

    +
      {physical?.length ? physical.map((model, index) => ) :
    • No physical model results yet.
    • }
    +
    +

    Option-price estimates Risk-neutral odds

    Quote fit does not measure realized forecast accuracy.

    +
      {market?.length ? market.map((model, index) => ) :
    • No market model results yet.
    • }
    +
    +
    +
    , document.body) : null} + } diff --git a/frontend/src/index.css b/frontend/src/index.css index d76bc36..290cfb8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -243,20 +243,41 @@ .watch-prior-odds { margin-top: 12px; color: var(--muted-foreground); font-size: .78rem; line-height: 1.5; } .watch-prior-odds summary { width: fit-content; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } .watch-prior-odds p { margin: 3px 0 0; } - .model-comparison { max-width: 100%; margin-top: 8px; color: var(--muted-foreground); font-size: .76rem; line-height: 1.4; } - .model-comparison > summary { width: fit-content; color: var(--foreground); font-weight: 700; cursor: pointer; text-decoration: underline; text-decoration-color: var(--primary); text-underline-offset: 3px; } - .model-comparison > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } - .model-comparison-body { display: grid; gap: 14px; width: min(34rem, 80vw); max-width: 100%; margin-top: 9px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); text-align: left; white-space: normal; overflow-wrap: break-word; } - .model-comparison-body h4, .model-evidence h5 { margin: 0 0 4px; color: var(--foreground); font-size: .79rem; } - .model-comparison-body p { margin: 0 0 5px; } - .model-comparison-body ul { display: grid; gap: 9px; margin: 7px 0 0; padding: 0; list-style: none; } - .model-result { min-width: 0; padding: 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); } - .model-result-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 8px; color: var(--foreground); } - .model-result-heading strong { font-size: .8rem; } - .model-tag { color: var(--foreground); font-size: .7rem; font-weight: 700; } - .model-result > p { margin: 5px 0 0; } - .model-evidence { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); } - .model-evidence p { margin: 4px 0; } + .model-comparison-trigger { margin-top: 8px; padding: 0; border: 0; background: none; color: var(--foreground); font-size: .76rem; font-weight: 700; text-decoration: underline; text-decoration-color: var(--primary); text-underline-offset: 3px; cursor: pointer; } + .model-comparison-trigger:focus-visible, .model-comparison-dialog summary:focus-visible, .model-comparison-close:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + .model-comparison-dialog { width: min(58rem, calc(100vw - 32px)); max-width: none; max-height: min(88dvh, 860px); margin: auto; padding: 0; overflow: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); color: var(--foreground); box-shadow: 0 24px 70px rgb(0 0 0 / .3); font-size: .87rem; line-height: 1.45; } + .model-comparison-dialog::backdrop { background: rgb(0 0 0 / .55); } + .model-comparison-header { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 18px 22px; border-bottom: 1px solid var(--border); background: var(--card); } + .model-comparison-header h2 { margin: 0 0 3px; font-size: 1.2rem; } + .model-comparison-header p, .model-comparison-body p { margin: 0; color: var(--muted-foreground); } + .model-comparison-close { flex: none; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); color: var(--foreground); cursor: pointer; } + .model-comparison-body { display: grid; gap: 24px; padding: 20px 22px 24px; overflow-wrap: break-word; } + .model-comparison-body h3 { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; margin: 0 0 3px; font-size: 1rem; } + .model-comparison-body h3 span { color: var(--muted-foreground); font-size: .8rem; font-weight: 500; } + .model-comparison-body .model-spread { margin-top: 5px; font-size: .8rem; } + .model-comparison-body ul { display: grid; gap: 6px; margin: 12px 0 0; padding: 0; list-style: none; } + .model-result { min-width: 0; border: 1px solid var(--border); border-radius: 6px; background: var(--secondary); } + .model-result-heading { display: flex; justify-content: space-between; gap: 8px 16px; align-items: baseline; min-width: 0; padding: 10px 12px; color: var(--foreground); cursor: pointer; list-style: none; } + .model-result-heading::-webkit-details-marker, .model-evidence > summary::-webkit-details-marker { display: none; } + .model-result-heading::before { content: '▸'; flex: none; color: var(--muted-foreground); } + .model-result details[open] > .model-result-heading::before { content: '▾'; } + .model-result-heading strong { display: inline-flex; flex: 1; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: .87rem; } + .model-result-value { flex: none; font-weight: 700; font-variant-numeric: tabular-nums; } + .model-result-unavailable { color: var(--muted-foreground); font-size: .8rem; text-align: right; } + .model-tag { padding: 1px 6px; border-radius: 4px; background: var(--primary); color: var(--primary-foreground); font-size: .68rem; font-weight: 700; } + .model-result-details { display: grid; gap: 7px; padding: 0 12px 12px 27px; color: var(--muted-foreground); font-size: .8rem; } + .model-evidence { padding-top: 7px; border-top: 1px solid var(--border); } + .model-evidence > summary { width: fit-content; color: var(--foreground); cursor: pointer; } + .model-evidence > summary::before { content: '▸ '; } + .model-evidence[open] > summary::before { content: '▾ '; } + .model-evidence-content { display: grid; gap: 5px; padding: 8px 0 2px 12px; } + .model-evidence-empty { font-size: .78rem; } + @media (max-width: 600px) { + .model-comparison-dialog { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); } + .model-comparison-header, .model-comparison-body { padding: 14px; } + .model-result-heading { flex-wrap: wrap; } + .model-result-value, .model-result-unavailable { flex-basis: 100%; padding-left: 16px; text-align: left; } + } .watch-facts { display: grid; gap: 1px; margin: 12px 0; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--border); } .watch-facts div { display: flex; justify-content: space-between; gap: 12px; min-width: 0; padding: 7px 9px; background: var(--card); font-size: .78rem; } .watch-facts dt { flex: 0 1 48%; color: var(--muted-foreground); } @@ -1000,7 +1021,6 @@ thead th:nth-last-child(2) { z-index: 3; background: var(--secondary); } tbody td.watch-cell { background: var(--card); text-align: center; } tbody td.odds-cell { min-width: 11rem; white-space: normal; text-align: left; } - tbody td.odds-cell .model-comparison-body { max-width: 28rem; } .odds-values.compact, .odds-values.compact .odds-physical, .odds-values.compact .odds-market { display: grid; min-width: 0; gap: 2px; line-height: 1.3; } @@ -1185,7 +1205,6 @@ .mobile-row-summary[aria-expanded="true"] .row-chevron { transform: rotate(90deg); } .mobile-row-details { padding: 0 10px 12px; } .mobile-model-compare { padding: 0 10px 10px; } - .mobile-model-compare .model-comparison-body { width: 100%; } .mobile-row-details dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0 0 10px; overflow: hidden; border: 1px solid var(--border); border-radius: 6px; background: var(--border); } .mobile-row-details dl div { display: flex; min-width: 0; justify-content: space-between; gap: 8px; padding: 8px; background: var(--card); font-size: .72rem; } .mobile-row-details dt { color: var(--muted-foreground); } diff --git a/frontend/src/testSetup.ts b/frontend/src/testSetup.ts index fa2158a..b5775b6 100644 --- a/frontend/src/testSetup.ts +++ b/frontend/src/testSetup.ts @@ -28,6 +28,10 @@ if (typeof window !== "undefined") { }, }) } + if (typeof window.HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function () { this.open = true } + HTMLDialogElement.prototype.close = function () { this.open = false; this.dispatchEvent(new Event("close")) } + } } afterEach(() => { diff --git a/frontend/src/watchlist/Watchlist.browser.test.tsx b/frontend/src/watchlist/Watchlist.browser.test.tsx index ab94ee1..fdb1cc3 100644 --- a/frontend/src/watchlist/Watchlist.browser.test.tsx +++ b/frontend/src/watchlist/Watchlist.browser.test.tsx @@ -269,8 +269,9 @@ it("keeps an unavailable selected model unavailable while showing an available a expect(odds.querySelector(".watch-odds")?.textContent).toContain("Forecast unavailable") expect(odds.querySelector(".watch-odds")?.textContent).not.toContain("62.0%") fireEvent.click(screen.getByText("Compare models")) - expect(odds.textContent).toContain("62.0% ITM") - expect(odds.textContent).toContain("candidate_not_prepared") + const comparison = screen.getByRole("dialog", { name: "Compare models" }) + expect(comparison.textContent).toContain("62.0% ITM") + expect(comparison.textContent).toContain("candidate_not_prepared") const risk = screen.getByRole("region", { name: "Hypothetical expiry risk" }) expect(risk.textContent).toContain("Student-t EWMA") expect(risk.textContent).toContain("Selected forecast unavailable")