seriffic commited on
Commit
e8a6c67
·
1 Parent(s): 6a82282

Frontend overhaul: Lit kickoff → Svelte 5 custom elements → SvelteKit design-system

Browse files

Telescoped from ~25 spine commits into the three UI generations:

Lit (kept on disk for reference, not loaded):
web/static/components/{briefing,trace,signals,sources-footer}.js
Component-per-tag custom elements with reactive props.

Svelte 5 custom-element bundle (legacy primary, served at /legacy):
web/svelte/src/lib/{Briefing,Trace,SourcesFooter}.svelte + main.js
Shared store for cross-component highlight (citeIndex,
highlightedDocId). Built artifact ships at web/static/dist/riprap.js.

SvelteKit design-system v0.4.2 (the new primary):
web/sveltekit/src/* — adapter-static build, IBM Plex, four-tier
glyphs, MapLibre map with custom basemap, server-side render of
/q/sample + /q/<query>. Built artifact ships at web/sveltekit/build/.

web/main.py wired up the new endpoint surface:
/, /q/sample, /q/<query> → SvelteKit build
/legacy, /single, /compare → Svelte custom-element bundle
/api/agent/stream → SSE pipeline (plan/step/token/mellea_attempt
/final/error/done events)
/api/backend → live LLM-backend descriptor for the pill
/api/layers/* → MapLibre tile/polygon endpoints
/api/floodnet_near, /api/register/{class}, /print/{query_id}, etc.

HF Spaces ships pre-built artefacts (no Node at deploy time); rebuild
locally with cd web/sveltekit && npm run build, or cd web/svelte &&
npm run build for the legacy bundle.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. web/main.py +382 -6
  2. web/static/agent.html +523 -0
  3. web/static/agent.js +1391 -0
  4. web/static/app.js +270 -23
  5. web/static/components/briefing.js +133 -0
  6. web/static/components/signals.js +21 -0
  7. web/static/components/sources-footer.js +144 -0
  8. web/static/components/trace.js +87 -0
  9. web/static/dist/riprap.js +0 -0
  10. web/static/dist/riprap.js.map +0 -0
  11. web/static/index.html +1 -1
  12. web/static/report.html +244 -0
  13. web/static/report.js +218 -0
  14. web/static/style.css +50 -3
  15. web/svelte/package-lock.json +1337 -0
  16. web/svelte/package.json +15 -0
  17. web/svelte/src/lib/Briefing.svelte +124 -0
  18. web/svelte/src/lib/SourcesFooter.svelte +105 -0
  19. web/svelte/src/lib/Trace.svelte +109 -0
  20. web/svelte/src/lib/stores.js +10 -0
  21. web/svelte/src/main.js +11 -0
  22. web/svelte/vite.config.js +33 -0
  23. web/sveltekit/.gitignore +11 -0
  24. web/sveltekit/.npmrc +1 -0
  25. web/sveltekit/build/200.html +43 -0
  26. web/sveltekit/build/_app/env.js +1 -0
  27. web/sveltekit/build/_app/immutable/assets/0.KpTzaSsX.css +0 -0
  28. web/sveltekit/build/_app/immutable/assets/3.BZfqQRM0.css +1 -0
  29. web/sveltekit/build/_app/immutable/assets/4.CPUwsEjs.css +1 -0
  30. web/sveltekit/build/_app/immutable/assets/Briefing.Cg0TTl7h.css +1 -0
  31. web/sveltekit/build/_app/immutable/assets/MapLegend.DvDgr167.css +1 -0
  32. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-400-normal.BSMlKf0J.woff2 +0 -0
  33. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-400-normal.CEL4l2ZJ.woff +0 -0
  34. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-500-normal.Ael50iVv.woff +0 -0
  35. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-500-normal.Bq9vWWag.woff2 +0 -0
  36. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-600-normal.CTOM6hUh.woff2 +0 -0
  37. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-600-normal.fLZuRloM.woff +0 -0
  38. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-400-normal.DMdlQ8Kv.woff +0 -0
  39. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-400-normal.xuaO2J-f.woff2 +0 -0
  40. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-500-normal.BIfNGwUT.woff +0 -0
  41. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-500-normal.BqneJy0T.woff2 +0 -0
  42. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-600-normal.9HEixskS.woff +0 -0
  43. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-600-normal.V-xxqcpd.woff2 +0 -0
  44. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-400-normal.CvHOgSBP.woff +0 -0
  45. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2 +0 -0
  46. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-500-normal.CB9ihrfo.woff +0 -0
  47. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2 +0 -0
  48. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-600-normal.BgSNZQsw.woff2 +0 -0
  49. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-600-normal.DWFSQ4vo.woff +0 -0
  50. web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-ext-400-normal.BmRBH3aV.woff2 +0 -0
web/main.py CHANGED
@@ -1,10 +1,11 @@
1
- """HeliOS-NYC web UI — FastAPI + SSE streaming of the Burr FSM trace.
2
 
3
  Run: uvicorn web.main:app --reload --port 8000
4
  """
5
  from __future__ import annotations
6
 
7
  import json
 
8
  import warnings
9
  from pathlib import Path
10
 
@@ -20,14 +21,21 @@ from app.fsm import iter_steps # noqa: E402
20
 
21
  ROOT = Path(__file__).resolve().parent
22
  STATIC = ROOT / "static"
 
23
 
24
  app = FastAPI(title="Riprap")
25
  app.mount("/static", StaticFiles(directory=STATIC), name="static")
26
 
 
 
 
 
 
 
27
  import json as _json # noqa: E402
28
 
29
  import geopandas as _gpd # noqa: E402
30
- from fastapi.responses import JSONResponse, Response # noqa: E402
31
 
32
  _LAYER_CACHE: dict = {}
33
 
@@ -74,13 +82,221 @@ def _warm_caches():
74
  dep_stormwater.load(scen)
75
  print("[startup] flood layers ready", flush=True)
76
  print("[startup] warming RAG (Granite Embedding 278M + 5 PDFs)...", flush=True)
77
- from app import rag
78
- rag.warm()
79
- print("[startup] RAG ready", flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
 
82
  @app.get("/")
83
  def index():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return FileResponse(STATIC / "index.html")
85
 
86
 
@@ -89,6 +305,18 @@ def compare_page():
89
  return FileResponse(STATIC / "compare.html")
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  @app.get("/register/{asset_class}")
93
  def register_page(asset_class: str):
94
  if asset_class not in ("schools", "nycha", "mta_entrances"):
@@ -121,6 +349,7 @@ async def compare_stream(a: str, b: str, request: Request):
121
  route updates to the correct panel."""
122
  import asyncio
123
  import queue
 
124
  from app.fsm import iter_steps
125
 
126
  def gen_for_side(side: str, q_text: str, out_q):
@@ -132,7 +361,7 @@ async def compare_stream(a: str, b: str, request: Request):
132
  out_q.put({"side": side, "kind": "error", "err": str(e)})
133
  out_q.put({"side": side, "kind": "_done"})
134
 
135
- out_q: "queue.Queue[dict]" = queue.Queue()
136
 
137
  def kick():
138
  # run both sides in parallel threads — each Burr Application owns
@@ -187,6 +416,153 @@ async def stream(q: str, request: Request):
187
  "X-Accel-Buffering": "no"})
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  @app.get("/api/layers/sandy")
191
  def layer_sandy(lat: float, lon: float, r: float = 1500):
192
  key = ("sandy", round(lat, 4), round(lon, 4), int(r))
 
1
+ """Riprap web UI — FastAPI + SSE streaming of the Burr FSM trace.
2
 
3
  Run: uvicorn web.main:app --reload --port 8000
4
  """
5
  from __future__ import annotations
6
 
7
  import json
8
+ import os
9
  import warnings
10
  from pathlib import Path
11
 
 
21
 
22
  ROOT = Path(__file__).resolve().parent
23
  STATIC = ROOT / "static"
24
+ SVELTEKIT_BUILD = ROOT / "sveltekit" / "build"
25
 
26
  app = FastAPI(title="Riprap")
27
  app.mount("/static", StaticFiles(directory=STATIC), name="static")
28
 
29
+ # SvelteKit static build (adapter-static). Serves the new design-system UI
30
+ # from /, /q/sample, /q/<query>. The legacy custom-element pages remain at
31
+ # /legacy, /single, /compare, /register/* for as long as they're useful.
32
+ if SVELTEKIT_BUILD.exists():
33
+ app.mount("/_app", StaticFiles(directory=SVELTEKIT_BUILD / "_app"), name="sveltekit_assets")
34
+
35
  import json as _json # noqa: E402
36
 
37
  import geopandas as _gpd # noqa: E402
38
+ from fastapi.responses import JSONResponse # noqa: E402
39
 
40
  _LAYER_CACHE: dict = {}
41
 
 
82
  dep_stormwater.load(scen)
83
  print("[startup] flood layers ready", flush=True)
84
  print("[startup] warming RAG (Granite Embedding 278M + 5 PDFs)...", flush=True)
85
+ # RAG warm loads sentence-transformers, which on some HF Space rebuilds
86
+ # has hit transformers-lazy-import edge cases (CodeCarbonCallback). The
87
+ # Space *must* start even if RAG fails — the FSM still works without
88
+ # RAG citations (specialists deliver their own grounded data, and the
89
+ # rag step in fsm.py already handles `rag=[]` gracefully). Surface the
90
+ # failure loudly in logs but don't kill the app.
91
+ try:
92
+ from app import rag
93
+ rag.warm()
94
+ print("[startup] RAG ready", flush=True)
95
+ except Exception as e: # noqa: BLE001
96
+ print(f"[startup] RAG warm FAILED — continuing without RAG: "
97
+ f"{type(e).__name__}: {e}", flush=True)
98
+ import traceback
99
+ traceback.print_exc()
100
+ # Pre-import the heavy EO/ML stacks on the main thread so the
101
+ # parallel-fanout workers don't race each other on first
102
+ # import (sklearn's "partially initialized module" surfaces as a
103
+ # spurious ImportError when terratorch / tsfm_public both pull
104
+ # sklearn concurrently from worker threads).
105
+ # Warm the Ollama LLM models so the first user query doesn't pay a
106
+ # cold-load penalty (~70 s for the 3B planner, ~12 s for the 8B
107
+ # reconciler at Q4_K_M). Sets keep_alive to 24 h so they stay
108
+ # resident across queries. Both calls use num_ctx that matches the
109
+ # production call sites (Mellea's 4096), so Ollama's KV cache is
110
+ # pre-allocated at the right size and the first reconcile doesn't
111
+ # pay an extra grow-and-reinit cost.
112
+ if os.environ.get("RIPRAP_SKIP_LLM_WARM", "").lower() not in ("1", "true", "yes"):
113
+ print("[startup] warming Ollama models (granite4.1:3b + 8b)...",
114
+ flush=True)
115
+ try:
116
+ import httpx as _httpx
117
+ base = os.environ.get(
118
+ "OLLAMA_BASE_URL",
119
+ os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
120
+ )
121
+ if not base.startswith("http"):
122
+ base = "http://" + base
123
+ keep_alive = os.environ.get("OLLAMA_KEEP_ALIVE", "24h")
124
+ num_ctx = int(os.environ.get("RIPRAP_MELLEA_NUM_CTX", "4096"))
125
+ for tag in (os.environ.get("RIPRAP_OLLAMA_3B_TAG", "granite4.1:3b"),
126
+ os.environ.get("RIPRAP_OLLAMA_8B_TAG", "granite4.1:8b")):
127
+ try:
128
+ r = _httpx.post(
129
+ base.rstrip("/") + "/api/generate",
130
+ json={
131
+ "model": tag,
132
+ "prompt": "hi",
133
+ "stream": False,
134
+ "keep_alive": keep_alive,
135
+ "options": {"num_ctx": num_ctx, "num_predict": 1},
136
+ },
137
+ timeout=180,
138
+ )
139
+ if r.status_code == 200:
140
+ load_s = r.json().get("load_duration", 0) / 1e9
141
+ print(f"[startup] {tag} loaded "
142
+ f"(load_duration={load_s:.1f}s, "
143
+ f"keep_alive={keep_alive}, num_ctx={num_ctx})",
144
+ flush=True)
145
+ else:
146
+ print(f"[startup] {tag} warm failed "
147
+ f"({r.status_code})", flush=True)
148
+ except Exception as warm_err:
149
+ print(f"[startup] {tag} warm failed: {warm_err}",
150
+ flush=True)
151
+ except Exception as e:
152
+ print(f"[startup] LLM warm skipped: {e}", flush=True)
153
+ print("[startup] pre-importing terratorch + tsfm_public...", flush=True)
154
+ try:
155
+ import sklearn # noqa: F401 prime sklearn first
156
+ import terratorch # noqa: F401
157
+ import tsfm_public # noqa: F401
158
+ except Exception as e:
159
+ print(f"[startup] heavy-EO pre-import skipped: {e}", flush=True)
160
+ # Warm the TerraMind specialist so first per-query call is just
161
+ # the diffusion (~3 s), not model load (~30 s). No-ops if deps
162
+ # are missing on this deployment.
163
+ try:
164
+ from app.context import terramind_synthesis
165
+ terramind_synthesis.warm()
166
+ print("[startup] TerraMind ready", flush=True)
167
+ except Exception as e:
168
+ print(f"[startup] TerraMind warm skipped: {e}", flush=True)
169
+
170
+
171
+ @app.get("/api/debug/eo")
172
+ def api_debug_eo():
173
+ """Diagnostic for the EO toolchain (Phase 1 + Phase 4) on HF Spaces.
174
+
175
+ Surfaces sys.path, PYTHONPATH, and per-module import status so we
176
+ can tell whether terratorch is actually findable from inside the
177
+ uvicorn process. Used to debug why the runtime --target install
178
+ appears to succeed in the entrypoint but isn't visible to the
179
+ FSM specialists at request time.
180
+ """
181
+ import os
182
+ import sys
183
+ import traceback
184
+ from pathlib import Path
185
+
186
+ out = {
187
+ "python_executable": sys.executable,
188
+ "python_version": sys.version,
189
+ "PYTHONPATH": os.environ.get("PYTHONPATH"),
190
+ "PYTHONNOUSERSITE": os.environ.get("PYTHONNOUSERSITE"),
191
+ "HOME": os.environ.get("HOME"),
192
+ "sys.path": sys.path,
193
+ }
194
+ eo_dir = Path(os.environ.get("HOME", "/home/user")) / ".eo-pkgs"
195
+ out["eo_dir"] = str(eo_dir)
196
+ out["eo_dir_exists"] = eo_dir.exists()
197
+ if eo_dir.exists():
198
+ out["eo_dir_contents"] = sorted(p.name for p in eo_dir.iterdir())[:50]
199
+ out["modules"] = {}
200
+ for name in ("terratorch", "einops", "diffusers", "timm",
201
+ "rasterio", "planetary_computer", "pystac_client"):
202
+ try:
203
+ mod = __import__(name)
204
+ out["modules"][name] = {"ok": True,
205
+ "file": getattr(mod, "__file__", "?")}
206
+ except Exception as e:
207
+ out["modules"][name] = {"ok": False,
208
+ "err": f"{type(e).__name__}: {e}",
209
+ "tb": traceback.format_exc().splitlines()[-3:]}
210
+ return JSONResponse(out)
211
+
212
+
213
+ @app.get("/api/backend")
214
+ async def api_backend():
215
+ """Live LLM-backend descriptor for the UI's hardware badge.
216
+
217
+ Returns the configured primary (vLLM/AMD or Ollama/local), plus a
218
+ quick reachability ping so the badge can show whether the primary is
219
+ actually answering or whether the Router is on the fallback path.
220
+ """
221
+ import httpx
222
+
223
+ from app import llm
224
+ info = llm.backend_info()
225
+ reachable = None
226
+ try:
227
+ if info["primary"] in ("vllm", "mlx") and info["vllm_base_url"]:
228
+ url = info["vllm_base_url"].rstrip("/") + "/models"
229
+ async with httpx.AsyncClient(timeout=2.5) as client:
230
+ r = await client.get(url, headers={"Authorization": "Bearer ping"})
231
+ # vLLM and mlx_lm.server both return 200 on /v1/models when
232
+ # reachable; vLLM may return 401 with --api-key set. Either
233
+ # proves the server is up. Anything else = unreachable.
234
+ reachable = r.status_code in (200, 401)
235
+ else:
236
+ url = info["ollama_base_url"].rstrip("/") + "/api/tags"
237
+ async with httpx.AsyncClient(timeout=2.5) as client:
238
+ r = await client.get(url)
239
+ reachable = r.status_code == 200
240
+ except Exception:
241
+ reachable = False
242
+ info["reachable"] = reachable
243
+ info["effective_engine"] = (
244
+ info["engine"] if reachable
245
+ else (info.get("fallback_engine") or "offline")
246
+ )
247
+ return JSONResponse(info)
248
 
249
 
250
  @app.get("/")
251
  def index():
252
+ """SvelteKit cold-start page (the new design-system UI). Falls back to
253
+ the legacy custom-element agent.html if the SvelteKit build hasn't been
254
+ compiled yet — that lets `uvicorn` boot in a fresh checkout without a
255
+ Node toolchain present."""
256
+ sk = SVELTEKIT_BUILD / "index.html"
257
+ if sk.exists():
258
+ return FileResponse(sk)
259
+ return FileResponse(STATIC / "agent.html")
260
+
261
+
262
+ @app.get("/q/sample")
263
+ def q_sample_page():
264
+ """The prerendered Red Hook demo briefing (no SSE)."""
265
+ sk = SVELTEKIT_BUILD / "q" / "sample.html"
266
+ if sk.exists():
267
+ return FileResponse(sk)
268
+ return JSONResponse({"error": "sveltekit build not present"}, status_code=503)
269
+
270
+
271
+ @app.get("/q/{query_id}")
272
+ def q_query_page(query_id: str): # noqa: ARG001 — captured for the SPA router
273
+ """Live briefing route. Served by the SvelteKit SPA fallback (200.html);
274
+ the client opens an EventSource to /api/agent/stream."""
275
+ sk = SVELTEKIT_BUILD / "200.html"
276
+ if sk.exists():
277
+ return FileResponse(sk)
278
+ return JSONResponse({"error": "sveltekit build not present"}, status_code=503)
279
+
280
+
281
+ @app.get("/print/{query_id}")
282
+ def print_page(query_id: str): # noqa: ARG001 — captured by the SPA router
283
+ """Curated print artifact for a completed briefing. The client
284
+ hydrates from localStorage (key riprap:print:<query_id>) and
285
+ auto-fires window.print() — no backend round-trip."""
286
+ sk = SVELTEKIT_BUILD / "200.html"
287
+ if sk.exists():
288
+ return FileResponse(sk)
289
+ return JSONResponse({"error": "sveltekit build not present"}, status_code=503)
290
+
291
+
292
+ @app.get("/legacy")
293
+ def legacy_index():
294
+ """Original custom-element agent page, preserved for fallback / debugging."""
295
+ return FileResponse(STATIC / "agent.html")
296
+
297
+
298
+ @app.get("/single")
299
+ def single_address_page():
300
  return FileResponse(STATIC / "index.html")
301
 
302
 
 
305
  return FileResponse(STATIC / "compare.html")
306
 
307
 
308
+ @app.get("/agent")
309
+ def agent_page():
310
+ return FileResponse(STATIC / "agent.html")
311
+
312
+
313
+ @app.get("/report")
314
+ def report_page():
315
+ """Print-ready auditable report. Reads the prior agent run from
316
+ the browser's sessionStorage; fully client-side render."""
317
+ return FileResponse(STATIC / "report.html")
318
+
319
+
320
  @app.get("/register/{asset_class}")
321
  def register_page(asset_class: str):
322
  if asset_class not in ("schools", "nycha", "mta_entrances"):
 
349
  route updates to the correct panel."""
350
  import asyncio
351
  import queue
352
+
353
  from app.fsm import iter_steps
354
 
355
  def gen_for_side(side: str, q_text: str, out_q):
 
361
  out_q.put({"side": side, "kind": "error", "err": str(e)})
362
  out_q.put({"side": side, "kind": "_done"})
363
 
364
+ out_q: queue.Queue[dict] = queue.Queue()
365
 
366
  def kick():
367
  # run both sides in parallel threads — each Burr Application owns
 
416
  "X-Accel-Buffering": "no"})
417
 
418
 
419
+ @app.get("/api/agent")
420
+ def api_agent(q: str):
421
+ """Agentic endpoint: take a natural-language query, plan it via
422
+ Granite 4.1, dispatch to the appropriate intent module, return the
423
+ full result as JSON. The Plan is included so callers can see the
424
+ agent's routing decision.
425
+
426
+ All non-trivial reconciliation (single_address / neighborhood /
427
+ development_check) routes through Mellea-validated rejection
428
+ sampling against four grounding requirements. live_now stays on
429
+ streaming reconcile because outputs are short and the live signals
430
+ have low hallucination surface."""
431
+ from app.intents import development_check as i_dev
432
+ from app.intents import live_now as i_live
433
+ from app.intents import neighborhood as i_nbhd
434
+ from app.intents import single_address as i_addr
435
+ from app.planner import plan as run_planner
436
+ p = run_planner(q)
437
+ if p.intent == "development_check":
438
+ out = i_dev.run(p, q, strict=True)
439
+ elif p.intent == "neighborhood":
440
+ out = i_nbhd.run(p, q, strict=True)
441
+ elif p.intent == "live_now":
442
+ out = i_live.run(p, q)
443
+ else:
444
+ out = i_addr.run(p, q, strict=True)
445
+ return JSONResponse(out)
446
+
447
+
448
+ @app.get("/api/agent/stream")
449
+ async def api_agent_stream(q: str):
450
+ """SSE: emit `plan` once the planner finishes, then a `step` event per
451
+ finalized specialist, then `final` with the full result. The intent
452
+ runs in a thread; we marshal events through a queue."""
453
+ import asyncio
454
+ import queue
455
+ out_q: queue.Queue[dict] = queue.Queue()
456
+
457
+ def runner():
458
+ try:
459
+ from app.intents import development_check as i_dev
460
+ from app.intents import live_now as i_live
461
+ from app.intents import neighborhood as i_nbhd
462
+ from app.intents import single_address as i_addr
463
+ from app.planner import plan as run_planner
464
+
465
+ def _on_plan_token(delta: str):
466
+ out_q.put({"kind": "plan_token", "delta": delta})
467
+ p = run_planner(q, on_token=_on_plan_token)
468
+ out_q.put({"kind": "plan",
469
+ "intent": p.intent,
470
+ "targets": p.targets,
471
+ "specialists": p.specialists,
472
+ "rationale": p.rationale})
473
+ if p.intent == "development_check":
474
+ final = i_dev.run(p, q, progress_q=out_q, strict=True)
475
+ elif p.intent == "neighborhood":
476
+ final = i_nbhd.run(p, q, progress_q=out_q, strict=True)
477
+ elif p.intent == "live_now":
478
+ final = i_live.run(p, q, progress_q=out_q)
479
+ else:
480
+ final = i_addr.run(p, q, progress_q=out_q, strict=True)
481
+ out_q.put({"kind": "final", **final})
482
+ except Exception as e:
483
+ out_q.put({"kind": "error", "err": str(e)})
484
+ finally:
485
+ out_q.put({"kind": "_done"})
486
+
487
+ async def event_stream():
488
+ loop = asyncio.get_event_loop()
489
+ loop.run_in_executor(None, runner)
490
+ yield f"event: hello\ndata: {json.dumps({'query': q})}\n\n"
491
+ while True:
492
+ try:
493
+ ev = await asyncio.to_thread(out_q.get, True, 1.0)
494
+ except Exception:
495
+ continue
496
+ kind = ev.get("kind")
497
+ if kind == "_done":
498
+ break
499
+ yield f"event: {kind}\ndata: {json.dumps(ev, default=str)}\n\n"
500
+ yield "event: done\ndata: {}\n\n"
501
+
502
+ return StreamingResponse(event_stream(), media_type="text/event-stream",
503
+ headers={"Cache-Control": "no-cache",
504
+ "X-Accel-Buffering": "no"})
505
+
506
+
507
+ @app.get("/api/agent/plan")
508
+ def api_agent_plan(q: str):
509
+ """Just the plan, no execution. Useful for showing the agent's routing
510
+ decision before running specialists."""
511
+ from app.planner import plan as run_planner
512
+ p = run_planner(q)
513
+ return JSONResponse({
514
+ "intent": p.intent,
515
+ "targets": p.targets,
516
+ "specialists": p.specialists,
517
+ "rationale": p.rationale,
518
+ })
519
+
520
+
521
+ @app.get("/api/layers/nta")
522
+ def layer_nta(code: str):
523
+ """Return the NTA polygon for a given NTA code as GeoJSON (EPSG:4326)."""
524
+ from app.areas import nta as nta_mod
525
+ g = nta_mod.load()
526
+ sub = g[g["nta2020"] == code][["nta2020", "ntaname", "boroname", "geometry"]]
527
+ if sub.empty:
528
+ return JSONResponse({"type": "FeatureCollection", "features": []}, status_code=404)
529
+ return JSONResponse(_json.loads(sub.to_json()),
530
+ headers={"Cache-Control": "public, max-age=3600"})
531
+
532
+
533
+ @app.get("/api/layers/sandy_clipped")
534
+ def layer_sandy_clipped(code: str):
535
+ """Sandy inundation polygons clipped to an NTA bbox + simplified.
536
+ Used by the agent map for neighborhood / development_check intents."""
537
+ from app.areas import nta as nta_mod
538
+ from app.flood_layers import sandy_inundation
539
+ poly = nta_mod.polygon_for(code)
540
+ if poly is None:
541
+ return JSONResponse({"type": "FeatureCollection", "features": []})
542
+ bounds = poly.bounds
543
+ cx, cy = (bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2
544
+ # bbox half-extent in metres ~ half the polygon span × 111 km/deg
545
+ half_m = max((bounds[2] - bounds[0]), (bounds[3] - bounds[1])) / 2 * 111_000
546
+ return JSONResponse(_clip_simplify(sandy_inundation.load(), cy, cx, half_m * 1.2),
547
+ headers={"Cache-Control": "public, max-age=600"})
548
+
549
+
550
+ @app.get("/api/layers/dep_clipped")
551
+ def layer_dep_clipped(code: str, scenario: str = "dep_extreme_2080"):
552
+ """DEP scenario polygons clipped to an NTA bbox + simplified."""
553
+ from app.areas import nta as nta_mod
554
+ from app.flood_layers import dep_stormwater
555
+ poly = nta_mod.polygon_for(code)
556
+ if poly is None:
557
+ return JSONResponse({"type": "FeatureCollection", "features": []})
558
+ bounds = poly.bounds
559
+ cx, cy = (bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2
560
+ half_m = max((bounds[2] - bounds[0]), (bounds[3] - bounds[1])) / 2 * 111_000
561
+ return JSONResponse(_clip_simplify(dep_stormwater.load(scenario), cy, cx, half_m * 1.2,
562
+ props_keep={"Flooding_Category"}),
563
+ headers={"Cache-Control": "public, max-age=600"})
564
+
565
+
566
  @app.get("/api/layers/sandy")
567
  def layer_sandy(lat: float, lon: float, r: float = 1500):
568
  key = ("sandy", round(lat, 4), round(lon, 4), int(r))
web/static/agent.html ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Riprap — agent</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
8
+ <link rel="stylesheet" href="/static/style.css">
9
+ <style>
10
+ .agent-topbar-bar {
11
+ max-width: 1640px; margin: 14px auto 8px; padding: 0 20px;
12
+ display: flex; gap: 8px; align-items: center;
13
+ }
14
+ .agent-input-form {
15
+ flex: 1; display: flex; gap: 8px;
16
+ border: 1px solid var(--line); border-radius: 4px;
17
+ background: var(--panel); padding: 6px;
18
+ }
19
+ .agent-input-form input {
20
+ flex: 1; border: 0; outline: 0; padding: 10px 12px;
21
+ font-size: 14.5px; background: transparent; color: var(--text);
22
+ font-family: inherit;
23
+ }
24
+ .agent-input-form button {
25
+ padding: 8px 18px; border: 0; border-radius: 3px;
26
+ background: var(--nyc-blue); color: #fff; font-weight: 600;
27
+ cursor: pointer; font-size: 13px; font-family: inherit;
28
+ }
29
+ .agent-input-form button:disabled { opacity: 0.6; cursor: wait; }
30
+
31
+ /* Mellea compliance badge in the briefing header */
32
+ .mellea-badge {
33
+ display: inline-block; margin-left: 8px;
34
+ padding: 2px 9px; border-radius: 999px;
35
+ font-size: 10.5px; font-weight: 700;
36
+ font-family: var(--mono); letter-spacing: 0.03em;
37
+ vertical-align: middle;
38
+ color: white;
39
+ /* Bloom in once when the badge is rendered. transform-origin keeps the
40
+ scale anchored at the left edge so it doesn't push neighbors. */
41
+ animation: mellea-bloom 380ms cubic-bezier(.2,.7,.3,1.4);
42
+ transform-origin: left center;
43
+ }
44
+ @keyframes mellea-bloom {
45
+ 0% { transform: scale(0.5); opacity: 0; }
46
+ 60% { transform: scale(1.08); opacity: 1; }
47
+ 100% { transform: scale(1); opacity: 1; }
48
+ }
49
+ /* Inline banner that appears between the briefing header and the prose
50
+ when Mellea is about to reroll (or when it confirms first-try pass). */
51
+ .mellea-banner {
52
+ margin: 0 16px 8px; padding: 8px 12px;
53
+ border-radius: 4px; font-size: 11.5px;
54
+ font-family: var(--mono);
55
+ border: 1px solid transparent;
56
+ animation: mellea-bloom 280ms cubic-bezier(.2,.7,.3,1.1);
57
+ transform-origin: left center;
58
+ }
59
+ .mellea-banner.reroll {
60
+ background: rgba(217, 119, 6, 0.10);
61
+ border-color: rgba(217, 119, 6, 0.35);
62
+ color: #92400e;
63
+ }
64
+ .mellea-banner.pass {
65
+ background: rgba(26, 135, 84, 0.10);
66
+ border-color: rgba(26, 135, 84, 0.35);
67
+ color: #1a5e3a;
68
+ }
69
+ .mellea-banner code {
70
+ background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 3px;
71
+ font-size: 10.5px;
72
+ }
73
+ .mellea-badge.full { background: #1a8754; } /* 4/4 */
74
+ .mellea-badge.partial { background: #d97706; } /* 1-3/4 */
75
+ .mellea-badge.none { background: var(--nyc-scarlet); } /* 0/4 */
76
+ .mellea-badge .ico { font-size: 9px; margin-right: 3px; }
77
+ .agent-samples {
78
+ max-width: 1640px; margin: 4px auto 12px; padding: 0 20px;
79
+ display: flex; flex-wrap: wrap; gap: 8px;
80
+ }
81
+ .agent-samples .label {
82
+ font-size: 11px; color: var(--text-muted);
83
+ letter-spacing: 0.05em; text-transform: uppercase;
84
+ align-self: center; margin-right: 4px;
85
+ }
86
+ .sample-btn {
87
+ display: inline-flex; align-items: center; gap: 6px;
88
+ padding: 6px 11px; border: 1px solid var(--line);
89
+ background: var(--panel); border-radius: 999px;
90
+ font-size: 12px; color: var(--text); cursor: pointer;
91
+ font-family: inherit;
92
+ transition: background 0.12s, border-color 0.12s;
93
+ }
94
+ .sample-btn:hover { background: var(--bg-soft); border-color: var(--nyc-blue); }
95
+ .sample-btn .pill {
96
+ padding: 1px 7px; border-radius: 999px;
97
+ font-size: 9.5px; font-weight: 700;
98
+ letter-spacing: 0.05em; text-transform: uppercase;
99
+ }
100
+ .sample-btn .pill.live { background: #1a8754; color: white; }
101
+ .sample-btn .pill.addr { background: #6b7280; color: white; }
102
+ .sample-btn .pill.nbhd { background: #1642DF; color: white; }
103
+ .sample-btn .pill.dev { background: #af3a03; color: white; }
104
+ .sample-btn .qtxt {
105
+ white-space: nowrap; overflow: hidden;
106
+ text-overflow: ellipsis; max-width: 280px;
107
+ }
108
+
109
+ /* ---- planner box (full-width above the 3 panels) ---- */
110
+ .planner-row {
111
+ max-width: 1640px; margin: 0 auto 12px; padding: 0 20px;
112
+ }
113
+ .planner-box {
114
+ background: var(--bg-soft); border: 1px solid var(--line);
115
+ border-radius: 4px; padding: 10px 14px;
116
+ display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px;
117
+ font-size: 12px;
118
+ }
119
+ .planner-key {
120
+ color: var(--text-muted); font-weight: 700;
121
+ text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em;
122
+ }
123
+ .planner-val { font-family: var(--mono); font-size: 11.5px; }
124
+ .planner-rationale {
125
+ grid-column: 1 / -1;
126
+ color: var(--text-muted); font-style: italic; margin-top: 4px; font-size: 11.5px;
127
+ }
128
+ .intent-pill {
129
+ display: inline-block; padding: 1px 9px; border-radius: 999px;
130
+ background: var(--nyc-blue); color: white; font-size: 10px; font-weight: 700;
131
+ text-transform: uppercase; letter-spacing: 0.05em;
132
+ }
133
+ .intent-pill.dev { background: #af3a03; }
134
+ .intent-pill.live { background: #1a8754; }
135
+ .intent-pill.nbhd { background: #1642DF; }
136
+ .intent-pill.addr { background: #6b7280; }
137
+
138
+ /* ---- loading skeletons ---- */
139
+ @keyframes pulse {
140
+ 0%, 100% { background-color: var(--bg-soft); }
141
+ 50% { background-color: rgba(22, 66, 223, 0.08); }
142
+ }
143
+ .skel {
144
+ background: var(--bg-soft); border-radius: 3px;
145
+ animation: pulse 1.6s ease-in-out infinite;
146
+ }
147
+ .skel-line { height: 12px; margin: 6px 0; }
148
+ .skel-line.w-100 { width: 100%; }
149
+ .skel-line.w-80 { width: 80%; }
150
+ .skel-line.w-60 { width: 60%; }
151
+ .skel-line.w-40 { width: 40%; }
152
+ .skel-pad { padding: 14px 16px; }
153
+
154
+ .loading-overlay {
155
+ position: relative;
156
+ pointer-events: none;
157
+ }
158
+ .loading-overlay::after {
159
+ content: ""; position: absolute; inset: 0;
160
+ background: rgba(255,255,255,0.55);
161
+ backdrop-filter: blur(0.5px);
162
+ }
163
+
164
+ .map-loading {
165
+ position: absolute; left: 50%; top: 50%;
166
+ transform: translate(-50%, -50%);
167
+ background: var(--panel); border: 1px solid var(--line);
168
+ border-radius: 4px; padding: 8px 14px;
169
+ font-size: 11.5px; color: var(--text-muted);
170
+ z-index: 10; pointer-events: none;
171
+ display: flex; align-items: center; gap: 8px;
172
+ }
173
+ .map-loading .dot {
174
+ width: 6px; height: 6px; border-radius: 50%;
175
+ background: var(--nyc-blue);
176
+ animation: dotpulse 1.2s ease-in-out infinite;
177
+ }
178
+ @keyframes dotpulse {
179
+ 0%, 100% { opacity: 0.3; transform: scale(0.85); }
180
+ 50% { opacity: 1; transform: scale(1.1); }
181
+ }
182
+
183
+ /* ---- map legend (intent-aware) ---- */
184
+ .map-legend {
185
+ position: absolute; left: 10px; bottom: 10px;
186
+ background: rgba(255,255,255,0.95);
187
+ border: 1px solid var(--line); border-radius: 4px;
188
+ padding: 8px 12px; font-size: 11px; color: var(--text);
189
+ box-shadow: 0 2px 6px rgba(0,0,0,0.06);
190
+ pointer-events: none;
191
+ z-index: 5;
192
+ }
193
+ .map-legend .legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
194
+ .legend-swatch {
195
+ width: 12px; height: 12px; border-radius: 50%;
196
+ border: 1.5px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.08);
197
+ }
198
+ .legend-swatch.fill {
199
+ width: 14px; height: 10px; border-radius: 2px; box-shadow: none; border: 0;
200
+ }
201
+
202
+ /* ---- briefing header (matches the report-head idiom from /) ---- */
203
+ .brief-head {
204
+ padding: 14px 16px;
205
+ border-bottom: 1px solid var(--line);
206
+ background: linear-gradient(180deg, var(--bg-soft) 0%, #fff 100%);
207
+ }
208
+ .brief-eyebrow {
209
+ font-size: 10px; font-weight: 700;
210
+ letter-spacing: 0.10em; text-transform: uppercase;
211
+ color: var(--nyc-blue);
212
+ }
213
+ .brief-title {
214
+ margin-top: 4px;
215
+ font-size: 16px; font-weight: 600;
216
+ line-height: 1.25; color: var(--text);
217
+ }
218
+ .brief-meta {
219
+ margin-top: 6px;
220
+ font-family: var(--mono); font-size: 11px;
221
+ color: var(--text-muted);
222
+ display: flex; flex-wrap: wrap; gap: 4px 10px;
223
+ }
224
+ .brief-meta-k {
225
+ text-transform: uppercase; font-size: 9.5px;
226
+ letter-spacing: 0.05em; color: var(--text-faint);
227
+ }
228
+ .brief-meta-v { color: var(--text); }
229
+
230
+ .report-btn {
231
+ display: none; /* shown by JS once a query completes */
232
+ margin-top: 10px; padding: 6px 12px;
233
+ border: 1px solid var(--nyc-blue);
234
+ background: var(--panel); color: var(--nyc-blue);
235
+ border-radius: 3px; cursor: pointer; font-size: 12px;
236
+ font-weight: 600; font-family: inherit;
237
+ transition: background 0.12s, color 0.12s;
238
+ }
239
+ .report-btn:hover { background: var(--nyc-blue); color: white; }
240
+ .report-btn.ready { display: inline-block; }
241
+
242
+ /* tier badge inline with the title — single_address intent only.
243
+ Mirrors the colour idiom from the legacy /single page tier-badge. */
244
+ .tier-chip {
245
+ display: inline-block;
246
+ margin-left: 8px;
247
+ padding: 2px 10px;
248
+ border-radius: 999px;
249
+ font-size: 11px; font-weight: 700;
250
+ font-family: var(--mono); letter-spacing: 0.04em;
251
+ vertical-align: middle;
252
+ color: white;
253
+ }
254
+ .tier-chip.t-0 { background: var(--good); }
255
+ .tier-chip.t-1 { background: var(--nyc-scarlet); }
256
+ .tier-chip.t-2 { background: #d97706; }
257
+ .tier-chip.t-3 { background: #ca8a04; }
258
+ .tier-chip.t-4 { background: var(--nyc-blue); }
259
+ .tier-floor {
260
+ font-size: 9.5px; font-weight: 600;
261
+ background: rgba(255,255,255,0.22);
262
+ padding: 1px 5px; border-radius: 6px; margin-left: 4px;
263
+ }
264
+
265
+ /* ---- streaming caret on the briefing while tokens flow ---- */
266
+ .streaming::after {
267
+ content: "▋";
268
+ display: inline-block; color: var(--nyc-blue);
269
+ margin-left: 2px;
270
+ animation: caret 0.9s steps(1) infinite;
271
+ }
272
+
273
+ /* ---- citation chips + Sources footer ---- */
274
+ .report-pane #paragraph .cite {
275
+ cursor: pointer;
276
+ transition: background 0.15s, color 0.15s;
277
+ }
278
+ .report-pane #paragraph .cite:hover,
279
+ .report-pane #paragraph .cite.hl {
280
+ background: var(--nyc-blue) !important;
281
+ color: white !important;
282
+ }
283
+ #sourcesSection {
284
+ border-top: 1px solid var(--line);
285
+ background: var(--bg-soft);
286
+ padding: 12px 16px 14px;
287
+ }
288
+ #sourcesSection .src-h {
289
+ font-size: 10px; font-weight: 700;
290
+ text-transform: uppercase; letter-spacing: 0.10em;
291
+ color: var(--text-muted);
292
+ margin: 0 0 8px;
293
+ }
294
+ #sourcesSection ol {
295
+ margin: 0; padding: 0; list-style: none;
296
+ display: grid; gap: 6px;
297
+ font-size: 11.5px; line-height: 1.45;
298
+ }
299
+ #sourcesSection ol li {
300
+ display: grid; grid-template-columns: 22px 1fr;
301
+ gap: 8px; align-items: baseline;
302
+ padding: 4px 6px; border-radius: 3px;
303
+ transition: background 0.15s;
304
+ }
305
+ #sourcesSection ol li.hl { background: rgba(22, 66, 223, 0.10); }
306
+ #sourcesSection .src-num {
307
+ font-family: var(--mono); font-size: 10.5px;
308
+ font-weight: 700; color: var(--nyc-blue);
309
+ text-align: right;
310
+ }
311
+ #sourcesSection .src-label { color: var(--text); }
312
+ #sourcesSection .src-link {
313
+ color: var(--text); text-decoration: none;
314
+ border-bottom: 1px dotted var(--text-muted);
315
+ transition: color 0.12s, border-color 0.12s;
316
+ }
317
+ #sourcesSection .src-link:hover {
318
+ color: var(--nyc-blue);
319
+ border-bottom-color: var(--nyc-blue);
320
+ }
321
+ #sourcesSection .src-ext {
322
+ font-size: 9.5px; color: var(--text-faint);
323
+ margin-left: 2px; vertical-align: super;
324
+ }
325
+ #sourcesSection .src-id {
326
+ font-family: var(--mono); font-size: 10px;
327
+ color: var(--text-faint); margin-left: 6px;
328
+ }
329
+
330
+ /* live-streaming planner output (raw JSON forming character-by-character) */
331
+ .planner-streaming {
332
+ background: var(--bg-soft); border: 1px solid var(--line);
333
+ border-radius: 4px; padding: 10px 14px;
334
+ font-family: var(--mono); font-size: 11px; color: var(--text-muted);
335
+ line-height: 1.5; white-space: pre-wrap; word-break: break-word;
336
+ max-height: 160px; overflow: auto;
337
+ position: relative;
338
+ }
339
+ .planner-streaming::before {
340
+ content: "Planner thinking…";
341
+ position: absolute; top: 6px; right: 10px;
342
+ font-family: inherit; font-size: 9.5px;
343
+ color: var(--text-faint); letter-spacing: 0.06em;
344
+ text-transform: uppercase;
345
+ }
346
+ .planner-streaming::after {
347
+ content: "▋";
348
+ display: inline-block; color: var(--nyc-blue);
349
+ animation: caret 0.9s steps(1) infinite;
350
+ }
351
+ @keyframes caret { 50% { opacity: 0; } }
352
+
353
+ #map { width: 100%; height: 600px; border: 1px solid var(--line); border-radius: 4px; }
354
+
355
+ /* ---- structured report ---- */
356
+ .report-pane #paragraph .rsum-h {
357
+ margin: 12px 0 6px; font-size: 10.5px; font-weight: 700;
358
+ text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted);
359
+ }
360
+ .report-pane #paragraph .rsum-h:first-child { margin-top: 0; }
361
+ .report-pane #paragraph .rsum-p { margin: 0 0 6px; line-height: 1.55; font-size: 13px; }
362
+ .report-pane #paragraph .rsum-list { margin: 4px 0 8px 0; padding: 0; list-style: none; }
363
+ .report-pane #paragraph .rsum-list li {
364
+ display: block; padding: 8px 10px; margin: 4px 0;
365
+ background: var(--bg-soft); border-left: 3px solid var(--nyc-blue);
366
+ border-radius: 0 3px 3px 0; font-size: 12.5px; line-height: 1.5;
367
+ }
368
+ .report-pane #paragraph strong {
369
+ font-weight: 600;
370
+ background: linear-gradient(transparent 60%, var(--nyc-blue-soft) 60%);
371
+ padding: 0 2px;
372
+ }
373
+ .report-pane #paragraph .cite {
374
+ display: inline-block; vertical-align: super; font-size: 9.5px;
375
+ font-family: var(--mono); padding: 0 5px; margin-left: 2px;
376
+ background: var(--bg-soft); border-radius: 8px;
377
+ color: var(--text-muted);
378
+ }
379
+
380
+ /* ---- intent-specific facts panel ---- */
381
+ .facts-grid {
382
+ display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px;
383
+ margin: 8px 0; font-size: 12px;
384
+ }
385
+ .facts-grid dt {
386
+ color: var(--text-muted); font-size: 10.5px;
387
+ text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700;
388
+ }
389
+ .facts-grid dd { margin: 0; font-family: var(--mono); }
390
+ .headline-stat {
391
+ font-size: 26px; font-weight: 700; color: var(--text);
392
+ margin: 6px 0 2px; line-height: 1.1;
393
+ }
394
+ .headline-sub {
395
+ color: var(--text-muted); font-size: 12px; margin-bottom: 8px;
396
+ }
397
+ </style>
398
+ </head>
399
+ <body>
400
+ <header class="topbar">
401
+ <div class="topbar-inner">
402
+ <div class="brand">
403
+ <span class="brand-name">Riprap</span>
404
+ <span class="brand-sep">·</span>
405
+ <span class="brand-tag">citation-grounded flood-exposure briefings for NYC</span>
406
+ </div>
407
+ <div class="topbar-right">
408
+ <span id="backendPill" class="local-pill" data-state="loading"
409
+ title="Granite 4.1 inference. No vendor LLM is contacted.">
410
+ <span class="dot"></span><span id="backendPillText">checking…</span>
411
+ </span>
412
+ </div>
413
+ </div>
414
+ </header>
415
+
416
+ <div class="agent-topbar-bar">
417
+ <form id="agentForm" class="agent-input-form" autocomplete="off">
418
+ <input id="q" type="text" placeholder="Ask anything: an address, a neighborhood, 'what are they building in Gowanus', 'is there flooding right now'…" autofocus />
419
+ <button type="submit" id="goBtn">Ask</button>
420
+ </form>
421
+ </div>
422
+
423
+ <div class="agent-samples">
424
+ <span class="label">Try:</span>
425
+ <!-- Defaults chosen by a 16-query sweep across NYC; ranked by
426
+ (map-layers populated, unique-citations, latency).
427
+ See /tmp/sweep-out.log for the full ranking. -->
428
+ <button class="sample-btn" data-q="2940 Brighton 3rd St, Brooklyn"
429
+ title="single_address — 5 map layers + 8 cites; coastal Sandy + DEP + 311 + FloodNet + Ida HWMs + NOAA gauge + TerraMind LULC">
430
+ <span class="pill addr">address</span><span class="qtxt">2940 Brighton 3rd St (coastal)</span>
431
+ </button>
432
+ <button class="sample-btn" data-q="180-08 Hillside Ave, Jamaica, NY"
433
+ title="single_address — 5 layers + 7 cites; Jamaica/Hollis pluvial-inland pattern">
434
+ <span class="pill addr">address</span><span class="qtxt">Hillside Ave, Jamaica (pluvial)</span>
435
+ </button>
436
+ <button class="sample-btn" data-q="100 Gold St Manhattan"
437
+ title="single_address — 4 layers + 7 cites; Lower Manhattan dense urban">
438
+ <span class="pill addr">address</span><span class="qtxt">100 Gold St (Manhattan)</span>
439
+ </button>
440
+ <button class="sample-btn" data-q="Far Rockaway"
441
+ title="neighborhood — 7 unique cites; coastal Queens NTA polygon scope">
442
+ <span class="pill nbhd">neighborhood</span><span class="qtxt">Far Rockaway</span>
443
+ </button>
444
+ <button class="sample-btn" data-q="Gowanus"
445
+ title="neighborhood — 6 cites; combined-sewer / pluvial Brooklyn">
446
+ <span class="pill nbhd">neighborhood</span><span class="qtxt">Gowanus</span>
447
+ </button>
448
+ <button class="sample-btn" data-q="is there flooding right now in NYC"
449
+ title="live_now — fast (~13 s); NWS alerts + NOAA tides + TTM surge nowcast">
450
+ <span class="pill live">live</span><span class="qtxt">flooding right now in NYC</span>
451
+ </button>
452
+ </div>
453
+
454
+ <div class="planner-row" id="plannerRow"></div>
455
+
456
+ <div class="workbench">
457
+ <aside class="col-left">
458
+ <section class="panel">
459
+ <h2>Specialist trace <span class="hint" id="traceMeta"></span></h2>
460
+ <r-trace id="steps"></r-trace>
461
+ <div id="traceSkel" class="skel-pad" style="display:none">
462
+ <div class="skel skel-line w-80"></div>
463
+ <div class="skel skel-line w-60"></div>
464
+ <div class="skel skel-line w-100"></div>
465
+ <div class="skel skel-line w-40"></div>
466
+ </div>
467
+ </section>
468
+ </aside>
469
+
470
+ <main class="col-mid">
471
+ <div id="map-card" class="panel panel-map" style="position:relative">
472
+ <div id="map"></div>
473
+ <div id="mapLoading" class="map-loading" style="display:none">
474
+ <span class="dot"></span><span id="mapLoadingText">Resolving location…</span>
475
+ </div>
476
+ <div id="mapLegend" class="map-legend" style="display:none"></div>
477
+ </div>
478
+ <section class="panel" id="factsPanel" style="display:none">
479
+ <h2 id="factsTitle">Findings</h2>
480
+ <div id="factsBody" style="padding: 12px 16px;"></div>
481
+ </section>
482
+ </main>
483
+
484
+ <aside class="col-right">
485
+ <section class="panel report-pane" id="reportPanel" style="display:none">
486
+ <header class="brief-head" id="briefHead">
487
+ <div class="brief-eyebrow" id="briefEyebrow">Briefing</div>
488
+ <div class="brief-title" id="briefTitle">—</div>
489
+ <div class="brief-meta" id="briefMeta"></div>
490
+ <button id="reportBtn" class="report-btn" title="Open a print-ready PDF-formatted report of this query in a new tab">
491
+ ↗ Generate auditable report
492
+ </button>
493
+ </header>
494
+ <div id="melleaBanner" class="mellea-banner" style="display:none"></div>
495
+ <r-briefing id="paragraph" style="display:block; padding: 14px 16px 18px;"></r-briefing>
496
+ <r-sources-footer id="sourcesFooter" hidden></r-sources-footer>
497
+ </section>
498
+ <section class="panel" id="reportSkel" style="display:none">
499
+ <header class="brief-head">
500
+ <div class="brief-eyebrow">Mellea is validating the briefing</div>
501
+ <div class="brief-title" style="color:var(--text-muted)">Granite drafts → 4 grounding requirements → reroll if any fail…</div>
502
+ </header>
503
+ <div class="skel-pad">
504
+ <div class="skel skel-line w-40" style="height:10px"></div>
505
+ <div class="skel skel-line w-100"></div>
506
+ <div class="skel skel-line w-100"></div>
507
+ <div class="skel skel-line w-80"></div>
508
+ <div class="skel skel-line w-40" style="height:10px; margin-top:14px"></div>
509
+ <div class="skel skel-line w-100"></div>
510
+ <div class="skel skel-line w-60"></div>
511
+ </div>
512
+ </section>
513
+ </aside>
514
+ </div>
515
+
516
+ <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
517
+ <!-- Svelte custom-element bundle — registers <r-briefing>, <r-trace>,
518
+ <r-sources-footer>. agent.js sets properties on these tags exactly
519
+ as before; Svelte just owns the implementation now. -->
520
+ <script type="module" src="/static/dist/riprap.js"></script>
521
+ <script src="/static/agent.js"></script>
522
+ </body>
523
+ </html>
web/static/agent.js ADDED
@@ -0,0 +1,1391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Riprap agent client — three-panel UI with live SSE streaming, intent-
2
+ // dispatched map, and structured report rendering.
3
+
4
+ const $ = (s) => document.querySelector(s);
5
+
6
+ const STEP_LABELS = {
7
+ // single_address chain (linear FSM)
8
+ geocode: ["Geocode (DCP Geosearch)", "address → lat/lon, BBL"],
9
+ sandy_inundation: ["Sandy Inundation (NYC OD)", "empirical 2012 extent"],
10
+ dep_stormwater: ["DEP Stormwater Maps", "pluvial scenarios + 2080 SLR"],
11
+ floodnet: ["FloodNet sensor network", "live ultrasonic depth sensors"],
12
+ nyc311: ["NYC 311 archive", "flood complaints in 200m"],
13
+ noaa_tides: ["NOAA Tides & Currents (live)", "Battery / Kings Pt / Sandy Hook"],
14
+ nws_alerts: ["NWS Public Alerts (live)", "active flood-relevant alerts"],
15
+ nws_obs: ["NWS METAR observation (live)", "nearest ASOS recent precipitation"],
16
+ ttm_forecast: ["Granite TTM r2 — surge nowcast", "9.6h forecast at the closest of Battery / Kings Pt / Sandy Hook"],
17
+ ttm_311_forecast: ["Granite TTM r2 — 311 forecast", "4-week per-address flood-complaint forecast (52w history)"],
18
+ floodnet_forecast: ["Granite TTM r2 — FloodNet forecast", "flood-event recurrence forecast at nearest FloodNet sensor"],
19
+ mta_entrance_exposure: ["MTA subway entrances", "subway-entrance exposure (point-in-polygon Sandy + DEP)"],
20
+ nycha_development_exposure: ["NYCHA developments", "NYCHA campus footprint × Sandy + DEP overlap %"],
21
+ doe_school_exposure: ["NYC DOE schools", "school-point exposure (Sandy + DEP)"],
22
+ doh_hospital_exposure: ["NYS DOH hospitals", "Article-28 hospital exposure (Sandy + DEP)"],
23
+ microtopo_lidar: ["LiDAR terrain (DEM + TWI + HAND)", "USGS 3DEP DEM + whitebox-workflows"],
24
+ ida_hwm_2021: ["Ida 2021 high-water marks", "USGS empirical post-event extent"],
25
+ prithvi_eo_v2: ["Prithvi-EO 2.0 (NASA/IBM)", "Sen1Floods11 satellite segmentation"],
26
+ prithvi_eo_live: ["Prithvi-EO 2.0 — live segmentation","fresh Sentinel-2 water mask at this address"],
27
+ terramind_synthesis: ["TerraMind 1.0 base — synthetic LULC", "DEM → ESRI Land Cover, any-to-any generative synthesis (IBM/ESA)"],
28
+ rag_granite_embedding: ["Granite Embedding 278M (RAG)", "policy corpus retrieval (+ Granite Reranker R2 if enabled)"],
29
+ gliner_extract: ["GLiNER typed extraction", "agencies, dollar amounts, projects, locations"],
30
+ reconcile_granite41: ["Granite 4.1 reconcile (local)", "document-grounded synthesis"],
31
+ // neighborhood + dev_check
32
+ nta_resolve: ["NTA polygon resolve", "name → NYC NTA 2020 polygon"],
33
+ sandy_nta: ["Sandy 2012, polygon-aggregated", "% of NTA inside 2012 inundation"],
34
+ dep_extreme_2080_nta: ["DEP Extreme-2080, polygon", "% of NTA in modeled flooding"],
35
+ dep_moderate_2050_nta: ["DEP Moderate-2050, polygon", "% of NTA in modeled flooding"],
36
+ dep_moderate_current_nta:["DEP Moderate-current, polygon", "% of NTA in modeled flooding"],
37
+ nyc311_nta: ["NYC 311, polygon-aggregated", "complaints inside polygon"],
38
+ microtopo_nta: ["LiDAR terrain, polygon", "median HAND/TWI + flood bands"],
39
+ rag_nta: ["Granite Embedding RAG (NTA)", "policy retrieval for the place"],
40
+ reconcile_neighborhood: ["Granite 4.1 reconcile (NTA)", "polygon-flavored briefing"],
41
+ // dev_check
42
+ dob_permits_nta: ["NYC DOB permits in polygon", "active NB / A1 / DM jobs ↔ flood layers"],
43
+ rag_dev: ["Granite Embedding RAG (dev)", "policy on new construction in flood zones"],
44
+ reconcile_development: ["Granite 4.1 reconcile (dev)", "flagged-projects briefing"],
45
+ // live_now
46
+ reconcile_live_now: ["Granite 4.1 reconcile (live)", "current-conditions briefing"],
47
+ };
48
+
49
+ const SOURCE_LABELS = {
50
+ geocode: "NYC DCP Geosearch",
51
+ nta_resolve: "NYC DCP Neighborhood Tabulation Areas 2020",
52
+ sandy: "NYC OD 5xsi-dfpx — Sandy 2012 inundation",
53
+ sandy_nta: "Sandy 2012 inundation, polygon-aggregated",
54
+ dep_extreme_2080: "NYC DEP Stormwater — Extreme-2080",
55
+ dep_moderate_2050: "NYC DEP Stormwater — Moderate-2050",
56
+ dep_moderate_current: "NYC DEP Stormwater — Moderate-current",
57
+ dep_extreme_2080_nta: "NYC DEP Extreme-2080, polygon-aggregated",
58
+ dep_moderate_2050_nta: "NYC DEP Moderate-2050, polygon-aggregated",
59
+ dep_moderate_current_nta: "NYC DEP Moderate-current, polygon-aggregated",
60
+ floodnet: "FloodNet NYC",
61
+ nyc311: "NYC 311 (erm2-nwe9)",
62
+ nyc311_nta: "NYC 311, polygon-aggregated",
63
+ microtopo: "USGS 3DEP DEM",
64
+ microtopo_nta: "USGS 3DEP DEM, polygon-aggregated",
65
+ ida_hwm: "USGS Hurricane Ida 2021 HWMs",
66
+ prithvi_water: "Prithvi-EO 2.0 — Hurricane Ida 2021 polygons",
67
+ prithvi_live: "Prithvi-EO 2.0 ��� live Sentinel-2 water segmentation",
68
+ terramind_synthetic: "TerraMind 1.0 base — synthetic LULC (DEM→ESRI Land Cover)",
69
+ gliner_comptroller: "GLiNER over Comptroller report",
70
+ gliner_dep_2013: "GLiNER over DEP wastewater plan",
71
+ gliner_nycha: "GLiNER over NYCHA Lessons Learned",
72
+ gliner_mta: "GLiNER over MTA Climate Resilience Roadmap",
73
+ gliner_coned: "GLiNER over Con Edison Climate Resilience",
74
+ noaa_tides: "NOAA CO-OPS Tides & Currents",
75
+ nws_alerts: "NWS Public Alerts",
76
+ nws_obs: "NWS Station Observations",
77
+ ttm_forecast: "Granite TimeSeries TTM r2 — surge residual nowcast",
78
+ ttm_311_forecast: "Granite TimeSeries TTM r2 — per-address 311 weekly forecast",
79
+ floodnet_forecast: "Granite TimeSeries TTM r2 — FloodNet sensor recurrence forecast",
80
+ dob_permits: "NYC DOB Permit Issuance (Socrata ipu4-2q9a)",
81
+ live_target: "Riprap planner — live target",
82
+ rag_comptroller: 'NYC Comptroller — "Is NYC Ready for Rain?" (2024)',
83
+ rag_npcc4: "NPCC4 (2024)",
84
+ rag_mta: "MTA Climate Resilience Roadmap",
85
+ rag_nycha: "NYCHA Flood Resilience: Lessons Learned",
86
+ rag_coned: "Con Edison Climate Resilience Plan",
87
+ // Register-specialist family labels — chip lookups for dynamic
88
+ // doc_ids (mta_entrance_<id>, nycha_dev_<tds>, doe_school_<loc>,
89
+ // nyc_hospital_<fac>) fall through to these via family-prefix match.
90
+ mta_entrance: "MTA subway-entrance exposure (Open Data)",
91
+ nycha_dev: "NYCHA development exposure (NYC OD phvi-damg)",
92
+ doe_school: "NYC DOE school exposure",
93
+ nyc_hospital: "NYS DOH hospital exposure (vn5v-hh5r)",
94
+ };
95
+
96
+ // Canonical URL per doc_id — clicking a source row opens the underlying
97
+ // dataset / API / report in a new tab so users can verify provenance.
98
+ const SOURCE_URLS = {
99
+ geocode: "https://geosearch.planninglabs.nyc/",
100
+ nta_resolve: "https://www.nyc.gov/site/planning/data-maps/open-data/dwn-nynta.page",
101
+ sandy: "https://data.cityofnewyork.us/Environment/Sandy-Inundation-Zone/uyj8-7rv5",
102
+ sandy_nta: "https://data.cityofnewyork.us/Environment/Sandy-Inundation-Zone/uyj8-7rv5",
103
+ dep_extreme_2080: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Extreme-Flood-with-Curren/w8eg-8ha6",
104
+ dep_moderate_2050: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood-with-Curre/9i7c-xyvv",
105
+ dep_moderate_current: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood/5rzh-cyqd",
106
+ dep_extreme_2080_nta: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Extreme-Flood-with-Curren/w8eg-8ha6",
107
+ dep_moderate_2050_nta: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood-with-Curre/9i7c-xyvv",
108
+ dep_moderate_current_nta: "https://data.cityofnewyork.us/Environment/NYC-Stormwater-Flood-Map-Moderate-Flood/5rzh-cyqd",
109
+ floodnet: "https://www.floodnet.nyc/",
110
+ nyc311: "https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9",
111
+ nyc311_nta: "https://data.cityofnewyork.us/Social-Services/311-Service-Requests-from-2010-to-Present/erm2-nwe9",
112
+ microtopo: "https://www.usgs.gov/3d-elevation-program",
113
+ microtopo_nta: "https://www.usgs.gov/3d-elevation-program",
114
+ ida_hwm: "https://stn.wim.usgs.gov/STNDataPortal/",
115
+ prithvi_water: "https://huggingface.co/ibm-nasa-geospatial/Prithvi-EO-2.0-300M-TL-Sen1Floods11",
116
+ prithvi_live: "https://huggingface.co/ibm-nasa-geospatial/Prithvi-EO-2.0-300M-TL-Sen1Floods11",
117
+ terramind_synthetic: "https://huggingface.co/ibm-esa-geospatial/TerraMind-1.0-base",
118
+ gliner_comptroller: "https://huggingface.co/urchade/gliner_medium-v2.1",
119
+ gliner_dep_2013: "https://huggingface.co/urchade/gliner_medium-v2.1",
120
+ gliner_nycha: "https://huggingface.co/urchade/gliner_medium-v2.1",
121
+ gliner_mta: "https://huggingface.co/urchade/gliner_medium-v2.1",
122
+ gliner_coned: "https://huggingface.co/urchade/gliner_medium-v2.1",
123
+ noaa_tides: "https://tidesandcurrents.noaa.gov/",
124
+ nws_alerts: "https://www.weather.gov/documentation/services-web-api",
125
+ nws_obs: "https://www.weather.gov/documentation/services-web-api",
126
+ ttm_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
127
+ ttm_311_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
128
+ floodnet_forecast: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
129
+ dob_permits: "https://data.cityofnewyork.us/Housing-Development/DOB-Permit-Issuance/ipu4-2q9a",
130
+ rag_comptroller: "https://comptroller.nyc.gov/reports/is-new-york-city-ready-for-rain/",
131
+ rag_npcc4: "https://nyaspubs.onlinelibrary.wiley.com/toc/17496632/2024/1539/1",
132
+ rag_mta: "https://new.mta.info/sustainability/climate-resilience",
133
+ rag_nycha: "https://www.nyc.gov/site/nycha/about/sustainability.page",
134
+ rag_coned: "https://www.coned.com/en/our-energy-future/climate-change-resilience",
135
+ mta_entrance: "https://data.ny.gov/Transportation/MTA-Subway-Entrances-and-Exits-2024/i9wp-a4ja",
136
+ nycha_dev: "https://data.cityofnewyork.us/Housing-Development/Map-of-NYCHA-Developments/i9rv-hdr5",
137
+ doe_school: "https://data.cityofnewyork.us/Education/School-Locations/jfju-ynrr",
138
+ nyc_hospital: "https://health.data.ny.gov/Health/Health-Facility-Certification-Information/2g9y-7kqm",
139
+ };
140
+
141
+ // Per-source vintage / "as of" — what date the underlying data represents.
142
+ // For live sources, the answer is "live; observation timestamps in payload".
143
+ // For archival sources, this is the dataset publication or extent date.
144
+ const SOURCE_VINTAGES = {
145
+ geocode: "live (NYC DCP Geosearch v2)",
146
+ nta_resolve: "NYC NTA 2020 boundaries (DCP, Sept 2022 release)",
147
+ sandy: "Sandy 2012 inundation extent (NYC OEM survey, dataset published 2013)",
148
+ sandy_nta: "Sandy 2012 inundation extent (polygon-aggregated)",
149
+ dep_extreme_2080: "NYC DEP Stormwater Flood Map — Extreme + 2080 SLR (2021 release)",
150
+ dep_moderate_2050: "NYC DEP Stormwater Flood Map — Moderate + 2050 SLR (2021 release)",
151
+ dep_moderate_current: "NYC DEP Stormwater Flood Map — Moderate, current SLR (2021 release)",
152
+ dep_extreme_2080_nta: "NYC DEP Extreme-2080 (2021 release; polygon-aggregated)",
153
+ dep_moderate_2050_nta: "NYC DEP Moderate-2050 (2021 release; polygon-aggregated)",
154
+ dep_moderate_current_nta: "NYC DEP Moderate-current (2021 release; polygon-aggregated)",
155
+ floodnet: "live FloodNet sensor stream (per-event timestamps in payload)",
156
+ nyc311: "live NYC 311 archive, trailing 5-year window (latest record in payload)",
157
+ nyc311_nta: "live NYC 311 archive, trailing 3-year window (polygon-aggregated)",
158
+ microtopo: "USGS 3DEP DEM (NYC LiDAR collect, ~2018) + derived HAND/TWI",
159
+ microtopo_nta: "USGS 3DEP DEM (NYC ~2018) — polygon-aggregated stats",
160
+ ida_hwm: "USGS Short-Term Network Event 312 — Hurricane Ida 2021 high-water marks (Sept 1-2 2021 survey)",
161
+ prithvi_water: "Prithvi-EO 2.0 satellite segmentation, scenes 2021-08-25 (pre) & 2021-09-02 (post Ida)",
162
+ prithvi_live: "live Sentinel-2 L2A scene from Microsoft Planetary Computer (acquisition timestamp in payload)",
163
+ terramind_synthetic: "synthetic prior — TerraMind 1.0 base generated a plausible categorical land-cover map from the LiDAR terrain at this point (deterministic seed, 10 diffusion steps; class fractions cite-able; not a measurement)",
164
+ gliner_comptroller: "GLiNER typed extraction over the Comptroller PDF (per-paragraph)",
165
+ gliner_dep_2013: "GLiNER typed extraction over the DEP wastewater plan",
166
+ gliner_nycha: "GLiNER typed extraction over the NYCHA Lessons Learned PDF",
167
+ gliner_mta: "GLiNER typed extraction over the MTA Resilience Roadmap",
168
+ gliner_coned: "GLiNER typed extraction over the Con Edison Climate Resilience plan",
169
+ noaa_tides: "live NOAA CO-OPS, 6-min cadence (observation time in payload)",
170
+ nws_alerts: "live NWS Public Alerts API (effective/expires in payload)",
171
+ nws_obs: "live NWS hourly METAR observation (observation time in payload)",
172
+ ttm_forecast: "live TTM forecast based on trailing 51 h at the closest NOAA gauge to this address (Battery / Kings Pt / Sandy Hook)",
173
+ ttm_311_forecast: "live TTM forecast based on trailing 52 weeks of NYC 311 flood complaints within 200 m of this address",
174
+ floodnet_forecast: "live TTM forecast based on the 512-day daily flood-event series at the nearest FloodNet sensor",
175
+ dob_permits: "live NYC DOB Permit Issuance, trailing 18-month window (per-permit issuance dates in payload)",
176
+ rag_comptroller: "NYC Comptroller report 'Is NYC Ready for Rain?' (2024)",
177
+ rag_npcc4: "NPCC4 — NYC Climate Assessment 4th edition, Annals NYAS vol. 1539 (2024)",
178
+ rag_mta: "MTA Climate Resilience Roadmap, October 2025 update",
179
+ rag_nycha: "NYCHA Flood Resilience: Lessons Learned (post-Sandy)",
180
+ rag_coned: "Con Edison Climate Change Resilience Plan, NY PSC Case 22-E-0222 (2023)",
181
+ scope_note: "Riprap planner — geographic scope guard (this query)",
182
+ live_target: "Riprap planner — live target (this query)",
183
+ mta_entrance: "MTA Open Data subway-entrance geometry (refreshed monthly) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM",
184
+ nycha_dev: "NYC Open Data NYCHA Developments (phvi-damg) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM",
185
+ doe_school: "NYC DOE Locations Points (1992 schools) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM",
186
+ nyc_hospital: "NYS DOH Health Facility Certification (vn5v-hh5r, NYC counties + fac_desc_short=HOSP) joined to Sandy 2012 + DEP scenarios + USGS 3DEP DEM",
187
+ };
188
+
189
+ const INTENT_PILL_CLASS = {
190
+ development_check: "dev",
191
+ live_now: "live",
192
+ neighborhood: "nbhd",
193
+ single_address: "addr",
194
+ };
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // MAP
198
+ // ---------------------------------------------------------------------------
199
+
200
+ let map = null;
201
+ let mapInit = false;
202
+
203
+ function ensureMap() {
204
+ if (mapInit) return;
205
+ mapInit = true;
206
+ map = new maplibregl.Map({
207
+ container: "map",
208
+ style: {
209
+ version: 8,
210
+ // CARTO Voyager — more editorial typography + softer palette than
211
+ // Positron, no API key required. Retina (@2x) tiles for crisp type.
212
+ sources: {
213
+ basemap: {
214
+ type: "raster",
215
+ tiles: [
216
+ "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png",
217
+ "https://b.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png",
218
+ "https://c.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png",
219
+ "https://d.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2x.png",
220
+ ],
221
+ tileSize: 256,
222
+ attribution: "© OpenStreetMap contributors © CARTO",
223
+ },
224
+ },
225
+ layers: [
226
+ { id: "bg", type: "background", paint: { "background-color": "#f3f5f8" } },
227
+ { id: "basemap", type: "raster", source: "basemap" },
228
+ ],
229
+ },
230
+ center: [-74.0, 40.72],
231
+ zoom: 10,
232
+ attributionControl: { compact: true },
233
+ // Required for map.getCanvas().toDataURL() to work on the report-export
234
+ // path. Otherwise the WebGL drawing buffer is cleared after each frame
235
+ // and snapshots come back blank.
236
+ preserveDrawingBuffer: true,
237
+ });
238
+ map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "top-right");
239
+ map.on("load", initMapSources);
240
+ }
241
+
242
+ function initMapSources() {
243
+ // Sandy + DEP overlays (used for nbhd / dev_check)
244
+ map.addSource("sandy", { type: "geojson", data: empty() });
245
+ map.addLayer({ id: "sandy-fill", type: "fill", source: "sandy",
246
+ paint: { "fill-color": "#fc5d52", "fill-opacity": 0.25 } });
247
+ map.addLayer({ id: "sandy-line", type: "line", source: "sandy",
248
+ paint: { "line-color": "#fc5d52", "line-width": 0.5, "line-opacity": 0.7 } });
249
+
250
+ map.addSource("dep", { type: "geojson", data: empty() });
251
+ map.addLayer({ id: "dep-fill", type: "fill", source: "dep",
252
+ paint: {
253
+ "fill-color": ["match", ["get", "Flooding_Category"],
254
+ 1, "#568adf", 2, "#1642DF", 3, "#031553", "#568adf"],
255
+ "fill-opacity": 0.32 } });
256
+
257
+ // Prithvi-EO 2.0 live water-segmentation polygons. Cyan to differ
258
+ // visually from Sandy (red) and DEP (blue) — this is *observed today*
259
+ // water from the latest cloud-free Sentinel-2 scene, not a modeled
260
+ // scenario. We outline + fill so even sliver geometries (river edges,
261
+ // canal banks) show up at street zoom.
262
+ map.addSource("prithvi_live", { type: "geojson", data: empty() });
263
+ map.addLayer({ id: "prithvi-live-fill", type: "fill", source: "prithvi_live",
264
+ paint: { "fill-color": "#48c6eb", "fill-opacity": 0.45 } });
265
+ map.addLayer({ id: "prithvi-live-line", type: "line", source: "prithvi_live",
266
+ paint: { "line-color": "#1aa3c8", "line-width": 1.2, "line-opacity": 0.85 } });
267
+
268
+ // TerraMind synthesised LULC polygons — *synthetic-prior* tier
269
+ // (4th epistemic class). Per-feature fill_color carried from the
270
+ // server side so the legend stays in one place. Dashed outline so
271
+ // it visually reads as "synthesized, not observed".
272
+ map.addSource("terramind_lulc", { type: "geojson", data: empty() });
273
+ map.addLayer({ id: "terramind-lulc-fill", type: "fill",
274
+ source: "terramind_lulc",
275
+ paint: {
276
+ "fill-color": ["coalesce", ["get", "fill_color"], "#9ca3af"],
277
+ "fill-opacity": 0.30,
278
+ },
279
+ });
280
+ map.addLayer({ id: "terramind-lulc-line", type: "line",
281
+ source: "terramind_lulc",
282
+ paint: {
283
+ "line-color": ["coalesce", ["get", "fill_color"], "#9ca3af"],
284
+ "line-width": 1.0,
285
+ "line-dasharray": [2, 2],
286
+ "line-opacity": 0.65,
287
+ },
288
+ });
289
+ map.on("click", "terramind-lulc-fill", (e) => {
290
+ const f = e.features[0]; const p = f.properties;
291
+ new maplibregl.Popup().setLngLat(e.lngLat)
292
+ .setHTML(`<b>TerraMind synthetic land-cover</b><br>` +
293
+ `Class: ${escapeHtml(p.label || "")} (tentative)<br>` +
294
+ `<i>Synthesised from LiDAR DEM, not observed.</i>`)
295
+ .addTo(map);
296
+ });
297
+
298
+ // NTA polygon outline
299
+ map.addSource("nta", { type: "geojson", data: empty() });
300
+ map.addLayer({ id: "nta-line", type: "line", source: "nta",
301
+ paint: { "line-color": "#0b3b6b", "line-width": 2.4, "line-opacity": 0.9 } });
302
+ map.addLayer({ id: "nta-fill", type: "fill", source: "nta",
303
+ paint: { "fill-color": "#0b3b6b", "fill-opacity": 0.04 } });
304
+
305
+ // DOB permit pins
306
+ map.addSource("permits", { type: "geojson", data: empty() });
307
+ map.addLayer({ id: "permits-circles", type: "circle", source: "permits",
308
+ paint: {
309
+ "circle-radius": ["case", ["get", "any_flood"], 6, 4],
310
+ "circle-color": [
311
+ "case",
312
+ ["get", "in_sandy"], "#fc5d52",
313
+ [">=", ["get", "dep_max_class"], 2], "#1642DF",
314
+ [">", ["get", "dep_max_class"], 0], "#568adf",
315
+ "#1a8754",
316
+ ],
317
+ "circle-stroke-color": "#ffffff",
318
+ "circle-stroke-width": 1.4,
319
+ "circle-opacity": 0.95,
320
+ } });
321
+ map.on("click", "permits-circles", (e) => {
322
+ const f = e.features[0]; const p = f.properties;
323
+ new maplibregl.Popup()
324
+ .setLngLat(f.geometry.coordinates)
325
+ .setHTML(
326
+ `<b>${escapeHtml(p.address || "(unknown)")}</b><br>` +
327
+ `${p.job_type} · ${p.in_sandy === 'true' ? 'Sandy zone' : 'outside Sandy'}<br>` +
328
+ `DEP class: ${p.dep_max_class}`)
329
+ .addTo(map);
330
+ });
331
+
332
+ // Address pin (single_address intent)
333
+ map.addSource("addr", { type: "geojson", data: empty() });
334
+ map.addLayer({ id: "addr-pin", type: "circle", source: "addr",
335
+ paint: { "circle-radius": 10, "circle-color": "#0b3b6b",
336
+ "circle-stroke-color": "#fff", "circle-stroke-width": 3 } });
337
+
338
+ // Search-radius circles (200 m / 600 m / 800 m). Visualizes the
339
+ // spatial scope each specialist is reading from. Drawn as a thin
340
+ // line so the underlying point data is readable through them.
341
+ map.addSource("scope", { type: "geojson", data: empty() });
342
+ map.addLayer({ id: "scope-line", type: "line", source: "scope",
343
+ paint: { "line-color": "#0b3b6b", "line-width": 1.0,
344
+ "line-opacity": 0.55, "line-dasharray": [3, 3] } });
345
+
346
+ // NYC 311 flood complaint pins — coloured by descriptor.
347
+ map.addSource("nyc311_pts", { type: "geojson", data: empty() });
348
+ map.addLayer({ id: "nyc311-circles", type: "circle", source: "nyc311_pts",
349
+ paint: {
350
+ "circle-radius": 4.5,
351
+ "circle-color": ["match", ["get", "descriptor"],
352
+ "Sewer Backup (Use Comments) (SA)", "#fc5d52",
353
+ "Catch Basin Clogged/Flooding (Use Comments) (SC)", "#f59e0b",
354
+ "Street Flooding (SJ)", "#1642DF",
355
+ "Manhole Overflow (Use Comments) (SA1)", "#8b5cf6",
356
+ "#6b7280",
357
+ ],
358
+ "circle-stroke-color": "#ffffff",
359
+ "circle-stroke-width": 1.0,
360
+ "circle-opacity": 0.85,
361
+ },
362
+ });
363
+ map.on("click", "nyc311-circles", (e) => {
364
+ const f = e.features[0]; const p = f.properties;
365
+ new maplibregl.Popup().setLngLat(f.geometry.coordinates)
366
+ .setHTML(`<b>311 complaint</b><br>${escapeHtml(p.descriptor || "")}<br>` +
367
+ `${escapeHtml(p.date || "")}<br>${escapeHtml(p.address || "")}`)
368
+ .addTo(map);
369
+ });
370
+
371
+ // FloodNet sensors — triangles via SDF circle stand-in (cyan,
372
+ // larger if the sensor has triggered events).
373
+ map.addSource("floodnet_pts", { type: "geojson", data: empty() });
374
+ map.addLayer({ id: "floodnet-circles", type: "circle", source: "floodnet_pts",
375
+ paint: {
376
+ "circle-radius": 7,
377
+ "circle-color": "#48c6eb",
378
+ "circle-stroke-color": "#1aa3c8",
379
+ "circle-stroke-width": 2.0,
380
+ "circle-opacity": 0.95,
381
+ },
382
+ });
383
+ map.on("click", "floodnet-circles", (e) => {
384
+ const f = e.features[0]; const p = f.properties;
385
+ new maplibregl.Popup().setLngLat(f.geometry.coordinates)
386
+ .setHTML(`<b>FloodNet sensor</b><br>${escapeHtml(p.name || p.deployment_id || "")}`)
387
+ .addTo(map);
388
+ });
389
+
390
+ // USGS Hurricane Ida 2021 high-water marks — hot orange, sized by height.
391
+ map.addSource("ida_hwm_pts", { type: "geojson", data: empty() });
392
+ map.addLayer({ id: "ida-hwm-circles", type: "circle", source: "ida_hwm_pts",
393
+ paint: {
394
+ "circle-radius": ["interpolate", ["linear"],
395
+ ["coalesce", ["get", "height_above_gnd_ft"], 0],
396
+ 0, 4, 3, 7, 6, 11],
397
+ "circle-color": "#ea580c",
398
+ "circle-stroke-color": "#7c2d12",
399
+ "circle-stroke-width": 1.4,
400
+ "circle-opacity": 0.92,
401
+ },
402
+ });
403
+ map.on("click", "ida-hwm-circles", (e) => {
404
+ const f = e.features[0]; const p = f.properties;
405
+ new maplibregl.Popup().setLngLat(f.geometry.coordinates)
406
+ .setHTML(`<b>USGS Ida 2021 high-water mark</b><br>` +
407
+ `${escapeHtml(p.site || "(unnamed)")}<br>` +
408
+ `Elevation: ${p.elev_ft ?? "?"} ft<br>` +
409
+ `Height above ground: ${p.height_above_gnd_ft ?? "?"} ft`)
410
+ .addTo(map);
411
+ });
412
+
413
+ // NOAA tide gauge marker — shows which of the 3 gauges is active.
414
+ map.addSource("noaa_gauge", { type: "geojson", data: empty() });
415
+ map.addLayer({ id: "noaa-gauge-marker", type: "circle", source: "noaa_gauge",
416
+ paint: {
417
+ "circle-radius": 9,
418
+ "circle-color": "#0ea5e9",
419
+ "circle-stroke-color": "#fff",
420
+ "circle-stroke-width": 2.5,
421
+ },
422
+ });
423
+ map.on("click", "noaa-gauge-marker", (e) => {
424
+ const f = e.features[0]; const p = f.properties;
425
+ new maplibregl.Popup().setLngLat(f.geometry.coordinates)
426
+ .setHTML(`<b>NOAA tide gauge</b><br>${escapeHtml(p.name || "")}<br>` +
427
+ `Observed water level: ${p.observed_ft ?? "?"} ft MLLW<br>` +
428
+ `Residual (≈ surge): ${p.residual_ft ?? "?"} ft`)
429
+ .addTo(map);
430
+ });
431
+ }
432
+
433
+ // ~3 m/° latitude × cos(lat) for longitude. Build a circle polygon
434
+ // approximating a fixed-radius (meters) buffer around (lat, lon).
435
+ function metersBuffer(lat, lon, meters, steps = 64) {
436
+ const dLat = meters / 111_000.0;
437
+ const dLon = meters / (111_000.0 * Math.cos(lat * Math.PI / 180));
438
+ const ring = [];
439
+ for (let i = 0; i <= steps; i++) {
440
+ const a = (i / steps) * 2 * Math.PI;
441
+ ring.push([lon + dLon * Math.cos(a), lat + dLat * Math.sin(a)]);
442
+ }
443
+ return { type: "Polygon", coordinates: [ring] };
444
+ }
445
+
446
+ function empty() { return { type: "FeatureCollection", features: [] }; }
447
+
448
+ function clearMap() {
449
+ if (!map || !map.getSource) return;
450
+ for (const id of ["sandy", "dep", "nta", "permits", "addr", "prithvi_live",
451
+ "terramind_lulc",
452
+ "scope", "nyc311_pts", "floodnet_pts", "ida_hwm_pts",
453
+ "noaa_gauge"]) {
454
+ const s = map.getSource(id);
455
+ if (s) s.setData(empty());
456
+ }
457
+ }
458
+
459
+ async function fillMapForFinal(d) {
460
+ if (!map || !map.loaded()) {
461
+ map.once("load", () => fillMapForFinal(d));
462
+ return;
463
+ }
464
+ clearMap();
465
+ const intent = d.intent;
466
+ if (intent === "single_address") return fillMapAddress(d);
467
+ if (intent === "neighborhood") return fillMapNeighborhood(d);
468
+ if (intent === "development_check") return fillMapDevelopment(d);
469
+ if (intent === "live_now") return fillMapLive(d);
470
+ }
471
+
472
+ async function fillMapAddress(d) {
473
+ const geo = d.geocode;
474
+ if (!geo || !geo.lat) return;
475
+ map.flyTo({ center: [geo.lon, geo.lat], zoom: 15.5, duration: 700 });
476
+ map.getSource("addr").setData({ type: "FeatureCollection",
477
+ features: [{ type: "Feature",
478
+ geometry: { type: "Point", coordinates: [geo.lon, geo.lat] }, properties: {} }] });
479
+ // Fetch Sandy + DEP layers clipped to address
480
+ try {
481
+ const r = await fetch(`/api/layers/sandy?lat=${geo.lat}&lon=${geo.lon}&r=1500`);
482
+ map.getSource("sandy").setData(await r.json());
483
+ } catch {}
484
+ try {
485
+ const r = await fetch(`/api/layers/dep_extreme_2080?lat=${geo.lat}&lon=${geo.lon}&r=1500`);
486
+ map.getSource("dep").setData(await r.json());
487
+ } catch {}
488
+ // Prithvi-EO live water mask comes inlined in the SSE final event,
489
+ // not via a separate /api/layers fetch — it's per-query, not corpus.
490
+ const live = d.prithvi_live;
491
+ if (live && live.ok && live.polygons_geojson && map.getSource("prithvi_live")) {
492
+ map.getSource("prithvi_live").setData(live.polygons_geojson);
493
+ }
494
+
495
+ // TerraMind synthesised LULC polygons — same per-query pattern.
496
+ const tm = d.terramind;
497
+ if (tm && tm.ok && tm.polygons_geojson && map.getSource("terramind_lulc")) {
498
+ map.getSource("terramind_lulc").setData(tm.polygons_geojson);
499
+ }
500
+
501
+ // ---- search-radius scope rings (200 m / 600 m / 800 m) ----
502
+ // Three rings matching the buffers each specialist actually reads:
503
+ // 200 m for 311, 600 m for FloodNet sensors, 800 m for Ida HWMs.
504
+ if (map.getSource("scope")) {
505
+ map.getSource("scope").setData({
506
+ type: "FeatureCollection",
507
+ features: [200, 600, 800].map(r => ({
508
+ type: "Feature",
509
+ geometry: metersBuffer(geo.lat, geo.lon, r),
510
+ properties: { radius_m: r },
511
+ })),
512
+ });
513
+ }
514
+
515
+ // ---- NYC 311 flood complaint pins ----
516
+ const c311 = d.nyc311 || {};
517
+ const c311Pts = c311.points || [];
518
+ if (map.getSource("nyc311_pts")) {
519
+ map.getSource("nyc311_pts").setData({
520
+ type: "FeatureCollection",
521
+ features: c311Pts.filter(p => p.lat && p.lon).map(p => ({
522
+ type: "Feature",
523
+ geometry: { type: "Point", coordinates: [p.lon, p.lat] },
524
+ properties: {
525
+ descriptor: p.descriptor || "",
526
+ date: p.date || "",
527
+ address: p.address || "",
528
+ },
529
+ })),
530
+ });
531
+ }
532
+
533
+ // ---- FloodNet sensors ----
534
+ const fn = d.floodnet || {};
535
+ const fnSensors = fn.sensors || [];
536
+ if (map.getSource("floodnet_pts")) {
537
+ map.getSource("floodnet_pts").setData({
538
+ type: "FeatureCollection",
539
+ features: fnSensors.filter(s => s.lat && s.lon).map(s => ({
540
+ type: "Feature",
541
+ geometry: { type: "Point", coordinates: [s.lon, s.lat] },
542
+ properties: {
543
+ name: s.name || s.deployment_id || "",
544
+ deployment_id: s.deployment_id || "",
545
+ },
546
+ })),
547
+ });
548
+ }
549
+
550
+ // ---- USGS Ida 2021 HWMs ----
551
+ const hwm = d.ida_hwm || {};
552
+ const hwmPts = hwm.points || [];
553
+ if (map.getSource("ida_hwm_pts")) {
554
+ map.getSource("ida_hwm_pts").setData({
555
+ type: "FeatureCollection",
556
+ features: hwmPts.filter(p => p.lat && p.lon).map(p => ({
557
+ type: "Feature",
558
+ geometry: { type: "Point", coordinates: [p.lon, p.lat] },
559
+ properties: {
560
+ site: p.site || "",
561
+ elev_ft: p.elev_ft,
562
+ height_above_gnd_ft: p.height_above_gnd_ft,
563
+ },
564
+ })),
565
+ });
566
+ }
567
+
568
+ // ---- NOAA tide gauge marker ----
569
+ const tides = d.noaa_tides || {};
570
+ if (tides.station_id && tides.station_lat && tides.station_lon &&
571
+ map.getSource("noaa_gauge")) {
572
+ map.getSource("noaa_gauge").setData({
573
+ type: "FeatureCollection",
574
+ features: [{
575
+ type: "Feature",
576
+ geometry: { type: "Point",
577
+ coordinates: [tides.station_lon, tides.station_lat] },
578
+ properties: {
579
+ name: tides.station_name || tides.station_id,
580
+ observed_ft: tides.observed_ft_mllw,
581
+ residual_ft: tides.residual_ft,
582
+ },
583
+ }],
584
+ });
585
+ }
586
+ }
587
+
588
+ async function fillMapNeighborhood(d) {
589
+ const t = d.target;
590
+ if (!t || !t.bbox || !t.nta_code) return;
591
+ const [minx, miny, maxx, maxy] = t.bbox;
592
+ map.fitBounds([[minx, miny], [maxx, maxy]], { padding: 32, duration: 700 });
593
+ const [r1, r2, r3] = await Promise.all([
594
+ fetch(`/api/layers/nta?code=${t.nta_code}`).then(r => r.json()),
595
+ fetch(`/api/layers/sandy_clipped?code=${t.nta_code}`).then(r => r.json()).catch(() => empty()),
596
+ fetch(`/api/layers/dep_clipped?code=${t.nta_code}&scenario=dep_extreme_2080`).then(r => r.json()).catch(() => empty()),
597
+ ]);
598
+ map.getSource("nta").setData(r1);
599
+ map.getSource("sandy").setData(r2);
600
+ map.getSource("dep").setData(r3);
601
+ // Prithvi-EO live water mask (NTA centroid) — same per-query GeoJSON
602
+ // as the single_address path; clipped visually to the NTA polygon by
603
+ // the basemap zoom.
604
+ const live = d.prithvi_live;
605
+ if (live && live.ok && live.polygons_geojson && map.getSource("prithvi_live")) {
606
+ map.getSource("prithvi_live").setData(live.polygons_geojson);
607
+ }
608
+ // TerraMind synthesised LULC at NTA centroid.
609
+ const tm = d.terramind;
610
+ if (tm && tm.ok && tm.polygons_geojson && map.getSource("terramind_lulc")) {
611
+ map.getSource("terramind_lulc").setData(tm.polygons_geojson);
612
+ }
613
+ }
614
+
615
+ async function fillMapDevelopment(d) {
616
+ await fillMapNeighborhood(d); // same NTA + Sandy + DEP overlays
617
+ const pins = ((d.dob_summary || {}).all_pins) || [];
618
+ const fc = {
619
+ type: "FeatureCollection",
620
+ features: pins.filter(p => p.lat && p.lon).map(p => ({
621
+ type: "Feature",
622
+ geometry: { type: "Point", coordinates: [p.lon, p.lat] },
623
+ properties: {
624
+ address: p.address, job_type: p.job_type,
625
+ in_sandy: !!p.in_sandy, any_flood: !!p.any_flood,
626
+ dep_max_class: p.dep_max_class || 0,
627
+ },
628
+ })),
629
+ };
630
+ map.getSource("permits").setData(fc);
631
+ }
632
+
633
+ function fillMapLive(d) {
634
+ // NYC overview with the 3 NOAA gauges
635
+ map.flyTo({ center: [-74.0, 40.7], zoom: 10, duration: 700 });
636
+ }
637
+
638
+ // Fire as each FSM step completes, so the map updates progressively
639
+ // instead of waiting for the `final` event. Each branch is idempotent —
640
+ // it's safe if `final` later overwrites with the same data.
641
+ async function incrementallyFillMap(step) {
642
+ if (!map || !map.loaded()) {
643
+ map.once("load", () => incrementallyFillMap(step));
644
+ return;
645
+ }
646
+ const r = step.result || {};
647
+ // Address mode — geocode just resolved
648
+ if (step.step === "geocode" && r.lat != null && r.lon != null) {
649
+ map.flyTo({ center: [r.lon, r.lat], zoom: 15.5, duration: 700 });
650
+ map.getSource("addr").setData({ type: "FeatureCollection",
651
+ features: [{ type: "Feature",
652
+ geometry: { type: "Point", coordinates: [r.lon, r.lat] }, properties: {} }] });
653
+ Promise.all([
654
+ fetch(`/api/layers/sandy?lat=${r.lat}&lon=${r.lon}&r=1500`).then(x => x.json()).catch(() => empty()),
655
+ fetch(`/api/layers/dep_extreme_2080?lat=${r.lat}&lon=${r.lon}&r=1500`).then(x => x.json()).catch(() => empty()),
656
+ ]).then(([s, d]) => {
657
+ map.getSource("sandy").setData(s);
658
+ map.getSource("dep").setData(d);
659
+ });
660
+ return;
661
+ }
662
+ // Neighborhood / dev_check — NTA polygon resolved
663
+ if (step.step === "nta_resolve" && r.nta_code && r.bbox) {
664
+ const [minx, miny, maxx, maxy] = r.bbox;
665
+ map.fitBounds([[minx, miny], [maxx, maxy]], { padding: 32, duration: 700 });
666
+ Promise.all([
667
+ fetch(`/api/layers/nta?code=${r.nta_code}`).then(x => x.json()).catch(() => empty()),
668
+ fetch(`/api/layers/sandy_clipped?code=${r.nta_code}`).then(x => x.json()).catch(() => empty()),
669
+ fetch(`/api/layers/dep_clipped?code=${r.nta_code}&scenario=dep_extreme_2080`).then(x => x.json()).catch(() => empty()),
670
+ ]).then(([n, s, d]) => {
671
+ map.getSource("nta").setData(n);
672
+ map.getSource("sandy").setData(s);
673
+ map.getSource("dep").setData(d);
674
+ });
675
+ return;
676
+ }
677
+ // Dev_check — DOB permits arrived; pin them now
678
+ if (step.step === "dob_permits_nta" && Array.isArray(r.all_pins)) {
679
+ const fc = { type: "FeatureCollection",
680
+ features: r.all_pins.filter(p => p.lat && p.lon).map(p => ({
681
+ type: "Feature",
682
+ geometry: { type: "Point", coordinates: [p.lon, p.lat] },
683
+ properties: {
684
+ address: p.address, job_type: p.job_type,
685
+ in_sandy: !!p.in_sandy, any_flood: !!p.any_flood,
686
+ dep_max_class: p.dep_max_class || 0,
687
+ },
688
+ })) };
689
+ map.getSource("permits").setData(fc);
690
+ return;
691
+ }
692
+ }
693
+
694
+ // ---------------------------------------------------------------------------
695
+ // REPORT (paragraph) RENDERING
696
+ // ---------------------------------------------------------------------------
697
+
698
+ function escapeHtml(s) {
699
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
700
+ }
701
+
702
+ let CITE_INDEX = {};
703
+ // Resolve a doc_id to its source-label family. Register specialists emit
704
+ // per-asset doc_ids like `mta_entrance_54` / `nycha_dev_004` — for those
705
+ // we strip the trailing `_<id>` and look up the family key.
706
+ const _FAMILY_PREFIXES = ["mta_entrance", "nycha_dev", "doe_school", "nyc_hospital"];
707
+ function _docIdFamily(norm) {
708
+ for (const fam of _FAMILY_PREFIXES) {
709
+ if (norm.startsWith(fam + "_")) return fam;
710
+ }
711
+ return null;
712
+ }
713
+ function _resolveSourceLabel(norm) {
714
+ if (SOURCE_LABELS[norm]) return SOURCE_LABELS[norm];
715
+ const fam = _docIdFamily(norm);
716
+ return fam ? SOURCE_LABELS[fam] : norm;
717
+ }
718
+ function rewriteCitations(html) {
719
+ return html.replace(/\[([a-z0-9_]+)\]/gi, (_, id) => {
720
+ const norm = id.toLowerCase();
721
+ if (CITE_INDEX[norm] == null) CITE_INDEX[norm] = Object.keys(CITE_INDEX).length + 1;
722
+ const n = CITE_INDEX[norm];
723
+ return `<span class="cite" data-src-id="${norm}" data-src-n="${n}" title="${_resolveSourceLabel(norm)} — click to highlight in Sources">${n}</span>`;
724
+ });
725
+ }
726
+
727
+ // Sources footer is a Lit web component (<r-sources-footer>) — driven
728
+ // by the citeIndex signal in /static/components/signals.js. We feed
729
+ // it the labels/urls/vintages once at boot and update the signal as
730
+ // the briefing markdown is rendered.
731
+ async function renderSources() {
732
+ const el = document.getElementById("sourcesFooter");
733
+ if (!el) return;
734
+ // Module is loaded async; wait for define() then push fresh data.
735
+ await customElements.whenDefined("r-sources-footer");
736
+ el.labels = SOURCE_LABELS;
737
+ el.urls = SOURCE_URLS;
738
+ el.vintages = SOURCE_VINTAGES;
739
+ // Push the citation index into the shared signal — the component
740
+ // re-renders reactively.
741
+ const { citeIndex } = await import("/static/components/signals.js");
742
+ citeIndex.set({ ...CITE_INDEX });
743
+ }
744
+
745
+ function renderMarkdown(text) {
746
+ // Block recognizer:
747
+ // `**Header.**` (own line) → <h4>
748
+ // lines starting `- ` or `* ` → bullet items collected into <ul>
749
+ // anything else → <p>
750
+ // Inline `**foo**` → <strong>
751
+ const lines = text.split("\n");
752
+ const out = [];
753
+ let para = []; let bullets = [];
754
+ const flushPara = () => {
755
+ if (!para.length) return;
756
+ const safe = escapeHtml(para.join(" ").trim()).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
757
+ if (safe) out.push(`<p class="rsum-p">${safe}</p>`);
758
+ para = [];
759
+ };
760
+ const flushBullets = () => {
761
+ if (!bullets.length) return;
762
+ const items = bullets.map(b => {
763
+ const safe = escapeHtml(b.trim()).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
764
+ return `<li>${safe}</li>`;
765
+ }).join("");
766
+ out.push(`<ul class="rsum-list">${items}</ul>`);
767
+ bullets = [];
768
+ };
769
+ // Granite sometimes runs all bullets onto one line separated by " - ";
770
+ // pre-split those so each becomes its own bullet.
771
+ const expanded = [];
772
+ for (const line of lines) {
773
+ if (line.trim().startsWith("- ") && line.includes(" - ", 2)) {
774
+ // split into bullets
775
+ const parts = line.split(/(?:^|(?<=\.\s))\s*-\s+/g).filter(p => p.trim());
776
+ for (const p of parts) expanded.push("- " + p.trim());
777
+ } else {
778
+ expanded.push(line);
779
+ }
780
+ }
781
+ for (const line of expanded) {
782
+ const m = line.match(/^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/);
783
+ if (m) {
784
+ flushPara(); flushBullets();
785
+ out.push(`<h4 class="rsum-h">${escapeHtml(m[1])}</h4>`);
786
+ continue;
787
+ }
788
+ if (/^\s*[-*]\s+/.test(line)) {
789
+ flushPara();
790
+ bullets.push(line.replace(/^\s*[-*]\s+/, ""));
791
+ } else {
792
+ flushBullets();
793
+ para.push(line);
794
+ }
795
+ }
796
+ flushPara(); flushBullets();
797
+ return out.join("");
798
+ }
799
+
800
+ // Briefing is now the Lit <r-briefing> web component. It owns markdown
801
+ // rendering, citation chip binding, and pushing CITE_INDEX into the
802
+ // shared signal — agent.js just feeds it `.text` + `.sourceLabels`.
803
+ async function setBriefingText(text) {
804
+ const el = document.getElementById("paragraph");
805
+ if (!el) return;
806
+ await customElements.whenDefined("r-briefing");
807
+ el.sourceLabels = SOURCE_LABELS;
808
+ el.text = text || "";
809
+ }
810
+ function renderParagraph(text) { setBriefingText(text); }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // FACTS PANEL — intent-specific quick-look stats below the map
814
+ // ---------------------------------------------------------------------------
815
+
816
+ function renderFacts(d) {
817
+ const intent = d.intent;
818
+ const panel = $("#factsPanel");
819
+ const body = $("#factsBody");
820
+ panel.style.display = "";
821
+ if (intent === "neighborhood") renderNbhdFacts(d, body);
822
+ else if (intent === "development_check") renderDevFacts(d, body);
823
+ else if (intent === "live_now") renderLiveFacts(d, body);
824
+ else if (intent === "single_address") renderAddressFacts(d, body);
825
+ }
826
+
827
+ function renderNbhdFacts(d, body) {
828
+ $("#factsTitle").textContent = `Findings — ${d.target?.nta_name || ""}`;
829
+ const s = d.sandy_nta || {}; const dep = d.dep_nta || {};
830
+ const m = d.microtopo_nta || {}; const c = d.nyc311_nta || {};
831
+ const sandyPct = s.fraction != null ? (s.fraction * 100).toFixed(1) + "%" : "—";
832
+ const dep80 = (dep.dep_extreme_2080 || {}).fraction_any;
833
+ const dep50 = (dep.dep_moderate_2050 || {}).fraction_any;
834
+ body.innerHTML = `
835
+ <div class="headline-stat">${sandyPct}</div>
836
+ <div class="headline-sub">of the neighborhood is inside the 2012 Sandy Inundation Zone</div>
837
+ <dl class="facts-grid">
838
+ <dt>DEP Extreme 2080</dt><dd>${dep80!=null ? (dep80*100).toFixed(1)+"%" : "—"}</dd>
839
+ <dt>DEP Moderate 2050</dt><dd>${dep50!=null ? (dep50*100).toFixed(1)+"%" : "—"}</dd>
840
+ <dt>311 (3 yr)</dt><dd>${c.n ?? "—"} flood complaints</dd>
841
+ <dt>HAND median</dt><dd>${m.hand_median_m != null ? m.hand_median_m+" m" : "—"}</dd>
842
+ <dt>HAND &lt; 1 m fraction</dt><dd>${m.frac_hand_lt1 != null ? (m.frac_hand_lt1*100).toFixed(0)+"%" : "—"}</dd>
843
+ <dt>TWI median</dt><dd>${m.twi_median ?? "—"}</dd>
844
+ </dl>`;
845
+ }
846
+
847
+ function renderDevFacts(d, body) {
848
+ $("#factsTitle").textContent = `Active construction — ${d.target?.nta_name || ""}`;
849
+ const ds = d.dob_summary || {};
850
+ body.innerHTML = `
851
+ <div class="headline-stat">${ds.n_in_sandy ?? 0} <span style="color:var(--text-muted); font-size:18px; font-weight:400;">/ ${ds.n_total ?? 0}</span></div>
852
+ <div class="headline-sub">active projects inside the Sandy zone</div>
853
+ <dl class="facts-grid">
854
+ <dt>Total active</dt><dd>${ds.n_total ?? 0}</dd>
855
+ <dt>In any DEP scenario</dt><dd>${ds.n_in_dep_any ?? 0}</dd>
856
+ <dt>In severe DEP (≥1 ft)</dt><dd>${ds.n_in_dep_severe ?? 0}</dd>
857
+ <dt>By job type</dt><dd>${Object.entries(ds.by_job_type || {}).map(([k,v]) => `${v} ${k}`).join(", ")}</dd>
858
+ </dl>`;
859
+ }
860
+
861
+ function renderLiveFacts(d, body) {
862
+ $("#factsTitle").textContent = `Live conditions — ${d.place || "NYC"}`;
863
+ const t = d.noaa_tides || {}; const a = d.nws_alerts || {}; const o = d.nws_obs || {};
864
+ const ttm = d.ttm_forecast || {};
865
+ const r = t.residual_ft;
866
+ body.innerHTML = `
867
+ <div class="headline-stat">${a.n_active ?? 0} alerts</div>
868
+ <div class="headline-sub">active flood-relevant NWS alerts at this point</div>
869
+ <dl class="facts-grid">
870
+ <dt>Tide gauge</dt><dd>${t.station_name || "—"}</dd>
871
+ <dt>Observed</dt><dd>${t.observed_ft_mllw != null ? t.observed_ft_mllw+" ft MLLW" : "—"}</dd>
872
+ <dt>Residual</dt><dd>${r != null ? (r >= 0 ? "+" : "")+r+" ft" : "—"}</dd>
873
+ <dt>Nearest ASOS</dt><dd>${o.station_id || "—"}</dd>
874
+ <dt>Precip 1h</dt><dd>${o.precip_last_hour_mm != null ? o.precip_last_hour_mm+" mm" : "—"}</dd>
875
+ <dt>TTM peak (next 9.6h)</dt><dd>${ttm.forecast_peak_ft != null ? ttm.forecast_peak_ft+" ft" : "—"}</dd>
876
+ </dl>`;
877
+ }
878
+
879
+ function renderAddressFacts(d, body) {
880
+ $("#factsTitle").textContent = "Findings";
881
+ const geo = d.geocode || {};
882
+ const dep = d.dep || {}; const e80 = (dep.dep_extreme_2080 || {});
883
+ const m = d.microtopo || {};
884
+ body.innerHTML = `
885
+ <div class="headline-sub">${geo.address || "—"}</div>
886
+ <dl class="facts-grid">
887
+ <dt>Sandy zone</dt><dd>${d.sandy ? "INSIDE" : "outside"}</dd>
888
+ <dt>DEP Extreme 2080</dt><dd>${e80.depth_label || "—"}</dd>
889
+ <dt>HAND</dt><dd>${m.hand_m != null ? m.hand_m+" m" : "—"}</dd>
890
+ <dt>TWI</dt><dd>${m.twi ?? "—"}</dd>
891
+ <dt>Elev pct (200m)</dt><dd>${m.rel_elev_pct_200m ?? "—"}</dd>
892
+ <dt>311 (5y, 200m)</dt><dd>${(d.nyc311 || {}).n ?? "—"}</dd>
893
+ </dl>`;
894
+ }
895
+
896
+ // ---------------------------------------------------------------------------
897
+ // TRACE PANEL
898
+ // ---------------------------------------------------------------------------
899
+
900
+ // Trace list is a Lit web component (<r-trace>); pushTraceStep delegates
901
+ // once the component is registered. STEP_LABELS is set on the element
902
+ // at boot.
903
+ async function pushTraceStep(step) {
904
+ const el = document.getElementById("steps");
905
+ if (!el) return;
906
+ await customElements.whenDefined("r-trace");
907
+ if (!el.stepLabels || !Object.keys(el.stepLabels).length) {
908
+ el.stepLabels = STEP_LABELS;
909
+ }
910
+ el.pushStep(step);
911
+ }
912
+
913
+ async function clearTrace() {
914
+ const el = document.getElementById("steps");
915
+ if (el) {
916
+ await customElements.whenDefined("r-trace");
917
+ el.clear();
918
+ }
919
+ $("#traceMeta").textContent = "";
920
+ }
921
+
922
+ // --------------------------------------------------------------------------
923
+ // Loading-state and chrome helpers
924
+ // --------------------------------------------------------------------------
925
+
926
+ function setMapLoading(text) {
927
+ const el = $("#mapLoading");
928
+ if (!el) return;
929
+ if (text) {
930
+ el.style.display = "";
931
+ $("#mapLoadingText").textContent = text;
932
+ } else {
933
+ el.style.display = "none";
934
+ }
935
+ }
936
+
937
+ function setLegend(intent) {
938
+ const el = $("#mapLegend");
939
+ if (!el) return;
940
+ // Reusable legend rows shared across intents.
941
+ const empirical = `
942
+ <div class="legend-row"><span class="legend-swatch fill" style="background:#fc5d52; opacity:0.4"></span>Sandy 2012 extent</div>
943
+ <div class="legend-row"><span class="legend-swatch fill" style="background:#1642DF; opacity:0.4"></span>DEP Extreme-2080</div>`;
944
+ const points = `
945
+ <div class="legend-row"><span class="legend-swatch" style="background:#fc5d52; border-radius:50%"></span>311 — sewer backup</div>
946
+ <div class="legend-row"><span class="legend-swatch" style="background:#f59e0b; border-radius:50%"></span>311 — catch basin</div>
947
+ <div class="legend-row"><span class="legend-swatch" style="background:#1642DF; border-radius:50%"></span>311 — street flooding</div>
948
+ <div class="legend-row"><span class="legend-swatch" style="background:#48c6eb; border:2px solid #1aa3c8; border-radius:50%"></span>FloodNet sensor</div>
949
+ <div class="legend-row"><span class="legend-swatch" style="background:#ea580c; border:1px solid #7c2d12; border-radius:50%"></span>Ida 2021 high-water mark</div>
950
+ <div class="legend-row"><span class="legend-swatch" style="background:#0ea5e9; border:2px solid #fff; border-radius:50%; outline:1px solid #ccc"></span>NOAA tide gauge</div>`;
951
+ // Synthetic-prior tier — distinct visual idiom (dashed) so users
952
+ // immediately read it as "generated, not observed".
953
+ const synthetic = `
954
+ <div style="font-weight:700; font-size:9.5px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted); margin-top:6px; margin-bottom:2px">Synthetic priors (not observed)</div>
955
+ <div class="legend-row"><span class="legend-swatch fill" style="background:#48c6eb; opacity:0.45"></span>Prithvi-EO 2.0 — live water mask</div>
956
+ <div class="legend-row"><span class="legend-swatch fill" style="background:#16a34a; opacity:0.30; border:1px dashed #16a34a"></span>TerraMind — synthetic LULC (DEM→ESRI Land Cover, dashed = generated)</div>`;
957
+
958
+ if (intent === "development_check") {
959
+ el.innerHTML = `
960
+ <div style="font-weight:700; font-size:9.5px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-muted); margin-bottom:4px">Active permits</div>
961
+ <div class="legend-row"><span class="legend-swatch" style="background:#fc5d52"></span>Inside Sandy zone</div>
962
+ <div class="legend-row"><span class="legend-swatch" style="background:#1642DF"></span>DEP deep band (≥1 ft)</div>
963
+ <div class="legend-row"><span class="legend-swatch" style="background:#568adf"></span>DEP nuisance band</div>
964
+ <div class="legend-row"><span class="legend-swatch" style="background:#1a8754"></span>No flood layer</div>
965
+ <div class="legend-row" style="margin-top:6px">${empirical}</div>${synthetic}`;
966
+ el.style.display = "";
967
+ } else if (intent === "neighborhood") {
968
+ el.innerHTML = `${empirical}
969
+ <div class="legend-row"><span class="legend-swatch fill" style="background:transparent; border:2px solid #0b3b6b"></span>NTA boundary</div>${synthetic}`;
970
+ el.style.display = "";
971
+ } else if (intent === "single_address") {
972
+ el.innerHTML = `
973
+ <div class="legend-row"><span class="legend-swatch" style="background:#0b3b6b"></span>Address</div>${empirical}${points}${synthetic}`;
974
+ el.style.display = "";
975
+ } else {
976
+ el.style.display = "none";
977
+ }
978
+ }
979
+
980
+ // Mirrors app/score.py.composite() — see ARCHITECTURE.md / METHODOLOGY.md.
981
+ // Used only for the single_address intent badge; neighborhood and
982
+ // development_check have their own headline stats in the facts panel.
983
+ const REG_W = { fema_1pct: 1.0, fema_02pct: 0.5,
984
+ dep_moderate_2050: 0.75, dep_extreme_2080: 0.50, dep_tidal_2050: 0.75 };
985
+ const HYD_W = { hand_band: 1.0, twi_quartile: 0.5,
986
+ elev_pct_200m_inv: 0.5, elev_pct_750m_inv: 0.5, basin_relief_band: 0.25 };
987
+ const EMP_W = { sandy: 1.0, ida_hwm_within_100m: 1.0, ida_hwm_within_800m: 0.5,
988
+ prithvi_polygon: 0.75, complaints_band: 0.75, floodnet_trigger: 0.75 };
989
+ const handBand = h => h == null ? 0 : (h < 1 ? 1 : h < 3 ? 0.66 : h < 10 ? 0.33 : 0);
990
+ const pctInvBand = p => p == null ? 0 : (p < 10 ? 1 : p < 25 ? 0.66 : p < 50 ? 0.33 : 0);
991
+ const twiBand = t => t == null ? 0 : (t >= 12 ? 1 : t >= 10 ? 0.66 : t >= 8 ? 0.33 : 0);
992
+ const reliefBand = r => r == null ? 0 : (r >= 8 ? 1 : r >= 4 ? 0.66 : r >= 2 ? 0.33 : 0);
993
+ const complBand = n => !n ? 0 : (n >= 10 ? 1 : n >= 3 ? 0.66 : 0.33);
994
+ const sumW = w => Object.values(w).reduce((a, b) => a + b, 0);
995
+
996
+ function computeComposite(ev) {
997
+ const dep = ev.dep || {}, mt = ev.microtopo || {}, ida = ev.ida_hwm || {}, pw = ev.prithvi_water || {};
998
+ const s = {
999
+ fema_1pct: false, fema_02pct: false,
1000
+ dep_moderate_2050: (dep.dep_moderate_2050?.depth_class || 0) > 0,
1001
+ dep_extreme_2080: (dep.dep_extreme_2080?.depth_class || 0) > 0,
1002
+ dep_tidal_2050: false,
1003
+ hand_m: mt.hand_m, twi: mt.twi,
1004
+ rel_elev_pct_200m: mt.rel_elev_pct_200m,
1005
+ rel_elev_pct_750m: mt.rel_elev_pct_750m,
1006
+ basin_relief_m: mt.basin_relief_m,
1007
+ sandy: !!ev.sandy,
1008
+ ida_hwm_within_100m: (ida.nearest_dist_m != null && ida.nearest_dist_m < 100),
1009
+ ida_hwm_within_800m: (ida.n_within_radius || 0) > 0,
1010
+ prithvi_polygon: !!pw.inside_water_polygon,
1011
+ complaints_count: ev.nyc311?.n || 0,
1012
+ floodnet_trigger: (ev.floodnet?.n_flood_events_3y || 0) > 0,
1013
+ };
1014
+ let regRaw = 0; for (const [k, w] of Object.entries(REG_W)) regRaw += s[k] ? w : 0;
1015
+ const reg = regRaw / sumW(REG_W);
1016
+ const hb = { hand_band: handBand(s.hand_m), twi_quartile: twiBand(s.twi),
1017
+ elev_pct_200m_inv: pctInvBand(s.rel_elev_pct_200m),
1018
+ elev_pct_750m_inv: pctInvBand(s.rel_elev_pct_750m),
1019
+ basin_relief_band: reliefBand(s.basin_relief_m) };
1020
+ let hydRaw = 0; for (const [k, w] of Object.entries(HYD_W)) hydRaw += w * hb[k];
1021
+ const hyd = hydRaw / sumW(HYD_W);
1022
+ const ev2 = { sandy: s.sandy ? 1 : 0,
1023
+ ida_hwm_within_100m: s.ida_hwm_within_100m ? 1 : 0,
1024
+ ida_hwm_within_800m: s.ida_hwm_within_800m ? 1 : 0,
1025
+ prithvi_polygon: s.prithvi_polygon ? 1 : 0,
1026
+ complaints_band: complBand(s.complaints_count),
1027
+ floodnet_trigger: s.floodnet_trigger ? 1 : 0 };
1028
+ let empRaw = 0; for (const [k, w] of Object.entries(EMP_W)) empRaw += w * ev2[k];
1029
+ const emp = empRaw / sumW(EMP_W);
1030
+ const composite = reg + hyd + emp;
1031
+ let tier = 0;
1032
+ if (composite >= 1.50) tier = 1;
1033
+ else if (composite >= 1.00) tier = 2;
1034
+ else if (composite >= 0.50) tier = 3;
1035
+ else if (composite >= 0.01) tier = 4;
1036
+ const floorApplied = !!(s.sandy || s.ida_hwm_within_100m);
1037
+ if (floorApplied && (tier === 0 || tier > 2)) tier = 2;
1038
+ return { tier, composite, floorApplied,
1039
+ sub: { regulatory: reg, hydrological: hyd, empirical: emp } };
1040
+ }
1041
+
1042
+ function tierMeta(tier) {
1043
+ if (tier === 1) return { tier, label: "High exposure",
1044
+ help: "Multiple sub-indices saturated. Not a damage probability." };
1045
+ if (tier === 2) return { tier, label: "Elevated exposure",
1046
+ help: "At least one sub-index near saturation. Not a damage probability." };
1047
+ if (tier === 3) return { tier, label: "Moderate exposure",
1048
+ help: "Partial signals across categories. Not a damage probability." };
1049
+ if (tier === 4) return { tier, label: "Limited exposure",
1050
+ help: "A single contextual signal." };
1051
+ return { tier: 0, label: "No flagged exposure",
1052
+ help: "No positive flood signal across the assessed sources." };
1053
+ }
1054
+
1055
+ function renderBriefHead(d) {
1056
+ const intent = d.intent;
1057
+ const place = (d.target && d.target.nta_name)
1058
+ || (d.geocode && d.geocode.address)
1059
+ || d.place || "—";
1060
+ const meta = [];
1061
+ const eyebrowMap = {
1062
+ single_address: "Flood-exposure briefing — address",
1063
+ neighborhood: "Flood-exposure briefing — neighborhood",
1064
+ development_check: "Active development × flood exposure",
1065
+ live_now: "Current conditions — NYC",
1066
+ };
1067
+ $("#briefEyebrow").textContent = eyebrowMap[intent] || "Briefing";
1068
+ $("#briefTitle").innerHTML = escapeHtml(place);
1069
+
1070
+ // For single_address intent, append the tier badge inline with the title
1071
+ // — same idiom as the legacy /single page.
1072
+ if (intent === "single_address") {
1073
+ const c = computeComposite(d);
1074
+ const m = tierMeta(c.tier);
1075
+ const titleEl = $("#briefTitle");
1076
+ const floor = c.floorApplied ? ' <span class="tier-floor">empirical floor</span>' : "";
1077
+ titleEl.innerHTML += ` <span class="tier-chip t-${m.tier}" title="${escapeHtml(m.help)}">
1078
+ Tier ${m.tier} · ${escapeHtml(m.label)}${floor}
1079
+ </span>`;
1080
+ }
1081
+
1082
+ // Mellea compliance badge — present iff strict mode ran and produced
1083
+ // metadata. Color reflects pass ratio: green for full, amber partial,
1084
+ // red none.
1085
+ if (d.mellea) {
1086
+ const m = d.mellea;
1087
+ const passed = (m.requirements_passed || []).length;
1088
+ const total = m.requirements_total || 0;
1089
+ const cls = passed === total ? "full"
1090
+ : passed > 0 ? "partial"
1091
+ : "none";
1092
+ const tip = `Mellea (IBM Research) ran ${m.n_attempts} attempt${m.n_attempts === 1 ? "" : "s"}` +
1093
+ ` (${m.rerolls} reroll${m.rerolls === 1 ? "" : "s"}). ` +
1094
+ `Requirements passed: ${(m.requirements_passed || []).join(", ") || "none"}. ` +
1095
+ (m.requirements_failed?.length
1096
+ ? `Failed: ${m.requirements_failed.join(", ")}.` : "");
1097
+ $("#briefTitle").innerHTML +=
1098
+ ` <span class="mellea-badge ${cls}" title="${escapeHtml(tip)}">` +
1099
+ `<span class="ico">✓</span>Mellea ${passed}/${total}` +
1100
+ (m.rerolls > 0 ? ` · ${m.rerolls} reroll${m.rerolls === 1 ? "" : "s"}` : "") +
1101
+ `</span>`;
1102
+ }
1103
+ if (intent === "single_address" && d.geocode) {
1104
+ if (d.geocode.borough) meta.push(`<span class="brief-meta-k">borough</span> <span class="brief-meta-v">${escapeHtml(d.geocode.borough)}</span>`);
1105
+ if (d.geocode.bbl) meta.push(`<span class="brief-meta-k">bbl</span> <span class="brief-meta-v">${escapeHtml(d.geocode.bbl)}</span>`);
1106
+ } else if (d.target && d.target.borough) {
1107
+ meta.push(`<span class="brief-meta-k">borough</span> <span class="brief-meta-v">${escapeHtml(d.target.borough)}</span>`);
1108
+ if (d.target.nta_code) meta.push(`<span class="brief-meta-k">nta</span> <span class="brief-meta-v">${escapeHtml(d.target.nta_code)}</span>`);
1109
+ }
1110
+ if (d.total_s != null) meta.push(`<span class="brief-meta-k">runtime</span> <span class="brief-meta-v">${d.total_s}s</span>`);
1111
+ meta.push(`<span class="brief-meta-k">assessed</span> <span class="brief-meta-v">${new Date().toISOString().slice(0,16).replace("T"," ")}</span>`);
1112
+ $("#briefMeta").innerHTML = meta.join('<span style="color:var(--text-faint)">·</span>');
1113
+ }
1114
+
1115
+ // ---------------------------------------------------------------------------
1116
+ // PLANNER ROW
1117
+ // ---------------------------------------------------------------------------
1118
+
1119
+ function renderPlan(p) {
1120
+ const pillCls = INTENT_PILL_CLASS[p.intent] || "";
1121
+ $("#plannerRow").innerHTML = `
1122
+ <div class="planner-box">
1123
+ <div class="planner-key">Planner</div>
1124
+ <div><span class="intent-pill ${pillCls}">${escapeHtml(p.intent)}</span></div>
1125
+ <div class="planner-key">Targets</div>
1126
+ <div class="planner-val">${(p.targets || []).map(t => escapeHtml(t.type) + ":" + escapeHtml(t.text)).join(", ") || "(none)"}</div>
1127
+ <div class="planner-key">Specialists</div>
1128
+ <div class="planner-val">${(p.specialists || []).join(", ")}</div>
1129
+ <div class="planner-rationale">"${escapeHtml(p.rationale || "")}"</div>
1130
+ </div>`;
1131
+ }
1132
+
1133
+ // ---------------------------------------------------------------------------
1134
+ // SSE driver
1135
+ // ---------------------------------------------------------------------------
1136
+
1137
+ let currentEs = null;
1138
+ // Buffers for the report-export feature — capture the full plan, trace,
1139
+ // and final result during streaming so the report page can render the
1140
+ // complete evidence package without re-running the agent.
1141
+ let LAST_RESULT = null;
1142
+ let LAST_TRACE = [];
1143
+ let LAST_PLAN = null;
1144
+ let LAST_PLAN_OBJ = null;
1145
+ let TRACE_BUF = [];
1146
+
1147
+ function ask(q) {
1148
+ ensureMap();
1149
+ clearTrace(); clearMap();
1150
+ $("#plannerRow").innerHTML = "";
1151
+ setBriefingText("");
1152
+ $("#paragraph").classList.remove("streaming");
1153
+ const banner = $("#melleaBanner");
1154
+ if (banner) { banner.style.display = "none"; banner.innerHTML = ""; }
1155
+ $("#reportPanel").style.display = "none";
1156
+ $("#factsPanel").style.display = "none";
1157
+ $("#reportSkel").style.display = "";
1158
+ $("#traceSkel").style.display = "";
1159
+ $("#mapLegend").style.display = "none";
1160
+ setMapLoading("Granite is planning the query…");
1161
+ $("#goBtn").disabled = true;
1162
+ $("#traceMeta").textContent = "…";
1163
+
1164
+ if (currentEs) currentEs.close();
1165
+ const es = new EventSource("/api/agent/stream?q=" + encodeURIComponent(q));
1166
+ currentEs = es;
1167
+ const t0 = Date.now();
1168
+ let streamBuf = "";
1169
+ let streamTimer = null;
1170
+ let planStreamBuf = "";
1171
+ let planStreamTimer = null;
1172
+ const ensurePlannerStream = () => {
1173
+ let el = $("#plannerRow .planner-streaming");
1174
+ if (!el) {
1175
+ $("#plannerRow").innerHTML = `<div class="planner-streaming"></div>`;
1176
+ el = $("#plannerRow .planner-streaming");
1177
+ }
1178
+ return el;
1179
+ };
1180
+ const repaintPlanner = () => {
1181
+ const el = $("#plannerRow .planner-streaming");
1182
+ if (el) el.textContent = planStreamBuf;
1183
+ };
1184
+ const schedulePlannerRepaint = () => {
1185
+ if (planStreamTimer) return;
1186
+ planStreamTimer = setTimeout(() => { planStreamTimer = null; repaintPlanner(); }, 60);
1187
+ };
1188
+ // Re-render the partial markdown on every token, but at most every 80 ms
1189
+ // so the browser isn't murdered by a token-stream that arrives in bursts.
1190
+ // Build the Sources footer alongside so it grows as new doc_ids appear.
1191
+ // Briefing component owns citation indexing + chip binding via shared
1192
+ // signals; we just feed it the latest text. Sources footer reacts to
1193
+ // the citeIndex signal that <r-briefing> updates each render.
1194
+ const repaint = () => {
1195
+ setBriefingText(streamBuf);
1196
+ renderSources();
1197
+ };
1198
+ const scheduleRepaint = () => {
1199
+ if (streamTimer) return;
1200
+ streamTimer = setTimeout(() => { streamTimer = null; repaint(); }, 80);
1201
+ };
1202
+
1203
+ es.addEventListener("plan_token", (e) => {
1204
+ ensurePlannerStream();
1205
+ const d = JSON.parse(e.data);
1206
+ planStreamBuf += d.delta || "";
1207
+ schedulePlannerRepaint();
1208
+ });
1209
+ es.addEventListener("plan", (e) => {
1210
+ if (planStreamTimer) { clearTimeout(planStreamTimer); planStreamTimer = null; }
1211
+ const planObj = JSON.parse(e.data);
1212
+ LAST_PLAN_OBJ = planObj;
1213
+ renderPlan(planObj);
1214
+ setLegend(planObj.intent);
1215
+ setMapLoading(planObj.intent === "live_now" ? null : "Resolving location…");
1216
+ $("#traceSkel").style.display = "none";
1217
+ TRACE_BUF = [];
1218
+ $("#reportBtn").classList.remove("ready");
1219
+ });
1220
+ es.addEventListener("step", (e) => {
1221
+ const step = JSON.parse(e.data);
1222
+ TRACE_BUF.push(step);
1223
+ incrementallyFillMap(step);
1224
+ if (step.step === "geocode" || step.step === "nta_resolve") setMapLoading(null);
1225
+ });
1226
+ es.addEventListener("step", (e) => { pushTraceStep(JSON.parse(e.data)); });
1227
+ let currentAttempt = 0;
1228
+ es.addEventListener("token", (e) => {
1229
+ const d = JSON.parse(e.data);
1230
+ if (!streamBuf || (d.attempt != null && d.attempt !== currentAttempt)) {
1231
+ // First token of a (possibly new) attempt → reveal panel, reset
1232
+ // buffer if Mellea moved to a reroll.
1233
+ if (d.attempt != null && d.attempt !== currentAttempt) {
1234
+ currentAttempt = d.attempt;
1235
+ streamBuf = "";
1236
+ }
1237
+ $("#reportSkel").style.display = "none";
1238
+ $("#reportPanel").style.display = "";
1239
+ $("#paragraph").classList.add("streaming");
1240
+ }
1241
+ streamBuf += d.delta || "";
1242
+ scheduleRepaint();
1243
+ });
1244
+ // Mellea per-attempt outcome — render a small banner above the briefing
1245
+ // when a reroll is about to start so the user knows the model is
1246
+ // self-correcting (and what failed).
1247
+ es.addEventListener("mellea_attempt", (e) => {
1248
+ const d = JSON.parse(e.data);
1249
+ const banner = $("#melleaBanner");
1250
+ if (!banner) return;
1251
+ if (d.failed && d.failed.length) {
1252
+ banner.className = "mellea-banner reroll";
1253
+ banner.innerHTML = `<strong>↻ Mellea reroll</strong> — attempt ${(d.attempt|0)+1} failed: <code>${d.failed.join(", ")}</code>. Re-drafting…`;
1254
+ banner.style.display = "";
1255
+ } else {
1256
+ banner.className = "mellea-banner pass";
1257
+ banner.innerHTML = `<strong>✓ Mellea</strong> — all 4 grounding requirements satisfied`;
1258
+ banner.style.display = "";
1259
+ }
1260
+ });
1261
+ es.addEventListener("final", (e) => {
1262
+ const d = JSON.parse(e.data);
1263
+ const dt = ((Date.now() - t0) / 1000).toFixed(1);
1264
+ $("#traceMeta").textContent = `${dt}s`;
1265
+ setMapLoading(null);
1266
+ $("#reportSkel").style.display = "none";
1267
+ $("#paragraph").classList.remove("streaming");
1268
+ if (d.paragraph) {
1269
+ $("#reportPanel").style.display = "";
1270
+ streamBuf = d.paragraph;
1271
+ if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
1272
+ repaint();
1273
+ renderBriefHead(d);
1274
+ }
1275
+ renderFacts(d);
1276
+ fillMapForFinal(d);
1277
+ // Stash everything needed for the auditable-report page.
1278
+ LAST_RESULT = { query: q, finishedAt: new Date().toISOString(),
1279
+ wallSeconds: Number(dt), result: d };
1280
+ LAST_TRACE = TRACE_BUF.slice();
1281
+ LAST_PLAN = LAST_PLAN_OBJ;
1282
+ $("#reportBtn").classList.add("ready");
1283
+ });
1284
+ es.addEventListener("error", () => {});
1285
+ es.addEventListener("done", () => { es.close(); $("#goBtn").disabled = false; });
1286
+ }
1287
+
1288
+ // ---------------------------------------------------------------------------
1289
+ // wire
1290
+ // ---------------------------------------------------------------------------
1291
+
1292
+ // Bind form/sample handlers FIRST so a throw in ensureMap() (e.g. a
1293
+ // WebGL init failure) can't strand the user with a dead "Ask" button.
1294
+ $("#agentForm").addEventListener("submit", (e) => {
1295
+ e.preventDefault();
1296
+ const q = $("#q").value.trim();
1297
+ if (q) ask(q);
1298
+ });
1299
+ document.querySelectorAll(".sample-btn").forEach(b => {
1300
+ b.addEventListener("click", () => { $("#q").value = b.dataset.q; ask(b.dataset.q); });
1301
+ });
1302
+ try { ensureMap(); } catch (e) { console.error("ensureMap failed:", e); }
1303
+
1304
+ // Backend hardware pill: fetches /api/backend, renders "<HW> · <ENGINE>"
1305
+ // and a state color (green=primary up, amber=fallback active, red=down).
1306
+ // Refreshes every 60s so a flipped droplet shows up without a page reload.
1307
+ async function refreshBackendPill() {
1308
+ const pill = document.getElementById("backendPill");
1309
+ const text = document.getElementById("backendPillText");
1310
+ if (!pill || !text) return;
1311
+ try {
1312
+ const r = await fetch("/api/backend", { cache: "no-store" });
1313
+ if (!r.ok) throw new Error("status " + r.status);
1314
+ const info = await r.json();
1315
+ const onFallback = info.reachable === false && !!info.fallback_engine;
1316
+ const engine = onFallback ? info.fallback_engine : info.engine;
1317
+ const hw = onFallback ? "fallback" : info.hardware;
1318
+ text.textContent = `${hw} · Granite 4.1 / ${engine}`;
1319
+ pill.dataset.state =
1320
+ info.reachable ? "ok" :
1321
+ onFallback ? "fallback" : "down";
1322
+ const detail = info.vllm_base_url
1323
+ ? `Primary: ${info.engine} @ ${info.vllm_base_url}`
1324
+ : `Engine: ${info.engine}`;
1325
+ pill.title = info.reachable
1326
+ ? `${detail} — reachable. No vendor LLM is contacted.`
1327
+ : onFallback
1328
+ ? `${detail} unreachable; running on ${info.fallback_engine} fallback.`
1329
+ : `${detail} — UNREACHABLE.`;
1330
+ } catch (e) {
1331
+ text.textContent = "backend unknown";
1332
+ pill.dataset.state = "down";
1333
+ pill.title = "Could not query /api/backend: " + e.message;
1334
+ }
1335
+ }
1336
+ refreshBackendPill();
1337
+ setInterval(refreshBackendPill, 60000);
1338
+
1339
+ // Subscribe to the shared highlight signal so vanilla-rendered citation
1340
+ // chips in the briefing prose mirror the highlight state driven by the
1341
+ // Lit <r-sources-footer> (and vice versa).
1342
+ (async () => {
1343
+ const { highlightedDocId } = await import("/static/components/signals.js");
1344
+ const apply = () => {
1345
+ const id = highlightedDocId.get();
1346
+ document.querySelectorAll("#paragraph .cite").forEach(c => {
1347
+ c.classList.toggle("hl", c.dataset.srcId === id);
1348
+ });
1349
+ };
1350
+ // Lit-labs/signals exposes a subscribe / effect — try both shapes.
1351
+ if (typeof highlightedDocId.subscribe === "function") {
1352
+ highlightedDocId.subscribe(apply);
1353
+ } else {
1354
+ // Polyfill: poll on mutation. Cheap; signal updates are rare.
1355
+ const orig = highlightedDocId.set.bind(highlightedDocId);
1356
+ highlightedDocId.set = (v) => { orig(v); apply(); };
1357
+ }
1358
+ })();
1359
+
1360
+ // "Generate auditable report" — snapshots the live map, packs the full
1361
+ // evidence (query / plan / per-specialist trace / final result / per-source
1362
+ // vintages / labels / urls), parks it in sessionStorage, opens /report.
1363
+ $("#reportBtn").addEventListener("click", () => {
1364
+ if (!LAST_RESULT) return;
1365
+ let mapPng = null;
1366
+ try {
1367
+ if (map && map.loaded()) {
1368
+ // preserveDrawingBuffer:false would force a one-frame render here
1369
+ map.triggerRepaint();
1370
+ mapPng = map.getCanvas().toDataURL("image/png");
1371
+ }
1372
+ } catch (e) {
1373
+ console.warn("map snapshot failed", e);
1374
+ }
1375
+ const pkg = {
1376
+ ...LAST_RESULT,
1377
+ plan: LAST_PLAN,
1378
+ trace: LAST_TRACE,
1379
+ mapPng,
1380
+ sourceLabels: SOURCE_LABELS,
1381
+ sourceUrls: SOURCE_URLS,
1382
+ sourceVintages: SOURCE_VINTAGES,
1383
+ stepLabels: STEP_LABELS,
1384
+ };
1385
+ try {
1386
+ sessionStorage.setItem("riprap_report", JSON.stringify(pkg));
1387
+ window.open("/report", "_blank");
1388
+ } catch (e) {
1389
+ alert("Could not stash report payload (storage may be full): " + e.message);
1390
+ }
1391
+ });
web/static/app.js CHANGED
@@ -1,4 +1,4 @@
1
- // HeliOS-NYC web client. Subscribes to SSE, lights up FSM steps.
2
 
