Spaces:
Runtime error
Runtime error
Upload 16 files
Browse files- app.py +18 -84
- data_us.py +10 -6
- news_watch.py +64 -0
- signal_runner.py +7 -7
- theme.py +146 -0
app.py
CHANGED
|
@@ -25,80 +25,9 @@ import rotation
|
|
| 25 |
import signal_runner
|
| 26 |
|
| 27 |
# ─────────────────────────────────────────── Spectrum 2 theme ──
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
theme = gr.themes.Default(
|
| 32 |
-
font=[S2_FONT, "Adobe Clean", "system-ui", "sans-serif"],
|
| 33 |
-
font_mono=[S2_MONO, "monospace"],
|
| 34 |
-
primary_hue=gr.themes.colors.blue,
|
| 35 |
-
neutral_hue=gr.themes.colors.gray,
|
| 36 |
-
radius_size=gr.themes.sizes.radius_lg,
|
| 37 |
-
).set(
|
| 38 |
-
button_primary_background_fill="#0265DC",
|
| 39 |
-
button_primary_background_fill_hover="#0054B6",
|
| 40 |
-
button_primary_text_color="#FFFFFF",
|
| 41 |
-
button_secondary_background_fill="#FFFFFF",
|
| 42 |
-
button_secondary_border_color="#B1B1B1",
|
| 43 |
-
button_secondary_text_color="#222222",
|
| 44 |
-
block_background_fill="#FFFFFF",
|
| 45 |
-
background_fill_primary="#F8F8F8",
|
| 46 |
-
background_fill_secondary="#FFFFFF",
|
| 47 |
-
border_color_primary="#E6E6E6",
|
| 48 |
-
block_shadow="0 1px 4px rgba(0,0,0,.06)",
|
| 49 |
-
block_radius="16px",
|
| 50 |
-
input_radius="8px",
|
| 51 |
-
)
|
| 52 |
-
|
| 53 |
-
S2_CSS = """
|
| 54 |
-
:root{
|
| 55 |
-
--s2-accent:#0265DC; --s2-accent-down:#0054B6;
|
| 56 |
-
--s2-gray-50:#F8F8F8; --s2-gray-75:#F3F3F3; --s2-gray-200:#E6E6E6;
|
| 57 |
-
--s2-gray-800:#292929; --s2-positive:#007A39; --s2-negative:#D7373F;
|
| 58 |
-
}
|
| 59 |
-
body, .gradio-container{background:var(--s2-gray-50)!important;color:var(--s2-gray-800);}
|
| 60 |
-
.gradio-container{max-width:1280px!important;margin:0 auto!important;}
|
| 61 |
-
|
| 62 |
-
/* Spectrum 2 signature: pill buttons — SCOPED to real action buttons only.
|
| 63 |
-
(A global rule turned the Dataframe's internal icon buttons into ovals
|
| 64 |
-
that floated over the table.) */
|
| 65 |
-
button.primary, button.secondary, button.lg, button.sm{
|
| 66 |
-
border-radius:999px!important;font-weight:600!important;}
|
| 67 |
-
button:focus-visible{outline:2px solid var(--s2-accent)!important;outline-offset:2px;}
|
| 68 |
-
.table-wrap button, table button, .dataframe button, [class*="cell-menu"] button{
|
| 69 |
-
border-radius:6px!important;font-weight:500!important;}
|
| 70 |
-
|
| 71 |
-
/* hero */
|
| 72 |
-
#s2-hero{background:linear-gradient(120deg,#0265DC 0%,#5258E4 55%,#7326D3 100%);
|
| 73 |
-
border-radius:20px;padding:28px 32px;color:#fff;margin-bottom:6px;}
|
| 74 |
-
#s2-hero h1{margin:0;font-size:30px;font-weight:800;letter-spacing:-.5px;color:#fff;}
|
| 75 |
-
#s2-hero p{margin:6px 0 0;opacity:.92;font-size:15px;color:#fff;}
|
| 76 |
-
#s2-hero .chips span{display:inline-block;background:rgba(255,255,255,.16);
|
| 77 |
-
border:1px solid rgba(255,255,255,.35);border-radius:999px;padding:3px 12px;
|
| 78 |
-
font-size:12.5px;margin:10px 8px 0 0;}
|
| 79 |
-
|
| 80 |
-
/* tab strip — Spectrum quiet tabs with accent underline */
|
| 81 |
-
.tab-nav{border-bottom:2px solid var(--s2-gray-200)!important;}
|
| 82 |
-
.tab-nav button{border-radius:8px 8px 0 0!important;font-size:15px!important;
|
| 83 |
-
color:#6e6e6e!important;background:transparent!important;border:none!important;}
|
| 84 |
-
.tab-nav button.selected{color:var(--s2-accent)!important;
|
| 85 |
-
box-shadow:inset 0 -3px 0 var(--s2-accent)!important;font-weight:700!important;}
|
| 86 |
-
|
| 87 |
-
/* cards */
|
| 88 |
-
.block{border:1px solid var(--s2-gray-200)!important;}
|
| 89 |
-
table{font-size:13.5px!important;}
|
| 90 |
-
thead th{background:var(--s2-gray-75)!important;font-weight:700!important;}
|
| 91 |
-
|
| 92 |
-
.s2-footnote{color:#6e6e6e;font-size:12.5px;}
|
| 93 |
-
|
| 94 |
-
/* AI output panel — big, framed, unmissable */
|
| 95 |
-
.ai-panel{background:#fff;border:1.5px solid var(--s2-accent)!important;
|
| 96 |
-
border-left:6px solid var(--s2-accent)!important;border-radius:14px!important;
|
| 97 |
-
padding:18px 22px!important;min-height:240px;max-height:560px;overflow-y:auto;
|
| 98 |
-
font-size:15px;line-height:1.55;box-shadow:0 2px 10px rgba(2,101,220,.08);}
|
| 99 |
-
.ai-panel:empty::after{content:"AI output will appear here";color:#9a9a9a;}
|
| 100 |
-
#detail-log textarea{font-family:'Source Code Pro',monospace!important;font-size:12.5px!important;}
|
| 101 |
-
"""
|
| 102 |
|
| 103 |
# ─────────────────────────────────────────── UI callbacks ──
|
| 104 |
def ui_run_signals(tickers_text, force):
|
|
@@ -182,9 +111,11 @@ def ui_save_holdings(text):
|
|
| 182 |
|
| 183 |
|
| 184 |
def ui_check_news():
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
def ui_research(ticker):
|
|
@@ -212,13 +143,16 @@ _style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
|
|
| 212 |
with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
| 213 |
gr.HTML("""
|
| 214 |
<div id="s2-hero">
|
| 215 |
-
<
|
| 216 |
-
<
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
| 218 |
<div class="chips">
|
| 219 |
-
<span>🧠 Local
|
| 220 |
-
<span>
|
| 221 |
-
<span>
|
| 222 |
</div>
|
| 223 |
</div>""")
|
| 224 |
|
|
@@ -265,7 +199,7 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 265 |
save_btn = gr.Button("💾 Save holdings", scale=1)
|
| 266 |
news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
|
| 267 |
hold_status = gr.Markdown()
|
| 268 |
-
news_out = gr.Markdown()
|
| 269 |
|
| 270 |
with gr.Tab("🧪 Auto Research"):
|
| 271 |
gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
|
|
|
|
| 25 |
import signal_runner
|
| 26 |
|
| 27 |
# ─────────────────────────────────────────── Spectrum 2 theme ──
|
| 28 |
+
# Single source of truth: theme.py from the Chan Compass · Spectrum 2 design
|
| 29 |
+
# system (tokens mirrored from the design package's tokens/*.css).
|
| 30 |
+
from theme import THEME as theme, CSS as S2_CSS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# ─────────────────────────────────────────── UI callbacks ──
|
| 33 |
def ui_run_signals(tickers_text, force):
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
def ui_check_news():
|
| 114 |
+
last = ""
|
| 115 |
+
for md in news_watch.check_holdings_news_stream():
|
| 116 |
+
last = md
|
| 117 |
+
yield md
|
| 118 |
+
automation.STATE["news_md"] = last
|
| 119 |
|
| 120 |
|
| 121 |
def ui_research(ticker):
|
|
|
|
| 143 |
with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
| 144 |
gr.HTML("""
|
| 145 |
<div id="s2-hero">
|
| 146 |
+
<div class="mark">🧭</div>
|
| 147 |
+
<div>
|
| 148 |
+
<h1>Chan Compass <span>· US Markets</span></h1>
|
| 149 |
+
<p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
|
| 150 |
+
confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
|
| 151 |
+
</div>
|
| 152 |
<div class="chips">
|
| 153 |
+
<span>🧠 Local · no cloud APIs</span><span>🦙 llama.cpp</span>
|
| 154 |
+
<span>🤖 4 sub-agents · 1.7B+4B</span><span>📊 Yahoo Finance</span>
|
| 155 |
+
<span>⏰ 18:10 ET</span><span>💾 /data bucket</span><span>🎨 Spectrum 2</span>
|
| 156 |
</div>
|
| 157 |
</div>""")
|
| 158 |
|
|
|
|
| 199 |
save_btn = gr.Button("💾 Save holdings", scale=1)
|
| 200 |
news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
|
| 201 |
hold_status = gr.Markdown()
|
| 202 |
+
news_out = gr.Markdown(elem_classes=["ai-panel"])
|
| 203 |
|
| 204 |
with gr.Tab("🧪 Auto Research"):
|
| 205 |
gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
|
data_us.py
CHANGED
|
@@ -30,16 +30,18 @@ import paths
|
|
| 30 |
CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", paths.CACHE_DIR)
|
| 31 |
|
| 32 |
LEVELS = {
|
| 33 |
-
# level: (yfinance interval, period)
|
| 34 |
"d": ("1d", "10y"),
|
| 35 |
"60m": ("60m", "730d"),
|
| 36 |
"30m": ("30m", "60d"),
|
| 37 |
"15m": ("15m", "60d"),
|
| 38 |
"5m": ("5m", "60d"),
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
_STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
|
| 42 |
-
"15m": 2 * 3600, "5m": 2 * 3600}
|
| 43 |
|
| 44 |
|
| 45 |
def _cache_path(ticker: str, level: str) -> str:
|
|
@@ -107,10 +109,12 @@ def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
|
|
| 107 |
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
|
| 108 |
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
#
|
| 112 |
-
#
|
| 113 |
-
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
|
|
|
|
| 30 |
CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", paths.CACHE_DIR)
|
| 31 |
|
| 32 |
LEVELS = {
|
| 33 |
+
# level: (yfinance interval, period) — Yahoo's max history per interval
|
| 34 |
"d": ("1d", "10y"),
|
| 35 |
"60m": ("60m", "730d"),
|
| 36 |
"30m": ("30m", "60d"),
|
| 37 |
"15m": ("15m", "60d"),
|
| 38 |
"5m": ("5m", "60d"),
|
| 39 |
+
"1m": ("1m", "7d"), # only 7 days available; short but usable for the
|
| 40 |
+
# finest nested-interval confirmation when present
|
| 41 |
}
|
| 42 |
|
| 43 |
_STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
|
| 44 |
+
"15m": 2 * 3600, "5m": 2 * 3600, "1m": 1800}
|
| 45 |
|
| 46 |
|
| 47 |
def _cache_path(ticker: str, level: str) -> str:
|
|
|
|
| 109 |
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
|
| 110 |
|
| 111 |
|
| 112 |
+
# Full nested-interval set (区间套): the more sub-levels confirm, the more
|
| 113 |
+
# precise the buy/sell point. We fetch the deepest Yahoo allows. 1m has only
|
| 114 |
+
# 7 days of history — included when present, skipped gracefully otherwise.
|
| 115 |
+
# Downloads are parallel + cached, so the extra levels cost little wall-time.
|
| 116 |
+
FULL_LEVELS = ("d", "60m", "30m", "15m", "5m", "1m")
|
| 117 |
+
FAST_LEVELS = FULL_LEVELS # default everywhere; alias kept for older callers
|
| 118 |
|
| 119 |
|
| 120 |
def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
|
news_watch.py
CHANGED
|
@@ -97,6 +97,70 @@ def _llm_brief(ticker: str, items: list) -> str:
|
|
| 97 |
return ""
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def check_holdings_news(tickers=None) -> str:
|
| 101 |
"""Markdown report: AI brief per holding with today-news; quiet list otherwise."""
|
| 102 |
tickers = tickers if tickers is not None else load_holdings()
|
|
|
|
| 97 |
return ""
|
| 98 |
|
| 99 |
|
| 100 |
+
def check_holdings_news_stream(tickers=None):
|
| 101 |
+
"""Generator for the UI: emits cumulative markdown as it goes — each ticker,
|
| 102 |
+
each headline, and each AI brief appears the moment it's ready, so the user
|
| 103 |
+
never stares at a frozen screen. AI briefs run on the Reporter sub-agent."""
|
| 104 |
+
import llm_local
|
| 105 |
+
tickers = tickers if tickers is not None else load_holdings()
|
| 106 |
+
if not tickers:
|
| 107 |
+
yield ("**No holdings configured.** Add tickers above (e.g. `AAPL, NVDA`) "
|
| 108 |
+
"and save — they'll be checked for news every day.")
|
| 109 |
+
return
|
| 110 |
+
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
| 111 |
+
out = [f"_Checking {len(tickers)} holding(s) · {stamp}_"]
|
| 112 |
+
quiet = []
|
| 113 |
+
|
| 114 |
+
def render():
|
| 115 |
+
body = "\n".join(out)
|
| 116 |
+
if quiet:
|
| 117 |
+
body += f"\n\n**Quiet today (no news):** {', '.join(quiet)}"
|
| 118 |
+
return body
|
| 119 |
+
|
| 120 |
+
for t in tickers:
|
| 121 |
+
out.append(f"\n### 📰 {t} …searching today's news")
|
| 122 |
+
yield render()
|
| 123 |
+
items = fetch_today_news(t)
|
| 124 |
+
if not items:
|
| 125 |
+
out.pop() # drop the "searching" line
|
| 126 |
+
quiet.append(t)
|
| 127 |
+
yield render()
|
| 128 |
+
continue
|
| 129 |
+
out[-1] = f"\n### 📰 {t} — {len(items)} item(s) today"
|
| 130 |
+
yield render()
|
| 131 |
+
# print each headline the moment we have it
|
| 132 |
+
for x in items[:6]:
|
| 133 |
+
link = f" · [link]({x['link']})" if x["link"] else ""
|
| 134 |
+
out.append(f"- **{x['time']}** {x['title']} — *{x['publisher']}*{link}")
|
| 135 |
+
yield render()
|
| 136 |
+
# then stream the AI brief for this ticker (Reporter sub-agent)
|
| 137 |
+
if llm_local.is_loaded("reporter") or llm_local.is_loaded("translator"):
|
| 138 |
+
wk = "reporter" if llm_local.is_loaded("reporter") else "translator"
|
| 139 |
+
heads = "\n".join(f"- [{x['time']}] {x['title']} ({x['publisher']})"
|
| 140 |
+
for x in items[:6])
|
| 141 |
+
prompt = (
|
| 142 |
+
f"You are an equity news analyst. Today's headlines for {ticker_safe(t)} "
|
| 143 |
+
f"(a stock the user HOLDS):\n{heads}\n\nIn ENGLISH ONLY:\n"
|
| 144 |
+
f"1) **Per-headline:** one short line each — what it says and why it "
|
| 145 |
+
f"matters (or 'noise');\n2) **Net read:** POSITIVE / NEGATIVE / NEUTRAL "
|
| 146 |
+
f"+ one sentence;\n3) **Action:** one concrete suggestion. ≤180 words.")
|
| 147 |
+
out.append("\n> 🤖 **Reporter sub-agent brief:** _thinking…_")
|
| 148 |
+
base = len(out) - 1
|
| 149 |
+
for acc in llm_local.chat_stream(prompt, max_tokens=420, worker=wk):
|
| 150 |
+
out[base] = "> 🤖 **Reporter sub-agent brief:**\n>\n> " + \
|
| 151 |
+
acc.replace("\n", "\n> ")
|
| 152 |
+
yield render()
|
| 153 |
+
else:
|
| 154 |
+
out.append("\n> _Model still loading — headlines shown; brief will work shortly._")
|
| 155 |
+
yield render()
|
| 156 |
+
out.append(f"\n_Done · {stamp}_")
|
| 157 |
+
yield render()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def ticker_safe(t):
|
| 161 |
+
return str(t).upper()
|
| 162 |
+
|
| 163 |
+
|
| 164 |
def check_holdings_news(tickers=None) -> str:
|
| 165 |
"""Markdown report: AI brief per holding with today-news; quiet list otherwise."""
|
| 166 |
tickers = tickers if tickers is not None else load_holdings()
|
signal_runner.py
CHANGED
|
@@ -120,10 +120,10 @@ TREND_EN = {"up_trend": "Up", "down_trend": "Down", "consolidation": "Range",
|
|
| 120 |
|
| 121 |
def analyze_one(ticker: str, force: bool = False):
|
| 122 |
"""Run the simplified long-hold Chan analysis for one ticker.
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
dfs = data_us.load_levels(ticker, data_us.
|
| 127 |
d = dfs["d"]
|
| 128 |
if d is None or len(d) < 60:
|
| 129 |
return None, f"{ticker}: not enough daily history ({0 if d is None else len(d)} bars)."
|
|
@@ -133,8 +133,8 @@ def analyze_one(ticker: str, force: bool = False):
|
|
| 133 |
ml = MultiLevelChan(
|
| 134 |
df_daily=d, df_weekly=w, df_monthly=m,
|
| 135 |
df_60m=dfs.get("60m"), df_30m=dfs.get("30m"),
|
| 136 |
-
df_15m=
|
| 137 |
-
df_1m=
|
| 138 |
code=ticker, strict=True,
|
| 139 |
)
|
| 140 |
res = ml.analyze()
|
|
@@ -172,7 +172,7 @@ def run_signals(tickers=None, force: bool = False):
|
|
| 172 |
"""Run the whole pool. Returns (DataFrame, {ticker: detail}, summary_str)."""
|
| 173 |
tickers = [t.strip().upper() for t in (tickers or DEFAULT_POOL) if t.strip()]
|
| 174 |
try: # parallel download phase (IO-bound) — analysis stays CPU-only, no LLM
|
| 175 |
-
data_us.prefetch(tickers, data_us.
|
| 176 |
except Exception:
|
| 177 |
pass
|
| 178 |
rows, details, errors = [], {}, []
|
|
|
|
| 120 |
|
| 121 |
def analyze_one(ticker: str, force: bool = False):
|
| 122 |
"""Run the simplified long-hold Chan analysis for one ticker.
|
| 123 |
+
Full nested-interval set: monthly/weekly (resampled) + daily +
|
| 124 |
+
60m/30m/15m/5m/1m confirmation (区间套). Deeper sub-levels = more precise
|
| 125 |
+
buy/sell points; any missing level (e.g. 1m beyond 7 days) is skipped."""
|
| 126 |
+
dfs = data_us.load_levels(ticker, data_us.FULL_LEVELS, force=force)
|
| 127 |
d = dfs["d"]
|
| 128 |
if d is None or len(d) < 60:
|
| 129 |
return None, f"{ticker}: not enough daily history ({0 if d is None else len(d)} bars)."
|
|
|
|
| 133 |
ml = MultiLevelChan(
|
| 134 |
df_daily=d, df_weekly=w, df_monthly=m,
|
| 135 |
df_60m=dfs.get("60m"), df_30m=dfs.get("30m"),
|
| 136 |
+
df_15m=dfs.get("15m"), df_5m=dfs.get("5m"),
|
| 137 |
+
df_1m=dfs.get("1m"), # finest nested-interval level when Yahoo has it
|
| 138 |
code=ticker, strict=True,
|
| 139 |
)
|
| 140 |
res = ml.analyze()
|
|
|
|
| 172 |
"""Run the whole pool. Returns (DataFrame, {ticker: detail}, summary_str)."""
|
| 173 |
tickers = [t.strip().upper() for t in (tickers or DEFAULT_POOL) if t.strip()]
|
| 174 |
try: # parallel download phase (IO-bound) — analysis stays CPU-only, no LLM
|
| 175 |
+
data_us.prefetch(tickers, data_us.FULL_LEVELS, force=force, budget_s=60)
|
| 176 |
except Exception:
|
| 177 |
pass
|
| 178 |
rows, details, errors = [], {}, []
|
theme.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spectrum 2 theme for Gradio — Chan Compass
|
| 2 |
+
# ---------------------------------------------------------------
|
| 3 |
+
# Single source of truth for the LIVE Gradio app's look. Mirrors the
|
| 4 |
+
# Chan Compass · Spectrum 2 design system (tokens in ../tokens/*.css).
|
| 5 |
+
#
|
| 6 |
+
# Usage in app.py:
|
| 7 |
+
#
|
| 8 |
+
# from theme import THEME, CSS
|
| 9 |
+
# with gr.Blocks(title="Chan Compass · US", theme=THEME, css=CSS) as demo:
|
| 10 |
+
# ...
|
| 11 |
+
# demo.launch()
|
| 12 |
+
#
|
| 13 |
+
# On Gradio >= 6 pass them to launch() instead:
|
| 14 |
+
# demo.launch(theme=THEME, css=CSS)
|
| 15 |
+
# ---------------------------------------------------------------
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
# ---- Spectrum 2 palette (light) -------------------------------------------
|
| 19 |
+
ACCENT = "#0265dc" # blue-900
|
| 20 |
+
ACCENT_HOVER = "#0054b6" # blue-1000
|
| 21 |
+
ACCENT_DOWN = "#00418f" # blue-1100
|
| 22 |
+
ACCENT_SUBTLE= "#e0f2ff" # blue-100
|
| 23 |
+
GRAY_25 = "#ffffff"
|
| 24 |
+
GRAY_50 = "#f8f8f8" # canvas
|
| 25 |
+
GRAY_75 = "#f3f3f3"
|
| 26 |
+
GRAY_100 = "#e6e6e6" # hairline border
|
| 27 |
+
GRAY_300 = "#b1b1b1" # field border
|
| 28 |
+
GRAY_500 = "#6d6d6d" # muted text
|
| 29 |
+
GRAY_700 = "#292929" # body text
|
| 30 |
+
GRAY_800 = "#1b1b1b"
|
| 31 |
+
POSITIVE = "#007a39"
|
| 32 |
+
NEGATIVE = "#d7373f"
|
| 33 |
+
NOTICE = "#b25309"
|
| 34 |
+
|
| 35 |
+
THEME = gr.themes.Default(
|
| 36 |
+
font=[gr.themes.GoogleFont("Source Sans 3"), "Adobe Clean", "system-ui", "sans-serif"],
|
| 37 |
+
font_mono=[gr.themes.GoogleFont("Source Code Pro"), "monospace"],
|
| 38 |
+
primary_hue=gr.themes.colors.blue,
|
| 39 |
+
neutral_hue=gr.themes.colors.gray,
|
| 40 |
+
radius_size=gr.themes.sizes.radius_lg,
|
| 41 |
+
spacing_size=gr.themes.sizes.spacing_md,
|
| 42 |
+
).set(
|
| 43 |
+
# surfaces
|
| 44 |
+
body_background_fill=GRAY_50,
|
| 45 |
+
background_fill_primary=GRAY_25,
|
| 46 |
+
background_fill_secondary=GRAY_75,
|
| 47 |
+
block_background_fill=GRAY_25,
|
| 48 |
+
block_border_color=GRAY_100,
|
| 49 |
+
block_border_width="1px",
|
| 50 |
+
block_radius="16px",
|
| 51 |
+
block_shadow="0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04)",
|
| 52 |
+
block_label_text_color=GRAY_500,
|
| 53 |
+
block_title_text_color=GRAY_800,
|
| 54 |
+
border_color_primary=GRAY_100,
|
| 55 |
+
# text
|
| 56 |
+
body_text_color=GRAY_700,
|
| 57 |
+
body_text_color_subdued=GRAY_500,
|
| 58 |
+
# buttons — Spectrum 2 signature pill
|
| 59 |
+
button_large_radius="9999px",
|
| 60 |
+
button_small_radius="9999px",
|
| 61 |
+
button_primary_background_fill=ACCENT,
|
| 62 |
+
button_primary_background_fill_hover=ACCENT_HOVER,
|
| 63 |
+
button_primary_text_color="#ffffff",
|
| 64 |
+
button_primary_border_color=ACCENT,
|
| 65 |
+
button_secondary_background_fill=GRAY_25,
|
| 66 |
+
button_secondary_background_fill_hover=GRAY_75,
|
| 67 |
+
button_secondary_border_color=GRAY_300,
|
| 68 |
+
button_secondary_text_color=GRAY_700,
|
| 69 |
+
# inputs
|
| 70 |
+
input_background_fill=GRAY_25,
|
| 71 |
+
input_border_color=GRAY_300,
|
| 72 |
+
input_border_color_focus=ACCENT,
|
| 73 |
+
input_radius="8px",
|
| 74 |
+
# accents / links
|
| 75 |
+
color_accent_soft=ACCENT_SUBTLE,
|
| 76 |
+
link_text_color=ACCENT_HOVER,
|
| 77 |
+
link_text_color_hover=ACCENT_DOWN,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# ---- Fine-grained CSS the theme object can't express ----------------------
|
| 81 |
+
CSS = """
|
| 82 |
+
:root{
|
| 83 |
+
--s2-accent:#0265dc; --s2-accent-down:#0054b6;
|
| 84 |
+
--s2-gray-50:#f8f8f8; --s2-gray-75:#f3f3f3; --s2-gray-100:#e6e6e6;
|
| 85 |
+
--s2-gray-300:#b1b1b1; --s2-gray-500:#6d6d6d; --s2-gray-700:#292929; --s2-gray-800:#1b1b1b;
|
| 86 |
+
--s2-positive:#007a39; --s2-negative:#d7373f; --s2-notice:#b25309;
|
| 87 |
+
--s2-radius-card:16px;
|
| 88 |
+
}
|
| 89 |
+
body,.gradio-container{ background:var(--s2-gray-50)!important; color:var(--s2-gray-700);
|
| 90 |
+
font-family:'Source Sans 3','Adobe Clean',system-ui,sans-serif; }
|
| 91 |
+
.gradio-container{ max-width:1280px!important; margin:0 auto!important; }
|
| 92 |
+
|
| 93 |
+
/* Signature pill buttons — scoped to real action buttons, not table chrome */
|
| 94 |
+
button.primary, button.secondary, button.lg, button.sm{
|
| 95 |
+
border-radius:9999px!important; font-weight:600!important; letter-spacing:0;
|
| 96 |
+
transition:background-color .13s cubic-bezier(.45,0,.4,1), transform .13s cubic-bezier(0,0,.4,1); }
|
| 97 |
+
button.primary:active, button.secondary:active{ transform:scale(.98); }
|
| 98 |
+
button:focus-visible{ outline:2px solid var(--s2-accent)!important; outline-offset:2px; }
|
| 99 |
+
.table-wrap button, table button, .dataframe button, [class*="cell-menu"] button{
|
| 100 |
+
border-radius:6px!important; font-weight:500!important; }
|
| 101 |
+
|
| 102 |
+
/* Brand header (replaces gradient hero with a clean Spectrum bar) */
|
| 103 |
+
#s2-hero{ display:flex; align-items:center; gap:16px;
|
| 104 |
+
background:#fff; border:1px solid var(--s2-gray-100); border-radius:20px;
|
| 105 |
+
padding:22px 26px; margin-bottom:8px; box-shadow:0 1px 3px rgba(0,0,0,.06); }
|
| 106 |
+
#s2-hero .mark{ width:46px; height:46px; flex:none; border-radius:12px; display:grid; place-items:center;
|
| 107 |
+
background:linear-gradient(135deg,#0265dc 0%,#5258e4 60%,#7326d3 100%);
|
| 108 |
+
color:#fff; font-size:24px; box-shadow:0 2px 12px rgba(2,101,220,.18); }
|
| 109 |
+
#s2-hero h1{ margin:0; font-size:24px; font-weight:800; letter-spacing:-.01em; color:var(--s2-gray-800); }
|
| 110 |
+
#s2-hero h1 span{ font-weight:300; color:var(--s2-gray-500); }
|
| 111 |
+
#s2-hero p{ margin:3px 0 0; color:var(--s2-gray-500); font-size:14px; }
|
| 112 |
+
#s2-hero .chips{ margin-left:auto; display:flex; flex-wrap:wrap; gap:7px; justify-content:flex-end; max-width:360px; }
|
| 113 |
+
#s2-hero .chips span{ background:var(--s2-gray-75); border:1px solid var(--s2-gray-100); border-radius:9999px;
|
| 114 |
+
padding:3px 11px; font-size:12px; font-weight:600; color:var(--s2-gray-700); }
|
| 115 |
+
|
| 116 |
+
/* Quiet tabs with accent underline */
|
| 117 |
+
.tab-nav{ border-bottom:2px solid var(--s2-gray-100)!important; gap:24px; }
|
| 118 |
+
.tab-nav button{ border:none!important; background:transparent!important; border-radius:0!important;
|
| 119 |
+
font-size:16px!important; font-weight:600!important; color:var(--s2-gray-500)!important;
|
| 120 |
+
padding:12px 2px!important; margin-bottom:-2px; }
|
| 121 |
+
.tab-nav button.selected{ color:var(--s2-accent)!important;
|
| 122 |
+
box-shadow:inset 0 -2px 0 var(--s2-accent)!important; }
|
| 123 |
+
|
| 124 |
+
/* Cards / blocks */
|
| 125 |
+
.block{ border:1px solid var(--s2-gray-100)!important; border-radius:var(--s2-radius-card)!important; }
|
| 126 |
+
|
| 127 |
+
/* Data tables */
|
| 128 |
+
table{ font-size:13.5px!important; }
|
| 129 |
+
thead th{ background:var(--s2-gray-75)!important; font-weight:700!important; text-transform:uppercase;
|
| 130 |
+
letter-spacing:.04em; font-size:11.5px!important; color:var(--s2-gray-500)!important; }
|
| 131 |
+
tbody td{ border-color:var(--s2-gray-100)!important; }
|
| 132 |
+
|
| 133 |
+
/* AI output panel — the signature accent-framed surface */
|
| 134 |
+
.ai-panel{ background:#fff; border:2px solid var(--s2-accent)!important;
|
| 135 |
+
border-left:6px solid var(--s2-accent)!important; border-radius:12px!important;
|
| 136 |
+
padding:18px 22px!important; min-height:240px; max-height:560px; overflow-y:auto;
|
| 137 |
+
font-size:15px; line-height:1.55; box-shadow:0 2px 12px rgba(2,101,220,.10); }
|
| 138 |
+
.ai-panel:empty::after{ content:"AI output appears here"; color:#9a9a9a; }
|
| 139 |
+
|
| 140 |
+
/* Mono log boxes */
|
| 141 |
+
#detail-log textarea{ font-family:'Source Code Pro',monospace!important; font-size:12.5px!important; }
|
| 142 |
+
|
| 143 |
+
.s2-footnote{ color:var(--s2-gray-500); font-size:12.5px; }
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
__all__ = ["THEME", "CSS"]
|