lucashudsn commited on
Commit
c5672b8
·
1 Parent(s): 69d722b

finished!!

Browse files
AGENTS.md CHANGED
@@ -49,16 +49,20 @@ survives only as the fallback layer in `ui/_compat.py`.
49
  ## Deploying to HF Spaces
50
 
51
  - The Space `lucashudsn/wavereader` (public) *is* this repo — remote
52
- `space`. Ship with `git push space v2:main`, then `git push origin v2`
53
- to keep GitHub in sync. The Space builds from the pushed commit; watch
54
- *Logs → Build* on the Space page or poll
 
 
55
  `HfApi().get_space_runtime("lucashudsn/wavereader")` until `RUNNING`.
56
- - The YAML block at the top of `README.md` is the Space config. Keep
57
  `sdk_version` in lockstep with the gradio pin in `requirements.txt`
58
  (regenerate that file only via `uv export --no-hashes
59
  --format requirements-txt --no-dev -o requirements.txt` — other
60
  invocations can leave stray progress lines that break pip), and
61
  `python_version` in lockstep with `pyproject.toml`'s `requires-python`.
 
 
62
  - `HF_TOKEN` is a Space secret (set once via *Settings → Variables and
63
  secrets* or `HfApi().add_space_secret`); `WR_*` overrides can be added
64
  the same way. `.cache/` is ephemeral on the Space and rebuilds lazily —
 
49
  ## Deploying to HF Spaces
50
 
51
  - The Space `lucashudsn/wavereader` (public) *is* this repo — remote
52
+ `space`. Ship with `scripts/deploy_space.sh` (builds a throwaway commit
53
+ that prepends `space-config.yaml` to `README.md` and pushes it to
54
+ `space:main` — Spaces need YAML frontmatter, GitHub doesn't have to see
55
+ it), then `git push origin v2` to keep GitHub in sync. The Space builds
56
+ from the pushed commit; watch *Logs → Build* on the Space page or poll
57
  `HfApi().get_space_runtime("lucashudsn/wavereader")` until `RUNNING`.
58
+ - `space-config.yaml` is the Space config. Keep
59
  `sdk_version` in lockstep with the gradio pin in `requirements.txt`
60
  (regenerate that file only via `uv export --no-hashes
61
  --format requirements-txt --no-dev -o requirements.txt` — other
62
  invocations can leave stray progress lines that break pip), and
63
  `python_version` in lockstep with `pyproject.toml`'s `requires-python`.
64
+ If you change Space settings via the web UI, HF rewrites the Space's
65
+ `README.md` — mirror anything you care about back into the yaml.
66
  - `HF_TOKEN` is a Space secret (set once via *Settings → Variables and
67
  secrets* or `HfApi().add_space_secret`); `WR_*` overrides can be added
68
  the same way. `.cache/` is ephemeral on the Space and rebuilds lazily —
README.md CHANGED
@@ -1,17 +1,3 @@
1
- ---
2
- title: wave~reader
3
- emoji: 🏄
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.26.0
8
- python_version: "3.14"
9
- app_file: app.py
10
- license: mit
11
- pinned: false
12
- short_description: Surf-forecast world model for the Australian coast
13
- ---
14
-
15
  # wave~reader
16
 
17
  **A surf-forecasting world model for the Australian coast: 238 breaks, 168
@@ -130,21 +116,26 @@ is session-only and never logged.
130
  ## Deploying to HF Spaces
131
 