3
  const STEP_LABELS = {
4
  geocode: ["Geocode (DCP Geosearch)", "address → lat/lon, BBL"],
@@ -6,6 +6,10 @@ const STEP_LABELS = {
6
  dep_stormwater: ["DEP Stormwater Maps", "pluvial scenarios + 2080 SLR"],
7
  floodnet: ["FloodNet sensor network", "live ultrasonic depth sensors"],
8
  nyc311: ["NYC 311 archive", "flood complaints in buffer"],
 
 
 
 
9
  microtopo_lidar: ["LiDAR terrain (DEM + TWI + HAND)", "USGS 3DEP DEM + whitebox-workflows hydrology"],
10
  ida_hwm_2021: ["Ida 2021 high-water marks", "USGS empirical post-event extent"],
11
  prithvi_eo_v2: ["Prithvi-EO 2.0 (300M, NASA/IBM)", "Sen1Floods11 satellite water segmentation"],
@@ -15,6 +19,7 @@ const STEP_LABELS = {
15
 
16
  const STEPS_ORDER = [
17
  "geocode", "sandy_inundation", "dep_stormwater", "floodnet", "nyc311",
 
18
  "microtopo_lidar", "ida_hwm_2021", "prithvi_eo_v2",
19
  "rag_granite_embedding", "reconcile_granite41",
20
  ];
@@ -255,8 +260,45 @@ function rewriteCitations(text) {
255
  });
256
  }
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  function renderParagraph(text) {
259
- $("#paragraph").innerHTML = rewriteCitations(text);
 
 
260
  }
261
 
262
  const SOURCE_LABELS = {
@@ -275,6 +317,10 @@ const SOURCE_LABELS = {
275
  rag_coned: "Con Edison Climate Change Resilience Plan (Case 22-E-0222)",
276
  rag_mta: "MTA Climate Resilience Roadmap (Oct 2025)",
277
  rag_comptroller: "NYC Comptroller — \"Is NYC Ready for Rain?\" (2024)",
 
 
 
 
278
  };
279
 
280
  // ----------------------------------------------------------------------
@@ -282,29 +328,129 @@ const SOURCE_LABELS = {
282
  // evidence cards, policy quotes, methodology footer.
283
  // ----------------------------------------------------------------------
284
 
285
- function tierMeta(score) {
286
- // Mirror app/score.py rubric: ≥6 = T1, 4-5 = T2, 2-3 = T3, 1 = T4, 0 = T0.
287
- if (score >= 6) return {tier: 1, label: "High exposure", help: "Multiple positive flood signals — historical inundation and modeled scenarios both indicate substantial risk."};
288
- if (score >= 4) return {tier: 2, label: "Elevated exposure", help: "Significant overlap with at least one empirical or modeled scenario."};
289
- if (score >= 2) return {tier: 3, label: "Moderate exposure", help: "One or two positive signals; localised or scenario-specific risk."};
290
- if (score >= 1) return {tier: 4, label: "Limited exposure", help: "A single contextual signal; no positive scenario hits."};
291
- return {tier: 0, label: "No flagged exposure", help: "No positive flood signal across the assessed sources."};
 
 
 
 
 
 
292
  }
293
 
294
- function computeScore(ev) {
295
- // Mirror server-side rubric so we render consistently.
296
- let s = 0;
297
- if (ev.sandy) s += 3;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  const dep = ev.dep || {};
299
- if ((dep.dep_extreme_2080?.depth_class || 0) > 0) s += 2;
300
- if ((dep.dep_moderate_2050?.depth_class || 0) > 0) s += 2;
301
- if ((dep.dep_moderate_current?.depth_class || 0) > 0) s += 1;
302
- if ((ev.nyc311?.n || 0) >= 3) s += 1;
303
- if ((ev.floodnet?.n_flood_events_3y || 0) > 0) s += 1;
304
- if ((ev.rag || []).length) s += 1;
305
- return s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
 
 
 
 
308
  function renderHeader(ev) {
309
  const geo = ev.geocode || {};
310
  $("#reportAddr").textContent = geo.address || "(unresolved)";
@@ -314,12 +460,13 @@ function renderHeader(ev) {
314
  }
315
 
316
  function renderTier(ev) {
317
- const score = computeScore(ev);
318
- const m = tierMeta(score);
319
  const badge = $("#tierBadge");
320
  badge.className = "tier-badge t-" + m.tier;
321
  $("#tierNum").textContent = m.tier;
322
- $("#tierLabel").textContent = `Tier ${m.tier} ${m.label}`;
 
323
  $("#tierHelp").textContent = m.help;
324
  }
325
 
@@ -557,6 +704,106 @@ function renderEvidence(ev) {
557
  }));
558
  }
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  $("#evidenceCards").innerHTML = cards.join("");
561
  }
562
 
 
1
+ // Riprap web client subscribes to SSE, lights up FSM steps, renders the report.
2
 
3
  const STEP_LABELS = {
4
  geocode: ["Geocode (DCP Geosearch)", "address → lat/lon, BBL"],
 
6
  dep_stormwater: ["DEP Stormwater Maps", "pluvial scenarios + 2080 SLR"],
7
  floodnet: ["FloodNet sensor network", "live ultrasonic depth sensors"],
8
  nyc311: ["NYC 311 archive", "flood complaints in buffer"],
9
+ noaa_tides: ["NOAA Tides & Currents (live)", "Battery / Kings Pt / Sandy Hook water level"],
10
+ nws_alerts: ["NWS Public Alerts (live)", "active flood-relevant alerts at point"],
11
+ nws_obs: ["NWS METAR observation (live)", "nearest ASOS recent precipitation"],
12
+ ttm_forecast: ["Granite TTM r2 (TimeSeries)", "9.6h surge-residual nowcast at the Battery"],
13
  microtopo_lidar: ["LiDAR terrain (DEM + TWI + HAND)", "USGS 3DEP DEM + whitebox-workflows hydrology"],
14
  ida_hwm_2021: ["Ida 2021 high-water marks", "USGS empirical post-event extent"],
15
  prithvi_eo_v2: ["Prithvi-EO 2.0 (300M, NASA/IBM)", "Sen1Floods11 satellite water segmentation"],
 
19
 
20
  const STEPS_ORDER = [
21
  "geocode", "sandy_inundation", "dep_stormwater", "floodnet", "nyc311",
22
+ "noaa_tides", "nws_alerts", "nws_obs", "ttm_forecast",
23
  "microtopo_lidar", "ida_hwm_2021", "prithvi_eo_v2",
24
  "rag_granite_embedding", "reconcile_granite41",
25
  ];
 
260
  });
