diff --git a/ARCHITECTURE_TODO.md b/ARCHITECTURE_TODO.md
new file mode 100644
index 0000000..c0881f2
--- /dev/null
+++ b/ARCHITECTURE_TODO.md
@@ -0,0 +1,43 @@
+# Architecture TODO
+
+This file tracks the current architecture cleanup work for the Nanobot web app.
+
+## Card Platform
+
+- [x] Move all template-backed cards to the dynamic `card.js` runtime.
+- [x] Remove inline-script fallback execution from the frontend shell.
+- [x] Keep the card runtime contract documented and stable.
+- [x] Validate every template card automatically in CI/local checks.
+- [x] Add lightweight fixture coverage for mount/update/destroy behavior.
+
+## Template Source Of Truth
+
+- [x] Treat `~/.nanobot/cards/templates` as the live source of truth.
+- [x] Sync repo examples from the live template tree with a script instead of manual copying.
+- [x] Make template drift a failing quality check.
+
+## Backend Structure
+
+- [x] Split card persistence/materialization out of `app.py`.
+- [x] Split session helpers out of `app.py`.
+- [x] Split Nanobot transport/client code out of `app.py`.
+- [x] Keep HTTP routes thin and push domain logic into service modules.
+
+## Frontend Structure
+
+- [x] Split `useWebRTC.ts` into transport, sessions, cards/feed, and workbench modules.
+- [x] Reduce `App.tsx` to layout/navigation concerns only.
+- [x] Keep `CardFeed.tsx` focused on rendering, not domain mutation orchestration.
+
+## Updates And Reactivity
+
+- [x] Replace polling-heavy paths with evented updates where possible.
+- [x] Keep card updates localized so a live sensor refresh does not churn the whole feed.
+
+## Remaining Cleanup
+
+- [x] Move backend startup state into FastAPI lifespan/app state.
+- [x] Stop importing private underscore helpers across app boundaries.
+- [x] Split the backend route surface into router modules.
+- [x] Split the card runtime renderer into loader/host/renderer modules.
+- [x] Replace async tool-job polling with an evented stream.
diff --git a/CARD_RUNTIME.md b/CARD_RUNTIME.md
new file mode 100644
index 0000000..c6534d6
--- /dev/null
+++ b/CARD_RUNTIME.md
@@ -0,0 +1,126 @@
+# Card Runtime
+
+The app shell is responsible for layout, navigation, sessions, feed ordering, and workbench placement.
+Cards are responsible for their own UI and behavior through a small dynamic runtime contract.
+
+## Source Of Truth
+
+- Live card templates live under `~/.nanobot/cards/templates`.
+- Repo examples under `examples/cards/templates` are mirrors for development/reference.
+- New cards must be added as `manifest.json + template.html + card.js`.
+- `template.html` is markup and styles only. Do not put executable `'
- f"{template_html}"
- ""
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
)
+ @app.get("/health")
+ async def health() -> JSONResponse:
+ return JSONResponse({"status": "ok"})
-def _load_card(card_id: str) -> dict[str, Any] | None:
- meta_path = _card_meta_path(card_id)
- if meta_path is None or not meta_path.exists():
- return None
- try:
- raw = json.loads(meta_path.read_text(encoding="utf-8"))
- except Exception:
- return None
- if not isinstance(raw, dict):
- return None
+ app.include_router(tools_router)
+ app.include_router(sessions_router)
+ app.include_router(cards_router)
+ app.include_router(workbench_router)
+ app.include_router(inbox_router)
+ app.include_router(templates_router)
+ app.include_router(messages_router)
+ app.include_router(rtc_router)
- state_path = _card_state_path(card_id)
- if state_path is not None and state_path.exists():
- try:
- raw_state = json.loads(state_path.read_text(encoding="utf-8"))
- except Exception:
- raw_state = {}
- if isinstance(raw_state, dict):
- raw["template_state"] = raw_state
-
- card = _coerce_card_record(raw)
- if card is None:
- return None
-
- card["content"] = _materialize_card_content(card)
- return card
-
-
-def _write_card(card: dict[str, Any]) -> dict[str, Any] | None:
- normalized = _coerce_card_record(card)
- if normalized is None:
- return None
-
- now = datetime.now(timezone.utc).isoformat()
- existing = _load_card(normalized["id"])
- if existing is not None:
- normalized["created_at"] = existing.get("created_at") or normalized.get("created_at") or now
- else:
- normalized["created_at"] = normalized.get("created_at") or now
- normalized["updated_at"] = normalized.get("updated_at") or now
-
- instance_dir = _card_instance_dir(normalized["id"])
- meta_path = _card_meta_path(normalized["id"])
- state_path = _card_state_path(normalized["id"])
- if instance_dir is None or meta_path is None or state_path is None:
- return None
-
- instance_dir.mkdir(parents=True, exist_ok=True)
-
- template_state = normalized.pop("template_state", {})
- meta_path.write_text(
- json.dumps(normalized, indent=2, ensure_ascii=False) + "\n",
- encoding="utf-8",
- )
- if normalized["kind"] == "text":
- state_path.write_text(
- json.dumps(
- template_state if isinstance(template_state, dict) else {},
- indent=2,
- ensure_ascii=False,
+ if DIST_DIR.exists():
+ assets_dir = DIST_DIR / "assets"
+ if assets_dir.exists():
+ app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
+ if CARD_TEMPLATES_DIR.exists():
+ app.mount(
+ "/card-templates",
+ StaticFiles(directory=str(CARD_TEMPLATES_DIR)),
+ name="card-templates",
)
- + "\n",
- encoding="utf-8",
- )
- else:
- state_path.unlink(missing_ok=True)
- return _load_card(normalized["id"])
+ @app.get("/{full_path:path}")
+ async def spa_fallback(full_path: str) -> FileResponse:
+ candidate = DIST_DIR / full_path
+ if candidate.is_file():
+ return FileResponse(str(candidate))
+ response = FileResponse(str(DIST_DIR / "index.html"))
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
+ response.headers["Pragma"] = "no-cache"
+ response.headers["Expires"] = "0"
+ return response
+ return app
-def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
- return sorted(
- cards,
- key=lambda item: (
- _CARD_LANE_ORDER.get(str(item.get("lane", "context")), 99),
- _CARD_STATE_ORDER.get(str(item.get("state", "active")), 99),
- -int(item.get("priority", 0) or 0),
- str(item.get("updated_at", "")),
- str(item.get("created_at", "")),
- ),
- reverse=False,
- )
-
-def _load_cards() -> list[dict[str, Any]]:
- CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
- cards: list[dict[str, Any]] = []
- now = datetime.now(timezone.utc)
- for instance_dir in CARD_INSTANCES_DIR.iterdir():
- if not instance_dir.is_dir():
- continue
- card = _load_card(instance_dir.name)
- if card is None:
- continue
- if card.get("state") == "archived":
- continue
- snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or ""))
- if snooze_until is not None and snooze_until > now:
- continue
- cards.append(card)
- return _sort_cards(cards)
-
-
-def _find_card_by_slot(slot: str, *, chat_id: str) -> dict[str, Any] | None:
- target_slot = slot.strip()
- if not target_slot:
- return None
- for card in _load_cards():
- if str(card.get("chat_id", "")).strip() != chat_id:
- continue
- if str(card.get("slot", "")).strip() == target_slot:
- return card
- return None
-
-
-def _persist_card(card: dict[str, Any]) -> dict[str, Any] | None:
- normalized = _coerce_card_record(card)
- if normalized is None:
- return None
-
- existing_same_slot = None
- slot = str(normalized.get("slot", "")).strip()
- chat_id = str(normalized.get("chat_id", "web") or "web")
- if slot:
- existing_same_slot = _find_card_by_slot(slot, chat_id=chat_id)
-
- if existing_same_slot is not None and existing_same_slot["id"] != normalized["id"]:
- superseded = dict(existing_same_slot)
- superseded["state"] = "superseded"
- superseded["updated_at"] = datetime.now(timezone.utc).isoformat()
- _write_card(superseded)
-
- return _write_card(normalized)
-
-
-def _delete_card(card_id: str) -> bool:
- instance_dir = _card_instance_dir(card_id)
- if instance_dir is None or not instance_dir.exists():
- return False
- shutil.rmtree(instance_dir, ignore_errors=True)
- return True
-
-
-def _template_dir(template_key: str) -> Path:
- return CARD_TEMPLATES_DIR / template_key
-
-
-def _template_html_path(template_key: str) -> Path:
- return _template_dir(template_key) / "template.html"
-
-
-def _template_meta_path(template_key: str) -> Path:
- return _template_dir(template_key) / "manifest.json"
-
-
-def _read_template_meta(template_key: str) -> dict[str, Any]:
- meta_path = _template_meta_path(template_key)
- try:
- meta = json.loads(meta_path.read_text(encoding="utf-8"))
- return meta if isinstance(meta, dict) else {}
- except Exception:
- return {}
-
-
-def _list_templates(limit: int | None = None) -> list[dict[str, Any]]:
- CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
- templates: list[dict[str, Any]] = []
-
- for template_dir in CARD_TEMPLATES_DIR.iterdir():
- if not template_dir.is_dir():
- continue
- key = _normalize_template_key(template_dir.name)
- if not key:
- continue
-
- html_path = _template_html_path(key)
- if not html_path.exists():
- continue
- try:
- content = html_path.read_text(encoding="utf-8")
- except Exception:
- continue
-
- stat = html_path.stat()
- meta = _read_template_meta(key)
- if bool(meta.get("deprecated")):
- continue
- created_at = str(
- meta.get("created_at")
- or datetime.fromtimestamp(stat.st_ctime, timezone.utc).isoformat()
- )
- updated_at = str(
- meta.get("updated_at")
- or datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()
- )
- templates.append(
- {
- "id": key,
- "key": key,
- "title": str(meta.get("title", "")),
- "content": content,
- "notes": str(meta.get("notes", "")),
- "example_state": meta.get("example_state", {}),
- "created_at": created_at,
- "updated_at": updated_at,
- "file_url": f"/card-templates/{key}/template.html",
- }
- )
-
- templates.sort(key=lambda item: item["updated_at"], reverse=True)
- if limit is not None:
- return templates[: max(0, limit)]
- return templates
-
-
-def _render_templates_markdown(rows: list[dict[str, Any]]) -> str:
- lines = [
- "# Card Templates",
- "",
- "These are user-approved template layouts for `mcp_display_render_card` cards.",
- "Each card instance should provide a `template_key` and a `template_state` JSON object.",
- "Use a matching template when the request intent fits.",
- "Do not rewrite the HTML layout when an existing template already fits; fill the template_state instead.",
- "",
- ]
- for row in rows:
- key = str(row.get("key", "")).strip() or "unnamed"
- title = str(row.get("title", "")).strip() or "(untitled)"
- notes = str(row.get("notes", "")).strip() or "(no usage notes)"
- content = str(row.get("content", "")).strip()
- example_state = row.get("example_state", {})
- if len(content) > MAX_TEMPLATE_HTML_CHARS:
- content = content[:MAX_TEMPLATE_HTML_CHARS] + "\n"
- html_lines = [f" {line}" for line in content.splitlines()] if content else [" "]
- state_text = (
- json.dumps(example_state, indent=2, ensure_ascii=False)
- if isinstance(example_state, dict)
- else "{}"
- )
- state_lines = [f" {line}" for line in state_text.splitlines()]
- lines.extend(
- [
- f"## {key}",
- f"- Title: {title}",
- f"- Usage: {notes}",
- "- Example State:",
- *state_lines,
- "- HTML:",
- *html_lines,
- "",
- ]
- )
- return "\n".join(lines).rstrip() + "\n"
-
-
-def _sync_templates_context_file() -> None:
- try:
- rows = _list_templates(limit=MAX_TEMPLATES_IN_PROMPT)
- if not rows:
- TEMPLATES_CONTEXT_PATH.unlink(missing_ok=True)
- return
- TEMPLATES_CONTEXT_PATH.parent.mkdir(parents=True, exist_ok=True)
- TEMPLATES_CONTEXT_PATH.write_text(_render_templates_markdown(rows), encoding="utf-8")
- except Exception:
- return
-
-
-def _to_typed_message(event_dict: dict[str, Any]) -> dict[str, Any] | None:
- role = str(event_dict.get("role", "")).strip()
- text = str(event_dict.get("text", ""))
- timestamp = str(event_dict.get("timestamp", ""))
-
- if role == "agent-state":
- return {"type": "agent_state", "state": text}
-
- if role in {"nanobot", "nanobot-progress", "nanobot-tool", "system", "user"}:
- return {
- "type": "message",
- "role": role,
- "content": text,
- "is_progress": role in {"nanobot-progress", "nanobot-tool"},
- "is_tool_hint": role == "nanobot-tool",
- "timestamp": timestamp,
- }
-
- if role == "card":
- payload = _decode_object(text)
- if payload is None:
- return None
- card = _coerce_card_record(payload)
- if card is None:
- return None
- card["type"] = "card"
- return card
-
- return None
-
-
-# ---------------------------------------------------------------------------
-# Nanobot API helpers
-# ---------------------------------------------------------------------------
-
-
-class _NanobotApiError(RuntimeError):
- def __init__(self, code: int, message: str) -> None:
- super().__init__(message)
- self.code = code
-
-
-def _jsonrpc_request(request_id: str, method: str, params: dict[str, Any]) -> dict[str, Any]:
- return {
- "jsonrpc": _JSONRPC_VERSION,
- "id": request_id,
- "method": method,
- "params": params,
- }
-
-
-async def _open_nanobot_api_socket() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
- if not NANOBOT_API_SOCKET.exists():
- raise RuntimeError(
- f"Nanobot API socket not found at {NANOBOT_API_SOCKET}. "
- "Enable channels.api and start `nanobot gateway`."
- )
- try:
- return await asyncio.open_unix_connection(
- path=str(NANOBOT_API_SOCKET),
- limit=_NANOBOT_API_STREAM_LIMIT,
- )
- except OSError as exc:
- raise RuntimeError(f"failed to connect to Nanobot API socket: {exc}") from exc
-
-
-async def _send_nanobot_api_request(
- method: str,
- params: dict[str, Any],
- *,
- timeout_seconds: float,
-) -> Any:
- request_id = str(uuid.uuid4())
- reader, writer = await _open_nanobot_api_socket()
- try:
- writer.write(
- (
- json.dumps(_jsonrpc_request(request_id, method, params), ensure_ascii=False) + "\n"
- ).encode("utf-8")
- )
- await writer.drain()
-
- loop = asyncio.get_running_loop()
- deadline = loop.time() + timeout_seconds
-
- while True:
- remaining = deadline - loop.time()
- if remaining <= 0:
- raise RuntimeError(f"timed out waiting for Nanobot API response to {method}")
-
- try:
- line = await asyncio.wait_for(reader.readline(), timeout=remaining)
- except ValueError as exc:
- raise RuntimeError(
- "Nanobot API response exceeded the configured stream limit"
- ) from exc
- if not line:
- raise RuntimeError("Nanobot API socket closed before responding")
-
- try:
- message = json.loads(line.decode("utf-8", errors="replace"))
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if message.get("jsonrpc") != _JSONRPC_VERSION:
- continue
- if "method" in message:
- continue
- if str(message.get("id", "")).strip() != request_id:
- continue
- if "error" in message:
- error = message.get("error", {})
- if isinstance(error, dict):
- raise _NanobotApiError(
- int(error.get("code", -32000)),
- str(error.get("message", "unknown Nanobot API error")),
- )
- raise _NanobotApiError(-32000, str(error))
- return message.get("result")
- finally:
- writer.close()
- await writer.wait_closed()
-
-
-def _utc_now_iso() -> str:
- return datetime.now(timezone.utc).isoformat()
-
-
-async def _prune_tool_jobs_locked() -> None:
- cutoff = datetime.now(timezone.utc).timestamp() - _TOOL_JOB_RETENTION_SECONDS
- expired_job_ids: list[str] = []
-
- for job_id, payload in _tool_jobs.items():
- finished_at = str(payload.get("finished_at", "") or "")
- if not finished_at:
- continue
- try:
- finished_ts = datetime.fromisoformat(finished_at).timestamp()
- except ValueError:
- finished_ts = 0.0
- if finished_ts <= cutoff:
- expired_job_ids.append(job_id)
-
- for job_id in expired_job_ids:
- task = _tool_job_tasks.get(job_id)
- if task is not None and not task.done():
- continue
- _tool_jobs.pop(job_id, None)
- _tool_job_tasks.pop(job_id, None)
-
-
-def _serialize_tool_job(payload: dict[str, Any]) -> dict[str, Any]:
- result = payload.get("result")
- if not isinstance(result, dict):
- result = None
- return {
- "job_id": str(payload.get("job_id", "")),
- "tool_name": str(payload.get("tool_name", "")),
- "status": str(payload.get("status", "queued") or "queued"),
- "created_at": str(payload.get("created_at", "")),
- "started_at": payload.get("started_at"),
- "finished_at": payload.get("finished_at"),
- "result": result,
- "error": payload.get("error"),
- "error_code": payload.get("error_code"),
- }
-
-
-async def _start_tool_job(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
- job_id = uuid.uuid4().hex
- payload = {
- "job_id": job_id,
- "tool_name": tool_name,
- "status": "queued",
- "created_at": _utc_now_iso(),
- "started_at": None,
- "finished_at": None,
- "result": None,
- "error": None,
- "error_code": None,
- }
-
- async with _tool_job_lock:
- await _prune_tool_jobs_locked()
- _tool_jobs[job_id] = payload
- _tool_job_tasks[job_id] = asyncio.create_task(
- _run_tool_job(job_id, tool_name, dict(arguments)),
- name=f"manual-tool-{job_id}",
- )
- return _serialize_tool_job(payload)
-
-
-async def _run_tool_job(job_id: str, tool_name: str, arguments: dict[str, Any]) -> None:
- async with _tool_job_lock:
- payload = _tool_jobs.get(job_id)
- if payload is None:
- return
- payload["status"] = "running"
- payload["started_at"] = _utc_now_iso()
-
- try:
- result = await _send_nanobot_api_request(
- "tool.call",
- {"name": tool_name, "arguments": arguments},
- timeout_seconds=_TOOL_JOB_TIMEOUT_SECONDS,
- )
- if not isinstance(result, dict):
- raise RuntimeError("Nanobot API returned an invalid tool response")
- async with _tool_job_lock:
- payload = _tool_jobs.get(job_id)
- if payload is None:
- return
- payload["status"] = "completed"
- payload["result"] = result
- payload["finished_at"] = _utc_now_iso()
- except asyncio.CancelledError:
- async with _tool_job_lock:
- payload = _tool_jobs.get(job_id)
- if payload is not None:
- payload["status"] = "failed"
- payload["error"] = "tool job cancelled"
- payload["finished_at"] = _utc_now_iso()
- raise
- except _NanobotApiError as exc:
- async with _tool_job_lock:
- payload = _tool_jobs.get(job_id)
- if payload is not None:
- payload["status"] = "failed"
- payload["error"] = str(exc)
- payload["error_code"] = exc.code
- payload["finished_at"] = _utc_now_iso()
- except RuntimeError as exc:
- async with _tool_job_lock:
- payload = _tool_jobs.get(job_id)
- if payload is not None:
- payload["status"] = "failed"
- payload["error"] = str(exc)
- payload["finished_at"] = _utc_now_iso()
- except Exception as exc:
- async with _tool_job_lock:
- payload = _tool_jobs.get(job_id)
- if payload is not None:
- payload["status"] = "failed"
- payload["error"] = f"unexpected tool job error: {exc}"
- payload["finished_at"] = _utc_now_iso()
- finally:
- async with _tool_job_lock:
- _tool_job_tasks.pop(job_id, None)
- await _prune_tool_jobs_locked()
-
-
-async def _get_tool_job(job_id: str) -> dict[str, Any] | None:
- async with _tool_job_lock:
- await _prune_tool_jobs_locked()
- payload = _tool_jobs.get(job_id)
- return _serialize_tool_job(payload) if payload is not None else None
-
-
-# ---------------------------------------------------------------------------
-# API routes
-# ---------------------------------------------------------------------------
-
-
-@app.get("/health")
-async def health() -> JSONResponse:
- return JSONResponse({"status": "ok"})
-
-
-@app.get("/tools")
-async def list_tools() -> JSONResponse:
- try:
- result = await _send_nanobot_api_request("tool.list", {}, timeout_seconds=20.0)
- except _NanobotApiError as exc:
- status_code = 503 if exc.code == -32000 else 502
- return JSONResponse({"error": str(exc)}, status_code=status_code)
- except RuntimeError as exc:
- return JSONResponse({"error": str(exc)}, status_code=503)
-
- if not isinstance(result, dict):
- return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502)
-
- tools = result.get("tools", [])
- if not isinstance(tools, list):
- return JSONResponse({"error": "Nanobot API returned an invalid tool list"}, status_code=502)
- return JSONResponse({"tools": tools})
-
-
-@app.post("/tools/call")
-async def call_tool(request: Request) -> JSONResponse:
- try:
- payload = await _read_json_request(request)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
-
- tool_name = str(payload.get("tool_name", payload.get("name", ""))).strip()
- if not tool_name:
- return JSONResponse({"error": "tool_name is required"}, status_code=400)
-
- arguments = payload.get("arguments", payload.get("params", {}))
- if arguments is None:
- arguments = {}
- if not isinstance(arguments, dict):
- return JSONResponse({"error": "arguments must be a JSON object"}, status_code=400)
- async_requested = payload.get("async") is True
-
- if async_requested:
- job_payload = await _start_tool_job(tool_name, arguments)
- return JSONResponse(job_payload, status_code=202)
-
- try:
- result = await _send_nanobot_api_request(
- "tool.call",
- {"name": tool_name, "arguments": arguments},
- timeout_seconds=60.0,
- )
- except _NanobotApiError as exc:
- status_code = 400 if exc.code == -32602 else 503 if exc.code == -32000 else 502
- return JSONResponse({"error": str(exc)}, status_code=status_code)
- except RuntimeError as exc:
- return JSONResponse({"error": str(exc)}, status_code=503)
-
- if not isinstance(result, dict):
- return JSONResponse(
- {"error": "Nanobot API returned an invalid tool response"}, status_code=502
- )
- return JSONResponse(result)
-
-
-@app.get("/tools/jobs/{job_id}")
-async def get_tool_job(job_id: str) -> JSONResponse:
- safe_job_id = job_id.strip()
- if not safe_job_id:
- return JSONResponse({"error": "job id is required"}, status_code=400)
-
- payload = await _get_tool_job(safe_job_id)
- if payload is None:
- return JSONResponse({"error": "tool job not found"}, status_code=404)
- return JSONResponse(payload)
-
-
-@app.get("/cards")
-async def get_cards() -> JSONResponse:
- return JSONResponse(_load_cards())
-
-
-@app.delete("/cards/{card_id}")
-async def delete_card(card_id: str) -> JSONResponse:
- if not _normalize_card_id(card_id):
- return JSONResponse({"error": "invalid card id"}, status_code=400)
- _delete_card(card_id)
- return JSONResponse({"status": "ok"})
-
-
-@app.post("/cards/{card_id}/snooze")
-async def snooze_card(card_id: str, request: Request) -> JSONResponse:
- if not _normalize_card_id(card_id):
- return JSONResponse({"error": "invalid card id"}, status_code=400)
-
- try:
- payload = await _read_json_request(request)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
-
- until_raw = str(payload.get("until", "")).strip()
- until_dt = _parse_iso_datetime(until_raw)
- if until_dt is None:
- return JSONResponse({"error": "until must be a valid ISO datetime"}, status_code=400)
-
- card = _load_card(card_id)
- if card is None:
- return JSONResponse({"error": "card not found"}, status_code=404)
-
- card["snooze_until"] = until_dt.isoformat()
- card["updated_at"] = datetime.now(timezone.utc).isoformat()
- persisted = _write_card(card)
- if persisted is None:
- return JSONResponse({"error": "failed to snooze card"}, status_code=500)
- return JSONResponse({"status": "ok", "card": persisted})
-
-
-@app.post("/cards/{card_id}/state")
-async def update_card_state(card_id: str, request: Request) -> JSONResponse:
- if not _normalize_card_id(card_id):
- return JSONResponse({"error": "invalid card id"}, status_code=400)
-
- try:
- payload = await _read_json_request(request)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
-
- template_state = payload.get("template_state")
- if not isinstance(template_state, dict):
- return JSONResponse({"error": "template_state must be an object"}, status_code=400)
-
- card = _load_card(card_id)
- if card is None:
- return JSONResponse({"error": "card not found"}, status_code=404)
-
- if str(card.get("kind", "")) != "text":
- return JSONResponse({"error": "only text cards support template_state"}, status_code=400)
-
- card["template_state"] = template_state
- card["updated_at"] = datetime.now(timezone.utc).isoformat()
- persisted = _write_card(card)
- if persisted is None:
- return JSONResponse({"error": "failed to update card state"}, status_code=500)
- return JSONResponse({"status": "ok", "card": persisted})
-
-
-@app.get("/templates")
-async def get_templates() -> JSONResponse:
- return JSONResponse(_list_templates())
-
-
-@app.post("/templates")
-async def save_template(request: Request) -> JSONResponse:
- try:
- payload = await _read_json_request(request)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
- key = _normalize_template_key(str(payload.get("key", "")))
- title = str(payload.get("title", "")).strip()
- content = str(payload.get("content", "")).strip()
- notes = str(payload.get("notes", "")).strip()
- example_state = payload.get("example_state", {})
- if not isinstance(example_state, dict):
- example_state = {}
-
- if not key:
- return JSONResponse({"error": "template key is required"}, status_code=400)
- if not content:
- return JSONResponse({"error": "template content is required"}, status_code=400)
-
- template_dir = _template_dir(key)
- template_dir.mkdir(parents=True, exist_ok=True)
- now = datetime.now(timezone.utc).isoformat()
- existing_meta = _read_template_meta(key)
- created_at = str(existing_meta.get("created_at") or now)
-
- _template_html_path(key).write_text(content, encoding="utf-8")
- _template_meta_path(key).write_text(
- json.dumps(
- {
- "key": key,
- "title": title,
- "notes": notes,
- "example_state": example_state,
- "created_at": created_at,
- "updated_at": now,
- },
- indent=2,
- ensure_ascii=False,
- )
- + "\n",
- encoding="utf-8",
- )
-
- _sync_templates_context_file()
- return JSONResponse(
- {
- "status": "ok",
- "id": key,
- "key": key,
- "example_state": example_state,
- "file_url": f"/card-templates/{key}/template.html",
- }
- )
-
-
-@app.delete("/templates/{template_key}")
-async def delete_template(template_key: str) -> JSONResponse:
- key = _normalize_template_key(template_key)
- if not key:
- return JSONResponse({"error": "invalid template key"}, status_code=400)
- shutil.rmtree(_template_dir(key), ignore_errors=True)
- _sync_templates_context_file()
- return JSONResponse({"status": "ok", "key": key})
-
-
-@app.post("/message")
-async def post_message(request: Request) -> JSONResponse:
- try:
- payload = await _read_json_request(request)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
- text = str(payload.get("text", "")).strip()
- metadata = payload.get("metadata", {})
- if not text:
- return JSONResponse({"error": "empty message"}, status_code=400)
- if not isinstance(metadata, dict):
- metadata = {}
- try:
- await gateway.send_user_message(text, metadata=metadata)
- except RuntimeError as exc:
- return JSONResponse({"error": str(exc)}, status_code=503)
- return JSONResponse({"status": "ok"})
-
-
-@app.post("/rtc/offer")
-async def rtc_offer(request: Request) -> JSONResponse:
- global _active_session, _active_queue, _sender_task
-
- try:
- payload = await _read_json_request(request)
- except ValueError as exc:
- return JSONResponse({"error": str(exc)}, status_code=400)
-
- if _active_session is not None:
- await _active_session.close()
- _active_session = None
- if _active_queue is not None:
- await gateway.unsubscribe(_active_queue)
- _active_queue = None
- if _sender_task is not None:
- _sender_task.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await _sender_task
- _sender_task = None
-
- queue = await gateway.subscribe()
- _active_queue = queue
-
- voice_session = WebRTCVoiceSession(gateway=gateway)
- _active_session = voice_session
-
- _sender_task = asyncio.create_task(
- _sender_loop(queue, voice_session),
- name="rtc-sender",
- )
-
- answer = await voice_session.handle_offer(payload)
- if answer is None:
- return JSONResponse(
- {"error": "WebRTC backend unavailable on host (aiortc is not installed)."},
- status_code=503,
- )
-
- await gateway.connect_nanobot()
- return JSONResponse(answer)
-
-
-@app.on_event("shutdown")
-async def on_shutdown() -> None:
- global _active_session, _active_queue, _sender_task
- tool_tasks = list(_tool_job_tasks.values())
- for task in tool_tasks:
- task.cancel()
- if tool_tasks:
- with contextlib.suppress(asyncio.CancelledError):
- await asyncio.gather(*tool_tasks, return_exceptions=True)
- if _sender_task is not None:
- _sender_task.cancel()
- with contextlib.suppress(asyncio.CancelledError):
- await _sender_task
- if _active_session is not None:
- await _active_session.close()
- if _active_queue is not None:
- await gateway.unsubscribe(_active_queue)
- await gateway.shutdown()
-
-
-async def _sender_loop(
- queue: asyncio.Queue,
- voice_session: "WebRTCVoiceSession",
-) -> None:
- while True:
- event = await queue.get()
- if event.role == "nanobot-tts-partial":
- await voice_session.queue_output_text(event.text, partial=True)
- continue
- if event.role == "nanobot-tts-flush":
- await voice_session.flush_partial_output_text()
- continue
- if event.role == "nanobot-tts":
- for segment in _chunk_tts_text(event.text):
- await voice_session.queue_output_text(segment)
- continue
- typed_event = _to_typed_message(event.to_dict())
- if typed_event is None:
- continue
- if typed_event.get("type") == "card":
- persisted = _persist_card(typed_event)
- if persisted is None:
- continue
- payload = dict(persisted)
- payload["type"] = "card"
- voice_session.send_to_datachannel(payload)
- continue
- voice_session.send_to_datachannel(typed_event)
-
-
-@app.on_event("startup")
-async def on_startup() -> None:
- CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
- CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
- _sync_templates_context_file()
-
-
-if DIST_DIR.exists():
- assets_dir = DIST_DIR / "assets"
- if assets_dir.exists():
- app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
- if CARD_TEMPLATES_DIR.exists():
- app.mount(
- "/card-templates",
- StaticFiles(directory=str(CARD_TEMPLATES_DIR)),
- name="card-templates",
- )
-
- @app.get("/{full_path:path}")
- async def spa_fallback(full_path: str) -> FileResponse:
- candidate = DIST_DIR / full_path
- if candidate.is_file():
- return FileResponse(str(candidate))
- response = FileResponse(str(DIST_DIR / "index.html"))
- response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
- response.headers["Pragma"] = "no-cache"
- response.headers["Expires"] = "0"
- return response
+app = create_app()
diff --git a/app_dependencies.py b/app_dependencies.py
new file mode 100644
index 0000000..0fa9822
--- /dev/null
+++ b/app_dependencies.py
@@ -0,0 +1,9 @@
+from __future__ import annotations
+
+from fastapi import Request
+
+from web_runtime import WebAppRuntime
+
+
+def get_runtime(request: Request) -> WebAppRuntime:
+ return request.app.state.runtime
diff --git a/card_store.py b/card_store.py
new file mode 100644
index 0000000..57d1f44
--- /dev/null
+++ b/card_store.py
@@ -0,0 +1,488 @@
+from __future__ import annotations
+
+import html
+import json
+import os
+import re
+import shutil
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+NANOBOT_WORKSPACE = Path(os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))).expanduser()
+CARDS_ROOT = NANOBOT_WORKSPACE / "cards"
+CARD_INSTANCES_DIR = CARDS_ROOT / "instances"
+CARD_TEMPLATES_DIR = CARDS_ROOT / "templates"
+TEMPLATES_CONTEXT_PATH = NANOBOT_WORKSPACE / "CARD_TEMPLATES.md"
+MAX_TEMPLATES_IN_PROMPT = 12
+MAX_TEMPLATE_HTML_CHARS = 4000
+_INVALID_TEMPLATE_KEY_CHARS = re.compile(r"[^a-z0-9_-]+")
+_CARD_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
+_CARD_LANE_ORDER = {"attention": 0, "work": 1, "context": 2, "history": 3}
+_CARD_STATE_ORDER = {"active": 0, "stale": 1, "resolved": 2, "superseded": 3, "archived": 4}
+
+
+def ensure_card_store_dirs() -> None:
+ CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
+ CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def _normalize_template_key(raw: str) -> str:
+ key = _INVALID_TEMPLATE_KEY_CHARS.sub("-", raw.strip().lower()).strip("-")
+ return key[:64]
+
+
+def _normalize_card_id(raw: str) -> str:
+ card_id = raw.strip()
+ return card_id if _CARD_ID_PATTERN.fullmatch(card_id) else ""
+
+
+def _card_instance_dir(card_id: str) -> Path | None:
+ card_id_clean = _normalize_card_id(card_id)
+ if not card_id_clean:
+ return None
+ return CARD_INSTANCES_DIR / card_id_clean
+
+
+def _card_meta_path(card_id: str) -> Path | None:
+ instance_dir = _card_instance_dir(card_id)
+ if instance_dir is None:
+ return None
+ return instance_dir / "card.json"
+
+
+def _card_state_path(card_id: str) -> Path | None:
+ instance_dir = _card_instance_dir(card_id)
+ if instance_dir is None:
+ return None
+ return instance_dir / "state.json"
+
+
+def _parse_iso_datetime(raw: str) -> datetime | None:
+ value = raw.strip()
+ if not value:
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
+def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
+ card_id = _normalize_card_id(str(raw.get("id", "")))
+ if not card_id:
+ return None
+
+ kind = str(raw.get("kind", "text") or "text").strip().lower()
+ if kind not in {"text", "question"}:
+ kind = "text"
+
+ lane = str(raw.get("lane", "context") or "context").strip().lower()
+ if lane not in _CARD_LANE_ORDER:
+ lane = "context"
+
+ state = str(raw.get("state", "active") or "active").strip().lower()
+ if state not in _CARD_STATE_ORDER:
+ state = "active"
+
+ try:
+ priority = int(raw.get("priority", 50))
+ except (TypeError, ValueError):
+ priority = 50
+ priority = max(0, min(priority, 100))
+
+ raw_choices = raw.get("choices", [])
+ choices = [str(choice) for choice in raw_choices] if isinstance(raw_choices, list) else []
+ raw_template_state = raw.get("template_state", {})
+ template_state = raw_template_state if isinstance(raw_template_state, dict) else {}
+
+ return {
+ "id": card_id,
+ "kind": kind,
+ "title": str(raw.get("title", "")),
+ "content": str(raw.get("content", "")),
+ "question": str(raw.get("question", "")),
+ "choices": choices,
+ "response_value": str(raw.get("response_value", "")),
+ "slot": str(raw.get("slot", "")),
+ "lane": lane,
+ "priority": priority,
+ "state": state,
+ "template_key": str(raw.get("template_key", "")),
+ "template_state": template_state,
+ "context_summary": str(raw.get("context_summary", "")),
+ "chat_id": str(raw.get("chat_id", "web") or "web"),
+ "snooze_until": str(raw.get("snooze_until", "") or ""),
+ "created_at": str(raw.get("created_at", "")),
+ "updated_at": str(raw.get("updated_at", "")),
+ }
+
+
+def _json_script_text(payload: dict[str, Any]) -> str:
+ return json.dumps(payload, ensure_ascii=False).replace("", "<\\/")
+
+
+def _template_dir(template_key: str) -> Path:
+ return CARD_TEMPLATES_DIR / template_key
+
+
+def _template_html_path(template_key: str) -> Path:
+ return _template_dir(template_key) / "template.html"
+
+
+def _template_meta_path(template_key: str) -> Path:
+ return _template_dir(template_key) / "manifest.json"
+
+
+def _read_template_meta(template_key: str) -> dict[str, Any]:
+ meta_path = _template_meta_path(template_key)
+ try:
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
+ return meta if isinstance(meta, dict) else {}
+ except Exception:
+ return {}
+
+
+def _materialize_card_content(card: dict[str, Any]) -> str:
+ if card.get("kind") != "text":
+ return str(card.get("content", ""))
+
+ template_key = str(card.get("template_key", "")).strip()
+ if not template_key:
+ return str(card.get("content", ""))
+
+ html_path = _template_html_path(template_key)
+ try:
+ template_html = html_path.read_text(encoding="utf-8")
+ except Exception:
+ return (
+ '
'
+ f"Missing template: {html.escape(template_key)}"
+ "
"
+ )
+
+ state_payload = card.get("template_state", {})
+ if not isinstance(state_payload, dict):
+ state_payload = {}
+
+ card_id = html.escape(str(card.get("id", "")))
+ safe_template_key = html.escape(template_key)
+ return (
+ f''
+ f''
+ f"{template_html}"
+ "
"
+ )
+
+
+def _load_card(card_id: str) -> dict[str, Any] | None:
+ meta_path = _card_meta_path(card_id)
+ if meta_path is None or not meta_path.exists():
+ return None
+ try:
+ raw = json.loads(meta_path.read_text(encoding="utf-8"))
+ except Exception:
+ return None
+ if not isinstance(raw, dict):
+ return None
+
+ state_path = _card_state_path(card_id)
+ if state_path is not None and state_path.exists():
+ try:
+ raw_state = json.loads(state_path.read_text(encoding="utf-8"))
+ except Exception:
+ raw_state = {}
+ if isinstance(raw_state, dict):
+ raw["template_state"] = raw_state
+
+ card = _coerce_card_record(raw)
+ if card is None:
+ return None
+
+ card["content"] = _materialize_card_content(card)
+ return card
+
+
+def _write_card(card: dict[str, Any]) -> dict[str, Any] | None:
+ normalized = _coerce_card_record(card)
+ if normalized is None:
+ return None
+
+ now = datetime.now(timezone.utc).isoformat()
+ existing = _load_card(normalized["id"])
+ if existing is not None:
+ normalized["created_at"] = existing.get("created_at") or normalized.get("created_at") or now
+ else:
+ normalized["created_at"] = normalized.get("created_at") or now
+ normalized["updated_at"] = normalized.get("updated_at") or now
+
+ instance_dir = _card_instance_dir(normalized["id"])
+ meta_path = _card_meta_path(normalized["id"])
+ state_path = _card_state_path(normalized["id"])
+ if instance_dir is None or meta_path is None or state_path is None:
+ return None
+
+ instance_dir.mkdir(parents=True, exist_ok=True)
+
+ template_state = normalized.pop("template_state", {})
+ meta_path.write_text(
+ json.dumps(normalized, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ if normalized["kind"] == "text":
+ state_path.write_text(
+ json.dumps(
+ template_state if isinstance(template_state, dict) else {},
+ indent=2,
+ ensure_ascii=False,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ else:
+ state_path.unlink(missing_ok=True)
+
+ return _load_card(normalized["id"])
+
+
+def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return sorted(
+ cards,
+ key=lambda item: (
+ _CARD_LANE_ORDER.get(str(item.get("lane", "context")), 99),
+ _CARD_STATE_ORDER.get(str(item.get("state", "active")), 99),
+ -int(item.get("priority", 0) or 0),
+ str(item.get("updated_at", "")),
+ str(item.get("created_at", "")),
+ ),
+ reverse=False,
+ )
+
+
+def _load_cards(chat_id: str | None = None) -> list[dict[str, Any]]:
+ ensure_card_store_dirs()
+ cards: list[dict[str, Any]] = []
+ now = datetime.now(timezone.utc)
+ for instance_dir in CARD_INSTANCES_DIR.iterdir():
+ if not instance_dir.is_dir():
+ continue
+ card = _load_card(instance_dir.name)
+ if card is None:
+ continue
+ if chat_id is not None and str(card.get("chat_id", "web") or "web") != chat_id:
+ continue
+ if card.get("state") == "archived":
+ continue
+ snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or ""))
+ if snooze_until is not None and snooze_until > now:
+ continue
+ cards.append(card)
+ return _sort_cards(cards)
+
+
+def _find_card_by_slot(slot: str, *, chat_id: str) -> dict[str, Any] | None:
+ target_slot = slot.strip()
+ if not target_slot:
+ return None
+ for card in _load_cards():
+ if str(card.get("chat_id", "")).strip() != chat_id:
+ continue
+ if str(card.get("slot", "")).strip() == target_slot:
+ return card
+ return None
+
+
+def _persist_card(card: dict[str, Any]) -> dict[str, Any] | None:
+ normalized = _coerce_card_record(card)
+ if normalized is None:
+ return None
+
+ existing_same_slot = None
+ slot = str(normalized.get("slot", "")).strip()
+ chat_id = str(normalized.get("chat_id", "web") or "web")
+ if slot:
+ existing_same_slot = _find_card_by_slot(slot, chat_id=chat_id)
+
+ if existing_same_slot is not None and existing_same_slot["id"] != normalized["id"]:
+ superseded = dict(existing_same_slot)
+ superseded["state"] = "superseded"
+ superseded["updated_at"] = datetime.now(timezone.utc).isoformat()
+ _write_card(superseded)
+
+ return _write_card(normalized)
+
+
+def _delete_card(card_id: str) -> bool:
+ instance_dir = _card_instance_dir(card_id)
+ if instance_dir is None or not instance_dir.exists():
+ return False
+ shutil.rmtree(instance_dir, ignore_errors=True)
+ return True
+
+
+def _delete_cards_for_chat(chat_id: str) -> int:
+ ensure_card_store_dirs()
+ removed = 0
+ for instance_dir in CARD_INSTANCES_DIR.iterdir():
+ if not instance_dir.is_dir():
+ continue
+ card = _load_card(instance_dir.name)
+ if card is None:
+ continue
+ if str(card.get("chat_id", "web") or "web") != chat_id:
+ continue
+ if _delete_card(instance_dir.name):
+ removed += 1
+ return removed
+
+
+def _list_templates(limit: int | None = None) -> list[dict[str, Any]]:
+ ensure_card_store_dirs()
+ templates: list[dict[str, Any]] = []
+
+ for template_dir in CARD_TEMPLATES_DIR.iterdir():
+ if not template_dir.is_dir():
+ continue
+ key = _normalize_template_key(template_dir.name)
+ if not key:
+ continue
+
+ html_path = _template_html_path(key)
+ if not html_path.exists():
+ continue
+ try:
+ content = html_path.read_text(encoding="utf-8")
+ except Exception:
+ continue
+
+ stat = html_path.stat()
+ meta = _read_template_meta(key)
+ if bool(meta.get("deprecated")):
+ continue
+ created_at = str(
+ meta.get("created_at")
+ or datetime.fromtimestamp(stat.st_ctime, timezone.utc).isoformat()
+ )
+ updated_at = str(
+ meta.get("updated_at")
+ or datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()
+ )
+ templates.append(
+ {
+ "id": key,
+ "key": key,
+ "title": str(meta.get("title", "")),
+ "content": content,
+ "notes": str(meta.get("notes", "")),
+ "example_state": meta.get("example_state", {}),
+ "created_at": created_at,
+ "updated_at": updated_at,
+ "file_url": f"/card-templates/{key}/template.html",
+ }
+ )
+
+ templates.sort(key=lambda item: item["updated_at"], reverse=True)
+ if limit is not None:
+ return templates[: max(0, limit)]
+ return templates
+
+
+def _render_templates_markdown(rows: list[dict[str, Any]]) -> str:
+ lines = [
+ "# Card Templates",
+ "",
+ "These are user-approved template layouts for `mcp_display_render_card` cards.",
+ "Each card instance should provide a `template_key` and a `template_state` JSON object.",
+ "Use a matching template when the request intent fits.",
+ "Do not rewrite the HTML layout when an existing template already fits; fill the template_state instead.",
+ "",
+ ]
+ for row in rows:
+ key = str(row.get("key", "")).strip() or "unnamed"
+ title = str(row.get("title", "")).strip() or "(untitled)"
+ notes = str(row.get("notes", "")).strip() or "(no usage notes)"
+ content = str(row.get("content", "")).strip()
+ example_state = row.get("example_state", {})
+ if len(content) > MAX_TEMPLATE_HTML_CHARS:
+ content = content[:MAX_TEMPLATE_HTML_CHARS] + "\n"
+ html_lines = [f" {line}" for line in content.splitlines()] if content else [" "]
+ state_text = (
+ json.dumps(example_state, indent=2, ensure_ascii=False)
+ if isinstance(example_state, dict)
+ else "{}"
+ )
+ state_lines = [f" {line}" for line in state_text.splitlines()]
+ lines.extend(
+ [
+ f"## {key}",
+ f"- Title: {title}",
+ f"- Usage: {notes}",
+ "- Example State:",
+ *state_lines,
+ "- HTML:",
+ *html_lines,
+ "",
+ ]
+ )
+ return "\n".join(lines).rstrip() + "\n"
+
+
+def _sync_templates_context_file() -> None:
+ try:
+ ensure_card_store_dirs()
+ rows = _list_templates(limit=MAX_TEMPLATES_IN_PROMPT)
+ if not rows:
+ TEMPLATES_CONTEXT_PATH.unlink(missing_ok=True)
+ return
+ TEMPLATES_CONTEXT_PATH.parent.mkdir(parents=True, exist_ok=True)
+ TEMPLATES_CONTEXT_PATH.write_text(_render_templates_markdown(rows), encoding="utf-8")
+ except Exception:
+ return
+
+
+normalize_template_key = _normalize_template_key
+normalize_card_id = _normalize_card_id
+parse_iso_datetime = _parse_iso_datetime
+coerce_card_record = _coerce_card_record
+template_dir = _template_dir
+template_html_path = _template_html_path
+template_meta_path = _template_meta_path
+read_template_meta = _read_template_meta
+load_card = _load_card
+load_cards = _load_cards
+write_card = _write_card
+persist_card = _persist_card
+delete_card = _delete_card
+delete_cards_for_chat = _delete_cards_for_chat
+list_templates = _list_templates
+sync_templates_context_file = _sync_templates_context_file
+json_script_text = _json_script_text
+
+__all__ = [
+ "CARD_INSTANCES_DIR",
+ "CARD_TEMPLATES_DIR",
+ "NANOBOT_WORKSPACE",
+ "TEMPLATES_CONTEXT_PATH",
+ "coerce_card_record",
+ "delete_card",
+ "delete_cards_for_chat",
+ "ensure_card_store_dirs",
+ "json_script_text",
+ "list_templates",
+ "load_card",
+ "load_cards",
+ "normalize_card_id",
+ "normalize_template_key",
+ "parse_iso_datetime",
+ "persist_card",
+ "read_template_meta",
+ "sync_templates_context_file",
+ "template_dir",
+ "template_html_path",
+ "template_meta_path",
+ "write_card",
+]
diff --git a/examples/cards/instances/live-calendar-timeline-weather/card.json b/examples/cards/instances/live-calendar-timeline-weather/card.json
new file mode 100644
index 0000000..e4ab920
--- /dev/null
+++ b/examples/cards/instances/live-calendar-timeline-weather/card.json
@@ -0,0 +1,19 @@
+{
+ "id": "live-calendar-timeline-weather",
+ "kind": "text",
+ "title": "Today Calendar Weather",
+ "content": "",
+ "question": "",
+ "choices": [],
+ "response_value": "",
+ "slot": "live-calendar-timeline-weather",
+ "lane": "context",
+ "priority": 89,
+ "state": "active",
+ "template_key": "calendar-timeline-weather-live",
+ "context_summary": "",
+ "chat_id": "web",
+ "snooze_until": "",
+ "created_at": "2026-04-02T00:00:00+00:00",
+ "updated_at": "2026-04-02T00:00:00+00:00"
+}
diff --git a/examples/cards/instances/live-calendar-timeline-weather/state.json b/examples/cards/instances/live-calendar-timeline-weather/state.json
new file mode 100644
index 0000000..ebe10f2
--- /dev/null
+++ b/examples/cards/instances/live-calendar-timeline-weather/state.json
@@ -0,0 +1,16 @@
+{
+ "title": "Today Calendar Weather",
+ "subtitle": "Family Calendar",
+ "tool_name": "mcp_home_assistant_calendar_get_events",
+ "calendar_names": [
+ "Family Calendar"
+ ],
+ "refresh_ms": 900000,
+ "min_start_hour": 6,
+ "max_end_hour": 22,
+ "min_window_hours": 6,
+ "slot_height": 24,
+ "empty_text": "No events for today.",
+ "weather_tool_name": "exec",
+ "weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48"
+}
diff --git a/examples/cards/instances/live-outdoor-aqi/card.json b/examples/cards/instances/live-outdoor-aqi/card.json
new file mode 100644
index 0000000..a7c8a7f
--- /dev/null
+++ b/examples/cards/instances/live-outdoor-aqi/card.json
@@ -0,0 +1,17 @@
+{
+ "id": "live-outdoor-aqi",
+ "kind": "text",
+ "title": "Outdoor AQI",
+ "question": "",
+ "choices": [],
+ "response_value": "",
+ "slot": "live-outdoor-aqi",
+ "lane": "context",
+ "priority": 24,
+ "state": "active",
+ "template_key": "sensor-live",
+ "context_summary": "",
+ "chat_id": "web",
+ "created_at": "2026-04-05T20:40:00-04:00",
+ "updated_at": "2026-04-05T20:40:00-04:00"
+}
diff --git a/examples/cards/instances/live-outdoor-aqi/state.json b/examples/cards/instances/live-outdoor-aqi/state.json
new file mode 100644
index 0000000..a9df4b5
--- /dev/null
+++ b/examples/cards/instances/live-outdoor-aqi/state.json
@@ -0,0 +1,17 @@
+{
+ "title": "Outdoor AQI",
+ "subtitle": "Outdoor air quality",
+ "tool_name": "mcp_home_assistant_GetLiveContext",
+ "match_name": "Worcester Summer St Air quality index",
+ "device_class": "aqi",
+ "unit": "AQI",
+ "refresh_ms": 300000,
+ "value_decimals": 0,
+ "alert_only": true,
+ "alert_score_elevated": 90,
+ "alert_score_high": 99,
+ "thresholds": {
+ "good_max": 100,
+ "elevated_max": 150
+ }
+}
diff --git a/examples/cards/instances/live-weather-01545/state.json b/examples/cards/instances/live-weather-01545/state.json
index 27db94e..0c81eae 100644
--- a/examples/cards/instances/live-weather-01545/state.json
+++ b/examples/cards/instances/live-weather-01545/state.json
@@ -4,9 +4,14 @@
"tool_name": "mcp_home_assistant_GetLiveContext",
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4",
- "provider_prefix": "OpenWeatherMap",
- "temperature_name": "OpenWeatherMap Temperature",
- "humidity_name": "OpenWeatherMap Humidity",
+ "provider_prefix": "Worcester Summer St",
+ "temperature_name": "Worcester Summer St Temperature",
+ "humidity_name": "Worcester Summer St Humidity",
+ "uv_name": "OpenWeatherMap UV index",
"condition_label": "Weather",
- "refresh_ms": 86400000
+ "morning_start_hour": 6,
+ "morning_end_hour": 11,
+ "morning_score": 84,
+ "default_score": 38,
+ "refresh_ms": 300000
}
diff --git a/examples/cards/templates/calendar-agenda-live/card.js b/examples/cards/templates/calendar-agenda-live/card.js
new file mode 100644
index 0000000..fd875c7
--- /dev/null
+++ b/examples/cards/templates/calendar-agenda-live/card.js
@@ -0,0 +1,263 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const subtitleEl = root.querySelector('[data-calendar-subtitle]');
+ const statusEl = root.querySelector('[data-calendar-status]');
+ const rangeEl = root.querySelector('[data-calendar-range]');
+ const emptyEl = root.querySelector('[data-calendar-empty]');
+ const listEl = root.querySelector('[data-calendar-list]');
+ const updatedEl = root.querySelector('[data-calendar-updated]');
+ if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
+
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const calendarNames = Array.isArray(state.calendar_names)
+ ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const rangeDaysRaw = Number(state.range_days);
+ const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
+ const maxEventsRaw = Number(state.max_events);
+ const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+ const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
+
+ subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
+ emptyEl.textContent = emptyText;
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const setStatus = (label, color) => {
+ if (!(statusEl instanceof HTMLElement)) return;
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const normalizeDateValue = (value) => {
+ if (typeof value === 'string') return value;
+ if (value && typeof value === 'object') {
+ if (typeof value.dateTime === 'string') return value.dateTime;
+ if (typeof value.date === 'string') return value.date;
+ }
+ return '';
+ };
+
+ const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
+ const eventSortKey = (event) => {
+ const raw = normalizeDateValue(event && event.start);
+ const time = new Date(raw).getTime();
+ return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
+ };
+ const formatTime = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return '--:--';
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
+ const date = new Date(raw);
+ if (Number.isNaN(date.getTime())) return '--:--';
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ };
+ const formatDay = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return '--';
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
+ if (Number.isNaN(date.getTime())) return '--';
+ return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
+ };
+ const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
+
+ const extractEvents = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (Array.isArray(parsed.result)) return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
+ return parsed.result;
+ }
+ } catch {
+ return [];
+ }
+ }
+ return [];
+ };
+
+ const eventTime = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return Number.MAX_SAFE_INTEGER;
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
+ const time = new Date(normalized).getTime();
+ return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
+ };
+
+ const resolveToolConfig = async () => {
+ const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
+ if (!host.listTools) {
+ return { name: fallbackName, availableCalendars: calendarNames };
+ }
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
+ : null;
+ const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
+ ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ return {
+ name: tool?.name || fallbackName,
+ availableCalendars: enumValues,
+ };
+ } catch {
+ return { name: fallbackName, availableCalendars: calendarNames };
+ }
+ };
+
+ const renderEvents = (events) => {
+ listEl.innerHTML = '';
+ if (!Array.isArray(events) || events.length === 0) {
+ emptyEl.style.display = 'block';
+ return;
+ }
+ emptyEl.style.display = 'none';
+
+ for (const [index, event] of events.slice(0, maxEvents).entries()) {
+ const item = document.createElement('li');
+ item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
+ item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
+
+ const summary = document.createElement('div');
+ summary.style.fontSize = '0.98rem';
+ summary.style.lineHeight = '1.3';
+ summary.style.fontWeight = '700';
+ summary.style.color = 'var(--theme-card-neutral-text)';
+ summary.textContent = String(event.summary || '(No title)');
+ item.appendChild(summary);
+
+ const timing = document.createElement('div');
+ timing.style.marginTop = '4px';
+ timing.style.fontSize = '0.9rem';
+ timing.style.lineHeight = '1.35';
+ timing.style.color = 'var(--theme-card-neutral-subtle)';
+ const dayLabel = formatDay(event.start);
+ const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
+ timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
+ item.appendChild(timing);
+
+ listEl.appendChild(item);
+ }
+ };
+
+ const refresh = async () => {
+ setStatus('Refreshing', 'var(--theme-status-muted)');
+ const start = new Date();
+ start.setHours(0, 0, 0, 0);
+ const end = new Date(start);
+ end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
+ end.setHours(23, 59, 59, 999);
+ rangeEl.textContent = formatRange(start, end);
+
+ try {
+ const toolConfig = await resolveToolConfig();
+ const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
+ if (!toolConfig.name) throw new Error('Calendar tool unavailable');
+ if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
+ throw new Error('No calendars configured');
+ }
+
+ const resolvedSubtitle = subtitle || selectedCalendars.join(', ');
+ subtitleEl.textContent = resolvedSubtitle;
+
+ const allEvents = [];
+ const rangeMode = rangeDays > 1 ? 'week' : 'today';
+ const endExclusiveTime = end.getTime() + 1;
+ for (const calendarName of selectedCalendars) {
+ const toolResult = await host.callTool(toolConfig.name, {
+ calendar: calendarName,
+ range: rangeMode,
+ });
+ const events = extractEvents(toolResult);
+ for (const event of events) {
+ const startTime = eventTime(event?.start);
+ if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
+ allEvents.push({ ...event, _calendarName: calendarName });
+ }
+ }
+
+ allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
+ renderEvents(allEvents);
+ const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ updatedEl.textContent = updatedText;
+ const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
+ setStatus(statusLabel, 'var(--theme-status-live)');
+ const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
+ const dayLabel = formatDay(event.start);
+ const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
+ return {
+ summary: String(event.summary || '(No title)'),
+ start: normalizeDateValue(event.start) || null,
+ end: normalizeDateValue(event.end) || null,
+ day_label: dayLabel === '--' ? null : dayLabel,
+ time_label: timeLabel,
+ all_day: isAllDay(event.start, event.end),
+ };
+ });
+ updateLiveContent({
+ kind: 'calendar_agenda',
+ subtitle: resolvedSubtitle || null,
+ tool_name: toolConfig.name,
+ calendar_names: selectedCalendars,
+ range_label: rangeEl.textContent || null,
+ status: statusLabel,
+ updated_at: updatedText,
+ event_count: snapshotEvents.length,
+ events: snapshotEvents,
+ });
+ } catch (error) {
+ const errorText = String(error);
+ renderEvents([]);
+ updatedEl.textContent = errorText;
+ setStatus('Unavailable', 'var(--theme-status-danger)');
+ updateLiveContent({
+ kind: 'calendar_agenda',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
+ calendar_names: calendarNames,
+ range_label: rangeEl.textContent || null,
+ status: 'Unavailable',
+ updated_at: errorText,
+ event_count: 0,
+ events: [],
+ error: errorText,
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => { void refresh(); }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/calendar-agenda-live/template.html b/examples/cards/templates/calendar-agenda-live/template.html
index 2a0a86d..1aad4e5 100644
--- a/examples/cards/templates/calendar-agenda-live/template.html
+++ b/examples/cards/templates/calendar-agenda-live/template.html
@@ -1,257 +1,10 @@
-
+
-
--
-
No events scheduled.
+
--
+
No events scheduled.
-
Updated --
+
Updated --
-
diff --git a/examples/cards/templates/calendar-timeline-live/card.js b/examples/cards/templates/calendar-timeline-live/card.js
new file mode 100644
index 0000000..c0080c3
--- /dev/null
+++ b/examples/cards/templates/calendar-timeline-live/card.js
@@ -0,0 +1,584 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const headlineEl = root.querySelector('[data-calendar-headline]');
+ const detailEl = root.querySelector('[data-calendar-detail]');
+ const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]');
+ const allDayEl = root.querySelector('[data-calendar-all-day]');
+ const emptyEl = root.querySelector('[data-calendar-empty]');
+ const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]');
+ const timelineEl = root.querySelector('[data-calendar-timeline]');
+
+ if (!(headlineEl instanceof HTMLElement) ||
+ !(detailEl instanceof HTMLElement) ||
+ !(allDayWrapEl instanceof HTMLElement) ||
+ !(allDayEl instanceof HTMLElement) ||
+ !(emptyEl instanceof HTMLElement) ||
+ !(timelineShellEl instanceof HTMLElement) ||
+ !(timelineEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const calendarNames = Array.isArray(state.calendar_names)
+ ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+ const minStartHourRaw = Number(state.min_start_hour);
+ const maxEndHourRaw = Number(state.max_end_hour);
+ const minWindowHoursRaw = Number(state.min_window_hours);
+ const slotHeightRaw = Number(state.slot_height);
+ const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6;
+ const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22;
+ const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60;
+ const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18;
+ const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
+ ? state.empty_text.trim()
+ : 'No events for today.';
+
+ const LABEL_WIDTH = 42;
+ const TRACK_TOP_PAD = 6;
+ const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events';
+
+ let latestEvents = [];
+ let latestSelectedCalendars = [];
+ let latestUpdatedAt = '';
+ let clockIntervalId = null;
+
+ emptyEl.textContent = emptyText;
+
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const normalizeDateValue = (value) => {
+ if (typeof value === 'string') return value;
+ if (value && typeof value === 'object') {
+ if (typeof value.dateTime === 'string') return value.dateTime;
+ if (typeof value.date === 'string') return value.date;
+ }
+ return '';
+ };
+
+ const isAllDay = (start, end) =>
+ /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) ||
+ /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
+
+ const parseDate = (value, allDay, endOfDay = false) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return null;
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
+ ? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}`
+ : raw;
+ const date = new Date(normalized);
+ return Number.isNaN(date.getTime()) ? null : date;
+ };
+
+ const eventBounds = (event) => {
+ const allDay = isAllDay(event?.start, event?.end);
+ const startDate = parseDate(event?.start, allDay, false);
+ const endDate = parseDate(event?.end, allDay, allDay);
+ if (!startDate) return null;
+ const start = startDate.getTime();
+ let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000;
+ if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000;
+ return { start, end, allDay };
+ };
+
+ const formatClock = (date) => {
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${hours}:${minutes}`;
+ };
+ const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
+
+ const formatDistance = (ms) => {
+ const totalMinutes = Math.max(0, Math.round(ms / 60000));
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ if (hours <= 0) return `${minutes}m`;
+ if (minutes === 0) return `${hours}h`;
+ return `${hours}h ${minutes}m`;
+ };
+
+ const formatTimeRange = (event) => {
+ const bounds = eventBounds(event);
+ if (!bounds) return '--';
+ if (bounds.allDay) return 'All day';
+ return `${formatClock(new Date(bounds.start))}–${formatClock(new Date(bounds.end))}`;
+ };
+
+ const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0');
+
+ const minutesIntoDay = (time) => {
+ const date = new Date(time);
+ return date.getHours() * 60 + date.getMinutes();
+ };
+
+ const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30;
+ const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30;
+
+ const computeVisibleWindow = (events) => {
+ const now = new Date();
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
+ const timedEvents = events.filter((event) => !event._allDay);
+
+ if (timedEvents.length === 0) {
+ let start = roundDownToHalfHour(nowMinutes - 120);
+ let end = start + minWindowMinutes;
+ const minBound = minStartHour * 60;
+ const maxBound = maxEndHour * 60;
+ if (start < minBound) {
+ start = minBound;
+ end = start + minWindowMinutes;
+ }
+ if (end > maxBound) {
+ end = maxBound;
+ start = Math.max(minBound, end - minWindowMinutes);
+ }
+ return { start, end };
+ }
+
+ const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start)));
+ const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end)));
+
+ let start = roundDownToHalfHour(earliest - 60);
+ let end = roundUpToHalfHour(latest + 90);
+ if (end - start < minWindowMinutes) {
+ const center = roundDownToHalfHour((earliest + latest) / 2);
+ start = center - Math.floor(minWindowMinutes / 2);
+ end = start + minWindowMinutes;
+ }
+
+ const minBound = minStartHour * 60;
+ const maxBound = maxEndHour * 60;
+
+ if (start < minBound) {
+ const shift = minBound - start;
+ start += shift;
+ end += shift;
+ }
+ if (end > maxBound) {
+ const shift = end - maxBound;
+ start -= shift;
+ end -= shift;
+ }
+ start = Math.max(minBound, start);
+ end = Math.min(maxBound, end);
+ if (end - start < 120) {
+ end = Math.min(maxBound, start + 120);
+ }
+ return { start, end };
+ };
+
+ const assignColumns = (events) => {
+ const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start);
+ let active = [];
+ let cluster = [];
+ let clusterEnd = -Infinity;
+ let clusterMax = 1;
+
+ const finalizeCluster = () => {
+ for (const item of cluster) item._columns = clusterMax;
+ };
+
+ for (const event of timed) {
+ if (cluster.length > 0 && event._start >= clusterEnd) {
+ finalizeCluster();
+ active = [];
+ cluster = [];
+ clusterEnd = -Infinity;
+ clusterMax = 1;
+ }
+ active = active.filter((item) => item.end > event._start);
+ const used = new Set(active.map((item) => item.column));
+ let column = 0;
+ while (used.has(column)) column += 1;
+ event._column = column;
+ active.push({ end: event._end, column });
+ cluster.push(event);
+ clusterEnd = Math.max(clusterEnd, event._end);
+ clusterMax = Math.max(clusterMax, active.length, column + 1);
+ }
+
+ if (cluster.length > 0) finalizeCluster();
+ return timed;
+ };
+
+ const extractEvents = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) {
+ return toolResult.parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result;
+ } catch {}
+ }
+ return [];
+ };
+
+ const resolveToolConfig = async () => {
+ if (!host.listTools) {
+ return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
+ }
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
+ : null;
+ const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
+ ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ return {
+ name: tool?.name || TOOL_FALLBACK,
+ availableCalendars: enumValues,
+ };
+ } catch {
+ return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
+ }
+ };
+
+ const computeScore = (events, nowTime) => {
+ const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime);
+ if (current) return 99;
+ const next = events.find((event) => !event._allDay && event._start > nowTime);
+ if (!next) {
+ if (events.some((event) => event._allDay)) return 74;
+ return 18;
+ }
+ const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000));
+ let score = 70;
+ if (minutesAway <= 15) score = 98;
+ else if (minutesAway <= 30) score = 95;
+ else if (minutesAway <= 60) score = 92;
+ else if (minutesAway <= 180) score = 88;
+ else if (minutesAway <= 360) score = 82;
+ else score = 76;
+ score += Math.min(events.length, 3);
+ return Math.min(100, score);
+ };
+
+ const createAllDayChip = (event) => {
+ const chip = document.createElement('div');
+ chip.style.padding = '3px 6px';
+ chip.style.border = '1px solid rgba(161, 118, 84, 0.28)';
+ chip.style.background = 'rgba(255, 249, 241, 0.94)';
+ chip.style.color = '#5e412d';
+ chip.style.fontSize = '0.62rem';
+ chip.style.lineHeight = '1.2';
+ chip.style.fontWeight = '700';
+ chip.style.minWidth = '0';
+ chip.style.maxWidth = '100%';
+ chip.style.overflow = 'hidden';
+ chip.style.textOverflow = 'ellipsis';
+ chip.style.whiteSpace = 'nowrap';
+ chip.textContent = String(event.summary || '(No title)');
+ return chip;
+ };
+
+ const renderState = () => {
+ const now = new Date();
+ const nowTime = now.getTime();
+ const todayLabel = formatShortDate(now);
+ if (!Array.isArray(latestEvents) || latestEvents.length === 0) {
+ headlineEl.textContent = todayLabel;
+ detailEl.textContent = emptyText;
+ allDayWrapEl.style.display = 'none';
+ timelineShellEl.style.display = 'none';
+ emptyEl.style.display = 'block';
+ updateLiveContent({
+ kind: 'calendar_timeline',
+ subtitle: subtitle || null,
+ tool_name: TOOL_FALLBACK,
+ calendar_names: latestSelectedCalendars,
+ updated_at: latestUpdatedAt || null,
+ now_label: formatClock(now),
+ headline: headlineEl.textContent || null,
+ detail: detailEl.textContent || null,
+ event_count: 0,
+ all_day_count: 0,
+ score: 18,
+ events: [],
+ });
+ return;
+ }
+
+ const allDayEvents = latestEvents.filter((event) => event._allDay);
+ const timedEvents = latestEvents.filter((event) => !event._allDay);
+ const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null;
+ const nextEvent = timedEvents.find((event) => event._start > nowTime) || null;
+
+ headlineEl.textContent = todayLabel;
+
+ if (currentEvent) {
+ detailEl.textContent = '';
+ } else if (nextEvent) {
+ detailEl.textContent = '';
+ } else if (allDayEvents.length > 0) {
+ detailEl.textContent = 'All-day events on your calendar.';
+ } else {
+ detailEl.textContent = 'Your calendar is clear for the rest of the day.';
+ }
+
+ const windowRange = computeVisibleWindow(latestEvents);
+ allDayEl.innerHTML = '';
+ allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none';
+ for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event));
+
+ emptyEl.style.display = 'none';
+ timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none';
+ timelineEl.innerHTML = '';
+
+ if (timedEvents.length > 0) {
+ const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30));
+ const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight;
+ timelineEl.style.height = `${timelineHeight}px`;
+
+ const gridLayer = document.createElement('div');
+ gridLayer.style.position = 'absolute';
+ gridLayer.style.inset = '0';
+ timelineEl.appendChild(gridLayer);
+
+ for (let index = 0; index <= slotCount; index += 1) {
+ const minutes = windowRange.start + index * 30;
+ const top = TRACK_TOP_PAD + index * slotHeight;
+
+ const line = document.createElement('div');
+ line.style.position = 'absolute';
+ line.style.left = `${LABEL_WIDTH}px`;
+ line.style.right = '0';
+ line.style.top = `${top}px`;
+ line.style.borderTop = minutes % 60 === 0
+ ? '1px solid rgba(143, 101, 69, 0.24)'
+ : '1px dashed rgba(181, 145, 116, 0.18)';
+ gridLayer.appendChild(line);
+
+ if (minutes % 60 === 0 && minutes < windowRange.end) {
+ const label = document.createElement('div');
+ label.style.position = 'absolute';
+ label.style.left = '0';
+ label.style.top = `${Math.max(0, top - 7)}px`;
+ label.style.width = `${LABEL_WIDTH - 8}px`;
+ label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace";
+ label.style.fontSize = '0.54rem';
+ label.style.lineHeight = '1';
+ label.style.color = '#8a6248';
+ label.style.textAlign = 'right';
+ label.style.textTransform = 'uppercase';
+ label.style.letterSpacing = '0.05em';
+ label.textContent = hourLabel(minutes);
+ gridLayer.appendChild(label);
+ }
+ }
+
+ const eventsLayer = document.createElement('div');
+ eventsLayer.style.position = 'absolute';
+ eventsLayer.style.left = `${LABEL_WIDTH + 6}px`;
+ eventsLayer.style.right = '0';
+ eventsLayer.style.top = `${TRACK_TOP_PAD}px`;
+ eventsLayer.style.bottom = '0';
+ timelineEl.appendChild(eventsLayer);
+
+ const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event })));
+ for (const event of layoutEvents) {
+ const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start;
+ const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start;
+ const top = Math.max(0, (offsetStart / 30) * slotHeight);
+ const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3);
+
+ const block = document.createElement('div');
+ block.style.position = 'absolute';
+ block.style.top = `${top}px`;
+ block.style.height = `${height}px`;
+ block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`;
+ block.style.width = `calc(${100 / event._columns}% - 4px)`;
+ block.style.padding = '5px 6px';
+ block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
+ ? '1px solid rgba(169, 39, 29, 0.38)'
+ : '1px solid rgba(162, 105, 62, 0.26)';
+ block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
+ ? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)'
+ : 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)';
+ block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)';
+ block.style.overflow = 'hidden';
+
+ const title = document.createElement('div');
+ title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif";
+ title.style.fontSize = '0.74rem';
+ title.style.lineHeight = '0.98';
+ title.style.letterSpacing = '-0.01em';
+ title.style.color = '#22140c';
+ title.style.fontWeight = '700';
+ title.style.whiteSpace = 'nowrap';
+ title.style.textOverflow = 'ellipsis';
+ title.style.overflow = 'hidden';
+ title.textContent = String(event.summary || '(No title)');
+ block.appendChild(title);
+
+ eventsLayer.appendChild(block);
+ }
+
+ if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() &&
+ nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) {
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
+ const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight;
+
+ const line = document.createElement('div');
+ line.style.position = 'absolute';
+ line.style.left = `${LABEL_WIDTH + 6}px`;
+ line.style.right = '0';
+ line.style.top = `${nowTop}px`;
+ line.style.borderTop = '1.5px solid #cf2f21';
+ line.style.transition = 'top 28s linear';
+ timelineEl.appendChild(line);
+
+ const dot = document.createElement('div');
+ dot.style.position = 'absolute';
+ dot.style.left = `${LABEL_WIDTH + 1}px`;
+ dot.style.top = `${nowTop - 4}px`;
+ dot.style.width = '8px';
+ dot.style.height = '8px';
+ dot.style.borderRadius = '999px';
+ dot.style.background = '#cf2f21';
+ dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)';
+ dot.style.transition = 'top 28s linear';
+ timelineEl.appendChild(dot);
+
+ }
+ }
+
+ updateLiveContent({
+ kind: 'calendar_timeline',
+ subtitle: subtitle || null,
+ tool_name: TOOL_FALLBACK,
+ calendar_names: latestSelectedCalendars,
+ updated_at: latestUpdatedAt || null,
+ now_label: formatClock(now),
+ headline: headlineEl.textContent || null,
+ detail: detailEl.textContent || null,
+ current_event: currentEvent ? {
+ summary: String(currentEvent.summary || '(No title)'),
+ start: normalizeDateValue(currentEvent.start) || null,
+ end: normalizeDateValue(currentEvent.end) || null,
+ } : null,
+ next_event: nextEvent ? {
+ summary: String(nextEvent.summary || '(No title)'),
+ start: normalizeDateValue(nextEvent.start) || null,
+ end: normalizeDateValue(nextEvent.end) || null,
+ starts_in: formatDistance(nextEvent._start - nowTime),
+ } : null,
+ event_count: latestEvents.length,
+ all_day_count: allDayEvents.length,
+ score: computeScore(latestEvents, nowTime),
+ events: latestEvents.map((event) => ({
+ summary: String(event.summary || '(No title)'),
+ start: normalizeDateValue(event.start) || null,
+ end: normalizeDateValue(event.end) || null,
+ all_day: Boolean(event._allDay),
+ })),
+ });
+ };
+
+ const refresh = async () => {
+ headlineEl.textContent = 'Loading today…';
+ detailEl.textContent = 'Checking your calendar.';
+
+ try {
+ const toolConfig = await resolveToolConfig();
+ const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
+ if (!toolConfig.name) throw new Error('Calendar tool unavailable');
+ if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
+ throw new Error('No calendars configured');
+ }
+
+ const allEvents = [];
+ for (const calendarName of selectedCalendars) {
+ const toolResult = await host.callTool(toolConfig.name, {
+ calendar: calendarName,
+ range: 'today',
+ });
+ const events = extractEvents(toolResult);
+ for (const event of events) {
+ const bounds = eventBounds(event);
+ if (!bounds) continue;
+ allEvents.push({
+ ...event,
+ _calendarName: calendarName,
+ _start: bounds.start,
+ _end: bounds.end,
+ _allDay: bounds.allDay,
+ });
+ }
+ }
+
+ allEvents.sort((left, right) => left._start - right._start);
+ latestEvents = allEvents;
+ latestSelectedCalendars = selectedCalendars;
+ latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ renderState();
+ } catch (error) {
+ const errorText = String(error);
+ latestEvents = [];
+ latestSelectedCalendars = calendarNames;
+ latestUpdatedAt = errorText;
+ headlineEl.textContent = formatShortDate(new Date());
+ detailEl.textContent = errorText;
+ allDayWrapEl.style.display = 'none';
+ timelineShellEl.style.display = 'none';
+ emptyEl.style.display = 'block';
+ updateLiveContent({
+ kind: 'calendar_timeline',
+ subtitle: subtitle || null,
+ tool_name: TOOL_FALLBACK,
+ calendar_names: latestSelectedCalendars,
+ updated_at: errorText,
+ now_label: formatClock(new Date()),
+ headline: headlineEl.textContent || null,
+ detail: detailEl.textContent || null,
+ event_count: 0,
+ all_day_count: 0,
+ score: 0,
+ events: [],
+ error: errorText,
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+
+ if (clockIntervalId) window.clearInterval(clockIntervalId);
+ clockIntervalId = __setInterval(() => {
+ renderState();
+ }, 30000);
+
+ void refresh();
+ __setInterval(() => {
+ void refresh();
+ }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/calendar-timeline-live/manifest.json b/examples/cards/templates/calendar-timeline-live/manifest.json
new file mode 100644
index 0000000..a28b227
--- /dev/null
+++ b/examples/cards/templates/calendar-timeline-live/manifest.json
@@ -0,0 +1,20 @@
+{
+ "key": "calendar-timeline-live",
+ "title": "Today Calendar Timeline",
+ "notes": "Today-only Home Assistant calendar timeline with half-hour grid, current time marker, and next-event distance. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, and empty_text.",
+ "example_state": {
+ "subtitle": "Family Calendar",
+ "tool_name": "mcp_home_assistant_calendar_get_events",
+ "calendar_names": [
+ "Family Calendar"
+ ],
+ "refresh_ms": 900000,
+ "min_start_hour": 6,
+ "max_end_hour": 22,
+ "min_window_hours": 6,
+ "slot_height": 24,
+ "empty_text": "No events for today."
+ },
+ "created_at": "2026-04-02T00:00:00+00:00",
+ "updated_at": "2026-04-02T00:00:00+00:00"
+}
diff --git a/examples/cards/templates/calendar-timeline-live/template.html b/examples/cards/templates/calendar-timeline-live/template.html
new file mode 100644
index 0000000..518ed95
--- /dev/null
+++ b/examples/cards/templates/calendar-timeline-live/template.html
@@ -0,0 +1,38 @@
+
+
+
Today Calendar
+
+
+
+
Loading today…
+
Checking your calendar.
+
+
+
+
+
+
No events for today.
+
+
+
diff --git a/examples/cards/templates/calendar-timeline-weather-live/card.js b/examples/cards/templates/calendar-timeline-weather-live/card.js
new file mode 100644
index 0000000..d604897
--- /dev/null
+++ b/examples/cards/templates/calendar-timeline-weather-live/card.js
@@ -0,0 +1,778 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const headlineEl = root.querySelector('[data-calendar-headline]');
+ const detailEl = root.querySelector('[data-calendar-detail]');
+ const allDayWrapEl = root.querySelector('[data-calendar-all-day-wrap]');
+ const allDayEl = root.querySelector('[data-calendar-all-day]');
+ const emptyEl = root.querySelector('[data-calendar-empty]');
+ const timelineShellEl = root.querySelector('[data-calendar-timeline-shell]');
+ const weatherScaleEl = root.querySelector('[data-calendar-weather-scale]');
+ const weatherLowEl = root.querySelector('[data-calendar-weather-low]');
+ const weatherHighEl = root.querySelector('[data-calendar-weather-high]');
+ const timelineEl = root.querySelector('[data-calendar-timeline]');
+
+ if (!(headlineEl instanceof HTMLElement) ||
+ !(detailEl instanceof HTMLElement) ||
+ !(allDayWrapEl instanceof HTMLElement) ||
+ !(allDayEl instanceof HTMLElement) ||
+ !(emptyEl instanceof HTMLElement) ||
+ !(timelineShellEl instanceof HTMLElement) ||
+ !(weatherScaleEl instanceof HTMLElement) ||
+ !(weatherLowEl instanceof HTMLElement) ||
+ !(weatherHighEl instanceof HTMLElement) ||
+ !(timelineEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const calendarNames = Array.isArray(state.calendar_names)
+ ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+ const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : 'exec';
+ const weatherCommand = typeof state.weather_command === 'string' ? state.weather_command.trim() : '';
+ const minStartHourRaw = Number(state.min_start_hour);
+ const maxEndHourRaw = Number(state.max_end_hour);
+ const minWindowHoursRaw = Number(state.min_window_hours);
+ const slotHeightRaw = Number(state.slot_height);
+ const minStartHour = Number.isFinite(minStartHourRaw) ? Math.max(0, Math.min(23, Math.round(minStartHourRaw))) : 6;
+ const maxEndHour = Number.isFinite(maxEndHourRaw) ? Math.max(minStartHour + 1, Math.min(24, Math.round(maxEndHourRaw))) : 22;
+ const minWindowMinutes = (Number.isFinite(minWindowHoursRaw) ? Math.max(3, Math.min(18, minWindowHoursRaw)) : 6) * 60;
+ const slotHeight = Number.isFinite(slotHeightRaw) ? Math.max(14, Math.min(24, Math.round(slotHeightRaw))) : 18;
+ const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
+ ? state.empty_text.trim()
+ : 'No events for today.';
+
+ const LABEL_WIDTH = 42;
+ const TRACK_TOP_PAD = 6;
+ const TOOL_FALLBACK = configuredToolName || 'mcp_home_assistant_calendar_get_events';
+
+ let latestEvents = [];
+ let latestSelectedCalendars = [];
+ let latestUpdatedAt = '';
+ let latestWeatherPoints = [];
+ let latestWeatherRange = null;
+ let clockIntervalId = null;
+
+ emptyEl.textContent = emptyText;
+
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const normalizeDateValue = (value) => {
+ if (typeof value === 'string') return value;
+ if (value && typeof value === 'object') {
+ if (typeof value.dateTime === 'string') return value.dateTime;
+ if (typeof value.date === 'string') return value.date;
+ }
+ return '';
+ };
+
+ const isAllDay = (start, end) =>
+ /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) ||
+ /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
+
+ const parseDate = (value, allDay, endOfDay = false) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return null;
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
+ ? `${raw}T${endOfDay ? '23:59:59' : '00:00:00'}`
+ : raw;
+ const date = new Date(normalized);
+ return Number.isNaN(date.getTime()) ? null : date;
+ };
+
+ const eventBounds = (event) => {
+ const allDay = isAllDay(event?.start, event?.end);
+ const startDate = parseDate(event?.start, allDay, false);
+ const endDate = parseDate(event?.end, allDay, allDay);
+ if (!startDate) return null;
+ const start = startDate.getTime();
+ let end = endDate ? endDate.getTime() : start + 30 * 60 * 1000;
+ if (!Number.isFinite(end) || end <= start) end = start + 30 * 60 * 1000;
+ return { start, end, allDay };
+ };
+
+ const formatClock = (date) => {
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${hours}:${minutes}`;
+ };
+ const formatShortDate = (date) => date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
+
+ const formatDistance = (ms) => {
+ const totalMinutes = Math.max(0, Math.round(ms / 60000));
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ if (hours <= 0) return `${minutes}m`;
+ if (minutes === 0) return `${hours}h`;
+ return `${hours}h ${minutes}m`;
+ };
+
+ const formatTimeRange = (event) => {
+ const bounds = eventBounds(event);
+ if (!bounds) return '--';
+ if (bounds.allDay) return 'All day';
+ return `${formatClock(new Date(bounds.start))}–${formatClock(new Date(bounds.end))}`;
+ };
+
+ const hourLabel = (minutes) => String(Math.floor(minutes / 60)).padStart(2, '0');
+
+ const minutesIntoDay = (time) => {
+ const date = new Date(time);
+ return date.getHours() * 60 + date.getMinutes();
+ };
+
+ const roundDownToHalfHour = (minutes) => Math.floor(minutes / 30) * 30;
+ const roundUpToHalfHour = (minutes) => Math.ceil(minutes / 30) * 30;
+
+ const computeWeatherWindow = () => {
+ if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length < 2) return null;
+ const sorted = latestWeatherPoints
+ .map((point) => minutesIntoDay(point.time))
+ .filter((minutes) => Number.isFinite(minutes))
+ .sort((left, right) => left - right);
+ if (sorted.length < 2) return null;
+ const start = roundDownToHalfHour(sorted[0]);
+ const end = roundUpToHalfHour(sorted[sorted.length - 1]);
+ if (end <= start) return null;
+ return { start, end };
+ };
+
+ const computeVisibleWindow = (events) => {
+ const now = new Date();
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
+ const timedEvents = events.filter((event) => !event._allDay);
+ const weatherWindow = computeWeatherWindow();
+
+ if (timedEvents.length === 0) {
+ let start = roundDownToHalfHour(nowMinutes - 120);
+ let end = start + minWindowMinutes;
+ const minBound = minStartHour * 60;
+ const maxBound = maxEndHour * 60;
+ if (start < minBound) {
+ start = minBound;
+ end = start + minWindowMinutes;
+ }
+ if (end > maxBound) {
+ end = maxBound;
+ start = Math.max(minBound, end - minWindowMinutes);
+ }
+ if (weatherWindow) {
+ start = Math.max(start, weatherWindow.start);
+ end = Math.min(end, weatherWindow.end);
+ if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end };
+ }
+ return { start, end };
+ }
+
+ const earliest = Math.min(nowMinutes, ...timedEvents.map((event) => minutesIntoDay(event._start)));
+ const latest = Math.max(nowMinutes + 30, ...timedEvents.map((event) => minutesIntoDay(event._end)));
+
+ let start = roundDownToHalfHour(earliest - 60);
+ let end = roundUpToHalfHour(latest + 90);
+ if (end - start < minWindowMinutes) {
+ const center = roundDownToHalfHour((earliest + latest) / 2);
+ start = center - Math.floor(minWindowMinutes / 2);
+ end = start + minWindowMinutes;
+ }
+
+ const minBound = minStartHour * 60;
+ const maxBound = maxEndHour * 60;
+
+ if (start < minBound) {
+ const shift = minBound - start;
+ start += shift;
+ end += shift;
+ }
+ if (end > maxBound) {
+ const shift = end - maxBound;
+ start -= shift;
+ end -= shift;
+ }
+ start = Math.max(minBound, start);
+ end = Math.min(maxBound, end);
+ if (end - start < 120) {
+ end = Math.min(maxBound, start + 120);
+ }
+ if (weatherWindow) {
+ start = Math.max(start, weatherWindow.start);
+ end = Math.min(end, weatherWindow.end);
+ if (end <= start) return { start: weatherWindow.start, end: weatherWindow.end };
+ }
+ return { start, end };
+ };
+
+ const assignColumns = (events) => {
+ const timed = events.filter((event) => !event._allDay).sort((left, right) => left._start - right._start);
+ let active = [];
+ let cluster = [];
+ let clusterEnd = -Infinity;
+ let clusterMax = 1;
+
+ const finalizeCluster = () => {
+ for (const item of cluster) item._columns = clusterMax;
+ };
+
+ for (const event of timed) {
+ if (cluster.length > 0 && event._start >= clusterEnd) {
+ finalizeCluster();
+ active = [];
+ cluster = [];
+ clusterEnd = -Infinity;
+ clusterMax = 1;
+ }
+ active = active.filter((item) => item.end > event._start);
+ const used = new Set(active.map((item) => item.column));
+ let column = 0;
+ while (used.has(column)) column += 1;
+ event._column = column;
+ active.push({ end: event._end, column });
+ cluster.push(event);
+ clusterEnd = Math.max(clusterEnd, event._end);
+ clusterMax = Math.max(clusterMax, active.length, column + 1);
+ }
+
+ if (cluster.length > 0) finalizeCluster();
+ return timed;
+ };
+
+ const extractEvents = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && Array.isArray(toolResult.parsed.result)) {
+ return toolResult.parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) return parsed.result;
+ } catch {}
+ }
+ return [];
+ };
+
+ const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
+
+ const extractExecJson = (toolResult) => {
+ const text = stripExecFooter(toolResult?.content);
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+ };
+
+ const forecastTime = (entry) => {
+ const time = new Date(String(entry?.datetime || '')).getTime();
+ return Number.isFinite(time) ? time : Number.NaN;
+ };
+
+ const extractWeatherPoints = (payload) => {
+ const rows = Array.isArray(payload?.nws?.forecast) ? payload.nws.forecast : [];
+ const today = new Date();
+ const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0).getTime();
+ const dayEnd = dayStart + 24 * 60 * 60 * 1000;
+ return rows
+ .map((entry) => {
+ const time = forecastTime(entry);
+ const temp = Number(entry?.temperature);
+ if (!Number.isFinite(time) || !Number.isFinite(temp)) return null;
+ if (time < dayStart || time >= dayEnd) return null;
+ return {
+ time,
+ temp,
+ };
+ })
+ .filter(Boolean);
+ };
+
+ const resolveWeatherForecast = async () => {
+ if (!weatherCommand) return [];
+ const toolResult = await host.callTool(configuredWeatherToolName || 'exec', {
+ command: weatherCommand,
+ max_output_chars: 200000,
+ });
+ const payload = extractExecJson(toolResult);
+ if (!payload || typeof payload !== 'object') return [];
+ return extractWeatherPoints(payload);
+ };
+
+ const resolveToolConfig = async () => {
+ if (!host.listTools) {
+ return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
+ }
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
+ : null;
+ const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
+ ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ return {
+ name: tool?.name || TOOL_FALLBACK,
+ availableCalendars: enumValues,
+ };
+ } catch {
+ return { name: TOOL_FALLBACK, availableCalendars: calendarNames };
+ }
+ };
+
+ const computeScore = (events, nowTime) => {
+ const current = events.find((event) => !event._allDay && event._start <= nowTime && event._end > nowTime);
+ if (current) return 99;
+ const next = events.find((event) => !event._allDay && event._start > nowTime);
+ if (!next) {
+ if (events.some((event) => event._allDay)) return 74;
+ return 18;
+ }
+ const minutesAway = Math.max(0, Math.round((next._start - nowTime) / 60000));
+ let score = 70;
+ if (minutesAway <= 15) score = 98;
+ else if (minutesAway <= 30) score = 95;
+ else if (minutesAway <= 60) score = 92;
+ else if (minutesAway <= 180) score = 88;
+ else if (minutesAway <= 360) score = 82;
+ else score = 76;
+ score += Math.min(events.length, 3);
+ return Math.min(100, score);
+ };
+
+ const createAllDayChip = (event) => {
+ const chip = document.createElement('div');
+ chip.style.padding = '3px 6px';
+ chip.style.border = '1px solid rgba(161, 118, 84, 0.28)';
+ chip.style.background = 'rgba(255, 249, 241, 0.94)';
+ chip.style.color = '#5e412d';
+ chip.style.fontSize = '0.62rem';
+ chip.style.lineHeight = '1.2';
+ chip.style.fontWeight = '700';
+ chip.style.minWidth = '0';
+ chip.style.maxWidth = '100%';
+ chip.style.overflow = 'hidden';
+ chip.style.textOverflow = 'ellipsis';
+ chip.style.whiteSpace = 'nowrap';
+ chip.textContent = String(event.summary || '(No title)');
+ return chip;
+ };
+
+ const computeWeatherRange = (points) => {
+ if (!Array.isArray(points) || points.length === 0) return null;
+ let low = Number.POSITIVE_INFINITY;
+ let high = Number.NEGATIVE_INFINITY;
+ for (const point of points) {
+ low = Math.min(low, point.temp);
+ high = Math.max(high, point.temp);
+ }
+ if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
+ if (low === high) high = low + 1;
+ return { low, high };
+ };
+
+ const renderWeatherGraph = (windowRange, timelineHeight) => {
+ weatherScaleEl.style.display = 'none';
+ weatherLowEl.textContent = '--';
+ weatherHighEl.textContent = '--';
+
+ if (!Array.isArray(latestWeatherPoints) || latestWeatherPoints.length === 0 || !latestWeatherRange) return;
+
+ const visiblePoints = latestWeatherPoints.filter((point) => {
+ const minutes = minutesIntoDay(point.time);
+ return minutes >= windowRange.start && minutes <= windowRange.end;
+ });
+ if (visiblePoints.length < 2) return;
+
+ weatherScaleEl.style.display = 'flex';
+ weatherLowEl.textContent = `${Math.round(latestWeatherRange.low)}°`;
+ weatherHighEl.textContent = `${Math.round(latestWeatherRange.high)}°`;
+
+ const timelineWidth = timelineEl.getBoundingClientRect().width;
+ const overlayWidth = Math.max(1, timelineWidth - (LABEL_WIDTH + 6));
+
+ const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ overlay.setAttribute('viewBox', `0 0 ${overlayWidth} ${timelineHeight}`);
+ overlay.style.position = 'absolute';
+ overlay.style.left = `${LABEL_WIDTH + 6}px`;
+ overlay.style.top = '0';
+ overlay.style.height = `${timelineHeight}px`;
+ overlay.style.width = `${overlayWidth}px`;
+ overlay.style.pointerEvents = 'none';
+ overlay.style.opacity = '0.85';
+
+ const toPoint = (point) => {
+ const minutes = minutesIntoDay(point.time);
+ const y = TRACK_TOP_PAD + ((minutes - windowRange.start) / 30) * slotHeight;
+ const x = ((point.temp - latestWeatherRange.low) / (latestWeatherRange.high - latestWeatherRange.low)) * overlayWidth;
+ return { x, y };
+ };
+
+ const coords = visiblePoints.map(toPoint);
+ const buildSmoothPath = (points) => {
+ if (points.length === 0) return '';
+ if (points.length === 1) return `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
+ let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
+ for (let index = 1; index < points.length - 1; index += 1) {
+ const point = points[index];
+ const next = points[index + 1];
+ const midX = (point.x + next.x) / 2;
+ const midY = (point.y + next.y) / 2;
+ d += ` Q ${point.x.toFixed(2)} ${point.y.toFixed(2)} ${midX.toFixed(2)} ${midY.toFixed(2)}`;
+ }
+ const penultimate = points[points.length - 2];
+ const last = points[points.length - 1];
+ d += ` Q ${penultimate.x.toFixed(2)} ${penultimate.y.toFixed(2)} ${last.x.toFixed(2)} ${last.y.toFixed(2)}`;
+ return d;
+ };
+
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ line.setAttribute('d', buildSmoothPath(coords));
+ line.setAttribute('fill', 'none');
+ line.setAttribute('stroke', 'rgba(184, 110, 58, 0.34)');
+ line.setAttribute('stroke-width', '1.8');
+ line.setAttribute('stroke-linecap', 'round');
+ line.setAttribute('stroke-linejoin', 'round');
+ line.setAttribute('vector-effect', 'non-scaling-stroke');
+ overlay.appendChild(line);
+
+ for (const point of coords) {
+ const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ dot.setAttribute('cx', point.x.toFixed(2));
+ dot.setAttribute('cy', point.y.toFixed(2));
+ dot.setAttribute('r', '1.9');
+ dot.setAttribute('fill', 'rgba(184, 110, 58, 0.34)');
+ dot.setAttribute('stroke', 'rgba(226, 188, 156, 0.62)');
+ dot.setAttribute('stroke-width', '0.55');
+ overlay.appendChild(dot);
+ }
+
+ timelineEl.appendChild(overlay);
+ };
+
+ const renderState = () => {
+ const now = new Date();
+ const nowTime = now.getTime();
+ const todayLabel = formatShortDate(now);
+ if (!Array.isArray(latestEvents) || latestEvents.length === 0) {
+ headlineEl.textContent = todayLabel;
+ detailEl.textContent = emptyText;
+ allDayWrapEl.style.display = 'none';
+ timelineShellEl.style.display = 'none';
+ emptyEl.style.display = 'block';
+ updateLiveContent({
+ kind: 'calendar_timeline',
+ subtitle: subtitle || null,
+ tool_name: TOOL_FALLBACK,
+ calendar_names: latestSelectedCalendars,
+ updated_at: latestUpdatedAt || null,
+ now_label: formatClock(now),
+ headline: headlineEl.textContent || null,
+ detail: detailEl.textContent || null,
+ weather_temperature_range: latestWeatherRange ? {
+ low: latestWeatherRange.low,
+ high: latestWeatherRange.high,
+ } : null,
+ event_count: 0,
+ all_day_count: 0,
+ score: 18,
+ events: [],
+ });
+ return;
+ }
+
+ const allDayEvents = latestEvents.filter((event) => event._allDay);
+ const timedEvents = latestEvents.filter((event) => !event._allDay);
+ const currentEvent = timedEvents.find((event) => event._start <= nowTime && event._end > nowTime) || null;
+ const nextEvent = timedEvents.find((event) => event._start > nowTime) || null;
+
+ headlineEl.textContent = todayLabel;
+
+ if (currentEvent) {
+ detailEl.textContent = '';
+ } else if (nextEvent) {
+ detailEl.textContent = '';
+ } else if (allDayEvents.length > 0) {
+ detailEl.textContent = 'All-day events on your calendar.';
+ } else {
+ detailEl.textContent = 'Your calendar is clear for the rest of the day.';
+ }
+
+ const windowRange = computeVisibleWindow(latestEvents);
+ allDayEl.innerHTML = '';
+ allDayWrapEl.style.display = allDayEvents.length > 0 ? 'block' : 'none';
+ for (const event of allDayEvents) allDayEl.appendChild(createAllDayChip(event));
+
+ emptyEl.style.display = 'none';
+ timelineShellEl.style.display = timedEvents.length > 0 ? 'block' : 'none';
+ timelineEl.innerHTML = '';
+
+ if (timedEvents.length > 0) {
+ const slotCount = Math.max(1, Math.round((windowRange.end - windowRange.start) / 30));
+ const timelineHeight = TRACK_TOP_PAD + slotCount * slotHeight;
+ timelineEl.style.height = `${timelineHeight}px`;
+
+ const gridLayer = document.createElement('div');
+ gridLayer.style.position = 'absolute';
+ gridLayer.style.inset = '0';
+ timelineEl.appendChild(gridLayer);
+
+ for (let index = 0; index <= slotCount; index += 1) {
+ const minutes = windowRange.start + index * 30;
+ const top = TRACK_TOP_PAD + index * slotHeight;
+
+ const line = document.createElement('div');
+ line.style.position = 'absolute';
+ line.style.left = `${LABEL_WIDTH}px`;
+ line.style.right = '0';
+ line.style.top = `${top}px`;
+ line.style.borderTop = minutes % 60 === 0
+ ? '1px solid rgba(143, 101, 69, 0.24)'
+ : '1px dashed rgba(181, 145, 116, 0.18)';
+ gridLayer.appendChild(line);
+
+ if (minutes % 60 === 0 && minutes < windowRange.end) {
+ const label = document.createElement('div');
+ label.style.position = 'absolute';
+ label.style.left = '0';
+ label.style.top = `${Math.max(0, top - 7)}px`;
+ label.style.width = `${LABEL_WIDTH - 8}px`;
+ label.style.fontFamily = "'M-1m Code', ui-monospace, Menlo, Consolas, monospace";
+ label.style.fontSize = '0.54rem';
+ label.style.lineHeight = '1';
+ label.style.color = '#8a6248';
+ label.style.textAlign = 'right';
+ label.style.textTransform = 'uppercase';
+ label.style.letterSpacing = '0.05em';
+ label.textContent = hourLabel(minutes);
+ gridLayer.appendChild(label);
+ }
+ }
+
+ renderWeatherGraph(windowRange, timelineHeight);
+
+ const eventsLayer = document.createElement('div');
+ eventsLayer.style.position = 'absolute';
+ eventsLayer.style.left = `${LABEL_WIDTH + 6}px`;
+ eventsLayer.style.right = '0';
+ eventsLayer.style.top = `${TRACK_TOP_PAD}px`;
+ eventsLayer.style.bottom = '0';
+ timelineEl.appendChild(eventsLayer);
+
+ const layoutEvents = assignColumns(timedEvents.map((event) => ({ ...event })));
+ for (const event of layoutEvents) {
+ const offsetStart = Math.max(windowRange.start, minutesIntoDay(event._start)) - windowRange.start;
+ const offsetEnd = Math.min(windowRange.end, minutesIntoDay(event._end)) - windowRange.start;
+ const top = Math.max(0, (offsetStart / 30) * slotHeight);
+ const height = Math.max(16, ((offsetEnd - offsetStart) / 30) * slotHeight - 3);
+
+ const block = document.createElement('div');
+ block.style.position = 'absolute';
+ block.style.top = `${top}px`;
+ block.style.height = `${height}px`;
+ block.style.left = `calc(${(100 / event._columns) * event._column}% + ${event._column * 4}px)`;
+ block.style.width = `calc(${100 / event._columns}% - 4px)`;
+ block.style.padding = '5px 6px';
+ block.style.border = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
+ ? '1px solid rgba(169, 39, 29, 0.38)'
+ : '1px solid rgba(162, 105, 62, 0.26)';
+ block.style.background = currentEvent && currentEvent._start === event._start && currentEvent._end === event._end
+ ? 'linear-gradient(180deg, rgba(255, 228, 224, 0.98) 0%, rgba(248, 205, 198, 0.94) 100%)'
+ : 'linear-gradient(180deg, rgba(244, 220, 196, 0.98) 0%, rgba(230, 197, 165, 0.98) 100%)';
+ block.style.boxShadow = '0 4px 10px rgba(84, 51, 29, 0.08)';
+ block.style.overflow = 'hidden';
+
+ const title = document.createElement('div');
+ title.style.fontFamily = "'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif";
+ title.style.fontSize = '0.74rem';
+ title.style.lineHeight = '0.98';
+ title.style.letterSpacing = '-0.01em';
+ title.style.color = '#22140c';
+ title.style.fontWeight = '700';
+ title.style.whiteSpace = 'nowrap';
+ title.style.textOverflow = 'ellipsis';
+ title.style.overflow = 'hidden';
+ title.textContent = String(event.summary || '(No title)');
+ block.appendChild(title);
+
+ eventsLayer.appendChild(block);
+ }
+
+ if (nowTime >= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.start, 0, 0).getTime() &&
+ nowTime <= new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, windowRange.end, 0, 0).getTime()) {
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
+ const nowTop = TRACK_TOP_PAD + ((nowMinutes - windowRange.start) / 30) * slotHeight;
+
+ const line = document.createElement('div');
+ line.style.position = 'absolute';
+ line.style.left = `${LABEL_WIDTH + 6}px`;
+ line.style.right = '0';
+ line.style.top = `${nowTop}px`;
+ line.style.borderTop = '1.5px solid #cf2f21';
+ line.style.transition = 'top 28s linear';
+ timelineEl.appendChild(line);
+
+ const dot = document.createElement('div');
+ dot.style.position = 'absolute';
+ dot.style.left = `${LABEL_WIDTH + 1}px`;
+ dot.style.top = `${nowTop - 4}px`;
+ dot.style.width = '8px';
+ dot.style.height = '8px';
+ dot.style.borderRadius = '999px';
+ dot.style.background = '#cf2f21';
+ dot.style.boxShadow = '0 0 0 2px rgba(255, 255, 255, 0.95)';
+ dot.style.transition = 'top 28s linear';
+ timelineEl.appendChild(dot);
+
+ }
+ }
+
+ updateLiveContent({
+ kind: 'calendar_timeline',
+ subtitle: subtitle || null,
+ tool_name: TOOL_FALLBACK,
+ calendar_names: latestSelectedCalendars,
+ updated_at: latestUpdatedAt || null,
+ now_label: formatClock(now),
+ headline: headlineEl.textContent || null,
+ detail: detailEl.textContent || null,
+ weather_temperature_range: latestWeatherRange ? {
+ low: latestWeatherRange.low,
+ high: latestWeatherRange.high,
+ } : null,
+ current_event: currentEvent ? {
+ summary: String(currentEvent.summary || '(No title)'),
+ start: normalizeDateValue(currentEvent.start) || null,
+ end: normalizeDateValue(currentEvent.end) || null,
+ } : null,
+ next_event: nextEvent ? {
+ summary: String(nextEvent.summary || '(No title)'),
+ start: normalizeDateValue(nextEvent.start) || null,
+ end: normalizeDateValue(nextEvent.end) || null,
+ starts_in: formatDistance(nextEvent._start - nowTime),
+ } : null,
+ event_count: latestEvents.length,
+ all_day_count: allDayEvents.length,
+ score: computeScore(latestEvents, nowTime),
+ events: latestEvents.map((event) => ({
+ summary: String(event.summary || '(No title)'),
+ start: normalizeDateValue(event.start) || null,
+ end: normalizeDateValue(event.end) || null,
+ all_day: Boolean(event._allDay),
+ })),
+ });
+ };
+
+ const refresh = async () => {
+ headlineEl.textContent = 'Loading today…';
+ detailEl.textContent = 'Checking your calendar.';
+
+ try {
+ const toolConfig = await resolveToolConfig();
+ const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
+ if (!toolConfig.name) throw new Error('Calendar tool unavailable');
+ if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
+ throw new Error('No calendars configured');
+ }
+
+ const [weatherPoints, allEvents] = await Promise.all([
+ resolveWeatherForecast().catch(() => []),
+ (async () => {
+ const allEvents = [];
+ for (const calendarName of selectedCalendars) {
+ const toolResult = await host.callTool(toolConfig.name, {
+ calendar: calendarName,
+ range: 'today',
+ });
+ const events = extractEvents(toolResult);
+ for (const event of events) {
+ const bounds = eventBounds(event);
+ if (!bounds) continue;
+ allEvents.push({
+ ...event,
+ _calendarName: calendarName,
+ _start: bounds.start,
+ _end: bounds.end,
+ _allDay: bounds.allDay,
+ });
+ }
+ }
+ return allEvents;
+ })(),
+ ]);
+
+ allEvents.sort((left, right) => left._start - right._start);
+ latestWeatherPoints = weatherPoints;
+ latestWeatherRange = computeWeatherRange(weatherPoints);
+ latestEvents = allEvents;
+ latestSelectedCalendars = selectedCalendars;
+ latestUpdatedAt = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+ renderState();
+ } catch (error) {
+ const errorText = String(error);
+ latestWeatherPoints = [];
+ latestWeatherRange = null;
+ latestEvents = [];
+ latestSelectedCalendars = calendarNames;
+ latestUpdatedAt = errorText;
+ headlineEl.textContent = formatShortDate(new Date());
+ detailEl.textContent = errorText;
+ allDayWrapEl.style.display = 'none';
+ timelineShellEl.style.display = 'none';
+ emptyEl.style.display = 'block';
+ updateLiveContent({
+ kind: 'calendar_timeline',
+ subtitle: subtitle || null,
+ tool_name: TOOL_FALLBACK,
+ calendar_names: latestSelectedCalendars,
+ updated_at: errorText,
+ now_label: formatClock(new Date()),
+ headline: headlineEl.textContent || null,
+ detail: detailEl.textContent || null,
+ weather_temperature_range: null,
+ event_count: 0,
+ all_day_count: 0,
+ score: 0,
+ events: [],
+ error: errorText,
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+
+ if (clockIntervalId) window.clearInterval(clockIntervalId);
+ clockIntervalId = __setInterval(() => {
+ renderState();
+ }, 30000);
+
+ void refresh();
+ __setInterval(() => {
+ void refresh();
+ }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/calendar-timeline-weather-live/manifest.json b/examples/cards/templates/calendar-timeline-weather-live/manifest.json
new file mode 100644
index 0000000..c4c43fb
--- /dev/null
+++ b/examples/cards/templates/calendar-timeline-weather-live/manifest.json
@@ -0,0 +1,22 @@
+{
+ "key": "calendar-timeline-weather-live",
+ "title": "Today Calendar Weather Timeline",
+ "notes": "Experimental copy of the today-only Home Assistant calendar timeline with a subtle hourly temperature graph behind the timeline. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, refresh_ms, min_start_hour, max_end_hour, min_window_hours, slot_height, empty_text, weather_tool_name (defaults to exec), and weather_command.",
+ "example_state": {
+ "subtitle": "Family Calendar",
+ "tool_name": "mcp_home_assistant_calendar_get_events",
+ "calendar_names": [
+ "Family Calendar"
+ ],
+ "refresh_ms": 900000,
+ "min_start_hour": 6,
+ "max_end_hour": 22,
+ "min_window_hours": 6,
+ "slot_height": 24,
+ "empty_text": "No events for today.",
+ "weather_tool_name": "exec",
+ "weather_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48"
+ },
+ "created_at": "2026-04-02T00:00:00+00:00",
+ "updated_at": "2026-04-02T00:00:00+00:00"
+}
diff --git a/examples/cards/templates/calendar-timeline-weather-live/template.html b/examples/cards/templates/calendar-timeline-weather-live/template.html
new file mode 100644
index 0000000..30ede4d
--- /dev/null
+++ b/examples/cards/templates/calendar-timeline-weather-live/template.html
@@ -0,0 +1,41 @@
+
+
+
Today Calendar + Weather
+
+
+
+
Loading today…
+
Checking your calendar.
+
+
+
+
+
+
No events for today.
+
+
+
diff --git a/examples/cards/templates/git-diff-live/card.js b/examples/cards/templates/git-diff-live/card.js
new file mode 100644
index 0000000..64874a3
--- /dev/null
+++ b/examples/cards/templates/git-diff-live/card.js
@@ -0,0 +1,719 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const subtitleEl = root.querySelector('[data-git-subtitle]');
+ const branchEl = root.querySelector('[data-git-branch]');
+ const statusEl = root.querySelector('[data-git-status]');
+ const changedEl = root.querySelector('[data-git-changed]');
+ const stagingEl = root.querySelector('[data-git-staging]');
+ const untrackedEl = root.querySelector('[data-git-untracked]');
+ const upstreamEl = root.querySelector('[data-git-upstream]');
+ const plusEl = root.querySelector('[data-git-plus]');
+ const minusEl = root.querySelector('[data-git-minus]');
+ const updatedEl = root.querySelector('[data-git-updated]');
+ const filesEl = root.querySelector('[data-git-files]');
+ if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return;
+
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
+ ? state.tool_arguments
+ : {};
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
+ const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 });
+
+ const setStatus = (label, fg, bg) => {
+ statusEl.textContent = label;
+ statusEl.style.color = fg;
+ statusEl.style.background = bg;
+ statusEl.style.padding = '3px 7px';
+ statusEl.style.borderRadius = '999px';
+ };
+
+ const statusTone = (value) => {
+ if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' };
+ if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' };
+ return { fg: '#a14d43', bg: '#f3d8d2' };
+ };
+
+ const formatBranch = (payload) => {
+ const parts = [];
+ const branch = typeof payload.branch === 'string' ? payload.branch : '';
+ if (branch) parts.push(branch);
+ if (typeof payload.upstream === 'string' && payload.upstream) {
+ parts.push(payload.upstream);
+ }
+ const ahead = Number(payload.ahead || 0);
+ const behind = Number(payload.behind || 0);
+ if (ahead || behind) {
+ parts.push(`+${ahead} / -${behind}`);
+ }
+ if (!parts.length && typeof payload.head === 'string' && payload.head) {
+ parts.push(payload.head);
+ }
+ return parts.join(' · ') || 'No branch information';
+ };
+
+ const formatUpdated = (raw) => {
+ if (typeof raw !== 'string' || !raw) return '--';
+ const parsed = new Date(raw);
+ if (Number.isNaN(parsed.getTime())) return raw;
+ return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ };
+
+ const chipStyle = (status) => {
+ if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' };
+ if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' };
+ if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' };
+ return { fg: '#9a6a2f', bg: '#f3e1ba' };
+ };
+
+ const asLineNumber = (value) => {
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
+ return null;
+ };
+
+ const formatRangePart = (label, start, end) => {
+ if (start === null || end === null) return '';
+ return start === end ? `${label} ${start}` : `${label} ${start}-${end}`;
+ };
+
+ const buildSelectionPayload = (filePath, lines) => {
+ const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null);
+ const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null);
+ const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null;
+ const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null;
+ const newStart = newNumbers.length ? Math.min(...newNumbers) : null;
+ const newEnd = newNumbers.length ? Math.max(...newNumbers) : null;
+ const rangeParts = [
+ formatRangePart('old', oldStart, oldEnd),
+ formatRangePart('new', newStart, newEnd),
+ ].filter(Boolean);
+ const fileLabel = filePath || 'Selected diff';
+ const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines';
+ return {
+ kind: 'git_diff_range',
+ file_path: filePath || fileLabel,
+ file_label: fileLabel,
+ range_label: rangeLabel,
+ label: `${fileLabel} · ${rangeLabel}`,
+ old_start: oldStart,
+ old_end: oldEnd,
+ new_start: newStart,
+ new_end: newEnd,
+ };
+ };
+
+ let activeSelectionController = null;
+
+ const clearActiveSelection = () => {
+ if (activeSelectionController) {
+ const controller = activeSelectionController;
+ activeSelectionController = null;
+ controller.clear(false);
+ }
+ host.setSelection(null);
+ };
+
+ const renderPatchBody = (target, item) => {
+ target.innerHTML = '';
+ target.dataset.noSwipe = '1';
+ target.style.marginTop = '10px';
+ target.style.marginLeft = '-12px';
+ target.style.marginRight = '-12px';
+ target.style.width = 'calc(100% + 24px)';
+ target.style.paddingTop = '10px';
+ target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)';
+ target.style.overflow = 'hidden';
+ target.style.minWidth = '0';
+ target.style.maxWidth = 'none';
+
+ const viewport = document.createElement('div');
+ viewport.dataset.noSwipe = '1';
+ viewport.style.width = '100%';
+ viewport.style.maxWidth = 'none';
+ viewport.style.minWidth = '0';
+ viewport.style.overflowX = 'auto';
+ viewport.style.overflowY = 'hidden';
+ viewport.style.touchAction = 'auto';
+ viewport.style.overscrollBehavior = 'contain';
+ viewport.style.webkitOverflowScrolling = 'touch';
+ viewport.style.scrollbarWidth = 'thin';
+ viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent';
+
+ const diffText = typeof item?.diff === 'string' ? item.diff : '';
+ const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : [];
+ if (!diffText && diffLines.length === 0) {
+ const message = document.createElement('div');
+ message.textContent = 'No line diff available for this path.';
+ message.style.fontSize = '0.76rem';
+ message.style.lineHeight = '1.4';
+ message.style.color = '#9a7b68';
+ message.style.fontWeight = '600';
+ target.appendChild(message);
+ return;
+ }
+
+ const block = document.createElement('div');
+ block.dataset.noSwipe = '1';
+ block.style.display = 'grid';
+ block.style.gap = '0';
+ block.style.padding = '0';
+ block.style.borderRadius = '0';
+ block.style.background = 'rgba(255,255,255,0.58)';
+ block.style.border = '1px solid rgba(153, 118, 92, 0.14)';
+ block.style.borderLeft = '0';
+ block.style.borderRight = '0';
+ block.style.fontFamily =
+ "var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)";
+ block.style.fontSize = '0.64rem';
+ block.style.lineHeight = '1.45';
+ block.style.color = '#5f4a3f';
+ block.style.width = 'max-content';
+ block.style.minWidth = '100%';
+
+ const selectableLines = [];
+ let localSelection = null;
+ let localAnchorIndex = null;
+
+ const setSelectedState = (entry, selected) => {
+ if (selected) entry.lineEl.dataset.selected = 'true';
+ else delete entry.lineEl.dataset.selected;
+ };
+
+ const controller = {
+ clear(publish = true) {
+ localSelection = null;
+ localAnchorIndex = null;
+ for (const entry of selectableLines) setSelectedState(entry, false);
+ if (publish) {
+ if (activeSelectionController === controller) activeSelectionController = null;
+ host.setSelection(null);
+ }
+ },
+ };
+
+ const applySelection = (startIndex, endIndex) => {
+ const lower = Math.min(startIndex, endIndex);
+ const upper = Math.max(startIndex, endIndex);
+ localSelection = { startIndex: lower, endIndex: upper };
+ for (const [index, entry] of selectableLines.entries()) {
+ setSelectedState(entry, index >= lower && index <= upper);
+ }
+ if (activeSelectionController && activeSelectionController !== controller) {
+ activeSelectionController.clear(false);
+ }
+ activeSelectionController = controller;
+ host.setSelection(buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)),
+ );
+ };
+
+ const handleSelectableLine = (index) => {
+ if (!localSelection) {
+ localAnchorIndex = index;
+ applySelection(index, index);
+ return;
+ }
+ const singleLine = localSelection.startIndex === localSelection.endIndex;
+ if (singleLine) {
+ const anchorIndex = localAnchorIndex ?? localSelection.startIndex;
+ if (index === anchorIndex) {
+ controller.clear(true);
+ return;
+ }
+ applySelection(anchorIndex, index);
+ localAnchorIndex = null;
+ return;
+ }
+ localAnchorIndex = index;
+ applySelection(index, index);
+ };
+
+ const registerSelectableLine = (lineEl, oldNumber, newNumber) => {
+ const entry = {
+ lineEl,
+ oldNumber: asLineNumber(oldNumber),
+ newNumber: asLineNumber(newNumber),
+ };
+ const index = selectableLines.push(entry) - 1;
+ lineEl.dataset.selectable = 'true';
+ lineEl.tabIndex = 0;
+ lineEl.setAttribute('role', 'button');
+ lineEl.addEventListener('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ handleSelectableLine(index);
+ });
+ lineEl.addEventListener('keydown', (event) => {
+ if (event.key !== 'Enter' && event.key !== ' ') return;
+ event.preventDefault();
+ handleSelectableLine(index);
+ });
+ };
+
+ if (diffLines.length > 0) {
+ for (const row of diffLines) {
+ const lineEl = document.createElement('div');
+ lineEl.dataset.gitPatchRow = '1';
+ lineEl.style.display = 'grid';
+ lineEl.style.gridTemplateColumns = 'max-content max-content';
+ lineEl.style.columnGap = '8px';
+ lineEl.style.alignItems = 'start';
+ lineEl.style.justifyContent = 'start';
+ lineEl.style.padding = '0';
+ lineEl.style.borderRadius = '0';
+ lineEl.style.width = 'max-content';
+ lineEl.style.minWidth = '100%';
+
+ const numberEl = document.createElement('span');
+ const lineNumber =
+ typeof row?.line_number === 'number' || typeof row?.line_number === 'string'
+ ? String(row.line_number)
+ : '';
+ numberEl.textContent = lineNumber;
+ numberEl.style.minWidth = '2.2em';
+ numberEl.style.textAlign = 'right';
+ numberEl.style.color = '#8c7464';
+ numberEl.style.opacity = '0.92';
+
+ const textEl = document.createElement('span');
+ textEl.dataset.gitPatchText = '1';
+ textEl.textContent = typeof row?.text === 'string' ? row.text : '';
+ textEl.style.whiteSpace = 'pre';
+ textEl.style.wordBreak = 'normal';
+
+ const kind = typeof row?.kind === 'string' ? row.kind : '';
+ if (kind === 'added') {
+ lineEl.style.color = '#0c3f12';
+ lineEl.style.background = 'rgba(158, 232, 147, 0.98)';
+ } else if (kind === 'removed') {
+ lineEl.style.color = '#6d0d08';
+ lineEl.style.background = 'rgba(249, 156, 145, 0.98)';
+ } else {
+ lineEl.style.color = '#5f4a3f';
+ }
+
+ lineEl.append(numberEl, textEl);
+ block.appendChild(lineEl);
+ }
+ } else {
+ const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => {
+ const lineEl = document.createElement('div');
+ lineEl.dataset.gitPatchRow = '1';
+ lineEl.style.display = 'grid';
+ lineEl.style.gridTemplateColumns = 'max-content max-content max-content';
+ lineEl.style.columnGap = '8px';
+ lineEl.style.alignItems = 'start';
+ lineEl.style.justifyContent = 'start';
+ lineEl.style.padding = '0';
+ lineEl.style.borderRadius = '0';
+ lineEl.style.width = 'max-content';
+ lineEl.style.minWidth = '100%';
+
+ const oldEl = document.createElement('span');
+ oldEl.textContent = oldNumber ? String(oldNumber) : '';
+ oldEl.style.minWidth = '2.4em';
+ oldEl.style.textAlign = 'right';
+ oldEl.style.color = '#8c7464';
+ oldEl.style.opacity = '0.92';
+
+ const newEl = document.createElement('span');
+ newEl.textContent = newNumber ? String(newNumber) : '';
+ newEl.style.minWidth = '2.4em';
+ newEl.style.textAlign = 'right';
+ newEl.style.color = '#8c7464';
+ newEl.style.opacity = '0.92';
+
+ const textEl = document.createElement('span');
+ textEl.dataset.gitPatchText = '1';
+ textEl.textContent = line || ' ';
+ textEl.style.whiteSpace = 'pre';
+ textEl.style.wordBreak = 'normal';
+
+ if (kind === 'hunk') {
+ lineEl.style.color = '#6c523f';
+ lineEl.style.background = 'rgba(224, 204, 184, 0.94)';
+ lineEl.style.fontWeight = '800';
+ } else if (kind === 'added') {
+ lineEl.style.color = '#0f4515';
+ lineEl.style.background = 'rgba(170, 232, 160, 0.98)';
+ } else if (kind === 'removed') {
+ lineEl.style.color = '#74110a';
+ lineEl.style.background = 'rgba(247, 170, 160, 0.98)';
+ } else if (kind === 'context') {
+ lineEl.style.color = '#6f5b4d';
+ lineEl.style.background = 'rgba(247, 236, 223, 0.72)';
+ } else if (kind === 'meta') {
+ lineEl.style.color = '#725c4f';
+ lineEl.style.background = 'rgba(255, 255, 255, 0.42)';
+ } else if (kind === 'note') {
+ lineEl.style.color = '#8a6f5c';
+ lineEl.style.background = 'rgba(236, 226, 216, 0.72)';
+ lineEl.style.fontStyle = 'italic';
+ }
+
+ lineEl.append(oldEl, newEl, textEl);
+ if (kind === 'added' || kind === 'removed' || kind === 'context') {
+ registerSelectableLine(lineEl, oldNumber, newNumber);
+ }
+ return lineEl;
+ };
+
+ const parseHunkHeader = (line) => {
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
+ if (!match) return null;
+ return {
+ oldLine: Number(match[1] || '0'),
+ newLine: Number(match[3] || '0'),
+ };
+ };
+
+ const prelude = [];
+ const hunks = [];
+ let currentHunk = null;
+ for (const line of diffText.split('\n')) {
+ if (line.startsWith('@@')) {
+ if (currentHunk) hunks.push(currentHunk);
+ currentHunk = { header: line, lines: [] };
+ continue;
+ }
+ if (currentHunk) {
+ currentHunk.lines.push(line);
+ } else {
+ prelude.push(line);
+ }
+ }
+ if (currentHunk) hunks.push(currentHunk);
+
+ for (const line of prelude) {
+ let kind = 'meta';
+ if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note';
+ else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added';
+ else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed';
+ else if (line.startsWith(' ')) kind = 'context';
+ block.appendChild(makePatchLine(line, kind));
+ }
+
+ for (const hunk of hunks) {
+ const section = document.createElement('section');
+ section.style.display = 'grid';
+ section.style.gap = '0';
+ section.style.marginTop = block.childNodes.length ? '10px' : '0';
+ section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)';
+ section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)';
+
+ section.appendChild(makePatchLine(hunk.header, 'hunk'));
+ const parsed = parseHunkHeader(hunk.header);
+ let oldLine = parsed ? parsed.oldLine : 0;
+ let newLine = parsed ? parsed.newLine : 0;
+ for (const line of hunk.lines) {
+ if (line.startsWith('+') && !line.startsWith('+++')) {
+ section.appendChild(makePatchLine(line, 'added', '', newLine));
+ newLine += 1;
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
+ section.appendChild(makePatchLine(line, 'removed', oldLine, ''));
+ oldLine += 1;
+ } else if (line.startsWith('\\')) {
+ section.appendChild(makePatchLine(line, 'note'));
+ } else if (line.startsWith('+++') || line.startsWith('---')) {
+ section.appendChild(makePatchLine(line, 'meta'));
+ } else {
+ const oldNumber = oldLine ? oldLine : '';
+ const newNumber = newLine ? newLine : '';
+ section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber));
+ oldLine += 1;
+ newLine += 1;
+ }
+ }
+ block.appendChild(section);
+ }
+ }
+
+ viewport.appendChild(block);
+ target.appendChild(viewport);
+
+ if (item?.diff_truncated) {
+ const note = document.createElement('div');
+ note.textContent = 'Diff truncated for readability.';
+ note.style.marginTop = '8px';
+ note.style.fontSize = '0.72rem';
+ note.style.lineHeight = '1.35';
+ note.style.color = '#9a7b68';
+ note.style.fontWeight = '600';
+ target.appendChild(note);
+ }
+ };
+
+ const renderFiles = (items) => {
+ clearActiveSelection();
+ filesEl.innerHTML = '';
+ if (!Array.isArray(items) || items.length === 0) {
+ const empty = document.createElement('div');
+ empty.textContent = 'Working tree clean.';
+ empty.style.fontSize = '0.92rem';
+ empty.style.lineHeight = '1.4';
+ empty.style.color = '#7d8f73';
+ empty.style.fontWeight = '700';
+ empty.style.padding = '12px';
+ empty.style.borderRadius = '12px';
+ empty.style.background = 'rgba(223, 233, 216, 0.55)';
+ empty.style.border = '1px solid rgba(109, 138, 93, 0.12)';
+ filesEl.appendChild(empty);
+ return;
+ }
+
+ for (const item of items) {
+ const row = document.createElement('div');
+ row.style.display = 'block';
+ row.style.minWidth = '0';
+ row.style.maxWidth = '100%';
+ row.style.padding = '0';
+ row.style.borderRadius = '0';
+ row.style.background = 'transparent';
+ row.style.border = '0';
+ row.style.boxShadow = 'none';
+
+ const summaryButton = document.createElement('button');
+ summaryButton.type = 'button';
+ summaryButton.style.display = 'flex';
+ summaryButton.style.alignItems = 'flex-start';
+ summaryButton.style.justifyContent = 'space-between';
+ summaryButton.style.gap = '8px';
+ summaryButton.style.width = '100%';
+ summaryButton.style.minWidth = '0';
+ summaryButton.style.padding = '0';
+ summaryButton.style.margin = '0';
+ summaryButton.style.border = '0';
+ summaryButton.style.background = 'transparent';
+ summaryButton.style.textAlign = 'left';
+ summaryButton.style.cursor = 'pointer';
+
+ const left = document.createElement('div');
+ left.style.display = 'flex';
+ left.style.alignItems = 'flex-start';
+ left.style.gap = '8px';
+ left.style.minWidth = '0';
+ left.style.flex = '1 1 auto';
+
+ const chip = document.createElement('span');
+ const chipTone = chipStyle(String(item?.status || 'M'));
+ chip.textContent = String(item?.status || 'M');
+ chip.style.fontSize = '0.72rem';
+ chip.style.lineHeight = '1.1';
+ chip.style.fontWeight = '800';
+ chip.style.color = chipTone.fg;
+ chip.style.background = chipTone.bg;
+ chip.style.padding = '4px 7px';
+ chip.style.borderRadius = '999px';
+ chip.style.flex = '0 0 auto';
+
+ const pathWrap = document.createElement('div');
+ pathWrap.style.minWidth = '0';
+
+ const pathEl = document.createElement('div');
+ pathEl.textContent = String(item?.path || '--');
+ pathEl.style.fontSize = '0.92rem';
+ pathEl.style.lineHeight = '1.3';
+ pathEl.style.fontWeight = '700';
+ pathEl.style.color = '#65483a';
+ pathEl.style.wordBreak = 'break-word';
+
+ const detailEl = document.createElement('div');
+ detailEl.style.marginTop = '3px';
+ detailEl.style.fontSize = '0.77rem';
+ detailEl.style.lineHeight = '1.35';
+ detailEl.style.color = '#9a7b68';
+ const insertions = Number(item?.insertions || 0);
+ const deletions = Number(item?.deletions || 0);
+ detailEl.textContent = insertions || deletions
+ ? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}`
+ : 'No line diff';
+
+ pathWrap.append(pathEl, detailEl);
+ left.append(chip, pathWrap);
+ const toggle = document.createElement('span');
+ toggle.setAttribute('aria-hidden', 'true');
+ toggle.style.fontSize = '0.95rem';
+ toggle.style.lineHeight = '1';
+ toggle.style.fontWeight = '800';
+ toggle.style.color = '#9a7b68';
+ toggle.style.whiteSpace = 'nowrap';
+ toggle.style.flex = '0 0 auto';
+ toggle.style.paddingTop = '1px';
+
+ const body = document.createElement('div');
+ body.hidden = true;
+ body.style.width = '100%';
+ body.style.maxWidth = '100%';
+ body.style.minWidth = '0';
+ renderPatchBody(body, item);
+
+ const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff);
+ const setExpanded = (expanded) => {
+ body.hidden = !expanded;
+ summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
+ toggle.textContent = expanded ? '▴' : '▾';
+ };
+ setExpanded(false);
+
+ summaryButton.addEventListener('click', () => {
+ setExpanded(body.hidden);
+ });
+
+ summaryButton.append(left, toggle);
+ row.append(summaryButton, body);
+ filesEl.appendChild(row);
+ }
+ };
+
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const render = (payload) => {
+ subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo';
+ branchEl.textContent = formatBranch(payload);
+ changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0));
+ stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`;
+ untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0));
+ upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--';
+ plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`;
+ minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`;
+ updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`;
+ const label = payload.dirty ? 'Dirty' : 'Clean';
+ const tone = statusTone(label);
+ setStatus(label, tone.fg, tone.bg);
+ renderFiles(payload.files);
+ updateLiveContent({
+ kind: 'git_repo_diff',
+ repo_name: payload.repo_name || null,
+ repo_path: payload.repo_path || null,
+ branch: payload.branch || null,
+ upstream: payload.upstream || null,
+ ahead: Number(payload.ahead || 0),
+ behind: Number(payload.behind || 0),
+ dirty: Boolean(payload.dirty),
+ changed_files: Number(payload.changed_files || 0),
+ staged_files: Number(payload.staged_files || 0),
+ unstaged_files: Number(payload.unstaged_files || 0),
+ untracked_files: Number(payload.untracked_files || 0),
+ insertions: Number(payload.insertions || 0),
+ deletions: Number(payload.deletions || 0),
+ files: Array.isArray(payload.files)
+ ? payload.files.map((item) => ({
+ path: item?.path || null,
+ status: item?.status || null,
+ insertions: Number(item?.insertions || 0),
+ deletions: Number(item?.deletions || 0),
+ diff_available: Boolean(item?.diff_available),
+ diff_truncated: Boolean(item?.diff_truncated),
+ }))
+ : [],
+ generated_at: payload.generated_at || null,
+ });
+ };
+
+ const renderError = (message) => {
+ clearActiveSelection();
+ subtitleEl.textContent = subtitle || 'Git repo';
+ branchEl.textContent = 'Unable to load repo diff';
+ changedEl.textContent = '--';
+ stagingEl.textContent = '--';
+ untrackedEl.textContent = '--';
+ upstreamEl.textContent = '--';
+ plusEl.textContent = '+--';
+ minusEl.textContent = '- --';
+ updatedEl.textContent = message;
+ const tone = statusTone('Unavailable');
+ setStatus('Unavailable', tone.fg, tone.bg);
+ filesEl.innerHTML = '';
+ const error = document.createElement('div');
+ error.textContent = message;
+ error.style.fontSize = '0.88rem';
+ error.style.lineHeight = '1.4';
+ error.style.color = '#a45b51';
+ error.style.fontWeight = '700';
+ error.style.padding = '12px';
+ error.style.borderRadius = '12px';
+ error.style.background = 'rgba(243, 216, 210, 0.55)';
+ error.style.border = '1px solid rgba(164, 91, 81, 0.14)';
+ filesEl.appendChild(error);
+ updateLiveContent({
+ kind: 'git_repo_diff',
+ repo_name: null,
+ repo_path: null,
+ dirty: null,
+ error: message,
+ });
+ };
+
+ const loadPayload = async () => {
+ if (!configuredToolName) throw new Error('Missing template_state.tool_name');
+ if (!host.callToolAsync) throw new Error('Async tool helper unavailable');
+ const toolResult = await host.callToolAsync(
+ configuredToolName,
+ rawToolArguments,
+ { timeoutMs: 180000 },
+ );
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
+ return toolResult.parsed;
+ }
+ const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : '';
+ if (rawContent) {
+ if (rawContent.includes('(truncated,')) {
+ throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.');
+ }
+ const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim();
+ if (!normalizedContent.startsWith('{')) {
+ throw new Error(rawContent);
+ }
+ try {
+ const parsed = JSON.parse(normalizedContent);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error);
+ throw new Error(`Tool returned invalid JSON: ${detail}`);
+ }
+ }
+ throw new Error('Tool returned invalid JSON');
+ };
+
+ const refresh = async () => {
+ const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' };
+ setStatus('Refreshing', loadingTone.fg, loadingTone.bg);
+ try {
+ const payload = await loadPayload();
+ render(payload);
+ } catch (error) {
+ renderError(String(error));
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/git-diff-live/template.html b/examples/cards/templates/git-diff-live/template.html
index 626db78..7dd37f5 100644
--- a/examples/cards/templates/git-diff-live/template.html
+++ b/examples/cards/templates/git-diff-live/template.html
@@ -162,708 +162,3 @@
-
diff --git a/examples/cards/templates/list-total-live/card.js b/examples/cards/templates/list-total-live/card.js
new file mode 100644
index 0000000..0834bd7
--- /dev/null
+++ b/examples/cards/templates/list-total-live/card.js
@@ -0,0 +1,258 @@
+function clampDigits(raw) {
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed)) return 4;
+ return Math.max(1, Math.min(4, Math.round(parsed)));
+}
+
+function sanitizeValue(raw, maxDigits) {
+ return String(raw || "")
+ .replace(/\D+/g, "")
+ .slice(0, maxDigits);
+}
+
+function sanitizeName(raw) {
+ return String(raw || "")
+ .replace(/\s+/g, " ")
+ .trimStart();
+}
+
+function isBlankRow(row) {
+ return !row || (!String(row.value || "").trim() && !String(row.name || "").trim());
+}
+
+function normalizeRows(raw, maxDigits) {
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .filter((row) => row && typeof row === "object" && !Array.isArray(row))
+ .map((row) => ({
+ value: sanitizeValue(row.value, maxDigits),
+ name: sanitizeName(row.name),
+ }));
+}
+
+function ensureTrailingBlankRow(rows) {
+ const next = rows.map((row) => ({
+ value: String(row.value || ""),
+ name: String(row.name || ""),
+ }));
+ if (!next.length || !isBlankRow(next[next.length - 1])) {
+ next.push({ value: "", name: "" });
+ }
+ return next;
+}
+
+function persistedRows(rows) {
+ return rows
+ .filter((row) => !isBlankRow(row))
+ .map((row) => ({
+ value: String(row.value || ""),
+ name: String(row.name || ""),
+ }));
+}
+
+function totalValue(rows) {
+ return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
+}
+
+function normalizeConfig(state) {
+ const maxDigits = clampDigits(state.max_digits);
+ return {
+ leftLabel: String(state.left_label || "Value").trim() || "Value",
+ rightLabel: String(state.right_label || "Item").trim() || "Item",
+ totalLabel: String(state.total_label || "Total").trim() || "Total",
+ totalSuffix: String(state.total_suffix || "").trim(),
+ maxDigits,
+ score:
+ typeof state.score === "number" && Number.isFinite(state.score)
+ ? Math.max(0, Math.min(100, state.score))
+ : 24,
+ rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)),
+ };
+}
+
+function configState(config) {
+ return {
+ left_label: config.leftLabel,
+ right_label: config.rightLabel,
+ total_label: config.totalLabel,
+ total_suffix: config.totalSuffix,
+ max_digits: config.maxDigits,
+ score: config.score,
+ rows: persistedRows(config.rows),
+ };
+}
+
+function autoscore(config) {
+ if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) {
+ return config.score;
+ }
+ return persistedRows(config.rows).length ? 24 : 16;
+}
+
+export function mount({ root, state, host }) {
+ const labelsEl = root.querySelector(".list-total-card-ui__labels");
+ const rowsEl = root.querySelector(".list-total-card-ui__rows");
+ const statusEl = root.querySelector(".list-total-card-ui__status");
+ const totalLabelEl = root.querySelector(".list-total-card-ui__total-label");
+ const totalEl = root.querySelector(".list-total-card-ui__total-value");
+
+ if (
+ !(labelsEl instanceof HTMLElement) ||
+ !(rowsEl instanceof HTMLElement) ||
+ !(statusEl instanceof HTMLElement) ||
+ !(totalLabelEl instanceof HTMLElement) ||
+ !(totalEl instanceof HTMLElement)
+ ) {
+ return;
+ }
+
+ const leftLabelEl = labelsEl.children.item(0);
+ const rightLabelEl = labelsEl.children.item(1);
+ if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return;
+
+ let config = normalizeConfig(state);
+ let saveTimer = null;
+ let busy = false;
+
+ const clearSaveTimer = () => {
+ if (saveTimer !== null) {
+ window.clearTimeout(saveTimer);
+ saveTimer = null;
+ }
+ };
+
+ const setStatus = (text, kind) => {
+ statusEl.textContent = text || "";
+ statusEl.dataset.kind = kind || "";
+ };
+
+ const publishLiveContent = () => {
+ host.setLiveContent({
+ kind: "list_total",
+ item_count: persistedRows(config.rows).length,
+ total: totalValue(config.rows),
+ total_suffix: config.totalSuffix || null,
+ score: autoscore(config),
+ });
+ };
+
+ const renderTotal = () => {
+ const total = totalValue(config.rows);
+ totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`;
+ publishLiveContent();
+ };
+
+ const persist = async () => {
+ clearSaveTimer();
+ busy = true;
+ setStatus("Saving", "ok");
+ try {
+ const nextState = configState(config);
+ await host.replaceState(nextState);
+ config = normalizeConfig(nextState);
+ setStatus("", "");
+ } catch (error) {
+ console.error("List total card save failed", error);
+ setStatus("Unavailable", "error");
+ } finally {
+ busy = false;
+ render();
+ }
+ };
+
+ const schedulePersist = () => {
+ clearSaveTimer();
+ saveTimer = window.setTimeout(() => {
+ void persist();
+ }, 280);
+ };
+
+ const normalizeRowsAfterBlur = () => {
+ config.rows = ensureTrailingBlankRow(persistedRows(config.rows));
+ render();
+ schedulePersist();
+ };
+
+ const renderRows = () => {
+ rowsEl.innerHTML = "";
+ config.rows.forEach((row, index) => {
+ const rowEl = document.createElement("div");
+ rowEl.className = "list-total-card-ui__row";
+
+ const valueInput = document.createElement("input");
+ valueInput.className = "list-total-card-ui__input list-total-card-ui__value";
+ valueInput.type = "text";
+ valueInput.inputMode = "numeric";
+ valueInput.maxLength = config.maxDigits;
+ valueInput.placeholder = "0";
+ valueInput.value = row.value;
+ valueInput.disabled = busy;
+
+ const nameInput = document.createElement("input");
+ nameInput.className = "list-total-card-ui__input list-total-card-ui__name";
+ nameInput.type = "text";
+ nameInput.placeholder = "Item";
+ nameInput.value = row.name;
+ nameInput.disabled = busy;
+
+ valueInput.addEventListener("input", () => {
+ config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits);
+ valueInput.value = config.rows[index].value;
+ if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
+ config.rows = ensureTrailingBlankRow(config.rows);
+ render();
+ schedulePersist();
+ return;
+ }
+ renderTotal();
+ schedulePersist();
+ });
+
+ nameInput.addEventListener("input", () => {
+ config.rows[index].name = sanitizeName(nameInput.value);
+ nameInput.value = config.rows[index].name;
+ if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) {
+ config.rows = ensureTrailingBlankRow(config.rows);
+ render();
+ schedulePersist();
+ return;
+ }
+ renderTotal();
+ schedulePersist();
+ });
+
+ valueInput.addEventListener("blur", normalizeRowsAfterBlur);
+ nameInput.addEventListener("blur", normalizeRowsAfterBlur);
+
+ rowEl.append(valueInput, nameInput);
+ rowsEl.appendChild(rowEl);
+ });
+ };
+
+ const render = () => {
+ leftLabelEl.textContent = config.leftLabel;
+ rightLabelEl.textContent = config.rightLabel;
+ totalLabelEl.textContent = config.totalLabel;
+ renderRows();
+ renderTotal();
+ };
+
+ host.setRefreshHandler(() => {
+ config.rows = ensureTrailingBlankRow(config.rows);
+ render();
+ });
+
+ render();
+
+ return {
+ update({ state: nextState }) {
+ config = normalizeConfig(nextState);
+ render();
+ },
+ destroy() {
+ clearSaveTimer();
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ },
+ };
+}
diff --git a/examples/cards/templates/list-total-live/template.html b/examples/cards/templates/list-total-live/template.html
index 34465c2..18c8c09 100644
--- a/examples/cards/templates/list-total-live/template.html
+++ b/examples/cards/templates/list-total-live/template.html
@@ -1,336 +1,12 @@
-
-
-
-
-
Value
-
Item
+
+
-
-
-
-
-
diff --git a/examples/cards/templates/litellm-ups-usage-live/card.js b/examples/cards/templates/litellm-ups-usage-live/card.js
new file mode 100644
index 0000000..8c04613
--- /dev/null
+++ b/examples/cards/templates/litellm-ups-usage-live/card.js
@@ -0,0 +1,268 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const subtitleEl = root.querySelector('[data-usage-subtitle]');
+ const statusEl = root.querySelector('[data-usage-status]');
+ const updatedEl = root.querySelector('[data-usage-updated]');
+ const gridEl = root.querySelector('[data-usage-grid]');
+ const monthSectionEl = root.querySelector('[data-usage-month-section]');
+ const tokens24hEl = root.querySelector('[data-usage-tokens-24h]');
+ const power24hEl = root.querySelector('[data-usage-power-24h]');
+ const window24hEl = root.querySelector('[data-usage-window-24h]');
+ const tokensMonthEl = root.querySelector('[data-usage-tokens-month]');
+ const powerMonthEl = root.querySelector('[data-usage-power-month]');
+ const windowMonthEl = root.querySelector('[data-usage-window-month]');
+ if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return;
+
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
+ const configuredToolName24h = typeof state.tool_name_24h === 'string'
+ ? state.tool_name_24h.trim()
+ : typeof state.tool_name === 'string'
+ ? state.tool_name.trim()
+ : '';
+ const configuredToolNameMonth = typeof state.tool_name_month === 'string'
+ ? state.tool_name_month.trim()
+ : '';
+ const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h)
+ ? state.tool_arguments_24h
+ : state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
+ ? state.tool_arguments
+ : {};
+ const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month)
+ ? state.tool_arguments_month
+ : {};
+ const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : '';
+ const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : '';
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+
+ const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 });
+ const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 });
+ const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
+
+ subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy';
+ const hasMonthSource = Boolean(
+ sourceMonth ||
+ configuredToolNameMonth ||
+ Object.keys(rawToolArgumentsMonth).length,
+ );
+ if (!hasMonthSource) {
+ monthSectionEl.style.display = 'none';
+ gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)';
+ }
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const setStatus = (label, color) => {
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const parseLocalTimestamp = (raw) => {
+ if (typeof raw !== 'string' || !raw.trim()) return null;
+ const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/);
+ if (!match) return null;
+ const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+ };
+
+ const formatRangeLabel = (payload, fallbackLabel) => {
+ const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : '';
+ const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : '';
+ const start = parseLocalTimestamp(startRaw);
+ const end = parseLocalTimestamp(endRaw);
+ if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) {
+ return fallbackLabel;
+ }
+ return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
+ };
+
+ const renderSection = (elements, payload, fallbackLabel) => {
+ const tokens = Number(payload?.total_tokens_processed);
+ const kwh = Number(payload?.total_ups_kwh_in_range);
+ const localCost = Number(payload?.local_cost_usd_in_range);
+
+ elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--';
+ elements.power.textContent =
+ Number.isFinite(kwh) && Number.isFinite(localCost)
+ ? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}`
+ : Number.isFinite(kwh)
+ ? `${kwhFormatter.format(kwh)} kWh`
+ : '--';
+ elements.window.textContent = formatRangeLabel(payload, fallbackLabel);
+ };
+
+ const blankSection = (elements, fallbackLabel) => {
+ elements.tokens.textContent = '--';
+ elements.power.textContent = '--';
+ elements.window.textContent = fallbackLabel;
+ };
+
+ const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`;
+
+ const buildLegacyExecCommand = (rawUrl) => {
+ if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return '';
+ const [pathPart, queryPart = ''] = rawUrl.split('?', 2);
+ const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, '');
+ if (!relativeScript) return '';
+ const params = new URLSearchParams(queryPart);
+ const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean);
+ const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`;
+ return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`;
+ };
+
+ const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => {
+ if (toolName) {
+ return {
+ toolName,
+ toolArguments,
+ };
+ }
+ const legacyCommand = buildLegacyExecCommand(legacySourceUrl);
+ if (!legacyCommand) return null;
+ return {
+ toolName: 'exec',
+ toolArguments: { command: legacyCommand },
+ };
+ };
+
+ const loadPayload = async (toolCall) => {
+ if (!toolCall) throw new Error('Missing tool_name/tool_arguments');
+ if (!host.callToolAsync) throw new Error('Async tool helper unavailable');
+ const toolResult = await host.callToolAsync(
+ toolCall.toolName,
+ toolCall.toolArguments,
+ { timeoutMs: 180000 },
+ );
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
+ return toolResult.parsed;
+ }
+ if (typeof toolResult?.content === 'string' && toolResult.content.trim()) {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ }
+ throw new Error('Tool returned invalid JSON');
+ };
+
+ const refresh = async () => {
+ setStatus('Refreshing', 'var(--theme-status-muted)');
+ try {
+ const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h);
+ const toolCallMonth = hasMonthSource
+ ? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth)
+ : null;
+ const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)];
+ const results = await Promise.allSettled(jobs);
+ const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null;
+ const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null;
+
+ if (twentyFourHour) {
+ renderSection(
+ { tokens: tokens24hEl, power: power24hEl, window: window24hEl },
+ twentyFourHour,
+ 'Last 24 hours',
+ );
+ } else {
+ blankSection(
+ { tokens: tokens24hEl, power: power24hEl, window: window24hEl },
+ 'Last 24 hours',
+ );
+ }
+
+ if (hasMonthSource && month) {
+ renderSection(
+ { tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
+ month,
+ 'This month',
+ );
+ } else if (hasMonthSource) {
+ blankSection(
+ { tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
+ 'This month',
+ );
+ }
+
+ const successCount = [twentyFourHour, month].filter(Boolean).length;
+ const expectedCount = hasMonthSource ? 2 : 1;
+ if (successCount === expectedCount) {
+ setStatus('Live', 'var(--theme-status-live)');
+ } else if (successCount === 1) {
+ setStatus('Partial', 'var(--theme-status-warning)');
+ } else {
+ setStatus('Unavailable', 'var(--theme-status-danger)');
+ }
+
+ const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ updatedEl.textContent = updatedText;
+ updateLiveContent({
+ kind: 'litellm_ups_usage',
+ subtitle: subtitleEl.textContent || null,
+ status: statusEl.textContent || null,
+ updated_at: updatedText,
+ last_24h: twentyFourHour
+ ? {
+ total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0,
+ total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0,
+ local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0,
+ range_start_local: twentyFourHour.range_start_local || null,
+ range_end_local: twentyFourHour.range_end_local || null,
+ }
+ : null,
+ this_month: month
+ ? {
+ total_tokens_processed: Number(month.total_tokens_processed) || 0,
+ total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0,
+ local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0,
+ range_start_local: month.range_start_local || null,
+ range_end_local: month.range_end_local || null,
+ }
+ : null,
+ });
+ } catch (error) {
+ const errorText = String(error);
+ blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours');
+ if (hasMonthSource) {
+ blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month');
+ }
+ updatedEl.textContent = errorText;
+ setStatus('Unavailable', 'var(--theme-status-danger)');
+ updateLiveContent({
+ kind: 'litellm_ups_usage',
+ subtitle: subtitleEl.textContent || null,
+ status: 'Unavailable',
+ updated_at: errorText,
+ last_24h: null,
+ this_month: null,
+ error: errorText,
+ });
+ }
+ };
+
+ void refresh();
+ __setInterval(() => { void refresh(); }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/litellm-ups-usage-live/template.html b/examples/cards/templates/litellm-ups-usage-live/template.html
index 2087e92..0df43de 100644
--- a/examples/cards/templates/litellm-ups-usage-live/template.html
+++ b/examples/cards/templates/litellm-ups-usage-live/template.html
@@ -1,282 +1,30 @@
-
+
-
Loading…
-
Loading…
+
Loading…
+
Loading…
- Last 24h
+ Last 24h
--
- tokens
+ tokens
- --
- --
+ --
+ --
- This Month
+ This Month
--
- tokens
+ tokens
- --
- --
+ --
+ --
-
Updated --
+
Updated --
-
diff --git a/examples/cards/templates/live-bedroom-co2/card.js b/examples/cards/templates/live-bedroom-co2/card.js
new file mode 100644
index 0000000..bcef002
--- /dev/null
+++ b/examples/cards/templates/live-bedroom-co2/card.js
@@ -0,0 +1,218 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const valueEl = root.querySelector("[data-co2-value]");
+ const statusEl = root.querySelector("[data-co2-status]");
+ const updatedEl = root.querySelector("[data-co2-updated]");
+ if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
+
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
+ const INTERVAL_RAW = Number(state.refresh_ms);
+ const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
+
+ const setStatus = (label, color) => {
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const setUpdatedNow = () => {
+ updatedEl.textContent = new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ };
+
+ const parseValue = (raw) => {
+ const n = Number(raw);
+ return Number.isFinite(n) ? Math.round(n) : null;
+ };
+
+ const computeScore = (value) => {
+ if (!Number.isFinite(value)) return 0;
+ if (value >= 1600) return 96;
+ if (value >= 1400) return 90;
+ if (value >= 1200) return 82;
+ if (value >= 1000) return 68;
+ if (value >= 900) return 54;
+ if (value >= 750) return 34;
+ return 16;
+ };
+
+ const stripQuotes = (value) => {
+ const text = String(value ?? '').trim();
+ if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
+ return text.slice(1, -1);
+ }
+ return text;
+ };
+
+ const normalizeText = (value) => String(value || '').trim().toLowerCase();
+
+ const parseLiveContextEntries = (payloadText) => {
+ const text = String(payloadText || '').replace(/\r/g, '');
+ const startIndex = text.indexOf('- names: ');
+ const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
+ const entries = [];
+ let current = null;
+ let inAttributes = false;
+
+ const pushCurrent = () => {
+ if (current) entries.push(current);
+ current = null;
+ inAttributes = false;
+ };
+
+ for (const rawLine of relevant.split('\n')) {
+ if (rawLine.startsWith('- names: ')) {
+ pushCurrent();
+ current = {
+ name: stripQuotes(rawLine.slice(9)),
+ domain: '',
+ state: '',
+ attributes: {},
+ };
+ continue;
+ }
+ if (!current) continue;
+ const trimmed = rawLine.trim();
+ if (!trimmed) continue;
+ if (trimmed === 'attributes:') {
+ inAttributes = true;
+ continue;
+ }
+ if (rawLine.startsWith(' domain:')) {
+ current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' state:')) {
+ current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (inAttributes && rawLine.startsWith(' ')) {
+ const separatorIndex = rawLine.indexOf(':');
+ if (separatorIndex >= 0) {
+ const key = rawLine.slice(4, separatorIndex).trim();
+ const value = stripQuotes(rawLine.slice(separatorIndex + 1));
+ current.attributes[key] = value;
+ }
+ continue;
+ }
+ inAttributes = false;
+ }
+
+ pushCurrent();
+ return entries;
+ };
+
+ const extractLiveContextText = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (typeof parsed.result === 'string') return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
+ return parsed.result;
+ }
+ } catch {
+ return toolResult.content;
+ }
+ return toolResult.content;
+ }
+ return '';
+ };
+
+ const resolveToolName = async () => {
+ if (configuredToolName) return configuredToolName;
+ if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
+ try {
+ const tools = await host.listTools();
+ const liveContextTool = Array.isArray(tools)
+ ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
+ : null;
+ return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
+ } catch {
+ return 'mcp_home_assistant_GetLiveContext';
+ }
+ };
+
+ const refresh = async () => {
+ setStatus("Refreshing", "var(--theme-status-muted)");
+ try {
+ const toolName = await resolveToolName();
+ const toolResult = await host.callTool(toolName, {});
+ const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
+ const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
+ if (!entry) throw new Error(`Missing sensor ${matchName}`);
+ const value = parseValue(entry.state);
+ if (value === null) throw new Error("Invalid sensor payload");
+
+ valueEl.textContent = String(value);
+ if (value >= 1200) setStatus("High", "var(--theme-status-danger)");
+ else if (value >= 900) setStatus("Elevated", "var(--theme-status-warning)");
+ else setStatus("Good", "var(--theme-status-live)");
+ setUpdatedNow();
+ host.setLiveContent({
+ kind: 'sensor',
+ tool_name: toolName,
+ match_name: entry.name,
+ value,
+ display_value: String(value),
+ unit: entry.attributes?.unit_of_measurement || 'ppm',
+ status: statusEl.textContent || null,
+ updated_at: updatedEl.textContent || null,
+ score: computeScore(value),
+ });
+ } catch (err) {
+ valueEl.textContent = "--";
+ setStatus("Unavailable", "var(--theme-status-danger)");
+ updatedEl.textContent = String(err);
+ host.setLiveContent({
+ kind: 'sensor',
+ tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
+ match_name: matchName,
+ value: null,
+ display_value: '--',
+ unit: 'ppm',
+ status: 'Unavailable',
+ updated_at: String(err),
+ error: String(err),
+ score: 0,
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => {
+ void refresh();
+ }, INTERVAL_MS);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/live-bedroom-co2/template.html b/examples/cards/templates/live-bedroom-co2/template.html
index 3092b1b..3aeefe7 100644
--- a/examples/cards/templates/live-bedroom-co2/template.html
+++ b/examples/cards/templates/live-bedroom-co2/template.html
@@ -1,204 +1,15 @@
-
+
-
Bedroom CO2
- Loading...
+ Bedroom CO2
+ Loading...
--
- ppm
+ ppm
-
-
diff --git a/examples/cards/templates/live-calendar-today/card.js b/examples/cards/templates/live-calendar-today/card.js
new file mode 100644
index 0000000..038df2b
--- /dev/null
+++ b/examples/cards/templates/live-calendar-today/card.js
@@ -0,0 +1,266 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const statusEl = root.querySelector("[data-cal-status]");
+ const subtitleEl = root.querySelector("[data-cal-subtitle]");
+ const dateEl = root.querySelector("[data-cal-date]");
+ const listEl = root.querySelector("[data-cal-list]");
+ const emptyEl = root.querySelector("[data-cal-empty]");
+ const updatedEl = root.querySelector("[data-cal-updated]");
+
+ if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) ||
+ !(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const configuredCalendarNames = Array.isArray(state.calendar_names)
+ ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const refreshMsRaw = Number(state.refresh_ms);
+ const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+
+ const setStatus = (label, color) => {
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const dayBounds = () => {
+ const now = new Date();
+ const start = new Date(now);
+ start.setHours(0, 0, 0, 0);
+ const end = new Date(now);
+ end.setHours(23, 59, 59, 999);
+ return { start, end };
+ };
+
+ const formatDateHeader = () => {
+ const now = new Date();
+ return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" });
+ };
+
+ const normalizeDateValue = (value) => {
+ if (typeof value === "string") return value;
+ if (value && typeof value === "object") {
+ if (typeof value.dateTime === "string") return value.dateTime;
+ if (typeof value.date === "string") return value.date;
+ }
+ return "";
+ };
+
+ const formatTime = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return "--:--";
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day";
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) return "--:--";
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ };
+
+ const isAllDay = (start, end) => {
+ const s = normalizeDateValue(start);
+ const e = normalizeDateValue(end);
+ return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e);
+ };
+
+ const eventSortKey = (evt) => {
+ const raw = normalizeDateValue(evt && evt.start);
+ const t = new Date(raw).getTime();
+ return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER;
+ };
+
+ const extractEvents = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (Array.isArray(parsed.result)) return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
+ return parsed.result;
+ }
+ } catch {
+ return [];
+ }
+ }
+ return [];
+ };
+
+ const resolveToolConfig = async () => {
+ const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
+ if (!host.listTools) {
+ return { name: fallbackName, calendars: configuredCalendarNames };
+ }
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
+ : null;
+ const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
+ ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ return {
+ name: tool?.name || fallbackName,
+ calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues,
+ };
+ } catch {
+ return { name: fallbackName, calendars: configuredCalendarNames };
+ }
+ };
+
+ const renderEvents = (events) => {
+ listEl.innerHTML = "";
+
+ if (!Array.isArray(events) || events.length === 0) {
+ emptyEl.style.display = "block";
+ return;
+ }
+
+ emptyEl.style.display = "none";
+
+ for (const evt of events) {
+ const li = document.createElement("li");
+ li.style.border = "1px solid var(--theme-card-neutral-border)";
+ li.style.borderRadius = "10px";
+ li.style.padding = "10px 12px";
+ li.style.background = "#ffffff";
+
+ const summary = document.createElement("div");
+ summary.style.fontSize = "0.96rem";
+ summary.style.fontWeight = "600";
+ summary.style.color = "var(--theme-card-neutral-text)";
+ summary.textContent = String(evt.summary || "(No title)");
+
+ const timing = document.createElement("div");
+ timing.style.marginTop = "4px";
+ timing.style.fontSize = "0.86rem";
+ timing.style.color = "var(--theme-card-neutral-subtle)";
+
+ if (isAllDay(evt.start, evt.end)) {
+ timing.textContent = "All day";
+ } else {
+ timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`;
+ }
+
+ li.appendChild(summary);
+ li.appendChild(timing);
+
+ if (evt.location) {
+ const location = document.createElement("div");
+ location.style.marginTop = "4px";
+ location.style.fontSize = "0.84rem";
+ location.style.color = "var(--theme-card-neutral-muted)";
+ location.textContent = `Location: ${String(evt.location)}`;
+ li.appendChild(location);
+ }
+
+ listEl.appendChild(li);
+ }
+ };
+
+ const refresh = async () => {
+ dateEl.textContent = formatDateHeader();
+ setStatus("Refreshing", "var(--theme-status-muted)");
+
+ try {
+ const toolConfig = await resolveToolConfig();
+ if (!toolConfig.name) throw new Error('Calendar tool unavailable');
+ if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) {
+ subtitleEl.textContent = "No Home Assistant calendars available";
+ renderEvents([]);
+ updatedEl.textContent = new Date().toLocaleTimeString();
+ setStatus("OK", "var(--theme-status-live)");
+ updateLiveContent({
+ kind: 'calendar_today',
+ tool_name: toolConfig.name,
+ calendar_names: [],
+ updated_at: updatedEl.textContent || null,
+ event_count: 0,
+ events: [],
+ });
+ return;
+ }
+
+ subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`;
+
+ const allEvents = [];
+ for (const calendarName of toolConfig.calendars) {
+ const toolResult = await host.callTool(toolConfig.name, {
+ calendar: calendarName,
+ range: 'today',
+ });
+ const events = extractEvents(toolResult);
+ for (const evt of events) {
+ allEvents.push({ ...evt, _calendarName: calendarName });
+ }
+ }
+
+ allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b));
+ renderEvents(allEvents);
+ updatedEl.textContent = new Date().toLocaleTimeString();
+ setStatus("Daily", "var(--theme-status-live)");
+ updateLiveContent({
+ kind: 'calendar_today',
+ tool_name: toolConfig.name,
+ calendar_names: toolConfig.calendars,
+ updated_at: updatedEl.textContent || null,
+ event_count: allEvents.length,
+ events: allEvents.map((evt) => ({
+ summary: String(evt.summary || '(No title)'),
+ start: normalizeDateValue(evt.start) || null,
+ end: normalizeDateValue(evt.end) || null,
+ location: typeof evt.location === 'string' ? evt.location : null,
+ calendar_name: evt._calendarName || null,
+ })),
+ });
+ } catch (err) {
+ subtitleEl.textContent = "Could not load Home Assistant calendar";
+ renderEvents([]);
+ updatedEl.textContent = String(err);
+ setStatus("Unavailable", "var(--theme-status-danger)");
+ updateLiveContent({
+ kind: 'calendar_today',
+ tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
+ calendar_names: configuredCalendarNames,
+ updated_at: String(err),
+ event_count: 0,
+ events: [],
+ error: String(err),
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => {
+ void refresh();
+ }, REFRESH_MS);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/live-calendar-today/template.html b/examples/cards/templates/live-calendar-today/template.html
index 501814b..6ba2c87 100644
--- a/examples/cards/templates/live-calendar-today/template.html
+++ b/examples/cards/templates/live-calendar-today/template.html
@@ -1,273 +1,23 @@
-
+
-
Today's Calendar
-
Loading calendars...
+
Today's Calendar
+
Loading calendars...
-
Loading...
+
Loading...
-
- Date:
--
+
+ Date: --
-
+
No events for today.
-
-
diff --git a/examples/cards/templates/live-weather-01545/card.js b/examples/cards/templates/live-weather-01545/card.js
new file mode 100644
index 0000000..26a4668
--- /dev/null
+++ b/examples/cards/templates/live-weather-01545/card.js
@@ -0,0 +1,243 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const tempEl = root.querySelector("[data-weather-temp]");
+ const unitEl = root.querySelector("[data-weather-unit]");
+ const condEl = root.querySelector("[data-weather-condition]");
+ const humidityEl = root.querySelector("[data-weather-humidity]");
+ const windEl = root.querySelector("[data-weather-wind]");
+ const pressureEl = root.querySelector("[data-weather-pressure]");
+ const updatedEl = root.querySelector("[data-weather-updated]");
+ const statusEl = root.querySelector("[data-weather-status]");
+
+ if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
+ !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
+ !(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
+ return;
+ }
+
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
+ const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
+ const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
+ const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
+ const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
+ const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
+ const refreshMsRaw = Number(state.refresh_ms);
+ const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
+
+ const setStatus = (label, color) => {
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const nowLabel = () => new Date().toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+
+ const stripQuotes = (value) => {
+ const text = String(value ?? '').trim();
+ if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
+ return text.slice(1, -1);
+ }
+ return text;
+ };
+
+ const normalizeText = (value) => String(value || '').trim().toLowerCase();
+
+ const parseLiveContextEntries = (payloadText) => {
+ const text = String(payloadText || '').replace(/\r/g, '');
+ const startIndex = text.indexOf('- names: ');
+ const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
+ const entries = [];
+ let current = null;
+ let inAttributes = false;
+
+ const pushCurrent = () => {
+ if (current) entries.push(current);
+ current = null;
+ inAttributes = false;
+ };
+
+ for (const rawLine of relevant.split('\n')) {
+ if (rawLine.startsWith('- names: ')) {
+ pushCurrent();
+ current = {
+ name: stripQuotes(rawLine.slice(9)),
+ domain: '',
+ state: '',
+ attributes: {},
+ };
+ continue;
+ }
+ if (!current) continue;
+ const trimmed = rawLine.trim();
+ if (!trimmed) continue;
+ if (trimmed === 'attributes:') {
+ inAttributes = true;
+ continue;
+ }
+ if (rawLine.startsWith(' domain:')) {
+ current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' state:')) {
+ current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (inAttributes && rawLine.startsWith(' ')) {
+ const separatorIndex = rawLine.indexOf(':');
+ if (separatorIndex >= 0) {
+ const key = rawLine.slice(4, separatorIndex).trim();
+ const value = stripQuotes(rawLine.slice(separatorIndex + 1));
+ current.attributes[key] = value;
+ }
+ continue;
+ }
+ inAttributes = false;
+ }
+
+ pushCurrent();
+ return entries;
+ };
+
+ const extractLiveContextText = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (typeof parsed.result === 'string') return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
+ return parsed.result;
+ }
+ } catch {
+ return toolResult.content;
+ }
+ return toolResult.content;
+ }
+ return '';
+ };
+
+ const resolveToolName = async () => {
+ if (configuredToolName) return configuredToolName;
+ if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
+ try {
+ const tools = await host.listTools();
+ const liveContextTool = Array.isArray(tools)
+ ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
+ : null;
+ return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
+ } catch {
+ return 'mcp_home_assistant_GetLiveContext';
+ }
+ };
+
+ const findEntry = (entries, candidates) => {
+ const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
+ if (normalizedCandidates.length === 0) return null;
+ const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
+ if (exactMatch) return exactMatch;
+ return entries.find((entry) => {
+ const entryName = normalizeText(entry.name);
+ return normalizedCandidates.some((candidate) => entryName.includes(candidate));
+ }) || null;
+ };
+
+ const refresh = async () => {
+ setStatus("Refreshing", "var(--theme-status-muted)");
+ try {
+ const toolName = await resolveToolName();
+ const toolResult = await host.callTool(toolName, {});
+ const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
+ const temperatureEntry = findEntry(entries, [temperatureName]);
+ const humidityEntry = findEntry(entries, [humidityName]);
+ const pressureEntry = findEntry(entries, [pressureName]);
+ const windEntry = findEntry(entries, [windName]);
+
+ const tempNum = Number(temperatureEntry?.state);
+ tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
+ unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
+ condEl.textContent = conditionLabel;
+
+ const humidity = Number(humidityEntry?.state);
+ humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
+
+ const windSpeed = Number(windEntry?.state);
+ const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
+ windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
+
+ const pressureNum = Number(pressureEntry?.state);
+ pressureEl.textContent = Number.isFinite(pressureNum)
+ ? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
+ : "--";
+
+ updatedEl.textContent = nowLabel();
+ setStatus("Live", "var(--theme-status-live)");
+ host.setLiveContent({
+ kind: 'weather',
+ tool_name: toolName,
+ provider_prefix: providerPrefix,
+ temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
+ temperature_unit: unitEl.textContent || null,
+ humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
+ wind: windEl.textContent || null,
+ pressure: pressureEl.textContent || null,
+ condition: condEl.textContent || null,
+ updated_at: updatedEl.textContent || null,
+ status: statusEl.textContent || null,
+ });
+ } catch (err) {
+ setStatus("Unavailable", "var(--theme-status-danger)");
+ updatedEl.textContent = String(err);
+ host.setLiveContent({
+ kind: 'weather',
+ tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
+ provider_prefix: providerPrefix,
+ temperature: null,
+ humidity: null,
+ wind: null,
+ pressure: null,
+ condition: null,
+ updated_at: String(err),
+ status: 'Unavailable',
+ error: String(err),
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => {
+ void refresh();
+ }, REFRESH_MS);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/live-weather-01545/template.html b/examples/cards/templates/live-weather-01545/template.html
index 51c5774..2249cca 100644
--- a/examples/cards/templates/live-weather-01545/template.html
+++ b/examples/cards/templates/live-weather-01545/template.html
@@ -1,250 +1,23 @@
-
+
-
Weather 01545
-
Source: Home Assistant (weather.openweathermap)
+
Weather 01545
+
Source: Home Assistant (weather.openweathermap)
-
Loading...
+
Loading...
--
- °F
+ °F
-
--
+
--
-
+
Humidity: --
Wind: --
Pressure: --
Updated: --
-
diff --git a/examples/cards/templates/sensor-live/card.js b/examples/cards/templates/sensor-live/card.js
new file mode 100644
index 0000000..633ab5a
--- /dev/null
+++ b/examples/cards/templates/sensor-live/card.js
@@ -0,0 +1,309 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const subtitleEl = root.querySelector('[data-sensor-subtitle]');
+ const valueEl = root.querySelector('[data-sensor-value]');
+ const unitEl = root.querySelector('[data-sensor-unit]');
+ const statusEl = root.querySelector('[data-sensor-status]');
+ const updatedEl = root.querySelector('[data-sensor-updated]');
+ if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
+
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
+ const matchNames = Array.isArray(state.match_names)
+ ? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const searchTerms = Array.isArray(state.search_terms)
+ ? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
+ const decimalsRaw = Number(state.value_decimals);
+ const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
+ const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
+ const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
+ const goodMax = Number(thresholds.good_max);
+ const elevatedMax = Number(thresholds.elevated_max);
+ const alertOnly = state.alert_only === true;
+ const elevatedScoreRaw = Number(state.alert_score_elevated);
+ const highScoreRaw = Number(state.alert_score_high);
+ const elevatedScore = Number.isFinite(elevatedScoreRaw) ? elevatedScoreRaw : 88;
+ const highScore = Number.isFinite(highScoreRaw) ? highScoreRaw : 98;
+
+ subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
+ unitEl.textContent = fallbackUnit || '--';
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const setStatus = (label, color) => {
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const renderValue = (value) => {
+ if (!Number.isFinite(value)) return '--';
+ return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
+ };
+
+ const stripQuotes = (value) => {
+ const text = String(value ?? '').trim();
+ if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
+ return text.slice(1, -1);
+ }
+ return text;
+ };
+
+ const normalizeText = (value) => String(value || '').trim().toLowerCase();
+
+ const parseLiveContextEntries = (payloadText) => {
+ const text = String(payloadText || '').replace(/\r/g, '');
+ const startIndex = text.indexOf('- names: ');
+ const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
+ const entries = [];
+ let current = null;
+ let inAttributes = false;
+
+ const pushCurrent = () => {
+ if (current) entries.push(current);
+ current = null;
+ inAttributes = false;
+ };
+
+ for (const rawLine of relevant.split('\n')) {
+ if (rawLine.startsWith('- names: ')) {
+ pushCurrent();
+ current = {
+ name: stripQuotes(rawLine.slice(9)),
+ domain: '',
+ state: '',
+ areas: '',
+ attributes: {},
+ };
+ continue;
+ }
+ if (!current) continue;
+ const trimmed = rawLine.trim();
+ if (!trimmed) continue;
+ if (trimmed === 'attributes:') {
+ inAttributes = true;
+ continue;
+ }
+ if (rawLine.startsWith(' domain:')) {
+ current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' state:')) {
+ current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' areas:')) {
+ current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (inAttributes && rawLine.startsWith(' ')) {
+ const separatorIndex = rawLine.indexOf(':');
+ if (separatorIndex >= 0) {
+ const key = rawLine.slice(4, separatorIndex).trim();
+ const value = stripQuotes(rawLine.slice(separatorIndex + 1));
+ current.attributes[key] = value;
+ }
+ continue;
+ }
+ inAttributes = false;
+ }
+
+ pushCurrent();
+ return entries;
+ };
+
+ const extractLiveContextText = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (typeof parsed.result === 'string') return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
+ return parsed.result;
+ }
+ } catch {
+ return toolResult.content;
+ }
+ return toolResult.content;
+ }
+ return '';
+ };
+
+ const allMatchNames = [matchName, ...matchNames].filter(Boolean);
+ const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
+
+ const scoreEntry = (entry) => {
+ if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
+ const entryName = normalizeText(entry.name);
+ let score = 0;
+ for (const candidate of allMatchNames) {
+ const normalized = normalizeText(candidate);
+ if (!normalized) continue;
+ if (entryName === normalized) score += 100;
+ else if (entryName.includes(normalized)) score += 40;
+ }
+ for (const term of allSearchTerms) {
+ const normalized = normalizeText(term);
+ if (!normalized) continue;
+ if (entryName.includes(normalized)) score += 10;
+ }
+ const entryDeviceClass = normalizeText(entry.attributes?.device_class);
+ if (deviceClass && entryDeviceClass === deviceClass) score += 30;
+ if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
+ return score;
+ };
+
+ const findSensorEntry = (entries) => {
+ const scored = entries
+ .map((entry) => ({ entry, score: scoreEntry(entry) }))
+ .filter((item) => Number.isFinite(item.score) && item.score > 0)
+ .sort((left, right) => right.score - left.score);
+ return scored.length > 0 ? scored[0].entry : null;
+ };
+
+ const resolveToolName = async () => {
+ if (configuredToolName) return configuredToolName;
+ if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
+ try {
+ const tools = await host.listTools();
+ const liveContextTool = Array.isArray(tools)
+ ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
+ : null;
+ return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
+ } catch {
+ return 'mcp_home_assistant_GetLiveContext';
+ }
+ };
+
+ const classify = (value) => {
+ if (!Number.isFinite(value)) return { label: 'Unavailable', color: 'var(--theme-status-danger)' };
+ if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: 'var(--theme-status-danger)' };
+ if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: 'var(--theme-status-warning)' };
+ return { label: 'Good', color: 'var(--theme-status-live)' };
+ };
+
+ const computeAlertVisibility = (statusLabel) => {
+ if (!alertOnly) return false;
+ return statusLabel === 'Good' || statusLabel === 'Unavailable';
+ };
+
+ const computeAlertScore = (statusLabel, value) => {
+ if (!alertOnly) return Number.isFinite(value) ? 0 : null;
+ if (statusLabel === 'High') return highScore;
+ if (statusLabel === 'Elevated') return elevatedScore;
+ return 0;
+ };
+
+ const refresh = async () => {
+ const resolvedToolName = await resolveToolName();
+ if (!resolvedToolName) {
+ const errorText = 'Missing tool_name';
+ valueEl.textContent = '--';
+ setStatus('No tool', 'var(--theme-status-danger)');
+ updatedEl.textContent = errorText;
+ updateLiveContent({
+ kind: 'sensor',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: null,
+ match_name: matchName || matchNames[0] || null,
+ value: null,
+ display_value: '--',
+ unit: fallbackUnit || null,
+ status: 'No tool',
+ updated_at: errorText,
+ error: errorText,
+ });
+ return;
+ }
+
+ setStatus('Refreshing', 'var(--theme-status-muted)');
+ try {
+ const toolResult = await host.callTool(resolvedToolName, {});
+ const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
+ const entry = findSensorEntry(entries);
+ if (!entry) throw new Error('Matching sensor not found in live context');
+ const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
+ const numericValue = Number(entry.state);
+ const renderedValue = renderValue(numericValue);
+ valueEl.textContent = renderedValue;
+ const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
+ unitEl.textContent = unit;
+ subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
+ const status = classify(numericValue);
+ setStatus(status.label, status.color);
+ const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ updatedEl.textContent = updatedText;
+ updateLiveContent({
+ kind: 'sensor',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: resolvedToolName,
+ match_name: entry.name || matchName || matchNames[0] || null,
+ value: Number.isFinite(numericValue) ? numericValue : null,
+ display_value: renderedValue,
+ unit,
+ status: status.label,
+ updated_at: updatedText,
+ score: computeAlertScore(status.label, numericValue),
+ hidden: computeAlertVisibility(status.label),
+ });
+ } catch (error) {
+ const errorText = String(error);
+ valueEl.textContent = '--';
+ setStatus('Unavailable', 'var(--theme-status-danger)');
+ updatedEl.textContent = errorText;
+ updateLiveContent({
+ kind: 'sensor',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: resolvedToolName,
+ match_name: matchName || matchNames[0] || null,
+ value: null,
+ display_value: '--',
+ unit: fallbackUnit || null,
+ status: 'Unavailable',
+ updated_at: errorText,
+ error: errorText,
+ score: alertOnly ? 0 : null,
+ hidden: alertOnly,
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => { void refresh(); }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/sensor-live/manifest.json b/examples/cards/templates/sensor-live/manifest.json
index 5a8a396..84f9640 100644
--- a/examples/cards/templates/sensor-live/manifest.json
+++ b/examples/cards/templates/sensor-live/manifest.json
@@ -1,7 +1,7 @@
{
"key": "sensor-live",
"title": "Live Sensor",
- "notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. The card title comes from the feed header, not the template body.",
+ "notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. Set alert_only=true to hide the card while readings are good and surface it only when elevated/high, with optional alert_score_elevated/alert_score_high overrides. The card title comes from the feed header, not the template body.",
"example_state": {
"subtitle": "Home Assistant sensor",
"tool_name": "mcp_home_assistant_GetLiveContext",
@@ -13,7 +13,8 @@
"thresholds": {
"good_max": 900,
"elevated_max": 1200
- }
+ },
+ "alert_only": false
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
"updated_at": "2026-03-11T19:18:04.632189+00:00"
diff --git a/examples/cards/templates/sensor-live/template.html b/examples/cards/templates/sensor-live/template.html
index 72c4788..5de02b2 100644
--- a/examples/cards/templates/sensor-live/template.html
+++ b/examples/cards/templates/sensor-live/template.html
@@ -1,287 +1,15 @@
-
+
-
Loading…
-
Loading…
+
Loading…
+
Loading…
--
- --
+ --
-
-
diff --git a/examples/cards/templates/today-briefing-live/card.js b/examples/cards/templates/today-briefing-live/card.js
new file mode 100644
index 0000000..cbcc349
--- /dev/null
+++ b/examples/cards/templates/today-briefing-live/card.js
@@ -0,0 +1,494 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const dateEl = root.querySelector('[data-today-date]');
+ const tempEl = root.querySelector('[data-today-temp]');
+ const unitEl = root.querySelector('[data-today-unit]');
+ const feelsLikeEl = root.querySelector('[data-today-feels-like]');
+ const aqiChipEl = root.querySelector('[data-today-aqi-chip]');
+ const countEl = root.querySelector('[data-today-events-count]');
+ const emptyEl = root.querySelector('[data-today-empty]');
+ const listEl = root.querySelector('[data-today-events-list]');
+ const updatedEl = root.querySelector('[data-today-updated]');
+ if (!(dateEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(feelsLikeEl instanceof HTMLElement) || !(aqiChipEl instanceof HTMLElement) || !(countEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
+
+ const configuredWeatherToolName = typeof state.weather_tool_name === 'string' ? state.weather_tool_name.trim() : '';
+ const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : '';
+ const calendarNames = Array.isArray(state.calendar_names)
+ ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const weatherPrefix = typeof state.weather_prefix === 'string' ? state.weather_prefix.trim() : 'OpenWeatherMap';
+ const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
+ const apparentTemperatureName = typeof state.apparent_temperature_name === 'string' ? state.apparent_temperature_name.trim() : '';
+ const aqiName = typeof state.aqi_name === 'string' ? state.aqi_name.trim() : '';
+ const maxEventsRaw = Number(state.max_events);
+ const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 6;
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+ const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'Nothing scheduled today.';
+
+ emptyEl.textContent = emptyText;
+
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const stripQuotes = (value) => {
+ const text = String(value ?? '').trim();
+ if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
+ return text.slice(1, -1);
+ }
+ return text;
+ };
+
+ const normalizeText = (value) => String(value || '').trim().toLowerCase();
+
+ const parseLiveContextEntries = (payloadText) => {
+ const text = String(payloadText || '').replace(/\r/g, '');
+ const startIndex = text.indexOf('- names: ');
+ const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
+ const entries = [];
+ let current = null;
+ let inAttributes = false;
+
+ const pushCurrent = () => {
+ if (current) entries.push(current);
+ current = null;
+ inAttributes = false;
+ };
+
+ for (const rawLine of relevant.split('\n')) {
+ if (rawLine.startsWith('- names: ')) {
+ pushCurrent();
+ current = {
+ name: stripQuotes(rawLine.slice(9)),
+ domain: '',
+ state: '',
+ areas: '',
+ attributes: {},
+ };
+ continue;
+ }
+ if (!current) continue;
+ const trimmed = rawLine.trim();
+ if (!trimmed) continue;
+ if (trimmed === 'attributes:') {
+ inAttributes = true;
+ continue;
+ }
+ if (rawLine.startsWith(' domain:')) {
+ current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' state:')) {
+ current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' areas:')) {
+ current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (inAttributes && rawLine.startsWith(' ')) {
+ const separatorIndex = rawLine.indexOf(':');
+ if (separatorIndex >= 0) {
+ const key = rawLine.slice(4, separatorIndex).trim();
+ const value = stripQuotes(rawLine.slice(separatorIndex + 1));
+ current.attributes[key] = value;
+ }
+ continue;
+ }
+ inAttributes = false;
+ }
+
+ pushCurrent();
+ return entries;
+ };
+
+ const extractLiveContextText = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (typeof parsed.result === 'string') return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
+ return parsed.result;
+ }
+ } catch {
+ return toolResult.content;
+ }
+ return toolResult.content;
+ }
+ return '';
+ };
+
+ const normalizeDateValue = (value) => {
+ if (typeof value === 'string') return value;
+ if (value && typeof value === 'object') {
+ if (typeof value.dateTime === 'string') return value.dateTime;
+ if (typeof value.date === 'string') return value.date;
+ }
+ return '';
+ };
+
+ const formatHeaderDate = (value) => value.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
+ const formatTime = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return '--:--';
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
+ const date = new Date(raw);
+ if (Number.isNaN(date.getTime())) return '--:--';
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ };
+ const formatEventDay = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return '--';
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
+ if (Number.isNaN(date.getTime())) return '--';
+ return date.toLocaleDateString([], { weekday: 'short' });
+ };
+ const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
+
+ const extractEvents = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (Array.isArray(parsed.result)) return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
+ return parsed.result;
+ }
+ } catch {
+ return [];
+ }
+ }
+ return [];
+ };
+
+ const eventTime = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return Number.MAX_SAFE_INTEGER;
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
+ const time = new Date(normalized).getTime();
+ return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
+ };
+
+ const findEntry = (entries, candidates) => {
+ const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
+ if (normalizedCandidates.length === 0) return null;
+ const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
+ if (exactMatch) return exactMatch;
+ return entries.find((entry) => {
+ const entryName = normalizeText(entry.name);
+ return normalizedCandidates.some((candidate) => entryName.includes(candidate));
+ }) || null;
+ };
+
+ const findByDeviceClass = (entries, deviceClass) => entries.find((entry) => normalizeText(entry.attributes?.device_class) === normalizeText(deviceClass)) || null;
+ const parseNumericValue = (entry) => {
+ const value = Number(entry?.state);
+ return Number.isFinite(value) ? value : null;
+ };
+ const formatMetric = (value, unit) => {
+ if (!Number.isFinite(value)) return '--';
+ return `${Math.round(value)}${unit ? ` ${unit}` : ''}`;
+ };
+
+ const buildAqiStyle = (aqiValue) => {
+ if (!Number.isFinite(aqiValue)) {
+ return { label: 'AQI --', tone: 'Unavailable', background: 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)', color: 'var(--theme-card-neutral-subtle)' };
+ }
+ if (aqiValue <= 50) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Good', background: 'color-mix(in srgb, var(--theme-status-live) 16%, white)', color: 'var(--theme-status-live)' };
+ if (aqiValue <= 100) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Moderate', background: 'color-mix(in srgb, var(--theme-status-warning) 16%, white)', color: 'var(--theme-status-warning)' };
+ if (aqiValue <= 150) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Sensitive', background: 'color-mix(in srgb, var(--theme-status-warning) 24%, white)', color: 'var(--theme-status-warning)' };
+ if (aqiValue <= 200) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Unhealthy', background: 'color-mix(in srgb, var(--theme-status-danger) 14%, white)', color: 'var(--theme-status-danger)' };
+ if (aqiValue <= 300) return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Very unhealthy', background: 'color-mix(in srgb, var(--theme-accent) 14%, white)', color: 'var(--theme-accent-strong)' };
+ return { label: `AQI ${Math.round(aqiValue)}`, tone: 'Hazardous', background: 'color-mix(in srgb, var(--theme-accent-strong) 18%, white)', color: 'var(--theme-accent-strong)' };
+ };
+
+ const startTime = (value) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return Number.NaN;
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T12:00:00` : raw;
+ const time = new Date(normalized).getTime();
+ return Number.isFinite(time) ? time : Number.NaN;
+ };
+
+ const computeTodayScore = (events, status) => {
+ if (status === 'Unavailable') return 5;
+ if (!Array.isArray(events) || events.length === 0) {
+ return status === 'Partial' ? 18 : 24;
+ }
+ const now = Date.now();
+ const soonestMs = events
+ .map((event) => startTime(event?.start))
+ .filter((time) => Number.isFinite(time) && time >= now)
+ .sort((left, right) => left - right)[0];
+ const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null;
+ let score = 34;
+ if (soonestHours !== null) {
+ if (soonestHours <= 1) score = 80;
+ else if (soonestHours <= 3) score = 70;
+ else if (soonestHours <= 8) score = 58;
+ else if (soonestHours <= 24) score = 46;
+ }
+ score += Math.min(events.length, 3) * 3;
+ if (status === 'Partial') score -= 8;
+ return Math.max(0, Math.min(100, Math.round(score)));
+ };
+
+ const renderEvents = (events) => {
+ listEl.innerHTML = '';
+ if (!Array.isArray(events) || events.length === 0) {
+ emptyEl.style.display = 'block';
+ countEl.textContent = 'No events';
+ return;
+ }
+ emptyEl.style.display = 'none';
+ countEl.textContent = `${events.length} ${events.length === 1 ? 'event' : 'events'}`;
+
+ for (const [index, event] of events.slice(0, maxEvents).entries()) {
+ const item = document.createElement('li');
+ item.style.padding = index === 0 ? '10px 0 0' : '10px 0 0';
+ item.style.borderTop = '1px solid var(--theme-card-neutral-border)';
+
+ const timing = document.createElement('div');
+ timing.style.fontSize = '0.76rem';
+ timing.style.lineHeight = '1.2';
+ timing.style.textTransform = 'uppercase';
+ timing.style.letterSpacing = '0.05em';
+ timing.style.color = 'var(--theme-card-neutral-muted)';
+ timing.style.fontWeight = '700';
+ const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatEventDay(event.start)} · ${formatTime(event.start)}`;
+ timing.textContent = timeLabel;
+ item.appendChild(timing);
+
+ const summary = document.createElement('div');
+ summary.style.marginTop = '4px';
+ summary.style.fontSize = '0.95rem';
+ summary.style.lineHeight = '1.35';
+ summary.style.color = 'var(--theme-card-neutral-text)';
+ summary.style.fontWeight = '700';
+ summary.textContent = String(event.summary || '(No title)');
+ item.appendChild(summary);
+
+ listEl.appendChild(item);
+ }
+ };
+
+ const resolveToolName = async (configuredName, pattern, fallbackName) => {
+ if (configuredName) return configuredName;
+ if (!host.listTools) return fallbackName;
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => pattern.test(String(item?.name || '')))
+ : null;
+ return tool?.name || fallbackName;
+ } catch {
+ return fallbackName;
+ }
+ };
+
+ const resolveCalendarNames = async (toolName) => {
+ if (calendarNames.length > 0) return calendarNames;
+ if (!host.listTools) return calendarNames;
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => String(item?.name || '') === toolName)
+ : null;
+ const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
+ ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ return enumValues;
+ } catch {
+ return calendarNames;
+ }
+ };
+
+ const loadWeather = async (toolName) => {
+ const toolResult = await host.callTool(toolName, {});
+ const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
+ const temperatureEntry = findEntry(entries, [
+ temperatureName,
+ `${weatherPrefix} Temperature`,
+ ]) || findByDeviceClass(entries, 'temperature');
+ const apparentEntry = findEntry(entries, [
+ apparentTemperatureName,
+ `${weatherPrefix} Apparent temperature`,
+ `${weatherPrefix} Feels like`,
+ ]);
+ const aqiEntry = findEntry(entries, [
+ aqiName,
+ 'Air quality index',
+ ]) || findByDeviceClass(entries, 'aqi');
+
+ return {
+ toolName,
+ temperature: parseNumericValue(temperatureEntry),
+ temperatureUnit: String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'),
+ feelsLike: parseNumericValue(apparentEntry),
+ feelsLikeUnit: String(apparentEntry?.attributes?.unit_of_measurement || temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'),
+ aqi: parseNumericValue(aqiEntry),
+ };
+ };
+
+ const loadEvents = async (toolName) => {
+ const selectedCalendars = await resolveCalendarNames(toolName);
+ if (!toolName) throw new Error('Calendar tool unavailable');
+ if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
+ throw new Error('No calendars configured');
+ }
+
+ const start = new Date();
+ start.setHours(0, 0, 0, 0);
+ const end = new Date(start);
+ end.setHours(23, 59, 59, 999);
+ const endExclusiveTime = end.getTime() + 1;
+ const allEvents = [];
+
+ for (const calendarName of selectedCalendars) {
+ const toolResult = await host.callTool(toolName, {
+ calendar: calendarName,
+ range: 'today',
+ });
+ const events = extractEvents(toolResult);
+ for (const event of events) {
+ const startTime = eventTime(event?.start);
+ if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
+ allEvents.push({ ...event, _calendarName: calendarName });
+ }
+ }
+
+ allEvents.sort((left, right) => eventTime(left?.start) - eventTime(right?.start));
+ return {
+ toolName,
+ calendarNames: selectedCalendars,
+ events: allEvents.slice(0, maxEvents),
+ };
+ };
+
+ const refresh = async () => {
+ const now = new Date();
+ dateEl.textContent = formatHeaderDate(now);
+
+ const [weatherToolName, calendarToolName] = await Promise.all([
+ resolveToolName(configuredWeatherToolName, /(^|_)GetLiveContext$/i, 'mcp_home_assistant_GetLiveContext'),
+ resolveToolName(configuredCalendarToolName, /(^|_)calendar_get_events$/i, 'mcp_home_assistant_calendar_get_events'),
+ ]);
+
+ const [weatherResult, eventsResult] = await Promise.allSettled([
+ loadWeather(weatherToolName),
+ loadEvents(calendarToolName),
+ ]);
+
+ const snapshot = {
+ kind: 'today_briefing',
+ date_label: dateEl.textContent || null,
+ weather_tool_name: weatherToolName || null,
+ calendar_tool_name: calendarToolName || null,
+ updated_at: null,
+ status: null,
+ weather: null,
+ events: [],
+ errors: {},
+ };
+
+ let successCount = 0;
+
+ if (weatherResult.status === 'fulfilled') {
+ successCount += 1;
+ const weather = weatherResult.value;
+ tempEl.textContent = Number.isFinite(weather.temperature) ? String(Math.round(weather.temperature)) : '--';
+ unitEl.textContent = weather.temperatureUnit || '°F';
+ feelsLikeEl.textContent = formatMetric(weather.feelsLike, weather.feelsLikeUnit);
+ const aqiStyle = buildAqiStyle(weather.aqi);
+ aqiChipEl.textContent = `${aqiStyle.tone} · ${aqiStyle.label}`;
+ aqiChipEl.style.background = aqiStyle.background;
+ aqiChipEl.style.color = aqiStyle.color;
+ snapshot.weather = {
+ temperature: Number.isFinite(weather.temperature) ? Math.round(weather.temperature) : null,
+ temperature_unit: weather.temperatureUnit || null,
+ feels_like: Number.isFinite(weather.feelsLike) ? Math.round(weather.feelsLike) : null,
+ aqi: Number.isFinite(weather.aqi) ? Math.round(weather.aqi) : null,
+ aqi_tone: aqiStyle.tone,
+ };
+ } else {
+ tempEl.textContent = '--';
+ unitEl.textContent = '°F';
+ feelsLikeEl.textContent = '--';
+ aqiChipEl.textContent = 'AQI unavailable';
+ aqiChipEl.style.background = 'color-mix(in srgb, var(--theme-card-neutral-border) 65%, white)';
+ aqiChipEl.style.color = 'var(--theme-card-neutral-subtle)';
+ snapshot.errors.weather = String(weatherResult.reason);
+ }
+
+ if (eventsResult.status === 'fulfilled') {
+ successCount += 1;
+ const eventsData = eventsResult.value;
+ renderEvents(eventsData.events);
+ snapshot.events = eventsData.events.map((event) => ({
+ summary: String(event.summary || '(No title)'),
+ start: normalizeDateValue(event.start) || null,
+ end: normalizeDateValue(event.end) || null,
+ all_day: isAllDay(event.start, event.end),
+ }));
+ } else {
+ renderEvents([]);
+ countEl.textContent = 'Unavailable';
+ emptyEl.style.display = 'block';
+ emptyEl.textContent = 'Calendar unavailable.';
+ snapshot.errors.events = String(eventsResult.reason);
+ }
+
+ const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ updatedEl.textContent = updatedText;
+ snapshot.updated_at = updatedText;
+
+ if (successCount === 2) {
+ snapshot.status = 'Ready';
+ } else if (successCount === 1) {
+ snapshot.status = 'Partial';
+ } else {
+ snapshot.status = 'Unavailable';
+ }
+
+ snapshot.score = computeTodayScore(snapshot.events, snapshot.status);
+
+ updateLiveContent(snapshot);
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => { void refresh(); }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/today-briefing-live/manifest.json b/examples/cards/templates/today-briefing-live/manifest.json
new file mode 100644
index 0000000..32b9a2b
--- /dev/null
+++ b/examples/cards/templates/today-briefing-live/manifest.json
@@ -0,0 +1,21 @@
+{
+ "key": "today-briefing-live",
+ "title": "Today Briefing",
+ "notes": "Single-card day overview for local use. Fill template_state with weather_tool_name (defaults to Home Assistant GetLiveContext), calendar_tool_name (defaults to calendar_get_events), calendar_names, weather_prefix or exact sensor names for temperature/apparent/AQI, max_events, refresh_ms, and empty_text.",
+ "example_state": {
+ "weather_tool_name": "mcp_home_assistant_GetLiveContext",
+ "calendar_tool_name": "mcp_home_assistant_calendar_get_events",
+ "calendar_names": [
+ "Family Calendar"
+ ],
+ "weather_prefix": "OpenWeatherMap",
+ "temperature_name": "OpenWeatherMap Temperature",
+ "apparent_temperature_name": "OpenWeatherMap Apparent temperature",
+ "aqi_name": "Worcester Summer St Air quality index",
+ "max_events": 6,
+ "refresh_ms": 900000,
+ "empty_text": "Nothing scheduled today."
+ },
+ "created_at": "2026-03-15T22:35:00+00:00",
+ "updated_at": "2026-03-15T22:35:00+00:00"
+}
diff --git a/examples/cards/templates/today-briefing-live/template.html b/examples/cards/templates/today-briefing-live/template.html
new file mode 100644
index 0000000..560b39f
--- /dev/null
+++ b/examples/cards/templates/today-briefing-live/template.html
@@ -0,0 +1,32 @@
+
+
Today
+
+
+
Weather
+
+
+ --
+ °F
+
+
+
+ Feels like
+ --
+
+
+
+ AQI --
+
+
+
+
+
+
Nothing scheduled today.
+
+
+
+
Updated --
+
diff --git a/examples/cards/templates/todo-item-live/card.js b/examples/cards/templates/todo-item-live/card.js
new file mode 100644
index 0000000..e2dc2fd
--- /dev/null
+++ b/examples/cards/templates/todo-item-live/card.js
@@ -0,0 +1,708 @@
+const TASK_LANES = ["backlog", "committed", "in-progress", "blocked", "done", "canceled"];
+
+const TASK_ACTION_LABELS = {
+ backlog: "Backlog",
+ committed: "Commit",
+ "in-progress": "Start",
+ blocked: "Block",
+ done: "Done",
+ canceled: "Cancel",
+};
+
+const TASK_LANE_LABELS = {
+ backlog: "Backlog",
+ committed: "Committed",
+ "in-progress": "In Progress",
+ blocked: "Blocked",
+ done: "Done",
+ canceled: "Canceled",
+};
+
+const TASK_LANE_THEMES = {
+ backlog: {
+ accent: "#5f7884",
+ accentSoft: "rgba(95, 120, 132, 0.13)",
+ muted: "#6b7e87",
+ buttonInk: "#294a57",
+ },
+ committed: {
+ accent: "#8a6946",
+ accentSoft: "rgba(138, 105, 70, 0.14)",
+ muted: "#7f664a",
+ buttonInk: "#5a3b19",
+ },
+ "in-progress": {
+ accent: "#4f7862",
+ accentSoft: "rgba(79, 120, 98, 0.13)",
+ muted: "#5e7768",
+ buttonInk: "#214437",
+ },
+ blocked: {
+ accent: "#a55f4b",
+ accentSoft: "rgba(165, 95, 75, 0.13)",
+ muted: "#906659",
+ buttonInk: "#6c2f21",
+ },
+ done: {
+ accent: "#6d7f58",
+ accentSoft: "rgba(109, 127, 88, 0.12)",
+ muted: "#6b755d",
+ buttonInk: "#304121",
+ },
+ canceled: {
+ accent: "#7b716a",
+ accentSoft: "rgba(123, 113, 106, 0.12)",
+ muted: "#7b716a",
+ buttonInk: "#433932",
+ },
+};
+
+function isTaskLane(value) {
+ return typeof value === "string" && TASK_LANES.includes(value);
+}
+
+function normalizeTag(raw) {
+ const trimmed = String(raw || "")
+ .trim()
+ .replace(/^#+/, "")
+ .replace(/\s+/g, "-");
+ return trimmed ? `#${trimmed}` : "";
+}
+
+function normalizeTags(raw) {
+ if (!Array.isArray(raw)) return [];
+ const seen = new Set();
+ const tags = [];
+ for (const value of raw) {
+ const tag = normalizeTag(value);
+ const key = tag.toLowerCase();
+ if (!tag || seen.has(key)) continue;
+ seen.add(key);
+ tags.push(tag);
+ }
+ return tags;
+}
+
+function normalizeMetadata(raw) {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ const entries = Object.entries(raw).filter(([, value]) => value !== undefined);
+ return Object.fromEntries(entries);
+}
+
+function normalizeTask(raw, fallbackTitle) {
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
+ const lane = isTaskLane(record.lane) ? record.lane : "backlog";
+ return {
+ taskPath: typeof record.task_path === "string" ? record.task_path.trim() : "",
+ taskKey: typeof record.task_key === "string" ? record.task_key.trim() : "",
+ title:
+ typeof record.title === "string" && record.title.trim()
+ ? record.title.trim()
+ : fallbackTitle || "(Untitled task)",
+ lane,
+ created: typeof record.created === "string" ? record.created.trim() : "",
+ updated: typeof record.updated === "string" ? record.updated.trim() : "",
+ due: typeof record.due === "string" ? record.due.trim() : "",
+ tags: normalizeTags(record.tags),
+ body: typeof record.body === "string" ? record.body : "",
+ metadata: normalizeMetadata(record.metadata),
+ };
+}
+
+function normalizeTaskFromPayload(raw, fallback) {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback;
+ return {
+ taskPath: typeof raw.path === "string" ? raw.path.trim() : fallback.taskPath,
+ taskKey: fallback.taskKey,
+ title:
+ typeof raw.title === "string" && raw.title.trim()
+ ? raw.title.trim()
+ : fallback.title,
+ lane: isTaskLane(raw.lane) ? raw.lane : fallback.lane,
+ created: typeof raw.created === "string" ? raw.created.trim() : fallback.created,
+ updated: typeof raw.updated === "string" ? raw.updated.trim() : fallback.updated,
+ due: typeof raw.due === "string" ? raw.due.trim() : fallback.due,
+ tags: normalizeTags(raw.tags),
+ body: typeof raw.body === "string" ? raw.body : fallback.body,
+ metadata: normalizeMetadata(raw.metadata),
+ };
+}
+
+function parseToolPayload(result) {
+ if (result && typeof result === "object" && result.parsed && typeof result.parsed === "object") {
+ return result.parsed;
+ }
+ const raw = typeof result?.content === "string" ? result.content : "";
+ if (!raw.trim()) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+function dueScore(hoursUntilDue) {
+ if (hoursUntilDue <= 0) return 100;
+ if (hoursUntilDue <= 6) return 96;
+ if (hoursUntilDue <= 24) return 92;
+ if (hoursUntilDue <= 72) return 82;
+ if (hoursUntilDue <= 168) return 72;
+ return 62;
+}
+
+function ageScore(ageDays) {
+ if (ageDays >= 30) return 80;
+ if (ageDays >= 21) return 76;
+ if (ageDays >= 14) return 72;
+ if (ageDays >= 7) return 68;
+ if (ageDays >= 3) return 62;
+ if (ageDays >= 1) return 58;
+ return 54;
+}
+
+function computeTaskScore(task) {
+ const now = Date.now();
+ const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : "";
+ const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN;
+ let score = 54;
+ if (Number.isFinite(dueMs)) {
+ score = dueScore((dueMs - now) / (60 * 60 * 1000));
+ } else {
+ const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN;
+ if (Number.isFinite(createdMs)) {
+ score = ageScore(Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)));
+ }
+ }
+ if (task.lane === "committed") return Math.min(100, score + 1);
+ if (task.lane === "blocked") return Math.min(100, score + 4);
+ if (task.lane === "in-progress") return Math.min(100, score + 2);
+ return score;
+}
+
+function summarizeTaskBody(task) {
+ const trimmed = String(task.body || "").trim();
+ if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return "";
+ return trimmed;
+}
+
+function renderTaskBodyMarkdown(host, body) {
+ if (!body) return "";
+ return body
+ .replace(/\r\n?/g, "\n")
+ .trim()
+ .split("\n")
+ .map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) return '
';
+
+ let className = "task-card-ui__md-line";
+ let content = trimmed;
+ let prefix = "";
+
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
+ if (headingMatch) {
+ className += " task-card-ui__md-line--heading";
+ content = headingMatch[2];
+ } else if (/^[-*]\s+/.test(trimmed)) {
+ className += " task-card-ui__md-line--bullet";
+ content = trimmed.replace(/^[-*]\s+/, "");
+ prefix = "\u2022 ";
+ } else if (/^\d+\.\s+/.test(trimmed)) {
+ className += " task-card-ui__md-line--bullet";
+ content = trimmed.replace(/^\d+\.\s+/, "");
+ prefix = "\u2022 ";
+ } else if (/^>\s+/.test(trimmed)) {
+ className += " task-card-ui__md-line--quote";
+ content = trimmed.replace(/^>\s+/, "");
+ prefix = "> ";
+ }
+
+ const html = host.renderMarkdown(content, { inline: true });
+ return `
${
+ prefix ? `${prefix}` : ""
+ }${html}`;
+ })
+ .join("");
+}
+
+function formatTaskDue(task) {
+ if (!task.due) return "";
+ const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`;
+ const parsed = new Date(raw);
+ if (Number.isNaN(parsed.getTime())) return task.due;
+ if (task.due.includes("T")) {
+ const label = parsed.toLocaleString([], {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ return label.replace(/\s([AP]M)$/i, "$1");
+ }
+ return parsed.toLocaleDateString([], { month: "short", day: "numeric" });
+}
+
+function taskMoveOptions(lane) {
+ return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({
+ lane: targetLane,
+ label: TASK_ACTION_LABELS[targetLane],
+ }));
+}
+
+function taskLiveContent(task, errorText) {
+ return {
+ kind: "file_task",
+ exists: true,
+ task_path: task.taskPath || null,
+ task_key: task.taskKey || null,
+ title: task.title || null,
+ lane: task.lane,
+ created: task.created || null,
+ updated: task.updated || null,
+ due: task.due || null,
+ tags: task.tags,
+ metadata: task.metadata,
+ score: computeTaskScore(task),
+ status: task.lane,
+ error: errorText || null,
+ };
+}
+
+function autosizeEditor(editor) {
+ editor.style.height = "0px";
+ editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`;
+}
+
+export function mount({ root, item, state, host }) {
+ const cardEl = root.querySelector(".task-card-ui");
+ const laneToggleEl = root.querySelector(".task-card-ui__lane-button");
+ const laneWrapEl = root.querySelector(".task-card-ui__lane-wrap");
+ const laneMenuEl = root.querySelector(".task-card-ui__lane-menu");
+ const statusEl = root.querySelector(".task-card-ui__status");
+ const titleEl = root.querySelector(".task-card-ui__title-slot");
+ const tagsEl = root.querySelector(".task-card-ui__tags");
+ const bodyEl = root.querySelector(".task-card-ui__body-slot");
+ const metaEl = root.querySelector(".task-card-ui__meta");
+ const dueEl = root.querySelector(".task-card-ui__chip");
+
+ if (
+ !(cardEl instanceof HTMLElement) ||
+ !(laneToggleEl instanceof HTMLButtonElement) ||
+ !(laneWrapEl instanceof HTMLElement) ||
+ !(laneMenuEl instanceof HTMLElement) ||
+ !(statusEl instanceof HTMLElement) ||
+ !(titleEl instanceof HTMLElement) ||
+ !(tagsEl instanceof HTMLElement) ||
+ !(bodyEl instanceof HTMLElement) ||
+ !(metaEl instanceof HTMLElement) ||
+ !(dueEl instanceof HTMLElement)
+ ) {
+ return;
+ }
+
+ let task = normalizeTask(state, item.title);
+ let busy = false;
+ let errorText = "";
+ let statusLabel = "";
+ let statusKind = "neutral";
+ let laneMenuOpen = false;
+ let editingField = null;
+ let holdTimer = null;
+
+ const clearHoldTimer = () => {
+ if (holdTimer !== null) {
+ window.clearTimeout(holdTimer);
+ holdTimer = null;
+ }
+ };
+
+ const setStatus = (label, kind) => {
+ statusLabel = label || "";
+ statusKind = kind || "neutral";
+ statusEl.textContent = statusLabel;
+ statusEl.className = `task-card-ui__status${statusKind === "error" ? " is-error" : ""}`;
+ };
+
+ const publishLiveContent = () => {
+ host.setLiveContent(taskLiveContent(task, errorText));
+ };
+
+ const closeLaneMenu = () => {
+ laneMenuOpen = false;
+ laneToggleEl.setAttribute("aria-expanded", "false");
+ laneMenuEl.style.display = "none";
+ };
+
+ const openLaneMenu = () => {
+ if (busy || !task.taskPath) return;
+ laneMenuOpen = true;
+ laneToggleEl.setAttribute("aria-expanded", "true");
+ laneMenuEl.style.display = "flex";
+ };
+
+ const refreshFeed = () => {
+ closeLaneMenu();
+ host.requestFeedRefresh();
+ };
+
+ const setBusy = (nextBusy) => {
+ busy = !!nextBusy;
+ laneToggleEl.disabled = busy || !task.taskPath;
+ titleEl.style.pointerEvents = busy ? "none" : "";
+ bodyEl.style.pointerEvents = busy ? "none" : "";
+ tagsEl
+ .querySelectorAll("button")
+ .forEach((button) => (button.disabled = busy || (!task.taskPath && button.dataset.role !== "noop")));
+ laneMenuEl.querySelectorAll("button").forEach((button) => {
+ button.disabled = busy;
+ });
+ };
+
+ const runBusyAction = async (action) => {
+ setBusy(true);
+ errorText = "";
+ setStatus("Saving", "neutral");
+ try {
+ await action();
+ setStatus("", "neutral");
+ } catch (error) {
+ console.error("Task card action failed", error);
+ errorText = error instanceof Error ? error.message : String(error);
+ setStatus("Unavailable", "error");
+ } finally {
+ setBusy(false);
+ render();
+ }
+ };
+
+ const callTaskBoard = async (argumentsValue) => {
+ const result = await host.callTool("task_board", argumentsValue);
+ const payload = parseToolPayload(result);
+ if (payload && typeof payload.error === "string" && payload.error.trim()) {
+ throw new Error(payload.error);
+ }
+ return payload;
+ };
+
+ const moveTask = async (lane) =>
+ runBusyAction(async () => {
+ const payload = await callTaskBoard({ action: "move", task: task.taskPath, lane });
+ const nextTask = normalizeTaskFromPayload(payload?.task, {
+ ...task,
+ lane,
+ taskPath: typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath,
+ updated: new Date().toISOString(),
+ });
+ task = nextTask;
+ refreshFeed();
+ });
+
+ const editField = async (field, rawValue) => {
+ const nextValue = rawValue.trim();
+ const currentValue = field === "title" ? task.title : task.body;
+ if (field === "title" && !nextValue) return false;
+ if (nextValue === currentValue) {
+ editingField = null;
+ render();
+ return true;
+ }
+ await runBusyAction(async () => {
+ const payload = await callTaskBoard({
+ action: "edit",
+ task: task.taskPath,
+ ...(field === "title" ? { title: nextValue } : { description: nextValue }),
+ });
+ task = normalizeTaskFromPayload(payload?.task, {
+ ...task,
+ ...(field === "title" ? { title: nextValue } : { body: nextValue }),
+ });
+ editingField = null;
+ refreshFeed();
+ });
+ return true;
+ };
+
+ const addTag = async () => {
+ const raw = window.prompt("Add tag to task", "");
+ const tag = raw == null ? "" : normalizeTag(raw);
+ if (!tag) return;
+ await runBusyAction(async () => {
+ const payload = await callTaskBoard({
+ action: "add_tag",
+ task: task.taskPath,
+ tags: [tag],
+ });
+ task = normalizeTaskFromPayload(payload?.task, {
+ ...task,
+ tags: Array.from(new Set([...task.tags, tag])),
+ });
+ refreshFeed();
+ });
+ };
+
+ const removeTag = async (tag) =>
+ runBusyAction(async () => {
+ const payload = await callTaskBoard({
+ action: "remove_tag",
+ task: task.taskPath,
+ tags: [tag],
+ });
+ task = normalizeTaskFromPayload(payload?.task, {
+ ...task,
+ tags: task.tags.filter((value) => value !== tag),
+ });
+ refreshFeed();
+ });
+
+ const beginTitleEdit = () => {
+ if (!task.taskPath || busy || editingField) return;
+ closeLaneMenu();
+ editingField = "title";
+ render();
+ };
+
+ const beginBodyEdit = () => {
+ if (!task.taskPath || busy || editingField) return;
+ closeLaneMenu();
+ editingField = "body";
+ render();
+ };
+
+ const renderInlineEditor = (targetEl, field, value, placeholder) => {
+ targetEl.innerHTML = "";
+ const editor = document.createElement("textarea");
+ editor.className = `${field === "title" ? "task-card-ui__title" : "task-card-ui__body"} task-card-ui__editor`;
+ editor.rows = field === "title" ? 1 : Math.max(1, value.split("\n").length);
+ editor.value = value;
+ if (placeholder) editor.placeholder = placeholder;
+ editor.disabled = busy;
+ targetEl.appendChild(editor);
+ autosizeEditor(editor);
+
+ const cancel = () => {
+ editingField = null;
+ render();
+ };
+
+ editor.addEventListener("input", () => {
+ autosizeEditor(editor);
+ });
+
+ editor.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancel();
+ return;
+ }
+ if (field === "title" && event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ editor.blur();
+ return;
+ }
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
+ event.preventDefault();
+ editor.blur();
+ }
+ });
+
+ editor.addEventListener("blur", () => {
+ if (editingField !== field) return;
+ void editField(field, editor.value);
+ });
+
+ requestAnimationFrame(() => {
+ editor.focus();
+ const end = editor.value.length;
+ editor.setSelectionRange(end, end);
+ });
+ };
+
+ const renderTags = () => {
+ tagsEl.innerHTML = "";
+ task.tags.forEach((tag) => {
+ const button = document.createElement("button");
+ button.className = "task-card-ui__tag";
+ button.type = "button";
+ button.textContent = tag;
+ button.title = `Hold to remove ${tag}`;
+ button.disabled = busy;
+ button.addEventListener("pointerdown", (event) => {
+ if (event.pointerType === "mouse" && event.button !== 0) return;
+ clearHoldTimer();
+ button.classList.add("is-holding");
+ holdTimer = window.setTimeout(() => {
+ holdTimer = null;
+ button.classList.remove("is-holding");
+ if (window.confirm(`Remove ${tag} from this task?`)) {
+ void removeTag(tag);
+ }
+ }, 650);
+ });
+ ["pointerup", "pointerleave", "pointercancel"].forEach((eventName) => {
+ button.addEventListener(eventName, () => {
+ clearHoldTimer();
+ button.classList.remove("is-holding");
+ });
+ });
+ button.addEventListener("contextmenu", (event) => {
+ event.preventDefault();
+ });
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ tagsEl.appendChild(button);
+ });
+
+ const addButton = document.createElement("button");
+ addButton.className = "task-card-ui__tag task-card-ui__tag--action";
+ addButton.type = "button";
+ addButton.textContent = "+";
+ addButton.title = "Add tag";
+ addButton.disabled = busy || !task.taskPath;
+ addButton.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void addTag();
+ });
+ tagsEl.appendChild(addButton);
+ };
+
+ const renderLaneMenu = () => {
+ laneMenuEl.innerHTML = "";
+ if (!task.taskPath) {
+ closeLaneMenu();
+ return;
+ }
+ taskMoveOptions(task.lane).forEach((option) => {
+ const button = document.createElement("button");
+ button.className = "task-card-ui__lane-menu-item";
+ button.type = "button";
+ button.textContent = option.label;
+ button.disabled = busy;
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void moveTask(option.lane);
+ });
+ laneMenuEl.appendChild(button);
+ });
+ laneMenuEl.style.display = laneMenuOpen ? "flex" : "none";
+ };
+
+ const applyTheme = () => {
+ const theme = TASK_LANE_THEMES[task.lane] || TASK_LANE_THEMES.backlog;
+ cardEl.style.setProperty("--task-accent", theme.accent);
+ cardEl.style.setProperty("--task-accent-soft", theme.accentSoft);
+ cardEl.style.setProperty("--task-muted", theme.muted);
+ cardEl.style.setProperty("--task-button-ink", theme.buttonInk);
+ };
+
+ const render = () => {
+ applyTheme();
+ laneToggleEl.textContent = "";
+ const laneLabelEl = document.createElement("span");
+ laneLabelEl.className = "task-card-ui__lane";
+ laneLabelEl.textContent = TASK_LANE_LABELS[task.lane] || "Task";
+ const caretEl = document.createElement("span");
+ caretEl.className = `task-card-ui__lane-caret${laneMenuOpen ? " open" : ""}`;
+ caretEl.textContent = "▾";
+ laneToggleEl.append(laneLabelEl, caretEl);
+ laneToggleEl.disabled = busy || !task.taskPath;
+ laneToggleEl.setAttribute("aria-expanded", laneMenuOpen ? "true" : "false");
+
+ setStatus(statusLabel, statusKind);
+
+ if (editingField === "title") {
+ renderInlineEditor(titleEl, "title", task.title, "");
+ } else {
+ titleEl.innerHTML = "";
+ const button = document.createElement("button");
+ button.className = "task-card-ui__title task-card-ui__text-button";
+ button.type = "button";
+ button.disabled = busy || !task.taskPath;
+ button.textContent = task.title || "(Untitled task)";
+ button.addEventListener("click", beginTitleEdit);
+ titleEl.appendChild(button);
+ }
+
+ const bodySummary = summarizeTaskBody(task);
+ if (editingField === "body") {
+ renderInlineEditor(bodyEl, "body", task.body, "Add description");
+ } else {
+ bodyEl.innerHTML = "";
+ const button = document.createElement("button");
+ button.className = `task-card-ui__body task-card-ui__text-button task-card-ui__body-markdown${
+ bodySummary ? "" : " is-placeholder"
+ }`;
+ button.type = "button";
+ button.disabled = busy || !task.taskPath;
+ const inner = document.createElement("span");
+ inner.className = "task-card-ui__body-markdown-inner";
+ inner.innerHTML = bodySummary ? renderTaskBodyMarkdown(host, bodySummary) : "Add description";
+ button.appendChild(inner);
+ button.addEventListener("click", beginBodyEdit);
+ bodyEl.appendChild(button);
+ }
+
+ renderTags();
+ renderLaneMenu();
+
+ const dueText = formatTaskDue(task);
+ dueEl.textContent = dueText;
+ metaEl.style.display = dueText ? "flex" : "none";
+
+ publishLiveContent();
+ setBusy(busy);
+ };
+
+ const handleLaneToggle = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (laneMenuOpen) closeLaneMenu();
+ else openLaneMenu();
+ render();
+ };
+
+ const handleDocumentPointerDown = (event) => {
+ if (!laneMenuOpen) return;
+ if (!(event.target instanceof Node)) return;
+ if (laneWrapEl.contains(event.target)) return;
+ closeLaneMenu();
+ render();
+ };
+
+ const handleEscape = (event) => {
+ if (event.key !== "Escape" || !laneMenuOpen) return;
+ closeLaneMenu();
+ render();
+ };
+
+ laneToggleEl.addEventListener("click", handleLaneToggle);
+ document.addEventListener("pointerdown", handleDocumentPointerDown);
+ document.addEventListener("keydown", handleEscape);
+
+ render();
+
+ return {
+ update({ item: nextItem, state: nextState }) {
+ task = normalizeTask(nextState, nextItem.title);
+ errorText = "";
+ statusLabel = "";
+ statusKind = "neutral";
+ laneMenuOpen = false;
+ editingField = null;
+ render();
+ },
+ destroy() {
+ clearHoldTimer();
+ document.removeEventListener("pointerdown", handleDocumentPointerDown);
+ document.removeEventListener("keydown", handleEscape);
+ laneToggleEl.removeEventListener("click", handleLaneToggle);
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ },
+ };
+}
diff --git a/examples/cards/templates/todo-item-live/template.html b/examples/cards/templates/todo-item-live/template.html
index f96f326..bfa4c7e 100644
--- a/examples/cards/templates/todo-item-live/template.html
+++ b/examples/cards/templates/todo-item-live/template.html
@@ -1,1035 +1,17 @@
-
-
-
-
-
-
-
-
+
+
-
-
diff --git a/examples/cards/templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf b/examples/cards/templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf
new file mode 100644
index 0000000..6e32991
Binary files /dev/null and b/examples/cards/templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf differ
diff --git a/examples/cards/templates/upcoming-conditions-live/card.js b/examples/cards/templates/upcoming-conditions-live/card.js
new file mode 100644
index 0000000..19bce62
--- /dev/null
+++ b/examples/cards/templates/upcoming-conditions-live/card.js
@@ -0,0 +1,410 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const emptyEl = root.querySelector('[data-upcoming-empty]');
+ const listEl = root.querySelector('[data-upcoming-list]');
+ if (!(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement)) return;
+
+ const configuredCalendarToolName = typeof state.calendar_tool_name === 'string' ? state.calendar_tool_name.trim() : '';
+ const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
+ const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
+ const calendarNames = Array.isArray(state.calendar_names)
+ ? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ const eventWindowHoursRaw = Number(state.event_window_hours);
+ const eventWindowHours = Number.isFinite(eventWindowHoursRaw) && eventWindowHoursRaw >= 1 ? Math.min(eventWindowHoursRaw, 168) : 36;
+ const maxEventsRaw = Number(state.max_events);
+ const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 8) : 4;
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
+ const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim()
+ ? state.empty_text.trim()
+ : `No upcoming events in the next ${eventWindowHours} hours.`;
+
+ emptyEl.textContent = emptyText;
+
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const normalizeDateValue = (value) => {
+ if (typeof value === 'string') return value;
+ if (value && typeof value === 'object') {
+ if (typeof value.dateTime === 'string') return value.dateTime;
+ if (typeof value.date === 'string') return value.date;
+ }
+ return '';
+ };
+
+ const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
+
+ const eventTime = (value, allDay = false) => {
+ const raw = normalizeDateValue(value);
+ if (!raw) return Number.NaN;
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
+ ? `${raw}T${allDay ? '12:00:00' : '00:00:00'}`
+ : raw;
+ return new Date(normalized).getTime();
+ };
+
+ const formatEventLabel = (event) => {
+ const raw = normalizeDateValue(event?.start);
+ if (!raw) return '--';
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
+ const date = new Date(`${raw}T12:00:00`);
+ return `${date.toLocaleDateString([], { weekday: 'short' })} · all day`;
+ }
+ const date = new Date(raw);
+ if (Number.isNaN(date.getTime())) return '--';
+ return date.toLocaleString([], {
+ weekday: 'short',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ };
+
+ const extractEvents = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (Array.isArray(parsed.result)) return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
+ return parsed.result;
+ }
+ } catch {
+ return [];
+ }
+ }
+ return [];
+ };
+
+ const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
+
+ const extractExecJson = (toolResult) => {
+ const parsedText = stripExecFooter(toolResult?.content);
+ if (!parsedText) return null;
+ try {
+ return JSON.parse(parsedText);
+ } catch {
+ return null;
+ }
+ };
+
+ const resolveCalendarToolConfig = async () => {
+ const fallbackName = configuredCalendarToolName || 'mcp_home_assistant_calendar_get_events';
+ if (!host.listTools) {
+ return { name: fallbackName, availableCalendars: calendarNames };
+ }
+ try {
+ const tools = await host.listTools();
+ const tool = Array.isArray(tools)
+ ? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
+ : null;
+ const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
+ ? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
+ : [];
+ return {
+ name: tool?.name || fallbackName,
+ availableCalendars: enumValues,
+ };
+ } catch {
+ return { name: fallbackName, availableCalendars: calendarNames };
+ }
+ };
+
+ const resolveUpcomingEvents = async (toolConfig) => {
+ const now = Date.now();
+ const windowEnd = now + eventWindowHours * 60 * 60 * 1000;
+ const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
+ if (!toolConfig.name) throw new Error('Calendar tool unavailable');
+ if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
+ throw new Error('No calendars configured');
+ }
+
+ const upcomingEvents = [];
+ for (const calendarName of selectedCalendars) {
+ const toolResult = await host.callTool(toolConfig.name, {
+ calendar: calendarName,
+ range: 'week',
+ });
+ const events = extractEvents(toolResult);
+ for (const event of events) {
+ const allDay = isAllDay(event?.start, event?.end);
+ const startTime = eventTime(event?.start, allDay);
+ if (!Number.isFinite(startTime) || startTime < now || startTime > windowEnd) continue;
+ upcomingEvents.push({ ...event, _calendarName: calendarName, _allDay: allDay, _startTime: startTime });
+ }
+ }
+
+ upcomingEvents.sort((left, right) => left._startTime - right._startTime);
+ return upcomingEvents.slice(0, maxEvents);
+ };
+
+ const resolveForecastBundle = async () => {
+ if (!forecastCommand) throw new Error('Missing forecast_command');
+ const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
+ command: forecastCommand,
+ max_output_chars: 200000,
+ });
+ const payload = extractExecJson(toolResult);
+ if (!payload || typeof payload !== 'object') {
+ throw new Error('Invalid forecast payload');
+ }
+ return payload;
+ };
+
+ const forecastTime = (entry) => {
+ const time = new Date(String(entry?.datetime || '')).getTime();
+ return Number.isFinite(time) ? time : Number.NaN;
+ };
+
+ const nearestForecast = (entries, targetTime) => {
+ if (!Array.isArray(entries) || entries.length === 0 || !Number.isFinite(targetTime)) return null;
+ let bestEntry = null;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (const entry of entries) {
+ const entryTime = forecastTime(entry);
+ if (!Number.isFinite(entryTime)) continue;
+ const distance = Math.abs(entryTime - targetTime);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ bestEntry = entry;
+ }
+ }
+ return bestDistance <= 3 * 60 * 60 * 1000 ? bestEntry : null;
+ };
+
+ const computeUpcomingScore = (events) => {
+ if (!Array.isArray(events) || events.length === 0) return 0;
+ const now = Date.now();
+ const soonestMs = events
+ .map((event) => Number(event?._startTime))
+ .filter((time) => Number.isFinite(time) && time >= now)
+ .sort((left, right) => left - right)[0];
+ const soonestHours = Number.isFinite(soonestMs) ? (soonestMs - now) / (60 * 60 * 1000) : null;
+ let score = 44;
+ if (soonestHours !== null) {
+ if (soonestHours <= 1) score = 100;
+ else if (soonestHours <= 3) score = 97;
+ else if (soonestHours <= 8) score = 94;
+ else if (soonestHours <= 24) score = 90;
+ else if (soonestHours <= 36) score = 86;
+ else if (soonestHours <= 48) score = 82;
+ else if (soonestHours <= 72) score = 76;
+ else if (soonestHours <= 168) score = 62;
+ }
+ score += Math.min(events.length, 3);
+ return Math.max(0, Math.min(100, Math.round(score)));
+ };
+
+ const metricValue = (value, fallback = '--') => {
+ if (value === null || value === undefined || value === '') return fallback;
+ return String(value);
+ };
+
+ const createMetricCell = (glyph, label, value) => {
+ const cell = document.createElement('div');
+ cell.style.display = 'flex';
+ cell.style.alignItems = 'baseline';
+ cell.style.columnGap = '3px';
+ cell.style.flex = '0 0 auto';
+ cell.style.minWidth = '0';
+ cell.style.whiteSpace = 'nowrap';
+
+ cell.title = label;
+
+ const glyphEl = document.createElement('div');
+ glyphEl.style.fontSize = '0.54rem';
+ glyphEl.style.lineHeight = '1';
+ glyphEl.style.color = 'var(--theme-card-neutral-muted)';
+ glyphEl.style.fontWeight = '700';
+ glyphEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace";
+ glyphEl.style.flex = '0 0 auto';
+ glyphEl.textContent = glyph;
+ cell.appendChild(glyphEl);
+
+ const valueEl = document.createElement('div');
+ valueEl.style.fontSize = '0.53rem';
+ valueEl.style.lineHeight = '1.1';
+ valueEl.style.color = 'var(--theme-card-neutral-text)';
+ valueEl.style.fontWeight = '700';
+ valueEl.style.fontFamily = "'BlexMono Nerd Font Mono', monospace";
+ valueEl.style.whiteSpace = 'nowrap';
+ valueEl.style.overflow = 'hidden';
+ valueEl.style.textOverflow = 'ellipsis';
+ valueEl.style.textAlign = 'right';
+ valueEl.style.flex = '1 1 auto';
+ valueEl.textContent = metricValue(value);
+ cell.appendChild(valueEl);
+ return cell;
+ };
+
+ const renderEvents = (items, temperatureUnit, windSpeedUnit) => {
+ listEl.innerHTML = '';
+ if (!Array.isArray(items) || items.length === 0) {
+ emptyEl.style.display = 'block';
+ return;
+ }
+
+ emptyEl.style.display = 'none';
+
+ for (const [index, item] of items.entries()) {
+ const event = item.event;
+ const forecast = item.forecast;
+
+ const entry = document.createElement('li');
+ entry.style.padding = index === 0 ? '8px 0 0' : '8px 0 0';
+ entry.style.borderTop = '1px solid var(--theme-card-neutral-border)';
+
+ const whenEl = document.createElement('div');
+ whenEl.style.fontSize = '0.72rem';
+ whenEl.style.lineHeight = '1.2';
+ whenEl.style.letterSpacing = '0.02em';
+ whenEl.style.color = 'var(--theme-card-neutral-muted)';
+ whenEl.style.fontWeight = '700';
+ whenEl.textContent = formatEventLabel(event);
+ entry.appendChild(whenEl);
+
+ const titleEl = document.createElement('div');
+ titleEl.style.marginTop = '3px';
+ titleEl.style.fontSize = '0.9rem';
+ titleEl.style.lineHeight = '1.25';
+ titleEl.style.color = 'var(--theme-card-neutral-text)';
+ titleEl.style.fontWeight = '700';
+ titleEl.style.whiteSpace = 'normal';
+ titleEl.style.wordBreak = 'break-word';
+ titleEl.textContent = String(event.summary || '(No title)');
+ entry.appendChild(titleEl);
+
+ const detailGrid = document.createElement('div');
+ detailGrid.style.marginTop = '4px';
+ detailGrid.style.display = 'flex';
+ detailGrid.style.flexWrap = 'nowrap';
+ detailGrid.style.alignItems = 'baseline';
+ detailGrid.style.gap = '6px';
+ detailGrid.style.overflowX = 'auto';
+ detailGrid.style.overflowY = 'hidden';
+ detailGrid.style.scrollbarWidth = 'none';
+ detailGrid.style.msOverflowStyle = 'none';
+ detailGrid.style.webkitOverflowScrolling = 'touch';
+
+ const tempValue = Number.isFinite(Number(forecast?.temperature))
+ ? `${Math.round(Number(forecast.temperature))}${temperatureUnit || ''}`
+ : null;
+ const windValue = Number.isFinite(Number(forecast?.wind_speed))
+ ? `${Math.round(Number(forecast.wind_speed))}${windSpeedUnit || ''}`
+ : null;
+ const rainValue = Number.isFinite(Number(forecast?.precipitation_probability))
+ ? `${Math.round(Number(forecast.precipitation_probability))}%`
+ : null;
+ const uvValue = Number.isFinite(Number(forecast?.uv_index))
+ ? `${Math.round(Number(forecast.uv_index))}`
+ : null;
+
+ detailGrid.appendChild(createMetricCell('\uf2c9', 'Temperature', tempValue));
+ detailGrid.appendChild(createMetricCell('\uef16', 'Wind', windValue));
+ detailGrid.appendChild(createMetricCell('\uf043', 'Rain', rainValue));
+ detailGrid.appendChild(createMetricCell('\uf522', 'UV', uvValue));
+ entry.appendChild(detailGrid);
+
+ listEl.appendChild(entry);
+ }
+ };
+
+ const refresh = async () => {
+ const snapshot = {
+ kind: 'upcoming_conditions',
+ event_window_hours: eventWindowHours,
+ updated_at: null,
+ events: [],
+ errors: {},
+ };
+
+ try {
+ const [toolConfig, forecastBundle] = await Promise.all([
+ resolveCalendarToolConfig(),
+ resolveForecastBundle(),
+ ]);
+ const events = await resolveUpcomingEvents(toolConfig);
+
+ const nwsSource = forecastBundle?.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
+ const uvSource = forecastBundle?.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
+ const temperatureUnit = String(nwsSource?.temperature_unit || uvSource?.temperature_unit || '°F');
+ const windSpeedUnit = String(nwsSource?.wind_speed_unit || uvSource?.wind_speed_unit || 'mph');
+ const mergedItems = events.map((event) => {
+ const nwsForecast = nearestForecast(nwsSource?.forecast, event._startTime);
+ const uvForecast = nearestForecast(uvSource?.forecast, event._startTime);
+ return {
+ event,
+ forecast: {
+ datetime: nwsForecast?.datetime || uvForecast?.datetime || null,
+ condition: nwsForecast?.condition || uvForecast?.condition || null,
+ temperature: nwsForecast?.temperature ?? uvForecast?.temperature ?? null,
+ apparent_temperature: uvForecast?.apparent_temperature ?? null,
+ precipitation_probability: nwsForecast?.precipitation_probability ?? uvForecast?.precipitation_probability ?? null,
+ wind_speed: nwsForecast?.wind_speed ?? uvForecast?.wind_speed ?? null,
+ uv_index: uvForecast?.uv_index ?? null,
+ },
+ };
+ });
+
+ renderEvents(mergedItems, temperatureUnit, windSpeedUnit);
+
+ snapshot.events = mergedItems.map((item) => ({
+ summary: String(item.event.summary || '(No title)'),
+ start: normalizeDateValue(item.event.start) || null,
+ end: normalizeDateValue(item.event.end) || null,
+ all_day: Boolean(item.event._allDay),
+ calendar_name: String(item.event._calendarName || ''),
+ forecast_time: item.forecast.datetime || null,
+ condition: item.forecast.condition || null,
+ temperature: Number.isFinite(Number(item.forecast.temperature)) ? Number(item.forecast.temperature) : null,
+ apparent_temperature: Number.isFinite(Number(item.forecast.apparent_temperature)) ? Number(item.forecast.apparent_temperature) : null,
+ precipitation_probability: Number.isFinite(Number(item.forecast.precipitation_probability)) ? Number(item.forecast.precipitation_probability) : null,
+ wind_speed: Number.isFinite(Number(item.forecast.wind_speed)) ? Number(item.forecast.wind_speed) : null,
+ uv_index: Number.isFinite(Number(item.forecast.uv_index)) ? Number(item.forecast.uv_index) : null,
+ }));
+ snapshot.score = computeUpcomingScore(events);
+ } catch (error) {
+ listEl.innerHTML = '';
+ emptyEl.style.display = 'block';
+ emptyEl.textContent = String(error);
+ snapshot.errors.load = String(error);
+ snapshot.score = 0;
+ }
+
+ const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ snapshot.updated_at = updatedText;
+ updateLiveContent(snapshot);
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => { void refresh(); }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/upcoming-conditions-live/manifest.json b/examples/cards/templates/upcoming-conditions-live/manifest.json
new file mode 100644
index 0000000..1ecd86a
--- /dev/null
+++ b/examples/cards/templates/upcoming-conditions-live/manifest.json
@@ -0,0 +1,19 @@
+{
+ "key": "upcoming-conditions-live",
+ "title": "Upcoming Events",
+ "notes": "Upcoming event card with raw event-time forecast context. Fill template_state with calendar_tool_name (defaults to calendar_get_events), calendar_names, forecast_tool_name (defaults to exec), forecast_command, event_window_hours, max_events, refresh_ms, and empty_text. The card joins calendar events to the nearest hourly forecast rows without generating suggestions.",
+ "example_state": {
+ "calendar_tool_name": "mcp_home_assistant_calendar_get_events",
+ "calendar_names": [
+ "Family Calendar"
+ ],
+ "forecast_tool_name": "exec",
+ "forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 48",
+ "event_window_hours": 36,
+ "max_events": 3,
+ "refresh_ms": 900000,
+ "empty_text": "No upcoming events in the next 36 hours."
+ },
+ "created_at": "2026-03-16T14:00:00+00:00",
+ "updated_at": "2026-03-16T14:00:00+00:00"
+}
diff --git a/examples/cards/templates/upcoming-conditions-live/template.html b/examples/cards/templates/upcoming-conditions-live/template.html
new file mode 100644
index 0000000..67a68e7
--- /dev/null
+++ b/examples/cards/templates/upcoming-conditions-live/template.html
@@ -0,0 +1,23 @@
+
+
+
Upcoming Events
+
+
No upcoming events.
+
+
diff --git a/examples/cards/templates/weather-live/card.js b/examples/cards/templates/weather-live/card.js
new file mode 100644
index 0000000..d039eb3
--- /dev/null
+++ b/examples/cards/templates/weather-live/card.js
@@ -0,0 +1,356 @@
+export function mount({ root, state, host }) {
+ state = state || {};
+ const __cleanup = [];
+ const __setInterval = (...args) => {
+ const id = window.setInterval(...args);
+ __cleanup.push(() => window.clearInterval(id));
+ return id;
+ };
+ const __setTimeout = (...args) => {
+ const id = window.setTimeout(...args);
+ __cleanup.push(() => window.clearTimeout(id));
+ return id;
+ };
+ if (!(root instanceof HTMLElement)) return;
+
+ const subtitleEl = root.querySelector('[data-weather-subtitle]');
+ const tempEl = root.querySelector('[data-weather-temp]');
+ const unitEl = root.querySelector('[data-weather-unit]');
+ const humidityEl = root.querySelector('[data-weather-humidity]');
+ const windEl = root.querySelector('[data-weather-wind]');
+ const rainEl = root.querySelector('[data-weather-rain]');
+ const uvEl = root.querySelector('[data-weather-uv]');
+ const statusEl = root.querySelector('[data-weather-status]');
+ if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
+
+ const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
+ const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
+ const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
+ const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
+ const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
+ const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
+ const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
+ const uvName = typeof state.uv_name === 'string' ? state.uv_name.trim() : '';
+ const morningStartHourRaw = Number(state.morning_start_hour);
+ const morningEndHourRaw = Number(state.morning_end_hour);
+ const morningScoreRaw = Number(state.morning_score);
+ const defaultScoreRaw = Number(state.default_score);
+ const refreshMsRaw = Number(state.refresh_ms);
+ const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
+ const morningStartHour = Number.isFinite(morningStartHourRaw) ? morningStartHourRaw : 6;
+ const morningEndHour = Number.isFinite(morningEndHourRaw) ? morningEndHourRaw : 11;
+ const morningScore = Number.isFinite(morningScoreRaw) ? morningScoreRaw : 84;
+ const defaultScore = Number.isFinite(defaultScoreRaw) ? defaultScoreRaw : 38;
+
+ subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
+ const updateLiveContent = (snapshot) => {
+ host.setLiveContent(snapshot);
+ };
+
+ const setStatus = (label, color) => {
+ statusEl.textContent = label;
+ statusEl.style.color = color;
+ };
+
+ const stripQuotes = (value) => {
+ const text = String(value ?? '').trim();
+ if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
+ return text.slice(1, -1);
+ }
+ return text;
+ };
+
+ const normalizeText = (value) => String(value || '').trim().toLowerCase();
+
+ const parseLiveContextEntries = (payloadText) => {
+ const text = String(payloadText || '').replace(/\r/g, '');
+ const startIndex = text.indexOf('- names: ');
+ const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
+ const entries = [];
+ let current = null;
+ let inAttributes = false;
+
+ const pushCurrent = () => {
+ if (current) entries.push(current);
+ current = null;
+ inAttributes = false;
+ };
+
+ for (const rawLine of relevant.split('\n')) {
+ if (rawLine.startsWith('- names: ')) {
+ pushCurrent();
+ current = {
+ name: stripQuotes(rawLine.slice(9)),
+ domain: '',
+ state: '',
+ areas: '',
+ attributes: {},
+ };
+ continue;
+ }
+ if (!current) continue;
+ const trimmed = rawLine.trim();
+ if (!trimmed) continue;
+ if (trimmed === 'attributes:') {
+ inAttributes = true;
+ continue;
+ }
+ if (rawLine.startsWith(' domain:')) {
+ current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' state:')) {
+ current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (rawLine.startsWith(' areas:')) {
+ current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
+ inAttributes = false;
+ continue;
+ }
+ if (inAttributes && rawLine.startsWith(' ')) {
+ const separatorIndex = rawLine.indexOf(':');
+ if (separatorIndex >= 0) {
+ const key = rawLine.slice(4, separatorIndex).trim();
+ const value = stripQuotes(rawLine.slice(separatorIndex + 1));
+ current.attributes[key] = value;
+ }
+ continue;
+ }
+ inAttributes = false;
+ }
+
+ pushCurrent();
+ return entries;
+ };
+
+ const extractLiveContextText = (toolResult) => {
+ if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
+ const parsed = toolResult.parsed;
+ if (typeof parsed.result === 'string') return parsed.result;
+ }
+ if (typeof toolResult?.content === 'string') {
+ try {
+ const parsed = JSON.parse(toolResult.content);
+ if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
+ return parsed.result;
+ }
+ } catch {
+ return toolResult.content;
+ }
+ return toolResult.content;
+ }
+ return '';
+ };
+
+ const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
+
+ const extractExecJson = (toolResult) => {
+ const parsedText = stripExecFooter(toolResult?.content);
+ if (!parsedText) return null;
+ try {
+ return JSON.parse(parsedText);
+ } catch {
+ return null;
+ }
+ };
+
+ const resolveToolName = async () => {
+ if (configuredToolName) return configuredToolName;
+ if (!host.listTools) return 'mcp_home_assistant_GetLiveContext';
+ try {
+ const tools = await host.listTools();
+ const liveContextTool = Array.isArray(tools)
+ ? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
+ : null;
+ return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
+ } catch {
+ return 'mcp_home_assistant_GetLiveContext';
+ }
+ };
+
+ const findEntry = (entries, candidates) => {
+ const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
+ if (normalizedCandidates.length === 0) return null;
+ const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
+ if (exactMatch) return exactMatch;
+ return entries.find((entry) => {
+ const entryName = normalizeText(entry.name);
+ return normalizedCandidates.some((candidate) => entryName.includes(candidate));
+ }) || null;
+ };
+
+ const resolveForecastBundle = async () => {
+ if (!forecastCommand) return null;
+ const toolResult = await host.callTool(configuredForecastToolName || 'exec', {
+ command: forecastCommand,
+ max_output_chars: 200000,
+ });
+ const payload = extractExecJson(toolResult);
+ return payload && typeof payload === 'object' ? payload : null;
+ };
+
+ const firstForecastEntry = (bundle, key, metricKey = '') => {
+ const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
+ const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
+ if (!metricKey) {
+ return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
+ }
+ return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
+ };
+
+ const estimateUvIndex = (cloudCoverage) => {
+ const now = new Date();
+ const hour = now.getHours() + now.getMinutes() / 60;
+ const daylightPhase = Math.sin(((hour - 6) / 12) * Math.PI);
+ if (!Number.isFinite(daylightPhase) || daylightPhase <= 0) return 0;
+ const normalizedCloudCoverage = Number.isFinite(cloudCoverage)
+ ? Math.min(Math.max(cloudCoverage, 0), 100)
+ : null;
+ const cloudFactor = normalizedCloudCoverage === null
+ ? 1
+ : Math.max(0.2, 1 - normalizedCloudCoverage * 0.0065);
+ return Math.max(0, Math.round(7 * daylightPhase * cloudFactor));
+ };
+
+ const computeWeatherScore = () => {
+ const now = new Date();
+ const hour = now.getHours() + now.getMinutes() / 60;
+ if (hour >= morningStartHour && hour < morningEndHour) return morningScore;
+ return defaultScore;
+ };
+
+ const refresh = async () => {
+ const resolvedToolName = await resolveToolName();
+ if (!resolvedToolName) {
+ const errorText = 'Missing tool_name';
+ setStatus('No tool', 'var(--theme-status-danger)');
+ updateLiveContent({
+ kind: 'weather',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: null,
+ temperature: null,
+ temperature_unit: String(state.unit || '°F'),
+ humidity: null,
+ wind: null,
+ rain: null,
+ uv: null,
+ status: 'No tool',
+ error: errorText,
+ });
+ return;
+ }
+
+ setStatus('Refreshing', 'var(--theme-status-muted)');
+ try {
+ const [toolResult, forecastBundle] = await Promise.all([
+ host.callTool(resolvedToolName, {}),
+ resolveForecastBundle(),
+ ]);
+ const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
+ const prefix = providerPrefix || 'OpenWeatherMap';
+ const temperatureEntry = findEntry(entries, [
+ temperatureName,
+ `${prefix} Temperature`,
+ ]);
+ const humidityEntry = findEntry(entries, [
+ humidityName,
+ `${prefix} Humidity`,
+ ]);
+ const uvSensorEntry = findEntry(entries, [
+ uvName,
+ `${prefix} UV index`,
+ ]);
+
+ const temperature = Number(temperatureEntry?.state);
+ tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
+ unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
+
+ const humidity = Number(humidityEntry?.state);
+ humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
+
+ const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
+ const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
+ const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
+ const uvSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.uv && typeof forecastBundle.uv === 'object' ? forecastBundle.uv : null;
+
+ const windSpeed = Number(nwsEntry?.wind_speed);
+ const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
+ windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
+
+ const rainChance = Number(nwsEntry?.precipitation_probability);
+ rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
+
+ const liveUvValue = Number(uvSensorEntry?.state);
+ const forecastUvValue = Number(uvEntry?.uv_index);
+ const cloudCoverage = Number.isFinite(Number(nwsEntry?.cloud_coverage))
+ ? Number(nwsEntry?.cloud_coverage)
+ : Number(uvSource?.forecast?.[0]?.cloud_coverage);
+ const estimatedUvValue = estimateUvIndex(cloudCoverage);
+ const uvValue = Number.isFinite(liveUvValue)
+ ? liveUvValue
+ : (Number.isFinite(forecastUvValue) ? forecastUvValue : estimatedUvValue);
+ const uvEstimated = !Number.isFinite(liveUvValue) && !Number.isFinite(forecastUvValue);
+ uvEl.textContent = Number.isFinite(uvValue)
+ ? `${uvEstimated && uvValue > 0 ? '~' : ''}${Math.round(uvValue)}`
+ : '--';
+
+ subtitleEl.textContent = subtitle || prefix || 'Weather';
+ setStatus('Live', 'var(--theme-status-live)');
+ updateLiveContent({
+ kind: 'weather',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: resolvedToolName,
+ temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
+ temperature_unit: unitEl.textContent || null,
+ humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
+ wind: windEl.textContent || null,
+ rain: rainEl.textContent || null,
+ uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
+ uv_estimated: uvEstimated,
+ score: computeWeatherScore(),
+ status: 'Live',
+ });
+ } catch (error) {
+ const errorText = String(error);
+ setStatus('Unavailable', 'var(--theme-status-danger)');
+ tempEl.textContent = '--';
+ unitEl.textContent = String(state.unit || '°F');
+ humidityEl.textContent = '--';
+ windEl.textContent = '--';
+ rainEl.textContent = '--';
+ uvEl.textContent = '--';
+ updateLiveContent({
+ kind: 'weather',
+ subtitle: subtitleEl.textContent || null,
+ tool_name: resolvedToolName,
+ temperature: null,
+ temperature_unit: unitEl.textContent || null,
+ humidity: null,
+ wind: null,
+ rain: null,
+ uv: null,
+ score: computeWeatherScore(),
+ status: 'Unavailable',
+ error: errorText,
+ });
+ }
+ };
+
+ host.setRefreshHandler(() => {
+ void refresh();
+ });
+ void refresh();
+ __setInterval(() => { void refresh(); }, refreshMs);
+
+ return {
+ destroy() {
+ host.setRefreshHandler(null);
+ host.setLiveContent(null);
+ host.clearSelection();
+ for (const cleanup of __cleanup.splice(0)) cleanup();
+ },
+ };
+}
diff --git a/examples/cards/templates/weather-live/manifest.json b/examples/cards/templates/weather-live/manifest.json
index 40bcf75..9b53720 100644
--- a/examples/cards/templates/weather-live/manifest.json
+++ b/examples/cards/templates/weather-live/manifest.json
@@ -1,7 +1,7 @@
{
"key": "weather-live",
"title": "Live Weather",
- "notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.",
+ "notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional uv_name, optional condition_label, optional morning_start_hour/morning_end_hour/morning_score/default_score, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload. If a live UV reading is unavailable, the card falls back to a clearly approximate current UV estimate.",
"example_state": {
"subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext",
@@ -10,8 +10,13 @@
"provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity",
+ "uv_name": "OpenWeatherMap UV index",
"condition_label": "Weather",
- "refresh_ms": 86400000
+ "morning_start_hour": 6,
+ "morning_end_hour": 11,
+ "morning_score": 84,
+ "default_score": 38,
+ "refresh_ms": 300000
},
"created_at": "2026-03-11T04:12:48.601255+00:00",
"updated_at": "2026-03-11T19:18:04.632189+00:00"
diff --git a/examples/cards/templates/weather-live/template.html b/examples/cards/templates/weather-live/template.html
index 925deb6..3010bc9 100644
--- a/examples/cards/templates/weather-live/template.html
+++ b/examples/cards/templates/weather-live/template.html
@@ -1,4 +1,4 @@
-
+
-
Loading…
-
Loading…
+
Loading…
+
Loading…
--
- °F
+ °F
-
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d0eade0..a692716 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,512 +1,159 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
-import { AgentIndicator } from "./components/AgentIndicator";
-import { CardFeed } from "./components/CardFeed";
-import { ControlBar, VoiceStatus } from "./components/Controls";
-import { LogPanel } from "./components/LogPanel";
-import { useAudioMeter } from "./hooks/useAudioMeter";
-import { usePTT } from "./hooks/usePTT";
+import { useState } from "preact/hooks";
+import {
+ AgentWorkspace,
+ FeedWorkspace,
+ SwipeWorkspace,
+ useAppPresentation,
+ useSessionDrawerEdgeSwipe,
+} from "./appShell/presentation";
+import { VoiceStatus } from "./components/Controls";
+import { SessionDrawer } from "./components/SessionDrawer";
+import { WorkbenchOverlay } from "./components/WorkbenchOverlay";
import { useWebRTC } from "./hooks/useWebRTC";
-import type {
- AgentState,
- CardItem,
- CardMessageMetadata,
- CardSelectionRange,
- JsonValue,
- LogLine,
-} from "./types";
+import type { ThemeName, ThemeOption } from "./theme/themes";
+import { useThemePreference } from "./theme/useThemePreference";
-const SWIPE_THRESHOLD_PX = 64;
-const SWIPE_DIRECTION_RATIO = 1.15;
-const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
-type WorkspaceView = "agent" | "feed";
-
-interface AppRtcActions {
- connect(): Promise
;
- sendJson(
- msg:
- | { type: "command"; command: string }
- | { type: "card-response"; card_id: string; value: string }
- | { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
- ): void;
- setTextOnly(enabled: boolean): void;
- sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise;
- connected: boolean;
- connecting: boolean;
-}
-
-function toNullableNumber(value: JsonValue | undefined): number | null {
- return typeof value === "number" && Number.isFinite(value) ? value : null;
-}
-
-function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
- const raw = window.__nanobotGetCardSelection?.(cardId);
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const record = raw as Record;
- if (record.kind !== "git_diff_range") return null;
- if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
-
- const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
- const label =
- typeof record.label === "string"
- ? record.label
- : `${record.file_label} · ${record.range_label}`;
-
- return {
- kind: "git_diff_range",
- file_path: filePath,
- file_label: record.file_label,
- range_label: record.range_label,
- label,
- old_start: toNullableNumber(record.old_start),
- old_end: toNullableNumber(record.old_end),
- new_start: toNullableNumber(record.new_start),
- new_end: toNullableNumber(record.new_end),
- };
-}
-
-function buildCardMetadata(card: CardItem): CardMessageMetadata {
- const metadata: CardMessageMetadata = {
- card_id: card.serverId,
- card_slot: card.slot,
- card_title: card.title,
- card_lane: card.lane,
- card_template_key: card.templateKey,
- card_context_summary: card.contextSummary,
- card_response_value: card.responseValue,
- };
- const selection = card.serverId ? readCardSelection(card.serverId) : null;
- if (selection) {
- metadata.card_selection = selection as unknown as JsonValue;
- metadata.card_selection_label = selection.label;
- }
- const liveContent = card.serverId
- ? window.__nanobotGetCardLiveContent?.(card.serverId)
- : undefined;
- if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
- return metadata;
-}
-
-function AgentCardContext({
- card,
- selection,
- onClear,
-}: {
- card: CardItem;
- selection: CardSelectionRange | null;
- onClear(): void;
-}) {
- const label = selection ? "Using diff context" : "Using card";
- const title = selection?.file_label || card.title;
- const meta = selection?.range_label || "";
-
- return (
-
-
{label}
-
-
-
{title}
- {meta &&
{meta}
}
-
-
-
-
- );
-}
-
-function useSwipeHandlers(
- composing: boolean,
- view: WorkspaceView,
- setView: (view: WorkspaceView) => void,
- isInteractiveTarget: (target: EventTarget | null) => boolean,
-) {
- const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
-
- const onSwipeStart = useCallback(
- (e: Event) => {
- const pe = e as PointerEvent;
- if (composing) return;
- if (pe.pointerType === "mouse" && pe.button !== 0) return;
- if (isInteractiveTarget(pe.target)) return;
- swipeStartRef.current = { x: pe.clientX, y: pe.clientY };
- },
- [composing, isInteractiveTarget],
- );
-
- const onSwipeEnd = useCallback(
- (e: Event) => {
- const pe = e as PointerEvent;
- const start = swipeStartRef.current;
- swipeStartRef.current = null;
- if (!start || composing) return;
- const dx = pe.clientX - start.x;
- const dy = pe.clientY - start.y;
- if (Math.abs(dx) < SWIPE_THRESHOLD_PX) return;
- if (Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO) return;
- if (view === "agent" && dx < 0) setView("feed");
- if (view === "feed" && dx > 0) setView("agent");
- },
- [composing, view, setView],
- );
-
- return { onSwipeStart, onSwipeEnd };
-}
-
-function useCardActions(
- setView: (view: WorkspaceView) => void,
- setSelectedCardId: (cardId: string | null) => void,
-) {
- const handleAskCard = useCallback(
- (card: CardItem) => {
- if (!card.serverId) return;
- setSelectedCardId(card.serverId);
- setView("agent");
- },
- [setSelectedCardId, setView],
- );
-
- return { handleAskCard };
-}
-
-function useGlobalPointerBindings({
- handlePointerDown,
- handlePointerMove,
- handlePointerUp,
-}: {
- handlePointerDown: (event: Event) => void;
- handlePointerMove: (event: Event) => void;
- handlePointerUp: (event: Event) => void;
-}) {
- useEffect(() => {
- document.addEventListener("pointerdown", handlePointerDown, { passive: false });
- document.addEventListener("pointermove", handlePointerMove, { passive: true });
- document.addEventListener("pointerup", handlePointerUp, { passive: false });
- document.addEventListener("pointercancel", handlePointerUp, { passive: false });
- return () => {
- document.removeEventListener("pointerdown", handlePointerDown);
- document.removeEventListener("pointermove", handlePointerMove);
- document.removeEventListener("pointerup", handlePointerUp);
- document.removeEventListener("pointercancel", handlePointerUp);
- };
- }, [handlePointerDown, handlePointerMove, handlePointerUp]);
-}
-
-function useControlActions(rtc: AppRtcActions) {
- const handleReset = useCallback(async () => {
- const confirmed = window.confirm("Clear the current conversation context and start fresh?");
- if (!confirmed) return;
- await rtc.connect();
- rtc.sendJson({ type: "command", command: "reset" });
- }, [rtc]);
-
- const handleToggleTextOnly = useCallback(
- async (enabled: boolean) => {
- rtc.setTextOnly(enabled);
- if (enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
- },
- [rtc],
- );
-
- return { handleReset, handleToggleTextOnly };
-}
-
-function useSelectedCardActions({
+function AgentPanel({
+ app,
rtc,
- selectedCardId,
- setSelectedCardId,
- selectedCardMetadata,
+ sessionDrawerOpen,
+ setSessionDrawerOpen,
+ sessionDrawerEdgeGesture,
+ activeTheme,
+ themeOptions,
+ onSelectTheme,
}: {
- rtc: AppRtcActions & {
- sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise;
- };
- selectedCardId: string | null;
- setSelectedCardId: (cardId: string | null) => void;
- selectedCardMetadata: () => CardMessageMetadata | undefined;
-}) {
- const clearSelectedCardContext = useCallback(() => {
- if (selectedCardId) window.__nanobotClearCardSelection?.(selectedCardId);
- setSelectedCardId(null);
- }, [selectedCardId, setSelectedCardId]);
-
- const handleCardChoice = useCallback(
- (cardId: string, value: string) => {
- rtc.sendJson({ type: "card-response", card_id: cardId, value });
- },
- [rtc],
- );
-
- const handleSendMessage = useCallback(
- async (text: string) => {
- await rtc.sendTextMessage(text, selectedCardMetadata());
- },
- [rtc, selectedCardMetadata],
- );
-
- const handleResetWithSelection = useCallback(async () => {
- const confirmed = window.confirm("Clear the current conversation context and start fresh?");
- if (!confirmed) return;
- clearSelectedCardContext();
- await rtc.connect();
- rtc.sendJson({ type: "command", command: "reset" });
- }, [clearSelectedCardContext, rtc]);
-
- return {
- clearSelectedCardContext,
- handleCardChoice,
- handleSendMessage,
- handleResetWithSelection,
- };
-}
-
-function useSelectedCardContext(
- cards: CardItem[],
- selectedCardId: string | null,
- selectionVersion: number,
-) {
- const selectedCard = useMemo(
- () =>
- selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
- [cards, selectedCardId],
- );
- const selectedCardSelection = useMemo(
- () => (selectedCardId ? readCardSelection(selectedCardId) : null),
- [selectedCardId, selectionVersion],
- );
- const selectedCardMetadata = useCallback(
- () => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
- [selectedCard, selectionVersion],
- );
-
- return { selectedCard, selectedCardSelection, selectedCardMetadata };
-}
-
-function useCardSelectionLifecycle({
- cards,
- selectedCardId,
- setSelectedCardId,
- setSelectionVersion,
- setView,
-}: {
- cards: CardItem[];
- selectedCardId: string | null;
- setSelectedCardId: (cardId: string | null) => void;
- setSelectionVersion: (updater: (current: number) => number) => void;
- setView: (view: WorkspaceView) => void;
-}) {
- const autoOpenedFeedRef = useRef(false);
-
- useEffect(() => {
- if (autoOpenedFeedRef.current || cards.length === 0) return;
- autoOpenedFeedRef.current = true;
- setView("feed");
- }, [cards.length, setView]);
-
- useEffect(() => {
- if (!selectedCardId) return;
- if (cards.some((card) => card.serverId === selectedCardId)) return;
- setSelectedCardId(null);
- }, [cards, selectedCardId, setSelectedCardId]);
-
- useEffect(() => {
- const handleSelectionChange = (event: Event) => {
- const detail = (event as CustomEvent<{ cardId?: string; selection?: JsonValue | null }>)
- .detail;
- setSelectionVersion((current) => current + 1);
- const cardId = typeof detail?.cardId === "string" ? detail.cardId : "";
- if (cardId && detail?.selection) setSelectedCardId(cardId);
- };
-
- window.addEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
- return () => {
- window.removeEventListener(CARD_SELECTION_EVENT, handleSelectionChange as EventListener);
- };
- }, [setSelectedCardId, setSelectionVersion]);
-}
-
-function AgentWorkspace({
- active,
- selectedCard,
- selectedCardSelection,
- onClearSelectedCardContext,
- onReset,
- textOnly,
- onToggleTextOnly,
- logLines,
- connected,
- onSendMessage,
- onExpandChange,
- effectiveAgentState,
- connecting,
- audioLevel,
-}: {
- active: boolean;
- selectedCard: CardItem | null;
- selectedCardSelection: CardSelectionRange | null;
- onClearSelectedCardContext(): void;
- onReset(): Promise;
- textOnly: boolean;
- onToggleTextOnly(enabled: boolean): Promise;
- logLines: LogLine[];
- connected: boolean;
- onSendMessage(text: string): Promise;
- onExpandChange(expanded: boolean): void;
- effectiveAgentState: AgentState;
- connecting: boolean;
- audioLevel: number;
+ app: ReturnType;
+ rtc: ReturnType;
+ sessionDrawerOpen: boolean;
+ setSessionDrawerOpen(open: boolean): void;
+ sessionDrawerEdgeGesture: ReturnType;
+ activeTheme: ThemeName;
+ themeOptions: ThemeOption[];
+ onSelectTheme(themeName: ThemeName): void;
}) {
return (
-
- {active && (
-
- )}
- {active && selectedCard && (
-
+ ) : null
+ }
+ logLines={rtc.logLines}
+ connected={rtc.connected}
+ onSendMessage={app.handleSendMessage}
+ effectiveAgentState={app.effectiveAgentState}
+ textStreaming={rtc.textStreaming}
+ connecting={rtc.connecting}
+ audioLevel={app.audioLevel}
+ sessionDrawer={
+ 0 && !sessionDrawerOpen}
+ sessions={rtc.sessions}
+ activeSessionId={rtc.activeSessionId}
+ busy={rtc.sessionLoading || rtc.textStreaming}
+ onClose={() => setSessionDrawerOpen(false)}
+ onCreate={async () => {
+ await rtc.createSession();
+ setSessionDrawerOpen(false);
+ }}
+ onSelect={async (chatId) => {
+ await rtc.switchSession(chatId);
+ setSessionDrawerOpen(false);
+ }}
+ onRename={async (chatId, title) => {
+ await rtc.renameSession(chatId, title);
+ }}
+ onDelete={async (chatId) => {
+ await rtc.deleteSession(chatId);
+ setSessionDrawerOpen(false);
+ }}
+ activeTheme={activeTheme}
+ themeOptions={themeOptions}
+ onSelectTheme={onSelectTheme}
/>
- )}
- {active && (
-
- )}
- {}}
- onPointerUp={() => {}}
- />
-
+ }
+ />
);
}
-function FeedWorkspace({
- cards,
- onDismiss,
- onChoice,
- onAskCard,
+function FeedPanel({
+ app,
+ rtc,
}: {
- cards: CardItem[];
- onDismiss(id: number): void;
- onChoice(cardId: string, value: string): void;
- onAskCard(card: CardItem): void;
+ app: ReturnType;
+ rtc: ReturnType;
}) {
return (
-
+
);
}
export function App() {
const rtc = useWebRTC();
- const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
- const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
-
- const [view, setView] = useState("agent");
- const [composing, setComposing] = useState(false);
- const [selectedCardId, setSelectedCardId] = useState(null);
- const [selectionVersion, setSelectionVersion] = useState(0);
- const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
- rtc.cards,
- selectedCardId,
- selectionVersion,
- );
-
- const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
- connected: rtc.connected && !rtc.textOnly,
- currentAgentState: rtc.agentState,
- onSendPtt: (pressed) =>
- rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
- onBootstrap: rtc.connect,
- onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
- });
- const effectiveAgentState = agentStateOverride ?? rtc.agentState;
-
- const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
- if (!(target instanceof Element)) return false;
- return Boolean(target.closest("button,textarea,input,a,[data-no-swipe='1']"));
- }, []);
- const { onSwipeStart, onSwipeEnd } = useSwipeHandlers(
- composing,
- view,
- setView,
- isInteractiveTarget,
- );
- useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
- useCardSelectionLifecycle({
- cards: rtc.cards,
- selectedCardId,
- setSelectedCardId,
- setSelectionVersion,
- setView,
- });
-
- const { handleToggleTextOnly } = useControlActions(rtc);
- const { handleAskCard } = useCardActions(setView, setSelectedCardId);
- const {
- clearSelectedCardContext,
- handleCardChoice,
- handleSendMessage,
- handleResetWithSelection,
- } = useSelectedCardActions({
- rtc,
- selectedCardId,
- setSelectedCardId,
- selectedCardMetadata,
+ const app = useAppPresentation(rtc);
+ const theme = useThemePreference();
+ const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
+ const sessionDrawerEdgeGesture = useSessionDrawerEdgeSwipe({
+ enabled: app.view === "agent" && !sessionDrawerOpen,
+ onOpen: () => setSessionDrawerOpen(true),
});
return (
<>
-
+ }
+ feedWorkspace={}
+ />
>
);
diff --git a/frontend/src/appShell/presentation.tsx b/frontend/src/appShell/presentation.tsx
new file mode 100644
index 0000000..a7da5e4
--- /dev/null
+++ b/frontend/src/appShell/presentation.tsx
@@ -0,0 +1,905 @@
+import type { ComponentChildren } from "preact";
+import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
+import {
+ clearCardSelection,
+ getCardLiveContent,
+ getCardSelection,
+ subscribeCardSelection,
+} from "../cardRuntime/store";
+import { AgentIndicator } from "../components/AgentIndicator";
+import { CardFeed } from "../components/CardFeed";
+import { ControlBar } from "../components/Controls";
+import { LogPanel } from "../components/LogPanel";
+import { useAudioMeter } from "../hooks/useAudioMeter";
+import { usePTT } from "../hooks/usePTT";
+import type { WebRTCState } from "../hooks/webrtc/types";
+import type {
+ AgentState,
+ CardItem,
+ CardMessageMetadata,
+ CardSelectionRange,
+ JsonValue,
+ LogLine,
+} from "../types";
+
+const SWIPE_THRESHOLD_PX = 64;
+const SWIPE_INTENT_PX = 12;
+const SWIPE_COMMIT_RATIO = 0.18;
+const SWIPE_DIRECTION_RATIO = 1.15;
+const SESSION_DRAWER_OPEN_THRESHOLD_PX = 52;
+const SESSION_DRAWER_MAX_WIDTH_PX = 336;
+const SESSION_DRAWER_GUTTER_PX = 28;
+
+type WorkspaceView = "agent" | "feed";
+
+interface AppRtcActions {
+ connect(): Promise;
+ sendJson(
+ msg:
+ | { type: "command"; command: string }
+ | { type: "card-response"; card_id: string; value: string }
+ | { type: "voice-ptt"; pressed: boolean; metadata?: CardMessageMetadata },
+ ): void;
+ setTextOnly(enabled: boolean): void;
+ sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise;
+ connected: boolean;
+ connecting: boolean;
+}
+
+interface SwipeState {
+ pointerId: number;
+ x: number;
+ y: number;
+ dragging: boolean;
+}
+
+interface EdgeSwipeState {
+ identifier: number;
+ x: number;
+ y: number;
+}
+
+type EdgeSwipeOutcome = "idle" | "track" | "cancel" | "open";
+
+function findTouchById(touches: TouchList, identifier: number): Touch | null {
+ for (let i = 0; i < touches.length; i += 1) {
+ const touch = touches.item(i);
+ if (touch && touch.identifier === identifier) return touch;
+ }
+ return null;
+}
+
+function isSwipeInteractiveTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof Element)) return false;
+ return Boolean(
+ target.closest("textarea,input,select,[contenteditable='true'],[data-no-swipe='1']"),
+ );
+}
+
+function getViewportWidth(): number {
+ return window.innerWidth || document.documentElement.clientWidth || 1;
+}
+
+function toNullableNumber(value: JsonValue | undefined): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function readCardSelection(cardId: string | null | undefined): CardSelectionRange | null {
+ const raw = getCardSelection(cardId);
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const record = raw as Record;
+ if (record.kind !== "git_diff_range") return null;
+ if (typeof record.file_label !== "string" || typeof record.range_label !== "string") return null;
+
+ const filePath = typeof record.file_path === "string" ? record.file_path : record.file_label;
+ const label =
+ typeof record.label === "string"
+ ? record.label
+ : `${record.file_label} · ${record.range_label}`;
+
+ return {
+ kind: "git_diff_range",
+ file_path: filePath,
+ file_label: record.file_label,
+ range_label: record.range_label,
+ label,
+ old_start: toNullableNumber(record.old_start),
+ old_end: toNullableNumber(record.old_end),
+ new_start: toNullableNumber(record.new_start),
+ new_end: toNullableNumber(record.new_end),
+ };
+}
+
+function buildCardMetadata(card: CardItem): CardMessageMetadata {
+ const metadata: CardMessageMetadata = {
+ card_id: card.serverId,
+ card_slot: card.slot,
+ card_title: card.title,
+ card_lane: card.lane,
+ card_template_key: card.templateKey,
+ card_context_summary: card.contextSummary,
+ card_response_value: card.responseValue,
+ };
+ const selection = card.serverId ? readCardSelection(card.serverId) : null;
+ if (selection) {
+ metadata.card_selection = selection as unknown as JsonValue;
+ metadata.card_selection_label = selection.label;
+ }
+ const liveContent = card.serverId ? getCardLiveContent(card.serverId) : undefined;
+ if (liveContent !== undefined) metadata.card_live_content = liveContent as JsonValue;
+ return metadata;
+}
+
+function formatCardContextLabel(
+ card: CardItem | null,
+ selection: CardSelectionRange | null,
+): string | null {
+ if (!card) return null;
+ if (selection) {
+ return `diff: ${selection.file_label}${selection.range_label ? ` ${selection.range_label}` : ""}`;
+ }
+ const title = card.title?.trim();
+ if (!title) return "card";
+ if (card.templateKey === "todo-item-live") return `task: ${title}`;
+ if (card.templateKey === "upcoming-conditions-live") return `event: ${title}`;
+ if (card.templateKey === "list-total-live") return `tracker: ${title}`;
+ return `card: ${title}`;
+}
+
+function AgentCardContext({
+ card,
+ selection,
+ onClear,
+ textMode = false,
+}: {
+ card: CardItem;
+ selection: CardSelectionRange | null;
+ onClear(): void;
+ textMode?: boolean;
+}) {
+ const label = textMode
+ ? "Next message uses this context"
+ : selection
+ ? "Using diff context"
+ : "Using card";
+ const title = selection?.file_label || card.title;
+ const meta = selection?.range_label || card.contextSummary || "";
+
+ return (
+
+
{label}
+
+
+
{title}
+ {meta &&
{meta}
}
+ {textMode ? (
+
+ Ask your follow-up and Nanobot will include this card in the conversation context.
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function getSwipeDelta(swipe: SwipeState, event: PointerEvent): { dx: number; dy: number } {
+ return {
+ dx: event.clientX - swipe.x,
+ dy: event.clientY - swipe.y,
+ };
+}
+
+function hasSwipeIntent(dx: number, dy: number): boolean {
+ return Math.abs(dx) >= SWIPE_INTENT_PX || Math.abs(dy) >= SWIPE_INTENT_PX;
+}
+
+function isVerticalSwipeDominant(dx: number, dy: number): boolean {
+ return Math.abs(dx) < Math.abs(dy) * SWIPE_DIRECTION_RATIO;
+}
+
+function clampSwipeOffset(view: WorkspaceView, dx: number, width: number): number {
+ const minOffset = view === "agent" ? -width : 0;
+ const maxOffset = view === "agent" ? 0 : width;
+ return Math.max(minOffset, Math.min(maxOffset, dx));
+}
+
+function getCommittedSwipeView(
+ view: WorkspaceView,
+ dx: number,
+ width: number,
+): WorkspaceView | null {
+ const progress = width > 0 ? Math.abs(dx) / width : 0;
+ const crossedThreshold = Math.abs(dx) >= SWIPE_THRESHOLD_PX || progress >= SWIPE_COMMIT_RATIO;
+ if (!crossedThreshold) return null;
+ if (view === "agent" && dx < 0) return "feed";
+ if (view === "feed" && dx > 0) return "agent";
+ return null;
+}
+
+function releaseCapturedPointer(currentTarget: EventTarget | null, pointerId: number): void {
+ const element = currentTarget as HTMLElement | null;
+ if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
+}
+
+export function useSwipeTrackStyle(view: WorkspaceView, swipeOffsetPx: number, swiping: boolean) {
+ return useMemo(() => {
+ const base = view === "feed" ? "-50%" : "0%";
+ return {
+ transform:
+ swipeOffsetPx === 0
+ ? `translateX(${base})`
+ : `translateX(calc(${base} + ${swipeOffsetPx}px))`,
+ transition: swiping ? "none" : "transform 0.28s ease",
+ };
+ }, [swipeOffsetPx, swiping, view]);
+}
+
+function getEdgeSwipeOutcome(dx: number, dy: number): EdgeSwipeOutcome {
+ if (!hasSwipeIntent(dx, dy)) return "idle";
+ if (isVerticalSwipeDominant(dx, dy) || dx <= 0) return "cancel";
+ if (dx < SESSION_DRAWER_OPEN_THRESHOLD_PX) return "track";
+ return "open";
+}
+
+function getEdgeSwipeTouch(
+ touches: TouchList,
+ swipe: EdgeSwipeState | null,
+): { swipe: EdgeSwipeState; touch: Touch } | null {
+ if (!swipe) return null;
+ const touch = findTouchById(touches, swipe.identifier);
+ if (!touch) return null;
+ return { swipe, touch };
+}
+
+function getSessionDrawerWidth(viewportWidth: number): number {
+ return Math.max(
+ 1,
+ Math.min(SESSION_DRAWER_MAX_WIDTH_PX, viewportWidth - SESSION_DRAWER_GUTTER_PX),
+ );
+}
+
+function getSessionDrawerProgress(dx: number, viewportWidth: number): number {
+ return Math.max(0, Math.min(1, dx / getSessionDrawerWidth(viewportWidth)));
+}
+
+export function useSessionDrawerEdgeSwipe({
+ enabled,
+ onOpen,
+}: {
+ enabled: boolean;
+ onOpen(): void;
+}) {
+ const edgeSwipeRef = useRef(null);
+ const [progress, setProgress] = useState(0);
+
+ const clearEdgeSwipe = useCallback(() => {
+ edgeSwipeRef.current = null;
+ setProgress(0);
+ }, []);
+
+ const onTouchStart = useCallback(
+ (e: Event) => {
+ if (!enabled) return;
+ const te = e as TouchEvent;
+ if (te.touches.length !== 1) return;
+ const touch = te.touches.item(0);
+ if (!touch) return;
+ edgeSwipeRef.current = {
+ identifier: touch.identifier,
+ x: touch.clientX,
+ y: touch.clientY,
+ };
+ },
+ [enabled],
+ );
+
+ const onTouchMove = useCallback(
+ (e: Event) => {
+ const te = e as TouchEvent;
+ if (!enabled) return;
+ const trackedTouch = getEdgeSwipeTouch(te.touches, edgeSwipeRef.current);
+ if (!trackedTouch) return;
+
+ const { swipe, touch } = trackedTouch;
+ const dx = touch.clientX - swipe.x;
+ const dy = touch.clientY - swipe.y;
+ const outcome = getEdgeSwipeOutcome(dx, dy);
+ if (outcome === "idle") return;
+
+ if (outcome === "cancel") {
+ clearEdgeSwipe();
+ return;
+ }
+
+ if (te.cancelable) te.preventDefault();
+ setProgress(
+ getSessionDrawerProgress(
+ dx,
+ window.innerWidth || document.documentElement.clientWidth || 1,
+ ),
+ );
+ if (outcome === "track") return;
+ },
+ [clearEdgeSwipe, enabled],
+ );
+
+ const onTouchEnd = useCallback(
+ (e: Event) => {
+ const te = e as TouchEvent;
+ const trackedTouch = getEdgeSwipeTouch(te.changedTouches, edgeSwipeRef.current);
+ if (!trackedTouch) {
+ clearEdgeSwipe();
+ return;
+ }
+
+ const { swipe, touch } = trackedTouch;
+ const dx = touch.clientX - swipe.x;
+ const shouldOpen = dx >= SESSION_DRAWER_OPEN_THRESHOLD_PX;
+ clearEdgeSwipe();
+ if (shouldOpen) onOpen();
+ },
+ [clearEdgeSwipe, onOpen],
+ );
+
+ return {
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onTouchCancel: clearEdgeSwipe,
+ progress,
+ };
+}
+
+// biome-ignore lint/complexity/noExcessiveLinesPerFunction: touch and pointer swipe paths share one state machine here
+export function useSwipeHandlers(
+ view: WorkspaceView,
+ setView: (view: WorkspaceView) => void,
+ isInteractiveTarget: (target: EventTarget | null) => boolean,
+ getViewportWidthFn: () => number,
+) {
+ const swipeRef = useRef(null);
+ const [swipeOffsetPx, setSwipeOffsetPx] = useState(0);
+ const [swiping, setSwiping] = useState(false);
+
+ const clearSwipe = useCallback(() => {
+ swipeRef.current = null;
+ setSwiping(false);
+ setSwipeOffsetPx(0);
+ }, []);
+
+ const onSwipeStart = useCallback(
+ (e: Event) => {
+ const pe = e as PointerEvent;
+ if (pe.pointerType === "touch") return;
+ if (pe.pointerType === "mouse" && pe.button !== 0) return;
+ if (isInteractiveTarget(pe.target)) return;
+ swipeRef.current = { pointerId: pe.pointerId, x: pe.clientX, y: pe.clientY, dragging: false };
+ (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(pe.pointerId);
+ },
+ [isInteractiveTarget],
+ );
+
+ const onSwipeMove = useCallback(
+ (e: Event) => {
+ const pe = e as PointerEvent;
+ if (pe.pointerType === "touch") return;
+ const swipe = swipeRef.current;
+ if (!swipe || swipe.pointerId !== pe.pointerId) return;
+
+ const { dx, dy } = getSwipeDelta(swipe, pe);
+
+ if (!swipe.dragging) {
+ if (!hasSwipeIntent(dx, dy)) return;
+ if (isVerticalSwipeDominant(dx, dy)) {
+ clearSwipe();
+ return;
+ }
+ swipe.dragging = true;
+ setSwiping(true);
+ }
+
+ setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
+ if (pe.cancelable) pe.preventDefault();
+ },
+ [clearSwipe, getViewportWidthFn, view],
+ );
+
+ const finishSwipe = useCallback(
+ (e: Event, commit: boolean) => {
+ const pe = e as PointerEvent;
+ if (pe.pointerType === "touch") return;
+ const swipe = swipeRef.current;
+ if (!swipe || swipe.pointerId !== pe.pointerId) return;
+
+ releaseCapturedPointer(e.currentTarget, pe.pointerId);
+ if (commit) {
+ const { dx } = getSwipeDelta(swipe, pe);
+ const nextView = swipe.dragging
+ ? getCommittedSwipeView(view, dx, getViewportWidthFn())
+ : null;
+ if (nextView) setView(nextView);
+ }
+
+ clearSwipe();
+ },
+ [clearSwipe, getViewportWidthFn, setView, view],
+ );
+
+ const onSwipeEnd = useCallback((e: Event) => finishSwipe(e, true), [finishSwipe]);
+ const onSwipeCancel = useCallback((e: Event) => finishSwipe(e, false), [finishSwipe]);
+
+ const onTouchStart = useCallback(
+ (e: Event) => {
+ const te = e as TouchEvent;
+ if (te.touches.length !== 1) return;
+ const touch = te.touches.item(0);
+ if (!touch) return;
+ if (isInteractiveTarget(te.target)) return;
+ swipeRef.current = {
+ pointerId: touch.identifier,
+ x: touch.clientX,
+ y: touch.clientY,
+ dragging: false,
+ };
+ },
+ [isInteractiveTarget],
+ );
+
+ const onTouchMove = useCallback(
+ (e: Event) => {
+ const te = e as TouchEvent;
+ const swipe = swipeRef.current;
+ if (!swipe) return;
+ const touch = findTouchById(te.touches, swipe.pointerId);
+ if (!touch) return;
+
+ const dx = touch.clientX - swipe.x;
+ const dy = touch.clientY - swipe.y;
+
+ if (!swipe.dragging) {
+ if (!hasSwipeIntent(dx, dy)) return;
+ if (isVerticalSwipeDominant(dx, dy)) {
+ clearSwipe();
+ return;
+ }
+ swipe.dragging = true;
+ setSwiping(true);
+ }
+
+ setSwipeOffsetPx(clampSwipeOffset(view, dx, getViewportWidthFn()));
+ if (te.cancelable) te.preventDefault();
+ },
+ [clearSwipe, getViewportWidthFn, view],
+ );
+
+ const onTouchEnd = useCallback(
+ (e: Event) => {
+ const te = e as TouchEvent;
+ const swipe = swipeRef.current;
+ if (!swipe) return;
+ const touch = findTouchById(te.changedTouches, swipe.pointerId);
+ if (!touch) return;
+
+ if (swipe.dragging) {
+ const dx = touch.clientX - swipe.x;
+ const nextView = getCommittedSwipeView(view, dx, getViewportWidthFn());
+ if (nextView) setView(nextView);
+ }
+
+ clearSwipe();
+ },
+ [clearSwipe, getViewportWidthFn, setView, view],
+ );
+
+ const onTouchCancel = useCallback(() => {
+ clearSwipe();
+ }, [clearSwipe]);
+
+ return {
+ onSwipeStart,
+ onSwipeMove,
+ onSwipeEnd,
+ onSwipeCancel,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onTouchCancel,
+ swipeOffsetPx,
+ swiping,
+ };
+}
+
+function useCardActions(
+ setView: (view: WorkspaceView) => void,
+ setSelectedCardId: (cardId: string | null) => void,
+) {
+ const handleAskCard = useCallback(
+ (card: CardItem) => {
+ if (!card.serverId) return;
+ setSelectedCardId(card.serverId);
+ setView("agent");
+ },
+ [setSelectedCardId, setView],
+ );
+
+ return { handleAskCard };
+}
+
+function useGlobalPointerBindings({
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+}: {
+ handlePointerDown: (event: Event) => void;
+ handlePointerMove: (event: Event) => void;
+ handlePointerUp: (event: Event) => void;
+}) {
+ useEffect(() => {
+ document.addEventListener("pointerdown", handlePointerDown, { passive: false });
+ document.addEventListener("pointermove", handlePointerMove, { passive: true });
+ document.addEventListener("pointerup", handlePointerUp, { passive: false });
+ document.addEventListener("pointercancel", handlePointerUp, { passive: false });
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDown);
+ document.removeEventListener("pointermove", handlePointerMove);
+ document.removeEventListener("pointerup", handlePointerUp);
+ document.removeEventListener("pointercancel", handlePointerUp);
+ };
+ }, [handlePointerDown, handlePointerMove, handlePointerUp]);
+}
+
+function useControlActions(rtc: AppRtcActions) {
+ const handleToggleTextOnly = useCallback(
+ async (enabled: boolean) => {
+ rtc.setTextOnly(enabled);
+ if (!enabled && !rtc.connected && !rtc.connecting) await rtc.connect();
+ },
+ [rtc],
+ );
+
+ return { handleToggleTextOnly };
+}
+
+function useSelectedCardActions({
+ rtc,
+ selectedCardId,
+ setSelectedCardId,
+ selectedCardMetadata,
+}: {
+ rtc: AppRtcActions & {
+ sendTextMessage(text: string, metadata?: CardMessageMetadata): Promise;
+ };
+ selectedCardId: string | null;
+ setSelectedCardId: (cardId: string | null) => void;
+ selectedCardMetadata: () => CardMessageMetadata | undefined;
+}) {
+ const clearSelectedCardContext = useCallback(() => {
+ if (selectedCardId) clearCardSelection(selectedCardId);
+ setSelectedCardId(null);
+ }, [selectedCardId, setSelectedCardId]);
+
+ const handleCardChoice = useCallback(
+ (cardId: string, value: string) => {
+ rtc.sendJson({ type: "card-response", card_id: cardId, value });
+ },
+ [rtc],
+ );
+
+ const handleSendMessage = useCallback(
+ async (text: string) => {
+ await rtc.sendTextMessage(text, selectedCardMetadata());
+ },
+ [rtc, selectedCardMetadata],
+ );
+
+ return {
+ clearSelectedCardContext,
+ handleCardChoice,
+ handleSendMessage,
+ };
+}
+
+function useSelectedCardContext(
+ cards: CardItem[],
+ selectedCardId: string | null,
+ selectionVersion: number,
+) {
+ const selectedCard = useMemo(
+ () =>
+ selectedCardId ? (cards.find((card) => card.serverId === selectedCardId) ?? null) : null,
+ [cards, selectedCardId],
+ );
+ const selectedCardSelection = useMemo(
+ () => (selectedCardId ? readCardSelection(selectedCardId) : null),
+ [selectedCardId, selectionVersion],
+ );
+ const selectedCardMetadata = useCallback(
+ () => (selectedCard ? buildCardMetadata(selectedCard) : undefined),
+ [selectedCard, selectionVersion],
+ );
+
+ return { selectedCard, selectedCardSelection, selectedCardMetadata };
+}
+
+function useCardSelectionLifecycle({
+ cards,
+ selectedCardId,
+ setSelectedCardId,
+ setSelectionVersion,
+}: {
+ cards: CardItem[];
+ selectedCardId: string | null;
+ setSelectedCardId: (cardId: string | null) => void;
+ setSelectionVersion: (updater: (current: number) => number) => void;
+}) {
+ useEffect(() => {
+ if (!selectedCardId) return;
+ if (cards.some((card) => card.serverId === selectedCardId)) return;
+ setSelectedCardId(null);
+ }, [cards, selectedCardId, setSelectedCardId]);
+
+ useEffect(() => {
+ const unsubscribe = subscribeCardSelection((cardId, selection) => {
+ setSelectionVersion((current) => current + 1);
+ if (cardId && selection) setSelectedCardId(cardId);
+ });
+ return unsubscribe;
+ }, [setSelectedCardId, setSelectionVersion]);
+}
+
+export function AgentWorkspace({
+ active,
+ selectedCard,
+ selectedCardSelection,
+ selectedCardContextLabel,
+ onClearSelectedCardContext,
+ textOnly,
+ onToggleTextOnly,
+ sessionDrawerEdge,
+ logLines,
+ connected,
+ onSendMessage,
+ effectiveAgentState,
+ textStreaming,
+ connecting,
+ audioLevel,
+ sessionDrawer,
+ workbenchOverlay,
+}: {
+ active: boolean;
+ selectedCard: CardItem | null;
+ selectedCardSelection: CardSelectionRange | null;
+ selectedCardContextLabel: string | null;
+ onClearSelectedCardContext(): void;
+ textOnly: boolean;
+ onToggleTextOnly(enabled: boolean): Promise;
+ sessionDrawerEdge: ComponentChildren;
+ logLines: LogLine[];
+ connected: boolean;
+ onSendMessage(text: string): Promise;
+ effectiveAgentState: AgentState;
+ textStreaming: boolean;
+ connecting: boolean;
+ audioLevel: number;
+ sessionDrawer: ComponentChildren;
+ workbenchOverlay: ComponentChildren;
+}) {
+ return (
+
+ {active && }
+ {active && sessionDrawerEdge}
+ {active && sessionDrawer}
+ {active && workbenchOverlay}
+ {active && selectedCard && !textOnly && (
+
+ )}
+ {active && (
+ {
+ void onToggleTextOnly(false);
+ }}
+ contextLabel={selectedCardContextLabel}
+ onClearContext={selectedCard ? onClearSelectedCardContext : undefined}
+ agentState={effectiveAgentState}
+ textStreaming={textStreaming}
+ fullScreen={textOnly}
+ />
+ )}
+ {!textOnly && (
+ {}}
+ onPointerUp={() => {}}
+ />
+ )}
+
+ );
+}
+
+export function FeedWorkspace({
+ cards,
+ onDismiss,
+ onChoice,
+ onAskCard,
+}: {
+ cards: CardItem[];
+ onDismiss(id: number): void;
+ onChoice(cardId: string, value: string): void;
+ onAskCard(card: CardItem): void;
+}) {
+ return (
+
+ );
+}
+
+export function SwipeWorkspace({
+ view,
+ trackStyle,
+ onSwipeStart,
+ onSwipeMove,
+ onSwipeEnd,
+ onSwipeCancel,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onTouchCancel,
+ agentWorkspace,
+ feedWorkspace,
+}: {
+ view: WorkspaceView;
+ trackStyle: Record;
+ onSwipeStart: (event: Event) => void;
+ onSwipeMove: (event: Event) => void;
+ onSwipeEnd: (event: Event) => void;
+ onSwipeCancel: (event: Event) => void;
+ onTouchStart: (event: Event) => void;
+ onTouchMove: (event: Event) => void;
+ onTouchEnd: (event: Event) => void;
+ onTouchCancel: (event: Event) => void;
+ agentWorkspace: ComponentChildren;
+ feedWorkspace: ComponentChildren;
+}) {
+ return (
+
+
+ {agentWorkspace}
+ {feedWorkspace}
+
+
+ );
+}
+
+export function useAppPresentation(rtc: WebRTCState) {
+ const remoteAudioLevel = useAudioMeter(rtc.remoteStream);
+ const audioLevel = rtc.textOnly ? 0 : remoteAudioLevel;
+
+ const [view, setView] = useState("agent");
+ const [selectedCardId, setSelectedCardId] = useState(null);
+ const [selectionVersion, setSelectionVersion] = useState(0);
+ const { selectedCard, selectedCardSelection, selectedCardMetadata } = useSelectedCardContext(
+ rtc.cards,
+ selectedCardId,
+ selectionVersion,
+ );
+ const selectedCardContextLabel = useMemo(
+ () => formatCardContextLabel(selectedCard, selectedCardSelection),
+ [selectedCard, selectedCardSelection],
+ );
+
+ const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
+ connected: rtc.connected && !rtc.textOnly,
+ currentAgentState: rtc.agentState,
+ onSendPtt: (pressed) =>
+ rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
+ onBootstrap: rtc.textOnly ? async () => {} : rtc.connect,
+ onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
+ });
+ const effectiveAgentState = agentStateOverride ?? rtc.agentState;
+ const {
+ onSwipeStart,
+ onSwipeMove,
+ onSwipeEnd,
+ onSwipeCancel,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onTouchCancel,
+ swipeOffsetPx,
+ swiping,
+ } = useSwipeHandlers(view, setView, isSwipeInteractiveTarget, getViewportWidth);
+ useGlobalPointerBindings({ handlePointerDown, handlePointerMove, handlePointerUp });
+ useCardSelectionLifecycle({
+ cards: rtc.cards,
+ selectedCardId,
+ setSelectedCardId,
+ setSelectionVersion,
+ });
+
+ const { handleToggleTextOnly } = useControlActions(rtc);
+ const { handleAskCard } = useCardActions(setView, setSelectedCardId);
+ const { clearSelectedCardContext, handleCardChoice, handleSendMessage } = useSelectedCardActions({
+ rtc,
+ selectedCardId,
+ setSelectedCardId,
+ selectedCardMetadata: useCallback(() => {
+ const metadata = selectedCardMetadata();
+ if (!metadata) return metadata;
+ return {
+ ...metadata,
+ context_label: selectedCardContextLabel ?? undefined,
+ };
+ }, [selectedCardContextLabel, selectedCardMetadata]),
+ });
+
+ const trackStyle = useSwipeTrackStyle(view, swipeOffsetPx, swiping);
+
+ return {
+ audioLevel,
+ view,
+ selectedCard,
+ selectedCardSelection,
+ selectedCardContextLabel,
+ effectiveAgentState,
+ handleToggleTextOnly,
+ handleAskCard,
+ clearSelectedCardContext,
+ handleCardChoice,
+ handleSendMessage,
+ onSwipeStart,
+ onSwipeMove,
+ onSwipeEnd,
+ onSwipeCancel,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ onTouchCancel,
+ trackStyle,
+ };
+}
diff --git a/frontend/src/cardRuntime/api.ts b/frontend/src/cardRuntime/api.ts
new file mode 100644
index 0000000..b4e9671
--- /dev/null
+++ b/frontend/src/cardRuntime/api.ts
@@ -0,0 +1,363 @@
+import { streamSseResponse } from "../lib/sse";
+import type { JsonValue, WorkbenchItem } from "../types";
+
+export interface ManualToolResult {
+ tool_name: string;
+ content: string;
+ parsed: JsonValue | null;
+ is_json: boolean;
+}
+
+export interface ManualToolDefinition {
+ name: string;
+ description: string;
+ parameters: Record;
+ kind?: string;
+}
+
+export interface ManualToolJob {
+ job_id: string;
+ tool_name: string;
+ status: "queued" | "running" | "completed" | "failed";
+ created_at: string;
+ started_at: string | null;
+ finished_at: string | null;
+ result: ManualToolResult | null;
+ error: string | null;
+ error_code: number | null;
+}
+
+export interface ManualToolAsyncOptions {
+ timeoutMs?: number;
+}
+
+function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
+ if (value === undefined) return undefined;
+ try {
+ return JSON.parse(JSON.stringify(value)) as JsonValue;
+ } catch {
+ return value;
+ }
+}
+
+export async function decodeJsonError(resp: Response): Promise {
+ try {
+ const payload = (await resp.json()) as { error?: unknown };
+ if (payload && typeof payload === "object" && typeof payload.error === "string") {
+ return payload.error;
+ }
+ } catch {
+ // Fall back to status code.
+ }
+ return `request failed (${resp.status})`;
+}
+
+function normalizeManualToolResult(
+ payload: Partial | null | undefined,
+ fallbackName: string,
+): ManualToolResult {
+ return {
+ tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName,
+ content: typeof payload?.content === "string" ? payload.content : "",
+ parsed:
+ payload?.parsed === null || payload?.parsed === undefined
+ ? null
+ : (cloneJsonValue(payload.parsed as JsonValue) ?? null),
+ is_json: payload?.is_json === true,
+ };
+}
+
+function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob {
+ const record = payload && typeof payload === "object" ? (payload as Record) : {};
+ const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName;
+ const statusValue = typeof record.status === "string" ? record.status : "queued";
+ return {
+ job_id: typeof record.job_id === "string" ? record.job_id : "",
+ tool_name: toolName,
+ status:
+ statusValue === "running" || statusValue === "completed" || statusValue === "failed"
+ ? statusValue
+ : "queued",
+ created_at: typeof record.created_at === "string" ? record.created_at : "",
+ started_at: typeof record.started_at === "string" ? record.started_at : null,
+ finished_at: typeof record.finished_at === "string" ? record.finished_at : null,
+ result: normalizeManualToolJobResult(record.result, toolName),
+ error: typeof record.error === "string" ? record.error : null,
+ error_code: typeof record.error_code === "number" ? record.error_code : null,
+ };
+}
+
+function normalizeManualToolAsyncOptions(
+ options: ManualToolAsyncOptions,
+): Required {
+ const timeoutMs =
+ typeof options.timeoutMs === "number" &&
+ Number.isFinite(options.timeoutMs) &&
+ options.timeoutMs >= 100
+ ? Math.round(options.timeoutMs)
+ : 30000;
+ return { timeoutMs };
+}
+
+function normalizeManualToolJobResult(
+ payload: unknown,
+ fallbackName: string,
+): ManualToolResult | null {
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
+ return null;
+ }
+ const record = payload as Record;
+ const looksNormalized =
+ typeof record.tool_name === "string" ||
+ typeof record.content === "string" ||
+ record.parsed !== undefined ||
+ typeof record.is_json === "boolean";
+ if (looksNormalized) {
+ return normalizeManualToolResult(record as Partial, fallbackName);
+ }
+ return {
+ tool_name: fallbackName,
+ content: JSON.stringify(record),
+ parsed: cloneJsonValue(record as JsonValue) ?? null,
+ is_json: true,
+ };
+}
+
+function completedManualToolResult(
+ job: ManualToolJob,
+ toolName: string,
+): ManualToolResult | undefined {
+ if (job.status !== "completed") return undefined;
+ return job.result ?? normalizeManualToolResult(null, toolName);
+}
+
+function assertManualToolJobDidNotFail(job: ManualToolJob, toolName: string): void {
+ if (job.status === "failed") {
+ throw new Error(job.error || `${toolName} failed`);
+ }
+}
+
+async function streamManualToolJobUpdates(
+ jobId: string,
+ toolName: string,
+ timeoutMs: number,
+): Promise {
+ let current: ManualToolJob | null = null;
+ const controller = new AbortController();
+ const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const resp = await fetch(`/tools/jobs/${encodeURIComponent(jobId)}/stream`, {
+ cache: "no-store",
+ signal: controller.signal,
+ });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+
+ await streamSseResponse(resp, (raw) => {
+ let payload: unknown = null;
+ try {
+ payload = JSON.parse(raw);
+ } catch {
+ return;
+ }
+ if (!payload || typeof payload !== "object") return;
+ const record = payload as Record;
+ if (record.type !== "tool.job") return;
+ current = normalizeManualToolJob(record.job, toolName);
+ });
+ } catch (error) {
+ if (controller.signal.aborted) {
+ throw new Error(`${toolName} timed out`);
+ }
+ throw error;
+ } finally {
+ window.clearTimeout(timeoutId);
+ }
+
+ return current;
+}
+
+async function waitForManualToolJob(
+ job: ManualToolJob,
+ toolName: string,
+ timeoutMs: number,
+): Promise {
+ const immediate = completedManualToolResult(job, toolName);
+ if (immediate) return immediate;
+ assertManualToolJobDidNotFail(job, toolName);
+
+ const streamed = await streamManualToolJobUpdates(job.job_id, toolName, timeoutMs);
+ const streamedResult = streamed ? completedManualToolResult(streamed, toolName) : undefined;
+ if (streamedResult) return streamedResult;
+ if (streamed) assertManualToolJobDidNotFail(streamed, toolName);
+
+ const current = await getManualToolJob(job.job_id);
+ const finalResult = completedManualToolResult(current, toolName);
+ if (finalResult) return finalResult;
+ assertManualToolJobDidNotFail(current, toolName);
+ throw new Error(`${toolName} ended before completion`);
+}
+
+export async function callManualTool(
+ toolName: string,
+ argumentsValue: Record = {},
+): Promise {
+ const name = toolName.trim();
+ if (!name) throw new Error("tool name is required");
+
+ const cloned = cloneJsonValue(argumentsValue as JsonValue);
+ const safeArguments =
+ cloned && typeof cloned === "object" && !Array.isArray(cloned)
+ ? (cloned as Record)
+ : {};
+
+ const resp = await fetch("/tools/call", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tool_name: name, arguments: safeArguments }),
+ });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+
+ const payload = await resp.json();
+ if (!payload || typeof payload !== "object") {
+ throw new Error("invalid tool response");
+ }
+
+ return normalizeManualToolResult(payload as Partial, name);
+}
+
+export async function startManualToolCall(
+ toolName: string,
+ argumentsValue: Record = {},
+): Promise {
+ const name = toolName.trim();
+ if (!name) throw new Error("tool name is required");
+
+ const cloned = cloneJsonValue(argumentsValue as JsonValue);
+ const safeArguments =
+ cloned && typeof cloned === "object" && !Array.isArray(cloned)
+ ? (cloned as Record)
+ : {};
+
+ const resp = await fetch("/tools/call", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }),
+ });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+
+ const payload = await resp.json();
+ if (!payload || typeof payload !== "object") {
+ throw new Error("invalid tool job response");
+ }
+
+ const job = normalizeManualToolJob(payload, name);
+ if (!job.job_id) throw new Error("tool job id is required");
+ return job;
+}
+
+export async function getManualToolJob(jobId: string): Promise {
+ const key = jobId.trim();
+ if (!key) throw new Error("tool job id is required");
+
+ const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+
+ const payload = await resp.json();
+ if (!payload || typeof payload !== "object") {
+ throw new Error("invalid tool job response");
+ }
+
+ return normalizeManualToolJob(payload, "");
+}
+
+export async function callManualToolAsync(
+ toolName: string,
+ argumentsValue: Record = {},
+ options: ManualToolAsyncOptions = {},
+): Promise {
+ const { timeoutMs } = normalizeManualToolAsyncOptions(options);
+ const job = await startManualToolCall(toolName, argumentsValue);
+ return waitForManualToolJob(job, toolName, timeoutMs);
+}
+
+export async function listManualTools(): Promise {
+ const resp = await fetch("/tools", { cache: "no-store" });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+
+ const payload = (await resp.json()) as { tools?: unknown };
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
+ return tools
+ .filter((tool): tool is Record => !!tool && typeof tool === "object")
+ .map((tool) => ({
+ name: typeof tool.name === "string" ? tool.name : "",
+ description: typeof tool.description === "string" ? tool.description : "",
+ parameters:
+ tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
+ ? (tool.parameters as Record)
+ : {},
+ kind: typeof tool.kind === "string" ? tool.kind : undefined,
+ }))
+ .filter((tool) => tool.name);
+}
+
+export async function updateCardTemplateState(
+ cardId: string,
+ templateState: Record,
+): Promise {
+ const key = cardId.trim();
+ if (!key) throw new Error("card id is required");
+ const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template_state: templateState }),
+ });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+}
+
+export async function updateWorkbenchTemplateState(
+ item: WorkbenchItem,
+ templateState: Record,
+): Promise {
+ const resp = await fetch("/workbench", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: item.id,
+ chat_id: item.chatId,
+ kind: item.kind,
+ title: item.title,
+ content: item.content,
+ question: item.question || "",
+ choices: item.choices || [],
+ response_value: item.responseValue || "",
+ slot: item.slot || "",
+ template_key: item.templateKey || "",
+ template_state: templateState,
+ context_summary: item.contextSummary || "",
+ promotable: item.promotable,
+ source_card_id: item.sourceCardId || "",
+ }),
+ });
+ if (!resp.ok) throw new Error(await decodeJsonError(resp));
+}
+
+export async function copyTextToClipboard(text: string): Promise {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "true");
+ textarea.style.position = "fixed";
+ textarea.style.opacity = "0";
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ try {
+ document.execCommand("copy");
+ } finally {
+ textarea.remove();
+ }
+}
diff --git a/frontend/src/cardRuntime/runtimeAssets.ts b/frontend/src/cardRuntime/runtimeAssets.ts
new file mode 100644
index 0000000..ced737f
--- /dev/null
+++ b/frontend/src/cardRuntime/runtimeAssets.ts
@@ -0,0 +1,81 @@
+import type { JsonValue } from "../types";
+import type { RuntimeItem, RuntimeModule } from "./runtimeTypes";
+import { runtimeItemId } from "./runtimeUtils";
+
+const runtimeAssetCache = new Map<
+ string,
+ Promise<{ html: string | null; module: RuntimeModule } | null>
+>();
+
+function runtimeTemplateUrl(templateKey: string, filename: string): string {
+ return `/card-templates/${encodeURIComponent(templateKey)}/${filename}`;
+}
+
+function escapeAttribute(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(/"/g, """)
+ .replace(//g, ">");
+}
+
+function jsonScriptText(payload: Record): string {
+ return JSON.stringify(payload, null, 0).replace(/<\//g, "<\\/");
+}
+
+export function materializeRuntimeHtml(
+ templateHtml: string,
+ item: RuntimeItem,
+ state: Record,
+): string {
+ const cardId = escapeAttribute(runtimeItemId(item));
+ const templateKey = escapeAttribute(item.templateKey?.trim() || "");
+ return [
+ ``,
+ ``,
+ templateHtml,
+ "
",
+ ].join("");
+}
+
+export function syncRuntimeStateScript(
+ root: HTMLDivElement | null,
+ state: Record,
+): void {
+ const stateEl = root?.querySelector('script[data-card-state][type="application/json"]');
+ if (!(stateEl instanceof HTMLScriptElement)) return;
+ stateEl.textContent = jsonScriptText(state);
+}
+
+export async function loadRuntimeAssets(
+ templateKey: string,
+): Promise<{ html: string | null; module: RuntimeModule } | null> {
+ const cached = runtimeAssetCache.get(templateKey);
+ if (cached) return cached;
+
+ const loader = (async () => {
+ const moduleUrl = runtimeTemplateUrl(templateKey, "card.js");
+ const moduleProbe = await fetch(moduleUrl, { cache: "no-store" });
+ if (!moduleProbe.ok) return null;
+
+ const templatePromise = fetch(runtimeTemplateUrl(templateKey, "template.html"), {
+ cache: "no-store",
+ })
+ .then(async (response) => (response.ok ? response.text() : null))
+ .catch(() => null);
+
+ const namespace = (await import(/* @vite-ignore */ `${moduleUrl}?runtime=1`)) as
+ | RuntimeModule
+ | { default?: RuntimeModule };
+ const runtimeModule = ("default" in namespace ? namespace.default : namespace) as RuntimeModule;
+ if (!runtimeModule || typeof runtimeModule.mount !== "function") return null;
+
+ return {
+ html: await templatePromise,
+ module: runtimeModule,
+ };
+ })();
+
+ runtimeAssetCache.set(templateKey, loader);
+ return loader;
+}
diff --git a/frontend/src/cardRuntime/runtimeHost.ts b/frontend/src/cardRuntime/runtimeHost.ts
new file mode 100644
index 0000000..6d936ab
--- /dev/null
+++ b/frontend/src/cardRuntime/runtimeHost.ts
@@ -0,0 +1,98 @@
+import { marked } from "marked";
+import { useRef } from "preact/hooks";
+import type { JsonValue } from "../types";
+import {
+ callManualTool,
+ callManualToolAsync,
+ copyTextToClipboard,
+ getManualToolJob,
+ listManualTools,
+ startManualToolCall,
+ updateCardTemplateState,
+ updateWorkbenchTemplateState,
+} from "./api";
+import type { RuntimeHost, RuntimeItem, RuntimeSurface } from "./runtimeTypes";
+import { normalizeTemplateState, runtimeItemId } from "./runtimeUtils";
+import {
+ clearCardSelection,
+ getCardLiveContent,
+ getCardSelection,
+ requestCardFeedRefresh,
+ runCardRefresh,
+ setCardLiveContent,
+ setCardRefreshHandler,
+ setCardSelection,
+} from "./store";
+
+export function useRuntimeHost(
+ surface: RuntimeSurface,
+ item: RuntimeItem,
+ stateRef: { current: Record },
+ setState: (value: Record) => void,
+) {
+ const itemRef = useRef(item);
+ itemRef.current = item;
+
+ const hostRef = useRef(null);
+ if (!hostRef.current) {
+ hostRef.current = {
+ surface,
+ item,
+ getState: () => normalizeTemplateState(stateRef.current),
+ replaceState: async (nextState) => {
+ const normalized = normalizeTemplateState(nextState);
+ stateRef.current = normalized;
+ setState(normalized);
+ const currentItem = itemRef.current;
+ if ("serverId" in currentItem) {
+ if (!currentItem.serverId) throw new Error("card id is required");
+ await updateCardTemplateState(currentItem.serverId, normalized);
+ } else if ("chatId" in currentItem) {
+ await updateWorkbenchTemplateState(currentItem, normalized);
+ }
+ return normalizeTemplateState(normalized);
+ },
+ patchState: async (patch) => {
+ const nextState = { ...stateRef.current, ...normalizeTemplateState(patch) };
+ return hostRef.current?.replaceState(nextState) ?? normalizeTemplateState(nextState);
+ },
+ setLiveContent: (snapshot) => {
+ setCardLiveContent(runtimeItemId(itemRef.current), snapshot);
+ },
+ getLiveContent: () => getCardLiveContent(runtimeItemId(itemRef.current)),
+ setSelection: (selection) => {
+ setCardSelection(runtimeItemId(itemRef.current), selection);
+ },
+ getSelection: () => getCardSelection(runtimeItemId(itemRef.current)),
+ clearSelection: () => {
+ clearCardSelection(runtimeItemId(itemRef.current));
+ },
+ setRefreshHandler: (handler) => {
+ setCardRefreshHandler(runtimeItemId(itemRef.current), handler);
+ },
+ runRefresh: () => runCardRefresh(runtimeItemId(itemRef.current)),
+ requestFeedRefresh: () => {
+ requestCardFeedRefresh();
+ },
+ callTool: callManualTool,
+ startToolCall: startManualToolCall,
+ getToolJob: getManualToolJob,
+ callToolAsync: callManualToolAsync,
+ listTools: listManualTools,
+ renderMarkdown: (markdown, options = {}) =>
+ options.inline
+ ? (marked.parseInline(markdown) as string)
+ : (marked.parse(markdown) as string),
+ copyText: copyTextToClipboard,
+ getThemeName: () => document.documentElement.dataset.theme || "clay",
+ getThemeValue: (tokenName) => {
+ const normalized = tokenName.startsWith("--") ? tokenName : `--${tokenName}`;
+ return getComputedStyle(document.documentElement).getPropertyValue(normalized).trim();
+ },
+ };
+ }
+
+ hostRef.current.surface = surface;
+ hostRef.current.item = item;
+ return hostRef.current;
+}
diff --git a/frontend/src/cardRuntime/runtimeTypes.ts b/frontend/src/cardRuntime/runtimeTypes.ts
new file mode 100644
index 0000000..8e4f044
--- /dev/null
+++ b/frontend/src/cardRuntime/runtimeTypes.ts
@@ -0,0 +1,58 @@
+import type { CardItem, JsonValue, WorkbenchItem } from "../types";
+import type {
+ ManualToolAsyncOptions,
+ ManualToolDefinition,
+ ManualToolJob,
+ ManualToolResult,
+} from "./api";
+
+export type RuntimeSurface = "feed" | "workbench";
+export type RuntimeItem = CardItem | WorkbenchItem;
+
+export interface RuntimeHost {
+ surface: RuntimeSurface;
+ item: RuntimeItem;
+ getState(): Record;
+ replaceState(nextState: Record): Promise>;
+ patchState(patch: Record): Promise>;
+ setLiveContent(snapshot: JsonValue | null | undefined): void;
+ getLiveContent(): JsonValue | undefined;
+ setSelection(selection: JsonValue | null | undefined): void;
+ getSelection(): JsonValue | undefined;
+ clearSelection(): void;
+ setRefreshHandler(handler: (() => void) | null | undefined): void;
+ runRefresh(): boolean;
+ requestFeedRefresh(): void;
+ callTool(toolName: string, argumentsValue?: Record): Promise;
+ startToolCall(
+ toolName: string,
+ argumentsValue?: Record,
+ ): Promise;
+ getToolJob(jobId: string): Promise;
+ callToolAsync(
+ toolName: string,
+ argumentsValue?: Record,
+ options?: ManualToolAsyncOptions,
+ ): Promise;
+ listTools(): Promise;
+ renderMarkdown(markdown: string, options?: { inline?: boolean }): string;
+ copyText(text: string): Promise;
+ getThemeName(): string;
+ getThemeValue(tokenName: string): string;
+}
+
+export interface RuntimeContext {
+ root: HTMLElement;
+ item: RuntimeItem;
+ state: Record;
+ host: RuntimeHost;
+}
+
+export interface MountedRuntimeCard {
+ update?(context: RuntimeContext): void;
+ destroy?(): void;
+}
+
+export interface RuntimeModule {
+ mount(context: RuntimeContext): MountedRuntimeCard | undefined;
+}
diff --git a/frontend/src/cardRuntime/runtimeUtils.ts b/frontend/src/cardRuntime/runtimeUtils.ts
new file mode 100644
index 0000000..ac42863
--- /dev/null
+++ b/frontend/src/cardRuntime/runtimeUtils.ts
@@ -0,0 +1,35 @@
+import type { CardItem, JsonValue } from "../types";
+import type { RuntimeItem } from "./runtimeTypes";
+
+export function cloneJsonValue(value: T | null | undefined): T | undefined {
+ if (value === null || value === undefined) return undefined;
+ try {
+ return JSON.parse(JSON.stringify(value)) as T;
+ } catch {
+ return value ?? undefined;
+ }
+}
+
+export function normalizeTemplateState(
+ value: Record | undefined,
+): Record {
+ const cloned = cloneJsonValue((value || {}) as JsonValue);
+ return cloned && typeof cloned === "object" && !Array.isArray(cloned)
+ ? (cloned as Record)
+ : {};
+}
+
+export function runtimeItemId(item: RuntimeItem): string {
+ if (isCardItem(item)) {
+ return `card:${item.serverId || item.id}`;
+ }
+ return `workbench:${item.chatId}:${item.id}`;
+}
+
+function isCardItem(item: RuntimeItem): item is CardItem {
+ return "lane" in item;
+}
+
+export function looksLikeHtml(content: string): boolean {
+ return /^\s*<[a-zA-Z]/.test(content);
+}
diff --git a/frontend/src/cardRuntime/store.ts b/frontend/src/cardRuntime/store.ts
new file mode 100644
index 0000000..389c6df
--- /dev/null
+++ b/frontend/src/cardRuntime/store.ts
@@ -0,0 +1,192 @@
+import type { JsonValue } from "../types";
+
+type LiveContentListener = (cardId: string, snapshot: JsonValue | undefined) => void;
+type SelectionListener = (cardId: string, selection: JsonValue | undefined) => void;
+
+class CardRuntimeRegistry {
+ private readonly liveContentStore = new Map();
+ private readonly selectionStore = new Map();
+ private readonly refreshHandlers = new Map void>();
+ private readonly liveContentListeners = new Set();
+ private readonly selectionListeners = new Set();
+
+ private emitLiveContent(cardId: string, snapshot: JsonValue | undefined): void {
+ for (const listener of this.liveContentListeners) listener(cardId, cloneJsonValue(snapshot));
+ }
+
+ private emitSelection(cardId: string, selection: JsonValue | undefined): void {
+ for (const listener of this.selectionListeners) listener(cardId, cloneJsonValue(selection));
+ }
+
+ setCardLiveContent(
+ cardId: string | null | undefined,
+ snapshot: JsonValue | null | undefined,
+ ): void {
+ const key = String(cardId || "").trim();
+ if (!key) return;
+ const cloned = cloneJsonValue(snapshot ?? undefined);
+ if (cloned === undefined) {
+ this.liveContentStore.delete(key);
+ this.emitLiveContent(key, undefined);
+ return;
+ }
+ this.liveContentStore.set(key, cloned);
+ this.emitLiveContent(key, cloned);
+ }
+
+ getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
+ const key = String(cardId || "").trim();
+ if (!key) return undefined;
+ return cloneJsonValue(this.liveContentStore.get(key));
+ }
+
+ subscribeCardLiveContent(listener: LiveContentListener): () => void {
+ this.liveContentListeners.add(listener);
+ return () => {
+ this.liveContentListeners.delete(listener);
+ };
+ }
+
+ setCardSelection(
+ cardId: string | null | undefined,
+ selection: JsonValue | null | undefined,
+ ): void {
+ const key = String(cardId || "").trim();
+ if (!key) return;
+ const cloned = cloneJsonValue(selection ?? undefined);
+ if (cloned === undefined) {
+ this.selectionStore.delete(key);
+ this.emitSelection(key, undefined);
+ return;
+ }
+ this.selectionStore.set(key, cloned);
+ this.emitSelection(key, cloned);
+ }
+
+ getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
+ const key = String(cardId || "").trim();
+ if (!key) return undefined;
+ return cloneJsonValue(this.selectionStore.get(key));
+ }
+
+ clearCardSelection(cardId: string | null | undefined): void {
+ const key = String(cardId || "").trim();
+ if (!key) return;
+ this.selectionStore.delete(key);
+ this.emitSelection(key, undefined);
+ }
+
+ subscribeCardSelection(listener: SelectionListener): () => void {
+ this.selectionListeners.add(listener);
+ return () => {
+ this.selectionListeners.delete(listener);
+ };
+ }
+
+ setCardRefreshHandler(
+ cardId: string | null | undefined,
+ handler: (() => void) | null | undefined,
+ ): void {
+ const key = String(cardId || "").trim();
+ if (!key) return;
+ if (typeof handler !== "function") {
+ this.refreshHandlers.delete(key);
+ return;
+ }
+ this.refreshHandlers.set(key, handler);
+ }
+
+ hasCardRefreshHandler(cardId: string | null | undefined): boolean {
+ const key = String(cardId || "").trim();
+ if (!key) return false;
+ return this.refreshHandlers.has(key);
+ }
+
+ runCardRefresh(cardId: string | null | undefined): boolean {
+ const key = String(cardId || "").trim();
+ if (!key) return false;
+ const handler = this.refreshHandlers.get(key);
+ if (!handler) return false;
+ handler();
+ return true;
+ }
+
+ disposeCardRuntimeEntry(cardId: string | null | undefined): void {
+ const key = String(cardId || "").trim();
+ if (!key) return;
+ this.liveContentStore.delete(key);
+ this.selectionStore.delete(key);
+ this.refreshHandlers.delete(key);
+ this.emitLiveContent(key, undefined);
+ this.emitSelection(key, undefined);
+ }
+}
+
+const registry = new CardRuntimeRegistry();
+
+function cloneJsonValue(value: JsonValue | undefined): JsonValue | undefined {
+ if (value === undefined) return undefined;
+ try {
+ return JSON.parse(JSON.stringify(value)) as JsonValue;
+ } catch {
+ return value;
+ }
+}
+
+export function setCardLiveContent(
+ cardId: string | null | undefined,
+ snapshot: JsonValue | null | undefined,
+): void {
+ registry.setCardLiveContent(cardId, snapshot);
+}
+
+export function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
+ return registry.getCardLiveContent(cardId);
+}
+
+export function subscribeCardLiveContent(listener: LiveContentListener): () => void {
+ return registry.subscribeCardLiveContent(listener);
+}
+
+export function setCardSelection(
+ cardId: string | null | undefined,
+ selection: JsonValue | null | undefined,
+): void {
+ registry.setCardSelection(cardId, selection);
+}
+
+export function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
+ return registry.getCardSelection(cardId);
+}
+
+export function clearCardSelection(cardId: string | null | undefined): void {
+ registry.clearCardSelection(cardId);
+}
+
+export function subscribeCardSelection(listener: SelectionListener): () => void {
+ return registry.subscribeCardSelection(listener);
+}
+
+export function setCardRefreshHandler(
+ cardId: string | null | undefined,
+ handler: (() => void) | null | undefined,
+): void {
+ registry.setCardRefreshHandler(cardId, handler);
+}
+
+export function hasCardRefreshHandler(cardId: string | null | undefined): boolean {
+ return registry.hasCardRefreshHandler(cardId);
+}
+
+export function runCardRefresh(cardId: string | null | undefined): boolean {
+ return registry.runCardRefresh(cardId);
+}
+
+export function disposeCardRuntimeEntry(cardId: string | null | undefined): void {
+ registry.disposeCardRuntimeEntry(cardId);
+}
+
+export function requestCardFeedRefresh(): void {
+ if (typeof window === "undefined") return;
+ window.dispatchEvent(new Event("nanobot:cards-refresh"));
+}
diff --git a/frontend/src/components/CardBodyRenderer.tsx b/frontend/src/components/CardBodyRenderer.tsx
new file mode 100644
index 0000000..9644bf1
--- /dev/null
+++ b/frontend/src/components/CardBodyRenderer.tsx
@@ -0,0 +1,124 @@
+import { marked } from "marked";
+import { useEffect, useRef, useState } from "preact/hooks";
+import {
+ loadRuntimeAssets,
+ materializeRuntimeHtml,
+ syncRuntimeStateScript,
+} from "../cardRuntime/runtimeAssets";
+import { useRuntimeHost } from "../cardRuntime/runtimeHost";
+import type { MountedRuntimeCard, RuntimeItem, RuntimeSurface } from "../cardRuntime/runtimeTypes";
+import { looksLikeHtml, normalizeTemplateState, runtimeItemId } from "../cardRuntime/runtimeUtils";
+import { disposeCardRuntimeEntry } from "../cardRuntime/store";
+import type { JsonValue } from "../types";
+
+function StaticCardTextBody({
+ content,
+ bodyClass = "card-body",
+}: {
+ content: string;
+ bodyClass?: string;
+}) {
+ const html = looksLikeHtml(content) ? content : (marked.parse(content) as string);
+ return ;
+}
+
+export function DynamicCardBody({
+ item,
+ surface,
+ bodyClass = "card-body",
+}: {
+ item: RuntimeItem;
+ surface: RuntimeSurface;
+ bodyClass?: string;
+}) {
+ const templateKey = item.templateKey?.trim() || "";
+ const identity = runtimeItemId(item);
+ const rootRef = useRef(null);
+ const mountedRef = useRef(null);
+ const [runtimeAvailable, setRuntimeAvailable] = useState(
+ templateKey ? null : false,
+ );
+ const [runtimeState, setRuntimeState] = useState>(
+ normalizeTemplateState(item.templateState),
+ );
+ const runtimeStateRef = useRef(runtimeState);
+ runtimeStateRef.current = runtimeState;
+ const host = useRuntimeHost(surface, item, runtimeStateRef, setRuntimeState);
+
+ useEffect(() => {
+ const nextState = normalizeTemplateState(item.templateState);
+ const activeElement = document.activeElement;
+ const editingInside =
+ activeElement instanceof Node && !!rootRef.current?.contains(activeElement);
+ if (editingInside) return;
+ const currentJson = JSON.stringify(runtimeStateRef.current);
+ const nextJson = JSON.stringify(nextState);
+ if (currentJson === nextJson) return;
+ runtimeStateRef.current = nextState;
+ setRuntimeState(nextState);
+ }, [item.updatedAt, item.templateState]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const mountRuntime = async () => {
+ if (!templateKey) {
+ setRuntimeAvailable(false);
+ return;
+ }
+
+ setRuntimeAvailable(null);
+ const assets = await loadRuntimeAssets(templateKey);
+ if (cancelled) return;
+ if (!assets || !rootRef.current) {
+ setRuntimeAvailable(false);
+ return;
+ }
+
+ mountedRef.current?.destroy?.();
+ rootRef.current.innerHTML = materializeRuntimeHtml(
+ assets.html || "",
+ item,
+ runtimeStateRef.current,
+ );
+ mountedRef.current =
+ assets.module.mount({
+ root: rootRef.current,
+ item,
+ state: runtimeStateRef.current,
+ host,
+ }) || null;
+ setRuntimeAvailable(true);
+ };
+
+ void mountRuntime();
+
+ return () => {
+ cancelled = true;
+ mountedRef.current?.destroy?.();
+ mountedRef.current = null;
+ host.setRefreshHandler(null);
+ host.setLiveContent(undefined);
+ host.clearSelection();
+ disposeCardRuntimeEntry(identity);
+ };
+ }, [host, identity, templateKey]);
+
+ useEffect(() => {
+ if (!runtimeAvailable || !rootRef.current) return;
+ syncRuntimeStateScript(rootRef.current, runtimeState);
+ if (!mountedRef.current?.update) return;
+ mountedRef.current.update({
+ root: rootRef.current,
+ item,
+ state: runtimeState,
+ host,
+ });
+ }, [host, item, runtimeAvailable, runtimeState]);
+
+ if (!templateKey || runtimeAvailable === false) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/frontend/src/components/CardFeed.tsx b/frontend/src/components/CardFeed.tsx
index 625c0fd..cefabe8 100644
--- a/frontend/src/components/CardFeed.tsx
+++ b/frontend/src/components/CardFeed.tsx
@@ -1,877 +1,15 @@
-import { marked } from "marked";
-import { useEffect, useRef, useState } from "preact/hooks";
+import { memo } from "preact/compat";
+import { useCallback, useEffect, useRef, useState } from "preact/hooks";
+import {
+ getCardLiveContent,
+ hasCardRefreshHandler,
+ runCardRefresh,
+ subscribeCardLiveContent,
+} from "../cardRuntime/store";
import type { CardItem, JsonValue } from "../types";
-
-const EXECUTABLE_SCRIPT_TYPES = new Set([
- "",
- "text/javascript",
- "application/javascript",
- "module",
-]);
-const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
-const CARD_SELECTION_EVENT = "nanobot:card-selection-change";
-const cardLiveContentStore = new Map();
-const cardRefreshHandlers = new Map void>();
-const cardSelectionStore = new Map();
-
-interface ManualToolResult {
- tool_name: string;
- content: string;
- parsed: JsonValue | null;
- is_json: boolean;
-}
-
-interface ManualToolDefinition {
- name: string;
- description: string;
- parameters: Record;
- kind?: string;
-}
-
-interface ManualToolJob {
- job_id: string;
- tool_name: string;
- status: "queued" | "running" | "completed" | "failed";
- created_at: string;
- started_at: string | null;
- finished_at: string | null;
- result: ManualToolResult | null;
- error: string | null;
- error_code: number | null;
-}
-
-interface ManualToolAsyncOptions {
- pollMs?: number;
- timeoutMs?: number;
-}
-
-type TaskLane = "backlog" | "in-progress" | "blocked" | "done" | "canceled";
-interface ListTotalRow {
- value: string;
- name: string;
-}
-
-interface ListTotalCardState {
- leftLabel: string;
- rightLabel: string;
- totalLabel: string;
- totalSuffix: string;
- maxDigits: number;
- score: number;
- rows: ListTotalRow[];
-}
-
-interface TaskCardState {
- taskPath: string;
- taskKey: string;
- title: string;
- lane: TaskLane;
- created: string;
- updated: string;
- due: string;
- tags: string[];
- body: string;
- metadata: Record;
-}
-
-const TASK_LANES: TaskLane[] = ["backlog", "in-progress", "blocked", "done", "canceled"];
-
-const TASK_LANE_LABELS: Record = {
- backlog: "Backlog",
- "in-progress": "In Progress",
- blocked: "Blocked",
- done: "Done",
- canceled: "Canceled",
-};
-
-const TASK_ACTION_LABELS: Record = {
- backlog: "Backlog",
- "in-progress": "Start",
- blocked: "Block",
- done: "Done",
- canceled: "Cancel",
-};
-
-const TASK_LANE_THEMES: Record<
- TaskLane,
- { accent: string; accentSoft: string; muted: string; buttonInk: string }
-> = {
- backlog: {
- accent: "#5f7884",
- accentSoft: "rgba(95, 120, 132, 0.13)",
- muted: "#6b7e87",
- buttonInk: "#294a57",
- },
- "in-progress": {
- accent: "#4f7862",
- accentSoft: "rgba(79, 120, 98, 0.13)",
- muted: "#5e7768",
- buttonInk: "#214437",
- },
- blocked: {
- accent: "#a55f4b",
- accentSoft: "rgba(165, 95, 75, 0.13)",
- muted: "#906659",
- buttonInk: "#6c2f21",
- },
- done: {
- accent: "#6d7f58",
- accentSoft: "rgba(109, 127, 88, 0.12)",
- muted: "#6b755d",
- buttonInk: "#304121",
- },
- canceled: {
- accent: "#7b716a",
- accentSoft: "rgba(123, 113, 106, 0.12)",
- muted: "#7b716a",
- buttonInk: "#433932",
- },
-};
-
-function isTaskLane(value: unknown): value is TaskLane {
- return typeof value === "string" && TASK_LANES.includes(value as TaskLane);
-}
-
-function parseTemplateStateFromContent(content: string): Record | undefined {
- if (!content.includes("data-card-state")) return undefined;
- try {
- const parsed = new DOMParser().parseFromString(content, "text/html");
- const stateEl = parsed.querySelector('script[data-card-state][type="application/json"]');
- if (!(stateEl instanceof HTMLScriptElement)) return undefined;
- const payload = JSON.parse(stateEl.textContent || "{}");
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
- return payload as Record;
- } catch {
- return undefined;
- }
-}
-
-function normalizeTaskTag(raw: string): string {
- const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-");
- return trimmed ? `#${trimmed}` : "";
-}
-
-function normalizeTaskTags(raw: unknown): string[] {
- if (!Array.isArray(raw)) return [];
- const seen = new Set();
- const tags: string[] = [];
- for (const value of raw) {
- const tag = normalizeTaskTag(String(value || ""));
- const key = tag.toLowerCase();
- if (!tag || seen.has(key)) continue;
- seen.add(key);
- tags.push(tag);
- }
- return tags;
-}
-
-function normalizeTaskMetadata(raw: unknown): Record {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
- return Object.fromEntries(
- Object.entries(raw as Record).filter(([, value]) => value !== undefined),
- );
-}
-
-function sanitizeListTotalValue(raw: unknown, maxDigits: number): string {
- return String(raw || "")
- .replace(/\D+/g, "")
- .slice(0, maxDigits);
-}
-
-function sanitizeListTotalName(raw: unknown): string {
- return String(raw || "")
- .replace(/\s+/g, " ")
- .trimStart();
-}
-
-function isBlankListTotalRow(row: ListTotalRow | null | undefined): boolean {
- if (!row) return true;
- return !row.value.trim() && !row.name.trim();
-}
-
-function ensureTrailingBlankListTotalRow(rows: ListTotalRow[]): ListTotalRow[] {
- const next = rows.map((row) => ({ value: row.value, name: row.name }));
- if (!next.length || !isBlankListTotalRow(next[next.length - 1])) {
- next.push({ value: "", name: "" });
- }
- return next;
-}
-
-function normalizeListTotalRows(raw: unknown, maxDigits: number): ListTotalRow[] {
- if (!Array.isArray(raw)) return [{ value: "", name: "" }];
- return ensureTrailingBlankListTotalRow(
- raw
- .filter((row) => row && typeof row === "object" && !Array.isArray(row))
- .map((row) => {
- const record = row as Record;
- return {
- value: sanitizeListTotalValue(record.value, maxDigits),
- name: sanitizeListTotalName(record.name),
- };
- }),
- );
-}
-
-function normalizeListTotalCardState(
- card: Pick,
-): ListTotalCardState | null {
- if (card.templateKey !== "list-total-live") return null;
- const raw = card.templateState;
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const maxDigits = Math.max(
- 1,
- Math.min(
- 4,
- Number.isFinite(Number((raw as Record).max_digits))
- ? Number((raw as Record).max_digits)
- : 4,
- ),
- );
- return {
- leftLabel:
- typeof (raw as Record).left_label === "string" &&
- String((raw as Record).left_label).trim()
- ? String((raw as Record).left_label).trim()
- : "Value",
- rightLabel:
- typeof (raw as Record).right_label === "string" &&
- String((raw as Record).right_label).trim()
- ? String((raw as Record).right_label).trim()
- : "Item",
- totalLabel:
- typeof (raw as Record).total_label === "string" &&
- String((raw as Record).total_label).trim()
- ? String((raw as Record).total_label).trim()
- : "Total",
- totalSuffix:
- typeof (raw as Record).total_suffix === "string"
- ? String((raw as Record).total_suffix).trim()
- : "",
- maxDigits,
- score:
- typeof (raw as Record).score === "number" &&
- Number.isFinite((raw as Record).score as number)
- ? Math.max(0, Math.min(100, (raw as Record).score as number))
- : 24,
- rows: normalizeListTotalRows((raw as Record).rows, maxDigits),
- };
-}
-
-function normalizeTaskCardState(
- card: Pick,
-): TaskCardState | null {
- if (card.templateKey !== "todo-item-live") return null;
- const raw = card.templateState ?? parseTemplateStateFromContent(card.content);
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
- const taskPath = typeof raw.task_path === "string" ? raw.task_path.trim() : "";
- const lane = isTaskLane(raw.lane) ? raw.lane : "backlog";
- return {
- taskPath,
- taskKey: typeof raw.task_key === "string" ? raw.task_key.trim() : "",
- title:
- typeof raw.title === "string" && raw.title.trim()
- ? raw.title.trim()
- : card.title || "(Untitled task)",
- lane,
- created: typeof raw.created === "string" ? raw.created.trim() : "",
- updated: typeof raw.updated === "string" ? raw.updated.trim() : "",
- due: typeof raw.due === "string" ? raw.due.trim() : "",
- tags: normalizeTaskTags(raw.tags),
- body: typeof raw.body === "string" ? raw.body : "",
- metadata: normalizeTaskMetadata(raw.metadata),
- };
-}
-
-function normalizeTaskFromPayload(raw: unknown, fallback: TaskCardState): TaskCardState {
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback;
- const record = raw as Record;
- return {
- taskPath: typeof record.path === "string" ? record.path.trim() : fallback.taskPath,
- taskKey: fallback.taskKey,
- title:
- typeof record.title === "string" && record.title.trim()
- ? record.title.trim()
- : fallback.title,
- lane: isTaskLane(record.lane) ? record.lane : fallback.lane,
- created: typeof record.created === "string" ? record.created.trim() : fallback.created,
- updated: typeof record.updated === "string" ? record.updated.trim() : fallback.updated,
- due: typeof record.due === "string" ? record.due.trim() : "",
- tags: normalizeTaskTags(record.tags),
- body: typeof record.body === "string" ? record.body : fallback.body,
- metadata: normalizeTaskMetadata(record.metadata),
- };
-}
-
-function dueScore(hoursUntilDue: number): number {
- if (hoursUntilDue <= 0) return 100;
- if (hoursUntilDue <= 6) return 96;
- if (hoursUntilDue <= 24) return 92;
- if (hoursUntilDue <= 72) return 82;
- if (hoursUntilDue <= 168) return 72;
- return 62;
-}
-
-function ageScore(ageDays: number): number {
- if (ageDays >= 30) return 80;
- if (ageDays >= 21) return 76;
- if (ageDays >= 14) return 72;
- if (ageDays >= 7) return 68;
- if (ageDays >= 3) return 62;
- if (ageDays >= 1) return 58;
- return 54;
-}
-
-function computeTaskScore(task: TaskCardState): number {
- const now = Date.now();
- const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : "";
- const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN;
- let score = 54;
- if (Number.isFinite(dueMs)) {
- const hoursUntilDue = (dueMs - now) / (60 * 60 * 1000);
- score = dueScore(hoursUntilDue);
- } else {
- const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN;
- if (Number.isFinite(createdMs)) {
- const ageDays = Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000));
- score = ageScore(ageDays);
- }
- }
- if (task.lane === "blocked") return Math.min(100, score + 4);
- if (task.lane === "in-progress") return Math.min(100, score + 2);
- return score;
-}
-
-function summarizeTaskBody(task: TaskCardState): string {
- const trimmed = task.body.trim();
- if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return "";
- return trimmed;
-}
-
-function renderTaskBodyMarkdown(body: string): string {
- if (!body) return "";
- return body
- .replace(/\r\n?/g, "\n")
- .trim()
- .split("\n")
- .map((line) => {
- const trimmed = line.trim();
- if (!trimmed) return '';
-
- let className = "task-card-ui__md-line";
- let content = trimmed;
- let prefix = "";
-
- const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
- if (headingMatch) {
- className += " task-card-ui__md-line--heading";
- content = headingMatch[2];
- } else if (/^[-*]\s+/.test(trimmed)) {
- className += " task-card-ui__md-line--bullet";
- content = trimmed.replace(/^[-*]\s+/, "");
- prefix = "\u2022 ";
- } else if (/^\d+\.\s+/.test(trimmed)) {
- className += " task-card-ui__md-line--bullet";
- content = trimmed.replace(/^\d+\.\s+/, "");
- prefix = "\u2022 ";
- } else if (/^>\s+/.test(trimmed)) {
- className += " task-card-ui__md-line--quote";
- content = trimmed.replace(/^>\s+/, "");
- prefix = "> ";
- }
-
- const html = marked.parseInline(content, { gfm: true, breaks: true }) as string;
- return `${
- prefix ? `${prefix}` : ""
- }${html}`;
- })
- .join("");
-}
-
-function formatTaskDue(task: TaskCardState): string {
- if (!task.due) return "";
- const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`;
- const parsed = new Date(raw);
- if (Number.isNaN(parsed.getTime())) return task.due;
- if (task.due.includes("T")) {
- const label = parsed.toLocaleString([], {
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- });
- return label.replace(/\s([AP]M)$/i, "$1");
- }
- return parsed.toLocaleDateString([], { month: "short", day: "numeric" });
-}
-
-function taskMoveOptions(lane: TaskLane): Array<{ lane: TaskLane; label: string }> {
- return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({
- lane: targetLane,
- label: TASK_ACTION_LABELS[targetLane],
- }));
-}
-
-function taskCardLiveContent(task: TaskCardState, errorText: string): Record {
- return {
- kind: "file_task",
- exists: true,
- task_path: task.taskPath || null,
- task_key: task.taskKey || null,
- title: task.title || null,
- lane: task.lane,
- created: task.created || null,
- updated: task.updated || null,
- due: task.due || null,
- tags: task.tags,
- metadata: task.metadata,
- score: computeTaskScore(task),
- status: task.lane,
- error: errorText || null,
- };
-}
-
-function cloneJsonValue(value: T | null | undefined): T | undefined {
- if (value === null || value === undefined) return undefined;
- try {
- return JSON.parse(JSON.stringify(value)) as T;
- } catch {
- return undefined;
- }
-}
-
-function parseToolPayload(
- result: ManualToolResult | null | undefined,
-): Record | null {
- const payload = result?.parsed;
- if (payload && typeof payload === "object" && !Array.isArray(payload)) {
- return payload as Record;
- }
- const raw = typeof result?.content === "string" ? result.content : "";
- if (!raw.trim()) return null;
- try {
- const parsed = JSON.parse(raw);
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
- ? (parsed as Record)
- : null;
- } catch {
- return null;
- }
-}
-
-function readCardState(script: HTMLScriptElement | null): Record {
- const root = script?.closest("[data-nanobot-card-root]");
- if (!(root instanceof HTMLElement)) return {};
-
- const stateEl = root.querySelector('script[data-card-state][type="application/json"]');
- if (!(stateEl instanceof HTMLScriptElement)) return {};
-
- try {
- const parsed = JSON.parse(stateEl.textContent || "{}");
- return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {};
- } catch {
- return {};
- }
-}
-
-function resolveCardRoot(target: HTMLScriptElement | HTMLElement | null): HTMLElement | null {
- if (!(target instanceof HTMLElement)) return null;
- if (target.matches("[data-nanobot-card-root]")) return target;
- return target.closest("[data-nanobot-card-root]");
-}
-
-function dispatchCardLiveContentChange(cardId: string): void {
- window.dispatchEvent(new CustomEvent(CARD_LIVE_CONTENT_EVENT, { detail: { cardId } }));
-}
-
-function setCardLiveContent(
- target: HTMLScriptElement | HTMLElement | null,
- snapshot: JsonValue | null | undefined,
-): void {
- const root = resolveCardRoot(target);
- const cardId = root?.dataset.cardId?.trim();
- if (!cardId) return;
- const cloned = cloneJsonValue(snapshot ?? undefined);
- if (cloned === undefined) {
- cardLiveContentStore.delete(cardId);
- dispatchCardLiveContentChange(cardId);
- return;
- }
- cardLiveContentStore.set(cardId, cloned);
- dispatchCardLiveContentChange(cardId);
-}
-
-function getCardLiveContent(cardId: string | null | undefined): JsonValue | undefined {
- const key = (cardId || "").trim();
- if (!key) return undefined;
- return cloneJsonValue(cardLiveContentStore.get(key));
-}
-
-function setCardRefreshHandler(
- target: HTMLScriptElement | HTMLElement | null,
- handler: (() => void) | null | undefined,
-): void {
- const root = resolveCardRoot(target);
- const cardId = root?.dataset.cardId?.trim();
- if (!cardId) return;
- if (typeof handler !== "function") {
- cardRefreshHandlers.delete(cardId);
- return;
- }
- cardRefreshHandlers.set(cardId, handler);
-}
-
-function runCardRefresh(cardId: string | null | undefined): boolean {
- const key = (cardId || "").trim();
- if (!key) return false;
- const handler = cardRefreshHandlers.get(key);
- if (!handler) return false;
- handler();
- return true;
-}
-
-function dispatchCardSelectionChange(cardId: string, selection: JsonValue | undefined): void {
- window.dispatchEvent(
- new CustomEvent(CARD_SELECTION_EVENT, {
- detail: { cardId, selection: selection ?? null },
- }),
- );
-}
-
-function setCardSelection(
- target: HTMLScriptElement | HTMLElement | null,
- selection: JsonValue | null | undefined,
-): void {
- const root = resolveCardRoot(target);
- const cardId = root?.dataset.cardId?.trim();
- if (!cardId) return;
- const cloned = cloneJsonValue(selection ?? undefined);
- if (cloned === undefined) {
- cardSelectionStore.delete(cardId);
- dispatchCardSelectionChange(cardId, undefined);
- return;
- }
- cardSelectionStore.set(cardId, cloned);
- dispatchCardSelectionChange(cardId, cloned);
-}
-
-function getCardSelection(cardId: string | null | undefined): JsonValue | undefined {
- const key = (cardId || "").trim();
- if (!key) return undefined;
- return cloneJsonValue(cardSelectionStore.get(key));
-}
-
-function clearCardSelection(cardId: string | null | undefined): void {
- const key = (cardId || "").trim();
- if (!key) return;
- cardSelectionStore.delete(key);
- dispatchCardSelectionChange(key, undefined);
-}
-
-function nextLocalMidnightIso(): string {
- const tomorrow = new Date();
- tomorrow.setHours(24, 0, 0, 0);
- return tomorrow.toISOString();
-}
-
-async function snoozeCardUntilTomorrow(cardId: string | null | undefined): Promise {
- const key = (cardId || "").trim();
- if (!key) throw new Error("card id is required");
- const resp = await fetch(`/cards/${encodeURIComponent(key)}/snooze`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ until: nextLocalMidnightIso() }),
- });
- if (!resp.ok) throw new Error(await decodeJsonError(resp));
- window.dispatchEvent(new Event("nanobot:cards-refresh"));
-}
-
-async function decodeJsonError(resp: Response): Promise {
- try {
- const payload = (await resp.json()) as { error?: unknown };
- if (payload && typeof payload === "object" && typeof payload.error === "string") {
- return payload.error;
- }
- } catch {
- // Ignore invalid error bodies and fall back to the status code.
- }
- return `request failed (${resp.status})`;
-}
-
-async function updateCardTemplateState(
- cardId: string,
- templateState: Record,
-): Promise {
- const key = cardId.trim();
- if (!key) throw new Error("card id is required");
- const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ template_state: templateState }),
- });
- if (!resp.ok) throw new Error(await decodeJsonError(resp));
-}
-
-function normalizeManualToolResult(
- payload: Partial | null | undefined,
- fallbackName: string,
-): ManualToolResult {
- return {
- tool_name: typeof payload?.tool_name === "string" ? payload.tool_name : fallbackName,
- content: typeof payload?.content === "string" ? payload.content : "",
- parsed:
- payload?.parsed === null || payload?.parsed === undefined
- ? null
- : (cloneJsonValue(payload.parsed as JsonValue) ?? null),
- is_json: payload?.is_json === true,
- };
-}
-
-function normalizeManualToolJob(payload: unknown, fallbackName: string): ManualToolJob {
- const record = payload && typeof payload === "object" ? (payload as Record) : {};
- const toolName = typeof record.tool_name === "string" ? record.tool_name : fallbackName;
- const statusValue = typeof record.status === "string" ? record.status : "queued";
- return {
- job_id: typeof record.job_id === "string" ? record.job_id : "",
- tool_name: toolName,
- status:
- statusValue === "running" || statusValue === "completed" || statusValue === "failed"
- ? statusValue
- : "queued",
- created_at: typeof record.created_at === "string" ? record.created_at : "",
- started_at: typeof record.started_at === "string" ? record.started_at : null,
- finished_at: typeof record.finished_at === "string" ? record.finished_at : null,
- result:
- record.result && typeof record.result === "object"
- ? normalizeManualToolResult(record.result as Partial, toolName)
- : null,
- error: typeof record.error === "string" ? record.error : null,
- error_code: typeof record.error_code === "number" ? record.error_code : null,
- };
-}
-
-function normalizeManualToolAsyncOptions(options: ManualToolAsyncOptions): {
- pollMs: number;
- timeoutMs: number;
-} {
- const pollMs =
- typeof options.pollMs === "number" && Number.isFinite(options.pollMs) && options.pollMs >= 100
- ? options.pollMs
- : 400;
- const timeoutMs =
- typeof options.timeoutMs === "number" &&
- Number.isFinite(options.timeoutMs) &&
- options.timeoutMs >= 1000
- ? options.timeoutMs
- : 120000;
- return { pollMs, timeoutMs };
-}
-
-async function waitForManualToolJob(
- initialJob: ManualToolJob,
- toolName: string,
- timeoutMs: number,
- pollMs: number,
-): Promise {
- const deadline = Date.now() + timeoutMs;
- let job = initialJob;
-
- while (true) {
- if (job.status === "completed") {
- if (!job.result) throw new Error("tool job completed without a result");
- return job.result;
- }
- if (job.status === "failed") {
- throw new Error(job.error || `tool job failed (${job.tool_name || toolName})`);
- }
- if (Date.now() >= deadline) {
- throw new Error(`tool job timed out after ${Math.round(timeoutMs / 1000)}s`);
- }
- await new Promise((resolve) => window.setTimeout(resolve, pollMs));
- job = await getManualToolJob(job.job_id);
- }
-}
-
-async function callManualTool(
- toolName: string,
- argumentsValue: Record = {},
-): Promise {
- const name = toolName.trim();
- if (!name) throw new Error("tool name is required");
-
- const cloned = cloneJsonValue(argumentsValue as JsonValue);
- const safeArguments =
- cloned && typeof cloned === "object" && !Array.isArray(cloned)
- ? (cloned as Record)
- : {};
-
- const resp = await fetch("/tools/call", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tool_name: name, arguments: safeArguments }),
- });
- if (!resp.ok) throw new Error(await decodeJsonError(resp));
-
- const payload = await resp.json();
- if (!payload || typeof payload !== "object") {
- throw new Error("invalid tool response");
- }
-
- return normalizeManualToolResult(payload as Partial, name);
-}
-
-async function startManualToolCall(
- toolName: string,
- argumentsValue: Record = {},
-): Promise {
- const name = toolName.trim();
- if (!name) throw new Error("tool name is required");
-
- const cloned = cloneJsonValue(argumentsValue as JsonValue);
- const safeArguments =
- cloned && typeof cloned === "object" && !Array.isArray(cloned)
- ? (cloned as Record)
- : {};
-
- const resp = await fetch("/tools/call", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tool_name: name, arguments: safeArguments, async: true }),
- });
- if (!resp.ok) throw new Error(await decodeJsonError(resp));
-
- const payload = await resp.json();
- if (!payload || typeof payload !== "object") {
- throw new Error("invalid tool job response");
- }
-
- const job = normalizeManualToolJob(payload, name);
- if (!job.job_id) throw new Error("tool job id is required");
- return job;
-}
-
-async function getManualToolJob(jobId: string): Promise {
- const key = jobId.trim();
- if (!key) throw new Error("tool job id is required");
-
- const resp = await fetch(`/tools/jobs/${encodeURIComponent(key)}`, { cache: "no-store" });
- if (!resp.ok) throw new Error(await decodeJsonError(resp));
-
- const payload = await resp.json();
- if (!payload || typeof payload !== "object") {
- throw new Error("invalid tool job response");
- }
-
- return normalizeManualToolJob(payload, "");
-}
-
-async function callManualToolAsync(
- toolName: string,
- argumentsValue: Record = {},
- options: ManualToolAsyncOptions = {},
-): Promise {
- const { pollMs, timeoutMs } = normalizeManualToolAsyncOptions(options);
- const job = await startManualToolCall(toolName, argumentsValue);
- return waitForManualToolJob(job, toolName, timeoutMs, pollMs);
-}
-
-async function listManualTools(): Promise {
- const resp = await fetch("/tools", { cache: "no-store" });
- if (!resp.ok) throw new Error(await decodeJsonError(resp));
-
- const payload = (await resp.json()) as { tools?: unknown };
- const tools = Array.isArray(payload?.tools) ? payload.tools : [];
- return tools
- .filter((tool): tool is Record => !!tool && typeof tool === "object")
- .map((tool) => ({
- name: typeof tool.name === "string" ? tool.name : "",
- description: typeof tool.description === "string" ? tool.description : "",
- parameters:
- tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
- ? (tool.parameters as Record)
- : {},
- kind: typeof tool.kind === "string" ? tool.kind : undefined,
- }))
- .filter((tool) => tool.name);
-}
-
-function ensureCardStateHelper(): void {
- if (!window.__nanobotGetCardState) {
- window.__nanobotGetCardState = readCardState;
- }
- if (!window.__nanobotSetCardLiveContent) {
- window.__nanobotSetCardLiveContent = setCardLiveContent;
- }
- if (!window.__nanobotGetCardLiveContent) {
- window.__nanobotGetCardLiveContent = getCardLiveContent;
- }
- if (!window.__nanobotSetCardRefresh) {
- window.__nanobotSetCardRefresh = setCardRefreshHandler;
- }
- if (!window.__nanobotRefreshCard) {
- window.__nanobotRefreshCard = runCardRefresh;
- }
- if (!window.__nanobotSetCardSelection) {
- window.__nanobotSetCardSelection = setCardSelection;
- }
- if (!window.__nanobotGetCardSelection) {
- window.__nanobotGetCardSelection = getCardSelection;
- }
- if (!window.__nanobotClearCardSelection) {
- window.__nanobotClearCardSelection = clearCardSelection;
- }
- if (!window.__nanobotCallTool) {
- window.__nanobotCallTool = callManualTool;
- }
- if (!window.__nanobotStartToolCall) {
- window.__nanobotStartToolCall = startManualToolCall;
- }
- if (!window.__nanobotGetToolJob) {
- window.__nanobotGetToolJob = getManualToolJob;
- }
- if (!window.__nanobotCallToolAsync) {
- window.__nanobotCallToolAsync = callManualToolAsync;
- }
- if (!window.__nanobotListTools) {
- window.__nanobotListTools = listManualTools;
- }
-}
-
-declare global {
- interface Window {
- __nanobotGetCardState?: (script: HTMLScriptElement | null) => Record;
- __nanobotSetCardLiveContent?: (
- target: HTMLScriptElement | HTMLElement | null,
- snapshot: JsonValue | null | undefined,
- ) => void;
- __nanobotGetCardLiveContent?: (cardId: string | null | undefined) => JsonValue | undefined;
- __nanobotSetCardRefresh?: (
- target: HTMLScriptElement | HTMLElement | null,
- handler: (() => void) | null | undefined,
- ) => void;
- __nanobotRefreshCard?: (cardId: string | null | undefined) => boolean;
- __nanobotSetCardSelection?: (
- target: HTMLScriptElement | HTMLElement | null,
- selection: JsonValue | null | undefined,
- ) => void;
- __nanobotGetCardSelection?: (cardId: string | null | undefined) => JsonValue | undefined;
- __nanobotClearCardSelection?: (cardId: string | null | undefined) => void;
- __nanobotCallTool?: (
- toolName: string,
- argumentsValue?: Record,
- ) => Promise;
- __nanobotStartToolCall?: (
- toolName: string,
- argumentsValue?: Record,
- ) => Promise;
- __nanobotGetToolJob?: (jobId: string) => Promise;
- __nanobotCallToolAsync?: (
- toolName: string,
- argumentsValue?: Record,
- options?: ManualToolAsyncOptions,
- ) => Promise;
- __nanobotListTools?: () => Promise;
- }
-}
+import { DynamicCardBody } from "./CardBodyRenderer";
+import { captureInboxItem, InboxQuickAdd, InboxReviewCard } from "./cardFeed/inbox";
+import { snoozeCardUntilTomorrow } from "./cardFeed/snooze";
interface CardProps {
card: CardItem;
@@ -890,35 +28,6 @@ function MoreIcon() {
);
}
-function CardTextBody({ card }: { card: CardItem }) {
- const bodyRef = useRef(null);
-
- useEffect(() => {
- ensureCardStateHelper();
-
- const root = bodyRef.current;
- if (!root) return;
- const scripts = Array.from(root.querySelectorAll("script"));
- for (const oldScript of scripts) {
- const type = (oldScript.getAttribute("type") || "").trim().toLowerCase();
- if (!EXECUTABLE_SCRIPT_TYPES.has(type)) continue;
- const runtimeScript = document.createElement("script");
- for (const attr of oldScript.attributes) runtimeScript.setAttribute(attr.name, attr.value);
- runtimeScript.text = oldScript.textContent || "";
- oldScript.replaceWith(runtimeScript);
- }
- return () => {
- window.__nanobotSetCardLiveContent?.(bodyRef.current, null);
- window.__nanobotSetCardRefresh?.(bodyRef.current, null);
- window.__nanobotSetCardSelection?.(bodyRef.current, null);
- };
- }, [card.id, card.content]);
-
- const looksLikeHtml = /^\s*<[a-zA-Z]/.test(card.content);
- const html = looksLikeHtml ? card.content : (marked.parse(card.content) as string);
- return ;
-}
-
function CardQuestionBody({
card,
responding,
@@ -956,1073 +65,7 @@ function CardQuestionBody({
);
}
-type TaskEditField = "title" | "body";
-type TaskStatusKind = "error" | "neutral";
-
-function autosizeTaskEditor(editor: HTMLTextAreaElement | null): void {
- if (!editor) return;
- editor.style.height = "0px";
- editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`;
-}
-
-async function callTaskBoardAction(
- argumentsValue: Record,
-): Promise | null> {
- const result = await callManualTool("task_board", argumentsValue);
- const payload = parseToolPayload(result);
- if (payload && typeof payload.error === "string" && payload.error.trim()) {
- throw new Error(payload.error);
- }
- return payload;
-}
-
-function useTaskCardState(card: CardItem) {
- const initialTask = normalizeTaskCardState(card);
- const [task, setTask] = useState(initialTask);
- const [busy, setBusy] = useState(false);
- const [statusLabel, setStatusLabel] = useState("");
- const [statusKind, setStatusKind] = useState("neutral");
- const [errorText, setErrorText] = useState("");
- const [laneMenuOpen, setLaneMenuOpen] = useState(false);
- const [editingField, setEditingField] = useState(null);
- const [draftTitle, setDraftTitle] = useState(initialTask?.title ?? "");
- const [draftBody, setDraftBody] = useState(initialTask?.body ?? "");
- const [holdingTag, setHoldingTag] = useState(null);
-
- useEffect(() => {
- const nextTask = normalizeTaskCardState(card);
- setTask(nextTask);
- setBusy(false);
- setStatusLabel("");
- setStatusKind("neutral");
- setErrorText("");
- setLaneMenuOpen(false);
- setEditingField(null);
- setDraftTitle(nextTask?.title ?? "");
- setDraftBody(nextTask?.body ?? "");
- }, [card.serverId, card.updatedAt]);
-
- return {
- task,
- setTask,
- busy,
- setBusy,
- statusLabel,
- setStatusLabel,
- statusKind,
- setStatusKind,
- errorText,
- setErrorText,
- laneMenuOpen,
- setLaneMenuOpen,
- editingField,
- setEditingField,
- draftTitle,
- setDraftTitle,
- draftBody,
- setDraftBody,
- holdingTag,
- setHoldingTag,
- };
-}
-
-function useTaskCardEffects({
- rootRef,
- laneMenuRef,
- titleEditorRef,
- bodyEditorRef,
- holdTimerRef,
- task,
- errorText,
- laneMenuOpen,
- editingField,
- closeLaneMenu,
-}: {
- rootRef: { current: HTMLDivElement | null };
- laneMenuRef: { current: HTMLDivElement | null };
- titleEditorRef: { current: HTMLTextAreaElement | null };
- bodyEditorRef: { current: HTMLTextAreaElement | null };
- holdTimerRef: { current: number | null };
- task: TaskCardState | null;
- errorText: string;
- laneMenuOpen: boolean;
- editingField: TaskEditField | null;
- closeLaneMenu(): void;
-}) {
- useEffect(() => {
- ensureCardStateHelper();
- }, []);
-
- useEffect(() => {
- return () => {
- if (holdTimerRef.current !== null) {
- window.clearTimeout(holdTimerRef.current);
- }
- window.__nanobotSetCardLiveContent?.(rootRef.current, null);
- window.__nanobotSetCardSelection?.(rootRef.current, null);
- window.__nanobotSetCardRefresh?.(rootRef.current, null);
- };
- }, []);
-
- useEffect(() => {
- if (!laneMenuOpen) return;
- const handlePointerDown = (event: PointerEvent) => {
- if (event.target instanceof Node && !laneMenuRef.current?.contains(event.target)) {
- closeLaneMenu();
- }
- };
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") closeLaneMenu();
- };
- document.addEventListener("pointerdown", handlePointerDown);
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.removeEventListener("pointerdown", handlePointerDown);
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [laneMenuOpen, closeLaneMenu]);
-
- useEffect(() => {
- const editor = editingField === "title" ? titleEditorRef.current : bodyEditorRef.current;
- if (!editor) return;
- autosizeTaskEditor(editor);
- editor.focus();
- const end = editor.value.length;
- editor.setSelectionRange(end, end);
- }, [editingField]);
-
- useEffect(() => {
- window.__nanobotSetCardLiveContent?.(
- rootRef.current,
- task ? taskCardLiveContent(task, errorText) : null,
- );
- }, [task, errorText]);
-}
-
-function createTagHoldHandlers({
- setHoldingTag,
- holdTimerRef,
- removeTag,
-}: {
- setHoldingTag: (value: string | null) => void;
- holdTimerRef: { current: number | null };
- removeTag: (tag: string) => Promise;
-}) {
- const clearTagHold = () => {
- if (holdTimerRef.current !== null) {
- window.clearTimeout(holdTimerRef.current);
- holdTimerRef.current = null;
- }
- setHoldingTag(null);
- };
-
- const beginTagHold = (tag: string, busy: boolean) => {
- if (busy) return;
- clearTagHold();
- setHoldingTag(tag);
- holdTimerRef.current = window.setTimeout(() => {
- holdTimerRef.current = null;
- setHoldingTag(null);
- if (window.confirm(`Remove ${tag} from this task?`)) void removeTag(tag);
- }, 650);
- };
-
- return { beginTagHold, clearTagHold };
-}
-
-async function runTaskBusyAction(
- setBusy: (value: boolean) => void,
- setStatusLabel: (value: string) => void,
- setStatusKind: (value: TaskStatusKind) => void,
- setErrorText: (value: string) => void,
- action: () => Promise,
-) {
- setBusy(true);
- setStatusKind("neutral");
- setStatusLabel("Saving");
- setErrorText("");
- try {
- await action();
- setBusy(false);
- setStatusLabel("");
- } catch (error) {
- console.error("Task card action failed", error);
- setBusy(false);
- setStatusKind("error");
- setStatusLabel("Unavailable");
- setErrorText(error instanceof Error ? error.message : String(error));
- }
-}
-
-function createTaskCardActions({
- task,
- setTask,
- setBusy,
- setStatusLabel,
- setStatusKind,
- setErrorText,
- setLaneMenuOpen,
- setEditingField,
- setDraftTitle,
- setDraftBody,
- setHoldingTag,
- holdTimerRef,
- titleEditorRef,
-}: {
- task: TaskCardState;
- setTask: (
- value: TaskCardState | ((current: TaskCardState | null) => TaskCardState | null),
- ) => void;
- setBusy: (value: boolean) => void;
- setStatusLabel: (value: string) => void;
- setStatusKind: (value: TaskStatusKind) => void;
- setErrorText: (value: string) => void;
- setLaneMenuOpen: (value: boolean | ((current: boolean) => boolean)) => void;
- setEditingField: (value: TaskEditField | null) => void;
- setDraftTitle: (value: string) => void;
- setDraftBody: (value: string) => void;
- setHoldingTag: (value: string | null) => void;
- holdTimerRef: { current: number | null };
- titleEditorRef: { current: HTMLTextAreaElement | null };
-}) {
- const closeLaneMenu = () => setLaneMenuOpen(false);
- const refreshCards = () => {
- closeLaneMenu();
- window.dispatchEvent(new Event("nanobot:cards-refresh"));
- };
-
- const moveTask = async (lane: TaskLane) =>
- runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => {
- const payload = await callTaskBoardAction({ action: "move", task: task.taskPath, lane });
- const nextPath =
- typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath;
- setTask((current) =>
- current
- ? { ...current, taskPath: nextPath, lane, updated: new Date().toISOString() }
- : current,
- );
- refreshCards();
- });
-
- const editField = async (field: TaskEditField, rawValue: string) => {
- const nextValue = rawValue.trim();
- if (field === "title" && !nextValue) {
- titleEditorRef.current?.focus();
- return;
- }
- const currentValue = field === "title" ? task.title : task.body;
- if (nextValue === currentValue) {
- setEditingField(null);
- return;
- }
- await runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => {
- const payload = await callTaskBoardAction({
- action: "edit",
- task: task.taskPath,
- ...(field === "title" ? { title: nextValue } : { description: nextValue }),
- });
- const nextTask = normalizeTaskFromPayload(payload?.task, {
- ...task,
- ...(field === "title" ? { title: nextValue } : { body: nextValue }),
- });
- setTask(nextTask);
- setDraftTitle(nextTask.title);
- setDraftBody(nextTask.body);
- setEditingField(null);
- refreshCards();
- });
- };
-
- const addTag = async () => {
- const raw = window.prompt("Add tag to task", "");
- const tag = raw == null ? "" : normalizeTaskTag(raw);
- if (!tag) return;
- await runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => {
- const payload = await callTaskBoardAction({
- action: "add_tag",
- task: task.taskPath,
- tags: [tag],
- });
- const nextTask = normalizeTaskFromPayload(payload?.task, {
- ...task,
- tags: Array.from(new Set([...task.tags, tag])),
- });
- setTask(nextTask);
- refreshCards();
- });
- };
-
- const removeTag = async (tag: string) =>
- runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => {
- const payload = await callTaskBoardAction({
- action: "remove_tag",
- task: task.taskPath,
- tags: [tag],
- });
- const nextTask = normalizeTaskFromPayload(payload?.task, {
- ...task,
- tags: task.tags.filter((value) => value !== tag),
- });
- setTask(nextTask);
- refreshCards();
- });
-
- const { beginTagHold, clearTagHold } = createTagHoldHandlers({
- setHoldingTag,
- holdTimerRef,
- removeTag,
- });
-
- return { addTag, beginTagHold, clearTagHold, closeLaneMenu, editField, moveTask, refreshCards };
-}
-
-function useTaskCardRefs() {
- return {
- rootRef: useRef(null),
- laneMenuRef: useRef(null),
- titleEditorRef: useRef(null),
- bodyEditorRef: useRef(null),
- holdTimerRef: useRef(null),
- };
-}
-
-function TaskCardHeaderRow({
- lane,
- taskPath,
- laneMenuOpen,
- statusLabel,
- statusKind,
- busy,
- laneMenuRef,
- onToggleMenu,
- onMove,
-}: {
- lane: TaskLane;
- taskPath: string;
- laneMenuOpen: boolean;
- statusLabel: string;
- statusKind: TaskStatusKind;
- busy: boolean;
- laneMenuRef: { current: HTMLDivElement | null };
- onToggleMenu(): void;
- onMove(lane: TaskLane): void;
-}) {
- return (
-
-
-
- {laneMenuOpen && (
-
- )}
-
- {statusLabel ? (
-
- {statusLabel}
-
- ) : null}
-
- );
-}
-
-function TaskCardTitleField({
- task,
- busy,
- editingField,
- draftTitle,
- titleEditorRef,
- onDraftTitle,
- onStartEdit,
- onSave,
- onCancel,
-}: {
- task: TaskCardState;
- busy: boolean;
- editingField: TaskEditField | null;
- draftTitle: string;
- titleEditorRef: { current: HTMLTextAreaElement | null };
- onDraftTitle(value: string): void;
- onStartEdit(): void;
- onSave(): void;
- onCancel(): void;
-}) {
- if (editingField === "title") {
- return (
-