From eba116ed4af8f87ee35c2ed42aa845faedef8d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Rodr=C3=ADguez?= Date: Tue, 28 Jul 2026 16:15:16 +0100 Subject: [PATCH] Add the Tirma interface: chat, live memory panel, pixel athlete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A web front end for the coaching agent, served by the same FastAPI app. Three panes: session history, the conversation, and a context panel holding the client's memory store next to an athlete drawn from it. The athlete is the argument, not decoration. Everything about the pixel figure is derived from the memory store in api/ui_state.py — build from the sessions the coach logged, wrist wraps because an injury entry names the wrist, sleeves because one names the knee. Two rules keep it honest, and they are the coach's own rules from its system prompt: · a constraint stays in force until something clears it, resolved per section, so one file holding wrist + knee + lower back can close one and keep the others; · a partial clearance clears nothing. "Cleared for neutral grip, loaded wrist extension still contraindicated" keeps the wraps on. Reading a partial clearance as a general all-clear is the exact failure the agent exists to avoid, so the drawing must not commit it either. The coaching turn is untouched. api/ui.py relays Coach's own SSE frames and appends two once the store has been re-read — memory.updated and athlete.updated — so the panel and the figure change because the store changed, never because the browser guessed. Also adds DemoCoach: interface-compatible with Coach, no API key, real memory writes. api/ui.py never branches on which one it holds, so the demo and live paths exercise the same code. It exists so the interface could be built without burning tokens, and so a stage demo has a floor. Brand: TAM-50, piece type proto. IBM Plex only by proto role, Carbon icons used verbatim, TAM spacing and radii, no shadows, product palette with role discipline, product_logo_plus_tam_text signature in the footer. Every decision and every deviation is written down in ui/tam-decision.yml, including a conflict between proto.md v0.2 and TAM-50.design.md v0.4 over which signature variant this tier takes. ui/sprite-lab.html renders the sprite outside the app — every tier, every frame, every accessory. It caught two bugs worth having caught: plates clipping off the canvas at lockout, and shoulder tape drawn under the arm. Not verified: a real mobile device, and measured contrast ratios. Both are listed under warnings in the sidecar rather than left implied. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 + README.md | 45 +- api/conversations.py | 148 ++++ api/main.py | 41 +- api/ui.py | 319 ++++++++ api/ui_demo.py | 502 +++++++++++++ api/ui_state.py | 435 +++++++++++ ui/README.md | 154 ++++ ui/index.html | 153 ++++ ui/sprite-lab.html | 94 +++ ui/static/brand/favicon.svg | 10 + ui/static/brand/tirma-mark.svg | 8 + ui/static/brand/tirma-wordmark.svg | 61 ++ ui/static/css/app.css | 690 +++++++++++++++++ ui/static/css/tokens.css | 172 +++++ ui/static/fonts/IBMPlexMono-Medium.woff2 | Bin 0 -> 14888 bytes ui/static/fonts/IBMPlexMono-Regular.woff2 | Bin 0 -> 14708 bytes ui/static/fonts/IBMPlexSans-Italic.woff2 | Bin 0 -> 24356 bytes ui/static/fonts/IBMPlexSans-Medium.woff2 | Bin 0 -> 24184 bytes ui/static/fonts/IBMPlexSans-Regular.woff2 | Bin 0 -> 22588 bytes ui/static/fonts/LICENSE-OFL.txt | 93 +++ ui/static/js/app.js | 862 ++++++++++++++++++++++ ui/static/js/avatar.js | 593 +++++++++++++++ ui/static/js/icons.js | 71 ++ ui/tam-decision.yml | 123 +++ 25 files changed, 4573 insertions(+), 6 deletions(-) create mode 100644 api/conversations.py create mode 100644 api/ui.py create mode 100644 api/ui_demo.py create mode 100644 api/ui_state.py create mode 100644 ui/README.md create mode 100644 ui/index.html create mode 100644 ui/sprite-lab.html create mode 100644 ui/static/brand/favicon.svg create mode 100644 ui/static/brand/tirma-mark.svg create mode 100644 ui/static/brand/tirma-wordmark.svg create mode 100644 ui/static/css/app.css create mode 100644 ui/static/css/tokens.css create mode 100644 ui/static/fonts/IBMPlexMono-Medium.woff2 create mode 100644 ui/static/fonts/IBMPlexMono-Regular.woff2 create mode 100644 ui/static/fonts/IBMPlexSans-Italic.woff2 create mode 100644 ui/static/fonts/IBMPlexSans-Medium.woff2 create mode 100644 ui/static/fonts/IBMPlexSans-Regular.woff2 create mode 100644 ui/static/fonts/LICENSE-OFL.txt create mode 100644 ui/static/js/app.js create mode 100644 ui/static/js/avatar.js create mode 100644 ui/static/js/icons.js create mode 100644 ui/tam-decision.yml diff --git a/.gitignore b/.gitignore index ea0951b..178e358 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ venv/ # Local env .env + +# Conversation scrollback for the Tirma interface (human-facing only — +# the coaching memory lives in the memory store, not here) +.conversations.json +.conversations.json.tmp diff --git a/README.md b/README.md index bbb20da..2e9a25a 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,42 @@ See [`stretch-goals.md`](./stretch-goals.md). - Tie memory to a customer ID via metadata so the agent has per-tenant memory. - Use Files API to attach growing document sets across multiple sessions and watch context grow. +## The interface — Tirma + +There is a web front end for all of this in [`ui/`](./ui/README.md), served by +the same FastAPI app. It shows the conversation, the memory store live beside it, +and the athlete drawn from that memory — a pixel-art lifter that gets stronger as +sessions are logged and puts on wrist wraps when the memory says the wrist is hurt. + +From the repository root: + +```bash +ANTHROPIC_API_KEY="sk-ant-..." uvicorn api.main:app --reload +``` + +Then . The REST API and `/docs` are unchanged. + +No API key, or want a version that cannot fail on stage: + +```bash +TIRMA_DEMO=1 uvicorn api.main:app --reload +``` + +Full detail, including how memory maps onto the athlete, in +[`ui/README.md`](./ui/README.md). + ## Two-minute demo -Side-by-side terminal windows: +**On screen (the interface).** Start with the right-hand panel: seven sessions on +record, two live constraints, and an athlete already wearing wrist wraps and knee +sleeves because the memory says so. Then type one line — *"physio says the knee is +fully cleared now"* — and let the room watch three things happen at once: the +coach reads the training file, closes the knee entry while leaving the wrist +alone, and the sleeves come off the athlete. The wraps stay on, because the wrist +clearance was only partial. That is the whole pitch in fifteen seconds. + +**In the terminal (the mechanism), if you want to show the plumbing.** +Side-by-side windows: - Left: session 1 answer - Right: session 2 answer (same question, after memory + new context) @@ -96,7 +129,15 @@ institutional-memory/ ├── inspect_memory.py (demo helper — prints what's in the memory store) ├── stretch_memory_curator.py (stretch: curator sub-agent) ├── memory_backend.py (deprecated — the old client-side backend; safe to delete) +├── api/ (FastAPI: the coaching API, plus the interface it serves) +│ ├── coach.py (Managed Agents — sessions, memory stores, streaming) +│ ├── registry.py (one client, one memory store) +│ ├── ui.py (the endpoints the interface calls) +│ ├── ui_state.py (memory → athlete; the only place that decides this) +│ ├── ui_demo.py (scripted coach — same interface, no API key) +│ └── conversations.py (chat scrollback) +├── ui/ (the Tirma interface — see ui/README.md) └── synthetic-data/ - ├── round1/ (initial context — onboarding handbook, policies, customer cases) + ├── round1/ (initial context — intake, injuries, preferences) └── round2/ (updates and contradictions) ``` diff --git a/api/conversations.py b/api/conversations.py new file mode 100644 index 0000000..de9abed --- /dev/null +++ b/api/conversations.py @@ -0,0 +1,148 @@ +""" +Conversation threads for the Tirma interface. + +The coaching API creates a fresh Managed Agents session for every +message on purpose — continuity comes from the memory store, not from a +long-lived conversation. That is the right call for the agent and the +wrong shape for a chat window, which needs a scrollback and a history. + +So this module keeps the human-facing half: threads of messages grouped +into named conversations, per client. It holds no coaching state and no +memory. If you delete the file behind it, the coach loses nothing — the +client's training file is still in their memory store, which is the +whole point of the architecture. + +Backed by a JSON file, matching `registry.py`. Swapping in a database +means replacing this module and nothing else. +""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +STORE_PATH = Path(".conversations.json") + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def title_from(text: str, limit: int = 46) -> str: + """A conversation names itself after its first message.""" + clean = " ".join(text.split()) + if len(clean) <= limit: + return clean or "New conversation" + return clean[:limit].rstrip(" ,.;:") + "…" + + +class ConversationStore: + def __init__(self, path: Path = STORE_PATH) -> None: + self._path = path + self._lock = asyncio.Lock() + + # ── persistence ── + + def _read(self) -> dict[str, dict[str, Any]]: + if not self._path.exists(): + return {} + try: + return json.loads(self._path.read_text()) + except json.JSONDecodeError: + # A corrupt scratch file must never take the demo down. + return {} + + def _write(self, data: dict[str, dict[str, Any]]) -> None: + tmp = self._path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=2, default=str)) + tmp.replace(self._path) + + # ── reads ── + + async def for_client(self, client_id: str) -> list[dict[str, Any]]: + """Summaries for the session rail, newest first.""" + async with self._lock: + data = self._read() + rows = [c for c in data.values() if c["client_id"] == client_id] + rows.sort(key=lambda c: c["started_at"], reverse=True) + return [self._summary(c) for c in rows] + + async def get(self, conversation_id: str) -> dict[str, Any] | None: + async with self._lock: + return self._read().get(conversation_id) + + @staticmethod + def _summary(c: dict[str, Any]) -> dict[str, Any]: + return { + "id": c["id"], + "client_id": c["client_id"], + "title": c["title"], + "started_at": c["started_at"], + "message_count": len([m for m in c["messages"] if m["role"] in ("user", "agent")]), + "memory_writes": c.get("memory_writes", 0), + } + + # ── writes ── + + async def create(self, client_id: str, title: str = "New conversation") -> dict[str, Any]: + async with self._lock: + data = self._read() + conversation = { + "id": f"con_{uuid.uuid4().hex[:12]}", + "client_id": client_id, + "title": title, + "started_at": _now(), + "messages": [], + "memory_writes": 0, + "named": False, + } + data[conversation["id"]] = conversation + self._write(data) + return conversation + + async def add_message( + self, conversation_id: str, role: str, text: str + ) -> dict[str, Any] | None: + """Append a message, naming the conversation after the first one.""" + async with self._lock: + data = self._read() + conversation = data.get(conversation_id) + if conversation is None: + return None + message = { + "id": f"msg_{uuid.uuid4().hex[:10]}", + "role": role, + "text": text, + "at": _now(), + } + conversation["messages"].append(message) + if role == "user" and not conversation.get("named"): + conversation["title"] = title_from(text) + conversation["named"] = True + self._write(data) + return self._summary(conversation) + + async def record_writes(self, conversation_id: str, count: int) -> dict[str, Any] | None: + if count <= 0: + return None + async with self._lock: + data = self._read() + conversation = data.get(conversation_id) + if conversation is None: + return None + conversation["memory_writes"] = conversation.get("memory_writes", 0) + count + self._write(data) + return self._summary(conversation) + + async def latest_or_new(self, client_id: str) -> dict[str, Any]: + """The conversation to land in when a client is selected.""" + existing = await self.for_client(client_id) + if existing: + full = await self.get(existing[0]["id"]) + if full: + return full + return await self.create(client_id) diff --git a/api/main.py b/api/main.py index c8cacc4..a7b42a1 100644 --- a/api/main.py +++ b/api/main.py @@ -5,6 +5,7 @@ uvicorn api.main:app --reload Interactive docs at http://127.0.0.1:8000/docs +The Tirma interface at http://127.0.0.1:8000/ """ from __future__ import annotations @@ -13,8 +14,10 @@ from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, HTTPException, Path as PathParam -from fastapi.responses import StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from . import ui from .coach import Coach from .models import ( Client, @@ -23,15 +26,24 @@ MessageRequest, ) from .registry import ClientRegistry +from .ui_demo import DemoCoach -coach = Coach() +# TIRMA_DEMO=1 swaps in a scripted coach that needs no API key. Same +# interface, same events, real memory writes — it exists so the interface +# can be worked on without burning tokens, and so a live demo has +# something to fall back on. +coach = DemoCoach() if ui.demo_mode() else Coach() registry = ClientRegistry() +ui.install(coach, registry) @asynccontextmanager async def lifespan(app: FastAPI): - if not os.environ.get("ANTHROPIC_API_KEY"): - raise RuntimeError("Set ANTHROPIC_API_KEY before starting the API.") + if not ui.demo_mode() and not os.environ.get("ANTHROPIC_API_KEY"): + raise RuntimeError( + "Set ANTHROPIC_API_KEY before starting the API, " + "or run with TIRMA_DEMO=1 for the scripted coach." + ) await coach.ensure_infrastructure() yield @@ -63,11 +75,32 @@ async def get_client( async def health() -> dict: return { "status": "ok", + "demo": ui.demo_mode(), "agent_id": coach.agent_id, "environment_id": coach.environment_id, } +# ── The Tirma interface ───────────────────────────────────────────── +# The router adds the two things a browser needs and the agent does not: +# conversation scrollback, and the athlete derived from memory. The REST +# API below is untouched and still usable on its own. + +app.include_router(ui.router) +app.mount("/static", StaticFiles(directory=ui.UI_DIR / "static"), name="static") + + +@app.get("/", include_in_schema=False) +async def interface() -> FileResponse: + return ui.index() + + +@app.get("/sprite-lab.html", include_in_schema=False) +async def sprite_lab() -> FileResponse: + """A bench for inspecting the pixel athlete. Not part of the product.""" + return FileResponse(ui.UI_DIR / "sprite-lab.html") + + @app.post("/clients", response_model=Client, status_code=201, tags=["clients"]) async def create_client(body: CreateClientRequest) -> Client: """Register a client and provision a memory store for them.""" diff --git a/api/ui.py b/api/ui.py new file mode 100644 index 0000000..a4973d9 --- /dev/null +++ b/api/ui.py @@ -0,0 +1,319 @@ +""" +The endpoints the Tirma interface calls. + +This router sits on top of the coaching API rather than beside it. It +adds exactly two things the browser needs and the agent does not: + + · conversations — a scrollback, because the coach runs a fresh session + per message and so has no thread of its own; + · the athlete — the pixel figure, derived from the memory store by + `ui_state` so the drawing can never disagree with what is remembered. + +Everything else is passed straight through to `Coach` (or `DemoCoach`, +which is interface-compatible). The coaching turn itself is not +reimplemented here — this router relays the coach's own SSE frames and +appends its own once the turn is done. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, AsyncIterator + +from fastapi import APIRouter, Body, HTTPException +from fastapi.responses import FileResponse, StreamingResponse + +from .conversations import ConversationStore +from .models import Client, CreateClientRequest, Document +from .registry import ClientRegistry +from .ui_demo import DEMO_CLIENT_NAME, DEMO_STORE_ID, SUGGESTS, DemoCoach +from .ui_state import MemoryWatcher, derive_athlete, now_iso, parse_memory + +UI_DIR = Path(__file__).resolve().parent.parent / "ui" + +router = APIRouter(prefix="/ui/api", tags=["interface"]) + +conversations = ConversationStore() +watcher = MemoryWatcher() + +# Set by `install()` at import time in main.py. +_coach: Any = None +_registry: ClientRegistry | None = None + + +def demo_mode() -> bool: + return os.environ.get("TIRMA_DEMO", "").lower() in ("1", "true", "yes", "on") + + +def install(coach: Any, registry: ClientRegistry) -> None: + """Hand the router the coach and registry the app is already using.""" + global _coach, _registry + _coach, _registry = coach, registry + + +def _sse(event: str, payload: dict) -> str: + return f"event: {event}\ndata: {json.dumps(payload)}\n\n" + + +async def _ensure_demo_client() -> Client: + """ + In demo mode there is one client on the shelf, pointed at the seeded + store, so the interface has something to show on first load. + """ + assert _registry is not None + for client in await _registry.list(): + if client.memory_store_id == DEMO_STORE_ID: + return client + return await _registry.add(DEMO_CLIENT_NAME, DEMO_STORE_ID) + + +async def _clients() -> list[Client]: + assert _registry is not None + clients = await _registry.list() + if not clients and demo_mode(): + return [await _ensure_demo_client()] + return clients + + +async def _require_client(client_id: str) -> Client: + assert _registry is not None + client = await _registry.get(client_id) + if client is None: + raise HTTPException(404, f"No client with id {client_id!r}") + return client + + +async def _memory_and_athlete(client: Client) -> tuple[dict, dict, list[str]]: + """ + Read the client's memory store and derive everything the right-hand + panel shows. One read, both payloads, so the panel and the athlete + are always the same snapshot. + """ + raw = await _coach.list_memories(client.memory_store_id, True) + entries = parse_memory([m.model_dump() for m in raw]) + fresh = watcher.apply(client.memory_store_id, entries) + memory = { + "store_id": client.memory_store_id, + "updated_at": max((e["updated_at"] for e in entries if e["updated_at"]), + default=now_iso()), + "entries": entries, + } + athlete = derive_athlete(entries, name=client.name) + return memory, athlete, fresh + + +# ── the page ──────────────────────────────────────────────────────── + + +@router.get("/bootstrap") +async def bootstrap(client_id: str | None = None) -> dict[str, Any]: + """Everything the interface needs for a cold start, in one round trip.""" + clients = await _clients() + if not clients: + return { + "demo": demo_mode(), + "agent": {"id": _coach.agent_id, "environment_id": _coach.environment_id}, + "athletes": [], + "active": None, + } + + active = next((c for c in clients if c.client_id == client_id), clients[0]) + memory, athlete, _ = await _memory_and_athlete(active) + conversation = await conversations.latest_or_new(active.client_id) + + return { + "demo": demo_mode(), + "agent": {"id": _coach.agent_id, "environment_id": _coach.environment_id}, + "athletes": [ + {"id": c.client_id, "name": c.name, "store_id": c.memory_store_id} + for c in clients + ], + "active": { + "id": active.client_id, + "name": active.name, + "store_id": active.memory_store_id, + }, + "memory": memory, + "athlete": athlete, + "conversations": await conversations.for_client(active.client_id), + "active_conversation_id": conversation["id"], + "messages": conversation["messages"], + "suggests": SUGGESTS if demo_mode() else [ + "Give me tomorrow's session", + "My left knee hurt during squats today", + "What do you actually remember about me?", + ], + } + + +@router.get("/athletes/{client_id}") +async def read_athlete(client_id: str) -> dict[str, Any]: + """Switch to another client: their memory, their athlete, their threads.""" + client = await _require_client(client_id) + memory, athlete, _ = await _memory_and_athlete(client) + conversation = await conversations.latest_or_new(client_id) + return { + "active": {"id": client.client_id, "name": client.name, + "store_id": client.memory_store_id}, + "memory": memory, + "athlete": athlete, + "conversations": await conversations.for_client(client_id), + "active_conversation_id": conversation["id"], + "messages": conversation["messages"], + } + + +@router.post("/athletes", status_code=201) +async def create_athlete(body: CreateClientRequest) -> dict[str, Any]: + """A new client, with a memory store of their own.""" + assert _registry is not None + store_id = await _coach.create_memory_store(body.name) + client = await _registry.add(body.name, store_id) + memory, athlete, _ = await _memory_and_athlete(client) + conversation = await conversations.create(client.client_id) + return { + "active": {"id": client.client_id, "name": client.name, "store_id": store_id}, + "memory": memory, + "athlete": athlete, + "conversations": await conversations.for_client(client.client_id), + "active_conversation_id": conversation["id"], + "messages": [], + } + + +# ── conversations ─────────────────────────────────────────────────── + + +@router.get("/conversations/{conversation_id}") +async def read_conversation(conversation_id: str) -> dict[str, Any]: + conversation = await conversations.get(conversation_id) + if conversation is None: + raise HTTPException(404, f"No conversation with id {conversation_id!r}") + return {"id": conversation["id"], "title": conversation["title"], + "started_at": conversation["started_at"], + "messages": conversation["messages"]} + + +@router.post("/conversations", status_code=201) +async def create_conversation(client_id: str = Body(..., embed=True)) -> dict[str, Any]: + await _require_client(client_id) + conversation = await conversations.create(client_id) + return { + "conversation": { + "id": conversation["id"], "client_id": client_id, + "title": conversation["title"], "started_at": conversation["started_at"], + "message_count": 0, "memory_writes": 0, + }, + } + + +@router.post("/conversations/{conversation_id}/messages") +async def send( + conversation_id: str, + text: str = Body(..., embed=True), + documents: list[Document] = Body(default_factory=list), +) -> StreamingResponse: + """ + Run one coaching turn and stream it. + + The coach's own frames (`status`, `tool_use`, `message`, `error`, + `done`) are relayed untouched. Once the turn ends we re-read the + memory store and append two more: `memory.updated` and + `athlete.updated`. That is the near-real-time link — the panel and + the pixel athlete change because the store changed, not because the + interface guessed. + """ + conversation = await conversations.get(conversation_id) + if conversation is None: + raise HTTPException(404, f"No conversation with id {conversation_id!r}") + client = await _require_client(conversation["client_id"]) + + await conversations.add_message(conversation_id, "user", text) + + async def relay() -> AsyncIterator[str]: + answer = "" + writes: list[str] = [] + failed = False + + try: + async for frame in _coach.stream_message( + client.memory_store_id, text, documents + ): + yield frame + # Peek at the frames we need to act on afterwards, without + # taking over the coach's contract. + if frame.startswith("event: done"): + payload = _payload(frame) + answer = payload.get("answer", "") + writes = payload.get("memory_writes", []) + elif frame.startswith("event: error"): + failed = True + except Exception as exc: + yield _sse("error", {"detail": f"{type(exc).__name__}: {exc}"}) + failed = True + + if answer: + await conversations.add_message(conversation_id, "agent", answer) + summary = await conversations.record_writes(conversation_id, len(writes)) + + # Re-read the store even on failure — a turn can write memory and + # then fall over, and the panel should show what landed. + try: + memory, athlete, fresh = await _memory_and_athlete(client) + yield _sse("memory.updated", {"memory": memory, "fresh_ids": fresh}) + yield _sse("athlete.updated", {"athlete": athlete}) + except Exception as exc: + yield _sse("error", {"detail": f"Could not re-read memory: {exc}"}) + + if summary is None: + full = await conversations.get(conversation_id) + summary = ConversationStore._summary(full) if full else None + if summary: + yield _sse("conversation.updated", {"conversation": summary}) + + yield _sse("turn.end", {"ok": not failed}) + + return StreamingResponse( + relay(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +def _payload(frame: str) -> dict[str, Any]: + for line in frame.splitlines(): + if line.startswith("data:"): + try: + return json.loads(line[5:].strip()) + except json.JSONDecodeError: + return {} + return {} + + +# ── memory ────────────────────────────────────────────────────────── + + +@router.get("/athletes/{client_id}/memory") +async def read_memory(client_id: str) -> dict[str, Any]: + """Poll the store directly. Used on demand, not on a timer.""" + client = await _require_client(client_id) + memory, athlete, fresh = await _memory_and_athlete(client) + return {"memory": memory, "athlete": athlete, "fresh_ids": fresh} + + +@router.delete("/athletes/{client_id}/memory/{memory_id}", status_code=200) +async def forget(client_id: str, memory_id: str) -> dict[str, Any]: + """The 'forget that' half of memory management.""" + client = await _require_client(client_id) + await _coach.delete_memory(client.memory_store_id, memory_id) + memory, athlete, _ = await _memory_and_athlete(client) + return {"memory": memory, "athlete": athlete} + + +# ── the page itself ───────────────────────────────────────────────── + + +def index() -> FileResponse: + return FileResponse(UI_DIR / "index.html") diff --git a/api/ui_demo.py b/api/ui_demo.py new file mode 100644 index 0000000..a7927ab --- /dev/null +++ b/api/ui_demo.py @@ -0,0 +1,502 @@ +""" +A demo coach that needs no API key. + +`DemoCoach` is interface-compatible with `coach.Coach`: same methods, +same SSE frames. The UI router talks to whichever one it was handed and +cannot tell the difference, so there is one code path rather than two. + +What is faked: the replies are scripted. What is *not* faked is the +mechanism — memory files are really written, updates really overwrite +the previous version, and the athlete is really re-derived from the +resulting store by `ui_state`. Say "my left knee hurt during squats" and +knee sleeves appear because a memory file now says the knee is flagged. + +This exists for two reasons. It lets the interface be built and reviewed +without burning tokens, and it means a live demo has something to fall +back on if the network or the API is having a bad day on stage. + +Enable it with TIRMA_DEMO=1. +""" + +from __future__ import annotations + +import asyncio +import json +import re +import uuid +from dataclasses import dataclass, field +from typing import Any, AsyncIterator + +from .models import Document, Memory + +DEMO_STORE_ID = "memstore_demo_marco_01H9Z" +DEMO_CLIENT_NAME = "Marco Ferreira" + + +def _sse(event: str, payload: dict) -> str: + return f"event: {event}\ndata: {json.dumps(payload)}\n\n" + + +# ── Seed memory ───────────────────────────────────────────────────── +# What the coach would plausibly have written after a few sessions on +# the synthetic-data client. Kept in the coach's own voice — condensed +# training-file notes, not copies of the source documents, which is what +# the system prompt asks for. + +SEED: dict[str, str] = { + "/client-profile.md": """\ +# Marco Ferreira — profile + +34, backend engineer, ~9 hours seated a day. Trained 2023–2024, then 18 months +off. Currently detrained, restarted June 2026. + +- Three sessions a week: Monday, Wednesday, Friday. +- **30 minutes hard cap.** He has said more than once that a 45-minute plan is a + plan he will not do. Respect the cap. +- Sessions at 06:30, before work. Warm-ups under 5 minutes. +""", + + "/goals.md": """\ +# Goals + +1. **Primary: general strength.** He wants to feel capable, not chase a number. + Do not turn this into a numbers programme. +2. **Secondary: run a 5k without walking, by October 2026.** Conditioning work + lives on non-lifting days. Currently blocked — running is out until the knee + review on 2026-08-12. +3. **Explicitly not a goal:** physique work, or anything involving a scale. +""", + + "/injuries.md": """\ +# Injuries and clearances — ACTIVE + +*Authoritative. Overrides programme design. Updated 2026-07-22.* + +## Right wrist — de Quervain's tenosynovitis + +Diagnosed 2026-03-14, under active physio. **Partially cleared 2026-07-15.** + +- Neutral-grip dumbbell pressing now permitted up to 16 kg per hand, up from 8 kg. +- Neutral-grip rowing and pulling at full working load. Dead hangs to 30 s. +- **Still contraindicated:** loaded wrist extension. No flat-palm push-ups, no + planks on hands, no bench dips, no front rack. The clearance is for grip + position, not wrist angle — this is not a general all-clear. +- Reassessment 2026-09-01. + +## Left knee — active, new 2026-07-19 + +Tweaked at five-a-side, assessed 2026-07-22 as minor medial irritation, not +structural. Review 2026-08-12. + +- No deep knee flexion under load. No goblet squats below parallel, no walking + lunges, no step-ups above knee height, no Bulgarian split squats. +- Permitted: box squats to a high box, hip hinges, hip thrusts, leg curls, calves. +- **Running is out until the 2026-08-12 review.** This moves the 5k timeline. + +## Lower back — non-specific sensitivity + +No diagnosis. A bad day roughly monthly after long stretches of sitting. +Avoid loaded spinal flexion. Hip hinges historically help rather than hurt. +""", + + "/equipment.md": """\ +# Equipment — as of 2026-07-24 + +Available: + +- Adjustable dumbbells, 2–24 kg per hand +- Pull-up bar, doorway-mounted, rated 100 kg +- Kettlebell, 16 kg, single +- Resistance bands: light, medium, heavy +- Yoga mat + +**The adjustable bench broke on 2026-07-21.** Replacement due around 2026-08-20. +Until then everything previously programmed on the bench needs a floor-based or +standing substitute. + +Still none of: barbell, rack, cable machine, treadmill. Trains at home. +""", + + "/preferences.md": """\ +# How to coach this client + +- **Burpees: never.** He will skip an entire session that contains them. This is + adherence, not fitness — the preference stands on its own. +- No steady-state cardio inside a strength session. Over 10 continuous minutes + will not get done. 5k work lives on non-lifting days. +- **Absolute numbers, not RPE.** "3 sets of 8 at 14 kg" works. "at RPE 7" does not. +- Supersets, upper paired with lower — kept him inside the time cap and he liked + them. +- Fixed weekly template. Monday should look like last Monday. Novelty is not a + motivator; a varied programme is what he quit during in 2024. +""", + + "/training-log.md": """\ +# What has been programmed + +- **2026-07-27** — Lower, knee-safe. Box squats to high box 3x8 @ 20 kg, + hip thrusts 3x12 @ 24 kg, leg curls with band 3x15. 28 minutes. +- **2026-07-24** — Upper, neutral grip throughout. DB floor press 3x8 @ 16 kg + (first session at the new clearance), DB row 3x10 @ 20 kg, dead hangs 3x20 s. +- **2026-07-20** — Upper. DB floor press 3x8 @ 8 kg, band pull-aparts 3x15. + Bench had just broken; moved to the floor. +- **2026-07-17** — Lower. Goblet squats 3x10 @ 16 kg. Programmed before the knee + was reported. Would not program this today. +- **2026-07-13** — Upper, neutral grip. DB floor press 3x8 @ 8 kg. +- **2026-07-10** — Lower. Hip hinges 3x10 @ 20 kg, calf raises 3x20. +- **2026-07-06** — First session back. Bodyweight and bands only, 22 minutes. + +Adherence has been complete since 2026-07-06. Every session finished inside the +30-minute cap. +""", +} + + +SUGGESTS = [ + "My left knee hurt during squats today", + "Physio says the knee is fully cleared now", + "Give me tomorrow's session", + "What do you actually remember about me?", +] + + +# ── The script ────────────────────────────────────────────────────── + +BODY_PARTS = { + "wrist": "Right wrist", + "knee": "Left knee", + "shoulder": "Shoulder", + "elbow": "Elbow", + "back": "Lower back", + "hip": "Hip", + "ankle": "Ankle", +} + +PAIN = r"hurt|pain|sore|ache|tweak|twinge|flare|strain|sharp|niggl" +RECOVERY = r"\bfine\b|better|healed|cleared|clearance|settled|good now|full(y)? (recovered|cleared)|back to normal|no pain" + + +@dataclass +class Plan: + reads: list[str] = field(default_factory=list) + reply: str = "" + writes: list[tuple[str, str, str]] = field(default_factory=list) # path, content, note + + +class DemoCoach: + """Scripted stand-in for `Coach`. Same methods, same events.""" + + is_demo = True + + def __init__(self) -> None: + self.agent_id = "agent_demo_tirma_coach" + self.environment_id = "env_demo_tirma" + self._stores: dict[str, dict[str, str]] = {DEMO_STORE_ID: dict(SEED)} + + # ── provisioning ── + + async def ensure_infrastructure(self) -> None: + return None + + async def create_memory_store(self, client_name: str) -> str: + store_id = f"memstore_demo_{uuid.uuid4().hex[:10]}" + self._stores[store_id] = { + "/client-profile.md": f"# {client_name} — profile\n\nNew client. Nothing on " + f"record yet: no intake, no injuries, no equipment list.\n", + } + return store_id + + # ── memory ── + + async def list_memories(self, store_id: str, include_content: bool) -> list[Memory]: + files = self._stores.get(store_id, {}) + return sorted( + ( + Memory( + memory_id=f"mem_{re.sub(r'[^a-z0-9]+', '_', path.lower()).strip('_')}", + path=path, + size_bytes=len(content.encode("utf-8")), + content=content if include_content else None, + ) + for path, content in files.items() + ), + key=lambda m: m.path, + ) + + async def delete_memory(self, store_id: str, memory_id: str) -> None: + files = self._stores.get(store_id, {}) + for path in list(files): + slug = re.sub(r"[^a-z0-9]+", "_", path.lower()).strip("_") + if memory_id in (path, f"mem_{slug}"): + del files[path] + return + + # ── the turn ── + + async def stream_message( + self, memory_store_id: str, text: str, documents: list[Document] + ) -> AsyncIterator[str]: + session_id = f"ses_demo_{uuid.uuid4().hex[:8]}" + yield _sse("status", {"session_id": session_id, "state": "running"}) + await asyncio.sleep(0.4) + + plan = self._plan(memory_store_id, text, documents) + + yield _sse("tool_use", {"tool": "bash", "target": "ls /mnt/memory/", "is_memory": True}) + await asyncio.sleep(0.35) + for path in plan.reads: + yield _sse("tool_use", {"tool": "read", "target": f"/mnt/memory{path}", + "is_memory": True}) + await asyncio.sleep(0.3) + + await asyncio.sleep(0.35) + for chunk in _chunks(plan.reply): + yield _sse("message", {"text": chunk}) + await asyncio.sleep(0.03) + + writes: list[str] = [] + files = self._stores.setdefault(memory_store_id, {}) + for path, content, note in plan.writes: + await asyncio.sleep(0.6) + files[path] = content + writes.append(f"/mnt/memory{path}") + yield _sse("tool_use", {"tool": "write", "target": f"/mnt/memory{path}", + "is_memory": True, "note": note}) + + await asyncio.sleep(0.3) + yield _sse("done", {"session_id": session_id, "answer": plan.reply, + "memory_writes": writes}) + + # ── branches ── + + def _plan(self, store_id: str, text: str, documents: list[Document]) -> Plan: + low = text.lower() + files = self._stores.get(store_id, {}) + injuries = files.get("/injuries.md", "") + + # A document was attached — the round-2 move: reconcile and update. + if documents: + names = ", ".join(d.name for d in documents) + return Plan( + reads=["/injuries.md", "/equipment.md", "/training-log.md"], + reply=f"Read {names} against what I already had.\n\n" + f"**This changes things.** I have updated the training file rather " + f"than filing the document alongside it — the file is what I read " + f"next session, and two versions of the truth is how a client gets " + f"hurt.\n\n" + f"Look at the memory panel: the entry that changed shows what it " + f"said before.", + writes=[("/injuries.md", injuries + f"\n\n*Reconciled against {names} " + f"on 2026-07-28.*\n", + "reconciled against attached document")], + ) + + # A new or worsening injury. The athlete visibly gains kit. + if re.search(PAIN, low): + for key, label in BODY_PARTS.items(): + if key in low: + return self._injury(key, label, text, injuries) + + # Something cleared. The kit comes back off. + if re.search(RECOVERY, low): + for key, label in BODY_PARTS.items(): + if key in low: + return self._cleared(key, label, injuries) + + if "burpee" in low: + return Plan( + reads=["/preferences.md", "/injuries.md"], + reply="No. It is in your file twice, and it is an adherence note, not a " + "fitness one — you have told me you will skip the whole session.\n\n" + "It is also contraindicated by your wrist: burpees load wrist " + "extension, which is still off the table even after the July " + "clearance.\n\n" + "If you want the same effect: kettlebell swings with the 16 kg, " + "30 seconds on and 30 off for 8 rounds. Hip hinge, no wrist " + "extension, no knee flexion under load. Nothing to write down here.", + ) + + if re.search(r"what do you (know|remember)|about me|your memory|remember about", low): + return Plan( + reads=["/client-profile.md", "/injuries.md", "/equipment.md", + "/preferences.md", "/training-log.md"], + reply="Seven sessions on record, all inside your 30-minute cap. In the " + "order that changes what I program:\n\n" + "**Two active constraints.** Right wrist, de Quervain's — partially " + "cleared 15 Jul to 16 kg neutral grip, but loaded wrist extension " + "is still out. Left knee, tweaked 19 Jul at five-a-side — no deep " + "flexion under load, and running is out until the 12 Aug review, " + "which moves your 5k.\n\n" + "**Your bench broke on 21 Jul.** Replacement around 20 Aug. " + "Everything is floor-based until then.\n\n" + "**Hard preferences.** No burpees, ever. Absolute numbers, not RPE. " + "Fixed weekly template — Monday looks like last Monday.\n\n" + "Nothing to write from that. You asked me to read my file, not to " + "add to it.", + ) + + # Default: program a session from memory, and log it. + return Plan( + reads=["/injuries.md", "/equipment.md", "/preferences.md", "/training-log.md"], + reply="Based on the left knee still being live and the bench still being " + "broken, tomorrow is upper, floor-based, neutral grip throughout:\n\n" + "1. **DB floor press** — 3x8 at 16 kg per hand.\n" + "2. **DB row**, neutral grip — 3x10 at 20 kg per hand.\n" + "3. **Dead hangs** from the doorway bar — 3x25 seconds.\n" + "4. **Band pull-aparts** — 3x15, heavy band.\n\n" + "Supersets 1 and 2, then 3 and 4. That lands at about 26 minutes with " + "a four-minute warm-up.\n\n" + "What shaped it: no flat-palm work because your wrist clearance covers " + "grip position and not wrist angle; nothing on a bench because you do " + "not currently have one; nothing loading knee flexion.\n\n" + "Logged.", + writes=[("/training-log.md", _prepend_log( + files.get("/training-log.md", "# What has been programmed\n"), + "- **2026-07-28** — Upper, floor-based, neutral grip. DB floor press " + "3x8 @ 16 kg, DB row 3x10 @ 20 kg, dead hangs 3x25 s, band pull-aparts " + "3x15. Supersetted. ~26 minutes."), + "session programmed and logged")], + ) + + def _injury(self, key: str, label: str, text: str, injuries: str) -> Plan: + side = "left" if "left" in text.lower() else ("right" if "right" in text.lower() else "") + named = f"{side.capitalize()} {label.split(' ', 1)[-1].lower()}".strip() if side else label + + prescriptions = { + "knee": ("Box squats to a high box only, and the load comes down 20%. " + "No goblet squats below parallel, no lunges, no step-ups.", + "No deep knee flexion under load. Box squats to a high box, load " + "reduced 20%. Hip hinges, hip thrusts, leg curls and calves " + "permitted. Review in 7 days."), + "wrist": ("This is an escalation on a constraint that was only partially " + "cleared two weeks ago. Neutral-grip load drops back to 8 kg and " + "the dead hangs stop until your 1 Sep reassessment.", + "Escalated. Neutral-grip pressing back down to 8 kg per hand. Dead " + "hangs suspended. Loaded wrist extension remains contraindicated. " + "Flag to physio before 2026-09-01."), + "shoulder": ("Overhead work stops. Pressing goes to a slight incline on the " + "floor, neutral grip, and volume halves.", + "No overhead pressing. Slight-incline neutral-grip floor press " + "only, volume halved. Review in 7 days."), + "back": ("Hip hinges pause — which is unusual for you, since they normally " + "help. Nothing loading the spine for a week.", + "Loaded hip hinges paused. No spinal loading. Review in 7 days. " + "Note: hinges have historically helped this, so a flare during one " + "is worth telling the physio."), + "elbow": ("Pressing volume halves and the bottom of the range comes off. " + "No dead hangs.", + "Press volume halved, bottom range shortened, dead hangs suspended. " + "Review in 7 days."), + "hip": ("Hinges come down to bodyweight and the stance narrows.", + "Hip hinges at bodyweight only, narrower stance. Review in 7 days."), + "ankle": ("Heels get a plate under them and calf work stops.", + "Heel-elevated work only. Calf work paused. Review in 7 days."), + } + spoken, remembered = prescriptions.get( + key, + ("That comes out of the programme until it has been quiet for a full week.", + "Flagged. Loading reduced until quiet for a full week. Review in 7 days."), + ) + + block = (f"\n\n## {named} — active, reported 2026-07-28\n\n" + f"Reported directly by the client during a session. {remembered}\n") + return Plan( + reads=["/injuries.md", "/training-log.md"], + reply=f"**{named} is now flagged**, and it goes in your file — not just into " + f"tomorrow's session. A constraint I only work around once is a " + f"constraint I will have forgotten by next week.\n\n" + f"{spoken}\n\n" + f"Review in seven days. If it is worse rather than quieter before then, " + f"that is a physio conversation and not a programming one — I am not " + f"the person to assess it.\n\n" + f"Watch the right-hand panel: the athlete just picked up the kit that " + f"goes with this.", + writes=[("/injuries.md", (injuries or "# Injuries and clearances — ACTIVE\n") + + block, "new constraint recorded")], + ) + + def _cleared(self, key: str, label: str, injuries: str) -> Plan: + if key not in injuries.lower(): + return Plan( + reads=["/injuries.md"], + reply=f"There is nothing active on the {label.lower()} in your file, so " + f"there is nothing for me to clear. If it was bothering you and I " + f"never wrote it down, tell me and I will fix that.", + ) + updated, found = _clear_section(injuries, key, label) + if not found: + return Plan( + reads=["/injuries.md"], + reply=f"I have something about the {label.lower()} but not as its own " + f"entry, so I cannot cleanly close it. Send me what the physio " + f"actually wrote and I will rewrite the file properly.", + ) + return Plan( + reads=["/injuries.md", "/training-log.md"], + reply=f"Good — and I am closing the {label.lower()} entry rather than leaving " + f"it open forever. A training file that only ever grows is not a " + f"training file, it is a pile.\n\n" + f"Back in carefully: two sessions at 60% of where you left off before " + f"we talk about the original timeline again. I am treating your physio's " + f"clearance as fact, and a clearance is permission to load, not " + f"permission to load like nothing happened.\n\n" + f"The protective kit just came off the athlete on the right. That is " + f"the memory store changing its mind, not a cosmetic reset.", + writes=[("/injuries.md", updated, "constraint cleared and closed")], + ) + + +def _clear_section(raw: str, key: str, label: str, date: str = "2026-07-28") -> tuple[str, bool]: + """ + Rewrite the section for one body part as closed, leaving the others + alone. A coach keeps every constraint in one file, so clearing the + knee must not disturb the wrist. + + Deliberately avoids the words that mark a constraint as still in + force ("active", "re-check", "remains out") — those are what + ui_state reads to decide whether the kit comes off. + """ + out: list[str] = [] + in_target = False + found = False + for line in raw.splitlines(): + if line.startswith("## "): + in_target = key in line.lower() + if in_target: + found = True + out += [ + f"## {label} — CLEARED {date}", + "", + f"Physio clearance reported by the client on {date}. Nothing further " + f"to program around here.", + "", + "Returning to load progressively: two sessions at 60% of the previous " + "working weight before normal progression resumes.", + "", + ] + continue + if not in_target: + out.append(line) + return "\n".join(out), found + + +def _prepend_log(existing: str, line: str) -> str: + lines = existing.splitlines() + if lines and lines[0].startswith("#"): + return "\n".join([lines[0], "", line, *lines[1:]][:400]) + return line + "\n" + existing + + +def _chunks(text: str, size: int = 5) -> list[str]: + """Break the reply into word groups so it streams like a reply.""" + out, buf, count = [], "", 0 + for token in re.split(r"(\s+)", text): + buf += token + if token.strip(): + count += 1 + if count >= size: + out.append(buf) + buf, count = "", 0 + if buf: + out.append(buf) + return out diff --git a/api/ui_state.py b/api/ui_state.py new file mode 100644 index 0000000..bec11a6 --- /dev/null +++ b/api/ui_state.py @@ -0,0 +1,435 @@ +""" +Tirma UI · memory → athlete. + +One job: turn what is actually in a client's memory store into the +athlete the interface draws. The browser never decides this. If the +pixel athlete is wearing wrist wraps, it is because a memory entry says +the wrist is hurt. + +The input is whatever `Coach.list_memories` returns — raw markdown files +with a path and a body. Everything the UI shows (kind, title, summary, +accessories, tier) is derived here, so the interface can't drift from +what the agent chose to remember. + +Nothing in this module talks to the network, so it is straightforward to +test: pass dicts in, assert on dicts out. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# ── Tiers ─────────────────────────────────────────────────────────── +# Driven by sessions the coach has actually logged in memory, not by +# messages sent. Talking about training does not make anyone stronger. +# Fitness is one of the few domains where levels are honest rather than +# imposed — the progression is the point of the product. + +TIERS: list[dict[str, Any]] = [ + {"label": "Twig", "at": 0, "blurb": "Day one. Bar only."}, + {"label": "Sprout", "at": 2, "blurb": "Something is happening."}, + {"label": "Solid", "at": 5, "blurb": "Shirts fit differently now."}, + {"label": "Buff", "at": 10, "blurb": "Doorframes are a consideration."}, + {"label": "Beast", "at": 18, "blurb": "The bar bends a little."}, + {"label": "Absolute Unit", "at": 30, "blurb": "Gravity files a complaint."}, +] + + +# ── Entry kinds ───────────────────────────────────────────────────── +# The kind drives the semantic colour on the left edge of a memory card. +# It is status and risk — an active injury is a different order of thing +# from a stated preference — not an editorial category. + +KIND_SEVERITY = { + "injury": "critical", + "constraint": "warning", + "goal": "info", + "progress": "success", + "program": "info", + "preference": "muted", + "profile": "muted", +} + +_KIND_PATTERNS: list[tuple[str, str]] = [ + ("injury", r"injur|pain|tendon|sprain|strain|flare|physio|niggle|clearance|cleared for"), + ("progress", r"personal record|\bpr\b|1rm|best lift|logged session|training log|progress"), + ("program", r"program|programme|split|routine|block|mesocycle|workout plan"), + ("goal", r"\bgoal|target|aiming for|objective|wants to"), + ("constraint", r"equipment|kit|schedule|availabilit|constraint|no access|only has|travel"), + ("preference", r"prefer|dislike|hates|refuses|will not|likes"), +] + +_STEM_KINDS = { + "injuries": "injury", "injury": "injury", + "limitation": "injury", "limitations": "injury", + "records": "progress", "record": "progress", "log": "progress", "progress": "progress", + "programme": "program", "program": "program", + "goals": "goal", "goal": "goal", + "constraints": "constraint", "constraint": "constraint", "equipment": "constraint", + "preferences": "preference", "preference": "preference", + "profile": "profile", "intake": "profile", +} + + +def infer_kind(path: str, content: str = "") -> str: + """Best guess at an entry's kind. The filename wins, then the text.""" + stem = path.rsplit("/", 1)[-1].lower() + for key, kind in _STEM_KINDS.items(): + if key in stem: + return kind + low = content.lower() + for kind, pattern in _KIND_PATTERNS: + if re.search(pattern, low): + return kind + return "profile" + + +# ── Parsing a raw memory file ─────────────────────────────────────── + + +def _title_and_body(content: str, path: str) -> tuple[str, str]: + """The first markdown heading is the title; the rest is the summary.""" + lines = [ln.rstrip() for ln in (content or "").splitlines()] + title, start = "", 0 + for i, line in enumerate(lines): + if not line.strip(): + continue + heading = re.match(r"^#{1,6}\s+(.*)$", line) + title = (heading.group(1) if heading else re.sub(r"^[-*]\s+", "", line)).strip(" *_#") + start = i + 1 + break + if not title: + title = path.rsplit("/", 1)[-1].removesuffix(".md").replace("-", " ").capitalize() + + body_lines = [] + for line in lines[start:]: + if re.match(r"^#{1,6}\s+", line): + # Fold sub-headings into the flow rather than dropping them. + body_lines.append(re.sub(r"^#{1,6}\s+", "", line).strip() + ":") + else: + body_lines.append(re.sub(r"^[-*]\s+", "· ", line)) + body = re.sub(r"\n{3,}", "\n\n", "\n".join(body_lines).strip()) + return title[:120], body + + +def parse_memory(raw: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Normalise `Coach.list_memories` output into what the panel renders. + + Accepts the `Memory` model dumped to dicts: + `{memory_id, path, size_bytes, content}`. + """ + entries: list[dict[str, Any]] = [] + for item in raw: + path = item.get("path") or "" + content = item.get("content") or "" + title, body = _title_and_body(content, path) + entries.append({ + "id": item.get("memory_id") or item.get("id") or path, + "path": path, + "kind": infer_kind(path, content), + "title": title, + "body": body, + "raw": content, + "size_bytes": item.get("size_bytes") or len(content.encode("utf-8")), + "updated_at": None, + "previous": None, + "superseded_by": None, + "live_text": content, + "cleared": [], + }) + for entry in entries: + resolve_sections(entry) + order = {k: i for i, k in enumerate( + ["injury", "constraint", "progress", "goal", "program", "preference", "profile"])} + entries.sort(key=lambda e: (order.get(e["kind"], 99), e["path"])) + return entries + + +# ── Is a constraint still in force? ───────────────────────────────── +# The system prompt tells the coach a constraint stays live until +# something explicitly clears it, and to read partial clearances +# precisely: "cleared for X" is not "cleared for everything near X". +# +# A coach writes one injuries file with several constraints in it, so +# this has to work per section, not per file. Clearing the knee must +# take the knee sleeves off the athlete without touching the wrist +# wraps — and a *partial* clearance must take nothing off at all. + +_CLEARED = r"cleared|resolved|no longer|healed|discharged|fully recovered|back to full" + +# Anything here keeps a section in force, and beats the clearance words +# above. "Partially cleared … still contraindicated" stays live. +_STILL_LIVE = ( + r"\bactive\b|\bongoing\b|re-?check|reassess|review \d|still contraindicated" + r"|remains? (out|off|contraindicated)|partial|do not read this" +) + +_HEADING = re.compile(r"^#{2,6}\s+(.*)$", re.M) + + +def split_sections(raw: str) -> list[tuple[str | None, str]]: + """Break a memory file into (heading, text). The preamble comes first.""" + raw = raw or "" + marks = list(_HEADING.finditer(raw)) + if not marks: + return [(None, raw)] + out: list[tuple[str | None, str]] = [] + if marks[0].start() > 0: + out.append((None, raw[: marks[0].start()])) + for i, mark in enumerate(marks): + end = marks[i + 1].start() if i + 1 < len(marks) else len(raw) + out.append((mark.group(1).strip(), raw[mark.start(): end])) + return out + + +def section_cleared(text: str) -> bool: + low = text.lower() + if re.search(_STILL_LIVE, low): + return False + return bool(re.search(_CLEARED, low)) + + +def resolve_sections(entry: dict[str, Any]) -> None: + """ + Split an entry into constraints and work out which are still live. + + Sets `live_text` (what the trait rules read) and `cleared` (the + headings that have been closed, so the panel can show them). + """ + sections = split_sections(entry.get("raw", "")) + live, cleared = [], [] + for heading, text in sections: + if heading and section_cleared(text): + cleared.append(heading) + else: + live.append(text) + entry["live_text"] = "\n".join(live) + entry["cleared"] = cleared + # Every constraint in the file is closed → the entry itself is closed. + if entry.get("kind") == "injury" and cleared and not any( + h for h, t in sections if h and not section_cleared(t) + ): + entry["superseded_by"] = "cleared" + + +# ── Traits ────────────────────────────────────────────────────────── +# Each rule reads memory and puts something on the athlete. +# `require_kind` keeps a rule honest: a passing mention of "knee" in a +# goal must not produce knee sleeves — only an injury entry does. + + +@dataclass(frozen=True) +class TraitRule: + trait: str + label: str + pattern: str + severity: str = "muted" + require_kind: tuple[str, ...] = () + note: str = "" + + def matches(self, entry: dict[str, Any]) -> bool: + if self.require_kind and entry.get("kind") not in self.require_kind: + return False + # `live_text` excludes constraints that have been cleared, so a + # healed knee stops producing knee sleeves. + text = entry.get("live_text", entry.get("raw", "")) + return bool(re.search(self.pattern, text.lower())) + + +TRAIT_RULES: tuple[TraitRule, ...] = ( + # On the body — injuries and load. + TraitRule("wrist_brace", "Wrist brace", r"wrist.{0,60}(brace|immobilis|immobiliz|splint)", + "critical", ("injury",), "Braced, not just wrapped"), + TraitRule("wrist_wrap", "Wrist wraps", r"wrist", "critical", ("injury",), + "Wraps on because the wrist is flagged"), + TraitRule("knee_sleeve", "Knee sleeves", r"knee", "critical", ("injury",), + "Sleeves on because the knee is flagged"), + TraitRule("elbow_sleeve", "Elbow sleeves", r"elbow", "critical", ("injury",), + "Sleeves on because the elbow is flagged"), + TraitRule("shoulder_tape", "Shoulder tape", r"shoulder|rotator|impinge", "critical", ("injury",), + "Taped because the shoulder is flagged"), + TraitRule("belt", "Lifting belt", r"deadlift|lower back|heavy squat|\bbelt", + "warning", ("program", "injury", "progress"), "Belt for the heavy pulls"), + TraitRule("headband", "Headband", r"\b5k\b|\brun|cardio|conditioning|zone 2|rowing", + "info", ("goal", "program"), "There is conditioning in the plan"), + TraitRule("cap", "Cap", r"outdoor|outside|\bpark\b|beach|\bsun\b", "info", + ("constraint", "program", "preference"), "Some of it happens outdoors"), + TraitRule("shades", "Shades", r"personal record|\bpr\b|1rm|personal best", "success", + ("progress",), "Earned by a personal record"), + + # On the floor — the small human details. + TraitRule("chalk", "Chalk", r"chalk|grip slip|grip fail|grip strength", "muted", (), + "Grip came up in memory"), + TraitRule("water_bottle", "Water bottle", r"hydrat|water intake|drinks water", "muted", (), + "Hydration is tracked"), + TraitRule("coffee", "Coffee", r"coffee|espresso|caffeine|before work|0[4-7]:\d\d|early morning", + "muted", (), "Trains early, with coffee"), + TraitRule("notebook", "Training log", r"\blog\b|journal|notebook|writes it down|spreadsheet", + "muted", (), "Keeps a written log"), + TraitRule("towel", "Towel", r"towel|sweat", "muted", (), "Mentioned in memory"), + TraitRule("banana", "Banana", r"banana|snack|pre-?workout meal|fuel|breakfast", "muted", (), + "Pre-session fuel is in memory"), + TraitRule("cat", "The cat", r"home gym|garage|spare room|apartment|trains at home", "muted", (), + "Trains at home, and is not alone"), +) + + +# ── How many sessions are on record ───────────────────────────────── + +_DATE_PATTERNS = ( + r"\b\d{4}-\d{2}-\d{2}\b", + r"\b\d{1,2}\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b", +) + + +def count_sessions(entries: Iterable[dict[str, Any]]) -> int: + """ + Count the sessions the coach has written down. + + Every distinct date in a log, record or programme note counts once. + An explicit "session 12" wins if it is higher — some coaches number + them, and their number beats our inference. + """ + dates: set[str] = set() + explicit = 0 + for entry in entries: + if entry.get("kind") not in ("progress", "program"): + continue + low = entry.get("raw", "").lower() + for pattern in _DATE_PATTERNS: + dates.update(m.group(0) for m in re.finditer(pattern, low)) + for m in re.finditer(r"session\s*#?\s*(\d{1,3})", low): + explicit = max(explicit, int(m.group(1))) + return max(len(dates), explicit) + + +def tier_for(sessions_logged: int) -> tuple[int, float]: + """Return (tier index, progress toward the next tier as 0..1).""" + index = 0 + for i, tier in enumerate(TIERS): + if sessions_logged >= tier["at"]: + index = i + if index >= len(TIERS) - 1: + return index, 1.0 + here, nxt = TIERS[index]["at"], TIERS[index + 1]["at"] + span = max(1, nxt - here) + return index, min(1.0, max(0.0, (sessions_logged - here) / span)) + + +def derive_athlete( + entries: Iterable[dict[str, Any]], + *, + name: str = "Athlete", + sessions_logged: int | None = None, +) -> dict[str, Any]: + """ + Build the athlete the UI draws, from the memory store alone. + + Entries marked cleared are skipped — a resolved injury takes the + wraps back off, which is the point of a memory that updates instead + of only appending. + """ + entries = list(entries) + live = [e for e in entries if not e.get("superseded_by")] + if sessions_logged is None: + sessions_logged = count_sessions(live) + + traits: list[dict[str, Any]] = [] + seen: set[str] = set() + for rule in TRAIT_RULES: + if rule.trait in seen: + continue + for entry in live: + if not rule.matches(entry): + continue + # A brace supersedes plain wraps — don't draw both. + if rule.trait == "wrist_wrap" and "wrist_brace" in seen: + break + traits.append({ + "id": rule.trait, + "label": rule.label, + "severity": rule.severity, + "source_id": entry.get("id"), + "source_path": entry.get("path"), + "note": rule.note or f"From {entry.get('path')}", + }) + seen.add(rule.trait) + break + + tier, progress = tier_for(sessions_logged) + has_injury = any(e.get("kind") == "injury" for e in live) + has_record = any(e.get("kind") == "progress" for e in live) + + return { + "name": name, + "tier": tier, + "tier_label": TIERS[tier]["label"], + "blurb": TIERS[tier]["blurb"], + "progress": round(progress, 3), + "sessions_logged": sessions_logged, + "plates": min(3, 1 + tier // 2), + "mood": "neutral" if has_injury else ("grin" if has_record else "neutral"), + "traits": traits, + } + + +# ── Watching a store change ───────────────────────────────────────── +# The memory API carries no per-entry timestamp, and the demo needs to +# show a memory being *changed*, not merely present. So we fingerprint +# every entry the UI has shown: what changed, when, and what it said +# before. That is what fills "updated 20 s ago" and the "what this said +# before" disclosure — real diffs, not decoration. + + +class MemoryWatcher: + """Per-store fingerprints of everything the UI has shown so far.""" + + def __init__(self) -> None: + self._seen: dict[str, dict[str, dict[str, Any]]] = {} + + def apply(self, store_id: str, entries: list[dict[str, Any]]) -> list[str]: + """ + Stamp `entries` in place with `updated_at` / `previous` and return + the ids that are new or changed since the last call. The first + call for a store establishes the baseline and reports nothing as + fresh — everything there predates the page being open. + """ + first_pass = store_id not in self._seen + seen = self._seen.setdefault(store_id, {}) + stamp = now_iso() + fresh: list[str] = [] + + for entry in entries: + digest = hashlib.sha256(entry.get("raw", "").encode("utf-8")).hexdigest() + record = seen.get(entry["path"]) + if record is None: + seen[entry["path"]] = { + "digest": digest, "at": stamp, "body": entry["body"], "previous": None, + } + if not first_pass: + fresh.append(entry["id"]) + elif record["digest"] != digest: + record.update( + digest=digest, at=stamp, previous=record["body"], body=entry["body"], + ) + fresh.append(entry["id"]) + entry["updated_at"] = seen[entry["path"]]["at"] + entry["previous"] = seen[entry["path"]]["previous"] + + # Anything that vanished was deleted — the "forget that" half. + live_paths = {e["path"] for e in entries} + for path in list(seen): + if path not in live_paths: + del seen[path] + + return fresh diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..d22e8d1 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,154 @@ +# Tirma — the interface + +The web front end for the coaching agent. A personal trainer that remembers, +with the memory store on screen and the athlete drawn from it. + +Built by team **Tirma**. Brand tier **TAM-50**, piece type **proto** — see +[`tam-decision.yml`](./tam-decision.yml) for every brand decision and its +justification. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TIRMA Personal Trainer Agent Athlete ▾ agent · store ● Live │ +├───────────────┬─────────────────────────────────┬───────────────────┤ +│ Sessions │ Conversation │ Context │ +│ │ │ │ +│ · this one │ You: my left knee hurt… │ [pixel athlete] │ +│ · last week │ ▏memory read /injuries.md │ Marco Ferreira │ +│ · intake │ Tirma: knee is now flagged… │ ▓▓▓▓░░ SOLID │ +│ │ ▏memory written /injuries.md │ wraps · sleeves │ +│ │ │ ─────────────────│ +│ │ [ tell Tirma how it went ] │ Memory store │ +│ │ │ · injuries.md │ +│ │ │ · equipment.md │ +├───────────────┴─────────────────────────────────┴───────────────────┤ +│ ⌗ An initiative by The Agile Monkeys │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Run it + +Both commands from the repository root. + +With the real agent — needs a workspace API key: + +```bash +ANTHROPIC_API_KEY="sk-ant-..." uvicorn api.main:app --reload +``` + +Without one, on the scripted coach: + +```bash +TIRMA_DEMO=1 uvicorn api.main:app --reload +``` + +Then open . The REST API and its docs are unchanged and +still at `/docs`. + +`?intro=0` skips the brand intro, which is what you want while developing. + +## What the athlete is + +The pixel figure is not decoration and it is not random. Everything about it is +read out of the client's memory store by +[`api/ui_state.py`](../api/ui_state.py): + +| What you see | Where it comes from | +| --- | --- | +| Build, from Twig to Absolute Unit | dated sessions the coach logged in a training-log or programme entry | +| Plates on the bar | the tier, so a stronger athlete lifts more | +| Wrist wraps, knee sleeves, elbow sleeves, shoulder tape | an **injury** entry naming that body part | +| Lifting belt | deadlifts, heavy squats or a lower back note | +| Headband | conditioning or a running goal in the plan | +| Shades | a personal record on file | +| Coffee, bottle, notebook, banana, towel, chalk, the cat | the small human details in profile and preference entries | + +Two rules make this honest rather than cosmetic: + +- **A constraint stays in force until something clears it.** The same rule the + coach's system prompt runs on. Clearing is resolved per section, so a file + holding wrist + knee + lower back can close one and keep the other two. +- **A partial clearance clears nothing.** "Cleared for neutral grip up to 16 kg, + loaded wrist extension still contraindicated" keeps the wraps on. Reading a + partial clearance as a general all-clear is exactly the failure the agent is + built to avoid, so the drawing must not commit it either. + +Clicking a trait chip finds the memory entry that put it there. + +## Where things live + +``` +api/ + main.py their REST API · edited only to mount this interface + coach.py Managed Agents: sessions, memory stores, SSE (theirs) + registry.py one client → one memory store (theirs) + ui.py the endpoints this interface calls + ui_state.py memory → athlete. Pure functions, no network + ui_demo.py DemoCoach — same interface as Coach, no API key + conversations.py chat scrollback, JSON-backed +ui/ + index.html + sprite-lab.html every tier, pose and accessory at a readable size + static/css/ tokens.css = the TAM layer + the Tirma palette + static/js/ avatar.js = the sprite · app.js = the app · icons.js = Carbon + static/brand/ Tirma wordmark, mark, favicon + static/fonts/ IBM Plex Mono + Sans, woff2, under the OFL +``` + +Two things worth knowing before changing anything: + +- **`ui_state.py` is the single source of truth for the athlete.** The browser + renders what it is given and derives nothing. If the sprite and the memory + disagree, the bug is in that one file. +- **`DemoCoach` is interface-compatible with `Coach`.** Same methods, same SSE + frames. `api/ui.py` never branches on which one it has, so the demo path and + the real path exercise the same code. + +## The endpoints the browser uses + +All under `/ui/api`. The coaching turn relays the coach's own frames and appends +its own, so the panel updates because the store changed — not because the +interface guessed. + +| | | +| --- | --- | +| `GET /bootstrap` | everything for a cold start in one round trip | +| `GET /athletes/{client_id}` | switch client: their memory, athlete, threads | +| `POST /athletes` | new client, with a memory store of their own | +| `GET /conversations/{id}` | one thread's messages | +| `POST /conversations` | start a thread | +| `POST /conversations/{id}/messages` | run a turn, stream it back | +| `GET /athletes/{id}/memory` | read the store on demand | +| `DELETE /athletes/{id}/memory/{memory_id}` | forget one entry | + +Frames on a turn, in order: + +``` +status session created, work starting (from Coach) +tool_use a tool call; is_memory marks /mnt/memory/ reads and writes +message a chunk of the reply +done full answer + the memory paths written +memory.updated the store re-read, with fresh_ids for what changed +athlete.updated the figure re-derived from it +conversation.updated +turn.end +error may appear at any point; the turn still finishes cleanly +``` + +## Checked + +Dark and light, 1440×900 and 1280×720, `prefers-reduced-motion` on and off, +keyboard-only through every control. Session history, memory panel and athlete +all survive a reload. A failed turn puts the typed message back in the box +rather than losing it. + +Not checked: a real mobile device, and measured contrast ratios — see +`warnings` in `tam-decision.yml`. + +## The sprite lab + +`/sprite-lab.html` renders the athlete outside the app: every tier, every frame +of the rep, every accessory on its own, and the fully-loaded case. Filter it +with `?group=tiers|phases|traits|loaded`. It is where two real bugs were caught +— plates clipping off the top of the canvas at lockout, and shoulder tape being +drawn under the arm. diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..9eee39a --- /dev/null +++ b/ui/index.html @@ -0,0 +1,153 @@ + + + + + +Tirma · Personal Trainer Agent + + + + + + + + + + + + +
+
+ Tirma + + Personal Trainer Agent +
+ +
+ +
+ + +
+ +
+ +
+
Agent
+
Memory store
+
+ +
+ + + Connecting + + + +
+
+ +
+ + + + + +
+
+
+

Session

+

+
+

+
+ +
+ +
+
    +
    + + + +
    +

    Enter sends · Shift + Enter for a new line

    +
    +
    + + + + +
    + + + + +
    + + + + diff --git a/ui/sprite-lab.html b/ui/sprite-lab.html new file mode 100644 index 0000000..4ffce3c --- /dev/null +++ b/ui/sprite-lab.html @@ -0,0 +1,94 @@ + + + + +Tirma · sprite lab + + + + +

    Sprite lab

    +

    Not part of the product. A bench for checking every tier and pose at a +readable size — open /sprite-lab.html while the server is running.

    +
    + + + + diff --git a/ui/static/brand/favicon.svg b/ui/static/brand/favicon.svg new file mode 100644 index 0000000..68ad9d7 --- /dev/null +++ b/ui/static/brand/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui/static/brand/tirma-mark.svg b/ui/static/brand/tirma-mark.svg new file mode 100644 index 0000000..4022786 --- /dev/null +++ b/ui/static/brand/tirma-mark.svg @@ -0,0 +1,8 @@ + + Tirma + + + + + + diff --git a/ui/static/brand/tirma-wordmark.svg b/ui/static/brand/tirma-wordmark.svg new file mode 100644 index 0000000..cc3e08c --- /dev/null +++ b/ui/static/brand/tirma-wordmark.svg @@ -0,0 +1,61 @@ + + Tirma + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/static/css/app.css b/ui/static/css/app.css new file mode 100644 index 0000000..f74ba7f --- /dev/null +++ b/ui/static/css/app.css @@ -0,0 +1,690 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · app layer + TAM-50 · proto. Mono is the voice of the system, Sans is the human + voice. r4 for contained surfaces, r0 for writing surfaces and lines. + Soft structural separators. No shadows anywhere. + ───────────────────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + display: grid; + grid-template-rows: 64px minmax(0, 1fr) auto; + background: var(--surface); + color: var(--text); + font-family: var(--f-sans); + font-size: var(--fs-b2); + line-height: var(--lh-b2); + -webkit-font-smoothing: antialiased; +} + +/* ── Shared type roles ───────────────────────────────────────────── */ + +.eyebrow { + margin: 0; + font-family: var(--f-mono); + font-size: var(--fs-micro); + line-height: var(--lh-micro); + letter-spacing: var(--tr-caps); + text-transform: uppercase; + color: var(--text-2); + font-weight: 400; +} + +.sr-only { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +.skip-link { + position: absolute; top: var(--s2); left: var(--s2); + z-index: 60; + transform: translateY(-200%); + padding: var(--s2) var(--s4); + background: var(--accent); color: var(--on-accent); + font-family: var(--f-mono); font-size: var(--fs-b2); + text-decoration: none; border-radius: var(--r4); +} +.skip-link:focus { transform: none; } + +/* ── Focus · reinforcement of the component's own stroke ─────────── */ + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} +.btn--primary:focus-visible { outline-offset: 0; } + +/* ── Buttons · single line, 40px, r4. Never a pill. ──────────────── */ + +.btn { + height: 40px; + min-width: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s2); + padding: 0 var(--s5); + border: 1px solid transparent; + border-radius: var(--r4); + background: transparent; + color: var(--text); + font-family: var(--f-mono); + font-size: var(--fs-btn); + letter-spacing: var(--tr-btn); + white-space: nowrap; + cursor: pointer; + transition: background-color 120ms linear, border-color 120ms linear; +} +.btn:active { transform: scale(0.98); } + +.btn--primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } +.btn--primary:hover { background: color-mix(in srgb, var(--accent) 84%, var(--surface)); } +.btn--primary:disabled { background: var(--surface-3); border-color: var(--surface-3); color: var(--text-2); cursor: not-allowed; transform: none; } + +.btn--outline { border-color: var(--line-soft); } +.btn--outline:hover { background: var(--surface-3); } +.btn--outline:focus-visible { outline: none; border: 2px solid var(--focus); } + +.btn--icon { padding: 0; width: 40px; border-color: transparent; color: var(--text-2); } +.btn--icon:hover { background: var(--surface-3); color: var(--text); } + +.btn--textual { + height: auto; min-width: 0; padding: 0; + color: var(--text-2); border: none; +} +.btn--textual:hover { color: var(--text); text-decoration: underline; } +.btn--textual:focus-visible { + outline: none; + text-decoration: underline 2px solid var(--focus); + text-underline-offset: 4px; +} + +/* ── App bar ─────────────────────────────────────────────────────── */ + +.appbar { + display: flex; + align-items: center; + gap: var(--s6); + padding: 0 var(--s5); + border-bottom: 1px solid var(--line-soft); + background: var(--surface-2); +} + +.appbar__brand { display: flex; align-items: center; gap: var(--s3); min-width: 0; } +.appbar__wordmark { display: block; height: 22px; width: auto; } +:root[data-theme="dark"] .appbar__wordmark, +:root[data-theme="dark"] .intro__wordmark { filter: invert(1) brightness(1.9); } +.appbar__divider { width: 1px; height: 20px; background: var(--line-soft); } +.appbar__kind { + font-family: var(--f-mono); font-size: var(--fs-b2); + letter-spacing: var(--tr-mono-sm); color: var(--text-2); +} + +/* Athlete picker. Per-client memory is the architecture, so switching + athlete switches the memory store and the figure drawn from it. The + native select skin never ships — it is rebuilt in the proto grammar. */ +.picker { display: flex; align-items: center; gap: var(--s2); margin-left: var(--s5); } +.picker__label { + font-family: var(--f-mono); font-size: var(--fs-micro); + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.picker__control { position: relative; display: inline-flex; align-items: center; } +.picker__select { + appearance: none; + height: 32px; + padding: 0 var(--s6) 0 var(--s3); + border: 1px solid var(--line-soft); + border-radius: var(--r4); + background: var(--surface-3); + color: var(--text); + font-family: var(--f-mono); font-size: var(--fs-b2); + cursor: pointer; + max-width: 22ch; + text-overflow: ellipsis; +} +.picker__select:hover { border-color: var(--line-strong); } +.picker__select:focus-visible { outline: none; border: 2px solid var(--focus); } +.picker__chevron { + position: absolute; right: var(--s3); top: 50%; + width: 7px; height: 7px; margin-top: -5px; + border-right: 1.5px solid var(--text-2); + border-bottom: 1.5px solid var(--text-2); + transform: rotate(45deg); + pointer-events: none; +} + +.appbar__meta { display: flex; gap: var(--s5); margin: 0; margin-left: auto; } +.appbar__meta > div { display: flex; flex-direction: column; gap: 2px; } +.appbar__meta dt { + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1; + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.appbar__meta dd { + margin: 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1.2; + color: var(--text); max-width: 20ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.appbar__actions { display: flex; align-items: center; gap: var(--s2); } + +/* Live indicator. Green when the stream is open, muted when it isn't — + status, not decoration. */ +.live { display: inline-flex; align-items: center; gap: var(--s2); padding-right: var(--s2); } +.live__dot { width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--sem-muted); } +.live__label { + font-family: var(--f-mono); font-size: var(--fs-micro); + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.live[data-state="live"] .live__dot { background: var(--sem-success); } +.live[data-state="live"] .live__label { color: var(--text); } +.live[data-state="offline"] .live__dot { background: var(--sem-critical); } + +/* ── Three-pane shell. Shared metrics across the panes. ──────────── */ + +.shell { + display: grid; + grid-template-columns: 268px minmax(0, 1fr) 420px; + min-height: 0; +} + +.pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; } +.pane--rail { border-right: 1px solid var(--line-soft); background: var(--surface-2); } +.pane--context { border-left: 1px solid var(--line-soft); background: var(--surface-2); } + +.pane__head { + height: 48px; + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); + padding: 0 var(--s4); + border-bottom: 1px solid var(--line-soft); +} +.pane__head--split { height: auto; padding: var(--s3) var(--s5); align-items: flex-start; } +.pane__headText { min-width: 0; } +.pane__title { + margin: 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b1); line-height: var(--lh-b1); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.pane__sub, .pane__stat { + margin: 2px 0 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} +.pane__stat { text-align: right; white-space: nowrap; padding-top: var(--s1); } +.pane__stamp { + font-family: var(--f-mono); font-size: var(--fs-micro); + color: var(--text-2); +} + +/* ── Session list ────────────────────────────────────────────────── */ + +.sessions { list-style: none; margin: 0; padding: var(--s2); overflow-y: auto; flex: 1 1 auto; } + +.session { + width: 100%; + display: block; + text-align: left; + padding: var(--s3); + border: 1px solid transparent; + border-radius: var(--r4); + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} +.session:hover { background: var(--surface-3); } +.session[aria-current="true"] { background: var(--surface-3); border-color: var(--accent); } +.session:focus-visible { outline: none; border: 2px solid var(--focus); padding: calc(var(--s3) - 1px); } + +.session__title { + margin: 0; + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: var(--lh-b2); + color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.session__meta { + margin: var(--s1) 0 0; + display: flex; align-items: center; gap: var(--s2); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} +.session__writes { display: inline-flex; align-items: center; gap: 4px; color: var(--accent); } +.session[aria-current="true"] .session__title { font-weight: 500; } + +.rail__empty { + margin: 0; padding: var(--s3) var(--s4); + font-family: var(--f-mono); font-size: var(--fs-micro); color: var(--text-2); +} + +.rail__note { + flex: none; + margin: 0; + padding: var(--s3) var(--s4) var(--s4); + border-top: 1px solid var(--line-soft); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} + +/* ── Thread ──────────────────────────────────────────────────────── */ + +.thread { + flex: 1 1 auto; + overflow-y: auto; + padding: var(--s6) var(--s7); + display: flex; + flex-direction: column; + gap: var(--s5); +} + +.msg { display: flex; flex-direction: column; gap: var(--s2); max-width: 68ch; } +.msg--user { align-self: flex-end; align-items: flex-end; } + +.msg__who { + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1; + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.msg__body { + padding: var(--s3) var(--s4); + border: 1px solid var(--line-soft); + border-radius: var(--r4); + background: var(--surface-2); + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: 22px; + white-space: pre-wrap; +} +.msg--user .msg__body { background: var(--surface-3); border-color: var(--line-soft); } +.msg--agent .msg__body { background: var(--surface-2); } +:root[data-theme="light"] .msg--agent .msg__body { background: var(--paper); } +.msg__body strong { font-weight: 500; } +.msg__body em { font-style: italic; } + +/* Memory writes, surfaced inline. This is the demo: you watch the agent + decide what to keep while the right panel changes. */ +.trace { + align-self: flex-start; + display: flex; align-items: center; gap: var(--s2); + max-width: 68ch; + padding: var(--s2) var(--s3); + border-left: 2px solid var(--accent); + background: var(--accent-quiet); + border-radius: var(--r0); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text); +} +.trace--read { border-left-color: var(--line-strong); background: transparent; color: var(--text-2); } +.trace__path { color: var(--text-2); } + +.typing { display: inline-flex; gap: 3px; align-items: center; height: 20px; } +.typing i { width: 4px; height: 4px; background: var(--text-2); animation: blip 1s infinite steps(1); } +.typing i:nth-child(2) { animation-delay: .16s; } +.typing i:nth-child(3) { animation-delay: .32s; } +@keyframes blip { 0%, 40% { opacity: 1; } 41%, 100% { opacity: .25; } } + +.working__clock { + margin-left: var(--s2); + font-family: var(--f-mono); font-size: var(--fs-micro); + color: var(--text-2); +} + +.msg[data-error="true"] .msg__body { border-left: 2px solid var(--sem-critical); } + +.msg__body code { + font-family: var(--f-mono); font-size: 13px; + padding: 1px 4px; + background: var(--surface-4); + border-radius: var(--r4); +} + +.thread__empty { + margin: auto; max-width: 46ch; text-align: center; color: var(--text-2); +} +.thread__empty code { + font-family: var(--f-mono); font-size: 13px; + padding: 1px 4px; background: var(--surface-3); border-radius: var(--r4); +} +.thread__empty h2 { + margin: 0 0 var(--s3); + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b1); line-height: var(--lh-b1); color: var(--text); +} +.thread__empty p { margin: 0; font-size: var(--fs-b2); line-height: 22px; } + +/* ── Composer. Writing surface stays square (r0). ────────────────── */ + +.composer { + flex: none; + padding: var(--s4) var(--s7) var(--s5); + border-top: 1px solid var(--line-soft); +} +.suggests { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s2); margin: 0 0 var(--s3); padding: 0; } +.suggests .btn { height: 32px; padding: 0 var(--s3); font-size: var(--fs-micro); } + +.composer__form { display: flex; align-items: flex-end; gap: var(--s3); } +.composer__input { + flex: 1 1 auto; + min-height: 40px; max-height: 200px; + padding: 10px var(--s3); + border: 1px solid var(--line-strong); + border-radius: var(--r0); + background: var(--surface); + color: var(--text); + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: var(--lh-b2); + resize: none; +} +.composer__input::placeholder { color: var(--text-2); } +.composer__input:focus-visible { outline: none; border: 2px solid var(--focus); padding: 9px 11px; } +.composer__hint { + margin: var(--s2) 0 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} + +/* ── Right pane · the athlete ────────────────────────────────────── */ + +.context__scroll { flex: 1 1 auto; overflow-y: auto; min-height: 0; } + +/* The athlete stays put while the memory list scrolls under it. When a + memory changes, both halves of the change have to be visible at once. */ +.athlete { + position: sticky; + top: 0; + z-index: 2; + padding: 0 0 var(--s5); + background: var(--surface-2); + border-bottom: 1px solid var(--line-soft); +} + +.athlete__stage { + position: relative; + display: flex; justify-content: center; + padding: var(--s4) 0 var(--s2); + background: var(--surface-3); + border-bottom: 1px solid var(--line-soft); +} +.athlete__stage canvas { display: block; image-rendering: pixelated; } + +.athlete__bubble { + position: absolute; top: var(--s4); right: var(--s4); + margin: 0; + padding: var(--s1) var(--s2); + background: var(--text); color: var(--surface); + border-radius: var(--r4); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + opacity: 0; + transition: opacity 160ms linear; +} +.athlete__bubble[data-show="true"] { opacity: 1; } + +.athlete__id { + display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); + padding: var(--s4) var(--s4) 0; +} +.athlete__name { + margin: 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b1); line-height: var(--lh-b1); +} +.athlete__tier { + margin: 0; + font-family: var(--f-mono); font-size: var(--fs-b2); + letter-spacing: var(--tr-caps); text-transform: uppercase; + color: var(--accent); + white-space: nowrap; +} +.athlete__blurb { + margin: var(--s1) 0 0; + padding: 0 var(--s4); + font-family: var(--f-sans); font-style: italic; + font-size: var(--fs-b2); line-height: var(--lh-b2); + color: var(--text-2); +} + +/* Thin progress segment — an allowed use of a bar, not decoration. */ +.meter { padding: var(--s4) var(--s4) 0; } +.meter__track { height: 4px; background: var(--line-soft); border-radius: var(--r0); overflow: hidden; } +.meter__fill { height: 100%; width: 0; background: var(--accent); transition: width 500ms ease-out; } +.meter__legend { + margin: var(--s2) 0 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} + +/* Trait chips. Each one names the memory that put it on the athlete — + click it and that memory highlights. */ +.traits { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s2); margin: var(--s4) 0 0; padding: 0 var(--s4); } +.trait { + display: inline-flex; align-items: center; gap: 6px; + min-height: 24px; + padding: 3px var(--s2); + border: 1px solid var(--line-soft); + border-radius: var(--r4); + background: var(--surface-3); + color: var(--text); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + cursor: pointer; +} +.trait:hover { border-color: var(--accent); } +.trait:focus-visible { outline: none; border: 2px solid var(--focus); padding: 2px 7px; } +.trait__dot { width: 6px; height: 6px; border-radius: var(--r-pill); background: var(--sem-muted); flex: none; } + +/* ── Right pane · memory store ───────────────────────────────────── */ + +.memory { padding: var(--s5) 0 var(--s6); } +.memory__head { + display: flex; align-items: baseline; justify-content: space-between; + padding: 0 var(--s4) var(--s3); +} +.memory__count { font-family: var(--f-mono); font-size: var(--fs-micro); color: var(--text-2); } + +.memory__list { list-style: none; margin: 0; padding: 0 var(--s4); display: flex; flex-direction: column; gap: var(--s2); } + +.mem { + border: 1px solid var(--line-soft); + border-left: 2px solid var(--sem-muted); + border-radius: var(--r4); + background: var(--surface-3); + padding: var(--s3); + transition: background-color 400ms linear, border-color 400ms linear; +} +.mem[data-kind="injury"] { border-left-color: var(--sem-critical); } +.mem[data-kind="constraint"] { border-left-color: var(--sem-warning); } +.mem[data-kind="goal"] { border-left-color: var(--sem-info); } +.mem[data-kind="progress"] { border-left-color: var(--sem-success); } +.mem[data-kind="preference"] { border-left-color: var(--sem-muted); } +.mem[data-kind="profile"] { border-left-color: var(--sem-muted); } + +.mem__top { display: flex; align-items: center; gap: var(--s2); } +.mem__path { + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.mem__kind { + margin-left: auto; flex: none; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1; + letter-spacing: var(--tr-caps); text-transform: uppercase; + color: var(--text-2); +} +.mem__title { + margin: var(--s2) 0 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b2); line-height: var(--lh-b2); +} +.mem__body { + margin: var(--s1) 0 0; + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: 20px; + color: var(--text-2); +} +.mem[data-clamped="true"] .mem__body { + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + overflow: hidden; +} +.mem__more { + margin-top: var(--s2); + padding: 0; border: none; background: none; + color: var(--accent); + font-family: var(--f-mono); font-size: var(--fs-micro); + cursor: pointer; +} +.mem__more:hover { text-decoration: underline; } +.mem__more:focus-visible { + outline: none; + text-decoration: underline 2px solid var(--focus); + text-underline-offset: 4px; +} + +.mem__cleared { + margin: var(--s2) 0 0; + display: flex; align-items: center; gap: 6px; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--sem-success); +} + +.mem__foot { + margin: var(--s2) 0 0; + display: flex; align-items: center; gap: var(--s2); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} +.mem__prev { + margin: var(--s2) 0 0; + border-top: 1px solid var(--line-soft); + padding-top: var(--s2); +} +.mem__prev summary { + font-family: var(--f-mono); font-size: var(--fs-micro); + color: var(--text-2); cursor: pointer; list-style: none; +} +.mem__prev summary::marker { content: ""; } +.mem__prev summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.mem__prev p { + margin: var(--s2) 0 0; + font-family: var(--f-sans); font-size: var(--fs-micro); line-height: 18px; + color: var(--text-2); + text-decoration: line-through; + text-decoration-color: var(--sem-critical); +} + +.mem[data-fresh="true"] { background: var(--accent-quiet); border-color: var(--accent); } +.mem[data-highlight="true"] { border-color: var(--accent); } +.mem__new { + display: inline-flex; align-items: center; gap: 4px; + color: var(--accent); +} +.mem[data-superseded="true"] { opacity: .55; } +.mem[data-superseded="true"] .mem__path { text-decoration: line-through; } + +/* ── Footer signature ────────────────────────────────────────────── */ + +.signature { + display: flex; align-items: center; gap: var(--s2); + padding: var(--s4) var(--s5); + border-top: 1px solid var(--line-soft); + background: var(--surface); +} +.signature__mark { display: block; height: 14px; width: auto; color: var(--text); opacity: .9; } +:root[data-theme="dark"] .signature__mark { filter: invert(1) brightness(1.9); } +.signature__text { + font-family: var(--f-mono); font-size: var(--fs-b2); line-height: var(--lh-b2); + color: var(--text-2); + text-decoration: none; +} +.signature__text:hover { color: var(--text); } +@media (hover: none) { .signature__text { text-decoration: underline; } } +.signature__text:focus-visible { + outline: none; + text-decoration: underline 2px solid var(--focus); + text-underline-offset: 4px; +} + +/* ── Toast ───────────────────────────────────────────────────────── */ + +.toast { + position: fixed; bottom: var(--s7); left: 50%; + transform: translate(-50%, 200%); + z-index: 50; + display: flex; align-items: center; gap: var(--s2); + padding: var(--s3) var(--s4); + background: var(--text); color: var(--surface); + border-radius: var(--r4); + font-family: var(--f-mono); font-size: var(--fs-b2); + visibility: hidden; + transition: transform 240ms ease-out, visibility 240ms linear; +} +.toast[data-show="true"] { transform: translate(-50%, 0); visibility: visible; } + +/* ── Brand intro ─────────────────────────────────────────────────── */ + +.intro { + position: fixed; inset: 0; z-index: 100; + display: grid; place-items: center; + background: var(--basalt-900); + color: var(--ash-100); +} +.intro[hidden] { display: none; } +.intro__inner { display: grid; justify-items: center; gap: var(--s4); padding: var(--s6); text-align: center; } +.intro__eyebrow { color: var(--ash-300); } +.intro__wordmark { + width: min(500px, 62vw); height: auto; + animation: introRise 620ms ease-out both; +} +.intro__claim { + margin: 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-h2); line-height: var(--lh-h2); + animation: introRise 620ms 160ms ease-out both; +} +.intro__stage { animation: introRise 620ms 280ms ease-out both; } +.intro__stage canvas { display: block; image-rendering: pixelated; } +.intro__sig { + margin: 0; + font-family: var(--f-mono); font-size: var(--fs-b2); + color: var(--ash-300); + animation: introRise 620ms 400ms ease-out both; +} +.intro__skip { color: var(--ash-300); margin-top: var(--s2); } +.intro__skip:hover { color: var(--ash-100); } + +@keyframes introRise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} +.intro[data-closing="true"] { opacity: 0; transition: opacity 320ms linear; } + +/* ── Responsive. Desktop is the reference; the panes stack below. ── */ + +@media (max-width: 1180px) { + .shell { grid-template-columns: 220px minmax(0, 1fr) 360px; } + .thread, .composer { padding-left: var(--s5); padding-right: var(--s5); } + .appbar__meta { display: none; } +} + +@media (max-width: 900px) { + body { grid-template-rows: 64px minmax(0, 1fr) auto; } + .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto; } + .pane--rail { border-right: none; border-bottom: 1px solid var(--line-soft); max-height: 168px; } + .pane--context { border-left: none; border-top: 1px solid var(--line-soft); max-height: 52vh; } + .appbar { gap: var(--s3); } + .appbar__kind, .appbar__divider { display: none; } + .intro__claim { font-size: var(--fs-b1); line-height: var(--lh-b1); } +} + +/* ── Reduced motion ──────────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-delay: 0ms !important; + transition-duration: 0.001ms !important; + } +} diff --git a/ui/static/css/tokens.css b/ui/static/css/tokens.css new file mode 100644 index 0000000..318851a --- /dev/null +++ b/ui/static/css/tokens.css @@ -0,0 +1,172 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · design tokens + Tier: TAM-50 · type: proto + + Two layers, kept visually separate in this file: + + 1. INHERITED FROM TAM-100 — non-negotiable. Typography families and + scale, spacing scale, radii, breakpoints, no-shadow rule, the + restricted semantic palette. Do not add values here. + 2. TIRMA PRODUCT PALETTE — product freedom under TAM-50, but with + role discipline: primary, surface, accent, focus, line_soft. + Few colours, each with one job. + ───────────────────────────────────────────────────────────────── */ + +/* ── Fonts · IBM Plex only. proto does NOT use Neue Galano. ──────── */ + +@font-face { + font-family: "IBM Plex Mono"; + src: url("../fonts/IBMPlexMono-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Mono"; + src: url("../fonts/IBMPlexMono-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("../fonts/IBMPlexSans-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("../fonts/IBMPlexSans-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("../fonts/IBMPlexSans-Italic.woff2") format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +:root { + /* ═══ INHERITED · typography families ═════════════════════════════ + proto grammar: Mono = the voice of the system (chrome, labels, + buttons, badges, metadata, technical strings). Sans = the neutral + human voice (what the user reads or writes). Display default is + Sans Medium — Serif is not invoked by this project. */ + --f-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --f-sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif; + + /* ═══ INHERITED · typographic scale ══════════════════════════════ */ + --fs-h1: 40px; --lh-h1: 45px; + --fs-h2: 30px; --lh-h2: 40px; + --fs-b1: 20px; --lh-b1: 30px; + --fs-b2: 14px; --lh-b2: 20px; + --fs-caps: 14px; + --fs-btn: 14px; + /* Sub-scale step. Not in the TAM scale — declared here because a + dense proto needs one register below body_2 for timestamps and + micro-chips. Logged as an open decision in tam-decision.yml. */ + --fs-micro: 12px; --lh-micro: 16px; + + /* Tracking. The TAM kerning table is expressed in unitless figures; + read here as hundredths of an em. Buttons stay at 0 per the + button component spec (no expanded tracking without CAPS). */ + --tr-mono-lg: -0.03em; + --tr-mono-sm: -0.02em; + --tr-caps: 0.05em; + --tr-btn: 0; + + /* ═══ INHERITED · spacing scale · 4pt base ═══════════════════════ */ + --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; + --s6: 32px; --s7: 48px; --s8: 64px; --s9: 96px; --s10: 128px; + + /* ═══ INHERITED · radii ══════════════════════════════════════════ + r0 = separators, lines and writing surfaces (the composer stays + square — functionality is not softened). + r4 = any contained surface that reads as a UI object. + pill = pill_tag only. A button never takes a pill shape. */ + --r0: 0; + --r4: 4px; + --r-pill: 999px; + + /* ═══ INHERITED · restricted semantic palette ════════════════════ + Small footprints only: status dots, chips, thin bars. Never a + surface, never a decorative band, never a category colour. */ + --sem-success: #2FBF71; + --sem-warning: #FFB238; + --sem-critical: #FF6B60; + --sem-info: #79A2FF; + --sem-muted: #A1ACB8; + + /* ═══ TIRMA PRODUCT PALETTE · primitives ═════════════════════════ + Tirma is named after the massif on Gran Canaria. The palette is + basalt (surfaces) plus one lift-violet accent — deliberately off + the semantic hues so product accent and status never read alike. */ + --basalt-900: #0C0D11; + --basalt-800: #12141A; + --basalt-700: #181B22; + --basalt-600: #1F232C; + --basalt-500: #2A2F3A; + --basalt-400: #3A404D; + --ash-300: #999999; + --ash-200: #C9CDD4; + --ash-100: #F2F3F5; + --paper: #FFFFFF; + --paper-2: #F5F5F5; + --paper-3: #EBECEE; + --graphite: #000000; + --graphite-soft: #707070; + --line-light: #DFE1E5; + + --lift-violet: #8C7BFF; /* accent · dark surfaces */ + --lift-violet-deep: #5334F0; /* accent · light surfaces */ + + /* ═══ TIRMA PRODUCT PALETTE · roles ══════════════════════════════ + Components reference roles, never primitives. Mode swaps the + role→primitive mapping; it does not rename hexes. */ + color-scheme: dark; + --surface: var(--basalt-900); + --surface-2: var(--basalt-800); + --surface-3: var(--basalt-700); + --surface-4: var(--basalt-600); + --text: var(--ash-100); + --text-2: var(--ash-300); + --line-soft: var(--basalt-500); + --line-strong: var(--basalt-400); + --accent: var(--lift-violet); + --accent-quiet: color-mix(in srgb, var(--lift-violet) 16%, transparent); + --focus: var(--lift-violet); + --on-accent: var(--basalt-900); +} + +:root[data-theme="light"] { + color-scheme: light; + --surface: var(--paper); + --surface-2: var(--paper-2); + --surface-3: var(--paper-3); + --surface-4: var(--paper-2); + --text: var(--graphite); + --text-2: var(--graphite-soft); + --line-soft: var(--line-light); + --line-strong: var(--graphite); + --accent: var(--lift-violet-deep); + --accent-quiet: color-mix(in srgb, var(--lift-violet-deep) 10%, transparent); + --focus: var(--lift-violet-deep); + --on-accent: var(--paper); + + /* Semantic palette · light pairs. Explicit pairs, not a heuristic + inversion of the dark values. */ + --sem-success: #1E9E57; + --sem-warning: #E18800; + --sem-critical: #D83A34; + --sem-info: #3F6DD8; + --sem-muted: #7C8592; +} + +/* ═══ INHERITED · breakpoints (documentation) ═══════════════════════ + sm 640 · md 1024 · lg 1440 (reference viewport) + Declared in media queries in app.css. Mobile-first is the CSS + convention; the render at 1440 is the visual benchmark. */ diff --git a/ui/static/fonts/IBMPlexMono-Medium.woff2 b/ui/static/fonts/IBMPlexMono-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..090f82f7ec7997047324e07650865305a099969a GIT binary patch literal 14888 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 I#Zefm0=@KU)Bpeg literal 0 HcmV?d00001 diff --git a/ui/static/fonts/IBMPlexSans-Medium.woff2 b/ui/static/fonts/IBMPlexSans-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..6d5527eef47ace16b08cf9932050b83858995e62 GIT binary patch literal 24184 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 nzyI=9;Xy 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 R;$T5nL-p?z%LdxSxC2frlW_n5 literal 0 HcmV?d00001 diff --git a/ui/static/fonts/LICENSE-OFL.txt b/ui/static/fonts/LICENSE-OFL.txt new file mode 100644 index 0000000..2ae8ef3 --- /dev/null +++ b/ui/static/fonts/LICENSE-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/static/js/app.js b/ui/static/js/app.js new file mode 100644 index 0000000..0184375 --- /dev/null +++ b/ui/static/js/app.js @@ -0,0 +1,862 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · app + Renders the three panes and keeps them in step with the coaching API. + + The server is the only source of truth. Nothing here invents state: + the athlete's build and accessories arrive already derived from the + client's memory store (api/ui_state.py), so the pixel figure and the + agent can never disagree about what is remembered. + + A coaching turn is one POST that streams Server-Sent Events back: + the coach's own status / tool_use / message / done frames, then the + two this interface adds once the store has been re-read — + memory.updated and athlete.updated. + ───────────────────────────────────────────────────────────────── */ + +import { icon } from "./icons.js"; +import { AvatarStage, TIERS, TRAIT_LABELS } from "./avatar.js"; + +const $ = (id) => document.getElementById(id); + +const el = { + metaAgent: $("metaAgent"), + metaStore: $("metaStore"), + live: $("liveState"), + themeToggle: $("themeToggle"), + replayIntro: $("replayIntro"), + picker: $("athletePicker"), + newAthlete: $("newAthlete"), + newSession: $("newSession"), + sessionList: $("sessionList"), + railEmpty: $("railEmpty"), + railNote: $("railNote"), + sessionTitle: $("sessionTitle"), + sessionSub: $("sessionSub"), + sessionStat: $("sessionStat"), + thread: $("thread"), + suggests: $("suggests"), + form: $("composerForm"), + input: $("composerInput"), + sendBtn: $("sendBtn"), + contextStamp: $("contextStamp"), + athleteCanvas: $("athleteCanvas"), + athleteBubble: $("athleteBubble"), + athleteName: $("athleteName"), + athleteTier: $("athleteTier"), + athleteBlurb: $("athleteBlurb"), + meterFill: $("meterFill"), + meterLegend: $("meterLegend"), + traits: $("traits"), + memoryList: $("memoryList"), + memoryCount: $("memoryCount"), + toast: $("toast"), + intro: $("intro"), + introCanvas: $("introCanvas"), + introSkip: $("introSkip"), +}; + +const state = { + demo: false, + agent: null, + athletes: [], + active: null, // { id, name, store_id } + athlete: null, // derived figure + memory: { entries: [], store_id: null, updated_at: null }, + conversations: [], + conversationId: null, + messages: [], + suggests: [], + busy: false, + streamText: "", + streamBody: null, +}; + +let stage = null; +let introStage = null; +const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +/* ── helpers ─────────────────────────────────────────────────────── */ + +const esc = (s) => + String(s ?? "").replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +/** Just enough inline markup for the coach's replies. Escaped first. */ +function inline(text) { + return esc(text) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2") + .replace(/`([^`]+)`/g, "$1"); +} + +function ago(iso) { + if (!iso) return ""; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const s = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (s < 45) return "just now"; + if (s < 3600) return `${Math.round(s / 60)} min ago`; + if (s < 86400) return `${Math.round(s / 3600)} h ago`; + const d = Math.round(s / 86400); + if (d === 1) return "yesterday"; + if (d < 30) return `${d} days ago`; + return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short" }); +} + +const shortId = (id) => + !id ? "—" : id.length > 22 ? `${id.slice(0, 10)}…${id.slice(-8)}` : id; + +let toastTimer = null; +function toast(message, iconName = "checkmark") { + el.toast.innerHTML = `${icon(iconName, { size: 16 })}${esc(message)}`; + el.toast.dataset.show = "true"; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.toast.dataset.show = "false"; }, 4000); +} + +/* ── theme ───────────────────────────────────────────────────────── */ + +function surfaceColours() { + const cs = getComputedStyle(document.documentElement); + const v = (n) => cs.getPropertyValue(n).trim(); + return { + floor: v("--surface-4") || "#1F232C", + floorEdge: v("--line-soft") || "#2A2F3A", + shadow: v("--line-strong") || "#3A404D", + }; +} + +function applyTheme(theme) { + document.documentElement.dataset.theme = theme; + const toLight = theme === "dark"; + el.themeToggle.innerHTML = icon(toLight ? "light" : "asleep", { size: 20 }); + el.themeToggle.setAttribute("aria-label", `Switch to ${toLight ? "light" : "dark"} mode`); + try { localStorage.setItem("tirma.theme", theme); } catch {} + stage?.setSurface(surfaceColours()); +} + +function initTheme() { + let saved = null; + try { saved = localStorage.getItem("tirma.theme"); } catch {} + applyTheme(saved || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")); +} + +/* ── the athlete ─────────────────────────────────────────────────── */ + +const SEVERITY_COLOUR = { + critical: "var(--sem-critical)", + warning: "var(--sem-warning)", + info: "var(--sem-info)", + success: "var(--sem-success)", + muted: "var(--sem-muted)", +}; + +function renderAthlete(athlete) { + if (!athlete) return; + const previous = state.athlete; + state.athlete = athlete; + + const tier = TIERS[Math.min(athlete.tier ?? 0, TIERS.length - 1)]; + el.athleteName.textContent = athlete.name || "Athlete"; + el.athleteTier.textContent = athlete.tier_label || tier.label; + el.athleteBlurb.textContent = athlete.blurb || tier.blurb; + + const pct = Math.round((athlete.progress ?? 0) * 100); + el.meterFill.style.width = `${Math.min(100, pct)}%`; + const atTop = (athlete.tier ?? 0) >= TIERS.length - 1; + const next = TIERS[Math.min((athlete.tier ?? 0) + 1, TIERS.length - 1)]; + const n = athlete.sessions_logged ?? 0; + el.meterLegend.textContent = atTop + ? `${n} sessions on record · top tier` + : `${n} ${n === 1 ? "session" : "sessions"} on record · ${pct}% to ${next.label}`; + + const traits = athlete.traits || []; + el.traits.innerHTML = traits.length + ? traits.map((t) => ` +
  • `).join("") + : `
  • Nothing on the athlete yet — the memory store is empty.
  • `; + + if (!stage) return; + stage.setSpec({ + tier: athlete.tier ?? 0, + progress: athlete.progress ?? 0, + traits: traits.map((t) => t.id), + mood: athlete.mood || "neutral", + sessions_logged: n, + plates: athlete.plates ?? 1, + }); + + if (previous && previous.name === athlete.name) { + if ((athlete.tier ?? 0) > (previous.tier ?? 0)) { + stage.celebrate(); + toast(`Level up · ${athlete.tier_label || tier.label}`, "trophy"); + say("LEVEL UP"); + } else { + const before = new Set((previous.traits || []).map((t) => t.id)); + const gained = traits.filter((t) => !before.has(t.id)); + if (gained.length) say(gained[0].label.toLowerCase()); + } + } +} + +let bubbleTimer = null; +function say(text) { + el.athleteBubble.textContent = text; + el.athleteBubble.dataset.show = "true"; + clearTimeout(bubbleTimer); + bubbleTimer = setTimeout(() => { el.athleteBubble.dataset.show = "false"; }, 1600); +} + +/* ── the memory panel ────────────────────────────────────────────── */ + +const KIND_ICON = { + injury: "healthCross", + constraint: "warning", + goal: "flash", + progress: "trophy", + preference: "user", + profile: "notebook", + program: "list", +}; + +function memoryNode(entry) { + const li = document.createElement("li"); + li.className = "mem"; + li.dataset.kind = entry.kind || "profile"; + li.dataset.id = entry.id; + if (entry.superseded_by) li.dataset.superseded = "true"; + + const previous = entry.previous + ? `
    + ${icon("renew", { size: 12 })} What this said before +

    ${esc(entry.previous)}

    +
    ` + : ""; + + // A coach's training file runs long. Clamp it and let the reader open + // it — the panel is a summary of the store, not a document viewer. + const long = (entry.body || "").length > 240; + if (long) li.dataset.clamped = "true"; + + // Constraints closed inside a file that still holds live ones. This is + // the reconciliation made visible: the knee is cleared, the wrist is not. + const cleared = (entry.cleared || []).length + ? `

    ${icon("checkmark", { size: 12 })} + cleared · ${entry.cleared.map(esc).join(" · ")}

    ` + : ""; + + li.innerHTML = ` +
    + ${icon(KIND_ICON[entry.kind] || "notebook", { size: 14 })} + ${esc(entry.path)} + ${esc(entry.kind || "note")} +
    +

    ${esc(entry.title || "")}

    +

    ${inline(entry.body || "")}

    + ${long ? `` : ""} + ${cleared} +

    + ${icon("time", { size: 12 })}${esc(ago(entry.updated_at) || "on record")} + ${entry.superseded_by ? `· ${esc(entry.superseded_by)}` : ""} +

    + ${previous}`; + return li; +} + +function renderMemory(memory, freshIds = []) { + state.memory = memory || { entries: [] }; + const entries = state.memory.entries || []; + el.memoryCount.textContent = `${entries.length} ${entries.length === 1 ? "memory" : "memories"}`; + el.contextStamp.textContent = state.memory.updated_at + ? `updated ${ago(state.memory.updated_at)}` + : ""; + el.metaStore.textContent = shortId(state.memory.store_id); + el.metaStore.title = state.memory.store_id || ""; + + el.memoryList.replaceChildren(...entries.map(memoryNode)); + + for (const id of freshIds) { + const node = el.memoryList.querySelector(`[data-id="${CSS.escape(id)}"]`); + if (!node) continue; + node.dataset.fresh = "true"; + const foot = node.querySelector(".mem__foot"); + if (foot) { + const tag = document.createElement("span"); + tag.className = "mem__new"; + tag.innerHTML = `${icon("arrowUp", { size: 12 })}just written`; + foot.prepend(tag); + } + } + // Deliberately not scrolled into view: the athlete sits above the list + // and is the other half of the change. Scrolling to the entry would + // hide the thing the entry just altered. + if (freshIds.length && !reduced) { + setTimeout(() => { + el.memoryList.querySelectorAll('[data-fresh="true"]') + .forEach((n) => { n.dataset.fresh = "false"; }); + }, 3600); + } +} + +function highlightMemory(id) { + if (!id) return; + const node = el.memoryList.querySelector(`[data-id="${CSS.escape(id)}"]`); + if (!node) { toast("That memory is not in the store any more", "information"); return; } + el.memoryList.querySelectorAll("[data-highlight]").forEach((n) => delete n.dataset.highlight); + node.dataset.highlight = "true"; + node.scrollIntoView({ block: "nearest", behavior: reduced ? "auto" : "smooth" }); + setTimeout(() => { delete node.dataset.highlight; }, 3200); +} + +/* ── sessions rail ───────────────────────────────────────────────── */ + +function renderSessions() { + el.sessionList.replaceChildren(...state.conversations.map((c) => { + const li = document.createElement("li"); + const active = c.id === state.conversationId; + li.innerHTML = ` + `; + return li; + })); + el.railEmpty.hidden = state.conversations.length > 0; + el.railNote.textContent = state.active + ? `Every session reads and writes ${state.active.name}'s own memory store.` + : ""; +} + +function renderSessionHeader() { + const c = state.conversations.find((x) => x.id === state.conversationId); + el.sessionTitle.textContent = c ? c.title : "New session"; + el.sessionSub.textContent = c + ? `${c.id} · started ${ago(c.started_at)}` + : "Nothing said yet"; + const writes = c?.memory_writes ?? 0; + el.sessionStat.textContent = writes + ? `${writes} memory ${writes === 1 ? "write" : "writes"} this session` + : "no memory writes yet"; +} + +/* ── thread ──────────────────────────────────────────────────────── */ + +function messageNode(msg) { + const div = document.createElement("div"); + div.className = `msg msg--${msg.role === "user" ? "user" : "agent"}`; + div.dataset.id = msg.id || ""; + div.innerHTML = ` +

    ${msg.role === "user" ? "You" : "Tirma"}

    +
    ${inline(msg.text)}
    `; + return div; +} + +function traceNode({ tool, target, is_memory, note }) { + const div = document.createElement("div"); + div.className = `trace ${is_memory ? "" : "trace--read"}`; + const writing = /write|edit|create|str_replace/i.test(tool || ""); + const label = is_memory ? `memory ${writing ? "written" : "read"}` : `tool · ${tool}`; + div.innerHTML = ` + ${icon(is_memory ? (writing ? "notebook" : "book") : "flash", { size: 14 })} + ${esc(label)} + ${target ? `${esc(target)}` : ""} + ${note ? `· ${esc(note)}` : ""}`; + return div; +} + +const nearBottom = () => + el.thread.scrollHeight - el.thread.scrollTop - el.thread.clientHeight < 160; + +function scrollThread(force = false) { + if (force || nearBottom()) el.thread.scrollTop = el.thread.scrollHeight; +} + +function renderThread() { + if (!state.messages.length) { + el.thread.innerHTML = ` +
    +

    Tirma already knows this athlete.

    +

    Everything in the right-hand panel came out of earlier sessions. + Say how training went, or report a niggle, and watch the memory + store — and the athlete — change as it lands.

    +
    `; + return; + } + el.thread.replaceChildren(...state.messages.map(messageNode)); + scrollThread(true); +} + +function append(node) { + if (!state.messages.length && !el.thread.querySelector(".msg, .trace")) { + el.thread.innerHTML = ""; + } + el.thread.append(node); + scrollThread(); + return node; +} + +/* Working row. A real turn takes 30–90 s, so it counts up: a silent + minute reads as broken, a counted one reads as work. */ +let workTimer = null; +function showWorking() { + hideWorking(); + const started = Date.now(); + const div = document.createElement("div"); + div.className = "msg msg--agent"; + div.id = "workingRow"; + div.innerHTML = ` +

    Tirma

    +
    + + reading the training file +
    `; + append(div); + workTimer = setInterval(() => { + const clock = $("workingClock"); + if (clock) clock.textContent = `working · ${Math.round((Date.now() - started) / 1000)}s`; + }, 1000); +} + +function hideWorking() { + clearInterval(workTimer); + workTimer = null; + $("workingRow")?.remove(); +} + +function setBusy(busy) { + state.busy = busy; + el.sendBtn.disabled = busy; + el.input.disabled = busy; + el.sendBtn.querySelector("span").textContent = busy ? "Working…" : "Send message"; + el.suggests.querySelectorAll("button").forEach((b) => { b.disabled = busy; }); +} + +function renderSuggests() { + el.suggests.innerHTML = (state.suggests || []).map((s) => + `
  • ` + ).join(""); +} + +/* ── server ──────────────────────────────────────────────────────── */ + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { "content-type": "application/json" }, + ...options, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`${res.status} ${detail.slice(0, 200)}`); + } + return res.status === 204 ? null : res.json(); +} + +function setLive(name, label) { + el.live.dataset.state = name; + el.live.querySelector(".live__label").textContent = label; +} + +/** Parse an SSE body off a fetch response. */ +async function* frames(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let cut; + while ((cut = buffer.indexOf("\n\n")) >= 0) { + const raw = buffer.slice(0, cut); + buffer = buffer.slice(cut + 2); + let event = "message"; + let data = ""; + for (const line of raw.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) data += line.slice(5).trim(); + } + if (data) { + try { yield { event, data: JSON.parse(data) }; } catch { /* keepalive */ } + } + } + } +} + +/* ── loading ─────────────────────────────────────────────────────── */ + +function adopt(payload) { + if (payload.active) { + state.active = payload.active; + el.picker.value = payload.active.id; + } + if (payload.conversations) state.conversations = payload.conversations; + if (payload.active_conversation_id) state.conversationId = payload.active_conversation_id; + if (payload.messages) state.messages = payload.messages; + if (payload.memory) renderMemory(payload.memory); + if (payload.athlete) renderAthlete(payload.athlete); + renderSessions(); + renderSessionHeader(); + renderThread(); +} + +async function boot() { + const data = await api("/ui/api/bootstrap"); + state.demo = !!data.demo; + state.agent = data.agent; + state.athletes = data.athletes || []; + state.suggests = data.suggests || []; + + el.metaAgent.textContent = shortId(data.agent?.id); + el.metaAgent.title = data.agent?.id || ""; + el.picker.innerHTML = state.athletes + .map((a) => ``) + .join(""); + renderSuggests(); + + if (!state.athletes.length) { + el.thread.innerHTML = ` +
    +

    No athlete yet.

    +

    Add one with the + beside the picker. Each athlete gets a memory + store of their own, so one person's injuries can never turn up in + someone else's plan.

    +
    `; + setLive("live", state.demo ? "Demo" : "Ready"); + return; + } + + adopt(data); + // Reset the previous-athlete comparison so the first render never + // claims a level-up that only happened because the page loaded. + state.athlete = null; + renderAthlete(data.athlete); + setLive("live", state.demo ? "Demo" : "Live"); + el.athleteBubble.dataset.show = "false"; +} + +async function switchAthlete(id) { + setLive("connecting", "Loading"); + try { + state.athlete = null; + adopt(await api(`/ui/api/athletes/${encodeURIComponent(id)}`)); + setLive("live", state.demo ? "Demo" : "Live"); + } catch { + setLive("offline", "Failed"); + toast("Could not load that athlete", "warning"); + } +} + +async function addAthlete() { + const name = prompt("Athlete's name"); + if (!name || !name.trim()) return; + try { + const data = await api("/ui/api/athletes", { + method: "POST", + body: JSON.stringify({ name: name.trim() }), + }); + state.athletes.push({ id: data.active.id, name: data.active.name, store_id: data.active.store_id }); + el.picker.innerHTML = state.athletes + .map((a) => ``) + .join(""); + state.athlete = null; + adopt(data); + toast(`${data.active.name} has a memory store of their own`, "add"); + el.input.focus(); + } catch (err) { + toast("Could not add that athlete", "warning"); + } +} + +async function openSession(id) { + try { + const data = await api(`/ui/api/conversations/${encodeURIComponent(id)}`); + state.conversationId = id; + state.messages = data.messages || []; + renderSessions(); + renderSessionHeader(); + renderThread(); + } catch { + toast("Could not open that session", "warning"); + } +} + +async function newSession() { + if (!state.active) return; + const data = await api("/ui/api/conversations", { + method: "POST", + body: JSON.stringify({ client_id: state.active.id }), + }); + state.conversations.unshift(data.conversation); + state.conversationId = data.conversation.id; + state.messages = []; + renderSessions(); + renderSessionHeader(); + renderThread(); + el.input.focus(); +} + +/* ── the turn ────────────────────────────────────────────────────── */ + +async function send(text) { + const body = text.trim(); + if (!body || state.busy || !state.active) return; + + if (!state.conversationId) await newSession(); + + setBusy(true); + el.input.value = ""; + autogrow(); + + const mine = { id: `local-${Date.now()}`, role: "user", text: body }; + state.messages.push(mine); + append(messageNode(mine)); + showWorking(); + + let replyNode = null; + let reply = ""; + let failed = null; + + try { + const res = await fetch( + `/ui/api/conversations/${encodeURIComponent(state.conversationId)}/messages`, + { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: body }) }, + ); + if (!res.ok || !res.body) throw new Error(String(res.status)); + + for await (const { event, data } of frames(res)) { + switch (event) { + case "tool_use": + hideWorking(); + append(traceNode(data)); + if (data.is_memory && /write|edit|create/i.test(data.tool || "")) say("noted"); + if (!replyNode) showWorking(); + break; + + case "message": + hideWorking(); + if (!replyNode) { + replyNode = append(messageNode({ id: `reply-${Date.now()}`, role: "agent", text: "" })); + } + reply += data.text || ""; + replyNode.querySelector(".msg__body").innerHTML = inline(reply); + scrollThread(); + break; + + case "memory.updated": + renderMemory(data.memory, data.fresh_ids || []); + break; + + case "athlete.updated": + renderAthlete(data.athlete); + break; + + case "conversation.updated": { + const i = state.conversations.findIndex((c) => c.id === data.conversation.id); + if (i >= 0) state.conversations[i] = data.conversation; + else state.conversations.unshift(data.conversation); + renderSessions(); + renderSessionHeader(); + break; + } + + case "error": + failed = data.detail || "the turn did not finish"; + break; + + case "done": + if (data.answer && !reply) { + reply = data.answer; + if (!replyNode) { + replyNode = append(messageNode({ id: "reply", role: "agent", text: "" })); + } + replyNode.querySelector(".msg__body").innerHTML = inline(reply); + } + break; + } + } + } catch { + failed = failed || "the message did not reach Tirma"; + } + + hideWorking(); + setBusy(false); + + if (reply) state.messages.push({ id: "reply", role: "agent", text: reply }); + + if (failed && !reply) { + // The user's text goes back in the box. Losing what someone typed + // because a turn fell over is not acceptable. + const node = append(messageNode({ + id: `err-${Date.now()}`, role: "agent", + text: `That turn did not finish — ${failed}. Your message is back in the box, so you can send it again.`, + })); + node.dataset.error = "true"; + el.input.value = body; + autogrow(); + } else if (failed) { + toast("The turn ended early — part of the reply may be missing", "warning"); + } + + el.input.focus(); +} + +/* ── brand intro ─────────────────────────────────────────────────── */ + +let introTimer = null; + +function openIntro() { + el.intro.hidden = false; + el.intro.dataset.closing = "false"; + if (!introStage) { + introStage = new AvatarStage(el.introCanvas, { scale: 5, autoScale: false, period: 1500 }); + introStage.setSurface({ floor: "#181B22", floorEdge: "#2A2F3A", shadow: "#000000" }); + introStage.setSpec({ + tier: 3, progress: 0.5, plates: 2, mood: "grin", + traits: ["wrist_wrap", "headband", "belt"], + }); + } + introStage.start(); + el.introSkip.focus(); + clearTimeout(introTimer); + introTimer = setTimeout(closeIntro, reduced ? 1500 : 3800); +} + +function closeIntro() { + clearTimeout(introTimer); + if (el.intro.hidden) return; + el.intro.dataset.closing = "true"; + setTimeout(() => { + el.intro.hidden = true; + introStage?.stop(); + el.input.focus({ preventScroll: true }); + }, reduced ? 0 : 320); +} + +/* ── composer plumbing ───────────────────────────────────────────── */ + +function autogrow() { + el.input.style.height = "auto"; + el.input.style.height = `${Math.min(200, el.input.scrollHeight)}px`; +} + +/* ── boot ────────────────────────────────────────────────────────── */ + +function wire() { + el.themeToggle.addEventListener("click", () => + applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark")); + + el.replayIntro.innerHTML = icon("restart", { size: 20 }); + el.replayIntro.addEventListener("click", openIntro); + + el.newAthlete.innerHTML = icon("add", { size: 20 }); + el.newAthlete.addEventListener("click", addAthlete); + + el.newSession.innerHTML = icon("add", { size: 20 }); + el.newSession.addEventListener("click", () => + newSession().catch(() => toast("Could not start a session", "warning"))); + + el.sendBtn.insertAdjacentHTML("afterbegin", icon("send", { size: 16 })); + + el.picker.addEventListener("change", (e) => switchAthlete(e.target.value)); + + el.sessionList.addEventListener("click", (e) => { + const btn = e.target.closest(".session"); + if (btn && btn.dataset.id !== state.conversationId) openSession(btn.dataset.id); + }); + + el.traits.addEventListener("click", (e) => { + const btn = e.target.closest(".trait"); + if (btn) highlightMemory(btn.dataset.memory); + }); + + el.memoryList.addEventListener("click", (e) => { + const btn = e.target.closest(".mem__more"); + if (!btn) return; + const card = btn.closest(".mem"); + const open = card.dataset.clamped === "false"; + card.dataset.clamped = open ? "true" : "false"; + btn.textContent = open ? "Read the whole entry" : "Show less"; + }); + + el.suggests.addEventListener("click", (e) => { + const btn = e.target.closest("[data-say]"); + if (btn) send(btn.dataset.say); + }); + + el.form.addEventListener("submit", (e) => { e.preventDefault(); send(el.input.value); }); + + el.input.addEventListener("input", autogrow); + el.input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + send(el.input.value); + } + }); + + el.introSkip.addEventListener("click", closeIntro); + el.intro.addEventListener("click", (e) => { if (e.target === el.intro) closeIntro(); }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !el.intro.hidden) closeIntro(); + }); + + window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (e) => { + let saved = null; + try { saved = localStorage.getItem("tirma.theme"); } catch {} + if (!saved) applyTheme(e.matches ? "light" : "dark"); + }); +} + +function bootAvatar() { + stage = new AvatarStage(el.athleteCanvas, { autoScale: true }); + stage.setSurface(surfaceColours()); + stage.onRep = (n) => { + // An occasional grunt. Sparse on purpose — character, not a notification. + if (n % 5 === 0) say(AvatarStage.randomGrunt()); + }; + stage.start(); + document.addEventListener("visibilitychange", () => { + if (document.hidden) stage.stop(); else stage.start(); + }); +} + +async function main() { + initTheme(); + wire(); + bootAvatar(); + + if (new URLSearchParams(location.search).get("intro") !== "0") openIntro(); + + try { + await boot(); + } catch (err) { + setLive("offline", "No server"); + el.thread.innerHTML = ` +
    +

    Tirma is not answering.

    +

    The page loaded but the API behind it did not. Start it from the + repository root with uvicorn api.main:app --reload and + reload this page.

    +
    `; + } + + // Keep the timestamps honest without a reload. + setInterval(() => { + if (state.memory.updated_at) { + el.contextStamp.textContent = `updated ${ago(state.memory.updated_at)}`; + } + renderSessions(); + }, 30000); +} + +main(); diff --git a/ui/static/js/avatar.js b/ui/static/js/avatar.js new file mode 100644 index 0000000..715d5bb --- /dev/null +++ b/ui/static/js/avatar.js @@ -0,0 +1,593 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · pixel athlete + A procedural pixel-art sprite that presses a barbell overhead. + + Two things drive what you see, and nothing else: + · build — how many sessions have been logged (0 → 5). Shoulder + width, arm and leg thickness, neck, plate count. + · traits — accessories derived from the memory store. A wrist + injury puts wraps on the wrists; a belt, knee sleeves, + shoulder tape, a headband and the floor props all come + from the same place. + + Everything is drawn as integer rectangles on a 104×76 logical grid + and scaled by a whole number, so the pixels stay square and hard. + ───────────────────────────────────────────────────────────────── */ + +const W = 104; // logical grid width +const H = 76; // logical grid height +const GROUND = 66; // y of the floor line +const CX = 52; // centre of the athlete + +const SHOULDER_Y = 28; +const HIP_Y = 44; +const HEAD_TOP = 15; +const HEAD_H = 11; +const BAR_DOWN_Y = 30; // racked at the clavicle, just under the chin +const BAR_UP_Y = 10; // locked out — high enough that the plates still fit + +export const TIERS = [ + { label: "Twig", blurb: "Day one. Bar only." }, + { label: "Sprout", blurb: "Something is happening." }, + { label: "Solid", blurb: "Shirts fit differently now." }, + { label: "Buff", blurb: "Doorframes are a consideration." }, + { label: "Beast", blurb: "The bar bends a little." }, + { label: "Absolute Unit", blurb: "Gravity files a complaint." }, +]; + +const GRUNTS = ["hnngh", "one more", "oof", "let's go", "easy", "ngh", "yep"]; + +/* Sprite palette. Skin, hair and kit are fixed — the athlete is the + same character in both themes. Only the floor and shadow follow the + surface, so they arrive from the caller. */ +const KIT = { + skin: "#F0C396", + skinShade: "#CE9C6E", + hair: "#3A2A20", + hairShade: "#291C15", + tank: "#8C7BFF", + tankShade: "#6455D6", + shorts: "#242833", + shortsShade: "#171A22", + shoe: "#EDEFF2", + shoeSole: "#9AA0AC", + metal: "#B4BAC6", + metalShade: "#7E858F", + plate: "#22262F", + plateRim: "#8C7BFF", + band: "#F7F8FA", + bandShade: "#C9CDD4", + sleeve: "#4A5160", + belt: "#6B4A2F", + beltBuckle: "#D8B25E", + tape: "#79A2FF", + shades: "#101218", + chalk: "#FFFFFF", + sweat: "#9FD3F5", + mouth: "#7A3B33", +}; + +/* Trait catalogue. `on` marks the ones that ride on the body; the rest + are props that sit on the floor beside the platform. */ +export const TRAIT_LABELS = { + wrist_wrap: "Wrist wraps", + wrist_brace: "Wrist brace", + knee_sleeve: "Knee sleeves", + elbow_sleeve: "Elbow sleeves", + belt: "Lifting belt", + shoulder_tape: "Shoulder tape", + headband: "Headband", + cap: "Cap", + shades: "Shades", + chalk: "Chalk", + water_bottle: "Water bottle", + coffee: "Coffee", + towel: "Towel", + notebook: "Training log", + banana: "Banana", + cat: "Cat", +}; + +/* Deterministic jitter — a stable hash, so the wobble is consistent + frame to frame instead of flickering. */ +function noise(seed) { + let x = Math.sin(seed * 127.1) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} + +const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +const easeOut = (t) => 1 - Math.pow(1 - t, 2.2); +const easeIn = (t) => Math.pow(t, 1.7); + +/* Rep cycle → how far the bar has travelled (0 = racked, 1 = locked out) */ +function liftCurve(p) { + if (p < 0.40) return easeOut(p / 0.40); + if (p < 0.54) return 1; + if (p < 0.90) return 1 - easeIn((p - 0.54) / 0.36); + return 0; +} + +export class AvatarStage { + /** + * @param {HTMLCanvasElement} canvas + * @param {{scale?:number, autoScale?:boolean, period?:number}} opts + */ + constructor(canvas, opts = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); + this.scale = opts.scale || 4; + this.autoScale = opts.autoScale !== false; + // Height budget in CSS px. The athlete must not push the memory + // panel below the fold — the memory is the point of the product. + this.maxHeight = opts.maxHeight || 232; + this.period = opts.period || 1900; // ms per rep + this.spec = { + tier: 0, progress: 0, traits: [], mood: "neutral", + sessions_logged: 0, plates: 1, + }; + this.surface = { floor: "#181B22", floorEdge: "#2A2F3A", shadow: "#0A0B0E" }; + this.reps = 0; + this.flashUntil = 0; + this.onRep = null; + this.running = false; + this._t0 = 0; + this._lastPhase = 0; + this._raf = null; + this.reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (this.autoScale) { + this._ro = new ResizeObserver(() => this.fit()); + this._ro.observe(canvas.parentElement || canvas); + } + this.fit(); + } + + fit() { + const host = this.canvas.parentElement || this.canvas; + const avail = host.clientWidth || W * this.scale; + if (this.autoScale) { + this.scale = clamp( + Math.min(Math.floor(avail / W), Math.floor(this.maxHeight / H)), + 2, 8, + ); + } + this.canvas.width = W * this.scale; + this.canvas.height = H * this.scale; + this.canvas.style.width = `${W * this.scale}px`; + this.canvas.style.height = `${H * this.scale}px`; + this.ctx.imageSmoothingEnabled = false; + this.draw(this._lastPhase); + } + + setSpec(spec) { + this.spec = { ...this.spec, ...spec }; + this.draw(this._lastPhase); + } + + setSurface(surface) { + this.surface = { ...this.surface, ...surface }; + this.draw(this._lastPhase); + } + + /** One short flash when the athlete moves up a tier. No confetti. */ + celebrate() { + this.flashUntil = performance.now() + 900; + if (!this.running) this.draw(this._lastPhase); + } + + start() { + if (this.running) return; + this.running = true; + if (this.reduced) { + // Hold the top of the press. Still legible, no motion. + this.draw(0.47); + return; + } + this._t0 = performance.now(); + const tick = (now) => { + if (!this.running) return; + const p = ((now - this._t0) % this.period) / this.period; + if (p < this._lastPhase) { + this.reps += 1; + if (this.onRep) this.onRep(this.reps); + } + this._lastPhase = p; + this.draw(p); + this._raf = requestAnimationFrame(tick); + }; + this._raf = requestAnimationFrame(tick); + } + + stop() { + this.running = false; + if (this._raf) cancelAnimationFrame(this._raf); + } + + destroy() { + this.stop(); + if (this._ro) this._ro.disconnect(); + } + + /* ── drawing primitives ───────────────────────────────────────── */ + + px(x, y, w, h, color) { + const S = this.scale; + this.ctx.fillStyle = color; + this.ctx.fillRect(Math.round(x) * S, Math.round(y) * S, Math.round(w) * S, Math.round(h) * S); + } + + /** Thick line with a square brush — chunky pixel limbs. */ + line(x0, y0, x1, y1, thick, color) { + x0 = Math.round(x0); y0 = Math.round(y0); + x1 = Math.round(x1); y1 = Math.round(y1); + const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + let err = dx - dy; + const off = Math.floor(thick / 2); + for (;;) { + this.px(x0 - off, y0 - off, thick, thick, color); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x0 += sx; } + if (e2 < dx) { err += dx; y0 += sy; } + } + } + + has(trait) { + return (this.spec.traits || []).includes(trait); + } + + /* ── the frame ────────────────────────────────────────────────── */ + + draw(phase = 0) { + const ctx = this.ctx; + const S = this.scale; + ctx.clearRect(0, 0, W * S, H * S); + + const build = clamp((this.spec.tier || 0) + (this.spec.progress || 0), 0, 5); + const L = this.reduced ? 1 : liftCurve(phase); + + // Body geometry grows with build. + const g = { + shoulderW: 16 + Math.round(build * 3.2), + waistW: 12 + Math.round(build * 1.6), + neckW: 3 + Math.round(build * 0.8), + armW: 3 + Math.round(build * 0.8), + legW: 5 + Math.round(build * 1.0), + // A plate has to read as a disc, not a sliver. Height is capped so a + // fully loaded bar still clears the top of the grid at lockout — a + // clipped plate looks like a rendering fault, not a heavy lift. + plateW: 3 + Math.round(build * 0.8), + plateH: 9 + Math.round(build * 1.8), + }; + + // Leg drive: the whole body sinks at the bottom of the rep. + const dip = Math.round((1 - L) * 2); + // The bar wobbles when there is no one strong holding it. + const barTilt = Math.round(noise(Math.floor(phase * 6) + this.reps) * (1.6 - build * 0.28)); + const barY = Math.round(BAR_DOWN_Y + (BAR_UP_Y - BAR_DOWN_Y) * L); + + this.drawFloor(); + this.drawProps(); + this.drawShadow(g); + this.drawLegs(g, dip); + this.drawTorso(g, dip); + this.drawHead(g, dip, L); + this.drawArms(g, dip, barY, barTilt); + this.drawBar(g, barY, barTilt); + this.drawFx(g, dip, L, barY, phase); + + if (performance.now() < this.flashUntil) this.drawFlash(); + } + + drawFloor() { + const { floor, floorEdge } = this.surface; + this.px(14, GROUND, 76, 5, floor); + this.px(14, GROUND, 76, 1, floorEdge); + // Rubber-mat dashes, so the platform reads as a surface not a slab. + for (let x = 17; x < 88; x += 6) this.px(x, GROUND + 2, 3, 1, floorEdge); + this.px(0, GROUND + 4, W, 1, floorEdge); + } + + drawShadow(g) { + const w = g.shoulderW + 8; + this.px(CX - w / 2, GROUND - 1, w, 1, this.surface.shadow); + } + + drawLegs(g, dip) { + const { legW } = g; + const hip = HIP_Y + dip; + const kneeY = hip + 11 + dip; + const ankleY = GROUND - 3; + const stance = Math.round(g.shoulderW * 0.28); + + for (const s of [-1, 1]) { + const hx = CX + s * stance; + const kx = CX + s * (stance + 1); + const ax = CX + s * (stance + 1); + const outer = s > 0 ? Math.floor(legW / 2) - 1 : -Math.floor(legW / 2); + this.line(hx, hip, kx, kneeY, legW, KIT.shorts); + this.px(hx + outer, hip, 1, kneeY - hip, KIT.shortsShade); + this.line(kx, kneeY, ax, ankleY, legW - 1, KIT.skin); + this.px(kx + outer, kneeY, 1, ankleY - kneeY, KIT.skinShade); + + if (this.has("knee_sleeve")) { + this.px(kx - Math.floor(legW / 2), kneeY - 2, legW, 5, KIT.sleeve); + } + // Shoe + this.px(ax - Math.floor(legW / 2) - 1, ankleY, legW + 2, 2, KIT.shoe); + this.px(ax - Math.floor(legW / 2) - 1, ankleY + 2, legW + 2, 1, KIT.shoeSole); + } + } + + drawTorso(g, dip) { + const { shoulderW, waistW } = g; + const top = SHOULDER_Y + dip; + const bottom = HIP_Y + dip; + const rows = bottom - top; + + // Taper from shoulders to waist, one row at a time — a pixel V. + // The tank runs the full torso; the shorts waistband closes it off, so + // there is no accidental crop top. + for (let i = 0; i < rows; i++) { + const t = i / (rows - 1); + const w = Math.round(shoulderW - (shoulderW - waistW) * Math.pow(t, 0.75)); + const x = CX - Math.floor(w / 2); + const waistband = i >= rows - 3; + this.px(x, top + i, w, 1, waistband ? KIT.shorts : KIT.tank); + this.px(x + w - 1, top + i, 1, 1, waistband ? KIT.shortsShade : KIT.tankShade); + // Tank straps leave the deltoids bare at the very top. + if (i < 2) { + this.px(x, top + i, 3, 1, KIT.skin); + this.px(x + w - 3, top + i, 3, 1, KIT.skin); + } + } + + // A chest split, once there is a chest to split. + if (shoulderW > 20) this.px(CX, top + 3, 1, 5, KIT.tankShade); + if (shoulderW > 26) { + this.px(CX - 4, top + 2, 3, 1, KIT.tankShade); + this.px(CX + 2, top + 2, 3, 1, KIT.tankShade); + } + + if (this.has("belt")) { + const w = waistW + 2; + this.px(CX - Math.floor(w / 2), bottom - 4, w, 3, KIT.belt); + this.px(CX - 1, bottom - 4, 2, 3, KIT.beltBuckle); + } + // Neck + this.px(CX - Math.floor(g.neckW / 2), top - 2, g.neckW, 2, KIT.skin); + } + + drawHead(g, dip, L) { + const hx = CX - 6; + const hy = HEAD_TOP + dip; + + // Skull + this.px(hx, hy, 12, HEAD_H, KIT.skin); + this.px(hx + 11, hy + 2, 1, HEAD_H - 3, KIT.skinShade); + this.px(hx - 1, hy + 4, 1, 2, KIT.skin); // ears + this.px(hx + 12, hy + 4, 1, 2, KIT.skin); + + // Hair + if (!this.has("cap")) { + this.px(hx, hy - 1, 12, 3, KIT.hair); + this.px(hx, hy + 2, 3, 1, KIT.hair); + this.px(hx + 9, hy + 2, 3, 1, KIT.hairShade); + } + + const straining = L > 0.55; + const mood = this.spec.mood || "neutral"; + const eyeY = hy + 4; + + if (this.has("shades")) { + this.px(hx + 1, eyeY - 1, 10, 4, KIT.shades); + this.px(hx + 5, eyeY, 2, 1, KIT.metalShade); + } else if (straining) { + // Squeezed shut, plus angry brows. This is the funny bit. + this.px(hx + 2, eyeY + 1, 3, 1, KIT.hairShade); + this.px(hx + 7, eyeY + 1, 3, 1, KIT.hairShade); + this.px(hx + 2, eyeY - 1, 2, 1, KIT.hair); + this.px(hx + 8, eyeY - 1, 2, 1, KIT.hair); + } else { + const blink = Math.floor(performance.now() / 220) % 22 === 0; + const h = blink ? 1 : 2; + const y = blink ? eyeY + 1 : eyeY; + this.px(hx + 3, y, 2, h, KIT.shades); + if (mood === "wink") this.px(hx + 7, eyeY + 1, 2, 1, KIT.shades); + else this.px(hx + 7, y, 2, h, KIT.shades); + } + + // Mouth + const my = hy + 8; + if (straining) { + this.px(hx + 4, my - 1, 4, 3, KIT.mouth); // open, mid-grunt + this.px(hx + 5, my, 2, 1, KIT.chalk); // teeth + } else if (mood === "grin") { + this.px(hx + 4, my, 4, 1, KIT.mouth); + this.px(hx + 3, my - 1, 1, 1, KIT.mouth); + this.px(hx + 8, my - 1, 1, 1, KIT.mouth); + } else { + this.px(hx + 5, my, 3, 1, KIT.mouth); + } + + if (this.has("headband")) { + this.px(hx - 1, hy + 1, 14, 2, KIT.tank); + this.px(hx - 1, hy + 2, 14, 1, KIT.tankShade); + } + if (this.has("cap")) { + this.px(hx - 1, hy - 2, 14, 4, KIT.tank); + this.px(hx - 1, hy + 1, 14, 1, KIT.tankShade); + this.px(hx + 11, hy + 2, 5, 1, KIT.tank); // brim + } + } + + drawArms(g, dip, barY, barTilt) { + const { armW, shoulderW } = g; + const grip = Math.floor(shoulderW / 2) + 5; + + for (const s of [-1, 1]) { + const sx = CX + s * (Math.floor(shoulderW / 2) - 1); + const sy = SHOULDER_Y + dip + 2; + const handX = CX + s * grip; + const handY = barY + 1 + s * barTilt; + + // Elbow flares out and down when racked, tucks under when locked out. + const midX = (sx + handX) / 2; + const midY = (sy + handY) / 2; + const flare = clamp((barY - BAR_UP_Y) / (BAR_DOWN_Y - BAR_UP_Y), 0, 1); + const ex = Math.round(midX + s * (2 + flare * 5)); + const ey = Math.round(midY + flare * 5); + + this.line(sx, sy, ex, ey, armW, KIT.skin); + this.line(ex, ey, handX, handY, armW - 1, KIT.skin); + this.px(ex, ey, 1, 2, KIT.skinShade); + + // Bicep swells at the top of the press once there is a bicep. + if (g.shoulderW > 22) { + this.px(Math.round((sx + ex) / 2) - 1, Math.round((sy + ey) / 2) - 1, 2, 2, KIT.skinShade); + } + if (this.has("elbow_sleeve")) { + this.px(ex - Math.floor(armW / 2), ey - 1, armW, 4, KIT.sleeve); + } + if (this.has("wrist_brace")) { + this.line(handX, handY, Math.round((ex + handX) / 2), Math.round((ey + handY) / 2), armW, KIT.band); + } else if (this.has("wrist_wrap")) { + const wx = Math.round(handX - s * 1); + this.px(wx - Math.floor(armW / 2), handY + 1, armW + 1, 3, KIT.band); + this.px(wx - Math.floor(armW / 2), handY + 3, armW + 1, 1, KIT.bandShade); + } + // Fist over the bar + this.px(handX - 1, handY - 1, 3, 3, KIT.skin); + + // Tape goes on last, over the deltoid — drawn before the arm it + // would be hidden by it. + if (s > 0 && this.has("shoulder_tape")) { + for (let i = 0; i < 5; i++) this.px(sx - 3 + i, sy - 2 + i, 2, 1, KIT.tape); + } + } + } + + drawBar(g, barY, barTilt) { + const grip = Math.floor(g.shoulderW / 2) + 5; + const plates = clamp(this.spec.plates || 1, 1, 3); + const inner = grip + 3; // first plate starts here + const half = inner + plates * (g.plateW + 1); // sleeve runs through them + + // Shaft. Drawn as one tilted line so both ends stay on the bar. + this.line(CX - half, barY - barTilt, CX + half, barY + barTilt, 2, KIT.metal); + + for (const s of [-1, 1]) { + const tilt = s * barTilt; + // Collar, just inside the plates. + this.px(CX + s * (inner - 2) - (s < 0 ? 1 : 0), barY - 1 + tilt, 2, 4, KIT.metalShade); + + for (let i = 0; i < plates; i++) { + const from = inner + i * (g.plateW + 1); + const x = s > 0 ? CX + from : CX - from - g.plateW; + const h = g.plateH - i * 3; // outer plates are smaller + // Centred on the shaft, which sits at barY-1..barY. + const y = barY + tilt - Math.floor(h / 2); + this.px(x, y, g.plateW, h, KIT.plate); + this.px(x, y, g.plateW, 1, KIT.plateRim); // rims read as the lip + this.px(x, y + h - 1, g.plateW, 1, KIT.plateRim); + this.px(x + g.plateW - 1, y + 1, 1, h - 2, KIT.metalShade); // outer shading + } + } + } + + drawProps() { + const y = GROUND; + if (this.has("water_bottle")) { + this.px(19, y - 9, 4, 9, KIT.tape); + this.px(20, y - 11, 2, 2, KIT.metalShade); + this.px(19, y - 6, 4, 1, KIT.chalk); + } + if (this.has("coffee")) { + this.px(26, y - 5, 5, 5, KIT.chalk); + this.px(26, y - 6, 5, 1, KIT.metalShade); + this.px(31, y - 4, 1, 2, KIT.metalShade); + this.px(28, y - 9, 1, 2, KIT.metalShade); // steam + this.px(29, y - 11, 1, 1, KIT.metalShade); + } + if (this.has("banana")) { + this.px(34, y - 2, 5, 2, KIT.beltBuckle); + this.px(33, y - 3, 1, 1, KIT.beltBuckle); + this.px(39, y - 3, 1, 1, KIT.hairShade); + } + if (this.has("towel")) { + this.px(80, y - 3, 8, 3, KIT.band); + this.px(80, y - 1, 8, 1, KIT.bandShade); + } + if (this.has("notebook")) { + this.px(70, y - 4, 8, 4, KIT.chalk); + this.px(71, y - 3, 6, 1, KIT.metalShade); + this.px(71, y - 2, 4, 1, KIT.metalShade); + } + if (this.has("chalk")) { + this.px(90, y - 3, 5, 3, KIT.chalk); + this.px(90, y - 1, 5, 1, KIT.bandShade); + } + if (this.has("cat")) { + const cx = 92, cy = y - 6; + this.px(cx, cy + 2, 7, 4, KIT.hairShade); // body + this.px(cx + 4, cy, 4, 4, KIT.hairShade); // head + this.px(cx + 4, cy - 1, 1, 1, KIT.hairShade); // ears + this.px(cx + 7, cy - 1, 1, 1, KIT.hairShade); + this.px(cx + 5, cy + 1, 1, 1, KIT.sweat); // eyes + this.px(cx + 7, cy + 1, 1, 1, KIT.sweat); + this.px(cx - 1, cy + 1, 1, 3, KIT.hairShade); // tail + } + } + + drawFx(g, dip, L, barY, phase) { + // Effort marks either side of the head at the hard part of the rep. + if (L > 0.45 && L < 0.95) { + const hy = HEAD_TOP + dip + 2; + for (const s of [-1, 1]) { + const x = CX + s * 11; + this.px(x, hy, 1, 2, KIT.metalShade); + this.px(x + s, hy + 3, 1, 2, KIT.metalShade); + } + } + // Sweat flies off at lockout. + if (L > 0.9) { + const j = Math.floor(phase * 40); + this.px(CX - 12 + Math.round(noise(j) * 2), HEAD_TOP + dip + Math.round(noise(j + 7) * 3), 1, 2, KIT.sweat); + this.px(CX + 12 + Math.round(noise(j + 3) * 2), HEAD_TOP + dip + 4 + Math.round(noise(j + 9) * 3), 1, 2, KIT.sweat); + } + // Chalk puff off the hands as the bar leaves the shoulders. + if (this.has("chalk") && L > 0.05 && L < 0.3) { + const grip = Math.floor(g.shoulderW / 2) + 5; + for (const s of [-1, 1]) { + this.px(CX + s * (grip + 1), barY + 4, 1, 1, KIT.chalk); + this.px(CX + s * (grip + 3), barY + 6, 1, 1, KIT.chalk); + } + } + // Dust at the feet on the drive out of the bottom. + if (L > 0.02 && L < 0.22) { + this.px(CX - g.shoulderW / 2 - 4, GROUND - 2, 2, 1, this.surface.floorEdge); + this.px(CX + g.shoulderW / 2 + 3, GROUND - 2, 2, 1, this.surface.floorEdge); + } + } + + /** Level-up: an expanding pixel ring. One beat, then gone. */ + drawFlash() { + const t = 1 - (this.flashUntil - performance.now()) / 900; + const r = Math.round(8 + t * 30); + const cy = SHOULDER_Y + 4; + const step = 6; + for (let a = 0; a < 360; a += step) { + const rad = (a * Math.PI) / 180; + const x = Math.round(CX + Math.cos(rad) * r); + const y = Math.round(cy + Math.sin(rad) * r * 0.72); + if (x < 0 || x >= W || y < 0 || y >= H) continue; + this.px(x, y, 1, 1, KIT.plateRim); + } + } + + static randomGrunt() { + return GRUNTS[Math.floor(Math.random() * GRUNTS.length)]; + } +} + +export { W as AVATAR_W, H as AVATAR_H }; diff --git a/ui/static/js/icons.js b/ui/static/js/icons.js new file mode 100644 index 0000000..8d40888 --- /dev/null +++ b/ui/static/js/icons.js @@ -0,0 +1,71 @@ +/* ───────────────────────────────────────────────────────────────── + Icons · IBM Carbon Icons (Apache 2.0), 32×32 outline set. + Source: @carbon/icons v11.84.0 — svg/32/.svg, verbatim paths. + Carbon is the inherited TAM icon set; nothing here is hand-drawn. + ───────────────────────────────────────────────────────────────── */ + +const PATHS = { + add: '', + + send: '', + + arrowRight: '', + + arrowUp: '', + + book: '', + + notebook: '', + + list: '', + + time: '', + + user: '', + + warning: '', + + checkmark: '', + + information: '', + + close: '', + + chevronRight: '', + + chevronDown: '', + + renew: '', + + trophy: '', + + flash: '', + + activity: '', + + healthCross: '', + + circleDash: '', + + light: '', + + asleep: '', + + restart: '', +}; + +/** + * Return an inline Carbon SVG string. + * Icons are decorative here — labels always carry the meaning — so they + * are hidden from assistive tech unless a `label` is passed explicitly. + */ +export function icon(name, { size = 16, label = null, cls = "" } = {}) { + const body = PATHS[name]; + if (!body) throw new Error(`Unknown icon: ${name}`); + const a11y = label + ? `role="img" aria-label="${label}"` + : 'aria-hidden="true" focusable="false"'; + return `${body}`; +} + +export const ICON_NAMES = Object.keys(PATHS); diff --git a/ui/tam-decision.yml b/ui/tam-decision.yml new file mode 100644 index 0000000..1b61ea4 --- /dev/null +++ b/ui/tam-decision.yml @@ -0,0 +1,123 @@ +schema_version: 1 +system_version: 0.2.6 +generated_at: 2026-07-28T00:00:00Z +generator_mode: create +tier: 50 +type: proto +source_repo: theam/brand-system +entrypoint: ui/index.html +mode_applied: auto # dark by default, light honoured and toggleable +surface_scope: desktop_first # 1440 reference; panes stack below 900 +display_variant_used: sans_default +signature_variant: product_logo_plus_tam_text +stack: vanilla + +product: + name: Tirma + team: Tirma + what: Personal trainer agent whose memory of the athlete persists across sessions. + tam_relationship: > + Built and run by The Agile Monkeys. The athlete deals with Tirma; the + client buying it deals with TAM. That is the TAM-50 case. + +components_used: + - app_header # wordmark, athlete picker, agent/store metadata, live state + - rail_list # session history + - thread # chat log with inline tool/memory traces + - composer # square writing surface + primary button + - context_panel # sticky athlete + live memory store + - pixel_stage # canvas sprite, product-owned imagery + - meter # thin progress segment + - pill_tag # trait chips, memory kind labels + - toast + - brand_intro # dismissible first-load brand moment + +inherited_from_tam_100: + typography: > + IBM Plex only, by proto role. Mono for chrome, labels, buttons, badges, + metadata and technical strings. Sans for human text — chat messages, + memory summaries, input content. Display default is Sans Medium. + Neue Galano and Montserrat are absent, as proto requires. + scale: h1 40/45 · h2 30/40 · body_1 20/30 · body_2 14/20 + spacing: 4 8 12 16 24 32 48 64 96 128 + radii: r0 for writing surfaces and separators · r4 for contained surfaces · pill for pill_tag only + buttons: 40px, single line, squared, never a pill + icons: IBM Carbon Icons 11.84.0, outline, 32-grid paths used verbatim + shadows: none + breakpoints: 640 / 1024 / 1440 + focus: reinforcement of the component's own stroke, never an added outer ring + +product_freedom_used: + palette: + role_primary: Tirma violet — #8C7BFF on dark, #5334F0 on light + role_surface: basalt ramp on dark, TAM white/gray_light on light + role_accent: the same violet, repeated wherever it means "this is Tirma's" + role_focus: derived from the product palette, per TAM-50 color.roles + role_line_soft: attenuated, never hard black + rationale: > + The accent hue is deliberately off every hue in the TAM secondary + palette, so product accent and semantic status can never be confused. + imagery: > + The pixel athlete is product-owned imagery, drawn procedurally to a + canvas. No photography, so the TAM-100 threshold treatment does not + apply (and is not inherited at this tier). + wordmark: > + Tirma is a pixel lockup — a dumbbell glyph plus a 5x7 pixel-grid + wordmark, distributed as SVG. It is product identity, not sub-brand + naming; the TAM bullet and Neue Galano are deliberately absent. + +semantic_accent_used: + - critical_for_active_injury_constraints + - warning_for_equipment_and_schedule_constraints + - info_for_goals_and_programme + - success_for_logged_progress_and_cleared_constraints + - muted_for_profile_and_preferences + - success_for_live_stream_state + - critical_for_lost_stream_state + +open_decisions: + - > + A --fs-micro step (12/16) below body_2 was added for timestamps, memory + kind labels and trait chips. The TAM scale stops at body_2 (14/20), + which is too large for a three-pane operational surface at this + density. Used only for chrome, never for content. + - > + The TAM kerning table is expressed in unitless figures. Read here as + hundredths of an em (h1 -3 → -0.03em). Buttons stay at 0 per the + button component spec, which overrides the table. + - > + Memory entry kinds carry semantic colour on a 2px left edge. Read as + status and risk — an active injury is a different order of thing from a + stated preference — rather than as editorial category colouring, which + the system forbids. Confined to a hairline and a 12px label; no surface + is ever tinted by kind. + - > + Levels and tiers ("Twig" through "Absolute Unit") are gamification. + foundations/UX.md permits it where the domain justifies it and names + fitness explicitly. The progression is the product, and it is driven by + sessions the coach logged in memory, not by messages sent. + - > + generators/proto.md v0.2 says TAM-50 proto takes the + `with_wordmark_image` signature, while tiers/TAM-50.design.md v0.4 + fixes `product_logo_plus_tam_text` for the whole tier and makes it a + membership condition. The tier doc is followed, since proto.md states + it is not the SSOT. Worth reconciling in the system. + +warnings: + - > + Neue Galano is a licensed font and is not vendored here. It is not + needed: proto does not use it. Only the four IBM Plex woff2 files are + bundled, under the OFL included beside them. + - > + Reviewed at 1440x900 and 1280x720, light and dark, with + prefers-reduced-motion both on and off. Not yet reviewed on a real + mobile device — surface_scope is desktop_first and the sub-900 + breakpoint is untested on hardware. + - > + Contrast was reasoned from the token values rather than measured with a + checker. The greys are TAM's own AA-validated values, but the violet on + basalt pairing should be measured before this is called done. + - > + A live coaching turn takes 30-90 seconds. The working row counts up and + the tool traces stream so the wait reads as work rather than as a hang. + No artificial progress bar is shown, since the real duration is unknown.