261
  }
262
 
263
+ function escapeHtml(s) {
264
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
265
+ }
266
+
267
+ function renderMarkdown(text) {
268
+ // Tiny safe markdown subset:
269
+ // **Header.** (on its own line) -> <h4 class="rsum-h">Header</h4>
270
+ // **inline bold** (mid-sentence) -> <strong>...</strong>
271
+ // We escape HTML first to defang any injection in model output.
272
+ const lines = text.split("\n");
273
+ const out = [];
274
+ let bodyBuf = [];
275
+ const flushBody = () => {
276
+ if (!bodyBuf.length) return;
277
+ const body = bodyBuf.join(" ").trim();
278
+ bodyBuf = [];
279
+ if (!body) return;
280
+ const safe = escapeHtml(body)
281
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
282
+ out.push(`<p class="rsum-p">${safe}</p>`);
283
+ };
284
+ const headerRe = /^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/;
285
+ for (const line of lines) {
286
+ const m = line.match(headerRe);
287
+ if (m) {
288
+ flushBody();
289
+ out.push(`<h4 class="rsum-h">${escapeHtml(m[1])}</h4>`);
290
+ } else {
291
+ bodyBuf.push(line);
292
+ }
293
+ }
294
+ flushBody();
295
+ return out.join("");
296
+ }
297
+
298
  function renderParagraph(text) {
299
+ // Build markdown structure FIRST, then rewrite citations inside. Citations
300
+ // are bracketed tokens like [sandy] which don't conflict with our markdown.
301
+ $("#paragraph").innerHTML = rewriteCitations(renderMarkdown(text));
302
  }
