Compare commits
8 Commits
88615a95e9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| def878f773 | |||
| 6f0d74b56e | |||
| 1e4dddbbad | |||
| 5d79911c12 | |||
| 0683e348ba | |||
| 690d98d194 | |||
| 42e62c1ed2 | |||
| 08486667c3 |
@@ -84,3 +84,7 @@ spike/captures/
|
||||
|
||||
# Claude Code per-session task metadata (local only)
|
||||
.claude/
|
||||
|
||||
# Throwaway debugging scratch (PowerShell dumps, raw page captures)
|
||||
_dump*.ps1
|
||||
_pages*.txt
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# vex configuration — https://github.com/tenatarika/vex
|
||||
#
|
||||
# Place this file in your project root as .vex.toml
|
||||
|
||||
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
|
||||
# exclude = [
|
||||
# "vendor/**",
|
||||
# "node_modules/**",
|
||||
# "*.generated.go",
|
||||
# "dist/**",
|
||||
# ]
|
||||
|
||||
# Default output format: "text", "json", or "compact"
|
||||
# format = "text"
|
||||
|
||||
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
|
||||
semantic = true
|
||||
|
||||
# Automatically run `vex update` before search if the index is stale
|
||||
auto_update = true
|
||||
|
||||
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
|
||||
# Changing the embedder requires a full reindex.
|
||||
# embedder = "minilm-l6-v2"
|
||||
|
||||
# Cache directory override. Defaults to the platform cache location.
|
||||
# macOS: ~/Library/Caches/vex
|
||||
# Linux: $XDG_CACHE_HOME/vex (fallback: ~/.cache/vex)
|
||||
# Windows: %LOCALAPPDATA%\vex (fallback: %USERPROFILE%\AppData\Local\vex)
|
||||
# Accepts absolute paths, "~/..." or paths relative to this file (e.g. "./.vex/cache").
|
||||
# Can also be overridden per-invocation with --cache-dir or $VEX_CACHE_DIR.
|
||||
# cache_dir = "./.vex/cache"
|
||||
|
||||
# Store the index inside the project as `<project>/.vex_cache/`. Useful when
|
||||
# the cache should travel with the project (e.g. on a moved or renamed
|
||||
# directory). vex writes a `.gitignore` inside it so contents are not
|
||||
# committed. Overridden by `cache_dir`, `--cache-dir`, or $VEX_CACHE_DIR.
|
||||
# local_cache = false
|
||||
|
||||
# Thread count for parallel indexing (index/update/watch).
|
||||
# * unset — 80% of available cores, rounded up (default, leaves headroom)
|
||||
# * 0 — use all cores (explicit opt-in to max throughput)
|
||||
# * N — exactly N workers
|
||||
# Overridable per-invocation with `-j/--jobs` or $VEX_JOBS.
|
||||
# jobs = 4
|
||||
|
||||
# Build the persistent call-graph section. Disabling falls back to live-scan
|
||||
# for `vex callers`/`vex callees` (slower per-query, but saves indexing
|
||||
# time on large monorepos). The opt-out is persisted in the manifest so
|
||||
# `vex update` does not silently re-add the section.
|
||||
# Per-invocation override: `vex index --no-call-graph`.
|
||||
# call_graph = true
|
||||
|
||||
# Build the BM25 channel. Disabling drops the third RRF channel and keeps
|
||||
# only structural (+ semantic). Same persistence rules as `call_graph`.
|
||||
# Per-invocation override: `vex index --no-bm25`.
|
||||
# bm25 = true
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Marathon — Redesign Directions</title>
|
||||
<meta http-equiv="refresh" content="0; url=redesign-mockups.html">
|
||||
<body style="background:#05060a;color:#aeb8c8;font-family:monospace;padding:40px">
|
||||
<a href="redesign-mockups.html" style="color:#34e07f">Open Marathon redesign mockups →</a>
|
||||
</body>
|
||||
@@ -0,0 +1,641 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Marathon — Redesign Directions</title>
|
||||
|
||||
<!-- Distinct type per direction: Archivo+JetBrains Mono / Anton+DM Sans+Space Mono / Outfit+Manrope+IBM Plex Mono -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Archivo:wght@400;500;600;700;800;900&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,700&family=IBM+Plex+Mono:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700;800&family=Manrope:wght@400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
/* ============================================================
|
||||
META CHROME (not part of any design — the direction switcher)
|
||||
============================================================ */
|
||||
*,*::before,*::after{ box-sizing:border-box; }
|
||||
html,body{ margin:0; padding:0; }
|
||||
body{ background:#05060a; min-height:100vh; font-family:"JetBrains Mono",monospace; }
|
||||
a{ text-decoration:none; color:inherit; }
|
||||
button{ font:inherit; cursor:pointer; border:0; background:none; color:inherit; }
|
||||
svg{ display:block; }
|
||||
.ico{ width:18px; height:18px; flex:none; }
|
||||
|
||||
.switch{
|
||||
position:fixed; inset:0 0 auto 0; height:54px; z-index:9999;
|
||||
display:flex; align-items:center; gap:18px;
|
||||
padding:0 18px;
|
||||
background:rgba(8,10,16,.86);
|
||||
backdrop-filter:blur(14px);
|
||||
border-bottom:1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
.switch__label{
|
||||
font:600 11px/1 "JetBrains Mono",monospace; letter-spacing:.22em; text-transform:uppercase;
|
||||
color:#5b6678; white-space:nowrap;
|
||||
}
|
||||
.switch__label b{ color:#cdd6e4; font-weight:700; }
|
||||
.switch__tabs{ display:flex; gap:8px; }
|
||||
.tab{
|
||||
display:flex; flex-direction:column; gap:2px;
|
||||
padding:7px 14px; border-radius:9px;
|
||||
border:1px solid rgba(255,255,255,.10);
|
||||
background:rgba(255,255,255,.03);
|
||||
transition:border-color .18s, background .18s, transform .18s;
|
||||
}
|
||||
.tab:hover{ transform:translateY(-1px); border-color:rgba(255,255,255,.24); }
|
||||
.tab__name{ font:700 12.5px/1 "JetBrains Mono",monospace; letter-spacing:.04em; color:#aeb8c8; }
|
||||
.tab__tag{ font:500 9.5px/1 "JetBrains Mono",monospace; letter-spacing:.14em; text-transform:uppercase; color:#5b6678; }
|
||||
.tab[data-for="noir"].is-on{ background:rgba(43,213,118,.12); border-color:rgba(43,213,118,.5); }
|
||||
.tab[data-for="noir"].is-on .tab__name{ color:#34e07f; }
|
||||
.tab[data-for="velocity"].is-on{ background:rgba(198,244,0,.16); border-color:rgba(198,244,0,.6); }
|
||||
.tab[data-for="velocity"].is-on .tab__name{ color:#dcff4a; }
|
||||
.tab[data-for="aurora"].is-on{ background:rgba(139,124,255,.18); border-color:rgba(139,124,255,.6); }
|
||||
.tab[data-for="aurora"].is-on .tab__name{ color:#b3a8ff; }
|
||||
.switch__hint{ margin-left:auto; font:500 10px/1 "JetBrains Mono",monospace; letter-spacing:.16em; text-transform:uppercase; color:#3f4859; }
|
||||
|
||||
.stage{ display:none; min-height:100vh; padding-top:54px; position:relative; }
|
||||
.stage.is-active{ display:block; }
|
||||
.stage .fx{ position:absolute; inset:54px 0 0 0; pointer-events:none; z-index:0; overflow:hidden; }
|
||||
.mount{ position:relative; z-index:1; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{ animation-duration:.001ms !important; animation-iteration-count:1 !important; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SHARED STRUCTURE (styled differently under each .d-* scope)
|
||||
============================================================ */
|
||||
.app{ display:grid; grid-template-rows:auto 1fr; min-height:calc(100vh - 54px); }
|
||||
.body{ display:grid; grid-template-columns:248px minmax(0,1fr); min-height:0; }
|
||||
.main{ min-width:0; overflow:auto; }
|
||||
.bar{ display:flex; align-items:center; gap:14px; padding:0 22px; height:62px; }
|
||||
.bar__spacer{ flex:1; }
|
||||
.bar__tools{ display:flex; align-items:center; gap:14px; }
|
||||
.brand{ display:flex; align-items:baseline; gap:10px; }
|
||||
.locale{ display:inline-flex; overflow:hidden; }
|
||||
.nav{ display:flex; flex-direction:column; min-height:0; overflow:auto; }
|
||||
.nav-link{ display:flex; align-items:center; gap:12px; }
|
||||
.nav-link .lbl{ flex:1; }
|
||||
.badge{ display:inline-grid; place-items:center; }
|
||||
.stats{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.stat{ display:flex; flex-direction:column; }
|
||||
.grid2{ display:grid; grid-template-columns:minmax(0,1.55fr) minmax(0,1fr); }
|
||||
@media (max-width:1080px){ .grid2{ grid-template-columns:1fr; } .stats{ grid-template-columns:repeat(2,1fr); } }
|
||||
.feed{ display:flex; flex-direction:column; }
|
||||
.signal{ display:grid; }
|
||||
.sig-mkts{ display:flex; flex-wrap:wrap; }
|
||||
.mkt{ display:inline-flex; align-items:baseline; }
|
||||
.pipe{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; }
|
||||
.step{ display:flex; align-items:center; }
|
||||
.sporticon{ display:inline-grid; place-items:center; flex:none; font-weight:700; }
|
||||
|
||||
@keyframes rise{ from{ opacity:0; transform:translateY(10px);} to{ opacity:1; transform:none;} }
|
||||
@keyframes pulse{ 0%,100%{ opacity:.35; transform:scale(1);} 50%{ opacity:1; transform:scale(1.25);} }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
DIRECTION 1 — TERMINAL NOIR (dark trading terminal)
|
||||
Archivo (heavy numerals) + JetBrains Mono. Neon-on-black, grid.
|
||||
════════════════════════════════════════════════════════════ */
|
||||
.d-noir{
|
||||
--bg:#070a0f; --bg2:#0b0f16; --card:#0d1219; --card2:#10161f;
|
||||
--line:#1b2330; --line2:#28303d;
|
||||
--ink:#e7eef6; --mut:#828d9e; --dim:#5a6475;
|
||||
--grn:#2bd576; --red:#ff5a6e; --cyan:#36e0ff; --amber:#ffb02e; --signal:#ff4d5e;
|
||||
background:var(--bg); color:var(--ink);
|
||||
font-family:"JetBrains Mono",monospace;
|
||||
}
|
||||
.d-noir .fx{
|
||||
background:
|
||||
radial-gradient(900px 480px at 78% -6%, rgba(54,224,255,.07), transparent 60%),
|
||||
radial-gradient(760px 520px at 4% 108%, rgba(43,213,118,.06), transparent 60%),
|
||||
linear-gradient(transparent 95%, rgba(255,255,255,.022) 95%),
|
||||
linear-gradient(90deg, transparent 95%, rgba(255,255,255,.022) 95%);
|
||||
background-size:auto,auto,38px 38px,38px 38px;
|
||||
}
|
||||
.d-noir .bar{ background:linear-gradient(180deg,#0c1118,#0a0e14); border-bottom:1px solid var(--line); }
|
||||
.d-noir .bar__menu{ color:var(--mut); display:grid; place-items:center; width:34px; height:34px; border:1px solid var(--line); border-radius:6px; }
|
||||
.d-noir .bar__menu:hover{ color:var(--ink); border-color:var(--line2); }
|
||||
.d-noir .brand__mark{ font:800 19px/1 "Archivo",sans-serif; letter-spacing:-.01em; }
|
||||
.d-noir .brand__mark::first-letter{ color:var(--grn); }
|
||||
.d-noir .brand__sub{ font:500 10px/1 "JetBrains Mono"; letter-spacing:.2em; text-transform:uppercase; color:var(--dim); border-left:1px solid var(--line2); padding-left:10px; }
|
||||
.d-noir .capture{ display:inline-flex; align-items:center; gap:8px; font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.16em; text-transform:uppercase; color:var(--grn); padding:6px 10px; border:1px solid rgba(43,213,118,.35); border-radius:6px; background:rgba(43,213,118,.07); }
|
||||
.d-noir .capture__dot{ width:7px; height:7px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px var(--grn); animation:pulse 1.6s ease-in-out infinite; }
|
||||
.d-noir .locale{ border:1px solid var(--line2); border-radius:6px; }
|
||||
.d-noir .locale__btn{ padding:6px 11px; font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.12em; color:var(--dim); }
|
||||
.d-noir .locale__btn+.locale__btn{ border-left:1px solid var(--line2); }
|
||||
.d-noir .locale__btn.is-active{ background:var(--grn); color:#04130b; }
|
||||
.d-noir .theme{ width:34px; height:34px; display:grid; place-items:center; border:1px solid var(--line); border-radius:6px; color:var(--mut); }
|
||||
.d-noir .theme:hover{ color:var(--cyan); border-color:var(--line2); }
|
||||
|
||||
.d-noir .nav{ background:var(--bg2); border-right:1px solid var(--line); padding:14px 12px; gap:2px; }
|
||||
.d-noir .nav__brandblock{ padding:8px 10px 16px; }
|
||||
.d-noir .nav__group{ font:600 10px/1 "JetBrains Mono"; letter-spacing:.24em; text-transform:uppercase; color:var(--dim); padding:18px 10px 8px; }
|
||||
.d-noir .nav-link{ padding:9px 11px; border-radius:7px; color:var(--mut); font:500 13px/1 "JetBrains Mono"; border-left:2px solid transparent; transition:background .14s,color .14s; }
|
||||
.d-noir .nav-link svg{ color:var(--dim); }
|
||||
.d-noir .nav-link:hover{ background:rgba(255,255,255,.04); color:var(--ink); }
|
||||
.d-noir .nav-link.is-active{ background:rgba(43,213,118,.10); color:#eafff3; border-left-color:var(--grn); }
|
||||
.d-noir .nav-link.is-active svg{ color:var(--grn); }
|
||||
.d-noir .badge{ min-width:18px; height:18px; padding:0 5px; border-radius:5px; background:var(--signal); color:#fff; font:700 10px/18px "JetBrains Mono"; box-shadow:0 0 12px rgba(255,77,94,.6); }
|
||||
|
||||
.d-noir .main{ padding:30px 34px 46px; }
|
||||
.d-noir .hero{ max-width:760px; animation:rise .5s both; }
|
||||
.d-noir .kicker{ font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.26em; text-transform:uppercase; color:var(--cyan); }
|
||||
.d-noir .title{ font:800 clamp(30px,4.4vw,50px)/1.02 "Archivo",sans-serif; letter-spacing:-.02em; margin:14px 0 12px; }
|
||||
.d-noir .lede{ color:var(--mut); font:400 14px/1.6 "JetBrains Mono"; max-width:62ch; margin:0; }
|
||||
|
||||
.d-noir .stats{ gap:14px; margin:30px 0; animation:rise .5s .06s both; }
|
||||
.d-noir .stat{ background:linear-gradient(180deg,var(--card),var(--card2)); border:1px solid var(--line); border-radius:10px; padding:16px 18px; gap:10px; position:relative; overflow:hidden; }
|
||||
.d-noir .stat::after{ content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--cyan); opacity:.5; }
|
||||
.d-noir .stat--alert::after{ background:var(--signal); opacity:.9; box-shadow:0 0 18px var(--signal); }
|
||||
.d-noir .stat__label{ font:600 10px/1 "JetBrains Mono"; letter-spacing:.18em; text-transform:uppercase; color:var(--dim); }
|
||||
.d-noir .stat__value{ font:800 34px/1 "Archivo",sans-serif; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
|
||||
.d-noir .stat--alert .stat__value{ color:var(--signal); }
|
||||
.d-noir .stat__delta{ font:500 11px/1 "JetBrains Mono"; color:var(--grn); }
|
||||
.d-noir .stat__delta.dn{ color:var(--red); }
|
||||
.d-noir .stat__delta.flat{ color:var(--dim); }
|
||||
|
||||
.d-noir .grid2{ gap:22px; animation:rise .5s .12s both; }
|
||||
.d-noir .side{ display:flex; flex-direction:column; gap:22px; }
|
||||
.d-noir .panel{ background:linear-gradient(180deg,var(--card),var(--card2)); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
|
||||
.d-noir .panel__head{ display:flex; align-items:center; justify-content:space-between; padding:15px 18px; border-bottom:1px solid var(--line); }
|
||||
.d-noir .panel__more{ font:600 10.5px/1 "JetBrains Mono"; letter-spacing:.14em; text-transform:uppercase; color:var(--cyan); }
|
||||
.d-noir .panel__more:hover{ text-decoration:underline; }
|
||||
|
||||
.d-noir .signal{ grid-template-columns:54px 1fr auto; gap:14px; align-items:center; padding:14px 18px; border-bottom:1px solid var(--line); transition:background .14s; }
|
||||
.d-noir .signal:last-child{ border-bottom:0; }
|
||||
.d-noir .signal:hover{ background:rgba(54,224,255,.04); }
|
||||
.d-noir .signal__time{ font:500 11px/1.4 "JetBrains Mono"; color:var(--dim); }
|
||||
.d-noir .sporticon{ width:26px; height:26px; border-radius:7px; font:800 11px/1 "Archivo"; }
|
||||
.d-noir .si-football{ background:rgba(43,213,118,.16); color:var(--grn); }
|
||||
.d-noir .si-basketball{ background:rgba(255,176,46,.16); color:var(--amber); }
|
||||
.d-noir .si-tennis{ background:rgba(54,224,255,.16); color:var(--cyan); }
|
||||
.d-noir .si-hockey{ background:rgba(139,156,255,.18); color:#9db0ff; }
|
||||
.d-noir .sig-mid{ display:flex; align-items:center; gap:11px; min-width:0; }
|
||||
.d-noir .sig-teams{ font:500 14px/1.2 "Archivo",sans-serif; color:var(--ink); }
|
||||
.d-noir .sig-sub{ font:500 10.5px/1 "JetBrains Mono"; letter-spacing:.06em; color:var(--dim); margin-top:4px; }
|
||||
.d-noir .sig-mkts{ gap:7px; margin-top:9px; }
|
||||
.d-noir .mkt{ gap:6px; padding:4px 8px; border:1px solid var(--line); border-radius:6px; background:var(--bg); font-family:"JetBrains Mono"; }
|
||||
.d-noir .mkt__k{ font:700 10px/1; color:var(--dim); }
|
||||
.d-noir .mkt__pre{ font-size:11.5px; color:var(--mut); text-decoration:line-through; text-decoration-color:var(--line2); }
|
||||
.d-noir .mkt__arr{ color:var(--dim); font-size:11px; }
|
||||
.d-noir .mkt__post{ font-weight:700; font-size:12.5px; }
|
||||
.d-noir .mkt.up .mkt__post{ color:var(--grn); }
|
||||
.d-noir .mkt.dn .mkt__post{ color:var(--red); }
|
||||
.d-noir .sig-right{ display:flex; flex-direction:column; align-items:flex-end; gap:8px; }
|
||||
.d-noir .sev{ font:700 9.5px/1 "JetBrains Mono"; letter-spacing:.14em; text-transform:uppercase; padding:4px 8px; border-radius:5px; border:1px solid currentColor; }
|
||||
.d-noir .sev--high{ color:var(--signal); background:rgba(255,77,94,.10); }
|
||||
.d-noir .sev--medium{ color:var(--amber); background:rgba(255,176,46,.10); }
|
||||
.d-noir .sev--low{ color:var(--dim); background:rgba(255,255,255,.03); }
|
||||
.d-noir .score{ display:flex; align-items:center; gap:8px; }
|
||||
.d-noir .score__bar{ width:60px; height:5px; border-radius:3px; background:var(--line2); overflow:hidden; }
|
||||
.d-noir .score__fill{ height:100%; background:linear-gradient(90deg,var(--amber),var(--signal)); }
|
||||
.d-noir .score__n{ font:800 13px/1 "Archivo"; font-variant-numeric:tabular-nums; }
|
||||
|
||||
.d-noir .pipe{ padding:8px 10px; }
|
||||
.d-noir .step{ gap:13px; padding:11px 8px; border-bottom:1px dashed var(--line); }
|
||||
.d-noir .step:last-child{ border-bottom:0; }
|
||||
.d-noir .step__idx{ font:800 12px/1 "Archivo"; color:var(--dim); width:24px; }
|
||||
.d-noir .step__lbl{ flex:1; font:500 13px/1.2 "JetBrains Mono"; }
|
||||
.d-noir .step__lbl small{ display:block; color:var(--dim); font-size:10.5px; margin-top:3px; letter-spacing:.06em; }
|
||||
.d-noir .dot{ width:8px; height:8px; border-radius:50%; }
|
||||
.d-noir .dot--ok{ background:var(--grn); box-shadow:0 0 9px var(--grn); }
|
||||
.d-noir .dot--run{ background:var(--cyan); box-shadow:0 0 9px var(--cyan); animation:pulse 1.4s infinite; }
|
||||
.d-noir .dot--idle{ background:var(--line2); }
|
||||
|
||||
.d-noir .concept{ padding:18px; }
|
||||
.d-noir .concept h4{ margin:0 0 6px; font:800 16px/1.1 "Archivo"; }
|
||||
.d-noir .concept p{ margin:0 0 12px; font:400 12px/1.6 "JetBrains Mono"; color:var(--mut); }
|
||||
.d-noir .concept .meta{ font:500 10px/1.5 "JetBrains Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--cyan); }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
DIRECTION 2 — VELOCITY (neo-brutalist sportsbook)
|
||||
Anton (slammed caps) + DM Sans + Space Mono. Acid lime, hard shadows.
|
||||
════════════════════════════════════════════════════════════ */
|
||||
.d-velocity{
|
||||
--paper:#f3f1e9; --paper2:#fffef8; --ink:#0a0a0a; --ink2:#26241e;
|
||||
--lime:#c6f400; --blue:#244bff; --red:#ff3b30; --amber:#ff8a00;
|
||||
--shadow:6px 6px 0 var(--ink);
|
||||
background:var(--paper); color:var(--ink);
|
||||
font-family:"DM Sans",sans-serif;
|
||||
}
|
||||
.d-velocity .fx{
|
||||
background:
|
||||
repeating-linear-gradient(135deg, transparent 0 22px, rgba(10,10,10,.025) 22px 24px);
|
||||
}
|
||||
.d-velocity .bar{ background:var(--ink); color:var(--paper2); height:64px; border-bottom:3px solid var(--ink); }
|
||||
.d-velocity .bar__menu{ width:38px; height:38px; display:grid; place-items:center; border:2px solid var(--paper2); border-radius:9px; color:var(--paper2); }
|
||||
.d-velocity .bar__menu:hover{ background:var(--lime); color:var(--ink); border-color:var(--lime); }
|
||||
.d-velocity .brand__mark{ font:400 26px/1 "Anton",sans-serif; letter-spacing:.02em; text-transform:uppercase; color:var(--paper2); }
|
||||
.d-velocity .brand__mark::first-letter{ color:var(--lime); }
|
||||
.d-velocity .brand__sub{ font:700 10px/1 "Space Mono"; letter-spacing:.18em; text-transform:uppercase; color:var(--lime); }
|
||||
.d-velocity .capture{ display:inline-flex; align-items:center; gap:8px; font:700 10.5px/1 "Space Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--ink); background:var(--lime); padding:8px 12px; border-radius:8px; }
|
||||
.d-velocity .capture__dot{ width:8px; height:8px; border-radius:50%; background:var(--ink); animation:pulse 1.3s infinite; }
|
||||
.d-velocity .locale{ border:2px solid var(--paper2); border-radius:8px; }
|
||||
.d-velocity .locale__btn{ padding:7px 11px; font:700 10.5px/1 "Space Mono"; color:var(--paper2); }
|
||||
.d-velocity .locale__btn+.locale__btn{ border-left:2px solid var(--paper2); }
|
||||
.d-velocity .locale__btn.is-active{ background:var(--lime); color:var(--ink); }
|
||||
.d-velocity .theme{ width:38px; height:38px; display:grid; place-items:center; border:2px solid var(--paper2); border-radius:9px; color:var(--paper2); }
|
||||
.d-velocity .theme:hover{ background:var(--lime); color:var(--ink); border-color:var(--lime); }
|
||||
|
||||
.d-velocity .nav{ background:var(--paper2); border-right:3px solid var(--ink); padding:18px 14px; gap:6px; }
|
||||
.d-velocity .nav__group{ font:700 10px/1 "Space Mono"; letter-spacing:.18em; text-transform:uppercase; color:#9a958a; padding:16px 8px 8px; }
|
||||
.d-velocity .nav-link{ padding:11px 12px; border:2px solid transparent; border-radius:10px; font:700 13.5px/1 "DM Sans"; color:var(--ink2); }
|
||||
.d-velocity .nav-link:hover{ border-color:var(--ink); transform:translate(-1px,-1px); box-shadow:3px 3px 0 var(--ink); }
|
||||
.d-velocity .nav-link.is-active{ background:var(--lime); border-color:var(--ink); box-shadow:var(--shadow); }
|
||||
.d-velocity .badge{ min-width:20px; height:20px; padding:0 6px; border-radius:6px; background:var(--red); color:#fff; font:700 11px/20px "Space Mono"; border:2px solid var(--ink); }
|
||||
|
||||
.d-velocity .main{ padding:30px 34px 50px; }
|
||||
.d-velocity .hero{ max-width:820px; animation:rise .45s both; }
|
||||
.d-velocity .kicker{ display:inline-block; font:700 11px/1 "Space Mono"; letter-spacing:.18em; text-transform:uppercase; color:var(--ink); background:var(--lime); padding:6px 10px; border:2px solid var(--ink); border-radius:6px; transform:rotate(-1.5deg); }
|
||||
.d-velocity .title{ font:400 clamp(44px,7vw,82px)/.92 "Anton",sans-serif; letter-spacing:.005em; text-transform:uppercase; margin:18px 0 14px; }
|
||||
.d-velocity .title em{ font-style:normal; color:var(--blue); -webkit-text-stroke:2px var(--ink); }
|
||||
.d-velocity .lede{ color:var(--ink2); font:500 15.5px/1.55 "DM Sans"; max-width:60ch; margin:0; }
|
||||
|
||||
.d-velocity .stats{ gap:18px; margin:34px 0; animation:rise .45s .05s both; }
|
||||
.d-velocity .stat{ background:var(--paper2); border:3px solid var(--ink); border-radius:14px; box-shadow:var(--shadow); padding:18px 20px; gap:8px; position:relative; }
|
||||
.d-velocity .stat__label{ font:700 10.5px/1 "Space Mono"; letter-spacing:.12em; text-transform:uppercase; color:var(--ink2); }
|
||||
.d-velocity .stat__value{ font:400 46px/1 "Anton",sans-serif; letter-spacing:.01em; }
|
||||
.d-velocity .stat__value::after{ content:""; display:block; width:46px; height:6px; background:var(--lime); margin-top:6px; }
|
||||
.d-velocity .stat--alert{ background:var(--ink); color:var(--paper2); }
|
||||
.d-velocity .stat--alert .stat__label{ color:var(--lime); }
|
||||
.d-velocity .stat--alert .stat__value::after{ background:var(--red); }
|
||||
.d-velocity .stat__delta{ font:700 12px/1 "Space Mono"; color:var(--ink2); }
|
||||
.d-velocity .stat--alert .stat__delta{ color:var(--red); }
|
||||
|
||||
.d-velocity .grid2{ gap:24px; animation:rise .45s .1s both; }
|
||||
.d-velocity .side{ display:flex; flex-direction:column; gap:24px; }
|
||||
.d-velocity .panel{ background:var(--paper2); border:3px solid var(--ink); border-radius:16px; box-shadow:var(--shadow); overflow:hidden; }
|
||||
.d-velocity .panel__head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:3px solid var(--ink); background:var(--lime); }
|
||||
.d-velocity .panel__head .kicker{ transform:none; background:var(--ink); color:var(--lime); }
|
||||
.d-velocity .panel__more{ font:700 11px/1 "Space Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--ink); }
|
||||
.d-velocity .panel__more:hover{ text-decoration:underline; }
|
||||
|
||||
.d-velocity .signal{ grid-template-columns:auto 1fr auto; gap:16px; align-items:center; padding:16px 20px; border-bottom:2px solid var(--ink); transition:background .12s; }
|
||||
.d-velocity .signal:last-child{ border-bottom:0; }
|
||||
.d-velocity .signal:hover{ background:#faf7e8; }
|
||||
.d-velocity .sporticon{ width:46px; height:46px; border-radius:11px; border:2px solid var(--ink); font:400 17px/1 "Anton"; }
|
||||
.d-velocity .si-football{ background:var(--lime); }
|
||||
.d-velocity .si-basketball{ background:var(--amber); color:#fff; }
|
||||
.d-velocity .si-tennis{ background:#34d6c0; }
|
||||
.d-velocity .si-hockey{ background:var(--blue); color:#fff; }
|
||||
.d-velocity .sig-time{ font:700 11px/1 "Space Mono"; color:#8c887d; margin-bottom:5px; }
|
||||
.d-velocity .sig-teams{ font:400 21px/1 "Anton",sans-serif; text-transform:uppercase; letter-spacing:.01em; }
|
||||
.d-velocity .sig-mkts{ gap:9px; margin-top:11px; }
|
||||
.d-velocity .mkt{ gap:6px; padding:5px 9px; border:2px solid var(--ink); border-radius:7px; font-family:"Space Mono"; background:var(--paper); }
|
||||
.d-velocity .mkt__k{ font:700 11px/1; color:#8c887d; }
|
||||
.d-velocity .mkt__pre{ font-size:12px; color:#8c887d; text-decoration:line-through; }
|
||||
.d-velocity .mkt__arr{ color:var(--ink); }
|
||||
.d-velocity .mkt__post{ font-weight:700; font-size:14px; }
|
||||
.d-velocity .mkt.up{ background:var(--lime); }
|
||||
.d-velocity .mkt.dn{ background:#ffe2df; }
|
||||
.d-velocity .mkt.dn .mkt__post{ color:var(--red); }
|
||||
.d-velocity .sig-right{ display:flex; flex-direction:column; align-items:flex-end; gap:9px; }
|
||||
.d-velocity .sev{ font:700 10px/1 "Space Mono"; letter-spacing:.1em; text-transform:uppercase; padding:6px 10px; border:2px solid var(--ink); border-radius:7px; }
|
||||
.d-velocity .sev--high{ background:var(--red); color:#fff; }
|
||||
.d-velocity .sev--medium{ background:var(--amber); color:var(--ink); }
|
||||
.d-velocity .sev--low{ background:var(--paper); color:var(--ink2); }
|
||||
.d-velocity .score__n{ font:400 30px/1 "Anton"; }
|
||||
.d-velocity .score__bar{ display:none; }
|
||||
|
||||
.d-velocity .pipe{ padding:10px 14px; }
|
||||
.d-velocity .step{ gap:14px; padding:13px 8px; border-bottom:2px dashed var(--ink); }
|
||||
.d-velocity .step:last-child{ border-bottom:0; }
|
||||
.d-velocity .step__idx{ font:400 24px/1 "Anton"; color:var(--ink); width:34px; }
|
||||
.d-velocity .step__lbl{ flex:1; font:700 14px/1.2 "DM Sans"; }
|
||||
.d-velocity .step__lbl small{ display:block; font:700 11px/1 "Space Mono"; color:#8c887d; margin-top:4px; letter-spacing:.05em; }
|
||||
.d-velocity .dot{ width:13px; height:13px; border:2px solid var(--ink); border-radius:4px; }
|
||||
.d-velocity .dot--ok{ background:var(--lime); }
|
||||
.d-velocity .dot--run{ background:var(--blue); animation:pulse 1.3s infinite; }
|
||||
.d-velocity .dot--idle{ background:var(--paper); }
|
||||
|
||||
.d-velocity .concept{ padding:20px; }
|
||||
.d-velocity .concept h4{ margin:0 0 8px; font:400 26px/.95 "Anton"; text-transform:uppercase; }
|
||||
.d-velocity .concept p{ margin:0 0 12px; font:500 13px/1.55 "DM Sans"; color:var(--ink2); }
|
||||
.d-velocity .concept .meta{ font:700 10.5px/1.5 "Space Mono"; letter-spacing:.08em; text-transform:uppercase; color:var(--ink); background:var(--lime); display:inline-block; padding:4px 8px; border:2px solid var(--ink); border-radius:6px; }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
DIRECTION 3 — AURORA (refined premium fintech, dark glass)
|
||||
Outfit + Manrope + IBM Plex Mono. Indigo glass, aurora glow.
|
||||
════════════════════════════════════════════════════════════ */
|
||||
.d-aurora{
|
||||
--bg:#0a0d1a; --glass:rgba(255,255,255,.045); --glass2:rgba(255,255,255,.07);
|
||||
--line:rgba(255,255,255,.09); --line2:rgba(255,255,255,.16);
|
||||
--ink:#eef1fb; --mut:#9aa3c0; --dim:#6b7494;
|
||||
--violet:#8b7cff; --teal:#3dd6c4; --coral:#ff7a8a; --gold:#ffce6b; --grn:#5fe0a0;
|
||||
background:var(--bg); color:var(--ink);
|
||||
font-family:"Manrope",sans-serif;
|
||||
}
|
||||
.d-aurora .fx::before,.d-aurora .fx::after{ content:""; position:absolute; border-radius:50%; filter:blur(90px); opacity:.5; }
|
||||
.d-aurora .fx::before{ width:620px; height:620px; left:-120px; top:-140px; background:radial-gradient(circle,#5a47d6,transparent 70%); animation:drift1 22s ease-in-out infinite alternate; }
|
||||
.d-aurora .fx::after{ width:560px; height:560px; right:-100px; bottom:-160px; background:radial-gradient(circle,#1f8f88,transparent 70%); animation:drift2 26s ease-in-out infinite alternate; }
|
||||
@keyframes drift1{ to{ transform:translate(80px,60px) scale(1.1);} }
|
||||
@keyframes drift2{ to{ transform:translate(-70px,-50px) scale(1.15);} }
|
||||
.d-aurora .fx .glow3{ position:absolute; width:420px; height:420px; left:46%; top:30%; border-radius:50%; filter:blur(100px); opacity:.32; background:radial-gradient(circle,#b06bff,transparent 70%); }
|
||||
|
||||
.d-aurora .bar{ background:rgba(12,16,30,.6); backdrop-filter:blur(16px); border-bottom:1px solid var(--line); height:64px; }
|
||||
.d-aurora .bar__menu{ width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:11px; color:var(--mut); background:var(--glass); }
|
||||
.d-aurora .bar__menu:hover{ color:var(--ink); border-color:var(--line2); }
|
||||
.d-aurora .brand__mark{ font:600 20px/1 "Outfit",sans-serif; letter-spacing:-.01em; }
|
||||
.d-aurora .brand__mark::first-letter{ color:var(--violet); }
|
||||
.d-aurora .brand__sub{ font:500 10px/1 "IBM Plex Mono"; letter-spacing:.2em; text-transform:uppercase; color:var(--dim); }
|
||||
.d-aurora .capture{ display:inline-flex; align-items:center; gap:8px; font:500 10.5px/1 "IBM Plex Mono"; letter-spacing:.12em; text-transform:uppercase; color:var(--grn); padding:7px 12px; border-radius:20px; background:rgba(95,224,160,.10); border:1px solid rgba(95,224,160,.3); }
|
||||
.d-aurora .capture__dot{ width:7px; height:7px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px var(--grn); animation:pulse 1.7s infinite; }
|
||||
.d-aurora .locale{ border:1px solid var(--line); border-radius:20px; background:var(--glass); }
|
||||
.d-aurora .locale__btn{ padding:7px 13px; font:500 10.5px/1 "IBM Plex Mono"; letter-spacing:.1em; color:var(--dim); border-radius:20px; }
|
||||
.d-aurora .locale__btn.is-active{ background:linear-gradient(120deg,var(--violet),#6d8bff); color:#fff; }
|
||||
.d-aurora .theme{ width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:11px; color:var(--mut); background:var(--glass); }
|
||||
.d-aurora .theme:hover{ color:var(--violet); border-color:var(--line2); }
|
||||
|
||||
.d-aurora .nav{ background:rgba(12,16,30,.4); backdrop-filter:blur(10px); border-right:1px solid var(--line); padding:18px 14px; gap:3px; }
|
||||
.d-aurora .nav__group{ font:500 10px/1 "IBM Plex Mono"; letter-spacing:.2em; text-transform:uppercase; color:var(--dim); padding:18px 12px 9px; }
|
||||
.d-aurora .nav-link{ padding:10px 13px; border-radius:12px; font:600 13.5px/1 "Manrope"; color:var(--mut); position:relative; transition:background .16s,color .16s; }
|
||||
.d-aurora .nav-link svg{ color:var(--dim); }
|
||||
.d-aurora .nav-link:hover{ background:var(--glass); color:var(--ink); }
|
||||
.d-aurora .nav-link.is-active{ background:linear-gradient(120deg,rgba(139,124,255,.22),rgba(109,139,255,.10)); color:#fff; border:1px solid rgba(139,124,255,.32); }
|
||||
.d-aurora .nav-link.is-active svg{ color:var(--violet); }
|
||||
.d-aurora .badge{ min-width:19px; height:19px; padding:0 6px; border-radius:10px; background:linear-gradient(120deg,var(--coral),#ff5d8f); color:#fff; font:700 10.5px/19px "IBM Plex Mono"; }
|
||||
|
||||
.d-aurora .main{ padding:32px 38px 50px; }
|
||||
.d-aurora .hero{ max-width:780px; animation:rise .55s both; }
|
||||
.d-aurora .kicker{ font:500 11px/1 "IBM Plex Mono"; letter-spacing:.24em; text-transform:uppercase; background:linear-gradient(120deg,var(--violet),var(--teal)); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.d-aurora .title{ font:600 clamp(32px,4.6vw,54px)/1.04 "Outfit",sans-serif; letter-spacing:-.02em; margin:14px 0 14px; }
|
||||
.d-aurora .title em{ font-style:normal; background:linear-gradient(120deg,#b9aaff,var(--teal)); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.d-aurora .lede{ color:var(--mut); font:400 15.5px/1.65 "Manrope"; max-width:62ch; margin:0; }
|
||||
|
||||
.d-aurora .stats{ gap:18px; margin:32px 0; animation:rise .55s .07s both; }
|
||||
.d-aurora .stat{ background:var(--glass); border:1px solid var(--line); border-radius:18px; padding:20px; gap:11px; backdrop-filter:blur(12px); position:relative; overflow:hidden; box-shadow:0 8px 30px rgba(0,0,0,.25); }
|
||||
.d-aurora .stat::before{ content:""; position:absolute; inset:0 0 auto 0; height:1px; background:linear-gradient(90deg,transparent,var(--line2),transparent); }
|
||||
.d-aurora .stat__label{ font:500 10.5px/1 "IBM Plex Mono"; letter-spacing:.14em; text-transform:uppercase; color:var(--dim); }
|
||||
.d-aurora .stat__value{ font:600 38px/1 "Outfit",sans-serif; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
|
||||
.d-aurora .stat--alert .stat__value{ background:linear-gradient(120deg,var(--coral),var(--gold)); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.d-aurora .stat__delta{ display:inline-flex; align-items:center; gap:5px; align-self:flex-start; font:600 11px/1 "IBM Plex Mono"; color:var(--grn); background:rgba(95,224,160,.10); padding:4px 8px; border-radius:8px; }
|
||||
.d-aurora .stat__delta.dn{ color:var(--coral); background:rgba(255,122,138,.10); }
|
||||
.d-aurora .stat__delta.flat{ color:var(--dim); background:var(--glass2); }
|
||||
|
||||
.d-aurora .grid2{ gap:24px; animation:rise .55s .14s both; }
|
||||
.d-aurora .side{ display:flex; flex-direction:column; gap:24px; }
|
||||
.d-aurora .panel{ background:var(--glass); border:1px solid var(--line); border-radius:20px; overflow:hidden; backdrop-filter:blur(12px); box-shadow:0 10px 36px rgba(0,0,0,.28); }
|
||||
.d-aurora .panel__head{ display:flex; align-items:center; justify-content:space-between; padding:18px 22px; border-bottom:1px solid var(--line); }
|
||||
.d-aurora .panel__more{ font:500 11px/1 "IBM Plex Mono"; letter-spacing:.1em; text-transform:uppercase; color:var(--violet); }
|
||||
.d-aurora .panel__more:hover{ color:#b9aaff; }
|
||||
|
||||
.d-aurora .signal{ grid-template-columns:auto 1fr auto; gap:16px; align-items:center; padding:16px 22px; border-bottom:1px solid var(--line); transition:background .16s; }
|
||||
.d-aurora .signal:last-child{ border-bottom:0; }
|
||||
.d-aurora .signal:hover{ background:var(--glass2); }
|
||||
.d-aurora .sporticon{ width:42px; height:42px; border-radius:13px; font:600 15px/1 "Outfit"; border:1px solid var(--line2); }
|
||||
.d-aurora .si-football{ background:linear-gradient(135deg,rgba(95,224,160,.22),rgba(95,224,160,.06)); color:var(--grn); }
|
||||
.d-aurora .si-basketball{ background:linear-gradient(135deg,rgba(255,206,107,.22),rgba(255,206,107,.06)); color:var(--gold); }
|
||||
.d-aurora .si-tennis{ background:linear-gradient(135deg,rgba(61,214,196,.22),rgba(61,214,196,.06)); color:var(--teal); }
|
||||
.d-aurora .si-hockey{ background:linear-gradient(135deg,rgba(139,124,255,.26),rgba(139,124,255,.06)); color:#b9aaff; }
|
||||
.d-aurora .sig-time{ font:500 11px/1 "IBM Plex Mono"; color:var(--dim); margin-bottom:5px; }
|
||||
.d-aurora .sig-teams{ font:600 16px/1.15 "Outfit",sans-serif; color:var(--ink); }
|
||||
.d-aurora .sig-mkts{ gap:8px; margin-top:11px; }
|
||||
.d-aurora .mkt{ gap:6px; padding:5px 10px; border:1px solid var(--line); border-radius:10px; background:var(--glass); font-family:"IBM Plex Mono"; }
|
||||
.d-aurora .mkt__k{ font:600 10px/1; color:var(--dim); }
|
||||
.d-aurora .mkt__pre{ font-size:11.5px; color:var(--dim); text-decoration:line-through; text-decoration-color:var(--line2); }
|
||||
.d-aurora .mkt__arr{ color:var(--dim); }
|
||||
.d-aurora .mkt__post{ font-weight:600; font-size:12.5px; }
|
||||
.d-aurora .mkt.up .mkt__post{ color:var(--grn); }
|
||||
.d-aurora .mkt.dn .mkt__post{ color:var(--coral); }
|
||||
.d-aurora .sig-right{ display:flex; flex-direction:column; align-items:flex-end; gap:9px; }
|
||||
.d-aurora .sev{ font:600 9.5px/1 "IBM Plex Mono"; letter-spacing:.12em; text-transform:uppercase; padding:5px 11px; border-radius:20px; }
|
||||
.d-aurora .sev--high{ color:#fff; background:linear-gradient(120deg,var(--coral),#ff5d8f); }
|
||||
.d-aurora .sev--medium{ color:#3a2c08; background:linear-gradient(120deg,var(--gold),#ffb24d); }
|
||||
.d-aurora .sev--low{ color:var(--mut); background:var(--glass2); border:1px solid var(--line); }
|
||||
.d-aurora .score{ display:flex; align-items:center; gap:9px; }
|
||||
.d-aurora .score__bar{ width:54px; height:5px; border-radius:4px; background:var(--glass2); overflow:hidden; }
|
||||
.d-aurora .score__fill{ height:100%; border-radius:4px; background:linear-gradient(90deg,var(--violet),var(--coral)); }
|
||||
.d-aurora .score__n{ font:600 15px/1 "Outfit"; font-variant-numeric:tabular-nums; }
|
||||
|
||||
.d-aurora .pipe{ padding:10px 14px; }
|
||||
.d-aurora .step{ gap:14px; padding:13px 10px; border-bottom:1px solid var(--line); }
|
||||
.d-aurora .step:last-child{ border-bottom:0; }
|
||||
.d-aurora .step__idx{ font:600 14px/1 "Outfit"; color:var(--dim); width:26px; }
|
||||
.d-aurora .step__lbl{ flex:1; font:600 13.5px/1.2 "Manrope"; }
|
||||
.d-aurora .step__lbl small{ display:block; font:400 11px/1 "IBM Plex Mono"; color:var(--dim); margin-top:4px; letter-spacing:.04em; }
|
||||
.d-aurora .dot{ width:9px; height:9px; border-radius:50%; }
|
||||
.d-aurora .dot--ok{ background:var(--grn); box-shadow:0 0 10px var(--grn); }
|
||||
.d-aurora .dot--run{ background:var(--teal); box-shadow:0 0 10px var(--teal); animation:pulse 1.5s infinite; }
|
||||
.d-aurora .dot--idle{ background:var(--line2); }
|
||||
|
||||
.d-aurora .concept{ padding:22px; }
|
||||
.d-aurora .concept h4{ margin:0 0 8px; font:600 19px/1.1 "Outfit"; }
|
||||
.d-aurora .concept p{ margin:0 0 14px; font:400 13px/1.65 "Manrope"; color:var(--mut); }
|
||||
.d-aurora .concept .meta{ font:500 10px/1.5 "IBM Plex Mono"; letter-spacing:.1em; text-transform:uppercase; background:linear-gradient(120deg,var(--violet),var(--teal)); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body data-active="noir">
|
||||
|
||||
<!-- icon sprite -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
|
||||
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7" height="7" rx="1.2"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.2"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.2"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.2"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="i-bolt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M13 3 5 13h6l-1 8 8-10h-6z"/></symbol>
|
||||
<symbol id="i-warn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4 3 19h18z"/><path d="M12 10v4M12 16.5v.4"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m8.5 12 2.5 2.5 4.5-5"/></symbol>
|
||||
<symbol id="i-insight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.5.4.5 1 .5 1.6h6c0-.6 0-1.2.5-1.6A6 6 0 0 0 12 3z"/></symbol>
|
||||
<symbol id="i-receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5h12v17l-2.2-1.4-2 1.4-1.8-1.4-1.8 1.4-2-1.4L6 20.5z"/><path d="M9 8h6M9 12h6"/></symbol>
|
||||
<symbol id="i-stats" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19V10M10 19V5M15 19v-6M20 19v-9"/></symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M5 8h7M16 8h3"/><circle cx="14" cy="8" r="2.2"/><path d="M5 16h3M12 16h7"/><circle cx="10" cy="16" r="2.2"/></symbol>
|
||||
</svg>
|
||||
|
||||
<!-- ===== direction switcher ===== -->
|
||||
<div class="switch">
|
||||
<span class="switch__label"><b>Marathon</b> · Redesign</span>
|
||||
<div class="switch__tabs">
|
||||
<button class="tab is-on" data-for="noir"><span class="tab__name">Terminal Noir</span><span class="tab__tag">Dark · Quant</span></button>
|
||||
<button class="tab" data-for="velocity"><span class="tab__name">Velocity</span><span class="tab__tag">Brutalist · Sport</span></button>
|
||||
<button class="tab" data-for="aurora"><span class="tab__name">Aurora</span><span class="tab__tag">Premium · Glass</span></button>
|
||||
</div>
|
||||
<span class="switch__hint">Press 1 · 2 · 3 to switch</span>
|
||||
</div>
|
||||
|
||||
<!-- ===== stages ===== -->
|
||||
<section class="stage d-noir is-active" data-stage="noir"><div class="fx"></div><div class="mount"></div></section>
|
||||
<section class="stage d-velocity" data-stage="velocity"><div class="fx"></div><div class="mount"></div></section>
|
||||
<section class="stage d-aurora" data-stage="aurora"><div class="fx"><span class="glow3"></span></div><div class="mount"></div></section>
|
||||
|
||||
<script>
|
||||
const NAV = [
|
||||
["Analysis", null],
|
||||
["Dashboard","i-grid",true], ["Pre-Match","i-clock"], ["Live","i-bolt"],
|
||||
["Anomalies","i-warn",false,3], ["Results","i-check"], ["Insights","i-insight"],
|
||||
["My Bets","i-receipt"], ["Backtest","i-stats"],
|
||||
["System", null],
|
||||
["Settings","i-gear"],
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
["Events tracked","1,284","+38 today","up"],
|
||||
["Snapshots today","38,902","+5.1K live","up"],
|
||||
["Anomalies","47","+6 today","alert"],
|
||||
["Sports covered","4","all active","flat"],
|
||||
];
|
||||
|
||||
const SIGNALS = [
|
||||
{t:"14:32", sport:"Football", ic:"football", mono:"F", teams:"Динамо Минск — БАТЭ", league:"BLR · Vysshaya Liga", sev:"high", score:0.82, gap:"72s",
|
||||
mkts:[["1","1.85","2.40","up"],["X","3.40","3.05","dn"],["2","4.20","2.95","dn"]]},
|
||||
{t:"13:58", sport:"Basketball", ic:"basketball", mono:"B", teams:"ЦСКА — Зенит", league:"VTB United", sev:"medium", score:0.57, gap:"48s",
|
||||
mkts:[["1","1.62","1.95","up"],["2","2.30","1.88","dn"]]},
|
||||
{t:"12:10", sport:"Tennis", ic:"tennis", mono:"T", teams:"Medvedev — Sinner", league:"ATP Masters 1000", sev:"low", score:0.41, gap:"35s",
|
||||
mkts:[["1","1.40","1.55","up"],["2","2.95","2.55","dn"]]},
|
||||
{t:"11:25", sport:"Hockey", ic:"hockey", mono:"H", teams:"Динамо Мн — Спартак", league:"KHL", sev:"high", score:0.74, gap:"65s",
|
||||
mkts:[["1","2.10","2.85","up"],["X","3.80","3.40","dn"],["2","3.05","2.40","dn"]]},
|
||||
];
|
||||
|
||||
const PIPE = [
|
||||
["01","Schedule scan","Every 6h","ok"],
|
||||
["02","Capture snapshots","30s pre · 5s live","ok"],
|
||||
["03","Detect anomalies","Running now","run"],
|
||||
["04","Export workbook","Manual","idle"],
|
||||
];
|
||||
|
||||
const SEV_LABEL = {high:"High", medium:"Medium", low:"Low"};
|
||||
|
||||
const META = {
|
||||
noir: {name:"Terminal Noir", pick:"Pick this if you read Marathon as a precision instrument — dense, fast, every number first. Closest in spirit to the current build, taken fully dark and neon-lit.", fonts:"Type · Archivo + JetBrains Mono"},
|
||||
velocity: {name:"Velocity", pick:"Pick this if you want energy and impact — loud, confident, unmistakably about sport. Hard edges, acid lime, slammed headlines.", fonts:"Type · Anton + DM Sans + Space Mono"},
|
||||
aurora: {name:"Aurora", pick:"Pick this if you want a calm, premium product feel — soft glass, drifting aurora light, refined gradients. Modern fintech polish.", fonts:"Type · Outfit + Manrope"},
|
||||
};
|
||||
|
||||
function navHTML(){
|
||||
return NAV.map(item=>{
|
||||
if(item[1]===null) return `<div class="nav__group">${item[0]}</div>`;
|
||||
const [label,icon,active,badge]=item;
|
||||
return `<a class="nav-link${active?" is-active":""}">
|
||||
<svg class="ico"><use href="#${icon}"/></svg>
|
||||
<span class="lbl">${label}</span>
|
||||
${badge?`<span class="badge">${badge}</span>`:""}
|
||||
</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function statsHTML(){
|
||||
return STATS.map(([label,val,delta,kind])=>`
|
||||
<div class="stat${kind==="alert"?" stat--alert":""}">
|
||||
<span class="stat__label">${label}</span>
|
||||
<span class="stat__value">${val}</span>
|
||||
<span class="stat__delta ${kind}">${delta}</span>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function mktsHTML(mkts){
|
||||
return mkts.map(([k,pre,post,dir])=>`
|
||||
<span class="mkt ${dir}">
|
||||
<span class="mkt__k">${k}</span>
|
||||
<span class="mkt__pre">${pre}</span>
|
||||
<span class="mkt__arr">→</span>
|
||||
<span class="mkt__post">${post}</span>
|
||||
</span>`).join("");
|
||||
}
|
||||
|
||||
function signalsHTML(){
|
||||
return SIGNALS.map(s=>`
|
||||
<div class="signal">
|
||||
<span class="sporticon si-${s.ic}">${s.mono}</span>
|
||||
<div class="sig-mid">
|
||||
<div style="min-width:0">
|
||||
<div class="sig-time">${s.t} · ${s.sport} · ${s.league}</div>
|
||||
<div class="sig-teams">${s.teams}</div>
|
||||
<div class="sig-mkts">${mktsHTML(s.mkts)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-right">
|
||||
<span class="sev sev--${s.sev}">${SEV_LABEL[s.sev]}</span>
|
||||
<span class="score">
|
||||
<span class="score__bar"><span class="score__fill" style="width:${Math.round(s.score*100)}%"></span></span>
|
||||
<span class="score__n">${s.score.toFixed(2)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function pipeHTML(){
|
||||
return PIPE.map(([idx,lbl,sub,st])=>`
|
||||
<li class="step">
|
||||
<span class="step__idx">${idx}</span>
|
||||
<span class="step__lbl">${lbl}<small>${sub}</small></span>
|
||||
<span class="dot dot--${st}"></span>
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
function dashboardHTML(key){
|
||||
const m = META[key];
|
||||
return `
|
||||
<div class="app">
|
||||
<header class="bar">
|
||||
<button class="bar__menu" aria-label="Menu"><svg class="ico"><use href="#i-menu"/></svg></button>
|
||||
<div class="brand">
|
||||
<span class="brand__mark">Marathon</span>
|
||||
<span class="brand__sub">Odds Lab · v0.1</span>
|
||||
</div>
|
||||
<div class="bar__spacer"></div>
|
||||
<div class="bar__tools">
|
||||
<span class="capture"><span class="capture__dot"></span>Capturing</span>
|
||||
<div class="locale"><button class="locale__btn is-active">RU</button><button class="locale__btn">EN</button></div>
|
||||
<button class="theme" aria-label="Theme"><svg class="ico"><use href="#i-moon"/></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav class="nav">${navHTML()}</nav>
|
||||
<main class="main">
|
||||
<section class="hero">
|
||||
<span class="kicker">Odds Intelligence</span>
|
||||
<h1 class="title">Suspension-flip <em>radar</em></h1>
|
||||
<p class="lede">Live watch on frozen markets that reopen inverted — the moment a bookmaker swaps underdog and favourite. Sorted by confidence, newest first.</p>
|
||||
</section>
|
||||
|
||||
<div class="stats">${statsHTML()}</div>
|
||||
|
||||
<div class="grid2">
|
||||
<section class="panel">
|
||||
<header class="panel__head"><span class="kicker">Latest signals</span><a class="panel__more">View all →</a></header>
|
||||
<div class="feed">${signalsHTML()}</div>
|
||||
</section>
|
||||
<aside class="side">
|
||||
<section class="panel">
|
||||
<header class="panel__head"><span class="kicker">Pipeline</span></header>
|
||||
<ol class="pipe">${pipeHTML()}</ol>
|
||||
</section>
|
||||
<section class="panel concept">
|
||||
<h4>${m.name}</h4>
|
||||
<p>${m.pick}</p>
|
||||
<span class="meta">${m.fonts}</span>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// mount each stage
|
||||
document.querySelectorAll(".stage").forEach(st=>{
|
||||
st.querySelector(".mount").innerHTML = dashboardHTML(st.dataset.stage);
|
||||
});
|
||||
|
||||
// switcher
|
||||
const stages = document.querySelectorAll(".stage");
|
||||
const tabs = document.querySelectorAll(".tab");
|
||||
function show(key){
|
||||
document.body.dataset.active = key;
|
||||
stages.forEach(s=>s.classList.toggle("is-active", s.dataset.stage===key));
|
||||
tabs.forEach(t=>t.classList.toggle("is-on", t.dataset.for===key));
|
||||
// re-trigger entrance animation
|
||||
const active = document.querySelector(".stage.is-active .main");
|
||||
if(active){ active.style.animation="none"; void active.offsetWidth; active.style.animation=""; }
|
||||
window.scrollTo(0,0);
|
||||
}
|
||||
tabs.forEach(t=>t.addEventListener("click",()=>show(t.dataset.for)));
|
||||
addEventListener("keydown",e=>{
|
||||
if(e.key==="1") show("noir");
|
||||
if(e.key==="2") show("velocity");
|
||||
if(e.key==="3") show("aurora");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,4 +44,19 @@ public static class Csv
|
||||
return value;
|
||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defuses spreadsheet formula / DDE injection: when a cell would start with a formula
|
||||
/// trigger (<c>= + - @</c>, tab or CR) it is prefixed with an apostrophe so Excel /
|
||||
/// LibreOffice render it as text. Apply to USER-supplied or SCRAPED text fields (notes,
|
||||
/// event titles) before they enter a row — numeric/date cells your own code formats are
|
||||
/// trusted and don't need it (keeping them numeric for analysis).
|
||||
/// </summary>
|
||||
public static string NeutralizeFormula(string? field)
|
||||
{
|
||||
var value = field ?? string.Empty;
|
||||
return value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r'
|
||||
? "'" + value
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class ExportToCsvUseCase
|
||||
{
|
||||
b.PlacedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||
Title(titles, b.EventId),
|
||||
b.EventId.Value,
|
||||
Csv.NeutralizeFormula(b.EventId.Value),
|
||||
b.Selection.Type.ToString(),
|
||||
b.Selection.Side.ToString(),
|
||||
b.Selection.Value?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
@@ -68,7 +68,7 @@ public sealed class ExportToCsvUseCase
|
||||
b.Stake.ToString(CultureInfo.InvariantCulture),
|
||||
b.Outcome.ToString(),
|
||||
b.NetProfit?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
b.Notes ?? string.Empty,
|
||||
Csv.NeutralizeFormula(b.Notes),
|
||||
});
|
||||
|
||||
return await WriteAsync("journal", Csv.Document(header, rows), ct).ConfigureAwait(false);
|
||||
@@ -93,7 +93,7 @@ public sealed class ExportToCsvUseCase
|
||||
{
|
||||
b.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||
Title(titles, b.EventId),
|
||||
b.EventId.Value,
|
||||
Csv.NeutralizeFormula(b.EventId.Value),
|
||||
b.PickedSide.ToString(),
|
||||
b.Rate.ToString(CultureInfo.InvariantCulture),
|
||||
b.Stake.ToString(CultureInfo.InvariantCulture),
|
||||
@@ -127,6 +127,8 @@ public sealed class ExportToCsvUseCase
|
||||
return titles;
|
||||
}
|
||||
|
||||
// Titles are scraped ("Side1 vs Side2") so they're treated as untrusted text and
|
||||
// neutralized against CSV/formula injection.
|
||||
private static string Title(IReadOnlyDictionary<DomainEventId, string> titles, DomainEventId id) =>
|
||||
titles.TryGetValue(id, out var t) ? t : id.Value;
|
||||
Csv.NeutralizeFormula(titles.TryGetValue(id, out var t) ? t : id.Value);
|
||||
}
|
||||
|
||||
@@ -55,23 +55,33 @@ public sealed class UpdatePlacedBetUseCase
|
||||
"The event must already be present in the scrape store.");
|
||||
}
|
||||
|
||||
// Preserve the original entry time; re-grade from Pending so a changed
|
||||
// selection/event settles against the current result.
|
||||
// Only the selection or the event affects grading. When neither changed (e.g. a
|
||||
// stake/notes-only edit) keep the existing outcome — re-grading from Pending here
|
||||
// would SILENTLY UN-SETTLE a won/lost bet whose result row has since been pruned by
|
||||
// snapshot retention (the journal is FK-free and outlives result pruning). A
|
||||
// still-Pending bet is always (re)graded, mirroring RecordPlacedBetUseCase.
|
||||
var gradingInputChanged = !existing.EventId.Equals(eventId)
|
||||
|| !existing.Selection.Equals(selection);
|
||||
var regrade = gradingInputChanged || existing.Outcome == BetOutcome.Pending;
|
||||
|
||||
var toPersist = new PlacedBet(
|
||||
Id: id,
|
||||
EventId: eventId,
|
||||
Selection: selection,
|
||||
Stake: stake,
|
||||
PlacedAt: existing.PlacedAt,
|
||||
Outcome: BetOutcome.Pending,
|
||||
Outcome: regrade ? BetOutcome.Pending : existing.Outcome,
|
||||
Notes: notes);
|
||||
|
||||
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
if (regrade)
|
||||
{
|
||||
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
|
||||
if (graded is not null)
|
||||
toPersist = toPersist.WithOutcome(graded.Value);
|
||||
var result = await _results.GetAsync(eventId, ct).ConfigureAwait(false);
|
||||
if (result is not null)
|
||||
{
|
||||
var graded = Marathon.Domain.Betting.BetOutcomeResolver.Resolve(toPersist.Selection, result);
|
||||
if (graded is not null)
|
||||
toPersist = toPersist.WithOutcome(graded.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await _bets.UpdateAsync(toPersist, ct).ConfigureAwait(false);
|
||||
|
||||
@@ -7,6 +7,9 @@ namespace Marathon.Domain.ValueObjects;
|
||||
/// </summary>
|
||||
public sealed record EventId
|
||||
{
|
||||
/// <summary>Sane upper bound — real bookmaker ids are short (≈8 chars).</summary>
|
||||
private const int MaxLength = 128;
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public EventId(string value)
|
||||
@@ -14,6 +17,25 @@ public sealed record EventId
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("EventId must not be empty or whitespace.", nameof(value));
|
||||
|
||||
// Defense-in-depth. The id is deliberately string-typed for forward-compat with other
|
||||
// bookmakers' id formats, so we do NOT pin a charset whitelist. We only reject the few
|
||||
// characters that would be dangerous if any current or future consumer ever builds a
|
||||
// file path, filename, or log line from the id — path separators, parent-dir traversal
|
||||
// ("..") and control/newline characters — plus a length cap against pathological input.
|
||||
if (value.Length > MaxLength)
|
||||
throw new ArgumentException($"EventId must be at most {MaxLength} characters.", nameof(value));
|
||||
|
||||
if (value.Contains('/') || value.Contains('\\') || value.Contains(".."))
|
||||
throw new ArgumentException(
|
||||
"EventId must not contain path separators or '..'.", nameof(value));
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
throw new ArgumentException(
|
||||
"EventId must not contain control characters.", nameof(value));
|
||||
}
|
||||
|
||||
Value = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
|
||||
<!-- Prevent flash of unthemed content -->
|
||||
<style>
|
||||
html, body { background: #f5f4ef; margin: 0; }
|
||||
html, body { background: #f3f1e9; margin: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body { background: #0c0a09; }
|
||||
html, body { background: #141310; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
|
||||
<!-- Fonts: Oswald (display) + Manrope (body) + JetBrains Mono (numerals). Full Cyrillic coverage. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet" />
|
||||
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
@@ -26,13 +26,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
|
||||
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
|
||||
<div style="padding: 64px; font-family: 'Manrope', system-ui, sans-serif; color: #6b6757;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #0a0a0a; background: #c6f400; border: 2px solid #0a0a0a; border-radius: 8px; padding: 4px 9px;">Booting</span>
|
||||
<div style="font-family: 'Oswald', sans-serif; font-size: 42px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.01em; margin-top: 14px; color: #0a0a0a;">Marathon Odds Lab</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #ff3b30; color: #fffef8; font-family: 'Manrope', system-ui, sans-serif;">
|
||||
<span>An unhandled error has occurred.</span>
|
||||
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
|
||||
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.m-anomaly-card--high { border-left-color: var(--m-c-anomaly); }
|
||||
.m-anomaly-card--medium { border-left-color: var(--m-c-accent); }
|
||||
.m-anomaly-card--medium { border-left-color: var(--m-c-warning); }
|
||||
.m-anomaly-card--low { border-left-color: var(--m-c-ink-soft); }
|
||||
|
||||
.m-anomaly-card__head {
|
||||
@@ -132,7 +132,7 @@
|
||||
color: var(--m-c-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.m-anomaly-card__rate-arrow { color: var(--m-c-accent); font-size: 0.875rem; }
|
||||
.m-anomaly-card__rate-arrow { color: var(--m-c-ink-soft); font-size: 0.875rem; }
|
||||
.m-anomaly-card__rate-post {
|
||||
color: var(--m-c-ink);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
justify-content: center;
|
||||
font-family: var(--m-font-display);
|
||||
font-size: 1.75rem;
|
||||
color: var(--m-c-accent);
|
||||
color: var(--m-c-info);
|
||||
}
|
||||
.m-evidence__row {
|
||||
display: grid;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
@implements IDisposable
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject AnomalyBrowsingState AnomalyState
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@inject ILogger<NavBody> Logger
|
||||
|
||||
<nav class="m-nav" aria-label="primary">
|
||||
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
|
||||
<div style="font-family: var(--m-font-display); font-size: 1.25rem; color: #fafaf7;">
|
||||
<span style="color: var(--m-c-accent);">M</span>arathon
|
||||
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-4); border-bottom: 2px solid var(--m-c-ink);">
|
||||
<div style="font-family: var(--m-font-display); font-weight: 700; font-size: 1.5rem; text-transform: uppercase; letter-spacing: 0.01em; color: var(--m-c-ink);">
|
||||
<span style="background: var(--m-c-accent); color: var(--m-c-on-accent); padding: 0 5px; border-radius: 5px;">M</span>arathon
|
||||
</div>
|
||||
<div style="font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.18em; text-transform: uppercase; color: rgba(231,229,228,0.55); margin-top: 4px;">
|
||||
<div style="font-family: var(--m-font-mono); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--m-c-ink-soft); margin-top: 4px;">
|
||||
Odds Lab · v0.1
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,18 +81,20 @@
|
||||
<style>
|
||||
.m-nav__badge {
|
||||
margin-left: auto;
|
||||
min-width: 18px;
|
||||
box-sizing: border-box;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
background: var(--m-c-anomaly);
|
||||
color: #ffffff;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 18px;
|
||||
height: 18px;
|
||||
line-height: 16px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 2px solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-sm);
|
||||
animation: m-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
@@ -98,9 +103,56 @@
|
||||
</style>
|
||||
|
||||
@code {
|
||||
// Keep the unread badge live without a visit to the feed. A short poll is fine:
|
||||
// the count is a cheap server-side COUNT(*) over an indexed timestamp.
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15);
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
AnomalyState.OnChange += OnAnomalyStateChanged;
|
||||
_ = PollUnreadAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task PollUnreadAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Refresh once immediately so the badge is correct at startup too,
|
||||
// then keep it fresh on the timer.
|
||||
await RefreshUnreadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using var timer = new PeriodicTimer(PollInterval);
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
await RefreshUnreadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Component disposed — stop polling.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshUnreadAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Fresh DI scope per tick so we never touch a page's scoped DbContext
|
||||
// concurrently from this background loop.
|
||||
await using var scope = ScopeFactory.CreateAsyncScope();
|
||||
var anomalies = scope.ServiceProvider.GetRequiredService<IAnomalyBrowsingService>();
|
||||
var count = await anomalies.GetUnreadCountAsync(AnomalyState.LastSeenUtc, ct).ConfigureAwait(false);
|
||||
AnomalyState.SetUnreadCount(count); // no-op (and no re-render) when unchanged
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // bubble cancellation so the poll loop exits
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "NavBody: failed to refresh unread anomaly count; retrying next tick.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAnomalyStateChanged() => InvokeAsync(StateHasChanged);
|
||||
@@ -108,5 +160,7 @@
|
||||
public void Dispose()
|
||||
{
|
||||
AnomalyState.OnChange -= OnAnomalyStateChanged;
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
color: var(--m-c-ink-soft);
|
||||
transition: color 220ms ease;
|
||||
}
|
||||
.m-odds.is-up .m-odds__delta { color: var(--m-c-accent); }
|
||||
.m-odds.is-up .m-odds__delta { color: var(--m-c-positive); }
|
||||
.m-odds.is-down .m-odds__delta { color: var(--m-c-anomaly); }
|
||||
.m-odds.is-flat .m-odds__delta { color: var(--m-c-ink-soft); }
|
||||
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
var times = points.Select(p => (object)p.At.UtcDateTime).ToList();
|
||||
return new List<ITrace>
|
||||
{
|
||||
BuildSeries("Win 1", "#0f172a", points.Select(p => (object?)p.Win1Rate).ToList(), times),
|
||||
BuildSeries("Draw", "#d97706", points.Select(p => (object?)p.DrawRate).ToList(), times),
|
||||
BuildSeries("Win 2", "#dc2626", points.Select(p => (object?)p.Win2Rate).ToList(), times),
|
||||
BuildSeries("Win 1", "#244bff", points.Select(p => (object?)p.Win1Rate).ToList(), times),
|
||||
BuildSeries("Draw", "#ff8a00", points.Select(p => (object?)p.DrawRate).ToList(), times),
|
||||
BuildSeries("Win 2", "#ff3b30", points.Select(p => (object?)p.Win2Rate).ToList(), times),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,13 +178,13 @@
|
||||
{
|
||||
AutoSize = true,
|
||||
Margin = new Plotly.Blazor.LayoutLib.Margin { L = 56, R = 24, T = 24, B = 48 },
|
||||
PaperBgColor = dark ? "#1c1917" : "#fafaf7",
|
||||
PlotBgColor = dark ? "#0c0a09" : "#fafaf7",
|
||||
PaperBgColor = dark ? "#1e1c15" : "#fffef8",
|
||||
PlotBgColor = dark ? "#141310" : "#fffef8",
|
||||
Font = new Plotly.Blazor.LayoutLib.Font
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 11,
|
||||
Color = dark ? "#f5f5f4" : "#0f172a",
|
||||
Color = dark ? "#f5f3ea" : "#0a0a0a",
|
||||
},
|
||||
XAxis = new List<XAxis>
|
||||
{
|
||||
@@ -192,14 +192,14 @@
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = string.Empty },
|
||||
ShowGrid = true,
|
||||
GridColor = dark ? "#292524" : "#e7e5e4",
|
||||
GridColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
ZeroLine = false,
|
||||
LineColor = dark ? "#292524" : "#e7e5e4",
|
||||
LineColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
TickFont = new Plotly.Blazor.LayoutLib.XAxisLib.TickFont
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 10,
|
||||
Color = dark ? "#a8a29e" : "#475569",
|
||||
Color = dark ? "#9c9784" : "#6b6757",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -209,14 +209,14 @@
|
||||
{
|
||||
Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = string.Empty },
|
||||
ShowGrid = true,
|
||||
GridColor = dark ? "#292524" : "#e7e5e4",
|
||||
GridColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
ZeroLine = false,
|
||||
LineColor = dark ? "#292524" : "#e7e5e4",
|
||||
LineColor = dark ? "#2a2820" : "#e7e3d6",
|
||||
TickFont = new Plotly.Blazor.LayoutLib.YAxisLib.TickFont
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 10,
|
||||
Color = dark ? "#a8a29e" : "#475569",
|
||||
Color = dark ? "#9c9784" : "#6b6757",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -232,7 +232,7 @@
|
||||
{
|
||||
Family = "JetBrains Mono, IBM Plex Mono, Consolas, monospace",
|
||||
Size = 11,
|
||||
Color = dark ? "#e7e5e4" : "#1e293b",
|
||||
Color = dark ? "#d8d4c6" : "#26241e",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
SeverityBadge — small uppercase pill encoding an anomaly's severity bucket.
|
||||
|
||||
The High variant is signal-red (`--m-c-anomaly`) and pulses to draw the eye
|
||||
on the feed page. Medium uses the editorial amber accent. Low is a muted
|
||||
on the feed page. Medium uses an amber warning tone. Low is a muted
|
||||
neutral so it does not compete with higher severities.
|
||||
|
||||
The component is presentational only — callers compute the severity (via
|
||||
@@ -56,8 +56,8 @@
|
||||
background: color-mix(in srgb, var(--m-c-ink-soft) 8%, transparent);
|
||||
}
|
||||
.m-severity--medium {
|
||||
color: var(--m-c-accent);
|
||||
background: color-mix(in srgb, var(--m-c-accent) 12%, transparent);
|
||||
color: var(--m-c-warning);
|
||||
background: color-mix(in srgb, var(--m-c-warning) 12%, transparent);
|
||||
}
|
||||
.m-severity--high {
|
||||
color: var(--m-c-anomaly);
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.m-sport svg { width: 100%; height: 100%; display: block; }
|
||||
.m-sport[data-sport="6"] { color: #d97706; }
|
||||
.m-sport[data-sport="11"] { color: #15803d; }
|
||||
.m-sport[data-sport="22723"] { color: #0369a1; }
|
||||
.m-sport[data-sport="43658"] { color: #6d28d9; }
|
||||
.m-sport[data-sport="6"] { color: #ff8a00; } /* basketball — amber */
|
||||
.m-sport[data-sport="11"] { color: #1f9e3d; } /* football — green */
|
||||
.m-sport[data-sport="22723"] { color: #0d9488; } /* tennis — teal */
|
||||
.m-sport[data-sport="43658"] { color: #244bff; } /* hockey — electric blue */
|
||||
[data-theme="dark"] .m-sport { filter: brightness(1.1); }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
<div class="m-appbar__tools m-rise m-rise-2">
|
||||
<span class="m-capture-pill" data-test="capture-pill"
|
||||
aria-label="@L["Scraping.Aria"]" title="@L["Scraping.Aria"]"
|
||||
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.12em; color:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");">
|
||||
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");"></span>
|
||||
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; font-weight:700; text-transform:uppercase; letter-spacing:0.1em; padding:6px 10px; border-radius:8px; border:2px solid @(Capturing ? "#0a0a0a" : "rgba(255,254,248,0.4)"); background:@(Capturing ? "var(--m-c-accent)" : "transparent"); color:@(Capturing ? "var(--m-c-on-accent)" : "rgba(255,254,248,0.72)");">
|
||||
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "#0a0a0a" : "rgba(255,254,248,0.72)");"></span>
|
||||
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
|
||||
</span>
|
||||
<LocaleSwitcher />
|
||||
@@ -43,7 +43,7 @@
|
||||
ClipMode="DrawerClipMode.Always"
|
||||
Elevation="0"
|
||||
Width="248px"
|
||||
Color="Color.Dark">
|
||||
Color="Color.Surface">
|
||||
<NavBody />
|
||||
</MudDrawer>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<footer class="m-footer">
|
||||
<span class="m-kicker">Marathon Odds Lab</span>
|
||||
<span style="font-family: var(--m-font-mono); font-size: 0.6875rem; color: var(--m-c-ink-soft); letter-spacing: 0.16em; text-transform: uppercase;">
|
||||
Phase 5 · Editorial-Quant · v0.1
|
||||
Velocity · v0.1
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -76,8 +76,9 @@
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper);
|
||||
border-bottom: 2px solid var(--m-c-accent);
|
||||
background: #0a0a0a;
|
||||
color: #fffef8;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -116,15 +117,12 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
border-top: 2px solid var(--m-c-ink);
|
||||
background: var(--m-c-paper);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .m-appbar,
|
||||
[data-theme="dark"] .m-footer {
|
||||
background: var(--m-c-paper-2);
|
||||
border-color: var(--m-c-rule);
|
||||
}
|
||||
/* The appbar is a constant black bar in both themes (set above); only the
|
||||
footer follows the paper/charcoal surface token. */
|
||||
</style>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
}
|
||||
.m-anomaly-feed__stat--high dd { color: var(--m-c-anomaly); }
|
||||
.m-anomaly-feed__stat--medium dd { color: var(--m-c-accent); }
|
||||
.m-anomaly-feed__stat--medium dd { color: var(--m-c-warning); }
|
||||
|
||||
.m-anomaly-feed__list {
|
||||
display: grid;
|
||||
|
||||
@@ -452,8 +452,8 @@
|
||||
.m-backtest__submit {
|
||||
gap: var(--m-space-2);
|
||||
padding: 8px 16px;
|
||||
border-color: var(--m-c-accent);
|
||||
color: var(--m-c-accent);
|
||||
border-color: var(--m-c-info);
|
||||
color: var(--m-c-info);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
@@ -553,7 +553,7 @@
|
||||
letter-spacing: 0.14em;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.m-backtest__preset-save-btn:not(:disabled):hover { border-color: var(--m-c-accent); color: var(--m-c-accent); }
|
||||
.m-backtest__preset-save-btn:not(:disabled):hover { border-color: var(--m-c-info); color: var(--m-c-info); }
|
||||
.m-backtest__preset-save-btn:disabled { opacity: 0.6; cursor: progress; }
|
||||
@@media (prefers-reduced-motion: reduce) {
|
||||
.m-backtest__preset, .m-backtest__preset-del, .m-backtest__preset-save-btn { transition: none; }
|
||||
@@ -748,7 +748,7 @@
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-backtest__open:hover {
|
||||
color: var(--m-c-accent);
|
||||
color: var(--m-c-info);
|
||||
border-bottom-color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
|
||||
@@ -617,7 +617,7 @@
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-insights__open:hover {
|
||||
color: var(--m-c-accent);
|
||||
color: var(--m-c-info);
|
||||
border-bottom-color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
color: var(--m-c-ink); border-bottom: 1px solid var(--m-c-accent); padding-bottom: 1px;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.m-paper__open:hover { color: var(--m-c-accent); border-bottom-color: var(--m-c-ink); }
|
||||
.m-paper__open:hover { color: var(--m-c-info); border-bottom-color: var(--m-c-ink); }
|
||||
|
||||
.m-list-empty {
|
||||
display: grid; place-content: center; gap: var(--m-space-3); padding: var(--m-space-7);
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
.m-cmp__form-hint { font-size: 0.75rem; color: var(--m-c-ink-soft); }
|
||||
.m-cmp__form-actions { display: flex; align-items: end; }
|
||||
.m-cmp__run {
|
||||
gap: var(--m-space-2); padding: 8px 16px; border-color: var(--m-c-accent); color: var(--m-c-accent);
|
||||
gap: var(--m-space-2); padding: 8px 16px; border-color: var(--m-c-info); color: var(--m-c-info);
|
||||
font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
}
|
||||
.m-cmp__run:not(:disabled):hover { background: var(--m-c-accent); color: var(--m-c-paper); }
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{
|
||||
@* First-run: nothing captured yet. Make the next step unmissable. *@
|
||||
<div data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
|
||||
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
|
||||
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-info);">
|
||||
@L["Home.Empty.Heading"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
|
||||
@@ -51,7 +51,7 @@
|
||||
</p>
|
||||
<div>
|
||||
<a href="/settings" data-test="home-empty-cta"
|
||||
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border: 1px solid var(--m-c-accent); color: var(--m-c-accent); font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
|
||||
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; background: var(--m-c-accent); color: var(--m-c-on-accent); border: 2px solid var(--m-c-ink); border-radius: var(--m-radius-md); box-shadow: var(--m-shadow-hard-sm); font-family: var(--m-font-mono); font-weight: 700; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
|
||||
@L["Home.Empty.Cta"] →
|
||||
</a>
|
||||
</div>
|
||||
@@ -62,37 +62,42 @@
|
||||
@* Capturing, but the detector hasn't flagged anything yet. *@
|
||||
<div data-test="home-no-signals" style="display: grid; gap: var(--m-space-3); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
||||
<p style="color: var(--m-c-ink-soft); margin: 0;">@L["Home.NoSignals"]</p>
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-info); text-decoration: none;">
|
||||
@L["Home.ViewAll"] →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: grid; gap: var(--m-space-4);">
|
||||
<div class="m-signal-feed" data-test="home-signals">
|
||||
@foreach (var signal in _summary.LatestSignals)
|
||||
{
|
||||
<a href="@($"/anomalies/{signal.Id}")" data-test="home-signal"
|
||||
style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule); text-decoration: none; color: inherit;">
|
||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
||||
@FormatSignalTime(signal.DetectedAt)
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 500;">@signal.EventTitle</div>
|
||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
||||
@SportLabel(signal.Sport.Value) · @SeverityLabel(signal.Severity)
|
||||
<a href="@($"/anomalies/{signal.Id}")" class="m-signal" data-test="home-signal">
|
||||
<SportIcon Code="@signal.Sport.Value" Label="@SportLabel(signal.Sport.Value)" ClassName="m-signal__icon" />
|
||||
<div class="m-signal__mid">
|
||||
<div class="m-signal__meta m-mono">
|
||||
@FormatSignalTime(signal.DetectedAt) · @SportLabel(signal.Sport.Value) · @signal.CountryCode
|
||||
</div>
|
||||
<div class="m-signal__teams">@signal.EventTitle</div>
|
||||
<div class="m-signal__mkts">
|
||||
@Chip("1", signal.PreWin1Rate, signal.PostWin1Rate)
|
||||
@if (!signal.IsTwoWay)
|
||||
{
|
||||
@Chip("X", signal.PreDrawRate, signal.PostDrawRate)
|
||||
}
|
||||
@Chip("2", signal.PreWin2Rate, signal.PostWin2Rate)
|
||||
</div>
|
||||
</div>
|
||||
<span class="m-anomaly">
|
||||
<span class="m-anomaly__pulse"></span>
|
||||
@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
</span>
|
||||
<div class="m-signal__right">
|
||||
<SeverityBadge Severity="signal.Severity" ShowScore="false" ShowDot="false" />
|
||||
<span class="m-signal__score" data-numeric>@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-info); text-decoration: none;">
|
||||
@L["Home.ViewAll"] →
|
||||
</a>
|
||||
</div>
|
||||
@@ -111,6 +116,75 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.m-signal-feed { display: flex; flex-direction: column; }
|
||||
.m-signal {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--m-space-4);
|
||||
align-items: center;
|
||||
padding: var(--m-space-3) 0;
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.m-signal:hover { transform: translateX(2px); }
|
||||
.m-signal:focus-visible { outline: 2px solid var(--m-c-info); outline-offset: 2px; }
|
||||
.m-signal__icon { --m-sport-size: 28px; margin-top: 2px; }
|
||||
.m-signal__mid { min-width: 0; display: grid; gap: 5px; }
|
||||
.m-signal__meta {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
.m-signal__teams {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.1;
|
||||
color: var(--m-c-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-signal__mkts { display: flex; flex-wrap: wrap; gap: var(--m-space-2); margin-top: 2px; }
|
||||
.m-signal__mkt {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border: 2px solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-sm);
|
||||
background: var(--m-c-paper);
|
||||
color: var(--m-c-ink);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.m-signal__mkt-k { font-weight: 700; opacity: 0.55; }
|
||||
.m-signal__mkt-pre { opacity: 0.55; text-decoration: line-through; }
|
||||
.m-signal__mkt-arrow { opacity: 0.55; }
|
||||
.m-signal__mkt-post { font-weight: 700; }
|
||||
.m-signal__mkt--up { background: var(--m-c-accent); color: var(--m-c-on-accent); border-color: var(--m-c-ink); }
|
||||
.m-signal__mkt--dn { background: color-mix(in srgb, var(--m-c-anomaly) 14%, var(--m-c-paper)); }
|
||||
.m-signal__mkt--dn .m-signal__mkt-post { color: var(--m-c-anomaly); }
|
||||
.m-signal__right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--m-space-2); }
|
||||
.m-signal__score {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
@@media (max-width: 560px) {
|
||||
.m-signal { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.m-signal__right { grid-column: 1 / -1; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private DashboardSummary _summary = DashboardSummary.Empty;
|
||||
|
||||
@@ -157,10 +231,40 @@
|
||||
|
||||
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||
|
||||
private string SeverityLabel(AnomalySeverity severity) => severity switch
|
||||
// Direction of an odds move for the market chip colour: up = drifted out, dn = shortened.
|
||||
private static string ChipDir(decimal? pre, decimal? post) =>
|
||||
pre is { } p && post is { } q && p != q ? (q > p ? "up" : "dn") : string.Empty;
|
||||
|
||||
private static string FormatRate(decimal? r) =>
|
||||
r is { } v ? v.ToString("0.00", CultureInfo.InvariantCulture) : "—";
|
||||
|
||||
// A single 1 / X / 2 market chip: label · struck pre · → · post (coloured by direction).
|
||||
private RenderFragment Chip(string label, decimal? pre, decimal? post) => builder =>
|
||||
{
|
||||
AnomalySeverity.High => L["Anomaly.Severity.High"],
|
||||
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
|
||||
_ => L["Anomaly.Severity.Low"],
|
||||
var dir = ChipDir(pre, post);
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddAttribute(1, "class", dir.Length > 0 ? $"m-signal__mkt m-signal__mkt--{dir}" : "m-signal__mkt");
|
||||
|
||||
builder.OpenElement(2, "span");
|
||||
builder.AddAttribute(3, "class", "m-signal__mkt-k");
|
||||
builder.AddContent(4, label);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(5, "span");
|
||||
builder.AddAttribute(6, "class", "m-signal__mkt-pre");
|
||||
builder.AddContent(7, FormatRate(pre));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(8, "span");
|
||||
builder.AddAttribute(9, "class", "m-signal__mkt-arrow");
|
||||
builder.AddContent(10, "→");
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(11, "span");
|
||||
builder.AddAttribute(12, "class", "m-signal__mkt-post");
|
||||
builder.AddContent(13, FormatRate(post));
|
||||
builder.CloseElement();
|
||||
|
||||
builder.CloseElement();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -579,8 +579,8 @@
|
||||
gap: var(--m-space-3);
|
||||
}
|
||||
.m-journal__submit {
|
||||
border-color: var(--m-c-accent);
|
||||
color: var(--m-c-accent);
|
||||
border-color: var(--m-c-info);
|
||||
color: var(--m-c-info);
|
||||
}
|
||||
.m-journal__submit:not(:disabled):hover {
|
||||
background: var(--m-c-accent);
|
||||
|
||||
@@ -156,11 +156,11 @@
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
|
||||
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
|
||||
.m-result-winner--side1 { background: rgba(31,158,61,0.10); color: var(--m-c-positive); border-color: rgba(31,158,61,0.32); }
|
||||
.m-result-winner--side2 { background: rgba(36,75,255,0.10); color: var(--m-c-info); border-color: rgba(36,75,255,0.32); }
|
||||
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
|
||||
[data-theme="dark"] .m-result-winner--side1 { color: #4ade80; background: rgba(34,197,94,0.15); }
|
||||
[data-theme="dark"] .m-result-winner--side2 { color: #93c5fd; background: rgba(59,130,246,0.15); }
|
||||
[data-theme="dark"] .m-result-winner--side1 { background: rgba(74,222,128,0.16); }
|
||||
[data-theme="dark"] .m-result-winner--side2 { background: rgba(111,139,255,0.16); }
|
||||
</style>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -198,8 +198,8 @@
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
}
|
||||
.m-result-winner--side1 { background: rgba(34,197,94,0.10); color: #15803d; border-color: rgba(34,197,94,0.30); }
|
||||
.m-result-winner--side2 { background: rgba(59,130,246,0.10); color: #1d4ed8; border-color: rgba(59,130,246,0.30); }
|
||||
.m-result-winner--side1 { background: rgba(31,158,61,0.10); color: var(--m-c-positive); border-color: rgba(31,158,61,0.32); }
|
||||
.m-result-winner--side2 { background: rgba(36,75,255,0.10); color: var(--m-c-info); border-color: rgba(36,75,255,0.32); }
|
||||
.m-result-winner--draw { background: rgba(120,113,108,0.10); color: var(--m-c-ink-soft); }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
{
|
||||
if (_filter.SortKey != key) return;
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddAttribute(1, "style", "margin-left: 6px; color: var(--m-c-accent);");
|
||||
builder.AddAttribute(1, "style", "margin-left: 6px; color: var(--m-c-info);");
|
||||
builder.AddContent(2, _filter.SortDescending ? "▼" : "▲");
|
||||
builder.CloseElement();
|
||||
};
|
||||
|
||||
@@ -52,6 +52,9 @@ public sealed class AddBetForm
|
||||
/// <summary>Upper sanity cap on a single wager.</summary>
|
||||
public const decimal MaxStake = 10_000_000m;
|
||||
|
||||
/// <summary>Upper bound on the free-text note so a paste can't bloat the row unbounded.</summary>
|
||||
public const int MaxNotesLength = 2000;
|
||||
|
||||
public bool IsValid(out string? error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EventId)) { error = "EventId is required."; return false; }
|
||||
@@ -59,6 +62,7 @@ public sealed class AddBetForm
|
||||
if (Stake > MaxStake) { error = $"Stake must be at most {MaxStake:N0}."; return false; }
|
||||
if (Rate < 1.01m) { error = "Rate must be at least 1.01."; return false; }
|
||||
if (Rate > MaxRate) { error = $"Rate must be at most {MaxRate:N0}."; return false; }
|
||||
if (Notes is { Length: > MaxNotesLength }) { error = $"Notes must be at most {MaxNotesLength:N0} characters."; return false; }
|
||||
|
||||
// Mirror Bet invariants — surface a friendly message instead of throwing
|
||||
// ArgumentException deep in the use case.
|
||||
|
||||
@@ -6,12 +6,14 @@ namespace Marathon.UI.Theme;
|
||||
/// <summary>
|
||||
/// The Marathon design system, expressed as a MudBlazor theme.
|
||||
///
|
||||
/// Aesthetic direction: editorial-quant. Inspired by Bloomberg terminals,
|
||||
/// FT.com long-reads, and Quartz dashboards. Confident, information-dense,
|
||||
/// reveals patterns. Pairs IBM Plex Sans (Cyrillic-capable display + body)
|
||||
/// with JetBrains Mono for tabular numerals. Anomaly accent is a load-bearing
|
||||
/// signal red so Phase 7 can hang the entire anomaly visual language off
|
||||
/// <c>palette.Error</c> without coupling to a hard-coded hex.
|
||||
/// Aesthetic direction: "Velocity" — neo-brutalist sportsbook. Acid lime
|
||||
/// accent, hard offset shadows, slammed uppercase headlines. Pairs Oswald
|
||||
/// (Cyrillic-capable condensed display) and Manrope (body) with JetBrains
|
||||
/// Mono for tabular numerals — the mockup's Anton/DM Sans/Space Mono lack
|
||||
/// Cyrillic, which this Russian-first product requires. Anomaly accent is a
|
||||
/// load-bearing signal red so the anomaly visual language hangs off
|
||||
/// <c>palette.Error</c> without coupling to a hard-coded hex. Light "paper"
|
||||
/// chassis with a warm-charcoal dark mode; the black appbar is constant.
|
||||
/// </summary>
|
||||
public static class MarathonTheme
|
||||
{
|
||||
@@ -27,103 +29,105 @@ public static class MarathonTheme
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Palettes — single accent (amber #d97706), single signal (red #ef4444)
|
||||
// on a deep navy/parchment chassis. No purple gradients, no cliche.
|
||||
// Palettes — acid lime accent + signal red on a "paper" chassis
|
||||
// (warm-charcoal in dark). Primary is the boldest structural colour
|
||||
// per mode: ink in light, lime in dark. The appbar is black in both.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly PaletteLight LightPalette = new()
|
||||
{
|
||||
Primary = "#0f172a", // deep navy / ink
|
||||
PrimaryContrastText = "#fafaf7",
|
||||
Secondary = "#334155", // slate
|
||||
SecondaryContrastText = "#fafaf7",
|
||||
Tertiary = "#d97706", // amber accent
|
||||
TertiaryContrastText = "#1c1917",
|
||||
Info = "#0369a1",
|
||||
Success = "#15803d",
|
||||
Warning = "#b45309",
|
||||
Error = "#dc2626", // anomaly signal
|
||||
ErrorContrastText = "#fff7ed",
|
||||
Primary = "#0a0a0a", // ink — brutalist black actions
|
||||
PrimaryContrastText = "#fffef8",
|
||||
Secondary = "#26241e", // warm ink
|
||||
SecondaryContrastText = "#fffef8",
|
||||
Tertiary = "#c6f400", // acid lime accent
|
||||
TertiaryContrastText = "#0a0a0a",
|
||||
Info = "#244bff", // electric blue
|
||||
Success = "#1f9e3d",
|
||||
Warning = "#ff8a00",
|
||||
Error = "#ff3b30", // anomaly signal
|
||||
ErrorContrastText = "#fffef8",
|
||||
|
||||
Black = "#1c1917",
|
||||
White = "#fafaf7",
|
||||
Surface = "#fafaf7", // warm parchment
|
||||
Background = "#f5f4ef", // a half-step warmer than surface
|
||||
BackgroundGray = "#ebe9e1",
|
||||
DrawerBackground = "#0f172a", // dark drawer on light app — editorial contrast
|
||||
DrawerText = "#e7e5e4",
|
||||
DrawerIcon = "#d6d3d1",
|
||||
AppbarBackground = "#fafaf7",
|
||||
AppbarText = "#0f172a",
|
||||
Black = "#0a0a0a",
|
||||
White = "#fffef8",
|
||||
Surface = "#fffef8", // card paper
|
||||
Background = "#f3f1e9", // page paper
|
||||
BackgroundGray = "#e7e3d6",
|
||||
DrawerBackground = "#fffef8", // paper drawer — brutalist light nav
|
||||
DrawerText = "#26241e",
|
||||
DrawerIcon = "#6b6757",
|
||||
AppbarBackground = "#0a0a0a", // constant black bar
|
||||
AppbarText = "#fffef8",
|
||||
|
||||
TextPrimary = "#0f172a",
|
||||
TextSecondary = "#475569",
|
||||
TextDisabled = "#94a3b8",
|
||||
ActionDefault = "#334155",
|
||||
ActionDisabled = "#cbd5e1",
|
||||
ActionDisabledBackground = "#e2e8f0",
|
||||
TextPrimary = "#0a0a0a",
|
||||
TextSecondary = "#6b6757",
|
||||
TextDisabled = "#a8a293",
|
||||
ActionDefault = "#26241e",
|
||||
ActionDisabled = "#cbc7b8",
|
||||
ActionDisabledBackground = "#e7e3d6",
|
||||
|
||||
LinesDefault = "#e7e5e4",
|
||||
LinesInputs = "#cbd5e1",
|
||||
TableLines = "#e7e5e4",
|
||||
TableStriped = "#f5f4ef",
|
||||
TableHover = "#ebe9e1",
|
||||
Divider = "#e7e5e4",
|
||||
DividerLight = "#f1f5f9",
|
||||
LinesDefault = "#d8d4c4",
|
||||
LinesInputs = "#0a0a0a", // bold black input borders
|
||||
TableLines = "#e7e3d6",
|
||||
TableStriped = "#f3f1e9",
|
||||
TableHover = "#e7e3d6",
|
||||
Divider = "#d8d4c4",
|
||||
DividerLight = "#ebe7da",
|
||||
|
||||
OverlayDark = new MudColor("#0f172a99").Value,
|
||||
OverlayLight = new MudColor("#fafaf7cc").Value,
|
||||
OverlayDark = new MudColor("#0a0a0a99").Value,
|
||||
OverlayLight = new MudColor("#fffef8cc").Value,
|
||||
};
|
||||
|
||||
private static readonly PaletteDark DarkPalette = new()
|
||||
{
|
||||
Primary = "#fbbf24", // amber, promoted in dark mode
|
||||
PrimaryContrastText = "#0c0a09",
|
||||
Secondary = "#94a3b8",
|
||||
SecondaryContrastText = "#0c0a09",
|
||||
Tertiary = "#fbbf24",
|
||||
TertiaryContrastText = "#0c0a09",
|
||||
Info = "#38bdf8",
|
||||
Primary = "#c6f400", // lime, promoted in dark mode
|
||||
PrimaryContrastText = "#0a0a0a",
|
||||
Secondary = "#d8d4c6",
|
||||
SecondaryContrastText = "#0a0a0a",
|
||||
Tertiary = "#c6f400",
|
||||
TertiaryContrastText = "#0a0a0a",
|
||||
Info = "#6f8bff",
|
||||
Success = "#4ade80",
|
||||
Warning = "#fbbf24",
|
||||
Error = "#f87171", // anomaly signal — softened for dark
|
||||
ErrorContrastText = "#0c0a09",
|
||||
Warning = "#ffae42",
|
||||
Error = "#ff5a4f", // anomaly signal — softened for dark
|
||||
ErrorContrastText = "#0a0a0a",
|
||||
|
||||
Black = "#0c0a09",
|
||||
White = "#fafaf7",
|
||||
Surface = "#1c1917", // ink-stained paper
|
||||
Background = "#0c0a09", // near-black
|
||||
BackgroundGray = "#1c1917",
|
||||
DrawerBackground = "#0c0a09",
|
||||
DrawerText = "#e7e5e4",
|
||||
DrawerIcon = "#a8a29e",
|
||||
AppbarBackground = "#0c0a09",
|
||||
AppbarText = "#fafaf7",
|
||||
Black = "#0a0a0a",
|
||||
White = "#fffef8",
|
||||
Surface = "#1e1c15", // warm charcoal card
|
||||
Background = "#141310", // near-black warm
|
||||
BackgroundGray = "#2a2820",
|
||||
DrawerBackground = "#1e1c15",
|
||||
DrawerText = "#d8d4c6",
|
||||
DrawerIcon = "#9c9784",
|
||||
AppbarBackground = "#0a0a0a", // constant black bar
|
||||
AppbarText = "#f5f3ea",
|
||||
|
||||
TextPrimary = "#f5f5f4",
|
||||
TextSecondary = "#a8a29e",
|
||||
TextPrimary = "#f5f3ea",
|
||||
TextSecondary = "#9c9784",
|
||||
TextDisabled = "#57534e",
|
||||
ActionDefault = "#a8a29e",
|
||||
ActionDisabled = "#44403c",
|
||||
ActionDisabledBackground = "#1c1917",
|
||||
ActionDefault = "#d8d4c6",
|
||||
ActionDisabled = "#57534e",
|
||||
ActionDisabledBackground = "#2a2820",
|
||||
|
||||
LinesDefault = "#292524",
|
||||
LinesInputs = "#44403c",
|
||||
TableLines = "#292524",
|
||||
TableStriped = "#1c1917",
|
||||
TableHover = "#292524",
|
||||
Divider = "#292524",
|
||||
DividerLight = "#1c1917",
|
||||
LinesDefault = "#3a3730",
|
||||
LinesInputs = "#f5f3ea", // light input borders on charcoal
|
||||
TableLines = "#2a2820",
|
||||
TableStriped = "#1e1c15",
|
||||
TableHover = "#2a2820",
|
||||
Divider = "#3a3730",
|
||||
DividerLight = "#2a2820",
|
||||
|
||||
OverlayDark = new MudColor("#0c0a09cc").Value,
|
||||
OverlayLight = new MudColor("#fafaf722").Value,
|
||||
OverlayDark = new MudColor("#141310cc").Value,
|
||||
OverlayLight = new MudColor("#f5f3ea22").Value,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Typography — IBM Plex Sans / JetBrains Mono / IBM Plex Serif (display)
|
||||
// All have full Cyrillic coverage. Numerals are tabular.
|
||||
// Typography — Oswald (condensed display) / Manrope (body) /
|
||||
// JetBrains Mono (numerals). All have full Cyrillic coverage, which
|
||||
// the Velocity mockup's Anton/DM Sans/Space Mono do not. Numerals tabular.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly string[] DisplayStack = { "IBM Plex Serif", "PT Serif", "Georgia", "serif" };
|
||||
private static readonly string[] BodyStack = { "IBM Plex Sans", "PT Sans", "system-ui", "sans-serif" };
|
||||
private static readonly string[] DisplayStack = { "Oswald", "PT Sans Narrow", "Arial Narrow", "sans-serif" };
|
||||
private static readonly string[] BodyStack = { "Manrope", "Segoe UI", "system-ui", "sans-serif" };
|
||||
private static readonly string[] MonoStack = { "JetBrains Mono", "IBM Plex Mono", "Fira Code", "Consolas", "monospace" };
|
||||
|
||||
private static readonly Typography MarathonTypography = new()
|
||||
@@ -139,26 +143,29 @@ public static class MarathonTheme
|
||||
H1 = new H1
|
||||
{
|
||||
FontFamily = DisplayStack,
|
||||
FontWeight = 300,
|
||||
FontSize = "clamp(2.25rem, 4vw, 3.5rem)",
|
||||
LineHeight = 1.05,
|
||||
LetterSpacing = "-0.022em",
|
||||
FontWeight = 700,
|
||||
FontSize = "clamp(2.5rem, 5vw, 4rem)",
|
||||
LineHeight = 0.98,
|
||||
LetterSpacing = "0.005em",
|
||||
TextTransform = "uppercase",
|
||||
},
|
||||
H2 = new H2
|
||||
{
|
||||
FontFamily = DisplayStack,
|
||||
FontWeight = 400,
|
||||
FontSize = "clamp(1.75rem, 2.5vw, 2.25rem)",
|
||||
LineHeight = 1.15,
|
||||
LetterSpacing = "-0.018em",
|
||||
FontWeight = 700,
|
||||
FontSize = "clamp(1.875rem, 3vw, 2.5rem)",
|
||||
LineHeight = 1.02,
|
||||
LetterSpacing = "0.005em",
|
||||
TextTransform = "uppercase",
|
||||
},
|
||||
H3 = new H3
|
||||
{
|
||||
FontFamily = DisplayStack,
|
||||
FontWeight = 500,
|
||||
FontSize = "1.5rem",
|
||||
LineHeight = 1.25,
|
||||
LetterSpacing = "-0.012em",
|
||||
FontWeight = 600,
|
||||
FontSize = "1.625rem",
|
||||
LineHeight = 1.1,
|
||||
LetterSpacing = "0.01em",
|
||||
TextTransform = "uppercase",
|
||||
},
|
||||
H4 = new H4
|
||||
{
|
||||
@@ -242,12 +249,11 @@ public static class MarathonTheme
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Layout — sharp corners, narrow drawer. The aesthetic earns its
|
||||
// authority through restraint.
|
||||
// Layout — rounded brutalist blocks, narrow drawer.
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly LayoutProperties LayoutProps = new()
|
||||
{
|
||||
DefaultBorderRadius = "2px",
|
||||
DefaultBorderRadius = "10px",
|
||||
AppbarHeight = "60px",
|
||||
DrawerWidthLeft = "248px",
|
||||
DrawerWidthRight = "248px",
|
||||
|
||||
@@ -9,14 +9,14 @@ public static class Tokens
|
||||
{
|
||||
public static class Colors
|
||||
{
|
||||
public const string AnomalySignal = "#dc2626";
|
||||
public const string AnomalySignalDark = "#f87171";
|
||||
public const string Accent = "#d97706";
|
||||
public const string AccentDark = "#fbbf24";
|
||||
public const string InkPrimary = "#0f172a";
|
||||
public const string Parchment = "#fafaf7";
|
||||
public const string ParchmentDeep = "#f5f4ef";
|
||||
public const string InkDeep = "#0c0a09";
|
||||
public const string AnomalySignal = "#ff3b30";
|
||||
public const string AnomalySignalDark = "#ff5a4f";
|
||||
public const string Accent = "#c6f400"; // acid lime
|
||||
public const string AccentDark = "#c6f400";
|
||||
public const string InkPrimary = "#0a0a0a";
|
||||
public const string Parchment = "#fffef8";
|
||||
public const string ParchmentDeep = "#f3f1e9";
|
||||
public const string InkDeep = "#141310";
|
||||
}
|
||||
|
||||
public static class Spacing
|
||||
@@ -31,8 +31,8 @@ public static class Tokens
|
||||
|
||||
public static class Typography
|
||||
{
|
||||
public const string DisplayStack = "\"IBM Plex Serif\", \"PT Serif\", Georgia, serif";
|
||||
public const string BodyStack = "\"IBM Plex Sans\", \"PT Sans\", system-ui, sans-serif";
|
||||
public const string DisplayStack = "\"Oswald\", \"PT Sans Narrow\", \"Arial Narrow\", sans-serif";
|
||||
public const string BodyStack = "\"Manrope\", \"Segoe UI\", system-ui, sans-serif";
|
||||
public const string MonoStack = "\"JetBrains Mono\", \"IBM Plex Mono\", \"Fira Code\", Consolas, monospace";
|
||||
}
|
||||
}
|
||||
|
||||
+160
-117
@@ -1,9 +1,13 @@
|
||||
/* ===================================================================
|
||||
Marathon — Editorial-Quant design system
|
||||
Marathon — "Velocity" design system
|
||||
------------------------------------------------------------------
|
||||
Inspiration: long-form data journalism (FT, Quartz), terminal
|
||||
instruments (Bloomberg), and Belarusian / Soviet print typography.
|
||||
The aesthetic is confident, dense, and serif-led on display surfaces.
|
||||
Neo-brutalist sportsbook. Acid lime, hard offset shadows, slammed
|
||||
uppercase headlines. Cyrillic-complete type: Oswald (display) +
|
||||
Manrope (body) + JetBrains Mono (numerals / labels) — the Velocity
|
||||
mockup's Anton/DM Sans/Space Mono have no Cyrillic, which this
|
||||
Russian-first product needs.
|
||||
Light "paper" chassis + a warm-charcoal dark mode; both keep the
|
||||
lime accent and the hard ink/black shadow signature.
|
||||
=================================================================== */
|
||||
|
||||
:root {
|
||||
@@ -18,50 +22,65 @@
|
||||
--m-space-8: 64px;
|
||||
--m-space-9: 96px;
|
||||
|
||||
/* ----- Radius — sharp by default, soft variants for inputs ----- */
|
||||
/* ----- Radius — rounded brutalist blocks ----- */
|
||||
--m-radius-sharp: 0;
|
||||
--m-radius-xs: 2px;
|
||||
--m-radius-sm: 4px;
|
||||
--m-radius-md: 6px;
|
||||
--m-radius-lg: 10px;
|
||||
--m-radius-xs: 6px;
|
||||
--m-radius-sm: 8px;
|
||||
--m-radius-md: 10px;
|
||||
--m-radius-lg: 14px;
|
||||
|
||||
/* ----- Typography ----- */
|
||||
--m-font-display: "IBM Plex Serif", "PT Serif", Georgia, serif;
|
||||
--m-font-body: "IBM Plex Sans", "PT Sans", system-ui, sans-serif;
|
||||
/* ----- Brutalist borders + hard offset shadows ----- */
|
||||
--m-border-w: 2px;
|
||||
--m-border-w-bold: 3px;
|
||||
--m-shadow-hard: 5px 5px 0 var(--m-c-ink);
|
||||
--m-shadow-hard-sm: 3px 3px 0 var(--m-c-ink);
|
||||
--m-shadow-hard-accent: 5px 5px 0 var(--m-c-accent);
|
||||
--m-shadow-hard-anomaly: 5px 5px 0 var(--m-c-anomaly);
|
||||
|
||||
/* ----- Typography (all Cyrillic-complete) ----- */
|
||||
--m-font-display: "Oswald", "PT Sans Narrow", "Arial Narrow", sans-serif;
|
||||
--m-font-body: "Manrope", "Segoe UI", system-ui, sans-serif;
|
||||
--m-font-mono: "JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace;
|
||||
|
||||
/* ----- Colors — light (parchment) chassis ----- */
|
||||
--m-c-ink: #0f172a;
|
||||
--m-c-ink-2: #1e293b;
|
||||
--m-c-ink-soft: #475569;
|
||||
--m-c-paper: #fafaf7;
|
||||
--m-c-paper-2: #f5f4ef;
|
||||
--m-c-paper-3: #ebe9e1;
|
||||
--m-c-rule: #e7e5e4;
|
||||
--m-c-accent: #d97706;
|
||||
--m-c-accent-soft: #f59e0b;
|
||||
--m-c-anomaly: #dc2626;
|
||||
--m-c-positive: #15803d;
|
||||
--m-c-info: #0369a1;
|
||||
/* ----- Colors — light "paper" chassis ----- */
|
||||
--m-c-ink: #0a0a0a;
|
||||
--m-c-ink-2: #26241e;
|
||||
--m-c-ink-soft: #6b6757;
|
||||
--m-c-paper: #fffef8;
|
||||
--m-c-paper-2: #f3f1e9;
|
||||
--m-c-paper-3: #e7e3d6;
|
||||
--m-c-rule: #d8d4c4; /* soft divider — brutalist weight lives on cards/inputs, not page hairlines */
|
||||
--m-c-accent: #c6f400; /* acid lime */
|
||||
--m-c-accent-soft: #b3dd00;
|
||||
--m-c-on-accent: #0a0a0a; /* ink on lime — lime is a light hue */
|
||||
--m-c-anomaly: #ff3b30;
|
||||
--m-c-positive: #1f9e3d;
|
||||
--m-c-info: #244bff; /* electric blue — interactive / highlight / link text */
|
||||
--m-c-warning: #c2680a; /* amber — medium severity + warnings (lime is unreadable as text) */
|
||||
|
||||
/* Tabular numerals for everywhere odds/scores appear */
|
||||
--m-num-feature: "tnum" 1, "lnum" 1, "ss01" 1;
|
||||
--m-num-feature: "tnum" 1, "lnum" 1;
|
||||
}
|
||||
|
||||
/* Dark theme overrides (applied via class on <html> or via MudThemeProvider) */
|
||||
/* Dark theme overrides — warm charcoal, lime retained */
|
||||
.mud-theme-dark, [data-theme="dark"] {
|
||||
--m-c-ink: #f5f5f4;
|
||||
--m-c-ink-2: #e7e5e4;
|
||||
--m-c-ink-soft: #a8a29e;
|
||||
--m-c-paper: #1c1917;
|
||||
--m-c-paper-2: #0c0a09;
|
||||
--m-c-paper-3: #292524;
|
||||
--m-c-rule: #292524;
|
||||
--m-c-accent: #fbbf24;
|
||||
--m-c-accent-soft: #fcd34d;
|
||||
--m-c-anomaly: #f87171;
|
||||
--m-c-ink: #f5f3ea;
|
||||
--m-c-ink-2: #d8d4c6;
|
||||
--m-c-ink-soft: #9c9784;
|
||||
--m-c-paper: #1e1c15;
|
||||
--m-c-paper-2: #141310;
|
||||
--m-c-paper-3: #2a2820;
|
||||
--m-c-rule: #3a3730;
|
||||
--m-c-accent: #c6f400;
|
||||
--m-c-accent-soft: #aacc00;
|
||||
--m-c-on-accent: #0a0a0a;
|
||||
--m-c-anomaly: #ff5a4f;
|
||||
--m-c-positive: #4ade80;
|
||||
--m-c-info: #38bdf8;
|
||||
--m-c-info: #6f8bff;
|
||||
--m-c-warning: #ffb24d;
|
||||
/* Hard shadow goes pure-black on dark; the light border defines the block. */
|
||||
--m-shadow-hard: 5px 5px 0 #000000;
|
||||
--m-shadow-hard-sm: 3px 3px 0 #000000;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
@@ -81,19 +100,13 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
/* Subtle paper grain — 1px mottled noise, rendered cheaply via SVG. */
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 12%, rgba(217, 119, 6, 0.035), transparent 45%),
|
||||
radial-gradient(circle at 88% 78%, rgba(15, 23, 42, 0.040), transparent 50%),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
/* Subtle diagonal hatch — the Velocity texture. */
|
||||
background-image: repeating-linear-gradient(135deg, transparent 0 22px, rgba(10, 10, 10, 0.025) 22px 24px);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.mud-theme-dark body, [data-theme="dark"] body {
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 12%, rgba(251, 191, 36, 0.045), transparent 45%),
|
||||
radial-gradient(circle at 88% 78%, rgba(56, 189, 248, 0.030), transparent 50%),
|
||||
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.025 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
background-image: repeating-linear-gradient(135deg, transparent 0 22px, rgba(255, 255, 255, 0.022) 22px 24px);
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -120,61 +133,71 @@ body {
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Editorial markers — kicker label + serif display lockup
|
||||
Editorial markers — lime kicker chip + slammed display lockup
|
||||
=================================================================== */
|
||||
.m-kicker {
|
||||
font-family: var(--m-font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--m-c-accent);
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
color: var(--m-c-on-accent);
|
||||
background: var(--m-c-accent);
|
||||
border: var(--m-border-w) solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-md);
|
||||
padding: 5px 10px;
|
||||
display: inline-block;
|
||||
padding-bottom: var(--m-space-1);
|
||||
border-bottom: 1px solid var(--m-c-accent);
|
||||
}
|
||||
|
||||
/* A tilted variant for hero kickers — opt-in so it never skews chrome. */
|
||||
.m-kicker--tilt {
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
.m-display {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.022em;
|
||||
line-height: 1.05;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.005em;
|
||||
line-height: 0.98;
|
||||
text-transform: uppercase;
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
.m-rule {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--m-c-rule);
|
||||
border-top: var(--m-border-w) solid var(--m-c-ink);
|
||||
margin: var(--m-space-5) 0;
|
||||
}
|
||||
|
||||
.m-rule--double {
|
||||
border: 0;
|
||||
border-top: 3px double var(--m-c-rule);
|
||||
border-top: var(--m-border-w-bold) double var(--m-c-ink);
|
||||
margin: var(--m-space-5) 0;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Cards — paper-like, borders not shadows
|
||||
Cards — brutalist blocks: thick border + hard offset shadow
|
||||
=================================================================== */
|
||||
.m-card {
|
||||
background: var(--m-c-paper);
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: var(--m-border-w-bold) solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-lg);
|
||||
box-shadow: var(--m-shadow-hard);
|
||||
padding: var(--m-space-5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Emphasis variants recolour the hard shadow rather than a hidden left rule. */
|
||||
.m-card--accented {
|
||||
border-left: 3px solid var(--m-c-accent);
|
||||
box-shadow: var(--m-shadow-hard-accent);
|
||||
}
|
||||
|
||||
.m-card--anomaly {
|
||||
border-left: 3px solid var(--m-c-anomaly);
|
||||
box-shadow: var(--m-shadow-hard-anomaly);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Stat block — large number, mono, kicker on top
|
||||
Stat block — slammed Oswald number, lime tick, mono label
|
||||
=================================================================== */
|
||||
.m-stat {
|
||||
display: flex;
|
||||
@@ -183,26 +206,37 @@ body {
|
||||
}
|
||||
|
||||
.m-stat__value {
|
||||
font-family: var(--m-font-mono);
|
||||
font-family: var(--m-font-display);
|
||||
font-feature-settings: var(--m-num-feature);
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
font-weight: 500;
|
||||
font-size: clamp(2.25rem, 4.5vw, 3.25rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--m-c-ink);
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.m-stat__value::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 5px;
|
||||
margin-top: var(--m-space-2);
|
||||
background: var(--m-c-accent);
|
||||
}
|
||||
|
||||
.m-stat__label {
|
||||
font-family: var(--m-font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--m-c-ink-soft);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.m-stat__delta {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--m-c-positive);
|
||||
}
|
||||
|
||||
@@ -239,16 +273,16 @@ body {
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Focus rings — deliberate, accent, never invisible
|
||||
Focus rings — electric blue for contrast on lime/paper and charcoal
|
||||
=================================================================== */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--m-c-accent);
|
||||
outline: var(--m-border-w) solid var(--m-c-info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mud-button:focus-visible,
|
||||
.mud-icon-button:focus-visible {
|
||||
outline: 2px solid var(--m-c-accent);
|
||||
outline: var(--m-border-w) solid var(--m-c-info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -292,10 +326,12 @@ body {
|
||||
|
||||
.m-brand__mark {
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 500;
|
||||
font-size: 1.375rem;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.m-brand__mark::first-letter {
|
||||
@@ -304,47 +340,55 @@ body {
|
||||
|
||||
.m-brand__dateline {
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--m-c-ink-soft);
|
||||
border-left: 1px solid var(--m-c-rule);
|
||||
color: var(--m-c-accent);
|
||||
border-left: var(--m-border-w) solid currentColor;
|
||||
padding-left: var(--m-space-3);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Drawer — narrow, dark, mono labels
|
||||
Drawer — paper sidebar, brutalist nav blocks
|
||||
=================================================================== */
|
||||
.m-nav__group {
|
||||
padding: var(--m-space-3) var(--m-space-4);
|
||||
padding: var(--m-space-4) var(--m-space-3) var(--m-space-2);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: rgba(231, 229, 228, 0.55);
|
||||
color: var(--m-c-ink-soft);
|
||||
}
|
||||
|
||||
.m-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-3);
|
||||
padding: var(--m-space-3) var(--m-space-4);
|
||||
color: rgba(231, 229, 228, 0.85);
|
||||
padding: 10px 12px;
|
||||
color: var(--m-c-ink-2);
|
||||
text-decoration: none;
|
||||
font-size: 0.9375rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
font-family: var(--m-font-body);
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
border: var(--m-border-w) solid transparent;
|
||||
border-radius: var(--m-radius-md);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.m-nav__link:hover {
|
||||
background: rgba(217, 119, 6, 0.10);
|
||||
color: #ffffff;
|
||||
border-color: var(--m-c-ink);
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: var(--m-shadow-hard-sm);
|
||||
color: var(--m-c-ink);
|
||||
}
|
||||
|
||||
.m-nav__link.active {
|
||||
color: #ffffff;
|
||||
background: rgba(217, 119, 6, 0.14);
|
||||
border-left-color: var(--m-c-accent);
|
||||
background: var(--m-c-accent);
|
||||
color: var(--m-c-on-accent);
|
||||
border-color: var(--m-c-ink);
|
||||
box-shadow: var(--m-shadow-hard-sm);
|
||||
}
|
||||
|
||||
.m-nav__link .mud-icon-root { font-size: 1.1rem; }
|
||||
@@ -354,8 +398,8 @@ body {
|
||||
=================================================================== */
|
||||
.m-segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: var(--m-border-w) solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--m-c-paper);
|
||||
}
|
||||
@@ -367,15 +411,16 @@ body {
|
||||
padding: 6px 12px;
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--m-c-ink-soft);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.m-segmented__btn + .m-segmented__btn {
|
||||
border-left: 1px solid var(--m-c-rule);
|
||||
border-left: var(--m-border-w) solid var(--m-c-ink);
|
||||
}
|
||||
|
||||
.m-segmented__btn:hover {
|
||||
@@ -383,21 +428,17 @@ body {
|
||||
}
|
||||
|
||||
.m-segmented__btn.is-active {
|
||||
background: var(--m-c-ink);
|
||||
color: var(--m-c-paper);
|
||||
}
|
||||
|
||||
.mud-theme-dark .m-segmented__btn.is-active,
|
||||
[data-theme="dark"] .m-segmented__btn.is-active {
|
||||
background: var(--m-c-accent);
|
||||
color: var(--m-c-paper-2);
|
||||
color: var(--m-c-on-accent);
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Settings page — section ledger
|
||||
=================================================================== */
|
||||
.m-section {
|
||||
border: 1px solid var(--m-c-rule);
|
||||
border: var(--m-border-w-bold) solid var(--m-c-ink);
|
||||
border-radius: var(--m-radius-lg);
|
||||
box-shadow: var(--m-shadow-hard);
|
||||
background: var(--m-c-paper);
|
||||
margin-bottom: var(--m-space-5);
|
||||
}
|
||||
@@ -407,16 +448,17 @@ body {
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: var(--m-space-4) var(--m-space-5);
|
||||
border-bottom: 1px solid var(--m-c-rule);
|
||||
background: var(--m-c-paper-2);
|
||||
border-bottom: var(--m-border-w) solid var(--m-c-ink);
|
||||
background: var(--m-c-paper-3);
|
||||
}
|
||||
|
||||
.m-section__head h2 {
|
||||
margin: 0;
|
||||
font-family: var(--m-font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.012em;
|
||||
font-weight: 700;
|
||||
font-size: 1.375rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.m-section__body {
|
||||
@@ -450,15 +492,16 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--m-space-2);
|
||||
padding: 2px 8px;
|
||||
background: rgba(220, 38, 38, 0.10);
|
||||
padding: 3px 9px;
|
||||
background: var(--m-c-paper);
|
||||
color: var(--m-c-anomaly);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--m-radius-xs);
|
||||
border: var(--m-border-w) solid currentColor;
|
||||
border-radius: var(--m-radius-sm);
|
||||
font-family: var(--m-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.m-anomaly__pulse {
|
||||
|
||||
@@ -8,17 +8,17 @@
|
||||
|
||||
<!-- Prevent flash of unthemed content -->
|
||||
<style>
|
||||
html, body { background: #f5f4ef; margin: 0; }
|
||||
html, body { background: #f3f1e9; margin: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body { background: #0c0a09; }
|
||||
html, body { background: #141310; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Fonts: IBM Plex Sans / Serif / Mono + JetBrains Mono. Full Cyrillic coverage. -->
|
||||
<!-- Fonts: Oswald (display) + Manrope (body) + JetBrains Mono (numerals). Full Cyrillic coverage. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:wght@300;400;500;600&family=IBM+Plex+Mono:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet" />
|
||||
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" />
|
||||
@@ -26,13 +26,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div style="padding: 64px; font-family: 'IBM Plex Serif', Georgia, serif; color: #475569;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #d97706;">Booting</span>
|
||||
<div style="font-size: 32px; font-weight: 300; margin-top: 8px;">Marathon Odds Lab</div>
|
||||
<div style="padding: 64px; font-family: 'Manrope', system-ui, sans-serif; color: #6b6757;">
|
||||
<span style="font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #0a0a0a; background: #c6f400; border: 2px solid #0a0a0a; border-radius: 8px; padding: 4px 9px;">Booting</span>
|
||||
<div style="font-family: 'Oswald', sans-serif; font-size: 42px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.01em; margin-top: 14px; color: #0a0a0a;">Marathon Odds Lab</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #dc2626; color: #fafaf7; font-family: 'IBM Plex Sans', sans-serif;">
|
||||
<div id="blazor-error-ui" style="display:none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000; padding: 12px 24px; background: #ff3b30; color: #fffef8; font-family: 'Manrope', system-ui, sans-serif;">
|
||||
<span>An unhandled error has occurred.</span>
|
||||
<a href="" class="reload" style="color: #fff; text-decoration: underline; margin-left: 12px;">Reload</a>
|
||||
<a class="dismiss" style="float: right; cursor: pointer; padding: 0 8px;">×</a>
|
||||
|
||||
@@ -38,4 +38,32 @@ public sealed class CsvTests
|
||||
"Kelly,ok\r\n" +
|
||||
"\"Flat, fixed\",\"say \"\"hi\"\"\"\r\n");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("=cmd|'/c calc'!A1")]
|
||||
[InlineData("+1+1")]
|
||||
[InlineData("-2+3")]
|
||||
[InlineData("@SUM(A1)")]
|
||||
[InlineData("\ttab")]
|
||||
[InlineData("\rcr")]
|
||||
public void NeutralizeFormula_PrefixesLeadingFormulaTriggers(string dangerous)
|
||||
{
|
||||
Csv.NeutralizeFormula(dangerous).Should().Be("'" + dangerous);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Home vs Away")]
|
||||
[InlineData("normal note")]
|
||||
[InlineData("3-1 win")]
|
||||
[InlineData("")]
|
||||
public void NeutralizeFormula_LeavesSafeValuesUntouched(string safe)
|
||||
{
|
||||
Csv.NeutralizeFormula(safe).Should().Be(safe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeutralizeFormula_Null_IsEmpty()
|
||||
{
|
||||
Csv.NeutralizeFormula(null).Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,27 @@ public sealed class ExportToCsvUseCaseTests : IDisposable
|
||||
content.Should().Contain("evt-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportJournalAsync_NeutralizesFormulaInjection_InNotes()
|
||||
{
|
||||
var bet = new PlacedBet(
|
||||
Guid.NewGuid(),
|
||||
new EventId("evt-1"),
|
||||
new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(2.0m)),
|
||||
100m, T0, BetOutcome.Pending, "=cmd|'/c calc'!A1");
|
||||
_bets.ListAsync(Arg.Any<CancellationToken>()).Returns(new[] { bet });
|
||||
_events.GetManyAsync(Arg.Any<IReadOnlyCollection<EventId>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new Dictionary<EventId, Event>());
|
||||
|
||||
var path = await CreateSut().ExportJournalAsync();
|
||||
|
||||
var content = await File.ReadAllTextAsync(path!);
|
||||
// The dangerous note is apostrophe-prefixed so no cell opens as a live formula in
|
||||
// Excel; it sits in the last column, preceded by the CSV separator.
|
||||
content.Should().Contain(",'=cmd");
|
||||
content.Should().NotContain(",=cmd");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportPaperLedgerAsync_ReturnsNull_When_NoBets()
|
||||
{
|
||||
|
||||
@@ -82,4 +82,40 @@ public sealed class UpdatePlacedBetUseCaseTests
|
||||
Arg.Any<CancellationToken>());
|
||||
await _bets.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotesOnlyEdit_PreservesSettledOutcome_EvenWhenResultPruned()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var eid = new EventId("evt-1");
|
||||
var sameSelection = Selection(side: Side.Side1, rate: 2.0m);
|
||||
var settledWon = new PlacedBet(id, eid, sameSelection, 50m, PlacedAt, BetOutcome.Won, "old");
|
||||
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(settledWon);
|
||||
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
||||
// Result row has been pruned by retention.
|
||||
_results.GetAsync(eid, Arg.Any<CancellationToken>()).Returns((EventResult?)null);
|
||||
|
||||
// Same selection/event — only the note changes.
|
||||
var updated = await CreateSut().ExecuteAsync(id, eid, sameSelection, stake: 50m, notes: "new note");
|
||||
|
||||
updated.Outcome.Should().Be(BetOutcome.Won); // NOT silently reset to Pending
|
||||
updated.Notes.Should().Be("new note");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangedSelection_WithNoResult_ResetsToPending()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var eid = new EventId("evt-1");
|
||||
var settledWon = new PlacedBet(id, eid, Selection(side: Side.Side1, rate: 2.0m), 50m, PlacedAt, BetOutcome.Won, "old");
|
||||
_bets.GetAsync(id, Arg.Any<CancellationToken>()).Returns(settledWon);
|
||||
_events.GetAsync(eid, Arg.Any<CancellationToken>()).Returns(Ev(eid));
|
||||
_results.GetAsync(eid, Arg.Any<CancellationToken>()).Returns((EventResult?)null);
|
||||
|
||||
// Selection changed (Side1 → Side2) but no result to grade against → must be Pending,
|
||||
// never the now-stale Won.
|
||||
var updated = await CreateSut().ExecuteAsync(id, eid, Selection(side: Side.Side2, rate: 3.0m), stake: 50m, notes: "old");
|
||||
|
||||
updated.Outcome.Should().Be(BetOutcome.Pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,41 @@ public sealed class EventIdTests
|
||||
.WithParameterName("value");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("a/b")] // forward slash (path separator)
|
||||
[InlineData("a\\b")] // back slash (path separator)
|
||||
[InlineData("..")] // parent-dir traversal
|
||||
[InlineData("../etc/passwd")]
|
||||
[InlineData("evt\n1")] // control char (newline)
|
||||
[InlineData("evt\r1")] // control char (CR)
|
||||
[InlineData("evt\0id")] // control char (NUL)
|
||||
public void Constructor_ThrowsArgumentException_WhenValueHasDangerousCharacters(string value)
|
||||
{
|
||||
var act = () => new EventId(value);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentException_WhenValueExceedsMaxLength()
|
||||
{
|
||||
var act = () => new EventId(new string('1', 129));
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithParameterName("value");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("26456117")] // numeric (marathonbet.by)
|
||||
[InlineData("evt-1")] // hyphenated
|
||||
[InlineData("event_1")] // underscore
|
||||
[InlineData("evt.1")] // single dot is fine — only ".." is rejected
|
||||
[InlineData("AB12cd34")] // mixed-case alphanumeric (forward-compat)
|
||||
public void Constructor_Accepts_ValidAndForwardCompatIds(string value)
|
||||
{
|
||||
var id = new EventId(value);
|
||||
id.Value.Should().Be(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_ReturnsValue()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using FluentAssertions;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.UI.Services;
|
||||
|
||||
namespace Marathon.UI.Tests.Services;
|
||||
|
||||
public sealed class AddBetFormTests
|
||||
{
|
||||
private static AddBetForm Valid() => new()
|
||||
{
|
||||
EventId = "26456117",
|
||||
Type = BetType.Win,
|
||||
Side = Side.Side1,
|
||||
Rate = 1.90m,
|
||||
Stake = 100m,
|
||||
Notes = "ok",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsTrue_ForAWellFormedForm()
|
||||
{
|
||||
Valid().IsValid(out var error).Should().BeTrue();
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_AllowsNullNotes()
|
||||
{
|
||||
var form = Valid();
|
||||
form.Notes = null;
|
||||
|
||||
form.IsValid(out var error).Should().BeTrue();
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_AllowsNotesAtTheMaxLength()
|
||||
{
|
||||
var form = Valid();
|
||||
form.Notes = new string('x', AddBetForm.MaxNotesLength);
|
||||
|
||||
form.IsValid(out var error).Should().BeTrue();
|
||||
error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_RejectsNotesOverTheMaxLength()
|
||||
{
|
||||
var form = Valid();
|
||||
form.Notes = new string('x', AddBetForm.MaxNotesLength + 1);
|
||||
|
||||
form.IsValid(out var error).Should().BeFalse();
|
||||
error.Should().Contain("Notes");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user