132
  The app is live at [huggingface.co/spaces/lucashudsn/wavereader](https://huggingface.co/spaces/lucashudsn/wavereader).
133
- The Space *is* this git repo — the `space` remote points at it — and the
134
- YAML block at the top of this README is the Space config: `sdk_version`
135
- must match the gradio pin in `requirements.txt`, and `app_file` is `app.py`.
 
 
 
 
 
136
 
137
  ```sh
138
- git push space v2:main # ship whatever is committed on v2
139
- git push origin v2 # keep GitHub in sync
140
  ```
141
 
142
  The push asks for credentials: username `lucashudsn`, password = an HF
143
  token with write access (https://huggingface.co/settings/tokens). The
144
- build takes a few minutes; watch it under the Space's *Logs → Build* tab
145
  or poll `HfApi().get_space_runtime("lucashudsn/wavereader")`.
146
 
147
- Secrets live in the Space's *Settings → Variables and secrets* —
148
  `HF_TOKEN` is already set, which is what lets the agent and narrator work
149
  for visitors. `WR_MODEL` / `WR_PROVIDER` / `WR_BASE_URL` can be added the
150
  same way if you ever want to reroute the LLM. Caches under `.cache/` are
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # wave~reader
2
 
3
  **A surf-forecasting world model for the Australian coast: 238 breaks, 168
 
116
  ## Deploying to HF Spaces
117
 
118
  The app is live at [huggingface.co/spaces/lucashudsn/wavereader](https://huggingface.co/spaces/lucashudsn/wavereader).
119
+ The Space _is_ this git repo — the `space` remote points at it. Spaces
120
+ read their config from YAML frontmatter at the top of `README.md`, but
121
+ GitHub renders that block as an ugly metadata table, so it lives in
122
+ `space-config.yaml` instead and `deploy_space.sh` prepends it in a
123
+ throwaway commit at push time. Keep `sdk_version` in lockstep with the
124
+ gradio pin in `requirements.txt`. If you change Space settings through
125
+ the web UI, HF commits them into the Space's `README.md` — mirror
126
+ anything you care about back into `space-config.yaml`.
127
 
128
  ```sh
129
+ scripts/deploy_space.sh # ship what's committed on v2 (pass a ref to override)
130
+ git push origin branch_name # keep GitHub in sync
131
  ```
132
 
133
  The push asks for credentials: username `lucashudsn`, password = an HF
134
  token with write access (https://huggingface.co/settings/tokens). The
135
+ build takes a few minutes; watch it under the Space's _Logs → Build_ tab
136
  or poll `HfApi().get_space_runtime("lucashudsn/wavereader")`.
137
 
138
+ Secrets live in the Space's _Settings → Variables and secrets_ —
139
  `HF_TOKEN` is already set, which is what lets the agent and narrator work
140
  for visitors. `WR_MODEL` / `WR_PROVIDER` / `WR_BASE_URL` can be added the
141
  same way if you ever want to reroute the LLM. Caches under `.cache/` are
scripts/deploy_space.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Ship a committed ref to the HF Space without keeping the Space's YAML
3
+ # frontmatter in the repo copy of README.md (GitHub renders that block as
4
+ # an ugly metadata table). Builds a throwaway commit that prepends
5
+ # space-config.yaml to the README, pushes it to the `space` remote, then
6
+ # removes the worktree. The repo README stays clean; HF gets its config.
7
+ #
8
+ # scripts/deploy_space.sh [ref] [remote] # defaults: v2, space
9
+ set -euo pipefail
10
+
11
+ ref="${1:-v2}"
12
+ remote="${2:-space}"
13
+ root="$(git rev-parse --show-toplevel)"
14
+ config="$root/space-config.yaml"
15
+ tmp="$(mktemp -d)"
16
+ trap 'git worktree remove --force "$tmp" >/dev/null 2>&1 || rm -rf "$tmp"' EXIT
17
+
18
+ [ -f "$config" ] || { echo "missing $config" >&2; exit 1; }
19
+
20
+ git worktree add --quiet --detach "$tmp" "$ref"
21
+ { printf -- '---\n'; cat "$config"; printf -- '---\n\n'; cat "$tmp/README.md"; } > "$tmp/README.md.next"
22
+ mv "$tmp/README.md.next" "$tmp/README.md"
23
+
24
+ git -C "$tmp" add README.md
25
+ git -C "$tmp" commit --quiet -m "deploy: prepend Space config to README (from $ref)"
26
+ # Deploys are throwaway commits, so the Space's main is rewritten every
27
+ # time — only the tip (the tree) matters to the build.
28
+ git -C "$tmp" push --force "$remote" HEAD:refs/heads/main
29
+
30
+ echo "pushed $ref (+ Space config) to $remote:main — watch the Space's Logs → Build tab"
space-config.yaml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ title: wave~reader
2
+ emoji: 🏄
3
+ colorFrom: blue
4
+ colorTo: indigo
5
+ sdk: gradio
6
+ sdk_version: 6.26.0
7
+ python_version: "3.14"
8
+ app_file: app.py
9
+ license: mit
10
+ pinned: false
11
+ short_description: Surf-forecast world model for the Australian coast
tests/test_agent_viz.py CHANGED
@@ -27,29 +27,32 @@ CHATBOT = contracts.AGENT_KEYS.index("chatbot")
27
  _BELLS_ARGS = {"spot_name": "Bells Beach", "region": "Victoria"}
28
 
29
 
30
- def _fake_stream(obs, tool_name: str, args: dict, final: str):
 
 
 
 
 
 
 
31
  def stream(message, **kwargs):
32
  yield ("step", {"n": 1})
33
- yield ("tool_call", {"name": tool_name, "arguments": args})
34
- yield ("tool_result", {"name": tool_name, "arguments": args,
35
- "observation": obs, "summary": "ok",
36
- "ms": 1.0, "preview": ""})
37
  yield ("final", final)
38
- yield ("usage", {"steps": 1, "tool_calls": {tool_name: 1},
39
- "profile": "standard"})
40
- return stream
41
-
42
-
43
- def _last_emit(monkeypatch, obs, tool_name: str, args: dict,
44
- final: str = "**Best: Bells Beach @ Sat am — 6/10.**"):
45
- monkeypatch.setattr(C, "agent_run_stream",
46
- _fake_stream(obs, tool_name, args, final))
47
  records = C.list_breaks()
48
  selected = C.find_break(records, "Bells Beach")
49
  assert selected is not None, "Bells Beach must resolve from the catalogue"
50
- outs = list(agent_panel.chat_fn(
51
- "test question", [], "intermediate", "", selected,
52
- agent_panel.fresh_store(), "standard", "auto", records))
 
 
 
 
 
 
53
  return outs, outs[-1]
54
 
55
 
@@ -160,3 +163,86 @@ def test_scored_turn_still_charts_strip_midway(monkeypatch):
160
  charted = [o[strip] for o in outs if isinstance(o[strip], go.Figure)]
161
  assert charted, "mid-turn week strip never rendered"
162
  assert not isinstance(last[strip], go.Figure) or charted[-1] is last[strip]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  _BELLS_ARGS = {"spot_name": "Bells Beach", "region": "Victoria"}
28
 
29
 
30
+ def _tool_events(name: str, args: dict, obs) -> list[tuple]:
31
+ return [("tool_call", {"name": name, "arguments": args}),
32
+ ("tool_result", {"name": name, "arguments": args,
33
+ "observation": obs, "summary": "ok",
34
+ "ms": 1.0, "preview": ""})]
35
+
36
+
37
+ def _run(monkeypatch, events: list[tuple], final: str, query: str):
38
  def stream(message, **kwargs):
39
  yield ("step", {"n": 1})
40
+ yield from events
 
 
 
41
  yield ("final", final)
42
+ yield ("usage", {"steps": 1, "profile": "standard"})
43
+ monkeypatch.setattr(C, "agent_run_stream", stream)
 
 
 
 
 
 
 
44
  records = C.list_breaks()
45
  selected = C.find_break(records, "Bells Beach")
46
  assert selected is not None, "Bells Beach must resolve from the catalogue"
47
+ return list(agent_panel.chat_fn(
48
+ query, [], "intermediate", "", selected,
49
+ agent_panel.fresh_store(), "standard", C.ALL, C.ALL, records))
50
+
51
+
52
+ def _last_emit(monkeypatch, obs, tool_name: str, args: dict,
53
+ final: str = "**Best: Bells Beach @ Sat am — 6/10.**",
54
+ query: str = "test question"):
55
+ outs = _run(monkeypatch, _tool_events(tool_name, args, obs), final, query)
56
  return outs, outs[-1]
57
 
58
 
 
163
  charted = [o[strip] for o in outs if isinstance(o[strip], go.Figure)]
164
  assert charted, "mid-turn week strip never rendered"
165
  assert not isinstance(last[strip], go.Figure) or charted[-1] is last[strip]
166
+
167
+
168
+ def _scored_obs(spot_name: str, region: str, score: float) -> dict:
169
+ hours = [{"time": f"2026-09-09T{h:02d}:00", "score": score - (h % 3) * 0.5,
170
+ "wave_height_m": 1.2, "wave_period_s": 9.0,
171
+ "wind_speed_kt": 7.0, "wind_direction_deg": 220,
172
+ "daylight": True} for h in range(6, 18)]
173
+ return {"spot": {"name": spot_name, "region": region},
174
+ "skill": "intermediate", "scored": hours,
175
+ "daily": [], "best": hours[0], "stub": True}
176
+
177
+
178
+ def test_where_query_sweeps_region_when_agent_didnt_rank(monkeypatch):
179
+ # A recommendation question the agent answered without a rank sweep
180
+ # still closes on the recommended-scores leaderboard: the panel runs a
181
+ # deterministic engine sweep of the region named in the question
182
+ # (NSW -> New South Wales), never the viewed spot's unrelated week.
183
+ sweep = {"region": "New South Wales", "skill": "intermediate", "count": 2,
184
+ "spots": [
185
+ {"name": "Bondi Beach", "region": "Sydney",
186
+ "best_score": 6.5, "best_time": "2026-09-12T07:00"},
187
+ {"name": "Byron Bay", "region": "Northern Rivers",
188
+ "best_score": 5.5, "best_time": "2026-09-12T08:00"},
189
+ ]}
190
+ monkeypatch.setattr(C, "api_rank_region_week",
191
+ lambda region, skill="intermediate", limit=10: sweep)
192
+ monkeypatch.setattr(C, "get_scored_week", lambda *a, **k: (_ for _ in ()).throw(
193
+ AssertionError("viewed-spot fallback must not run when the sweep hits")))
194
+ obs = _tools.get_spot_knowledge(**_BELLS_ARGS)
195
+ assert "error" not in obs
196
+ _outs, last = _last_emit(
197
+ monkeypatch, obs, "get_spot_knowledge", _BELLS_ARGS,
198
+ query="Where should I surf in NSW this weekend as a beginner?")
199
+ fig = last[AGENT_STRIP]
200
+ assert isinstance(fig, go.Figure)
201
+ assert any(getattr(t, "orientation", None) == "h" for t in fig.data)
202
+ assert "recommended breaks — New South Wales" in last[CHATBOT][-1]["content"]
203
+
204
+
205
+ def test_seafloor_query_prefers_depth_map_over_leaderboard(monkeypatch):
206
+ # When the question asks about the seafloor, the depth map wins even
207
+ # with a rank sweep payload on the table — the chart matches the
208
+ # question, not the tool order.
209
+ rank_obs = {"region": "Victoria", "skill": "intermediate", "count": 1,
210
+ "spots": [{"name": "Bells Beach", "region": "Surf Coast",
211
+ "best_score": 7.5, "best_time": "2026-09-12T06:00"}]}
212
+ seafloor_obs = _tools.get_seafloor_profile(**_BELLS_ARGS)
213
+ assert "error" not in seafloor_obs
214
+ grid = {"lats": [-38.31, -38.30, -38.29], "lngs": [144.29, 144.30, 144.31],
215
+ "elev": [-20.0, -12.0, -6.0, -14.0, -8.0, -4.0,
216
+ -10.0, -5.0, -1.0],
217
+ "n": 3, "center": {"lat": -38.30, "lng": 144.30}}
218
+ monkeypatch.setattr(C, "get_seafloor",
219
+ lambda br, radius_km=1.2: {"grid": grid, "analysis": "",
220
+ "stats": {}})
221
+ events = _tool_events("rank_region_week", {"region": "Victoria"}, rank_obs)
222
+ events += _tool_events("get_seafloor_profile", _BELLS_ARGS, seafloor_obs)
223
+ outs = _run(monkeypatch, events,
224
+ "**Best: Bells Beach @ Sat am — 6/10.**",
225
+ "What's the seafloor like at Bells Beach?")
226
+ fig = outs[-1][AGENT_STRIP]
227
+ assert isinstance(fig, go.Figure)
228
+ assert any(t.type == "heatmap" for t in fig.data)
229
+ assert not any(getattr(t, "orientation", None) == "h" for t in fig.data)
230
+
231
+
232
+ def test_compare_query_charts_multi_spot_scores(monkeypatch):
233
+ # Two score_week calls in one turn -> the turn closes on the
234
+ # side-by-side best-score compare, not just the last spot's strip.
235
+ events = []
236
+ for name, region, score in (("Bells Beach", "Victoria", 7.0),
237
+ ("Snapper Rocks", "Queensland", 5.0)):
238
+ args = {"spot_name": name, "region": region}
239
+ events += _tool_events("score_week", args,
240
+ _scored_obs(name, region, score))
241
+ outs = _run(monkeypatch, events,
242
+ "**Best: Bells Beach @ Sun am — 7/10.**",
243
+ "Compare Bells Beach and Snapper Rocks this weekend.")
244
+ last = outs[-1]
245
+ fig = last[AGENT_STRIP]
246
+ assert isinstance(fig, go.Figure)
247
+ assert any(getattr(t, "orientation", None) == "h" for t in fig.data)
248
+ assert "best-score compare (2 spots)" in last[CHATBOT][-1]["content"]
ui/app.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  No tabs. One shared ``selected`` spot drives everything: picking it in
4
  the search box (or the load default) re-tells the whole page — badges,
5
- week strip, world model, climatology, break details — then auto-fetches
6
  the staged forecast, and the agent reads the same context. Exactly three
7
  session states: the shared pick, the scored payload, the agent store.
8
  Filters are one-way only.
@@ -64,8 +64,9 @@ def build() -> gr.Blocks:
64
  with gr.Row(elem_classes=["hero-row"]):
65
  with gr.Column(scale=5, min_width=220):
66
  gr.Markdown("# wave~reader", elem_classes=["hero-title"])
67
- gr.Markdown("one page from *where* to *when* — browse the map, "
68
- "pick a spot, read the week.", elem_classes=["hero-sub"])
 
69
  with gr.Column(scale=2, min_width=280, elem_classes=["hero-right"]):
70
  mode_toggle = gr.Radio(
71
  [_MODE_FORECAST, _MODE_AGENT], value=_MODE_FORECAST,
@@ -94,6 +95,11 @@ def build() -> gr.Blocks:
94
 
95
  a = agent_panel.build_agent(selected, agent_store, records, vocab, visible=False)
96
 
 
 
 
 
 
97
  def _switch_mode(mode: str):
98
  agentic = mode == _MODE_AGENT
99
  return gr.update(visible=not agentic), gr.update(visible=agentic)
@@ -104,7 +110,7 @@ def build() -> gr.Blocks:
104
  # Internal listeners stay off the API/MCP surface — only the three
105
  # typed ``gr.api`` tools (score_week / rank_region_week /
106
  # explain_score) are exposed.
107
- comp = {**b, **s, **i, **a,
108
  "selected": selected, "scored": scored, "agent_store": agent_store}
109
  select_outputs = [comp[k] for k in SELECT_KEYS]
110
  fetch_outputs = [comp[k] for k in FETCH_KEYS]
@@ -151,7 +157,12 @@ def build() -> gr.Blocks:
151
  s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
152
  show_progress="minimal", api_name=False,
153
  )
154
- s["narrate_btn"].click(s["narrate"], inputs=scored, outputs=s["report_md"],
 
 
 
 
 
155
  show_progress="minimal", api_name=False)
156
  # -- agent: chat streams trace cards + meter + verdict + charts --
157
  chat_inputs = [a["msg"], a["chatbot"], b["skill_dd"], a["token_box"],
 
2
 
3
  No tabs. One shared ``selected`` spot drives everything: picking it in
4
  the search box (or the load default) re-tells the whole page — badges,
5
+ week strip, world model, climatology — then auto-fetches
6
  the staged forecast, and the agent reads the same context. Exactly three
7
  session states: the shared pick, the scored payload, the agent store.
8
  Filters are one-way only.
 
64
  with gr.Row(elem_classes=["hero-row"]):
65
  with gr.Column(scale=5, min_width=220):
66
  gr.Markdown("# wave~reader", elem_classes=["hero-title"])
67
+ gr.Markdown("smart forecasting for australia's coast — 238 breaks scored "
68
+ "hour by hour (0–10) from live marine data, real seafloor and "
69
+ "each spot's ideal conditions.", elem_classes=["hero-sub"])
70
  with gr.Column(scale=2, min_width=280, elem_classes=["hero-right"]):
71
  mode_toggle = gr.Radio(
72
  [_MODE_FORECAST, _MODE_AGENT], value=_MODE_FORECAST,
 
95
 
96
  a = agent_panel.build_agent(selected, agent_store, records, vocab, visible=False)
97
 
98
+ # Footer: the machinery line — feed/scorer/world-model latencies —
99
+ # lives below the fold, in both modes (outside the swapped columns).
100
+ with gr.Column(elem_classes=["page-foot"]):
101
+ f = story_panel.build_footer()
102
+
103
  def _switch_mode(mode: str):
104
  agentic = mode == _MODE_AGENT
105
  return gr.update(visible=not agentic), gr.update(visible=agentic)
 
110
  # Internal listeners stay off the API/MCP surface — only the three
111
  # typed ``gr.api`` tools (score_week / rank_region_week /
112
  # explain_score) are exposed.
113
+ comp = {**b, **s, **i, **a, **f,
114
  "selected": selected, "scored": scored, "agent_store": agent_store}
115
  select_outputs = [comp[k] for k in SELECT_KEYS]
116
  fetch_outputs = [comp[k] for k in FETCH_KEYS]
 
157
  s["fetch_forecast"], inputs=[selected, b["skill_dd"]], outputs=fetch_outputs,
158
  show_progress="minimal", api_name=False,
159
  )
160
+ # -- intel rail: pill tabs swap the three feature columns --
161
+ i["tab_picker"].change(i["pick"], inputs=i["tab_picker"],
162
+ outputs=[i["report_col"], i["world_col"], i["climate_col"]],
163
+ api_name=False)
164
+ # the report button lives in the intel rail; its handler stays story-side
165
+ i["narrate_btn"].click(s["narrate"], inputs=scored, outputs=i["report_md"],
166
  show_progress="minimal", api_name=False)
167
  # -- agent: chat streams trace cards + meter + verdict + charts --
168
  chat_inputs = [a["msg"], a["chatbot"], b["skill_dd"], a["token_box"],
ui/contracts.py CHANGED
@@ -11,8 +11,8 @@ from __future__ import annotations
11
 
12
  # Picking a spot (search box / default on load): static story + intel.
13
  SELECT_KEYS = (
14
- "search_dd", "map_plot", "spot_header", "badges_md", "rose_plot",
15
- "year_plot", "months_md", "details_df", "selected",
16
  )
17
 
18
  # The staged forecast fetch: status, hero, strip, world model, state.
 
11
 
12
  # Picking a spot (search box / default on load): static story + intel.
13
  SELECT_KEYS = (
14
+ "search_dd", "map_plot", "spot_header", "badges_md", "description_md",
15
+ "rose_plot", "year_plot", "months_md", "selected",
16
  )
17
 
18
  # The staged forecast fetch: status, hero, strip, world model, state.
ui/panels/agent.py CHANGED
@@ -8,11 +8,13 @@ preview. The budget meter ticks live (steps / tool calls / score_week cap /
8
  ≈tokens) against the slider-chosen tool budget, and the final answer's
9
  "Best: …" contract line is lifted into a verdict banner. Any ``score_week``
10
  payload in a tool result is charted straight into the week strip (the LLM
11
- never owns numbers), and **every answered turn ends with a visualisation**:
12
- turns without scored hours render the region-sweep leaderboard, climate
13
- rose, seafloor depth map, similarity bars or the explained-hour component
14
- split from their tool payloads, falling back to the viewed spot's own
15
- scored week.
 
 
16
 
17
  User controls in the bar: a "max tool calls" slider (the per-turn step
18
  budget; token + score_week caps scale with it) and chained State → Region
@@ -50,6 +52,37 @@ _TOOL_ICONS = {
50
  _MEDALS = ["🥇", "🥈", "🥉"]
51
  _VERDICT_RE = re.compile(r"Best:\s*(.+)", re.IGNORECASE)
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  _EMPTY_TRACE = ("<div class='trace-empty'>tool calls land here live — "
54
  "watch the engines work…</div>")
55
  _EMPTY_RANK_HTML = ("<div class='rank-empty'>leaderboard — ask "
@@ -226,23 +259,164 @@ def _viz_spot(args: dict, obs) -> tuple[str, str]:
226
  return str(name), str(region)
227
 
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
230
- skill: str, records: list[dict]):
231
- """Pick + build a chart for a turn that scored no hours.
232
-
233
- Every agent answer ends with a visualisation. When no score_week /
234
- find_best_windows payload was charted mid-turn, render one from the
235
- other deterministic tool payloads — region-sweep leaderboard, climate
236
- rose, seafloor depth map, similarity bars, explained-hour component
237
- split — falling back to the viewed spot's own scored week. Numbers
238
- come from tool/engine output only, never from the answer text.
 
 
 
 
239
  Returns (figure, markdown note) or (None, "") when nothing chartable.
240
  """
241
  by_name: dict[str, tuple[dict, object]] = {}
242
  for name, args, obs in viz:
243
  by_name.setdefault(str(name), (args if isinstance(args, dict) else {}, obs))
244
-
245
- # 1. region sweep → leaderboard bars
 
 
 
 
 
 
 
 
 
 
 
246
  _a, obs = by_name.get("rank_region_week", ({}, None))
247
  rows = _coerce_rank_rows(obs)
248
  if rows:
@@ -253,56 +427,27 @@ def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
253
  note = (f"region sweep{region_bit} — top pick "
254
  f"**{top.get('name')}** ({top.get('best_score')}/10)")
255
  return score_chart.build_rank_fig(rows, region), note
 
 
 
256
 
257
- # 2. climate → 5-yr swell rose
258
- _a, obs = by_name.get("get_climate_profile", ({}, None))
259
- if isinstance(obs, dict):
260
- rose = (obs.get("climate") or {}).get("direction_rose_pct") or {}
261
- try:
262
- has_rose = any(float(v or 0) > 0 for v in rose.values())
263
- except (TypeError, ValueError):
264
- has_rose = False
265
- if has_rose:
266
- name, _region = _viz_spot(_a, obs)
267
- rec = C.find_break(records, name)
268
- ideal = (((rec or {}).get("idealSwell") or {}).get("direction")
269
- if rec else None)
270
- fig = climate_chart.build_rose_fig({"rose": rose}, name=name,
271
- ideal_dirs=ideal)
272
- return fig, f"swell climate — {name} (5-yr ERA5 rose)"
273
-
274
- # 3. seafloor → depth map from the world model
275
- _a, obs = by_name.get("get_seafloor_profile", ({}, None))
276
- if isinstance(obs, dict):
277
- name, _region = _viz_spot(_a, obs)
278
- rec = C.find_break(records, name)
279
- if rec:
280
  try:
281
- grid = (C.get_seafloor(rec) or {}).get("grid") or {}
282
- fig = seafloor_chart.build_depth_fig(grid, name)
283
- return fig, f"seafloor depth map — {name}"
284
- except Exception: # noqa: BLE001 — fall through to the next viz
285
- pass
286
-
287
- # 4. similar spots → similarity bars
288
- _a, obs = by_name.get("find_similar_spots", ({}, None))
289
- if (isinstance(obs, list) and obs and isinstance(obs[0], dict)
290
- and "similarity" in obs[0]):
291
- ref = str(_a.get("spot_name") or "")
292
- return (score_chart.build_similarity_fig(obs, ref),
293
- f"{len(obs)} spots like {ref} — similarity")
294
-
295
- # 5. explain_score → component split
296
- _a, obs = by_name.get("explain_score", ({}, None))
297
- if (isinstance(obs, dict) and isinstance(obs.get("components"), dict)
298
- and obs["components"]):
299
- name, _region = _viz_spot(_a, obs)
300
- when = str(obs.get("time") or "")
301
- fig = score_chart.build_component_fig(obs["components"], spot=name,
302
- when=when, score=obs.get("score"))
303
- return fig, f"component split — {name} @ {when}"
304
 
305
- # 6. nothing tool-chartable → the viewed spot's own scored week
306
  sel = selected or {}
307
  if sel.get("name"):
308
  payload = C.get_scored_week(sel, skill=skill, days=7)
@@ -415,7 +560,9 @@ def chat_fn(message: str, history: list[dict] | None, skill: str,
415
  entry_skill = skill
416
  if isinstance(obs, dict) and obs.get("skill"):
417
  entry_skill = obs["skill"]
418
- spots.append({"label": label, "hours": hours, "skill": entry_skill})
 
 
419
  # Dedupe repeated chart labels at append time ("#2", "#3", …).
420
  counts: dict[str, int] = {}
421
  for s in spots:
@@ -483,15 +630,21 @@ def chat_fn(message: str, history: list[dict] | None, skill: str,
483
  yield emit(f"❌ Agent run failed: {e}")
484
  return
485
 
486
- # Every answered turn ends with a visualisation. When nothing was
487
- # charted mid-turn, build one from the recorded tool payloads (or the
488
- # viewed spot's week) — the status is yielded first so a slow first
489
- # fetch reads as a staged spin-up, never a hang.
 
490
  fallback_fig = None
491
- if not turn_charted:
 
492
  yield emit("📊 assembling this turn's chart…")
493
  try:
494
- fallback_fig, viz_note = _turn_figure(viz, selected, skill, records)
 
 
 
 
495
  except Exception: # noqa: BLE001 — a chart must never break the answer
496
  fallback_fig, viz_note = None, ""
497
  if fallback_fig is not None:
@@ -582,8 +735,9 @@ def build_agent(selected, store, records: list[dict], vocab: dict | None,
582
  gr.Markdown("### 💬 ask the agent — engines compute, the llm narrates")
583
  gr.Markdown("the agent calls ⚙ scoring, 🪨 the gebco world model, 🌡 era5 climate "
584
  "and 📡 open-meteo directly; nemotron 3 ultra only narrates their "
585
- "numbers. every answer lands with a chart — week strip, rose, "
586
- "depth map, leaderboard or component split.")
 
587
  ctx_md = gr.Markdown("", elem_classes=["ctx-line"])
588
  with gr.Row():
589
  with gr.Column(scale=1):
@@ -595,7 +749,8 @@ def build_agent(selected, store, records: list[dict], vocab: dict | None,
595
  steps_slider = gr.Slider(1, 12, step=1,
596
  value=_DEFAULT_STEPS,
597
  label="max calls", scale=2,
598
- min_width=180)
 
599
  token_box = gr.Textbox(label="HF token (optional, session-only)", type="password",
600
  placeholder="Defaults to the Space secret — never logged or saved.")
601
  chatbot = gr.Chatbot(label="Surf agent", height=460)
 
8
  ≈tokens) against the slider-chosen tool budget, and the final answer's
9
  "Best: …" contract line is lifted into a verdict banner. Any ``score_week``
10
  payload in a tool result is charted straight into the week strip (the LLM
11
+ never owns numbers), and **every answered turn ends with a visualisation
12
+ matched to the question**: recommendation questions close on the
13
+ recommended-scores chart — a region-sweep leaderboard, or a side-by-side
14
+ compare when several spots were scored this turn (a deterministic engine
15
+ sweep fills in when the agent never ranked); climate / seafloor / similar /
16
+ explain questions get their rose, depth map, similarity bars or component
17
+ split; anything else falls back to the viewed spot's own scored week.
18
 
19
  User controls in the bar: a "max tool calls" slider (the per-turn step
20
  budget; token + score_week caps scale with it) and chained State → Region
 
52
  _MEDALS = ["🥇", "🥈", "🥉"]
53
  _VERDICT_RE = re.compile(r"Best:\s*(.+)", re.IGNORECASE)
54
 
55
+ # Query intent (deterministic, on the user's text only): which end-of-turn
56
+ # chart matches the question. Scores charts are the house default; the
57
+ # specialties jump the queue only when the question asks for them.
58
+ _REC_RE = re.compile(
59
+ r"\bwhere\s+(?:should|to|can|shall|could)\b"
60
+ r"|\bbest\s+(?:spots?|breaks?|beaches?|places?)\b"
61
+ r"|\brecommend|\brank\w*|\btop\s*\d+"
62
+ r"|\bcompare|\bversus\b|\bvs\b"
63
+ r"|\bshould\s+i\s+(?:surf|go|paddle)\b", re.IGNORECASE)
64
+ _CLIMATE_RE = re.compile(
65
+ r"\bclimate\b|\bmonthly\b|\bmonths?\b|winter|summer|autumn|spring\b"
66
+ r"|\bswell\s+direction\b|\bwhen\s+to\s+go\b", re.IGNORECASE)
67
+ _SEAFLOOR_RE = re.compile(
68
+ r"\bseafloor\b|\bsea\s?floor\b|\bsea\s?bed\b|\bbathymetry\b|\bdepths?\b"
69
+ r"|\bstructure\b|\bsandbar\b|\breef\b", re.IGNORECASE)
70
+ _SIMILAR_RE = re.compile(
71
+ r"\b(?:spots?|breaks?|beaches?)\s+like\b|\bsimilar\s+to\b"
72
+ r"|\balternatives?\s+to\b|\bquieter\b", re.IGNORECASE)
73
+ _EXPLAIN_RE = re.compile(
74
+ r"\bwhy\b|\bexplain\w*|\bcomponent\w*|\bbreakdown\b", re.IGNORECASE)
75
+ _SPECIALTY_RE = (
76
+ ("get_climate_profile", _CLIMATE_RE),
77
+ ("get_seafloor_profile", _SEAFLOOR_RE),
78
+ ("find_similar_spots", _SIMILAR_RE),
79
+ ("explain_score", _EXPLAIN_RE),
80
+ )
81
+ _STATE_CODES = {
82
+ "NSW": "New South Wales", "QLD": "Queensland", "VIC": "Victoria",
83
+ "WA": "Western Australia", "SA": "South Australia", "TAS": "Tasmania",
84
+ }
85
+
86
  _EMPTY_TRACE = ("<div class='trace-empty'>tool calls land here live — "
87
  "watch the engines work…</div>")
88
  _EMPTY_RANK_HTML = ("<div class='rank-empty'>leaderboard — ask "
 
259
  return str(name), str(region)
260
 
261
 
262
+ def _specialty_tool(query: str) -> str | None:
263
+ """The tool whose chart the question explicitly asks for, if any."""
264
+ for tool_name, rx in _SPECIALTY_RE:
265
+ if rx.search(query or ""):
266
+ return tool_name
267
+ return None
268
+
269
+
270
+ def _rose_chart(entry: tuple[dict, object], records: list[dict]):
271
+ _a, obs = entry
272
+ if not isinstance(obs, dict):
273
+ return None, ""
274
+ rose = (obs.get("climate") or {}).get("direction_rose_pct") or {}
275
+ try:
276
+ if not any(float(v or 0) > 0 for v in rose.values()):
277
+ return None, ""
278
+ except (TypeError, ValueError):
279
+ return None, ""
280
+ name, _region = _viz_spot(_a, obs)
281
+ rec = C.find_break(records, name)
282
+ ideal = (((rec or {}).get("idealSwell") or {}).get("direction")
283
+ if rec else None)
284
+ fig = climate_chart.build_rose_fig({"rose": rose}, name=name,
285
+ ideal_dirs=ideal)
286
+ return fig, f"swell climate — {name} (5-yr ERA5 rose)"
287
+
288
+
289
+ def _depth_chart(entry: tuple[dict, object], records: list[dict]):
290
+ _a, obs = entry
291
+ if not isinstance(obs, dict):
292
+ return None, ""
293
+ name, _region = _viz_spot(_a, obs)
294
+ rec = C.find_break(records, name)
295
+ if not rec:
296
+ return None, ""
297
+ try:
298
+ grid = (C.get_seafloor(rec) or {}).get("grid") or {}
299
+ return seafloor_chart.build_depth_fig(grid, name), \
300
+ f"seafloor depth map — {name}"
301
+ except Exception: # noqa: BLE001 — fall through to the next viz
302
+ return None, ""
303
+
304
+
305
+ def _similarity_chart(entry: tuple[dict, object], records: list[dict]):
306
+ _a, obs = entry
307
+ if (isinstance(obs, list) and obs and isinstance(obs[0], dict)
308
+ and "similarity" in obs[0]):
309
+ ref = str(_a.get("spot_name") or "")
310
+ return (score_chart.build_similarity_fig(obs, ref),
311
+ f"{len(obs)} spots like {ref} — similarity")
312
+ return None, ""
313
+
314
+
315
+ def _components_chart(entry: tuple[dict, object], records: list[dict]):
316
+ _a, obs = entry
317
+ if (isinstance(obs, dict) and isinstance(obs.get("components"), dict)
318
+ and obs["components"]):
319
+ name, _region = _viz_spot(_a, obs)
320
+ when = str(obs.get("time") or "")
321
+ fig = score_chart.build_component_fig(obs["components"], spot=name,
322
+ when=when, score=obs.get("score"))
323
+ return fig, f"component split — {name} @ {when}"
324
+ return None, ""
325
+
326
+
327
+ def _specialty_figure(by_name: dict[str, tuple[dict, object]],
328
+ records: list[dict],
329
+ prefer: str | None = None):
330
+ """First buildable question-specific chart from the tool payloads.
331
+
332
+ Scans climate rose → seafloor depth map → similarity bars → component
333
+ split; a question-matched tool (``prefer``) is scanned first so e.g. a
334
+ seafloor question wins its depth map even when a rose payload exists.
335
+ Returns (figure, markdown note) or (None, "").
336
+ """
337
+ checks = (
338
+ ("get_climate_profile", _rose_chart),
339
+ ("get_seafloor_profile", _depth_chart),
340
+ ("find_similar_spots", _similarity_chart),
341
+ ("explain_score", _components_chart),
342
+ )
343
+ if prefer:
344
+ checks = tuple(sorted(checks, key=lambda c: c[0] != prefer))
345
+ for _tool_name, build in checks:
346
+ fig, note = build(by_name.get(_tool_name, ({}, None)), records)
347
+ if fig is not None:
348
+ return fig, note
349
+ return None, ""
350
+
351
+
352
+ def _scored_compare_fig(scored: list[dict]):
353
+ """Side-by-side best-score bars for a multi-spot scored turn."""
354
+ rows = []
355
+ for s in scored or []:
356
+ best = score_chart.best_window(s.get("hours") or [])
357
+ if not best:
358
+ continue
359
+ rows.append({"name": str(s.get("label") or "?"),
360
+ "region": str(s.get("region") or ""),
361
+ "best_score": best.get("score"),
362
+ "best_time": best.get("time")})
363
+ if len(rows) < 2:
364
+ return None, ""
365
+ names = " vs ".join(r["name"].split(" (")[0] for r in rows[:2])
366
+ fig = score_chart.build_compare_fig(
367
+ rows, "name", "best_score", "recommended breaks — best score this week",
368
+ sub_keys=("region", "best_time"))
369
+ return fig, f"{names} — best-score compare ({len(rows)} spots)"
370
+
371
+
372
+ def _query_region(query: str, records: list[dict]) -> str | None:
373
+ """A canonical state/region named in the query (codes expanded), or None."""
374
+ text = f" {query or ''} ".lower()
375
+ vocab = sorted({str(r.get("state") or "") for r in records}
376
+ | {str(r.get("region") or "") for r in records},
377
+ key=len, reverse=True)
378
+ for name in vocab:
379
+ if name and re.search(rf"(?<![a-z]){re.escape(name.lower())}(?![a-z])",
380
+ text):
381
+ return name
382
+ for code, name in _STATE_CODES.items():
383
+ if re.search(rf"(?<![a-z]){code.lower()}(?![a-z])", text):
384
+ return name
385
+ return None
386
+
387
+
388
  def _turn_figure(viz: list[tuple[str, dict, object]], selected: dict | None,
389
+ skill: str, records: list[dict], query: str = "",
390
+ focus: str | None = None, scored: list[dict] | None = None,
391
+ charted: bool = False):
392
+ """Pick + build the end-of-turn chart, matched to the question.
393
+
394
+ The house chart is the recommended-surf-break-scores view: a
395
+ region-sweep leaderboard, or a side-by-side compare when several spots
396
+ were scored this turn. Question-specific charts (climate rose, seafloor
397
+ depth map, similarity bars, component split) jump the queue only when
398
+ the question asks for them. A recommendation question the agent answered
399
+ without any sweep still closes on one: a deterministic engine sweep of
400
+ the region named in the question (or the page's State/Region focus).
401
+ Numbers come from tool/engine output only, never from the answer text.
402
  Returns (figure, markdown note) or (None, "") when nothing chartable.
403
  """
404
  by_name: dict[str, tuple[dict, object]] = {}
405
  for name, args, obs in viz:
406
  by_name.setdefault(str(name), (args if isinstance(args, dict) else {}, obs))
407
+ q = str(query or "")
408
+ recommend = bool(_REC_RE.search(q))
409
+ prefer = _specialty_tool(q)
410
+ special = _specialty_figure(by_name, records, prefer)
411
+
412
+ if charted:
413
+ # Multi-spot scored turn: week strips already ran mid-turn — close
414
+ # on the side-by-side scores, never an unrelated refetch.
415
+ return _scored_compare_fig(scored or [])
416
+ if not recommend and prefer and special[0] is not None:
417
+ return special
418
+
419
+ # scores first: the sweep leaderboard, then this turn's scored spots
420
  _a, obs = by_name.get("rank_region_week", ({}, None))
421
  rows = _coerce_rank_rows(obs)
422
  if rows:
 
427
  note = (f"region sweep{region_bit} — top pick "
428
  f"**{top.get('name')}** ({top.get('best_score')}/10)")
429
  return score_chart.build_rank_fig(rows, region), note
430
+ compare = _scored_compare_fig(scored or [])
431
+ if compare[0] is not None:
432
+ return compare
433
 
434
+ if recommend:
435
+ region = _query_region(q, records) or (str(focus) if focus else None)
436
+ if region:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  try:
438
+ rows = _coerce_rank_rows(
439
+ C.api_rank_region_week(region, skill=skill))
440
+ except Exception: # noqa: BLE001 — chart fallbacks never raise
441
+ rows = None
442
+ if rows:
443
+ top = rows[0]
444
+ return (score_chart.build_rank_fig(rows, region),
445
+ f"recommended breaks — {region} — top pick "
446
+ f"**{top.get('name')}** ({top.get('best_score')}/10)")
447
+
448
+ if special[0] is not None:
449
+ return special
 
 
 
 
 
 
 
 
 
 
 
450
 
 
451
  sel = selected or {}
452
  if sel.get("name"):
453
  payload = C.get_scored_week(sel, skill=skill, days=7)
 
560
  entry_skill = skill
561
  if isinstance(obs, dict) and obs.get("skill"):
562
  entry_skill = obs["skill"]
563
+ spots.append({"label": label, "hours": hours,
564
+ "skill": entry_skill,
565
+ "region": str(region_arg or "")})
566
  # Dedupe repeated chart labels at append time ("#2", "#3", …).
567
  counts: dict[str, int] = {}
568
  for s in spots:
 
630
  yield emit(f"❌ Agent run failed: {e}")
631
  return
632
 
633
+ # Every answered turn ends with a visualisation matched to the question,
634
+ # scores first. Multi-spot scored turns close on the side-by-side
635
+ # compare; otherwise the chain runs only when nothing was charted
636
+ # mid-turn. The status is yielded first so a slow fetch (e.g. a make-up
637
+ # region sweep) reads as a staged spin-up, never a hang.
638
  fallback_fig = None
639
+ viz_note = ""
640
+ if (not turn_charted) or len(spots) > 1:
641
  yield emit("📊 assembling this turn's chart…")
642
  try:
643
+ fallback_fig, viz_note = _turn_figure(viz, selected, skill, records,
644
+ query=text,
645
+ focus=_region_hint(state, region),
646
+ scored=spots,
647
+ charted=turn_charted)
648
  except Exception: # noqa: BLE001 — a chart must never break the answer
649
  fallback_fig, viz_note = None, ""
650
  if fallback_fig is not None:
 
735
  gr.Markdown("### 💬 ask the agent — engines compute, the llm narrates")
736
  gr.Markdown("the agent calls ⚙ scoring, 🪨 the gebco world model, 🌡 era5 climate "
737
  "and 📡 open-meteo directly; nemotron 3 ultra only narrates their "
738
+ "numbers. every answer lands with a question-matched chart — "
739
+ "recommended-scores leaderboard, week strip, rose, depth map, "
740
+ "similarity bars or component split.")
741
  ctx_md = gr.Markdown("", elem_classes=["ctx-line"])
742
  with gr.Row():
743
  with gr.Column(scale=1):
 
749
  steps_slider = gr.Slider(1, 12, step=1,
750
  value=_DEFAULT_STEPS,
751
  label="max calls", scale=2,
752
+ min_width=180,
753
+ elem_classes=["steps-slider"])
754
  token_box = gr.Textbox(label="HF token (optional, session-only)", type="password",
755
  placeholder="Defaults to the Space secret — never logged or saved.")
756
  chatbot = gr.Chatbot(label="Surf agent", height=460)
ui/panels/intel.py CHANGED
@@ -1,19 +1,32 @@
1
- """Intel rail: the world model + the when-to-go climatology.
2
 
3
- Static panels — components only. Their inputs arrive through the shared
4
- contracts: world-model figs come from the staged fetch (FETCH_KEYS),
5
- climatology from the spot pick (SELECT_KEYS). Break details live in the
6
- lens column, directly under the map.
 
 
 
 
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import gradio as gr
12
 
 
 
13
 
14
  def build_intel():
15
  """Build the intel rail (inside an existing Column). Returns components."""
16
- with gr.Accordion("🌍 world model — gebco 2020", open=True):
 
 
 
 
 
 
17
  gr.Markdown("bathymetry around the takeoff zone (blue = deep, green = land, "
18
  "★ = break). the 3D view carries an **animated swell layer** that "
19
  "rides the forecast's dominant period — press ▶ swell.")
@@ -22,12 +35,21 @@ def build_intel():
22
  with gr.Accordion("depth + transects", open=False):
23
  depth_plot = gr.Plot(label="Depth map")
24
  transect_plot = gr.Plot(label="Transects")
25
- with gr.Accordion("📅 when to go — era5 5-yr climatology", open=True):
26
  year_plot = gr.Plot(label="When to go")
27
  months_md = gr.Markdown("_pick a spot — its climatology lands here._")
28
  rose_plot = gr.Plot(label="Swell climate rose")
29
 
 
 
 
 
 
 
30
  return {
 
 
 
31
  "surface_plot": surface_plot, "depth_plot": depth_plot,
32
  "transect_plot": transect_plot, "seafloor_md": seafloor_md,
33
  "year_plot": year_plot, "months_md": months_md, "rose_plot": rose_plot,
 
1
+ """Intel rail: three click-through tabs on the right edge of the page.
2
 
3
+ **surf report** (the LLM-narrated write-up, generated on demand),
4
+ **world model** (GEBCO bathymetry) and **when to go** (ERA5 climatology)
5
+ hang off one pill tab row — the header mode-toggle pattern: a Radio
6
+ swaps three stacked columns. (Gradio's native Tabs park overflow tabs in
7
+ a "⋯" menu at this column width; the rail keeps its own switcher
8
+ instead.) Components only — their inputs arrive through the shared
9
+ contracts: the report button wires in ``ui/app.py``, world-model figs
10
+ come from the staged fetch (FETCH_KEYS), climatology from the spot pick
11
+ (SELECT_KEYS).
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import gradio as gr
17
 
18
+ _TABS = ["report", "topography", "season"]
19
+
20
 
21
  def build_intel():
22
  """Build the intel rail (inside an existing Column). Returns components."""
23
+ tab_picker = gr.Radio(_TABS, value=_TABS[0], show_label=False,
24
+ container=False, elem_classes=["intel-tabs"])
25
+ with gr.Column(visible=True) as report_col:
26
+ narrate_btn = gr.Button("get surf report ✨", variant="secondary")
27
+ report_md = gr.Markdown(
28
+ "_no report yet — pick a spot, then get the surf report._")
29
+ with gr.Column(visible=False) as world_col:
30
  gr.Markdown("bathymetry around the takeoff zone (blue = deep, green = land, "
31
  "★ = break). the 3D view carries an **animated swell layer** that "
32
  "rides the forecast's dominant period — press ▶ swell.")
 
35
  with gr.Accordion("depth + transects", open=False):
36
  depth_plot = gr.Plot(label="Depth map")
37
  transect_plot = gr.Plot(label="Transects")
38
+ with gr.Column(visible=False) as climate_col:
39
  year_plot = gr.Plot(label="When to go")
40
  months_md = gr.Markdown("_pick a spot — its climatology lands here._")
41
  rose_plot = gr.Plot(label="Swell climate rose")
42
 
43
+ def pick(tab: str):
44
+ """Tab switch: show the picked feature column, hide the others."""
45
+ return (gr.update(visible=tab == _TABS[0]),
46
+ gr.update(visible=tab == _TABS[1]),
47
+ gr.update(visible=tab == _TABS[2]))
48
+
49
  return {
50
+ "tab_picker": tab_picker, "pick": pick,
51
+ "report_col": report_col, "world_col": world_col, "climate_col": climate_col,
52
+ "narrate_btn": narrate_btn, "report_md": report_md,
53
  "surface_plot": surface_plot, "depth_plot": depth_plot,
54
  "transect_plot": transect_plot, "seafloor_md": seafloor_md,
55
  "year_plot": year_plot, "months_md": months_md, "rose_plot": rose_plot,
ui/panels/lens.py CHANGED
@@ -9,8 +9,6 @@ records, so no extra session state is needed here.
9
 
10
  from __future__ import annotations
11
 
12
- import pandas as pd
13
-
14
  import gradio as gr
15
 
16
  from ui import _compat as C
@@ -19,44 +17,6 @@ from ui.charts import map as map_chart
19
  from ui.contracts import SELECT_KEYS, fill
20
 
21
 
22
- def _join(value) -> str:
23
- if isinstance(value, list):
24
- return ", ".join(str(v) for v in value)
25
- return "" if value is None else str(value)
26
-
27
-
28
- def break_to_table(break_: dict) -> pd.DataFrame:
29
- """Flatten one break record into a Field/Value table."""
30
- loc = (break_ or {}).get("location", {}) or {}
31
- coords = loc.get("coordinates", {}) or {}
32
- swell = (break_ or {}).get("idealSwell", {}) or {}
33
- swell_size = swell.get("sizeRangeFt", {}) or {}
34
- wind = (break_ or {}).get("idealWind", {}) or {}
35
- tide = (break_ or {}).get("idealTide", {}) or {}
36
- rows = [
37
- ("Name", break_.get("name", "")),
38
- ("State", break_.get("state", "")),
39
- ("Region", break_.get("region", "")),
40
- ("Description", break_.get("description", "")),
41
- ("Skill level", break_.get("skillLevel", "")),
42
- ("Break type", break_.get("breakType", "")),
43
- ("Peak type", break_.get("peakType", "")),
44
- ("Latitude", coords.get("lat", "")),
45
- ("Longitude", coords.get("lng", "")),
46
- ("Ideal swell", _join(swell.get("direction", ""))),
47
- ("Swell size (ft)", f"{swell_size.get('min', '?')}–{swell_size.get('max', '?')}"),
48
- ("Ideal wind", f"{_join(wind.get('direction', ''))} ({wind.get('type', '')})"),
49
- ("Ideal tide", _join(tide.get("stage", ""))),
50
- ("Best season", _join(break_.get("bestSeason", ""))),
51
- ("Hazards", _join(break_.get("hazards", ""))),
52
- ("Crowd", break_.get("crowdFactor", "")),
53
- ]
54
- return pd.DataFrame(rows, columns=["Field", "Value"])
55
-
56
-
57
- _EMPTY_DETAILS = pd.DataFrame(columns=["Field", "Value"])
58
-
59
-
60
  def build_lens(records: list[dict], vocab: dict, selected):
61
  """Build the lens column. Returns component/handler dict for wiring."""
62
  states = vocab["states"]
@@ -79,8 +39,6 @@ def build_lens(records: list[dict], vocab: dict, selected):
79
  )
80
  map_plot = gr.Plot(map_chart.build_map(records, default_view=True))
81
  gr.HTML(map_chart.skill_legend_html())
82
- with gr.Accordion("📖 break details", open=False):
83
- details_df = gr.Dataframe(headers=["Field", "Value"], wrap=True)
84
 
85
  def state_regions(state: str) -> dict:
86
  regions = regions_by_state.get(state, all_regions) if state != C.ALL else all_regions
@@ -111,7 +69,7 @@ def build_lens(records: list[dict], vocab: dict, selected):
111
  profile = {} # the stub rose would be fabricated data — stay honest
112
  monthly = C.get_climate_monthly(record)
113
  cam = C.get_surf_cam(record)
114
- from ui.panels.story import format_badges
115
 
116
  fly_fig = (map_chart.build_map(filtered or [record], selected=record)
117
  if fly else gr.skip())
@@ -121,6 +79,7 @@ def build_lens(records: list[dict], vocab: dict, selected):
121
  map_plot=fly_fig,
122
  spot_header=header,
123
  badges_md=format_badges(record, cam),
 
124
  rose_plot=climate_chart.build_rose_fig(
125
  profile, name,
126
  ideal_dirs=(record.get("idealSwell") or {}).get("direction"),
@@ -129,7 +88,6 @@ def build_lens(records: list[dict], vocab: dict, selected):
129
  monthly, name, window_label=profile.get("window_ft", ""),
130
  ),
131
  months_md=C.format_month_hint(monthly, profile),
132
- details_df=break_to_table(record),
133
  selected=record,
134
  )
135
 
@@ -143,7 +101,7 @@ def build_lens(records: list[dict], vocab: dict, selected):
143
 
144
  return {
145
  "search_dd": search_dd, "state_dd": state_dd, "region_dd": region_dd,
146
- "skill_dd": skill_dd, "map_plot": map_plot, "details_df": details_df,
147
  "state_regions": state_regions, "filter_changed": filter_changed,
148
  "pick_by_name": pick_by_name, "select_spot": select_spot,
149
  }
 
9
 
10
  from __future__ import annotations
11
 
 
 
12
  import gradio as gr
13
 
14
  from ui import _compat as C
 
17
  from ui.contracts import SELECT_KEYS, fill
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def build_lens(records: list[dict], vocab: dict, selected):
21
  """Build the lens column. Returns component/handler dict for wiring."""
22
  states = vocab["states"]
 
39
  )
40
  map_plot = gr.Plot(map_chart.build_map(records, default_view=True))
41
  gr.HTML(map_chart.skill_legend_html())
 
 
42
 
43
  def state_regions(state: str) -> dict:
44
  regions = regions_by_state.get(state, all_regions) if state != C.ALL else all_regions
 
69
  profile = {} # the stub rose would be fabricated data — stay honest
70
  monthly = C.get_climate_monthly(record)
71
  cam = C.get_surf_cam(record)
72
+ from ui.panels.story import format_badges, format_description
73
 
74
  fly_fig = (map_chart.build_map(filtered or [record], selected=record)
75
  if fly else gr.skip())
 
79
  map_plot=fly_fig,
80
  spot_header=header,
81
  badges_md=format_badges(record, cam),
82
+ description_md=format_description(record),
83
  rose_plot=climate_chart.build_rose_fig(
84
  profile, name,
85
  ideal_dirs=(record.get("idealSwell") or {}).get("direction"),
 
88
  monthly, name, window_label=profile.get("window_ft", ""),
89
  ),
90
  months_md=C.format_month_hint(monthly, profile),
 
91
  selected=record,
92
  )
93
 
 
101
 
102
  return {
103
  "search_dd": search_dd, "state_dd": state_dd, "region_dd": region_dd,
104
+ "skill_dd": skill_dd, "map_plot": map_plot,
105
  "state_regions": state_regions, "filter_changed": filter_changed,
106
  "pick_by_name": pick_by_name, "select_spot": select_spot,
107
  }
ui/panels/story.py CHANGED
@@ -1,11 +1,11 @@
1
- """Story panel: the selected spot's week — chips, strip, report.
2
 
3
  ``fetch_forecast`` stays a *staged generator* so the spin-up reads as
4
  engines working, not a spinner: stage 1 lands the Open-Meteo fetch +
5
  week strip immediately (~ms warm), while the GEBCO world model resolves
6
  in a parallel thread and streams in as stage 2. Stage timings (feed,
7
- scorer, world model) land in the status line — the LLM never owns the
8
- numbers.
9
  """
10
 
11
  from __future__ import annotations
@@ -34,7 +34,7 @@ def _join(value) -> str:
34
 
35
 
36
  def format_badges(record: dict | None, cam: dict | None = None) -> str:
37
- """Spot identity as lo-fi chips: skill, type, tide, crowd, hazards, cam."""
38
  b = record or {}
39
  if not b:
40
  return ""
@@ -49,10 +49,22 @@ def format_badges(record: dict | None, cam: dict | None = None) -> str:
49
  tide = _join((b.get("idealTide") or {}).get("stage"))
50
  if tide:
51
  parts.append(f'<span class="badge-chip">🌊 tide: {esc(tide)}</span>')
52
- swell = (b.get("idealSwell") or {}).get("sizeRangeFt") or {}
53
- if swell.get("min") is not None:
54
- parts.append(f'<span class="badge-chip">📏 likes {swell.get("min", "?")}–'
55
- f'{swell.get("max", "?")} ft</span>')
 
 
 
 
 
 
 
 
 
 
 
 
56
  if b.get("crowdFactor"):
57
  parts.append(f'<span class="badge-chip">👥 {esc(str(b["crowdFactor"]))}</span>')
58
  if b.get("bestSeason"):
@@ -66,6 +78,14 @@ def format_badges(record: dict | None, cam: dict | None = None) -> str:
66
  return '<div class="badge-row">' + " ".join(parts) + "</div>"
67
 
68
 
 
 
 
 
 
 
 
 
69
  def _empty_seafloor():
70
  blank = seafloor_chart._empty_fig()
71
  return blank, blank, blank
@@ -198,12 +218,18 @@ def narrate(payload: dict | None):
198
 
199
 
200
  def build_story(selected, scored):
201
- """Build the story column. Returns component dict for wiring."""
 
 
 
 
 
 
 
202
  with gr.Column(scale=4):
203
  spot_header = gr.Markdown("### pick a spot — search above or filter the map")
 
204
  badges_md = gr.Markdown()
205
- status_box = gr.Textbox(label="engines", interactive=False,
206
- placeholder="the spin-up line — feed, scorer, world model — lands here…")
207
  hero_md = gr.Markdown("_the week summary lands here._")
208
  with gr.Tabs():
209
  with gr.Tab("score"):
@@ -213,13 +239,23 @@ def build_story(selected, scored):
213
  with gr.Tab("wind"):
214
  wind_plot = gr.Plot(label="wind — speed + direction")
215
  gr.Markdown(f"<div class='strip-caption'>{strip_chart.CAPTION}</div>")
216
- narrate_btn = gr.Button("get surf report ✨", variant="secondary")
217
- report_md = gr.Markdown("_no report yet — pick a spot, then get the surf report._")
218
 
219
  return {
220
- "spot_header": spot_header, "badges_md": badges_md, "status_box": status_box,
221
- "hero_md": hero_md, "score_plot": score_plot, "swell_plot": swell_plot,
 
222
  "wind_plot": wind_plot,
223
- "narrate_btn": narrate_btn, "report_md": report_md,
224
  "fetch_forecast": fetch_forecast, "narrate": narrate,
225
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Story panel: the selected spot's week — chips, strip (report is intel-side).
2
 
3
  ``fetch_forecast`` stays a *staged generator* so the spin-up reads as
4
  engines working, not a spinner: stage 1 lands the Open-Meteo fetch +
5
  week strip immediately (~ms warm), while the GEBCO world model resolves
6
  in a parallel thread and streams in as stage 2. Stage timings (feed,
7
+ scorer, world model) land in the page footer's status line — the LLM
8
+ never owns the numbers.
9
  """
10
 
11
  from __future__ import annotations
 
34
 
35
 
36
  def format_badges(record: dict | None, cam: dict | None = None) -> str:
37
+ """Spot identity as lo-fi chips: every break detail but the description."""
38
  b = record or {}
39
  if not b:
40
  return ""
 
49
  tide = _join((b.get("idealTide") or {}).get("stage"))
50
  if tide:
51
  parts.append(f'<span class="badge-chip">🌊 tide: {esc(tide)}</span>')
52
+ swell = b.get("idealSwell") or {}
53
+ swell_dir = _join(swell.get("direction"))
54
+ if swell_dir:
55
+ parts.append(f'<span class="badge-chip">🧭 swell dir: {esc(swell_dir)}</span>')
56
+ size = swell.get("sizeRangeFt") or {}
57
+ if size.get("min") is not None:
58
+ parts.append(f'<span class="badge-chip">📏 likes {size.get("min", "?")}–'
59
+ f'{size.get("max", "?")} ft</span>')
60
+ wind = b.get("idealWind") or {}
61
+ wind_bits = [_ for _ in (_join(wind.get("direction")), str(wind.get("type") or "")) if _]
62
+ if wind_bits:
63
+ parts.append(f'<span class="badge-chip">🌬 wind: {esc(", ".join(wind_bits))}</span>')
64
+ coords = (b.get("location") or {}).get("coordinates") or {}
65
+ if coords.get("lat") is not None and coords.get("lng") is not None:
66
+ parts.append(f'<span class="badge-chip">📍 {coords["lat"]:.3f}, '
67
+ f'{coords["lng"]:.3f}</span>')
68
  if b.get("crowdFactor"):
69
  parts.append(f'<span class="badge-chip">👥 {esc(str(b["crowdFactor"]))}</span>')
70
  if b.get("bestSeason"):
 
78
  return '<div class="badge-row">' + " ".join(parts) + "</div>"
79
 
80
 
81
+ def format_description(record: dict | None) -> str:
82
+ """The break's own words, as a quiet paragraph under the badges."""
83
+ desc = html.escape((record or {}).get("description") or "").strip()
84
+ if not desc:
85
+ return "<div class='spot-desc spot-desc-empty'>no description on file for this spot</div>"
86
+ return f"<div class='spot-desc'>{desc}</div>"
87
+
88
+
89
  def _empty_seafloor():
90
  blank = seafloor_chart._empty_fig()
91
  return blank, blank, blank
 
218
 
219
 
220
  def build_story(selected, scored):
221
+ """Build the story column. Returns component dict for wiring.
222
+
223
+ The story column leads with the break itself: name, description,
224
+ then the detail chips and the daily ratings. The surf-report
225
+ feature lives in the intel
226
+ tabs (``ui/panels/intel.py``); the ``narrate`` handler stays here —
227
+ the wiring points the intel tab's button at it.
228
+ """
229
  with gr.Column(scale=4):
230
  spot_header = gr.Markdown("### pick a spot — search above or filter the map")
231
+ description_md = gr.Markdown()
232
  badges_md = gr.Markdown()
 
 
233
  hero_md = gr.Markdown("_the week summary lands here._")
234
  with gr.Tabs():
235
  with gr.Tab("score"):
 
239
  with gr.Tab("wind"):
240
  wind_plot = gr.Plot(label="wind — speed + direction")
241
  gr.Markdown(f"<div class='strip-caption'>{strip_chart.CAPTION}</div>")
 
 
242
 
243
  return {
244
+ "spot_header": spot_header, "badges_md": badges_md,
245
+ "description_md": description_md, "hero_md": hero_md,
246
+ "score_plot": score_plot, "swell_plot": swell_plot,
247
  "wind_plot": wind_plot,
 
248
  "fetch_forecast": fetch_forecast, "narrate": narrate,
249
  }
250
+
251
+
252
+ def build_footer():
253
+ """The page footer: the engines/latency line, out of the story's way.
254
+
255
+ Same component + contract key as before (``status_box`` in
256
+ FETCH_KEYS) — only its position on the page moved.
257
+ """
258
+ status_box = gr.Textbox(
259
+ label="engines", interactive=False, elem_classes=["foot-status"],
260
+ placeholder="idle — pick a spot and the spin-up line lands here…")
261
+ return {"status_box": status_box}
ui/theme.py CHANGED
@@ -111,6 +111,7 @@ button.primary:hover, button.lg.primary:hover {
111
  --checkbox-label-border-color-selected: #0b2c5c !important;
112
  --checkbox-label-text-color: #0b2c5c !important;
113
  --checkbox-label-text-color-selected: #ffffff !important;
 
114
  }
115
  .gradio-container .gr-box, .gradio-container .gr-panel,
116
  .gradio-container .block, .gradio-container .form,
@@ -213,6 +214,60 @@ ul.options .item {
213
  .badge-link { background: #d6e9f8 !important; text-decoration: none; font-weight: 600; }
214
  .badge-link:hover { background: #0b2c5c !important; color: #ffffff !important; }
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  /* week hero: day chips with score-coloured left borders */
217
  .day-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
218
  .day-chip {
@@ -249,6 +304,43 @@ ul.options .item {
249
  .agent-controls .wrap { flex-wrap: nowrap !important; }
250
  .ctx-line { font-size: 0.78em; opacity: 0.8; margin: 0 2px 4px !important; }
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  /* agent trace: live tool cards with ms timing bars + payload previews */
253
  .trace { display: flex; flex-direction: column; gap: 6px; }
254
  .trace-ask { font-size: 0.78em; opacity: 0.7; margin-bottom: 2px; }
@@ -307,7 +399,10 @@ ul.options .item {
307
  .mode-toggle .wrap { display: inline-flex !important; gap: 0 !important; }
308
  .mode-toggle input[type="radio"],
309
  .mode-toggle input[type="radio"] + * .circle,
310
- .mode-toggle span.circle { display: none !important; }
 
 
 
311
  .mode-toggle label {
312
  border-radius: 999px !important; padding: 4px 14px !important;
313
  font-size: 0.78em; font-weight: 600; color: #0b2c5c; cursor: pointer;
 
111
  --checkbox-label-border-color-selected: #0b2c5c !important;
112
  --checkbox-label-text-color: #0b2c5c !important;
113
  --checkbox-label-text-color-selected: #ffffff !important;
114
+ --slider-color: #0b2c5c !important;
115
  }
116
  .gradio-container .gr-box, .gradio-container .gr-panel,
117
  .gradio-container .block, .gradio-container .form,
 
214
  .badge-link { background: #d6e9f8 !important; text-decoration: none; font-weight: 600; }
215
  .badge-link:hover { background: #0b2c5c !important; color: #ffffff !important; }
216
 
217
+ /* intel rail: tab row styled after the story's score/swell/wind strip —
218
+ bare text tabs (no block chrome), one line, orange underline marks the
219
+ selected one (colors measured off the live strip: #f97316 selected) */
220
+ .gradio-container .intel-tabs,
221
+ .gradio-container form:has(.intel-tabs),
222
+ .gradio-container .block:has(> .intel-tabs) {
223
+ border: none !important;
224
+ background: transparent !important;
225
+ box-shadow: none !important;
226
+ padding: 0 !important;
227
+ }
228
+ .intel-tabs { margin: 0 0 4px; border-bottom: 1px solid #c9dff2; }
229
+ .intel-tabs .wrap { display: inline-flex !important; flex-wrap: nowrap !important; gap: 0 !important; }
230
+ .gradio-container .intel-tabs label {
231
+ border: none !important;
232
+ border-bottom: 2px solid transparent !important;
233
+ border-radius: 0 !important;
234
+ background: transparent !important;
235
+ box-shadow: none !important;
236
+ color: #0b2c5c !important;
237
+ font-size: 14px !important;
238
+ font-weight: 400 !important;
239
+ padding: 4px 16px 6px !important;
240
+ margin: 0 0 -1px !important;
241
+ white-space: nowrap !important;
242
+ }
243
+ .gradio-container .intel-tabs label.selected,
244
+ .gradio-container .intel-tabs label:has(input[type="radio"]:checked) {
245
+ background: transparent !important;
246
+ color: #f97316 !important;
247
+ border-bottom: 2px solid #f97316 !important;
248
+ }
249
+ .gradio-container .intel-tabs label.selected span,
250
+ .gradio-container .intel-tabs label:has(input[type="radio"]:checked) span {
251
+ color: #f97316 !important;
252
+ }
253
+
254
+ /* spot description: the break's own words, right under the spot name */
255
+ .spot-desc { font-size: 0.85em; line-height: 1.55; margin: 2px 0 4px !important; }
256
+ .spot-desc-empty { opacity: 0.55; font-style: italic; }
257
+
258
+ /* page footer: the machinery line (engines + latencies) below the fold */
259
+ .page-foot { border-top: 2px solid #c9dff2; margin-top: 10px; }
260
+ .gradio-container .page-foot .form,
261
+ .gradio-container .page-foot .block {
262
+ border: none !important; background: transparent !important;
263
+ padding: 0 !important; box-shadow: none !important;
264
+ }
265
+ .gradio-container .page-foot label span { font-size: 0.72em; opacity: 0.7; }
266
+ .gradio-container .page-foot textarea {
267
+ background: transparent !important; border: none !important;
268
+ font-size: 0.8em !important; padding: 2px 2px !important; resize: none !important;
269
+ }
270
+
271
  /* week hero: day chips with score-coloured left borders */
272
  .day-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; }
273
  .day-chip {
 
304
  .agent-controls .wrap { flex-wrap: nowrap !important; }
305
  .ctx-line { font-size: 0.78em; opacity: 0.8; margin: 0 2px 4px !important; }
306
 
307
+ /* max-calls slider: ink-blue instrument, no number box — the thumb is the
308
+ value; min/max labels bracket the track */
309
+ .steps-slider .tab-like-container { display: none !important; }
310
+ .steps-slider .slider_input_container { align-items: center !important; margin-top: 4px !important; }
311
+ .steps-slider .min_value, .steps-slider .max_value {
312
+ font-size: 12px !important; font-weight: 600 !important;
313
+ color: #4a6fa5 !important; opacity: 1 !important;
314
+ }
315
+ .steps-slider input[type="range"] { height: 20px !important; }
316
+ .steps-slider input[type="range"]::-webkit-slider-runnable-track {
317
+ height: 8px !important; border-radius: 999px !important;
318
+ background: linear-gradient(to right, #0b2c5c var(--range_progress), #c9dff2 var(--range_progress)) !important;
319
+ }
320
+ .steps-slider input[type="range"]::-webkit-slider-thumb {
321
+ appearance: none !important;
322
+ height: 18px !important; width: 18px !important; margin-top: -5px !important;
323
+ background: #ffffff !important;
324
+ border: 2.5px solid #0b2c5c !important;
325
+ border-radius: 50% !important;
326
+ box-shadow: 0 1px 4px rgba(11, 44, 92, 0.3) !important;
327
+ transition: transform 0.12s ease !important;
328
+ }
329
+ .steps-slider input[type="range"]:hover::-webkit-slider-thumb,
330
+ .steps-slider input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); }
331
+ .steps-slider input[type="range"]::-moz-range-track {
332
+ height: 8px !important; border-radius: 999px !important; background: #c9dff2 !important;
333
+ }
334
+ .steps-slider input[type="range"]::-moz-range-progress {
335
+ height: 8px !important; border-radius: 999px !important; background: #0b2c5c !important;
336
+ }
337
+ .steps-slider input[type="range"]::-moz-range-thumb {
338
+ height: 14px !important; width: 14px !important;
339
+ background: #ffffff !important; border: 2.5px solid #0b2c5c !important;
340
+ border-radius: 50% !important;
341
+ box-shadow: 0 1px 4px rgba(11, 44, 92, 0.3) !important;
342
+ }
343
+
344
  /* agent trace: live tool cards with ms timing bars + payload previews */
345
  .trace { display: flex; flex-direction: column; gap: 6px; }
346
  .trace-ask { font-size: 0.78em; opacity: 0.7; margin-bottom: 2px; }
 
399
  .mode-toggle .wrap { display: inline-flex !important; gap: 0 !important; }
400
  .mode-toggle input[type="radio"],
401
  .mode-toggle input[type="radio"] + * .circle,
402
+ .mode-toggle span.circle,
403
+ .intel-tabs input[type="radio"],
404
+ .intel-tabs input[type="radio"] + * .circle,
405
+ .intel-tabs span.circle { display: none !important; }
406
  .mode-toggle label {
407
  border-radius: 999px !important; padding: 4px 14px !important;
408
  font-size: 0.78em; font-weight: 600; color: #0b2c5c; cursor: pointer;