303
 
304
  const SOURCE_LABELS = {
 
317
  rag_coned: "Con Edison Climate Change Resilience Plan (Case 22-E-0222)",
318
  rag_mta: "MTA Climate Resilience Roadmap (Oct 2025)",
319
  rag_comptroller: "NYC Comptroller — \"Is NYC Ready for Rain?\" (2024)",
320
+ noaa_tides: "NOAA CO-OPS Tides & Currents — live water level (6-min)",
321
+ nws_alerts: "NWS Public Alerts API — active flood-relevant alerts",
322
+ nws_obs: "NWS Station Observations — nearest ASOS hourly METAR",
323
+ ttm_forecast: "Granite TimeSeries TTM r2 — surge-residual nowcast (Ekambaram et al. 2024, NeurIPS)",
324
  };
325
 
326
  // ----------------------------------------------------------------------
 
328
  // evidence cards, policy quotes, methodology footer.
329
  // ----------------------------------------------------------------------
330
 
331
+ // Tier meta — uses the new composite breakpoints, mirrors app/score.py.
332
+ // Tooltip copy explicitly states scope: exposure, not damage probability.
333
+ function tierMeta(tier) {
334
+ if (tier === 1) return {tier: 1, label: "High exposure",
335
+ help: "Multiple sub-indices saturated; empirical and/or modeled scenarios both indicate substantial exposure. Not a damage probability."};
336
+ if (tier === 2) return {tier: 2, label: "Elevated exposure",
337
+ help: "At least one sub-index near saturation; significant overlap with empirical or modeled scenarios. Not a damage probability."};
338
+ if (tier === 3) return {tier: 3, label: "Moderate exposure",
339
+ help: "Partial signals across categories; scenario- or neighborhood-specific exposure. Not a damage probability."};
340
+ if (tier === 4) return {tier: 4, label: "Limited exposure",
341
+ help: "A single contextual signal; no positive scenario hits."};
342
+ return {tier: 0, label: "No flagged exposure",
343
+ help: "No positive flood signal across the assessed sources."};
344
  }
