ranranrunforit commited on
Commit
ba3fe9e
·
verified ·
1 Parent(s): b49f11d

Upload 16 files

Browse files
Files changed (5) hide show
  1. app.py +18 -84
  2. data_us.py +10 -6
  3. news_watch.py +64 -0
  4. signal_runner.py +7 -7
  5. theme.py +146 -0
app.py CHANGED
@@ -25,80 +25,9 @@ import rotation
25
  import signal_runner
26
 
27
  # ─────────────────────────────────────────── Spectrum 2 theme ──
28
- S2_FONT = gr.themes.GoogleFont("Source Sans 3")
29
- S2_MONO = gr.themes.GoogleFont("Source Code Pro")
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
- md = news_watch.check_holdings_news()
186
- automation.STATE["news_md"] = md
187
- return md
 
 
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
- <h1>Chan Compass <span style="font-weight:300">· US Markets</span></h1>
216
- <p>Multi-timeframe 缠论 (Chan theory) signal engine — monthly → weekly → daily → 60m → 30m → 15m → 5m
217
- nested-interval confirmation plus sector capital rotation and a fully local llama.cpp research brain.</p>
 
 
 
218
  <div class="chips">
219
- <span>🧠 Local GGUF · no cloud APIs</span><span>🦙 llama.cpp runtime</span>
220
- <span>📊 Yahoo Finance data</span><span> Auto-update 18:10 ET</span>
221
- <span>🤖 Sub-agent pool · 1.7B + 4B</span><span>💾 Persistent /data bucket</span><span>🎨 Spectrum 2 design</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
- # Fast mode (default): the simplified "B3/S3 long-hold" pipeline only needs
111
- # monthly/weekly/daily (resampled from daily) + 60m/30m sub-level confirmation.
112
- # 15m/5m added little for next-day planning and doubled download+compute time.
113
- FAST_LEVELS = ("d", "60m", "30m")
 
 
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
- Fast level set: monthly/weekly (resampled) + daily + 60m/30m confirmation.
124
- (15m/5m dropped they doubled runtime and added nothing to a next-day,
125
- long-hold plan. The engine degrades gracefully without them.)"""
126
- dfs = data_us.load_levels(ticker, data_us.FAST_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,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=None, df_5m=None,
137
- df_1m=None, # Yahoo 1m history (7 days) is too short for Chan decomposition
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.FAST_LEVELS, force=force)
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"]