345
 
346
+ // ---- Score computation: mirrors app/score.py.composite() exactly ---------
347
+ // Three thematic sub-indices, equal weights within each, max-empirical
348
+ // floor. Live signals (NWS alerts, surge, precip) are NOT in this score
349
+ // per IPCC AR6 WG II's distinction between exposure (static) and event
350
+ // occurrence (live).
351
+ const REG_W = {
352
+ fema_1pct: 1.0, fema_02pct: 0.5,
353
+ dep_moderate_2050: 0.75, dep_extreme_2080: 0.50, dep_tidal_2050: 0.75,
354
+ };
355
+ const HYD_W = {
356
+ hand_band: 1.0, twi_quartile: 0.5,
357
+ elev_pct_200m_inv: 0.5, elev_pct_750m_inv: 0.5, basin_relief_band: 0.25,
358
+ };
359
+ const EMP_W = {
360
+ sandy: 1.0,
361
+ ida_hwm_within_100m: 1.0, ida_hwm_within_800m: 0.5,
362
+ prithvi_polygon: 0.75, complaints_band: 0.75, floodnet_trigger: 0.75,
363
+ };
364
+
365
+ const handBand = (h) => h == null ? 0 : (h < 1 ? 1 : h < 3 ? 0.66 : h < 10 ? 0.33 : 0);
366
+ const pctInvBand = (p) => p == null ? 0 : (p < 10 ? 1 : p < 25 ? 0.66 : p < 50 ? 0.33 : 0);
367
+ const twiBand = (t) => t == null ? 0 : (t >= 12 ? 1 : t >= 10 ? 0.66 : t >= 8 ? 0.33 : 0);
368
+ const reliefBand = (r) => r == null ? 0 : (r >= 8 ? 1 : r >= 4 ? 0.66 : r >= 2 ? 0.33 : 0);
369
+ const complBand = (n) => !n ? 0 : (n >= 10 ? 1 : n >= 3 ? 0.66 : 0.33);
370
+ const sumW = (w) => Object.values(w).reduce((a, b) => a + b, 0);
371
+
372
+ function computeComposite(ev) {
373
  const dep = ev.dep || {};
374
+ const mt = ev.microtopo || {};
375
+ const ida = ev.ida_hwm || {};
376
+ const pw = ev.prithvi_water || {};
377
+
378
+ // Build the signal dict in the shape app/score.py expects.
379
+ const s = {
380
+ // Regulatory
381
+ fema_1pct: false, // not yet wired in this build
382
+ fema_02pct: false,
383
+ dep_moderate_2050: (dep.dep_moderate_2050?.depth_class || 0) > 0,
384
+ dep_extreme_2080: (dep.dep_extreme_2080?.depth_class || 0) > 0,
385
+ dep_tidal_2050: false, // tidal scenario not in current FSM
386
+ // Hydrological
387
+ hand_m: mt.hand_m,
388
+ twi: mt.twi,
389
+ rel_elev_pct_200m: mt.rel_elev_pct_200m,
390
+ rel_elev_pct_750m: mt.rel_elev_pct_750m,
391
+ basin_relief_m: mt.basin_relief_m,
392
+ // Empirical
393
+ sandy: !!ev.sandy,
394
+ ida_hwm_within_100m: (ida.nearest_dist_m != null && ida.nearest_dist_m < 100) ||
395
+ (ida.n_within_radius || 0) > 0 && (ida.nearest_dist_m || 9999) < 100,
396
+ ida_hwm_within_800m: (ida.n_within_radius || 0) > 0,
397
+ prithvi_polygon: !!pw.inside_water_polygon,
398
+ complaints_count: ev.nyc311?.n || 0,
399
+ floodnet_trigger: (ev.floodnet?.n_flood_events_3y || 0) > 0,
400
+ };
401
+
402
+ // Regulatory sub-index (binary signals)
403
+ let regRaw = 0;
404
+ for (const [k, w] of Object.entries(REG_W)) regRaw += s[k] ? w : 0;
405
+ const reg = regRaw / sumW(REG_W);
406
+
407
+ // Hydrological sub-index (banded continuous)
408
+ const hydBands = {
409
+ hand_band: handBand(s.hand_m),
410
+ twi_quartile: twiBand(s.twi),
411
+ elev_pct_200m_inv: pctInvBand(s.rel_elev_pct_200m),
412
+ elev_pct_750m_inv: pctInvBand(s.rel_elev_pct_750m),
413
+ basin_relief_band: reliefBand(s.basin_relief_m),
414
+ };
415
+ let hydRaw = 0;
416
+ for (const [k, w] of Object.entries(HYD_W)) hydRaw += w * hydBands[k];
417
+ const hyd = hydRaw / sumW(HYD_W);
418
+
419
+ // Empirical sub-index
420
+ const empVals = {
421
+ sandy: s.sandy ? 1 : 0,
422
+ ida_hwm_within_100m: s.ida_hwm_within_100m ? 1 : 0,
423
+ ida_hwm_within_800m: s.ida_hwm_within_800m ? 1 : 0,
424
+ prithvi_polygon: s.prithvi_polygon ? 1 : 0,
425
+ complaints_band: complBand(s.complaints_count),
426
+ floodnet_trigger: s.floodnet_trigger ? 1 : 0,
427
+ };
428
+ let empRaw = 0;
429
+ for (const [k, w] of Object.entries(EMP_W)) empRaw += w * empVals[k];
430
+ const emp = empRaw / sumW(EMP_W);
431
+
432
+ const composite = reg + hyd + emp;
433
+
434
+ // Tier breakpoints (mirror score.py)
435
+ let tier = 0;
436
+ if (composite >= 1.50) tier = 1;
437
+ else if (composite >= 1.00) tier = 2;
438
+ else if (composite >= 0.50) tier = 3;
439
+ else if (composite >= 0.01) tier = 4;
440
+
441
+ // Max-empirical floor: Sandy or HWM-within-100m → tier ≤ 2
442
+ const floorApplied = !!(s.sandy || s.ida_hwm_within_100m);
443
+ if (floorApplied && (tier === 0 || tier > 2)) tier = 2;
444
+
445
+ return {
446
+ subindices: {regulatory: reg, hydrological: hyd, empirical: emp},
447
+ composite, tier, floorApplied,
448
+ };
449
  }
450
 
451
+ // Backward-compat shim: places that called computeScore() now read .tier.
452
+ function computeScore(ev) { return computeComposite(ev).tier; }
453
+
454
  function renderHeader(ev) {
455
  const geo = ev.geocode || {};
456
  $("#reportAddr").textContent = geo.address || "(unresolved)";
 
460
  }
461
 
462
  function renderTier(ev) {
463
+ const c = computeComposite(ev);
464
+ const m = tierMeta(c.tier);
465
  const badge = $("#tierBadge");
466
  badge.className = "tier-badge t-" + m.tier;
467
  $("#tierNum").textContent = m.tier;
468
+ const floor = c.floorApplied ? " · empirical floor" : "";
469
+ $("#tierLabel").textContent = `Tier ${m.tier} — ${m.label}${floor}`;
470
  $("#tierHelp").textContent = m.help;
471
  }
472
 
 
704
  }));
705
  }
706
 
707
+ // Live signals — refresh every query, may produce nothing on a calm day.
708
+ const tides = ev.noaa_tides;
709
+ if (tides && tides.observed_ft_mllw != null) {
710
+ const rows = [
711
+ ["Gauge", `${tides.station_name} (${tides.station_id})`],
712
+ ["Distance to gauge", `${tides.distance_km} km`],
713
+ ["Observed", `${tides.observed_ft_mllw} ft above MLLW`],
714
+ ];
715
+ if (tides.predicted_ft_mllw != null)
716
+ rows.push(["Predicted (astro tide)", `${tides.predicted_ft_mllw} ft`]);
717
+ if (tides.residual_ft != null)
718
+ rows.push(["Residual (obs − pred)", `${tides.residual_ft >= 0 ? "+" : ""}${tides.residual_ft} ft`]);
719
+ if (tides.obs_time)
720
+ rows.push(["Observation time", tides.obs_time]);
721
+ const flag = (tides.residual_ft != null && tides.residual_ft >= 1.0) ? "hit" : "note";
722
+ cards.push(evCard({
723
+ key: "noaa_tides",
724
+ title: "NOAA Tides & Currents — live coastal water level",
725
+ flag, rows,
726
+ sourceText: "NOAA CO-OPS API (api.tidesandcurrents.noaa.gov)",
727
+ sourceUrl: `https://tidesandcurrents.noaa.gov/stationhome.html?id=${tides.station_id}`,
728
+ vintage: "live, 6-min cadence; residual ≈ surge",
729
+ collapsed: false,
730
+ }));
731
+ }
732
+
733
+ const al = ev.nws_alerts;
734
+ if (al && al.n_active > 0) {
735
+ const rows = [["Active flood-relevant alerts", String(al.n_active)]];
736
+ (al.alerts || []).slice(0, 3).forEach((a, i) => {
737
+ rows.push([
738
+ `Alert ${i + 1}`,
739
+ `${a.event} (${a.severity || "?"} / ${a.urgency || "?"}) — expires ${
740
+ (a.expires || "").slice(0, 16)
741
+ }`,
742
+ ]);
743
+ });
744
+ cards.push(evCard({
745
+ key: "nws_alerts",
746
+ title: "NWS — active flood alerts at this point",
747
+ flag: "hit", rows,
748
+ sourceText: "NWS Public Alerts API (api.weather.gov)",
749
+ sourceUrl: "https://www.weather.gov/documentation/services-web-api",
750
+ vintage: "live, push-cadence (refresh on event)",
751
+ collapsed: false,
752
+ }));
753
+ }
754
+
755
+ const obs = ev.nws_obs;
756
+ if (obs && obs.station_id && !obs.error && (
757
+ obs.precip_last_hour_mm != null ||
758
+ obs.precip_last_6h_mm != null)) {
759
+ const rows = [
760
+ ["Nearest ASOS station", `${obs.station_name} (${obs.station_id})`],
761
+ ["Distance", `${obs.distance_km} km`],
762
+ ];
763
+ if (obs.precip_last_hour_mm != null)
764
+ rows.push(["Precip last 1 h", `${obs.precip_last_hour_mm} mm`]);
765
+ if (obs.precip_last_3h_mm != null)
766
+ rows.push(["Precip last 3 h", `${obs.precip_last_3h_mm} mm`]);
767
+ if (obs.precip_last_6h_mm != null)
768
+ rows.push(["Precip last 6 h", `${obs.precip_last_6h_mm} mm`]);
769
+ if (obs.obs_time)
770
+ rows.push(["Observation time", obs.obs_time]);
771
+ const heavy = (obs.precip_last_hour_mm || 0) >= 10 ||
772
+ (obs.precip_last_6h_mm || 0) >= 25;
773
+ cards.push(evCard({
774
+ key: "nws_obs",
775
+ title: "NWS hourly METAR — recent precipitation",
776
+ flag: heavy ? "hit" : "note", rows,
777
+ sourceText: "NWS station observations API",
778
+ sourceUrl: `https://www.weather.gov/wrh/timeseries?site=${obs.station_id}`,
779
+ vintage: "live, ~hourly",
780
+ collapsed: false,
781
+ }));
782
+ }
783
+
784
+ const ttm = ev.ttm_forecast;
785
+ if (ttm && ttm.available) {
786
+ const peak = ttm.forecast_peak_ft;
787
+ const rows = [
788
+ ["Gauge", `${ttm.station_name} (NOAA ${ttm.station_id})`],
789
+ ["Recent residual", `${ttm.history_recent_ft} ft`],
790
+ ["Recent peak |residual|", `${ttm.history_peak_abs_ft} ft (last ~51 h)`],
791
+ ["Forecast peak residual", `${peak >= 0 ? "+" : ""}${peak} ft`],
792
+ ["Forecast peak time", `~${ttm.forecast_peak_minutes_ahead} min ahead (${(ttm.forecast_peak_time_utc || "").slice(11, 16)} UTC)`],
793
+ ["Threshold", `±${ttm.threshold_ft} ft (gate for emission)`],
794
+ ];
795
+ const flag = ttm.interesting ? (Math.abs(peak) >= 0.5 ? "hit" : "note") : "miss";
796
+ cards.push(evCard({
797
+ key: "ttm_forecast",
798
+ title: "Granite TimeSeries TTM r2 — surge nowcast",
799
+ flag, rows,
800
+ sourceText: "IBM Granite TimeSeries TTM r2 (Ekambaram et al. 2024, NeurIPS)",
801
+ sourceUrl: "https://huggingface.co/ibm-granite/granite-timeseries-ttm-r2",
802
+ vintage: "zero-shot multivariate forecaster, ~1.5M params; runs on CPU",
803
+ collapsed: !ttm.interesting,
804
+ }));
805
+ }
806
+
807
  $("#evidenceCards").innerHTML = cards.join("");
808
  }
809
 
web/static/components/briefing.js ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // <r-briefing> — the streaming-token, citation-chipped briefing panel.
2
+ //
3
+ // Replaces the agent.js renderMarkdown + rewriteCitations + paint
4
+ // scheduler. Token streaming becomes "append to a signal, re-render."
5
+ //
6
+ // Properties:
7
+ // text — full markdown text (set by parent on token / final events)
8
+ // streaming — bool; shows the blinking caret
9
+ // citeIndex — { doc_id: number } shared with <r-sources-footer>
10
+ // sourceLabels — passed through for chip tooltips
11
+ //
12
+ // Signals consumed:
13
+ // highlightedDocId — toggles `.hl` on chips reactively (set by
14
+ // <r-sources-footer> on hover)
15
+ // Signals updated:
16
+ // citeIndex — populated as citations are encountered in the text
17
+ // highlightedDocId — set on chip hover/click
18
+
19
+ import { html, LitElement } from "https://esm.sh/lit@3";
20
+ import { unsafeHTML } from "https://esm.sh/lit@3/directives/unsafe-html.js";
21
+ import { SignalWatcher } from "https://esm.sh/@lit-labs/signals@0.1.x";
22
+ import { citeIndex, highlightedDocId } from "./signals.js";
23
+
24
+ // Same minimal markdown subset as agent.js renderMarkdown — kept
25
+ // duplicated for now; will collapse when agent.js stops calling
26
+ // renderMarkdown. After full port this is the only impl.
27
+ function renderMarkdownPure(text) {
28
+ const lines = text.split("\n");
29
+ const out = [];
30
+ let para = []; let bullets = [];
31
+ const escapeHtml = (s) =>
32
+ String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
33
+ const flushPara = () => {
34
+ if (!para.length) return;
35
+ const safe = escapeHtml(para.join(" ").trim())
36
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
37
+ if (safe) out.push(`<p class="rsum-p">${safe}</p>`);
38
+ para = [];
39
+ };
40
+ const flushBullets = () => {
41
+ if (!bullets.length) return;
42
+ const items = bullets.map(b => {
43
+ const safe = escapeHtml(b.trim()).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
44
+ return `<li>${safe}</li>`;
45
+ }).join("");
46
+ out.push(`<ul class="rsum-list">${items}</ul>`);
47
+ bullets = [];
48
+ };
49
+ // Granite sometimes runs all bullets onto one line.
50
+ const expanded = [];
51
+ for (const line of lines) {
52
+ if (line.trim().startsWith("- ") && line.includes(" - ", 2)) {
53
+ const parts = line.split(/(?:^|(?<=\.\s))\s*-\s+/g).filter(p => p.trim());
54
+ for (const p of parts) expanded.push("- " + p.trim());
55
+ } else { expanded.push(line); }
56
+ }
57
+ for (const line of expanded) {
58
+ const m = line.match(/^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/);
59
+ if (m) { flushPara(); flushBullets(); out.push(`<h4 class="rsum-h">${escapeHtml(m[1])}</h4>`); }
60
+ else if (/^\s*[-*]\s+/.test(line)) { flushPara(); bullets.push(line.replace(/^\s*[-*]\s+/, "")); }
61
+ else { flushBullets(); para.push(line); }
62
+ }
63
+ flushPara(); flushBullets();
64
+ return out.join("");
65
+ }
66
+
67
+ function rewriteCitations(html, sourceLabels, indexMap) {
68
+ return html.replace(/\[([a-z0-9_]+)\]/gi, (_, id) => {
69
+ const norm = id.toLowerCase();
70
+ if (indexMap[norm] == null) indexMap[norm] = Object.keys(indexMap).length + 1;
71
+ const n = indexMap[norm];
72
+ const lab = sourceLabels[norm] || norm;
73
+ return `<span class="cite" data-src-id="${norm}" data-src-n="${n}" title="${lab.replace(/"/g, "&quot;")} — click to highlight in Sources">${n}</span>`;
74
+ });
75
+ }
76
+
77
+ export class Briefing extends SignalWatcher(LitElement) {
78
+ static properties = {
79
+ text: { type: String },
80
+ streaming: { type: Boolean, reflect: true },
81
+ sourceLabels: { type: Object },
82
+ };
83
+
84
+ // No shadow DOM — we use the parent's `.report-pane #paragraph` styles
85
+ // directly so the markdown renders match the legacy/print idiom.
86
+ createRenderRoot() { return this; }
87
+
88
+ constructor() {
89
+ super();
90
+ this.text = "";
91
+ this.streaming = false;
92
+ this.sourceLabels = {};
93
+ }
94
+
95
+ updated(changed) {
96
+ if (changed.has("text") && this.text) {
97
+ // Bind chip hover/click to the highlight signal post-render.
98
+ this._bindChips();
99
+ }
100
+ }
101
+
102
+ _bindChips() {
103
+ this.querySelectorAll(".cite").forEach(c => {
104
+ const id = c.dataset.srcId;
105
+ if (!id || c.dataset.signalBound) return;
106
+ c.dataset.signalBound = "1";
107
+ c.addEventListener("mouseenter", () => highlightedDocId.set(id));
108
+ c.addEventListener("click", (e) => {
109
+ e.stopPropagation();
110
+ const cur = highlightedDocId.get();
111
+ highlightedDocId.set(cur === id ? null : id);
112
+ });
113
+ });
114
+ // Apply highlight class reactively from current signal value.
115
+ const hl = highlightedDocId.get();
116
+ this.querySelectorAll(".cite").forEach(c => {
117
+ c.classList.toggle("hl", c.dataset.srcId === hl);
118
+ });
119
+ }
120
+
121
+ render() {
122
+ if (!this.text) return html`<div class="rsum-p" style="color:var(--text-muted)">Waiting for content…</div>`;
123
+ const indexMap = {};
124
+ const md = renderMarkdownPure(this.text);
125
+ const withCites = rewriteCitations(md, this.sourceLabels, indexMap);
126
+ // Push the citation index up to the shared signal so SourcesFooter
127
+ // re-renders. Done in render() because indexMap is computed here.
128
+ queueMicrotask(() => citeIndex.set({ ...indexMap }));
129
+ return html`${unsafeHTML(withCites)}`;
130
+ }
131
+ }
132
+
133
+ customElements.define("r-briefing", Briefing);
web/static/components/signals.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared reactive state for Riprap web components.
2
+ //
3
+ // Lit components import these signals; updating one signal re-renders
4
+ // every subscribed component. Replaces the hand-wired DOM-querying
5
+ // cross-linking we used to do in vanilla JS.
6
+
7
+ import { signal } from "https://esm.sh/@lit-labs/signals@0.1.x";
8
+
9
+ // Currently-highlighted citation doc_id. When a Briefing chip is hovered
10
+ // or clicked, this gets set; SourcesFooter observes it and highlights
11
+ // the matching row, and vice versa.
12
+ export const highlightedDocId = signal(null);
13
+
14
+ // The full agent run output (from /api/agent/stream `final` event).
15
+ // Components that need the result post-render read from this.
16
+ export const lastResult = signal(null);
17
+
18
+ // The cite-index map { doc_id: number } populated by Briefing as it
19
+ // renders the streamed markdown. SourcesFooter reads it to know which
20
+ // numbered rows to render.
21
+ export const citeIndex = signal({});
web/static/components/sources-footer.js ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // <r-sources-footer> — numbered, hyperlinked, vintage-aware Sources
2
+ // section that appears below the Briefing in the agent UI and inside
3
+ // the printable /report.
4
+ //
5
+ // Shared signals power the cross-linking with <r-briefing>: hovering
6
+ // a [N] chip in the prose highlights the matching <li> here, and
7
+ // clicking either side persists the highlight + scrolls into view.
8
+ //
9
+ // Mounts via <r-sources-footer></r-sources-footer>. Reads:
10
+ // - citeIndex — { doc_id: number } from Briefing
11
+ // - highlightedDocId — current highlight target (in/out)
12
+ // Plus three label/url/vintage maps passed in as properties.
13
+
14
+ import { html, css, LitElement } from "https://esm.sh/lit@3";
15
+ import { SignalWatcher } from "https://esm.sh/@lit-labs/signals@0.1.x";
16
+ import { citeIndex, highlightedDocId } from "./signals.js";
17
+
18
+ export class SourcesFooter extends SignalWatcher(LitElement) {
19
+ static properties = {
20
+ labels: { type: Object },
21
+ urls: { type: Object },
22
+ vintages: { type: Object },
23
+ };
24
+
25
+ static styles = css`
26
+ :host {
27
+ display: block;
28
+ border-top: 1px solid var(--line, #e5e7eb);
29
+ background: var(--bg-soft, #f5f7fb);
30
+ padding: 12px 16px 14px;
31
+ }
32
+ :host([hidden]) { display: none; }
33
+ .src-h {
34
+ font-size: 10px; font-weight: 700;
35
+ text-transform: uppercase; letter-spacing: 0.10em;
36
+ color: var(--text-muted, #6b7280);
37
+ margin: 0 0 8px;
38
+ }
39
+ ol {
40
+ margin: 0; padding: 0; list-style: none;
41
+ display: grid; gap: 6px;
42
+ font-size: 11.5px; line-height: 1.45;
43
+ }
44
+ li {
45
+ display: grid; grid-template-columns: 22px 1fr;
46
+ gap: 8px; align-items: baseline;
47
+ padding: 4px 6px; border-radius: 3px;
48
+ cursor: pointer;
49
+ transition: background 0.15s;
50
+ }
51
+ li:hover, li.hl {
52
+ background: rgba(22, 66, 223, 0.10);
53
+ }
54
+ .src-num {
55
+ font-family: var(--mono, monospace); font-size: 10.5px;
56
+ font-weight: 700; color: var(--nyc-blue, #1642DF);
57
+ text-align: right;
58
+ }
59
+ .src-link {
60
+ color: var(--text, #111); text-decoration: none;
61
+ border-bottom: 1px dotted var(--text-muted, #6b7280);
62
+ transition: color 0.12s, border-color 0.12s;
63
+ }
64
+ .src-link:hover {
65
+ color: var(--nyc-blue, #1642DF);
66
+ border-bottom-color: var(--nyc-blue, #1642DF);
67
+ }
68
+ .src-ext {
69
+ font-size: 9.5px; color: var(--text-faint, #9ca3af);
70
+ margin-left: 2px; vertical-align: super;
71
+ }
72
+ .src-vintage {
73
+ display: block; color: var(--text-muted, #6b7280);
74
+ font-size: 9.5px; margin-top: 2px;
75
+ }
76
+ .src-id {
77
+ display: inline-block;
78
+ font-family: var(--mono, monospace); font-size: 9.5px;
79
+ color: var(--text-faint, #9ca3af); margin-left: 6px;
80
+ }
81
+ `;
82
+
83
+ constructor() {
84
+ super();
85
+ this.labels = {};
86
+ this.urls = {};
87
+ this.vintages = {};
88
+ }
89
+
90
+ _entries() {
91
+ return Object.entries(citeIndex.get() || {}).sort((a, b) => a[1] - b[1]);
92
+ }
93
+
94
+ _onHover(id) {
95
+ highlightedDocId.set(id);
96
+ }
97
+
98
+ _onLeave() {
99
+ // Only clear if not pinned by click — keep highlight on click.
100
+ // For now, hover-only highlight clears on leave.
101
+ }
102
+
103
+ _onClick(id) {
104
+ const cur = highlightedDocId.get();
105
+ highlightedDocId.set(cur === id ? null : id);
106
+ }
107
+
108
+ render() {
109
+ const entries = this._entries();
110
+ if (!entries.length) {
111
+ this.setAttribute("hidden", "");
112
+ return html``;
113
+ }
114
+ this.removeAttribute("hidden");
115
+ const hl = highlightedDocId.get();
116
+ return html`
117
+ <div class="src-h">Sources</div>
118
+ <ol>
119
+ ${entries.map(([id, n]) => {
120
+ const url = this.urls[id];
121
+ const label = this.labels[id] || id;
122
+ const vintage = this.vintages[id];
123
+ const cls = id === hl ? "hl" : "";
124
+ return html`
125
+ <li class="${cls}"
126
+ @mouseenter=${() => this._onHover(id)}
127
+ @click=${() => this._onClick(id)}>
128
+ <span class="src-num">[${n}]</span>
129
+ <div>
130
+ ${url
131
+ ? html`<a class="src-link" href="${url}" target="_blank" rel="noopener noreferrer" @click=${(e) => e.stopPropagation()}>${label} <span class="src-ext">↗</span></a>`
132
+ : html`<span>${label}</span>`}
133
+ <span class="src-id">${id}</span>
134
+ ${vintage ? html`<span class="src-vintage">${vintage}</span>` : ""}
135
+ </div>
136
+ </li>
137
+ `;
138
+ })}
139
+ </ol>
140
+ `;
141
+ }
142
+ }
143
+
144
+ customElements.define("r-sources-footer", SourcesFooter);
web/static/components/trace.js ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // <r-trace> — specialist trail. Reactive list of pipeline steps.
2
+ //
3
+ // API:
4
+ // .pushStep(step) — append a {step, ok, elapsed_s, result, err} record
5
+ // .clear() — reset
6
+ // .meta = "1.4s" — text shown in the header
7
+ // .stepLabels = {...} — { stepName: [label, hint] } map (set once at boot)
8
+ //
9
+ // Light DOM (no shadow) so the existing `#steps li.ok / .err / .running`
10
+ // CSS in agent.html keeps applying without rewrites.
11
+
12
+ import { html, css, LitElement } from "https://esm.sh/lit@3";
13
+
14
+ const escapeHtml = (s) =>
15
+ String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
16
+
17
+ export class Trace extends LitElement {
18
+ static properties = {
19
+ steps: { type: Array, state: true },
20
+ meta: { type: String, reflect: true },
21
+ stepLabels: { type: Object },
22
+ };
23
+
24
+ createRenderRoot() { return this; }
25
+
26
+ constructor() {
27
+ super();
28
+ this.steps = [];
29
+ this.meta = "";
30
+ this.stepLabels = {};
31
+ }
32
+
33
+ pushStep(step) {
34
+ this.steps = [...this.steps, step];
35
+ }
36
+
37
+ clear() {
38
+ this.steps = [];
39
+ this.meta = "";
40
+ }
41
+
42
+ _renderStep(step) {
43
+ const [label, hint] = this.stepLabels[step.step] || [step.step, ""];
44
+ const ok = step.ok === true;
45
+ const fail = step.ok === false;
46
+ const cls = ok ? "ok" : fail ? "err" : "running";
47
+ const mark = ok ? "✓" : fail ? "✗" : "○";
48
+ const time = step.elapsed_s != null
49
+ ? `<span class="time">${step.elapsed_s}s</span>` : "";
50
+ const result = step.result
51
+ ? `<div class="result">${escapeHtml(JSON.stringify(step.result))}</div>` : "";
52
+ const err = step.err
53
+ ? `<div class="result" style="color:var(--nyc-scarlet)">${escapeHtml(step.err)}</div>` : "";
54
+ // Inner HTML is hand-built so the existing list CSS targets the same
55
+ // structure as the legacy renderer; we keep .innerHTML rather than
56
+ // Lit's html`` for byte-for-byte parity here.
57
+ const li = document.createElement("li");
58
+ li.className = cls;
59
+ li.innerHTML = `
60
+ <span class="icon">${mark}</span>
61
+ <div>
62
+ <div class="label">${escapeHtml(label)}</div>
63
+ <div class="meta">${escapeHtml(hint)}</div>
64
+ </div>
65
+ ${time}
66
+ ${result}
67
+ ${err}
68
+ `;
69
+ return li;
70
+ }
71
+
72
+ render() {
73
+ // Render the <ol> as innerHTML on update so we don't fight Lit's
74
+ // template diffing for raw HTML lists.
75
+ queueMicrotask(() => {
76
+ const ol = this.querySelector("ol#steps-list");
77
+ if (!ol) return;
78
+ ol.innerHTML = "";
79
+ for (const s of this.steps) ol.appendChild(this._renderStep(s));
80
+ });
81
+ // Inline reset so the legacy `#steps { list-style: none; ... }` rules
82
+ // (which now target the host element, not the <ol>) keep applying.
83
+ return html`<ol id="steps-list" style="list-style:none; margin:0; padding:4px 0; font-size:12.5px;"></ol>`;
84
+ }
85
+ }
86
+
87
+ customElements.define("r-trace", Trace);
web/static/dist/riprap.js ADDED
The diff for this file is too large to render. See raw diff
 
web/static/dist/riprap.js.map ADDED
The diff for this file is too large to render. See raw diff
 
web/static/index.html CHANGED
@@ -14,7 +14,7 @@
14
  <div class="brand">
15
  <span class="brand-name">Riprap</span>
16
  <span class="brand-sep">·</span>
17
- <span class="brand-tag">citation-grounded civic AI for NYC flood risk</span>
18
  </div>
19
  <div class="topbar-right">
20
  <a href="/compare" class="modelink">compare</a>
 
14
  <div class="brand">
15
  <span class="brand-name">Riprap</span>
16
  <span class="brand-sep">·</span>
17
+ <span class="brand-tag">citation-grounded flood-exposure briefings for NYC not a risk score</span>
18
  </div>
19
  <div class="topbar-right">
20
  <a href="/compare" class="modelink">compare</a>
web/static/report.html ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Riprap — auditable flood-exposure report</title>
6
+ <link rel="stylesheet" href="/static/style.css">
7
+ <style>
8
+ :root {
9
+ --paper-w: 8.5in;
10
+ --paper-pad: 0.55in;
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body {
14
+ background: #e9eef5; margin: 0; padding: 24px 0;
15
+ font-family: var(--font-sans), system-ui, sans-serif;
16
+ color: var(--text);
17
+ }
18
+ .report-controls {
19
+ max-width: var(--paper-w); margin: 0 auto 16px; padding: 0 12px;
20
+ display: flex; gap: 12px; align-items: center;
21
+ }
22
+ .report-controls .lhs {
23
+ font-size: 12px; color: var(--text-muted);
24
+ }
25
+ .report-controls .actions {
26
+ margin-left: auto; display: flex; gap: 8px;
27
+ }
28
+ .btn-primary {
29
+ padding: 8px 16px; border: 0; border-radius: 4px;
30
+ background: var(--nyc-blue); color: white;
31
+ font-weight: 600; font-size: 13px; cursor: pointer;
32
+ font-family: inherit;
33
+ }
34
+ .btn-secondary {
35
+ padding: 8px 16px; border: 1px solid var(--line);
36
+ background: var(--panel); color: var(--text);
37
+ border-radius: 4px; font-size: 13px; cursor: pointer;
38
+ font-family: inherit; font-weight: 500;
39
+ }
40
+ .btn-primary:hover { background: #0f329d; }
41
+ .btn-secondary:hover { background: var(--bg-soft); }
42
+
43
+ .paper {
44
+ background: white;
45
+ width: var(--paper-w);
46
+ margin: 0 auto;
47
+ padding: var(--paper-pad);
48
+ box-shadow: 0 4px 24px rgba(0,0,0,0.08);
49
+ font-size: 11pt;
50
+ line-height: 1.45;
51
+ color: #111;
52
+ }
53
+
54
+ /* report-specific typography */
55
+ .r-head {
56
+ border-bottom: 2px solid #111;
57
+ padding-bottom: 12px; margin-bottom: 18px;
58
+ }
59
+ .r-brand {
60
+ font-weight: 700; font-size: 18pt; color: var(--nyc-blue);
61
+ letter-spacing: -0.01em;
62
+ }
63
+ .r-tagline {
64
+ font-size: 9pt; color: #555; margin-top: 2px;
65
+ text-transform: uppercase; letter-spacing: 0.06em;
66
+ }
67
+ .r-meta-grid {
68
+ display: grid; grid-template-columns: max-content 1fr;
69
+ gap: 4px 12px; font-size: 9.5pt; margin-top: 12px;
70
+ }
71
+ .r-meta-grid dt {
72
+ color: #666; font-weight: 600;
73
+ text-transform: uppercase; letter-spacing: 0.05em;
74
+ font-size: 8.5pt;
75
+ }
76
+ .r-meta-grid dd { margin: 0; color: #111; }
77
+ .r-meta-grid dd.mono { font-family: var(--mono); font-size: 9pt; }
78
+
79
+ .r-section {
80
+ margin-top: 22px;
81
+ page-break-inside: avoid;
82
+ }
83
+ .r-section h2 {
84
+ font-size: 9pt; font-weight: 700;
85
+ text-transform: uppercase; letter-spacing: 0.10em;
86
+ color: #444; margin: 0 0 8px;
87
+ border-bottom: 1px solid #ccc; padding-bottom: 4px;
88
+ }
89
+ .r-section .lead { font-size: 10pt; color: #333; margin-bottom: 8px; }
90
+
91
+ .r-query {
92
+ background: #f5f7fb; padding: 10px 14px;
93
+ border-left: 3px solid var(--nyc-blue);
94
+ font-style: italic; font-size: 11pt; color: #222;
95
+ }
96
+
97
+ .r-plan {
98
+ display: grid; grid-template-columns: max-content 1fr;
99
+ gap: 4px 12px; font-size: 9.5pt;
100
+ }
101
+ .r-plan dt {
102
+ color: #666; font-weight: 600;
103
+ text-transform: uppercase; letter-spacing: 0.05em; font-size: 8pt;
104
+ }
105
+ .r-plan dd { margin: 0; }
106
+ .r-plan dd.mono { font-family: var(--mono); font-size: 9pt; }
107
+ .r-plan-rationale {
108
+ grid-column: 1 / -1;
109
+ margin-top: 6px; padding-top: 6px;
110
+ border-top: 1px dotted #ccc;
111
+ color: #444; font-style: italic; font-size: 9.5pt;
112
+ }
113
+
114
+ .r-trace {
115
+ width: 100%; border-collapse: collapse; font-size: 8.5pt;
116
+ }
117
+ .r-trace th, .r-trace td {
118
+ text-align: left; padding: 5px 8px;
119
+ border-bottom: 1px solid #eee;
120
+ vertical-align: top;
121
+ }
122
+ .r-trace th {
123
+ background: #f5f7fb; color: #444;
124
+ font-weight: 700; text-transform: uppercase;
125
+ letter-spacing: 0.05em; font-size: 7.5pt;
126
+ }
127
+ .r-trace tr.ok .mark { color: #1a8754; }
128
+ .r-trace tr.err .mark { color: #c0392b; }
129
+ .r-trace .mono { font-family: var(--mono); }
130
+ .r-trace .mark { font-weight: 700; }
131
+ .r-trace .result {
132
+ font-family: var(--mono); font-size: 7.5pt;
133
+ color: #555; word-break: break-word;
134
+ max-width: 280px;
135
+ }
136
+ .r-trace .err-msg {
137
+ color: #c0392b; font-style: italic; font-size: 8pt;
138
+ }
139
+
140
+ .r-map { margin-top: 6px; }
141
+ .r-map img {
142
+ width: 100%; height: auto; display: block;
143
+ border: 1px solid #ddd; border-radius: 2px;
144
+ }
145
+ .r-map .legend-cap {
146
+ font-size: 8pt; color: #666; margin-top: 4px;
147
+ text-align: center; font-style: italic;
148
+ }
149
+ .r-map.no-map {
150
+ padding: 24px; background: #f5f7fb; border: 1px dashed #ccc;
151
+ color: #888; text-align: center; font-size: 9pt;
152
+ }
153
+
154
+ .r-briefing { font-size: 10pt; }
155
+ .r-briefing h4 {
156
+ font-size: 9pt; font-weight: 700;
157
+ text-transform: uppercase; letter-spacing: 0.06em;
158
+ color: #555; margin: 14px 0 4px;
159
+ }
160
+ .r-briefing h4:first-child { margin-top: 0; }
161
+ .r-briefing p { margin: 0 0 6px; line-height: 1.55; }
162
+ .r-briefing ul { margin: 4px 0 8px 0; padding-left: 20px; }
163
+ .r-briefing ul li { margin: 3px 0; line-height: 1.5; }
164
+ .r-briefing strong { font-weight: 700; background: #fff7d6; padding: 0 1px; }
165
+ .r-briefing .cite {
166
+ display: inline-block; vertical-align: super;
167
+ font-size: 7pt; font-family: var(--mono);
168
+ padding: 0 4px; margin-left: 1px;
169
+ background: #f5f7fb; color: var(--nyc-blue);
170
+ border-radius: 6px; font-weight: 700;
171
+ }
172
+
173
+ .r-sources {
174
+ font-size: 9pt;
175
+ }
176
+ .r-sources ol {
177
+ list-style: none; padding: 0; margin: 0;
178
+ }
179
+ .r-sources li {
180
+ display: grid; grid-template-columns: 26px 1fr;
181
+ gap: 8px; padding: 6px 0;
182
+ border-bottom: 1px dotted #ddd;
183
+ page-break-inside: avoid;
184
+ }
185
+ .r-sources li:last-child { border-bottom: 0; }
186
+ .r-sources .num {
187
+ font-family: var(--mono); font-weight: 700;
188
+ color: var(--nyc-blue); font-size: 9pt; text-align: right;
189
+ }
190
+ .r-sources .label { font-weight: 600; color: #111; }
191
+ .r-sources .vintage {
192
+ display: block; color: #666; font-size: 8.5pt; margin-top: 2px;
193
+ }
194
+ .r-sources .url {
195
+ display: block; font-family: var(--mono);
196
+ font-size: 8pt; color: #444; margin-top: 2px;
197
+ word-break: break-all;
198
+ }
199
+ .r-sources .url a { color: var(--nyc-blue); text-decoration: none; }
200
+
201
+ .r-method {
202
+ font-size: 8.5pt; color: #555; line-height: 1.5;
203
+ }
204
+ .r-method p { margin: 0 0 6px; }
205
+
206
+ .r-foot {
207
+ margin-top: 22px; padding-top: 10px;
208
+ border-top: 1px solid #ccc;
209
+ font-size: 7.5pt; color: #888;
210
+ display: flex; justify-content: space-between;
211
+ }
212
+
213
+ /* PRINT — strip chrome, force letter, page-break-aware */
214
+ @media print {
215
+ body { background: white; padding: 0; }
216
+ .report-controls { display: none; }
217
+ .paper {
218
+ box-shadow: none; margin: 0;
219
+ width: auto; padding: 0.5in;
220
+ }
221
+ @page { size: letter; margin: 0; }
222
+ }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <div class="report-controls">
227
+ <div class="lhs"><strong>Auditable report</strong> · ready to print or save as PDF</div>
228
+ <div class="actions">
229
+ <button class="btn-secondary" onclick="window.close()">Close</button>
230
+ <button class="btn-primary" onclick="window.print()">Print / Save PDF</button>
231
+ </div>
232
+ </div>
233
+
234
+ <article class="paper" id="paper">
235
+ <div style="padding: 60px 0; text-align: center; color: #888;">
236
+ Loading report from agent session…
237
+ <br><br>
238
+ <small>If this hangs, the report payload wasn't found in sessionStorage. Re-run the query and click <em>Generate auditable report</em> from there.</small>
239
+ </div>
240
+ </article>
241
+
242
+ <script src="/static/report.js"></script>
243
+ </body>
244
+ </html>
web/static/report.js ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Renders the print-ready auditable report from the agent's last result,
2
+ // passed via sessionStorage. Includes original query, planner decision,
3
+ // full specialist trail, map snapshot, briefing prose with citations,
4
+ // and a Sources section listing every doc_id with its vintage + URL.
5
+
6
+ (function () {
7
+ const raw = sessionStorage.getItem("riprap_report");
8
+ if (!raw) return;
9
+ let pkg;
10
+ try { pkg = JSON.parse(raw); } catch (e) {
11
+ document.getElementById("paper").innerHTML =
12
+ `<p style="color:#c00">Could not parse stored report payload: ${e.message}</p>`;
13
+ return;
14
+ }
15
+ render(pkg);
16
+ })();
17
+
18
+ function escapeHtml(s) {
19
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
+ }
21
+
22
+ function render(pkg) {
23
+ const r = pkg.result || {};
24
+ const plan = pkg.plan || r.plan || {};
25
+ const trace = pkg.trace || [];
26
+ const labels = pkg.sourceLabels || {};
27
+ const urls = pkg.sourceUrls || {};
28
+ const vintages = pkg.sourceVintages || {};
29
+ const stepLabels = pkg.stepLabels || {};
30
+
31
+ const intent = r.intent || plan.intent || "—";
32
+ const intentTitleMap = {
33
+ single_address: "Flood-exposure briefing — address",
34
+ neighborhood: "Flood-exposure briefing — neighborhood",
35
+ development_check: "Active development × flood exposure",
36
+ live_now: "Current conditions — NYC",
37
+ };
38
+ const place = (r.target && r.target.nta_name)
39
+ || (r.geocode && r.geocode.address)
40
+ || r.place || "—";
41
+
42
+ // Build the citation index from the briefing prose so we render a
43
+ // numbered Sources section in the SAME order the chips appear in the
44
+ // text — same idiom as the agent UI.
45
+ const citeIndex = {};
46
+ const para = r.paragraph || "";
47
+ const para2 = para.replace(/\[([a-z0-9_]+)\]/gi, (_, id) => {
48
+ const norm = id.toLowerCase();
49
+ if (citeIndex[norm] == null) citeIndex[norm] = Object.keys(citeIndex).length + 1;
50
+ return `<span class="cite">${citeIndex[norm]}</span>`;
51
+ });
52
+
53
+ const html = `
54
+ <header class="r-head">
55
+ <div class="r-brand">Riprap</div>
56
+ <div class="r-tagline">Citation-grounded flood-exposure briefing</div>
57
+ <dl class="r-meta-grid">
58
+ <dt>Subject</dt><dd>${escapeHtml(intentTitleMap[intent] || "Briefing")} · <strong>${escapeHtml(place)}</strong></dd>
59
+ ${r.geocode && r.geocode.borough ? `<dt>Borough</dt><dd>${escapeHtml(r.geocode.borough)}</dd>` : ""}
60
+ ${r.target && r.target.borough ? `<dt>Borough</dt><dd>${escapeHtml(r.target.borough)}</dd>` : ""}
61
+ ${r.geocode && r.geocode.bbl ? `<dt>BBL</dt><dd class="mono">${escapeHtml(r.geocode.bbl)}</dd>` : ""}
62
+ ${r.target && r.target.nta_code ? `<dt>NTA</dt><dd class="mono">${escapeHtml(r.target.nta_code)}</dd>` : ""}
63
+ <dt>Generated</dt><dd>${escapeHtml(pkg.finishedAt || new Date().toISOString())}</dd>
64
+ <dt>Total runtime</dt><dd>${pkg.wallSeconds ?? r.total_s ?? "—"} s</dd>
65
+ </dl>
66
+ </header>
67
+
68
+ <section class="r-section">
69
+ <h2>1 · Original query</h2>
70
+ <div class="r-query">"${escapeHtml(pkg.query)}"</div>
71
+ </section>
72
+
73
+ <section class="r-section">
74
+ <h2>2 · Agent routing decision</h2>
75
+ <dl class="r-plan">
76
+ <dt>Intent</dt><dd class="mono">${escapeHtml(plan.intent || intent)}</dd>
77
+ <dt>Targets</dt><dd class="mono">${escapeHtml((plan.targets || []).map(t => `${t.type}:${t.text}`).join(", ") || "—")}</dd>
78
+ <dt>Specialists requested</dt><dd class="mono">${escapeHtml((plan.specialists || []).join(", ") || "—")}</dd>
79
+ ${plan.rationale ? `<dd class="r-plan-rationale">"${escapeHtml(plan.rationale)}"</dd>` : ""}
80
+ </dl>
81
+ </section>
82
+
83
+ <section class="r-section">
84
+ <h2>3 · Specialist trail</h2>
85
+ <div class="lead">${trace.length} specialists invoked. Each row shows the
86
+ step name, status, elapsed time, and the structured result the step
87
+ produced. Sources of any data referenced in the briefing appear in
88
+ Section 6.</div>
89
+ <table class="r-trace">
90
+ <thead>
91
+ <tr><th>#</th><th>Step</th><th>Status</th><th>Elapsed</th><th>Result / error</th></tr>
92
+ </thead>
93
+ <tbody>
94
+ ${trace.map((s, i) => {
95
+ const ok = s.ok === true;
96
+ const fail = s.ok === false;
97
+ const cls = ok ? "ok" : fail ? "err" : "";
98
+ const mark = ok ? "✓" : fail ? "✗" : "○";
99
+ const [label] = stepLabels[s.step] || [s.step, ""];
100
+ const detail = s.err
101
+ ? `<span class="err-msg">${escapeHtml(s.err)}</span>`
102
+ : `<span class="result">${escapeHtml(JSON.stringify(s.result ?? {}))}</span>`;
103
+ return `<tr class="${cls}">
104
+ <td class="mono">${i + 1}</td>
105
+ <td><strong>${escapeHtml(label)}</strong><br>
106
+ <span class="mono" style="color:#888;font-size:7.5pt">${escapeHtml(s.step)}</span></td>
107
+ <td><span class="mark">${mark}</span></td>
108
+ <td class="mono">${s.elapsed_s != null ? s.elapsed_s + "s" : "—"}</td>
109
+ <td>${detail}</td>
110
+ </tr>`;
111
+ }).join("")}
112
+ </tbody>
113
+ </table>
114
+ </section>
115
+
116
+ ${pkg.mapPng ? `
117
+ <section class="r-section">
118
+ <h2>4 · Map (snapshot)</h2>
119
+ <div class="r-map">
120
+ <img src="${pkg.mapPng}" alt="Map snapshot at report-generation time">
121
+ <div class="legend-cap">Snapshot of the live MapLibre map captured at report-generation time. Layers: per-intent (Sandy 2012 / DEP scenarios / NTA boundary / DOB permit pins / address pin).</div>
122
+ </div>
123
+ </section>
124
+ ` : `
125
+ <section class="r-section">
126
+ <h2>4 · Map</h2>
127
+ <div class="r-map no-map">No map snapshot was captured (the map may have been hidden or empty for this query type).</div>
128
+ </section>
129
+ `}
130
+
131
+ <section class="r-section">
132
+ <h2>5 · Cited briefing</h2>
133
+ <div class="r-briefing">${renderBriefingMarkdown(para2)}</div>
134
+ </section>
135
+
136
+ <section class="r-section">
137
+ <h2>6 · Sources</h2>
138
+ <ol class="r-sources">
139
+ ${Object.entries(citeIndex).sort((a, b) => a[1] - b[1]).map(([id, n]) => {
140
+ const url = urls[id];
141
+ return `<li>
142
+ <span class="num">[${n}]</span>
143
+ <div>
144
+ <span class="label">${escapeHtml(labels[id] || id)}</span>
145
+ ${vintages[id] ? `<span class="vintage">Vintage: ${escapeHtml(vintages[id])}</span>` : ""}
146
+ ${url ? `<span class="url"><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></span>` : ""}
147
+ <span class="vintage" style="font-family:var(--mono);font-size:8pt;color:#888">doc_id: ${escapeHtml(id)}</span>
148
+ </div>
149
+ </li>`;
150
+ }).join("")}
151
+ </ol>
152
+ </section>
153
+
154
+ <section class="r-section">
155
+ <h2>7 · Methodology &amp; honest scope</h2>
156
+ <div class="r-method">
157
+ <p><strong>This is an exposure briefing, not a damage probability or insurance rating.</strong> Tier and headline statistics are computed from a deterministic, peer-reviewed-grounded rubric (see <em>METHODOLOGY.md</em> in the source repository). The synthesis prose is generated by IBM Granite 4.1 in document-grounded mode; every numeric claim is verified to appear verbatim in a source document before render, and unsupported sentences are dropped.</p>
158
+ <p><strong>Stack:</strong> Granite 4.1 (3b planner / 8b reconciler) via Ollama, Granite Embedding 278M for RAG over agency reports, Granite TimeSeries TTM r2 for live surge nowcast, Prithvi-EO 2.0 for satellite-derived flood polygons (offline pre-computed). Apache-2.0 across the stack. Inference runs locally on the deploying machine; no vendor LLM is contacted at runtime.</p>
159
+ <p><strong>Out of scope:</strong> engineering vulnerability (foundation/structural fragility), social capacity, financial absorption, sub-surface flooding (basement apartments, subway entrances). Datasets are vintage-bounded as noted per source above.</p>
160
+ </div>
161
+ </section>
162
+
163
+ <footer class="r-foot">
164
+ <span>Generated by Riprap · https://huggingface.co/spaces/msradam/riprap-nyc</span>
165
+ <span>${escapeHtml(pkg.finishedAt || "")}</span>
166
+ </footer>
167
+ `;
168
+ document.getElementById("paper").innerHTML = html;
169
+ // Update tab title to reflect the subject
170
+ document.title = `Riprap — ${place}`;
171
+ }
172
+
173
+ // Subset markdown for the briefing: `**Header.**` lines → <h4>; `- ` lines
174
+ // → <ul><li>; inline `**foo**` → <strong>; rest → <p>. Keep parity with
175
+ // agent.js's renderMarkdown so reports look like the live UI.
176
+ function renderBriefingMarkdown(text) {
177
+ const lines = text.split("\n");
178
+ const out = [];
179
+ let para = []; let bullets = [];
180
+ const flushPara = () => {
181
+ if (!para.length) return;
182
+ const safe = para.join(" ").trim().replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
183
+ if (safe) out.push(`<p>${safe}</p>`);
184
+ para = [];
185
+ };
186
+ const flushBullets = () => {
187
+ if (!bullets.length) return;
188
+ const items = bullets.map(b => {
189
+ const safe = b.trim().replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
190
+ return `<li>${safe}</li>`;
191
+ }).join("");
192
+ out.push(`<ul>${items}</ul>`);
193
+ bullets = [];
194
+ };
195
+ // Pre-split inline-bullet runs that Granite occasionally emits as one line
196
+ const expanded = [];
197
+ for (const line of lines) {
198
+ if (line.trim().startsWith("- ") && line.includes(" - ", 2)) {
199
+ const parts = line.split(/(?:^|(?<=\.\s))\s*-\s+/g).filter(p => p.trim());
200
+ for (const p of parts) expanded.push("- " + p.trim());
201
+ } else { expanded.push(line); }
202
+ }
203
+ for (const line of expanded) {
204
+ const m = line.match(/^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/);
205
+ if (m) {
206
+ flushPara(); flushBullets();
207
+ out.push(`<h4>${m[1]}</h4>`);
208
+ } else if (/^\s*[-*]\s+/.test(line)) {
209
+ flushPara();
210
+ bullets.push(line.replace(/^\s*[-*]\s+/, ""));
211
+ } else {
212
+ flushBullets();
213
+ para.push(line);
214
+ }
215
+ }
216
+ flushPara(); flushBullets();
217
+ return out.join("");
218
+ }
web/static/style.css CHANGED
@@ -116,6 +116,32 @@ a:hover { text-decoration: underline; }
116
 
117
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  /* ----- Form bar ----- */
120
 
121
  .form-bar {
@@ -218,9 +244,10 @@ button.chip:hover {
218
  }
219
  .col-right {
220
  display: flex; flex-direction: column; gap: 12px;
221
- position: sticky; top: calc(var(--topbar-h) + 14px);
222
- max-height: calc(100vh - var(--topbar-h) - 28px);
223
- overflow: auto;
 
224
  }
225
 
226
  /* ----- Panels ----- */
@@ -501,6 +528,26 @@ button.chip:hover {
501
  line-height: 1.55;
502
  color: var(--text);
503
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  .summary-box #paragraph .cite {
505
  display: inline-block;
506
  vertical-align: super;
 
116
 
117
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
118
 
119
+ /* Backend pill state colors. Green = primary reachable; amber =
120
+ running on the configured fallback; gray = checking; red = nothing
121
+ reachable. */
122
+ .local-pill[data-state="loading"] {
123
+ color: #6c757d;
124
+ background: rgba(108, 117, 125, 0.08);
125
+ border-color: rgba(108, 117, 125, 0.30);
126
+ }
127
+ .local-pill[data-state="loading"] .dot { background: #6c757d; box-shadow: none; }
128
+ .local-pill[data-state="fallback"] {
129
+ color: #b06b00;
130
+ background: rgba(176, 107, 0, 0.10);
131
+ border-color: rgba(176, 107, 0, 0.35);
132
+ }
133
+ .local-pill[data-state="fallback"] .dot {
134
+ background: #b06b00; box-shadow: 0 0 6px rgba(176, 107, 0, 0.7);
135
+ }
136
+ .local-pill[data-state="down"] {
137
+ color: #b00020;
138
+ background: rgba(176, 0, 32, 0.08);
139
+ border-color: rgba(176, 0, 32, 0.35);
140
+ }
141
+ .local-pill[data-state="down"] .dot {
142
+ background: #b00020; box-shadow: 0 0 6px rgba(176, 0, 32, 0.7);
143
+ }
144
+
145
  /* ----- Form bar ----- */
146
 
147
  .form-bar {
 
244
  }
245
  .col-right {
246
  display: flex; flex-direction: column; gap: 12px;
247
+ /* No viewport clamp: report flows to its natural height. The page
248
+ scrolls vertically when content exceeds viewport — with an internal
249
+ scrollbar (the previous behavior) the bottom citations were
250
+ unreachable for users who didn't notice the thin inner scrollbar. */
251
  }
252
 
253
  /* ----- Panels ----- */
 
528
  line-height: 1.55;
529
  color: var(--text);
530
  }
531
+ /* Section structure inside the summary (Status / Empirical / Modeled / Policy).
532
+ The reconciler emits markdown headers like `**Status.**` on their own line;
533
+ renderMarkdown() converts them to <h4 class="rsum-h">. */
534
+ .summary-box #paragraph .rsum-h {
535
+ margin: 12px 0 4px;
536
+ font-size: 10.5px;
537
+ font-weight: 700;
538
+ text-transform: uppercase;
539
+ letter-spacing: 0.08em;
540
+ color: var(--text-muted);
541
+ }
542
+ .summary-box #paragraph .rsum-h:first-child { margin-top: 0; }
543
+ .summary-box #paragraph .rsum-p {
544
+ margin: 0 0 4px;
545
+ }
546
+ .summary-box #paragraph .rsum-p strong {
547
+ font-weight: 600;
548
+ background: linear-gradient(transparent 60%, var(--nyc-blue-soft) 60%);
549
+ padding: 0 2px;
550
+ }
551
  .summary-box #paragraph .cite {
552
  display: inline-block;
553
  vertical-align: super;
web/svelte/package-lock.json ADDED
@@ -0,0 +1,1337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "riprap-svelte",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "riprap-svelte",
9
+ "version": "0.1.0",
10
+ "devDependencies": {
11
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
12
+ "svelte": "^5.0.0",
13
+ "vite": "^5.4.0"
14
+ }
15
+ },
16
+ "node_modules/@esbuild/aix-ppc64": {
17
+ "version": "0.21.5",
18
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
19
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
20
+ "cpu": [
21
+ "ppc64"
22
+ ],
23
+ "dev": true,
24
+ "license": "MIT",
25
+ "optional": true,
26
+ "os": [
27
+ "aix"
28
+ ],
29
+ "engines": {
30
+ "node": ">=12"
31
+ }
32
+ },
33
+ "node_modules/@esbuild/android-arm": {
34
+ "version": "0.21.5",
35
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
36
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
37
+ "cpu": [
38
+ "arm"
39
+ ],
40
+ "dev": true,
41
+ "license": "MIT",
42
+ "optional": true,
43
+ "os": [
44
+ "android"
45
+ ],
46
+ "engines": {
47
+ "node": ">=12"
48
+ }
49
+ },
50
+ "node_modules/@esbuild/android-arm64": {
51
+ "version": "0.21.5",
52
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
53
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
54
+ "cpu": [
55
+ "arm64"
56
+ ],
57
+ "dev": true,
58
+ "license": "MIT",
59
+ "optional": true,
60
+ "os": [
61
+ "android"
62
+ ],
63
+ "engines": {
64
+ "node": ">=12"
65
+ }
66
+ },
67
+ "node_modules/@esbuild/android-x64": {
68
+ "version": "0.21.5",
69
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
70
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
71
+ "cpu": [
72
+ "x64"
73
+ ],
74
+ "dev": true,
75
+ "license": "MIT",
76
+ "optional": true,
77
+ "os": [
78
+ "android"
79
+ ],
80
+ "engines": {
81
+ "node": ">=12"
82
+ }
83
+ },
84
+ "node_modules/@esbuild/darwin-arm64": {
85
+ "version": "0.21.5",
86
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
87
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
88
+ "cpu": [
89
+ "arm64"
90
+ ],
91
+ "dev": true,
92
+ "license": "MIT",
93
+ "optional": true,
94
+ "os": [
95
+ "darwin"
96
+ ],
97
+ "engines": {
98
+ "node": ">=12"
99
+ }
100
+ },
101
+ "node_modules/@esbuild/darwin-x64": {
102
+ "version": "0.21.5",
103
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
104
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
105
+ "cpu": [
106
+ "x64"
107
+ ],
108
+ "dev": true,
109
+ "license": "MIT",
110
+ "optional": true,
111
+ "os": [
112
+ "darwin"
113
+ ],
114
+ "engines": {
115
+ "node": ">=12"
116
+ }
117
+ },
118
+ "node_modules/@esbuild/freebsd-arm64": {
119
+ "version": "0.21.5",
120
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
121
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
122
+ "cpu": [
123
+ "arm64"
124
+ ],
125
+ "dev": true,
126
+ "license": "MIT",
127
+ "optional": true,
128
+ "os": [
129
+ "freebsd"
130
+ ],
131
+ "engines": {
132
+ "node": ">=12"
133
+ }
134
+ },
135
+ "node_modules/@esbuild/freebsd-x64": {
136
+ "version": "0.21.5",
137
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
138
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
139
+ "cpu": [
140
+ "x64"
141
+ ],
142
+ "dev": true,
143
+ "license": "MIT",
144
+ "optional": true,
145
+ "os": [
146
+ "freebsd"
147
+ ],
148
+ "engines": {
149
+ "node": ">=12"
150
+ }
151
+ },
152
+ "node_modules/@esbuild/linux-arm": {
153
+ "version": "0.21.5",
154
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
155
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
156
+ "cpu": [
157
+ "arm"
158
+ ],
159
+ "dev": true,
160
+ "license": "MIT",
161
+ "optional": true,
162
+ "os": [
163
+ "linux"
164
+ ],
165
+ "engines": {
166
+ "node": ">=12"
167
+ }
168
+ },
169
+ "node_modules/@esbuild/linux-arm64": {
170
+ "version": "0.21.5",
171
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
172
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
173
+ "cpu": [
174
+ "arm64"
175
+ ],
176
+ "dev": true,
177
+ "license": "MIT",
178
+ "optional": true,
179
+ "os": [
180
+ "linux"
181
+ ],
182
+ "engines": {
183
+ "node": ">=12"
184
+ }
185
+ },
186
+ "node_modules/@esbuild/linux-ia32": {
187
+ "version": "0.21.5",
188
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
189
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
190
+ "cpu": [
191
+ "ia32"
192
+ ],
193
+ "dev": true,
194
+ "license": "MIT",
195
+ "optional": true,
196
+ "os": [
197
+ "linux"
198
+ ],
199
+ "engines": {
200
+ "node": ">=12"
201
+ }
202
+ },
203
+ "node_modules/@esbuild/linux-loong64": {
204
+ "version": "0.21.5",
205
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
206
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
207
+ "cpu": [
208
+ "loong64"
209
+ ],
210
+ "dev": true,
211
+ "license": "MIT",
212
+ "optional": true,
213
+ "os": [
214
+ "linux"
215
+ ],
216
+ "engines": {
217
+ "node": ">=12"
218
+ }
219
+ },
220
+ "node_modules/@esbuild/linux-mips64el": {
221
+ "version": "0.21.5",
222
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
223
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
224
+ "cpu": [
225
+ "mips64el"
226
+ ],
227
+ "dev": true,
228
+ "license": "MIT",
229
+ "optional": true,
230
+ "os": [
231
+ "linux"
232
+ ],
233
+ "engines": {
234
+ "node": ">=12"
235
+ }
236
+ },
237
+ "node_modules/@esbuild/linux-ppc64": {
238
+ "version": "0.21.5",
239
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
240
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
241
+ "cpu": [
242
+ "ppc64"
243
+ ],
244
+ "dev": true,
245
+ "license": "MIT",
246
+ "optional": true,
247
+ "os": [
248
+ "linux"
249
+ ],
250
+ "engines": {
251
+ "node": ">=12"
252
+ }
253
+ },
254
+ "node_modules/@esbuild/linux-riscv64": {
255
+ "version": "0.21.5",
256
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
257
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
258
+ "cpu": [
259
+ "riscv64"
260
+ ],
261
+ "dev": true,
262
+ "license": "MIT",
263
+ "optional": true,
264
+ "os": [
265
+ "linux"
266
+ ],
267
+ "engines": {
268
+ "node": ">=12"
269
+ }
270
+ },
271
+ "node_modules/@esbuild/linux-s390x": {
272
+ "version": "0.21.5",
273
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
274
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
275
+ "cpu": [
276
+ "s390x"
277
+ ],
278
+ "dev": true,
279
+ "license": "MIT",
280
+ "optional": true,
281
+ "os": [
282
+ "linux"
283
+ ],
284
+ "engines": {
285
+ "node": ">=12"
286
+ }
287
+ },
288
+ "node_modules/@esbuild/linux-x64": {
289
+ "version": "0.21.5",
290
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
291
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
292
+ "cpu": [
293
+ "x64"
294
+ ],
295
+ "dev": true,
296
+ "license": "MIT",
297
+ "optional": true,
298
+ "os": [
299
+ "linux"
300
+ ],
301
+ "engines": {
302
+ "node": ">=12"
303
+ }
304
+ },
305
+ "node_modules/@esbuild/netbsd-x64": {
306
+ "version": "0.21.5",
307
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
308
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
309
+ "cpu": [
310
+ "x64"
311
+ ],
312
+ "dev": true,
313
+ "license": "MIT",
314
+ "optional": true,
315
+ "os": [
316
+ "netbsd"
317
+ ],
318
+ "engines": {
319
+ "node": ">=12"
320
+ }
321
+ },
322
+ "node_modules/@esbuild/openbsd-x64": {
323
+ "version": "0.21.5",
324
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
325
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
326
+ "cpu": [
327
+ "x64"
328
+ ],
329
+ "dev": true,
330
+ "license": "MIT",
331
+ "optional": true,
332
+ "os": [
333
+ "openbsd"
334
+ ],
335
+ "engines": {
336
+ "node": ">=12"
337
+ }
338
+ },
339
+ "node_modules/@esbuild/sunos-x64": {
340
+ "version": "0.21.5",
341
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
342
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
343
+ "cpu": [
344
+ "x64"
345
+ ],
346
+ "dev": true,
347
+ "license": "MIT",
348
+ "optional": true,
349
+ "os": [
350
+ "sunos"
351
+ ],
352
+ "engines": {
353
+ "node": ">=12"
354
+ }
355
+ },
356
+ "node_modules/@esbuild/win32-arm64": {
357
+ "version": "0.21.5",
358
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
359
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
360
+ "cpu": [
361
+ "arm64"
362
+ ],
363
+ "dev": true,
364
+ "license": "MIT",
365
+ "optional": true,
366
+ "os": [
367
+ "win32"
368
+ ],
369
+ "engines": {
370
+ "node": ">=12"
371
+ }
372
+ },
373
+ "node_modules/@esbuild/win32-ia32": {
374
+ "version": "0.21.5",
375
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
376
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
377
+ "cpu": [
378
+ "ia32"
379
+ ],
380
+ "dev": true,
381
+ "license": "MIT",
382
+ "optional": true,
383
+ "os": [
384
+ "win32"
385
+ ],
386
+ "engines": {
387
+ "node": ">=12"
388
+ }
389
+ },
390
+ "node_modules/@esbuild/win32-x64": {
391
+ "version": "0.21.5",
392
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
393
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
394
+ "cpu": [
395
+ "x64"
396
+ ],
397
+ "dev": true,
398
+ "license": "MIT",
399
+ "optional": true,
400
+ "os": [
401
+ "win32"
402
+ ],
403
+ "engines": {
404
+ "node": ">=12"
405
+ }
406
+ },
407
+ "node_modules/@jridgewell/gen-mapping": {
408
+ "version": "0.3.13",
409
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
410
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
411
+ "dev": true,
412
+ "license": "MIT",
413
+ "dependencies": {
414
+ "@jridgewell/sourcemap-codec": "^1.5.0",
415
+ "@jridgewell/trace-mapping": "^0.3.24"
416
+ }
417
+ },
418
+ "node_modules/@jridgewell/remapping": {
419
+ "version": "2.3.5",
420
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
421
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
422
+ "dev": true,
423
+ "license": "MIT",
424
+ "dependencies": {
425
+ "@jridgewell/gen-mapping": "^0.3.5",
426
+ "@jridgewell/trace-mapping": "^0.3.24"
427
+ }
428
+ },
429
+ "node_modules/@jridgewell/resolve-uri": {
430
+ "version": "3.1.2",
431
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
432
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
433
+ "dev": true,
434
+ "license": "MIT",
435
+ "engines": {
436
+ "node": ">=6.0.0"
437
+ }
438
+ },
439
+ "node_modules/@jridgewell/sourcemap-codec": {
440
+ "version": "1.5.5",
441
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
442
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
443
+ "dev": true,
444
+ "license": "MIT"
445
+ },
446
+ "node_modules/@jridgewell/trace-mapping": {
447
+ "version": "0.3.31",
448
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
449
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
450
+ "dev": true,
451
+ "license": "MIT",
452
+ "dependencies": {
453
+ "@jridgewell/resolve-uri": "^3.1.0",
454
+ "@jridgewell/sourcemap-codec": "^1.4.14"
455
+ }
456
+ },
457
+ "node_modules/@rollup/rollup-android-arm-eabi": {
458
+ "version": "4.60.2",
459
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
460
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
461
+ "cpu": [
462
+ "arm"
463
+ ],
464
+ "dev": true,
465
+ "license": "MIT",
466
+ "optional": true,
467
+ "os": [
468
+ "android"
469
+ ]
470
+ },
471
+ "node_modules/@rollup/rollup-android-arm64": {
472
+ "version": "4.60.2",
473
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
474
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
475
+ "cpu": [
476
+ "arm64"
477
+ ],
478
+ "dev": true,
479
+ "license": "MIT",
480
+ "optional": true,
481
+ "os": [
482
+ "android"
483
+ ]
484
+ },
485
+ "node_modules/@rollup/rollup-darwin-arm64": {
486
+ "version": "4.60.2",
487
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
488
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
489
+ "cpu": [
490
+ "arm64"
491
+ ],
492
+ "dev": true,
493
+ "license": "MIT",
494
+ "optional": true,
495
+ "os": [
496
+ "darwin"
497
+ ]
498
+ },
499
+ "node_modules/@rollup/rollup-darwin-x64": {
500
+ "version": "4.60.2",
501
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
502
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
503
+ "cpu": [
504
+ "x64"
505
+ ],
506
+ "dev": true,
507
+ "license": "MIT",
508
+ "optional": true,
509
+ "os": [
510
+ "darwin"
511
+ ]
512
+ },
513
+ "node_modules/@rollup/rollup-freebsd-arm64": {
514
+ "version": "4.60.2",
515
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
516
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
517
+ "cpu": [
518
+ "arm64"
519
+ ],
520
+ "dev": true,
521
+ "license": "MIT",
522
+ "optional": true,
523
+ "os": [
524
+ "freebsd"
525
+ ]
526
+ },
527
+ "node_modules/@rollup/rollup-freebsd-x64": {
528
+ "version": "4.60.2",
529
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
530
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
531
+ "cpu": [
532
+ "x64"
533
+ ],
534
+ "dev": true,
535
+ "license": "MIT",
536
+ "optional": true,
537
+ "os": [
538
+ "freebsd"
539
+ ]
540
+ },
541
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
542
+ "version": "4.60.2",
543
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
544
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
545
+ "cpu": [
546
+ "arm"
547
+ ],
548
+ "dev": true,
549
+ "libc": [
550
+ "glibc"
551
+ ],
552
+ "license": "MIT",
553
+ "optional": true,
554
+ "os": [
555
+ "linux"
556
+ ]
557
+ },
558
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
559
+ "version": "4.60.2",
560
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
561
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
562
+ "cpu": [
563
+ "arm"
564
+ ],
565
+ "dev": true,
566
+ "libc": [
567
+ "musl"
568
+ ],
569
+ "license": "MIT",
570
+ "optional": true,
571
+ "os": [
572
+ "linux"
573
+ ]
574
+ },
575
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
576
+ "version": "4.60.2",
577
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
578
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
579
+ "cpu": [
580
+ "arm64"
581
+ ],
582
+ "dev": true,
583
+ "libc": [
584
+ "glibc"
585
+ ],
586
+ "license": "MIT",
587
+ "optional": true,
588
+ "os": [
589
+ "linux"
590
+ ]
591
+ },
592
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
593
+ "version": "4.60.2",
594
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
595
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
596
+ "cpu": [
597
+ "arm64"
598
+ ],
599
+ "dev": true,
600
+ "libc": [
601
+ "musl"
602
+ ],
603
+ "license": "MIT",
604
+ "optional": true,
605
+ "os": [
606
+ "linux"
607
+ ]
608
+ },
609
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
610
+ "version": "4.60.2",
611
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
612
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
613
+ "cpu": [
614
+ "loong64"
615
+ ],
616
+ "dev": true,
617
+ "libc": [
618
+ "glibc"
619
+ ],
620
+ "license": "MIT",
621
+ "optional": true,
622
+ "os": [
623
+ "linux"
624
+ ]
625
+ },
626
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
627
+ "version": "4.60.2",
628
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
629
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
630
+ "cpu": [
631
+ "loong64"
632
+ ],
633
+ "dev": true,
634
+ "libc": [
635
+ "musl"
636
+ ],
637
+ "license": "MIT",
638
+ "optional": true,
639
+ "os": [
640
+ "linux"
641
+ ]
642
+ },
643
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
644
+ "version": "4.60.2",
645
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
646
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
647
+ "cpu": [
648
+ "ppc64"
649
+ ],
650
+ "dev": true,
651
+ "libc": [
652
+ "glibc"
653
+ ],
654
+ "license": "MIT",
655
+ "optional": true,
656
+ "os": [
657
+ "linux"
658
+ ]
659
+ },
660
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
661
+ "version": "4.60.2",
662
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
663
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
664
+ "cpu": [
665
+ "ppc64"
666
+ ],
667
+ "dev": true,
668
+ "libc": [
669
+ "musl"
670
+ ],
671
+ "license": "MIT",
672
+ "optional": true,
673
+ "os": [
674
+ "linux"
675
+ ]
676
+ },
677
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
678
+ "version": "4.60.2",
679
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
680
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
681
+ "cpu": [
682
+ "riscv64"
683
+ ],
684
+ "dev": true,
685
+ "libc": [
686
+ "glibc"
687
+ ],
688
+ "license": "MIT",
689
+ "optional": true,
690
+ "os": [
691
+ "linux"
692
+ ]
693
+ },
694
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
695
+ "version": "4.60.2",
696
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
697
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
698
+ "cpu": [
699
+ "riscv64"
700
+ ],
701
+ "dev": true,
702
+ "libc": [
703
+ "musl"
704
+ ],
705
+ "license": "MIT",
706
+ "optional": true,
707
+ "os": [
708
+ "linux"
709
+ ]
710
+ },
711
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
712
+ "version": "4.60.2",
713
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
714
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
715
+ "cpu": [
716
+ "s390x"
717
+ ],
718
+ "dev": true,
719
+ "libc": [
720
+ "glibc"
721
+ ],
722
+ "license": "MIT",
723
+ "optional": true,
724
+ "os": [
725
+ "linux"
726
+ ]
727
+ },
728
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
729
+ "version": "4.60.2",
730
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
731
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
732
+ "cpu": [
733
+ "x64"
734
+ ],
735
+ "dev": true,
736
+ "libc": [
737
+ "glibc"
738
+ ],
739
+ "license": "MIT",
740
+ "optional": true,
741
+ "os": [
742
+ "linux"
743
+ ]
744
+ },
745
+ "node_modules/@rollup/rollup-linux-x64-musl": {
746
+ "version": "4.60.2",
747
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
748
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
749
+ "cpu": [
750
+ "x64"
751
+ ],
752
+ "dev": true,
753
+ "libc": [
754
+ "musl"
755
+ ],
756
+ "license": "MIT",
757
+ "optional": true,
758
+ "os": [
759
+ "linux"
760
+ ]
761
+ },
762
+ "node_modules/@rollup/rollup-openbsd-x64": {
763
+ "version": "4.60.2",
764
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
765
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
766
+ "cpu": [
767
+ "x64"
768
+ ],
769
+ "dev": true,
770
+ "license": "MIT",
771
+ "optional": true,
772
+ "os": [
773
+ "openbsd"
774
+ ]
775
+ },
776
+ "node_modules/@rollup/rollup-openharmony-arm64": {
777
+ "version": "4.60.2",
778
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
779
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
780
+ "cpu": [
781
+ "arm64"
782
+ ],
783
+ "dev": true,
784
+ "license": "MIT",
785
+ "optional": true,
786
+ "os": [
787
+ "openharmony"
788
+ ]
789
+ },
790
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
791
+ "version": "4.60.2",
792
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
793
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
794
+ "cpu": [
795
+ "arm64"
796
+ ],
797
+ "dev": true,
798
+ "license": "MIT",
799
+ "optional": true,
800
+ "os": [
801
+ "win32"
802
+ ]
803
+ },
804
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
805
+ "version": "4.60.2",
806
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
807
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
808
+ "cpu": [
809
+ "ia32"
810
+ ],
811
+ "dev": true,
812
+ "license": "MIT",
813
+ "optional": true,
814
+ "os": [
815
+ "win32"
816
+ ]
817
+ },
818
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
819
+ "version": "4.60.2",
820
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
821
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
822
+ "cpu": [
823
+ "x64"
824
+ ],
825
+ "dev": true,
826
+ "license": "MIT",
827
+ "optional": true,
828
+ "os": [
829
+ "win32"
830
+ ]
831
+ },
832
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
833
+ "version": "4.60.2",
834
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
835
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
836
+ "cpu": [
837
+ "x64"
838
+ ],
839
+ "dev": true,
840
+ "license": "MIT",
841
+ "optional": true,
842
+ "os": [
843
+ "win32"
844
+ ]
845
+ },
846
+ "node_modules/@sveltejs/acorn-typescript": {
847
+ "version": "1.0.9",
848
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
849
+ "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
850
+ "dev": true,
851
+ "license": "MIT",
852
+ "peerDependencies": {
853
+ "acorn": "^8.9.0"
854
+ }
855
+ },
856
+ "node_modules/@sveltejs/vite-plugin-svelte": {
857
+ "version": "4.0.4",
858
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
859
+ "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==",
860
+ "dev": true,
861
+ "license": "MIT",
862
+ "dependencies": {
863
+ "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
864
+ "debug": "^4.3.7",
865
+ "deepmerge": "^4.3.1",
866
+ "kleur": "^4.1.5",
867
+ "magic-string": "^0.30.12",
868
+ "vitefu": "^1.0.3"
869
+ },
870
+ "engines": {
871
+ "node": "^18.0.0 || ^20.0.0 || >=22"
872
+ },
873
+ "peerDependencies": {
874
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
875
+ "vite": "^5.0.0"
876
+ }
877
+ },
878
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
879
+ "version": "3.0.1",
880
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
881
+ "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
882
+ "dev": true,
883
+ "license": "MIT",
884
+ "dependencies": {
885
+ "debug": "^4.3.7"
886
+ },
887
+ "engines": {
888
+ "node": "^18.0.0 || ^20.0.0 || >=22"
889
+ },
890
+ "peerDependencies": {
891
+ "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
892
+ "svelte": "^5.0.0-next.96 || ^5.0.0",
893
+ "vite": "^5.0.0"
894
+ }
895
+ },
896
+ "node_modules/@types/estree": {
897
+ "version": "1.0.8",
898
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
899
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
900
+ "dev": true,
901
+ "license": "MIT"
902
+ },
903
+ "node_modules/@types/trusted-types": {
904
+ "version": "2.0.7",
905
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
906
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
907
+ "dev": true,
908
+ "license": "MIT"
909
+ },
910
+ "node_modules/acorn": {
911
+ "version": "8.16.0",
912
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
913
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
914
+ "dev": true,
915
+ "license": "MIT",
916
+ "bin": {
917
+ "acorn": "bin/acorn"
918
+ },
919
+ "engines": {
920
+ "node": ">=0.4.0"
921
+ }
922
+ },
923
+ "node_modules/aria-query": {
924
+ "version": "5.3.1",
925
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
926
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
927
+ "dev": true,
928
+ "license": "Apache-2.0",
929
+ "engines": {
930
+ "node": ">= 0.4"
931
+ }
932
+ },
933
+ "node_modules/axobject-query": {
934
+ "version": "4.1.0",
935
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
936
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
937
+ "dev": true,
938
+ "license": "Apache-2.0",
939
+ "engines": {
940
+ "node": ">= 0.4"
941
+ }
942
+ },
943
+ "node_modules/clsx": {
944
+ "version": "2.1.1",
945
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
946
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
947
+ "dev": true,
948
+ "license": "MIT",
949
+ "engines": {
950
+ "node": ">=6"
951
+ }
952
+ },
953
+ "node_modules/debug": {
954
+ "version": "4.4.3",
955
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
956
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
957
+ "dev": true,
958
+ "license": "MIT",
959
+ "dependencies": {
960
+ "ms": "^2.1.3"
961
+ },
962
+ "engines": {
963
+ "node": ">=6.0"
964
+ },
965
+ "peerDependenciesMeta": {
966
+ "supports-color": {
967
+ "optional": true
968
+ }
969
+ }
970
+ },
971
+ "node_modules/deepmerge": {
972
+ "version": "4.3.1",
973
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
974
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
975
+ "dev": true,
976
+ "license": "MIT",
977
+ "engines": {
978
+ "node": ">=0.10.0"
979
+ }
980
+ },
981
+ "node_modules/devalue": {
982
+ "version": "5.8.0",
983
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz",
984
+ "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
985
+ "dev": true,
986
+ "license": "MIT"
987
+ },
988
+ "node_modules/esbuild": {
989
+ "version": "0.21.5",
990
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
991
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
992
+ "dev": true,
993
+ "hasInstallScript": true,
994
+ "license": "MIT",
995
+ "bin": {
996
+ "esbuild": "bin/esbuild"
997
+ },
998
+ "engines": {
999
+ "node": ">=12"
1000
+ },
1001
+ "optionalDependencies": {
1002
+ "@esbuild/aix-ppc64": "0.21.5",
1003
+ "@esbuild/android-arm": "0.21.5",
1004
+ "@esbuild/android-arm64": "0.21.5",
1005
+ "@esbuild/android-x64": "0.21.5",
1006
+ "@esbuild/darwin-arm64": "0.21.5",
1007
+ "@esbuild/darwin-x64": "0.21.5",
1008
+ "@esbuild/freebsd-arm64": "0.21.5",
1009
+ "@esbuild/freebsd-x64": "0.21.5",
1010
+ "@esbuild/linux-arm": "0.21.5",
1011
+ "@esbuild/linux-arm64": "0.21.5",
1012
+ "@esbuild/linux-ia32": "0.21.5",
1013
+ "@esbuild/linux-loong64": "0.21.5",
1014
+ "@esbuild/linux-mips64el": "0.21.5",
1015
+ "@esbuild/linux-ppc64": "0.21.5",
1016
+ "@esbuild/linux-riscv64": "0.21.5",
1017
+ "@esbuild/linux-s390x": "0.21.5",
1018
+ "@esbuild/linux-x64": "0.21.5",
1019
+ "@esbuild/netbsd-x64": "0.21.5",
1020
+ "@esbuild/openbsd-x64": "0.21.5",
1021
+ "@esbuild/sunos-x64": "0.21.5",
1022
+ "@esbuild/win32-arm64": "0.21.5",
1023
+ "@esbuild/win32-ia32": "0.21.5",
1024
+ "@esbuild/win32-x64": "0.21.5"
1025
+ }
1026
+ },
1027
+ "node_modules/esm-env": {
1028
+ "version": "1.2.2",
1029
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
1030
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
1031
+ "dev": true,
1032
+ "license": "MIT"
1033
+ },
1034
+ "node_modules/esrap": {
1035
+ "version": "2.2.5",
1036
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
1037
+ "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
1038
+ "dev": true,
1039
+ "license": "MIT",
1040
+ "dependencies": {
1041
+ "@jridgewell/sourcemap-codec": "^1.4.15"
1042
+ },
1043
+ "peerDependencies": {
1044
+ "@typescript-eslint/types": "^8.2.0"
1045
+ },
1046
+ "peerDependenciesMeta": {
1047
+ "@typescript-eslint/types": {
1048
+ "optional": true
1049
+ }
1050
+ }
1051
+ },
1052
+ "node_modules/fsevents": {
1053
+ "version": "2.3.3",
1054
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1055
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1056
+ "dev": true,
1057
+ "hasInstallScript": true,
1058
+ "license": "MIT",
1059
+ "optional": true,
1060
+ "os": [
1061
+ "darwin"
1062
+ ],
1063
+ "engines": {
1064
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1065
+ }
1066
+ },
1067
+ "node_modules/is-reference": {
1068
+ "version": "3.0.3",
1069
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
1070
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
1071
+ "dev": true,
1072
+ "license": "MIT",
1073
+ "dependencies": {
1074
+ "@types/estree": "^1.0.6"
1075
+ }
1076
+ },
1077
+ "node_modules/kleur": {
1078
+ "version": "4.1.5",
1079
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
1080
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
1081
+ "dev": true,
1082
+ "license": "MIT",
1083
+ "engines": {
1084
+ "node": ">=6"
1085
+ }
1086
+ },
1087
+ "node_modules/locate-character": {
1088
+ "version": "3.0.0",
1089
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
1090
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
1091
+ "dev": true,
1092
+ "license": "MIT"
1093
+ },
1094
+ "node_modules/magic-string": {
1095
+ "version": "0.30.21",
1096
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1097
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1098
+ "dev": true,
1099
+ "license": "MIT",
1100
+ "dependencies": {
1101
+ "@jridgewell/sourcemap-codec": "^1.5.5"
1102
+ }
1103
+ },
1104
+ "node_modules/ms": {
1105
+ "version": "2.1.3",
1106
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1107
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1108
+ "dev": true,
1109
+ "license": "MIT"
1110
+ },
1111
+ "node_modules/nanoid": {
1112
+ "version": "3.3.12",
1113
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1114
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1115
+ "dev": true,
1116
+ "funding": [
1117
+ {
1118
+ "type": "github",
1119
+ "url": "https://github.com/sponsors/ai"
1120
+ }
1121
+ ],
1122
+ "license": "MIT",
1123
+ "bin": {
1124
+ "nanoid": "bin/nanoid.cjs"
1125
+ },
1126
+ "engines": {
1127
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1128
+ }
1129
+ },
1130
+ "node_modules/picocolors": {
1131
+ "version": "1.1.1",
1132
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1133
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1134
+ "dev": true,
1135
+ "license": "ISC"
1136
+ },
1137
+ "node_modules/postcss": {
1138
+ "version": "8.5.13",
1139
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
1140
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
1141
+ "dev": true,
1142
+ "funding": [
1143
+ {
1144
+ "type": "opencollective",
1145
+ "url": "https://opencollective.com/postcss/"
1146
+ },
1147
+ {
1148
+ "type": "tidelift",
1149
+ "url": "https://tidelift.com/funding/github/npm/postcss"
1150
+ },
1151
+ {
1152
+ "type": "github",
1153
+ "url": "https://github.com/sponsors/ai"
1154
+ }
1155
+ ],
1156
+ "license": "MIT",
1157
+ "dependencies": {
1158
+ "nanoid": "^3.3.11",
1159
+ "picocolors": "^1.1.1",
1160
+ "source-map-js": "^1.2.1"
1161
+ },
1162
+ "engines": {
1163
+ "node": "^10 || ^12 || >=14"
1164
+ }
1165
+ },
1166
+ "node_modules/rollup": {
1167
+ "version": "4.60.2",
1168
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
1169
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
1170
+ "dev": true,
1171
+ "license": "MIT",
1172
+ "dependencies": {
1173
+ "@types/estree": "1.0.8"
1174
+ },
1175
+ "bin": {
1176
+ "rollup": "dist/bin/rollup"
1177
+ },
1178
+ "engines": {
1179
+ "node": ">=18.0.0",
1180
+ "npm": ">=8.0.0"
1181
+ },
1182
+ "optionalDependencies": {
1183
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
1184
+ "@rollup/rollup-android-arm64": "4.60.2",
1185
+ "@rollup/rollup-darwin-arm64": "4.60.2",
1186
+ "@rollup/rollup-darwin-x64": "4.60.2",
1187
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
1188
+ "@rollup/rollup-freebsd-x64": "4.60.2",
1189
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
1190
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
1191
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
1192
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
1193
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
1194
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
1195
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
1196
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
1197
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
1198
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
1199
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
1200
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
1201
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
1202
+ "@rollup/rollup-openbsd-x64": "4.60.2",
1203
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
1204
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
1205
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
1206
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
1207
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
1208
+ "fsevents": "~2.3.2"
1209
+ }
1210
+ },
1211
+ "node_modules/source-map-js": {
1212
+ "version": "1.2.1",
1213
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1214
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1215
+ "dev": true,
1216
+ "license": "BSD-3-Clause",
1217
+ "engines": {
1218
+ "node": ">=0.10.0"
1219
+ }
1220
+ },
1221
+ "node_modules/svelte": {
1222
+ "version": "5.55.5",
1223
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz",
1224
+ "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==",
1225
+ "dev": true,
1226
+ "license": "MIT",
1227
+ "dependencies": {
1228
+ "@jridgewell/remapping": "^2.3.4",
1229
+ "@jridgewell/sourcemap-codec": "^1.5.0",
1230
+ "@sveltejs/acorn-typescript": "^1.0.5",
1231
+ "@types/estree": "^1.0.5",
1232
+ "@types/trusted-types": "^2.0.7",
1233
+ "acorn": "^8.12.1",
1234
+ "aria-query": "5.3.1",
1235
+ "axobject-query": "^4.1.0",
1236
+ "clsx": "^2.1.1",
1237
+ "devalue": "^5.6.4",
1238
+ "esm-env": "^1.2.1",
1239
+ "esrap": "^2.2.4",
1240
+ "is-reference": "^3.0.3",
1241
+ "locate-character": "^3.0.0",
1242
+ "magic-string": "^0.30.11",
1243
+ "zimmerframe": "^1.1.2"
1244
+ },
1245
+ "engines": {
1246
+ "node": ">=18"
1247
+ }
1248
+ },
1249
+ "node_modules/vite": {
1250
+ "version": "5.4.21",
1251
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1252
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1253
+ "dev": true,
1254
+ "license": "MIT",
1255
+ "dependencies": {
1256
+ "esbuild": "^0.21.3",
1257
+ "postcss": "^8.4.43",
1258
+ "rollup": "^4.20.0"
1259
+ },
1260
+ "bin": {
1261
+ "vite": "bin/vite.js"
1262
+ },
1263
+ "engines": {
1264
+ "node": "^18.0.0 || >=20.0.0"
1265
+ },
1266
+ "funding": {
1267
+ "url": "https://github.com/vitejs/vite?sponsor=1"
1268
+ },
1269
+ "optionalDependencies": {
1270
+ "fsevents": "~2.3.3"
1271
+ },
1272
+ "peerDependencies": {
1273
+ "@types/node": "^18.0.0 || >=20.0.0",
1274
+ "less": "*",
1275
+ "lightningcss": "^1.21.0",
1276
+ "sass": "*",
1277
+ "sass-embedded": "*",
1278
+ "stylus": "*",
1279
+ "sugarss": "*",
1280
+ "terser": "^5.4.0"
1281
+ },
1282
+ "peerDependenciesMeta": {
1283
+ "@types/node": {
1284
+ "optional": true
1285
+ },
1286
+ "less": {
1287
+ "optional": true
1288
+ },
1289
+ "lightningcss": {
1290
+ "optional": true
1291
+ },
1292
+ "sass": {
1293
+ "optional": true
1294
+ },
1295
+ "sass-embedded": {
1296
+ "optional": true
1297
+ },
1298
+ "stylus": {
1299
+ "optional": true
1300
+ },
1301
+ "sugarss": {
1302
+ "optional": true
1303
+ },
1304
+ "terser": {
1305
+ "optional": true
1306
+ }
1307
+ }
1308
+ },
1309
+ "node_modules/vitefu": {
1310
+ "version": "1.1.3",
1311
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
1312
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
1313
+ "dev": true,
1314
+ "license": "MIT",
1315
+ "workspaces": [
1316
+ "tests/deps/*",
1317
+ "tests/projects/*",
1318
+ "tests/projects/workspace/packages/*"
1319
+ ],
1320
+ "peerDependencies": {
1321
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
1322
+ },
1323
+ "peerDependenciesMeta": {
1324
+ "vite": {
1325
+ "optional": true
1326
+ }
1327
+ }
1328
+ },
1329
+ "node_modules/zimmerframe": {
1330
+ "version": "1.1.4",
1331
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
1332
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
1333
+ "dev": true,
1334
+ "license": "MIT"
1335
+ }
1336
+ }
1337
+ }
web/svelte/package.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "riprap-svelte",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "vite build",
8
+ "dev": "vite build --watch"
9
+ },
10
+ "devDependencies": {
11
+ "svelte": "^5.0.0",
12
+ "vite": "^5.4.0",
13
+ "@sveltejs/vite-plugin-svelte": "^4.0.0"
14
+ }
15
+ }
web/svelte/src/lib/Briefing.svelte ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <svelte:options
2
+ customElement={{
3
+ tag: "r-briefing",
4
+ props: {
5
+ text: { type: "String" },
6
+ streaming: { type: "Boolean", reflect: true },
7
+ sourceLabels: { type: "Object" },
8
+ },
9
+ }} />
10
+
11
+ <script>
12
+ import { onMount, tick } from "svelte";
13
+ import { citeIndex, highlightedDocId } from "./stores.js";
14
+
15
+ let { text = "", streaming = false, sourceLabels = {} } = $props();
16
+
17
+ const escapeHtml = (s) =>
18
+ String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
19
+
20
+ function renderMarkdown(input) {
21
+ const lines = input.split("\n");
22
+ const out = [];
23
+ let para = []; let bullets = [];
24
+ const flushPara = () => {
25
+ if (!para.length) return;
26
+ const safe = escapeHtml(para.join(" ").trim())
27
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
28
+ if (safe) out.push(`<p class="rsum-p">${safe}</p>`);
29
+ para = [];
30
+ };
31
+ const flushBullets = () => {
32
+ if (!bullets.length) return;
33
+ const items = bullets.map(b => {
34
+ const safe = escapeHtml(b.trim()).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
35
+ return `<li>${safe}</li>`;
36
+ }).join("");
37
+ out.push(`<ul class="rsum-list">${items}</ul>`);
38
+ bullets = [];
39
+ };
40
+ // Granite sometimes runs all bullets onto one line.
41
+ const expanded = [];
42
+ for (const line of lines) {
43
+ if (line.trim().startsWith("- ") && line.includes(" - ", 2)) {
44
+ const parts = line.split(/(?:^|(?<=\.\s))\s*-\s+/g).filter(p => p.trim());
45
+ for (const p of parts) expanded.push("- " + p.trim());
46
+ } else { expanded.push(line); }
47
+ }
48
+ for (const line of expanded) {
49
+ const m = line.match(/^\s*\*\*([A-Z][A-Za-z\s/]+)\.\*\*\s*$/);
50
+ if (m) { flushPara(); flushBullets(); out.push(`<h4 class="rsum-h">${escapeHtml(m[1])}</h4>`); }
51
+ else if (/^\s*[-*]\s+/.test(line)) { flushPara(); bullets.push(line.replace(/^\s*[-*]\s+/, "")); }
52
+ else { flushBullets(); para.push(line); }
53
+ }
54
+ flushPara(); flushBullets();
55
+ return out.join("");
56
+ }
57
+
58
+ function rewriteCitations(html, indexMap) {
59
+ return html.replace(/\[([a-z0-9_]+)\]/gi, (_, id) => {
60
+ const norm = id.toLowerCase();
61
+ if (indexMap[norm] == null) indexMap[norm] = Object.keys(indexMap).length + 1;
62
+ const n = indexMap[norm];
63
+ const lab = sourceLabels[norm] || norm;
64
+ return `<span class="cite" data-src-id="${norm}" data-src-n="${n}" title="${lab.replace(/"/g, "&quot;")} — click to highlight">${n}</span>`;
65
+ });
66
+ }
67
+
68
+ let bodyHtml = $derived.by(() => {
69
+ if (!text) return "";
70
+ const indexMap = {};
71
+ const md = renderMarkdown(text);
72
+ const html = rewriteCitations(md, indexMap);
73
+ queueMicrotask(() => citeIndex.set({ ...indexMap }));
74
+ return html;
75
+ });
76
+
77
+ let container;
78
+ let hl = $derived($highlightedDocId);
79
+
80
+ // Re-bind chip listeners + hl class whenever the body or hl changes.
81
+ $effect(() => {
82
+ void bodyHtml; void hl;
83
+ if (!container) return;
84
+ tick().then(() => {
85
+ const chips = container.querySelectorAll(".cite");
86
+ chips.forEach(c => {
87
+ const id = c.dataset.srcId;
88
+ if (!id) return;
89
+ c.classList.toggle("hl", id === hl);
90
+ if (c.dataset.bound) return;
91
+ c.dataset.bound = "1";
92
+ c.addEventListener("mouseenter", () => highlightedDocId.set(id));
93
+ c.addEventListener("click", (e) => {
94
+ e.stopPropagation();
95
+ highlightedDocId.update(cur => cur === id ? null : id);
96
+ });
97
+ });
98
+ });
99
+ });
100
+ </script>
101
+
102
+ {#if !text}
103
+ <div class="rsum-p" style="color:var(--text-muted, #6b7280)">Waiting for content…</div>
104
+ {:else}
105
+ <div bind:this={container}>
106
+ {@html bodyHtml}
107
+ </div>
108
+ {/if}
109
+
110
+ <style>
111
+ :host { display: block; }
112
+ /* The host-level styles for typography, .cite, etc. live in the parent
113
+ stylesheet and target #paragraph descendants — they pierce shadow DOM
114
+ for inline-styled markup we don't ship here. The .rsum-* classes are
115
+ wired in the global stylesheet. We intentionally don't restate them. */
116
+ :host(.streaming)::after,
117
+ :host([streaming])::after {
118
+ content: "▋";
119
+ display: inline-block; color: var(--nyc-blue, #1642DF);
120
+ margin-left: 2px;
121
+ animation: caret 0.9s steps(1) infinite;
122
+ }
123
+ @keyframes caret { 50% { opacity: 0; } }
124
+ </style>
web/svelte/src/lib/SourcesFooter.svelte ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <svelte:options
2
+ customElement={{
3
+ tag: "r-sources-footer",
4
+ props: {
5
+ labels: { type: "Object" },
6
+ urls: { type: "Object" },
7
+ vintages: { type: "Object" },
8
+ },
9
+ }} />
10
+
11
+ <script>
12
+ import { citeIndex, highlightedDocId } from "./stores.js";
13
+ import { fade, scale } from "svelte/transition";
14
+ import { cubicOut } from "svelte/easing";
15
+
16
+ let { labels = {}, urls = {}, vintages = {} } = $props();
17
+
18
+ let entries = $derived(
19
+ Object.entries($citeIndex || {}).sort((a, b) => a[1] - b[1])
20
+ );
21
+ let hl = $derived($highlightedDocId);
22
+ </script>
23
+
24
+ {#if entries.length}
25
+ <div class="src-h" in:fade={{ duration: 200 }}>Sources</div>
26
+ <ol>
27
+ {#each entries as [id, n] (id)}
28
+ {@const url = urls[id]}
29
+ {@const label = labels[id] || id}
30
+ {@const vintage = vintages[id]}
31
+ <li class:hl={id === hl}
32
+ in:scale={{ start: 0.96, duration: 220, easing: cubicOut }}
33
+ onmouseenter={() => highlightedDocId.set(id)}
34
+ onclick={() => highlightedDocId.set(hl === id ? null : id)}>
35
+ <span class="src-num">[{n}]</span>
36
+ <div>
37
+ {#if url}
38
+ <a class="src-link" href={url} target="_blank" rel="noopener noreferrer"
39
+ onclick={(e) => e.stopPropagation()}>
40
+ {label} <span class="src-ext">↗</span>
41
+ </a>
42
+ {:else}
43
+ <span>{label}</span>
44
+ {/if}
45
+ <span class="src-id">{id}</span>
46
+ {#if vintage}<span class="src-vintage">{vintage}</span>{/if}
47
+ </div>
48
+ </li>
49
+ {/each}
50
+ </ol>
51
+ {/if}
52
+
53
+ <style>
54
+ :host {
55
+ display: block;
56
+ border-top: 1px solid var(--line, #e5e7eb);
57
+ background: var(--bg-soft, #f5f7fb);
58
+ padding: 12px 16px 14px;
59
+ }
60
+ :host(:not(:has(ol))) { display: none; }
61
+ .src-h {
62
+ font-size: 10px; font-weight: 700;
63
+ text-transform: uppercase; letter-spacing: 0.10em;
64
+ color: var(--text-muted, #6b7280);
65
+ margin: 0 0 8px;
66
+ }
67
+ ol {
68
+ margin: 0; padding: 0; list-style: none;
69
+ display: grid; gap: 6px;
70
+ font-size: 11.5px; line-height: 1.45;
71
+ }
72
+ li {
73
+ display: grid; grid-template-columns: 22px 1fr;
74
+ gap: 8px; align-items: baseline;
75
+ padding: 4px 6px; border-radius: 3px;
76
+ cursor: pointer;
77
+ transition: background 0.15s;
78
+ }
79
+ li:hover, li.hl { background: rgba(22, 66, 223, 0.10); }
80
+ li.hl {
81
+ /* Brief pulse each time a chip selects this row. */
82
+ animation: pulse 360ms cubic-bezier(.2,.7,.3,1);
83
+ }
84
+ @keyframes pulse {
85
+ 0% { box-shadow: 0 0 0 0 rgba(22, 66, 223, 0.35); }
86
+ 60% { box-shadow: 0 0 0 6px rgba(22, 66, 223, 0.00); }
87
+ 100% { box-shadow: 0 0 0 0 rgba(22, 66, 223, 0.00); }
88
+ }
89
+ .src-num {
90
+ font-family: var(--mono, monospace); font-size: 10.5px;
91
+ font-weight: 700; color: var(--nyc-blue, #1642DF);
92
+ text-align: right;
93
+ }
94
+ .src-link {
95
+ color: var(--text, #111); text-decoration: none;
96
+ border-bottom: 1px dotted var(--text-muted, #6b7280);
97
+ }
98
+ .src-link:hover {
99
+ color: var(--nyc-blue, #1642DF);
100
+ border-bottom-color: var(--nyc-blue, #1642DF);
101
+ }
102
+ .src-ext { font-size: 9.5px; color: var(--text-faint, #9ca3af); margin-left: 2px; vertical-align: super; }
103
+ .src-vintage { display: block; color: var(--text-muted, #6b7280); font-size: 9.5px; margin-top: 2px; }
104
+ .src-id { display: inline-block; font-family: var(--mono, monospace); font-size: 9.5px; color: var(--text-faint, #9ca3af); margin-left: 6px; }
105
+ </style>
web/svelte/src/lib/Trace.svelte ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <svelte:options
2
+ customElement={{
3
+ tag: "r-trace",
4
+ props: {
5
+ stepLabels: { type: "Object" },
6
+ },
7
+ }} />
8
+
9
+ <script>
10
+ import { onMount } from "svelte";
11
+ import { fly, fade } from "svelte/transition";
12
+ import { cubicOut } from "svelte/easing";
13
+
14
+ let { stepLabels = {} } = $props();
15
+ let steps = $state([]);
16
+
17
+ // Imperative API consumed by legacy agent.js. Exposed via the host
18
+ // element through onMount once the custom element is upgraded.
19
+ onMount(() => {
20
+ // `this` would be the wrapper context; rely on the component's
21
+ // host element discovery via document.currentScript trickery is
22
+ // brittle — Svelte exposes the host via the element instance.
23
+ // We expose pushStep / clear via a custom event-driven API:
24
+ // el.dispatchEvent(new CustomEvent('riprap-trace-push', { detail: step }))
25
+ // el.dispatchEvent(new CustomEvent('riprap-trace-clear'))
26
+ // But agent.js currently calls el.pushStep() / el.clear(); to keep
27
+ // that ergonomic we attach methods to the host in onMount.
28
+ // The host is the parent of the shadow root.
29
+ const host = container?.getRootNode()?.host;
30
+ if (host) {
31
+ host.pushStep = (step) => { steps = [...steps, step]; };
32
+ host.clear = () => { steps = []; };
33
+ }
34
+ });
35
+
36
+ let container;
37
+
38
+ const escapeHtml = (s) =>
39
+ String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
40
+
41
+ function classFor(step) {
42
+ return step.ok === true ? "ok" : step.ok === false ? "err" : "running";
43
+ }
44
+ function markFor(step) {
45
+ return step.ok === true ? "✓" : step.ok === false ? "✗" : "○";
46
+ }
47
+ function labelFor(step) {
48
+ return (stepLabels[step.step] && stepLabels[step.step][0]) || step.step;
49
+ }
50
+ function hintFor(step) {
51
+ return (stepLabels[step.step] && stepLabels[step.step][1]) || "";
52
+ }
53
+ </script>
54
+
55
+ <ol bind:this={container} id="steps-list">
56
+ {#each steps as step, i (i)}
57
+ <li class={classFor(step)}
58
+ in:fly={{ y: -8, duration: 220, easing: cubicOut }}>
59
+ <span class="icon">{markFor(step)}</span>
60
+ <div>
61
+ <div class="label">{labelFor(step)}</div>
62
+ <div class="meta">{hintFor(step)}</div>
63
+ </div>
64
+ {#if step.elapsed_s != null}
65
+ <span class="time">{step.elapsed_s}s</span>
66
+ {/if}
67
+ {#if step.result}
68
+ <div class="result">{JSON.stringify(step.result)}</div>
69
+ {/if}
70
+ {#if step.err}
71
+ <div class="result" style="color:var(--nyc-scarlet, #b80000)">{step.err}</div>
72
+ {/if}
73
+ </li>
74
+ {/each}
75
+ </ol>
76
+
77
+ <style>
78
+ :host { display: block; }
79
+ ol {
80
+ list-style: none; margin: 0; padding: 4px 0;
81
+ font-size: 12.5px;
82
+ }
83
+ li {
84
+ display: grid;
85
+ grid-template-columns: 18px 1fr auto;
86
+ gap: 10px;
87
+ padding: 7px 14px;
88
+ border-bottom: 1px solid var(--line, #e5e7eb);
89
+ align-items: baseline;
90
+ }
91
+ li:last-child { border-bottom: 0; }
92
+ .icon { font-weight: 700; font-size: 14px; line-height: 1; }
93
+ .running .icon { color: var(--nyc-blue, #1642DF); }
94
+ .ok .icon { color: var(--good, #1a8754); }
95
+ .err .icon { color: var(--nyc-scarlet, #b80000); }
96
+ .label { color: var(--text, #111); font-weight: 500; }
97
+ .meta { color: var(--text-muted, #6b7280); font-size: 11px; }
98
+ .time { font-family: var(--mono, monospace); color: var(--text-faint, #9ca3af); font-size: 11.5px; }
99
+ .running { background: rgba(22, 66, 223, 0.04); }
100
+ .result {
101
+ grid-column: 2 / -1;
102
+ color: var(--text-muted, #6b7280);
103
+ font-size: 11px;
104
+ font-family: var(--mono, monospace);
105
+ margin-top: 3px;
106
+ word-break: break-word;
107
+ line-height: 1.4;
108
+ }
109
+ </style>
web/svelte/src/lib/stores.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared state across all three custom elements. Svelte stores have
2
+ // .subscribe (Svelte runtime uses it) AND a manual subscribe is exposed
3
+ // here so legacy agent.js can wire vanilla DOM to the same signal that
4
+ // Svelte components react to.
5
+ import { writable } from "svelte/store";
6
+
7
+ export const highlightedDocId = writable(null);
8
+ // { doc_id: number } — Briefing populates as it encounters citations,
9
+ // SourcesFooter renders the numbered list from this.
10
+ export const citeIndex = writable({});
web/svelte/src/main.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Side-effect imports register the custom elements; agent.html mounts
2
+ // the same <r-briefing>, <r-trace>, <r-sources-footer> tags it always
3
+ // did — only the implementation changed.
4
+ import "./lib/SourcesFooter.svelte";
5
+ import "./lib/Briefing.svelte";
6
+ import "./lib/Trace.svelte";
7
+
8
+ // Re-export shared stores so non-Svelte code (legacy agent.js, the
9
+ // briefing chip-binding subscriber) can reach them. agent.js does:
10
+ // import("/static/dist/riprap.js").then(m => m.highlightedDocId.set(id))
11
+ export { highlightedDocId, citeIndex } from "./lib/stores.js";
web/svelte/vite.config.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
3
+ import { resolve } from "path";
4
+
5
+ // Svelte 5 custom-element build → drop-in replacement for the Lit
6
+ // components in /web/static/components. agent.html keeps using
7
+ // <r-briefing>, <r-trace>, <r-sources-footer>; this bundle just
8
+ // supplies their implementation.
9
+ export default defineConfig({
10
+ plugins: [
11
+ svelte({
12
+ compilerOptions: {
13
+ // Globally compile every .svelte file as a custom element so we
14
+ // get one bundle per page, no per-file flag needed.
15
+ customElement: true,
16
+ },
17
+ }),
18
+ ],
19
+ build: {
20
+ outDir: resolve(__dirname, "../static/dist"),
21
+ emptyOutDir: true,
22
+ lib: {
23
+ entry: resolve(__dirname, "src/main.js"),
24
+ formats: ["es"],
25
+ fileName: () => "riprap.js",
26
+ },
27
+ rollupOptions: {
28
+ output: { inlineDynamicImports: true },
29
+ },
30
+ target: "es2022",
31
+ sourcemap: true,
32
+ },
33
+ });
web/sveltekit/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .svelte-kit
3
+ # build/ is committed — HF Spaces does not run Node, so we ship the
4
+ # adapter-static output the same way web/static/dist/riprap.js is committed
5
+ # for the legacy custom-element bundle.
6
+ .DS_Store
7
+ .env*
8
+ !.env.example
9
+ vite.config.ts.timestamp-*
10
+ test-results/
11
+ playwright-report/
web/sveltekit/.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ engine-strict=true
web/sveltekit/build/200.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23FAFAF7'/%3E%3Crect x='2' y='2' width='5' height='12' fill='%23D17C00'/%3E%3C/svg%3E" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="description" content="Riprap — citation-grounded NYC flood-exposure briefings." />
8
+ <title>Riprap — flood-exposure briefing</title>
9
+ <link href="/_app/immutable/entry/start.CmPnsLDb.js" rel="modulepreload">
10
+ <link href="/_app/immutable/chunks/DOnKrFbX.js" rel="modulepreload">
11
+ <link href="/_app/immutable/chunks/CYuHyzh3.js" rel="modulepreload">
12
+ <link href="/_app/immutable/entry/app.NAzo06Kr.js" rel="modulepreload">
13
+ <link href="/_app/immutable/chunks/DwPgZwgo.js" rel="modulepreload">
14
+ <link href="/_app/immutable/chunks/BLULdth_.js" rel="modulepreload">
15
+ <link href="/_app/immutable/chunks/vWNuMvXT.js" rel="modulepreload">
16
+ <link href="/_app/immutable/nodes/0.B4c3XvjB.js" rel="modulepreload">
17
+ <link href="/_app/immutable/chunks/CdmpEGnB.js" rel="modulepreload">
18
+ <link href="/_app/immutable/chunks/DO0D806X.js" rel="modulepreload">
19
+ <link href="/_app/immutable/chunks/CpEmpa3I.js" rel="modulepreload">
20
+
21
+ <link href="/_app/immutable/assets/0.KpTzaSsX.css" rel="stylesheet">
22
+ </head>
23
+ <body data-sveltekit-preload-data="hover">
24
+ <div style="display: contents">
25
+ <script>
26
+ {
27
+ __sveltekit_yhur1t = {
28
+ base: ""
29
+ };
30
+
31
+ const element = document.currentScript.parentElement;
32
+
33
+ Promise.all([
34
+ import("/_app/immutable/entry/start.CmPnsLDb.js"),
35
+ import("/_app/immutable/entry/app.NAzo06Kr.js")
36
+ ]).then(([kit, app]) => {
37
+ kit.start(app, element);
38
+ });
39
+ }
40
+ </script>
41
+ </div>
42
+ </body>
43
+ </html>
web/sveltekit/build/_app/env.js ADDED
@@ -0,0 +1 @@
 
 
1
+ export const env={}
web/sveltekit/build/_app/immutable/assets/0.KpTzaSsX.css ADDED
The diff for this file is too large to render. See raw diff
 
web/sveltekit/build/_app/immutable/assets/3.BZfqQRM0.css ADDED
@@ -0,0 +1 @@
 
 
1
+ .print-doc.svelte-uialbm{max-width:7.5in;margin:.5in auto;padding:0 .5in;font-family:var(--font-serif),Georgia,serif;color:#111;background:#fff}.print-head.svelte-uialbm{border-bottom:1pt solid #111;padding-bottom:8pt;margin-bottom:14pt}.print-head-top.svelte-uialbm{display:flex;justify-content:space-between;align-items:baseline;font:9pt var(--font-mono, "IBM Plex Mono");color:#4a4a4a;text-transform:uppercase;letter-spacing:.04em}.wordmark.svelte-uialbm{font-weight:600;color:#111}.print-title.svelte-uialbm{font:600 22pt var(--font-sans, "IBM Plex Sans");margin:8pt 0 4pt;line-height:1.15}.print-sub.svelte-uialbm{font:10pt var(--font-mono, "IBM Plex Mono");color:#4a4a4a}.print-controls.svelte-uialbm{display:flex;gap:12px;align-items:center;margin:12pt 0;padding:8pt 10pt;background:#f5f5f3;border:1px solid #d8d6d2;border-radius:4px;font:10pt var(--font-sans, "IBM Plex Sans")}.print-controls.svelte-uialbm button:where(.svelte-uialbm){font:10pt var(--font-sans, "IBM Plex Sans");padding:4pt 10pt;background:#111;color:#fff;border:0;border-radius:3px;cursor:pointer}.hint.svelte-uialbm{color:#4a4a4a;font-size:9pt}.print-citations.svelte-uialbm{margin-top:18pt;padding-top:8pt;border-top:1pt solid #111;page-break-before:always}.print-citations.svelte-uialbm h2:where(.svelte-uialbm){font:600 13pt var(--font-sans, "IBM Plex Sans");margin:0 0 8pt}.print-citations.svelte-uialbm ol:where(.svelte-uialbm){list-style:none;padding:0;margin:0}.print-citations.svelte-uialbm li:where(.svelte-uialbm){margin-bottom:8pt;padding-left:28pt;position:relative;font-size:10pt;line-height:1.4;break-inside:avoid}.cn.svelte-uialbm{position:absolute;left:0;top:0;font:600 10pt var(--font-mono, "IBM Plex Mono");color:#0b5394}.cglyph.svelte-uialbm{display:inline-block;vertical-align:middle;margin-right:4pt}.csrc.svelte-uialbm{font-weight:600}.cvint.svelte-uialbm{color:#4a4a4a;margin-left:6pt;font-size:9pt}.ctitle.svelte-uialbm{color:#1a1a1a}.curl.svelte-uialbm{font:8.5pt var(--font-mono, "IBM Plex Mono");color:#0b5394;word-break:break-all}.cdocid.svelte-uialbm{font:8.5pt var(--font-mono, "IBM Plex Mono");color:#6b6b6b}.print-foot.svelte-uialbm{margin-top:18pt;padding-top:6pt;border-top:1pt solid #c8c6c2;font:8.5pt var(--font-mono, "IBM Plex Mono");color:#6b6b6b;line-height:1.5}.empty.svelte-uialbm{max-width:600px;margin:100px auto;padding:24px;font-family:var(--font-sans, "IBM Plex Sans");color:#1a1a1a}.empty.svelte-uialbm h1:where(.svelte-uialbm){font-size:20pt;margin-bottom:8pt}.empty.svelte-uialbm a:where(.svelte-uialbm){color:#0b5394}@media print{.no-print.svelte-uialbm{display:none!important}.print-doc.svelte-uialbm{margin:0;padding:0;max-width:none}@page{size:letter;margin:.85in .85in .85in 1in;@bottom-right{content:"page " counter(page) " of " counter(pages);font:9pt IBM Plex Mono;color:#4a4a4a}@bottom-left{content:"riprap.nyc";font:9pt IBM Plex Mono;color:#4a4a4a}}.print-citations.svelte-uialbm{page-break-before:always}}
web/sveltekit/build/_app/immutable/assets/4.CPUwsEjs.css ADDED
@@ -0,0 +1 @@
 
 
1
+ .register-grid.svelte-1q8jizq{display:grid;gap:16px;grid-template-columns:1fr}@media(min-width:1100px){.register-grid.svelte-1q8jizq{grid-template-columns:1fr 1fr}}.plan-details.svelte-1q8jizq{border:1px solid var(--rule-soft);background:var(--paper-deep);margin-bottom:16px}.plan-details.svelte-1q8jizq summary:where(.svelte-1q8jizq){padding:10px 14px;cursor:pointer;font-family:var(--font-mono);font-size:12px;color:var(--ink-secondary)}.plan-stream.svelte-1q8jizq{font-family:var(--font-mono);font-size:11px;color:var(--ink-tertiary);white-space:pre-wrap;padding:0 14px 12px;margin:0;max-height:240px;overflow:auto}.generating-status.svelte-1q8jizq{display:flex;align-items:center;gap:12px;padding:12px 0;font-family:var(--font-mono);font-size:13px;color:var(--ink-secondary);flex-wrap:wrap}.pulse.svelte-1q8jizq{width:8px;height:8px;border-radius:50%;background:var(--accent-graphical);animation:svelte-1q8jizq-pulse 1.4s ease-in-out infinite}@keyframes svelte-1q8jizq-pulse{0%,to{opacity:.3;transform:scale(.85)}50%{opacity:1;transform:scale(1.1)}}@media(prefers-reduced-motion:reduce){.pulse.svelte-1q8jizq{animation:none;opacity:.7}}
web/sveltekit/build/_app/immutable/assets/Briefing.Cg0TTl7h.css ADDED
@@ -0,0 +1 @@
 
 
1
+ .tier-badge.svelte-1acpjpp{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:500}
web/sveltekit/build/_app/immutable/assets/MapLegend.DvDgr167.css ADDED
@@ -0,0 +1 @@
 
 
1
+ .citation-drawer.svelte-1p339fd a{color:inherit;border-bottom:1px solid var(--rule-soft);text-decoration:none}.citation-drawer.svelte-1p339fd a:hover{border-bottom-color:var(--accent);color:var(--accent)}.rip-map-container.svelte-wk2bu4{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%}.map-frame.svelte-wk2bu4{aspect-ratio:8 / 5.6;position:relative}
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-400-normal.BSMlKf0J.woff2 ADDED
Binary file (8.36 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-400-normal.CEL4l2ZJ.woff ADDED
Binary file (7.2 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-500-normal.Ael50iVv.woff ADDED
Binary file (7.21 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-500-normal.Bq9vWWag.woff2 ADDED
Binary file (8.46 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-600-normal.CTOM6hUh.woff2 ADDED
Binary file (9.35 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-600-normal.fLZuRloM.woff ADDED
Binary file (7.27 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-400-normal.DMdlQ8Kv.woff ADDED
Binary file (5.82 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-400-normal.xuaO2J-f.woff2 ADDED
Binary file (6.91 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-500-normal.BIfNGwUT.woff ADDED
Binary file (5.77 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-500-normal.BqneJy0T.woff2 ADDED
Binary file (6.97 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-600-normal.9HEixskS.woff ADDED
Binary file (5.79 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-cyrillic-ext-600-normal.V-xxqcpd.woff2 ADDED
Binary file (7.69 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-400-normal.CvHOgSBP.woff ADDED
Binary file (13.1 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2 ADDED
Binary file (14.7 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-500-normal.CB9ihrfo.woff ADDED
Binary file (13.2 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2 ADDED
Binary file (14.9 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-600-normal.BgSNZQsw.woff2 ADDED
Binary file (15.6 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-600-normal.DWFSQ4vo.woff ADDED
Binary file (13.2 kB). View file
 
web/sveltekit/build/_app/immutable/assets/ibm-plex-mono-latin-ext-400-normal.BmRBH3aV.woff2 ADDED
Binary file (13.3 kB). View file