From 6fdebd50995f5770b35b2dc3907cdab7be2b66a6 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 11:15:56 -0400 Subject: [PATCH 1/3] Qt-removal R0: foundations - backend, single SPA, native shell, burn-down gate Per doc/QT_REMOVAL_PLAN.md phase R0 (terminal goal: ZERO PySide6/Qt): - backend/: FastAPI app + session-scoped WebSocket event bus carrying the same versioned full-state-snapshot + named-intent wire semantics as the IslandBridge layer it succeeds; static serving of the SPA build; /api/health; 14 pytest cases (bus + WS endpoint). - web_ui/src/app: the single-SPA target consolidating the islands (GRAPHLINK_ISLAND=app builds to dist/app). WS transport module with auto-reconnect/re-subscribe (9 tests), token-driven shell layout, live system panel. - graphlink_island_codegen.py: emits web_ui/src/lib/api-contract/topics.ts, the SPA's topic->type/validator registry derived from the same schema registry (--check enforced). - graphlink_desktop.py: NATIVE pywebview/WebView2 window (not a browser, zero Qt) that boots the backend and opens the SPA. - tests/test_no_qt_anywhere.py + qt_burndown.json: THE permanent gate. Pin = 168 files importing Qt (90 source + 78 tests); any increase fails, any decrease must lower the pin in-commit; flips to zero-mode assertions (package uninstalled, undeclared) when the pin reaches 0. backend/, web_ui/, and graphlink_desktop.py are held to zero Qt from day one. Acceptance drive PASSED: native window, SPA served by the Python backend, live WS ping round-trip 4.1 ms. 17 pytest + 544 vitest green. Burn-down: 168 of 168 remaining (R0 adds only Qt-free code by design). Co-Authored-By: Claude Fable 5 --- backend/__init__.py | 14 ++ backend/app.py | 140 +++++++++++++ backend/events.py | 171 ++++++++++++++++ backend/tests/test_app_ws.py | 97 +++++++++ backend/tests/test_event_bus.py | 123 ++++++++++++ graphlink_app/graphlink_island_codegen.py | 58 +++++- graphlink_app/tests/test_no_qt_anywhere.py | 103 ++++++++++ graphlink_desktop.py | 112 +++++++++++ pyproject.toml | 6 + qt_burndown.json | 6 + web_ui/src/app/App.tsx | 109 ++++++++++ web_ui/src/app/index.html | 13 ++ web_ui/src/app/main.tsx | 16 ++ web_ui/src/app/styles.css | 158 +++++++++++++++ web_ui/src/lib/api-contract/topics.ts | 70 +++++++ web_ui/src/lib/ws/transport.test.ts | 172 ++++++++++++++++ web_ui/src/lib/ws/transport.ts | 219 +++++++++++++++++++++ web_ui/vite.config.ts | 24 ++- 18 files changed, 1606 insertions(+), 5 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/app.py create mode 100644 backend/events.py create mode 100644 backend/tests/test_app_ws.py create mode 100644 backend/tests/test_event_bus.py create mode 100644 graphlink_app/tests/test_no_qt_anywhere.py create mode 100644 graphlink_desktop.py create mode 100644 qt_burndown.json create mode 100644 web_ui/src/app/App.tsx create mode 100644 web_ui/src/app/index.html create mode 100644 web_ui/src/app/main.tsx create mode 100644 web_ui/src/app/styles.css create mode 100644 web_ui/src/lib/api-contract/topics.ts create mode 100644 web_ui/src/lib/ws/transport.test.ts create mode 100644 web_ui/src/lib/ws/transport.ts diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..6881226 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,14 @@ +"""Graphlink backend (Qt-removal plan R0, doc/QT_REMOVAL_PLAN.md). + +The Python half of the target architecture: FastAPI + a WebSocket event bus +carrying full-state snapshots and intents between the domain services and the +single React SPA. This package is the direct successor of the QWebChannel +IslandBridge layer and deliberately preserves its wire semantics (versioned +full-state envelopes, named intents) so the existing island payload schemas +and generated TS types retarget without redesign. + +Zero Qt imports are permitted anywhere under this package - enforced by +graphlink_app/tests/test_no_qt_anywhere.py. +""" + +BACKEND_VERSION = "0.1.0" diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..e17cf80 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,140 @@ +"""FastAPI application: HTTP surface + the /ws WebSocket endpoint +(Qt-removal plan R0). + +Serves three things: +- /api/health - liveness + version (the desktop shell polls this at startup) +- /ws?session= - the event-bus WebSocket (state snapshots out, intents in) +- / - the built SPA (static files), when a build directory exists + +Client -> server message kinds over /ws: + {"kind": "subscribe", "topics": ["system", ...]} -> current snapshots + {"kind": "intent", "topic": t, "intent": name, + "args": [...], "id": optional} -> optional result +Server -> client: + {"kind": "state", "topic": t, "payload": {...envelope...}} + {"kind": "result", "id": ..., "value": ...} (only when id sent) + {"kind": "error", "id": ..., "error": "..."} (bad topic/intent) + +R0 registers only the `system` topic (backend identity) and its `ping` +intent - the acceptance round-trip. Real domain topics arrive per-phase +(R1 canvas, R2 chrome, ...), each registering here exactly like system does. +""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from backend import BACKEND_VERSION +from backend.events import EventBus, SessionBus, UnknownIntentError, UnknownTopicError + +logger = logging.getLogger(__name__) + +REPO_ROOT = Path(__file__).resolve().parents[1] +SPA_DIST_DIR = REPO_ROOT / "web_ui" / "dist" / "app" + + +def _configure_session(bus: SessionBus) -> None: + """Give every session the R0 topic surface. Later phases extend this + with canvas/chrome/node topics - one registrar, one place to read the + whole API surface.""" + + bus.register_topic( + "system", + lambda: {"app": "graphlink", "backendVersion": BACKEND_VERSION, "sessionId": bus.session_id}, + ) + + def ping(*args): + # The R0 acceptance round-trip: echo + a server-side timestamp so the + # UI can prove the reply crossed the process boundary. + return {"echo": list(args), "serverTime": time.time()} + + bus.register_intent("system", "ping", ping) + + +def create_app(spa_dir: Path | None = None) -> FastAPI: + app = FastAPI(title="Graphlink backend", version=BACKEND_VERSION) + bus = EventBus(configure_session=_configure_session) + app.state.bus = bus + + @app.get("/api/health") + async def health() -> JSONResponse: + return JSONResponse({"status": "ok", "app": "graphlink", "version": BACKEND_VERSION}) + + @app.websocket("/ws") + async def ws_endpoint(websocket: WebSocket) -> None: + session_id = websocket.query_params.get("session", "default") + session = bus.session(session_id) + await websocket.accept() + session.attach(websocket) + try: + while True: + message = await websocket.receive_json() + await _handle_message(session, websocket, message) + except WebSocketDisconnect: + pass + finally: + session.detach(websocket) + + resolved_spa = SPA_DIST_DIR if spa_dir is None else spa_dir + if resolved_spa.is_dir(): + # html=True serves index.html at / ; the explicit fallback below keeps + # deep links (client-side routes) working instead of 404ing. + app.mount("/assets", StaticFiles(directory=resolved_spa / "assets"), name="assets") + + @app.get("/{full_path:path}") + async def spa(full_path: str) -> FileResponse: + candidate = (resolved_spa / full_path).resolve() + if full_path and candidate.is_file() and candidate.is_relative_to(resolved_spa.resolve()): + return FileResponse(candidate) + return FileResponse(resolved_spa / "index.html") + else: + logger.warning("SPA build not found at %s - only /api and /ws are served", resolved_spa) + + return app + + +async def _handle_message(session: SessionBus, websocket: WebSocket, message: dict) -> None: + kind = message.get("kind") + msg_id = message.get("id") + + if kind == "subscribe": + topics = message.get("topics") or session.topic_names() + for topic in topics: + try: + await session.send_snapshot(topic, websocket) + except UnknownTopicError: + await websocket.send_json( + {"kind": "error", "id": msg_id, "error": f"unknown topic: {topic}"} + ) + return + + if kind == "intent": + topic = message.get("topic", "") + intent = message.get("intent", "") + args = message.get("args") or [] + try: + result = await session.dispatch_intent(topic, intent, args) + except (UnknownTopicError, UnknownIntentError) as exc: + await websocket.send_json({"kind": "error", "id": msg_id, "error": str(exc)}) + return + except Exception: + # Handler bugs surface as errors to the caller, never as a dropped + # socket - and always land in the log. + logger.exception("intent %s/%s failed", topic, intent) + await websocket.send_json( + {"kind": "error", "id": msg_id, "error": f"intent failed: {topic}/{intent}"} + ) + return + if msg_id is not None: + await websocket.send_json({"kind": "result", "id": msg_id, "value": result}) + return + + await websocket.send_json( + {"kind": "error", "id": msg_id, "error": f"unknown message kind: {kind!r}"} + ) diff --git a/backend/events.py b/backend/events.py new file mode 100644 index 0000000..787fe6d --- /dev/null +++ b/backend/events.py @@ -0,0 +1,171 @@ +"""WebSocket event bus: session-scoped topics carrying versioned full-state +snapshots plus named intents (Qt-removal plan R0). + +Wire semantics are inherited from the IslandBridge/QWebChannel layer this +replaces, because they were already the right shape and the frontend's +generated validators depend on them: + +- Server -> client: full-state snapshots only, never diffs. Every snapshot + carries schemaVersion / minCompatibleSchemaVersion / revision, stamped + here exactly as IslandBridge.publish() stamped them (a reader may accept a + NEWER payload than it understands - additive-only guarantee - but must + refuse one older than its stated minimum). +- Client -> server: named intents ("setGridSize", "ready", ...) addressed to + a topic, with positional JSON args - the successor of @Slot methods. + +Scoping: one SessionBus per session id. Topics, revisions, and connections +are session-local, so two windows on different sessions never see each +other's state. The bus is transport-agnostic: connections are anything with +an async send_json(dict) - real WebSockets in app.py, plain recorders in +tests. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +from typing import Any, Awaitable, Callable, Protocol + +logger = logging.getLogger(__name__) + +StateBuilder = Callable[[], dict[str, Any]] +IntentHandler = Callable[..., Any | Awaitable[Any]] + + +class Connection(Protocol): + async def send_json(self, data: dict[str, Any]) -> None: ... + + +class UnknownTopicError(KeyError): + """An intent or publish referenced a topic nothing registered.""" + + +class UnknownIntentError(KeyError): + """A client sent an intent name the topic does not expose.""" + + +class _Topic: + __slots__ = ("name", "builder", "schema_version", "min_compatible", "revision") + + def __init__(self, name: str, builder: StateBuilder, schema_version: int, min_compatible: int): + self.name = name + self.builder = builder + self.schema_version = schema_version + self.min_compatible = min_compatible + self.revision = 0 + + def snapshot(self) -> dict[str, Any]: + self.revision += 1 + payload = dict(self.builder()) + payload["schemaVersion"] = self.schema_version + payload["minCompatibleSchemaVersion"] = self.min_compatible + payload["revision"] = self.revision + return payload + + +class SessionBus: + """Topics + connections + intent handlers for ONE session.""" + + def __init__(self, session_id: str): + self.session_id = session_id + self._topics: dict[str, _Topic] = {} + self._intents: dict[tuple[str, str], IntentHandler] = {} + self._connections: set[Connection] = set() + + # -- registration ------------------------------------------------------ + + def register_topic( + self, + name: str, + builder: StateBuilder, + *, + schema_version: int = 1, + min_compatible: int = 1, + ) -> None: + assert name not in self._topics, f"topic {name!r} registered twice" + self._topics[name] = _Topic(name, builder, schema_version, min_compatible) + + def register_intent(self, topic: str, intent: str, handler: IntentHandler) -> None: + key = (topic, intent) + assert key not in self._intents, f"intent {topic}/{intent} registered twice" + self._intents[key] = handler + + # -- connections ------------------------------------------------------- + + def attach(self, conn: Connection) -> None: + self._connections.add(conn) + + def detach(self, conn: Connection) -> None: + self._connections.discard(conn) + + @property + def connection_count(self) -> int: + return len(self._connections) + + # -- state flow -------------------------------------------------------- + + async def publish(self, topic: str) -> dict[str, Any]: + """Build a fresh snapshot for `topic` and broadcast it to every + attached connection. Returns the snapshot (tests + send-current-state + on subscribe both want it).""" + t = self._topics.get(topic) + if t is None: + raise UnknownTopicError(topic) + snapshot = t.snapshot() + message = {"kind": "state", "topic": topic, "payload": snapshot} + # Snapshot the set: a failed send detaches the connection mid-loop. + for conn in list(self._connections): + try: + await conn.send_json(message) + except Exception: + # A dead socket must never poison the broadcast for the rest. + logger.warning("dropping dead connection on session %s", self.session_id) + self.detach(conn) + return snapshot + + async def send_snapshot(self, topic: str, conn: Connection) -> None: + """Send the current state of one topic to one connection (the + subscribe handshake - the successor of loadFinished -> publish()).""" + t = self._topics.get(topic) + if t is None: + raise UnknownTopicError(topic) + await conn.send_json({"kind": "state", "topic": topic, "payload": t.snapshot()}) + + def topic_names(self) -> list[str]: + return sorted(self._topics) + + async def dispatch_intent(self, topic: str, intent: str, args: list[Any]) -> Any: + """Run a registered intent handler. Sync and async handlers are both + supported; sync handlers run in a thread so a slow one cannot stall + the event loop (QThread's replacement in miniature).""" + handler = self._intents.get((topic, intent)) + if handler is None: + if topic not in self._topics and not any(t == topic for t, _ in self._intents): + raise UnknownTopicError(topic) + raise UnknownIntentError(f"{topic}/{intent}") + if inspect.iscoroutinefunction(handler): + return await handler(*args) + return await asyncio.to_thread(handler, *args) + + +class EventBus: + """All sessions. Session buses are created on first use and configured by + the app's registrar so every session exposes the same topic/intent + surface over its own state.""" + + def __init__(self, configure_session: Callable[[SessionBus], None] | None = None): + self._sessions: dict[str, SessionBus] = {} + self._configure_session = configure_session + + def session(self, session_id: str = "default") -> SessionBus: + bus = self._sessions.get(session_id) + if bus is None: + bus = SessionBus(session_id) + if self._configure_session is not None: + self._configure_session(bus) + self._sessions[session_id] = bus + return bus + + def session_ids(self) -> list[str]: + return sorted(self._sessions) diff --git a/backend/tests/test_app_ws.py b/backend/tests/test_app_ws.py new file mode 100644 index 0000000..f18607c --- /dev/null +++ b/backend/tests/test_app_ws.py @@ -0,0 +1,97 @@ +"""FastAPI app tests (Qt-removal plan R0): health endpoint, WS handshake, +subscribe snapshots, the system/ping acceptance round-trip, and error paths. +Runs the real ASGI app through Starlette's TestClient - no network, no Qt.""" + +from pathlib import Path + +from fastapi.testclient import TestClient + +from backend import BACKEND_VERSION +from backend.app import create_app + + +def make_client(tmp_path: Path | None = None) -> TestClient: + # Point spa_dir at a guaranteed-missing directory: R0 tests exercise the + # API surface, not the static build (the acceptance drive covers that). + spa = tmp_path if tmp_path is not None else Path("__no_such_dir__") + return TestClient(create_app(spa_dir=spa)) + + +def test_health_reports_ok_and_version(): + client = make_client() + response = client.get("/api/health") + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["version"] == BACKEND_VERSION + + +def test_subscribe_delivers_system_snapshot_with_envelope(): + client = make_client() + with client.websocket_connect("/ws?session=test-a") as ws: + ws.send_json({"kind": "subscribe", "topics": ["system"]}) + message = ws.receive_json() + assert message["kind"] == "state" + assert message["topic"] == "system" + payload = message["payload"] + assert payload["app"] == "graphlink" + assert payload["sessionId"] == "test-a" + assert payload["schemaVersion"] == 1 + assert payload["revision"] >= 1 + + +def test_subscribe_without_topics_sends_every_registered_topic(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json({"kind": "subscribe"}) + message = ws.receive_json() + assert message["topic"] == "system", "R0 registers exactly the system topic" + + +def test_ping_round_trip_returns_echo_and_server_time(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json( + {"kind": "intent", "topic": "system", "intent": "ping", "args": ["hello"], "id": 1} + ) + message = ws.receive_json() + assert message["kind"] == "result" + assert message["id"] == 1 + assert message["value"]["echo"] == ["hello"] + assert message["value"]["serverTime"] > 0 + + +def test_unknown_intent_and_topic_return_error_not_disconnect(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json({"kind": "intent", "topic": "system", "intent": "nope", "args": [], "id": 2}) + message = ws.receive_json() + assert message["kind"] == "error" + assert message["id"] == 2 + + ws.send_json({"kind": "intent", "topic": "nope", "intent": "x", "args": [], "id": 3}) + message = ws.receive_json() + assert message["kind"] == "error" + + # Socket must still be usable after errors. + ws.send_json({"kind": "intent", "topic": "system", "intent": "ping", "args": [], "id": 4}) + assert ws.receive_json()["kind"] == "result" + + +def test_unknown_message_kind_returns_error(): + client = make_client() + with client.websocket_connect("/ws") as ws: + ws.send_json({"kind": "bogus", "id": 9}) + message = ws.receive_json() + assert message["kind"] == "error" + assert "unknown message kind" in message["error"] + + +def test_sessions_do_not_share_connections(): + client = make_client() + with client.websocket_connect("/ws?session=a") as ws_a: + with client.websocket_connect("/ws?session=b") as ws_b: + ws_a.send_json({"kind": "subscribe", "topics": ["system"]}) + ws_b.send_json({"kind": "subscribe", "topics": ["system"]}) + assert ws_a.receive_json()["payload"]["sessionId"] == "a" + assert ws_b.receive_json()["payload"]["sessionId"] == "b" diff --git a/backend/tests/test_event_bus.py b/backend/tests/test_event_bus.py new file mode 100644 index 0000000..f5f9f2a --- /dev/null +++ b/backend/tests/test_event_bus.py @@ -0,0 +1,123 @@ +"""Event-bus unit tests (Qt-removal plan R0): envelope stamping, session +isolation, broadcast resilience, intent dispatch.""" + +import asyncio + +import pytest + +from backend.events import ( + EventBus, + SessionBus, + UnknownIntentError, + UnknownTopicError, +) + + +class Recorder: + def __init__(self, fail=False): + self.messages = [] + self.fail = fail + + async def send_json(self, data): + if self.fail: + raise ConnectionError("dead socket") + self.messages.append(data) + + +def make_session(name="s1"): + bus = SessionBus(name) + state = {"count": 0} + bus.register_topic("counter", lambda: {"count": state["count"]}) + + def bump(by): + state["count"] += by + return state["count"] + + bus.register_intent("counter", "bump", bump) + return bus, state + + +def test_snapshot_envelope_matches_island_bridge_contract(): + bus, _ = make_session() + snap = asyncio.run(bus.publish("counter")) + assert snap["schemaVersion"] == 1 + assert snap["minCompatibleSchemaVersion"] == 1 + assert snap["revision"] == 1 + assert snap["count"] == 0 + snap2 = asyncio.run(bus.publish("counter")) + assert snap2["revision"] == 2, "revision must increment per publish" + + +def test_broadcast_reaches_all_connections_and_drops_dead_ones(): + async def run(): + bus, _ = make_session() + alive, dead = Recorder(), Recorder(fail=True) + bus.attach(alive) + bus.attach(dead) + await bus.publish("counter") + assert len(alive.messages) == 1 + assert alive.messages[0]["kind"] == "state" + assert alive.messages[0]["topic"] == "counter" + assert bus.connection_count == 1, "dead connection must be detached" + # A second publish must not fail because of the removed socket. + await bus.publish("counter") + assert len(alive.messages) == 2 + + asyncio.run(run()) + + +def test_intent_dispatch_sync_handler_runs_and_returns(): + bus, state = make_session() + result = asyncio.run(bus.dispatch_intent("counter", "bump", [5])) + assert result == 5 + assert state["count"] == 5 + + +def test_intent_dispatch_async_handler(): + bus, _ = make_session() + + async def async_intent(x): + await asyncio.sleep(0) + return x * 2 + + bus.register_intent("counter", "double", async_intent) + assert asyncio.run(bus.dispatch_intent("counter", "double", [21])) == 42 + + +def test_unknown_topic_and_intent_raise_typed_errors(): + bus, _ = make_session() + with pytest.raises(UnknownTopicError): + asyncio.run(bus.publish("nope")) + with pytest.raises(UnknownTopicError): + asyncio.run(bus.dispatch_intent("nope", "x", [])) + with pytest.raises(UnknownIntentError): + asyncio.run(bus.dispatch_intent("counter", "nope", [])) + + +def test_sessions_are_isolated(): + calls = [] + + def configure(session_bus): + calls.append(session_bus.session_id) + state = {"n": 0} + session_bus.register_topic("t", lambda: {"n": state["n"]}) + session_bus.register_intent("t", "set", lambda v: state.__setitem__("n", v)) + + bus = EventBus(configure_session=configure) + a, b = bus.session("a"), bus.session("b") + assert bus.session("a") is a, "same id must return the same session" + assert calls == ["a", "b"], "configurator runs once per session" + + asyncio.run(a.dispatch_intent("t", "set", [7])) + snap_a = asyncio.run(a.publish("t")) + snap_b = asyncio.run(b.publish("t")) + assert snap_a["n"] == 7 + assert snap_b["n"] == 0, "state must not leak across sessions" + + +def test_duplicate_registration_is_a_programming_error(): + bus, _ = make_session() + with pytest.raises(AssertionError): + bus.register_topic("counter", dict) + with pytest.raises(AssertionError): + bus.register_intent("counter", "bump", lambda: None) diff --git a/graphlink_app/graphlink_island_codegen.py b/graphlink_app/graphlink_island_codegen.py index 80600c8..60d699c 100644 --- a/graphlink_app/graphlink_island_codegen.py +++ b/graphlink_app/graphlink_island_codegen.py @@ -474,6 +474,53 @@ def _validator_for(name: str, fields: dict[str, Any]) -> str: ] +_API_CONTRACT_PATH = _REPO_ROOT / "web_ui" / "src" / "lib" / "api-contract" / "topics.ts" + + +def api_contract_ts() -> str: + """Qt-removal plan R0 (doc/QT_REMOVAL_PLAN.md): the single-SPA API + contract, derived from the SAME registry as the per-island artifacts. + + The SPA consumes state over one WebSocket instead of nineteen + QWebChannels, so it needs one place answering "which topics exist, what + type does each carry, and how do I validate a payload at runtime" - this + barrel maps topic names (the island names, unchanged, so backend topics + and frontend subscriptions agree by construction) onto the already- + generated types and validators. Topics gain entries here automatically + when their payload joins GENERATED_ARTIFACTS; nothing is hand-maintained. + """ + entries = [] + for entry in GENERATED_ARTIFACTS: + stem = entry["ts_path"].stem # e.g. "grid-control-state" + topic = stem.removesuffix("-state") # topic name == island name + title = entry["title"] # e.g. "GridControlState" + entries.append((topic, stem, title)) + entries.sort() + + lines = [ + _HEADER.format(source="graphlink_app/graphlink_island_codegen.py::GENERATED_ARTIFACTS"), + "", + ] + for topic, stem, title in entries: + lines.append( + f'import {{ type {title}, validate{title} }} from "../bridge-core/generated/{stem}";' + ) + lines.append("") + lines.append("export const TOPIC_VALIDATORS = {") + for topic, _stem, title in entries: + lines.append(f' "{topic}": validate{title},') + lines.append("} as const;") + lines.append("") + lines.append("export type TopicName = keyof typeof TOPIC_VALIDATORS;") + lines.append("") + lines.append("export interface TopicStates {") + for topic, _stem, title in entries: + lines.append(f' "{topic}": {title};') + lines.append("}") + lines.append("") + return "\n".join(lines) + + def _main(argv: list[str]) -> int: """CLI entry point: `python graphlink_island_codegen.py [--check | --write]`. @@ -528,10 +575,19 @@ def _main(argv: list[str]) -> int: if checked_in != fresh: stale.append(f"{path} does not match regenerating it from {entry['source']}") + fresh_contract = api_contract_ts() if mode == "--write": - print(f"wrote {len(GENERATED_ARTIFACTS) * 2} generated file(s)") + if not _API_CONTRACT_PATH.is_file() or _API_CONTRACT_PATH.read_text(encoding="utf-8") != fresh_contract: + _API_CONTRACT_PATH.parent.mkdir(parents=True, exist_ok=True) + _API_CONTRACT_PATH.write_text(fresh_contract, encoding="utf-8", newline="\n") + print(f"wrote {len(GENERATED_ARTIFACTS) * 2 + 1} generated file(s)") return 0 + if not _API_CONTRACT_PATH.is_file(): + stale.append(f"{_API_CONTRACT_PATH} is missing") + elif _API_CONTRACT_PATH.read_text(encoding="utf-8") != fresh_contract: + stale.append(f"{_API_CONTRACT_PATH} does not match regenerating it from GENERATED_ARTIFACTS") + if stale: print("Generated wire-contract artifacts are stale:", file=sys.stderr) for message in stale: diff --git a/graphlink_app/tests/test_no_qt_anywhere.py b/graphlink_app/tests/test_no_qt_anywhere.py new file mode 100644 index 0000000..030115d --- /dev/null +++ b/graphlink_app/tests/test_no_qt_anywhere.py @@ -0,0 +1,103 @@ +"""THE Qt-removal gate (doc/QT_REMOVAL_PLAN.md sections 0 and 3). Permanent. + +Burn-down mode (pin > 0): the count of Python files importing PySide6/PyQt - +source AND tests, whole repo - must EXACTLY match qt_burndown.json. Adding a +Qt import anywhere fails this test; removing Qt files fails it too until the +pin is lowered in the same commit, which is what makes the pin the project's +single truthful progress number. + +Zero mode (pin == 0): additionally asserts PySide6 is not installed and not +declared in pyproject.toml. This test never gets deleted - it is what makes +"the Qt removal is complete" a machine-checked fact instead of a claim. + +The new architecture is held to zero from day one: nothing under backend/, +web_ui/, or the desktop entry point may ever import Qt, pin or no pin. +""" + +from __future__ import annotations + +import importlib.util +import json +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +PIN_PATH = REPO_ROOT / "qt_burndown.json" + +_EXCLUDED_DIRS = {".git", "node_modules", "dist", "__pycache__", ".venv", "venv"} +_QT_IMPORT = re.compile(r"^\s*(?:from|import)\s+(?:PySide6|PyQt\d?)\b", re.MULTILINE) + +# Files that must be Qt-free FOREVER, regardless of the pin: the replacement +# architecture itself. +_ZERO_TOLERANCE_ROOTS = ("backend", "web_ui") +_ZERO_TOLERANCE_FILES = ("graphlink_desktop.py",) + + +def _qt_importing_files() -> list[Path]: + hits: list[Path] = [] + for path in REPO_ROOT.rglob("*.py"): + if any(part in _EXCLUDED_DIRS for part in path.parts): + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if _QT_IMPORT.search(text): + hits.append(path.relative_to(REPO_ROOT)) + return sorted(hits) + + +def _split(hits: list[Path]) -> tuple[list[Path], list[Path]]: + tests = [h for h in hits if "tests" in h.parts] + source = [h for h in hits if "tests" not in h.parts] + return source, tests + + +def test_new_architecture_is_qt_free_forever(): + offenders = [ + h + for h in _qt_importing_files() + if h.parts[0] in _ZERO_TOLERANCE_ROOTS or str(h) in _ZERO_TOLERANCE_FILES + ] + assert offenders == [], ( + f"Qt imports in the NEW architecture (never allowed): {[str(o) for o in offenders]}" + ) + + +def test_qt_burndown_matches_pin(): + pin = json.loads(PIN_PATH.read_text(encoding="utf-8")) + hits = _qt_importing_files() + source, tests = _split(hits) + + assert len(hits) <= pin["total"], ( + f"Qt-file count ROSE: {len(hits)} files import PySide6/PyQt but the pin is " + f"{pin['total']}. New Qt imports are forbidden - the project goal is ZERO " + f"(doc/QT_REMOVAL_PLAN.md section 0). New since pin: rerun with -rA and compare." + ) + assert len(hits) == pin["total"], ( + f"Qt-file count dropped to {len(hits)} but qt_burndown.json still pins " + f"{pin['total']}. Good progress - now lower the pin in the SAME commit " + f"(source_files={len(source)}, test_files={len(tests)}, total={len(hits)}) " + f"and record the drop in the plan ledger." + ) + assert (len(source), len(tests)) == (pin["source_files"], pin["test_files"]), ( + f"Pin split drifted: actual source={len(source)}/tests={len(tests)}, " + f"pin says {pin['source_files']}/{pin['test_files']} - update qt_burndown.json." + ) + + +def test_zero_mode_gate_when_pin_reaches_zero(): + pin = json.loads(PIN_PATH.read_text(encoding="utf-8")) + if pin["total"] != 0: + return # burn-down mode; the assertions above carry the load + + # THE GATE (plan section 0): no import anywhere, package gone, not declared. + assert _qt_importing_files() == [] + assert importlib.util.find_spec("PySide6") is None, ( + "pin is 0 but PySide6 is still installed - `pip uninstall PySide6` is part " + "of the R7 cutover" + ) + pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert "PySide6" not in pyproject and "qtawesome" not in pyproject, ( + "pin is 0 but pyproject.toml still declares Qt dependencies" + ) diff --git a/graphlink_desktop.py b/graphlink_desktop.py new file mode 100644 index 0000000..0117f95 --- /dev/null +++ b/graphlink_desktop.py @@ -0,0 +1,112 @@ +"""Graphlink desktop entry point (Qt-removal plan R0, doc/QT_REMOVAL_PLAN.md). + +Launches the app as a NATIVE DESKTOP WINDOW with zero Qt: + + 1. starts the Python backend (FastAPI/uvicorn) on a free localhost port, + in a daemon thread inside this same process; + 2. waits for /api/health to answer; + 3. opens a pywebview window - the OS's own embedded webview component + (WebView2 on Windows), NOT the user's browser: no tabs, no address bar, + just an application window rendering the built SPA. + +`python graphlink_desktop.py` is the whole launch story, same as the Qt +entry point it replaces. When the R7 cutover deletes the Qt app, this file +becomes graphlink_app.py. + +Environment: + GRAPHLINK_BACKEND_PORT pin the backend port (default: OS-assigned free port) + GRAPHLINK_DEBUG_WEBVIEW set to 1 to enable the webview's devtools +""" + +from __future__ import annotations + +import logging +import os +import socket +import sys +import threading +import time +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(REPO_ROOT)) + +logger = logging.getLogger("graphlink.desktop") + +STARTUP_TIMEOUT_SECONDS = 15.0 + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _wait_for_health(base_url: str, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{base_url}/api/health", timeout=1.0) as response: + if response.status == 200: + return True + except OSError: + time.sleep(0.1) + return False + + +def _start_backend(port: int) -> threading.Thread: + import uvicorn + + from backend.app import create_app + + config = uvicorn.Config( + create_app(), + host="127.0.0.1", + port=port, + log_level="warning", + # The desktop process owns lifetime: closing the window exits the + # process, taking this daemon thread (and the server) with it. + ) + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, name="graphlink-backend", daemon=True) + thread.start() + return thread + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s") + + spa_index = REPO_ROOT / "web_ui" / "dist" / "app" / "index.html" + if not spa_index.is_file(): + logger.error( + "SPA build missing at %s - run: cd web_ui && GRAPHLINK_ISLAND=app npx vite build", + spa_index, + ) + return 1 + + port = int(os.environ.get("GRAPHLINK_BACKEND_PORT", 0)) or _free_port() + base_url = f"http://127.0.0.1:{port}" + + _start_backend(port) + if not _wait_for_health(base_url, STARTUP_TIMEOUT_SECONDS): + logger.error("backend did not become healthy at %s within %.0fs", base_url, STARTUP_TIMEOUT_SECONDS) + return 1 + logger.info("backend healthy at %s", base_url) + + import webview # pywebview - the native (non-Qt, non-browser) window + + webview.create_window( + "Graphlink", + url=base_url, + width=1440, + height=900, + min_size=(960, 600), + background_color="#1a1a1a", + ) + webview.start(debug=bool(os.environ.get("GRAPHLINK_DEBUG_WEBVIEW"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 8a6fee4..e354b17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,12 @@ requires-python = ">=3.10" dependencies = [ "anthropic", "beautifulsoup4", + # Qt-removal target architecture (doc/QT_REMOVAL_PLAN.md): Python backend + # + native pywebview shell. PySide6/qtawesome below are scheduled for + # REMOVAL at the R7 cutover - do not add new Qt dependencies. + "fastapi", + "uvicorn", + "pywebview", "ddgs", "Markdown", "matplotlib", diff --git a/qt_burndown.json b/qt_burndown.json new file mode 100644 index 0000000..6b528f2 --- /dev/null +++ b/qt_burndown.json @@ -0,0 +1,6 @@ +{ + "comment": "Qt-removal burn-down pin (doc/QT_REMOVAL_PLAN.md section 3). The number of Python files importing PySide6/PyQt anywhere in the repo, source AND tests. tests/test_no_qt_anywhere.py fails if the real count deviates from this pin in EITHER direction: an increase is a regression toward Qt; a decrease means Qt files were removed and this pin must be lowered in the same commit (the ledger in the plan doc records each drop). The project is done when this reads 0 and PySide6 is uninstalled.", + "source_files": 90, + "test_files": 78, + "total": 168 +} diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx new file mode 100644 index 0000000..b6d62f1 --- /dev/null +++ b/web_ui/src/app/App.tsx @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport"; + +/** + * The single-app shell (Qt-removal plan R0). + * + * R0 scope on purpose: layout regions where the real surfaces land in later + * phases (R1 canvas, R2 chrome, R3 nodes), plus the live system panel that + * proves the Python backend round-trip - the R0 acceptance criterion. + */ + +interface SystemState { + app?: string; + backendVersion?: string; + sessionId?: string; + revision?: number; +} + +function App() { + const transportRef = useRef(null); + const [status, setStatus] = useState("closed"); + const [system, setSystem] = useState({}); + const [pingMs, setPingMs] = useState(null); + const [pingError, setPingError] = useState(null); + + const transport = useMemo(() => { + const t = new WsTransport(defaultWsUrl()); + transportRef.current = t; + return t; + }, []); + + useEffect(() => { + const offStatus = transport.onStatus(setStatus); + const offSystem = transport.subscribe("system", (payload) => { + setSystem(payload as SystemState); + }); + transport.connect(); + return () => { + offStatus(); + offSystem(); + transport.dispose(); + }; + }, [transport]); + + async function ping() { + setPingError(null); + const started = performance.now(); + try { + await transport.request("system", "ping", ["r0-acceptance"]); + setPingMs(Math.round((performance.now() - started) * 10) / 10); + } catch (error) { + setPingMs(null); + setPingError(error instanceof Error ? error.message : String(error)); + } + } + + return ( +
+
+ Graphlink + app bar lands in R2 + + {status === "open" ? "backend connected" : status} + +
+ +
+
+

Canvas

+

React Flow node graph lands in R1.

+
+ +
+

SYSTEM

+
+
+
Backend
+
{system.backendVersion ?? "—"}
+
+
+
Session
+
{system.sessionId ?? "—"}
+
+
+
Revision
+
{system.revision ?? "—"}
+
+
+ + {pingMs !== null &&

round-trip {pingMs} ms

} + {pingError !== null &&

{pingError}

} +
+
+ +
+
Composer dock lands in R2.
+
+
+ ); +} + +export default App; diff --git a/web_ui/src/app/index.html b/web_ui/src/app/index.html new file mode 100644 index 0000000..b204fae --- /dev/null +++ b/web_ui/src/app/index.html @@ -0,0 +1,13 @@ + + + + + + + Graphlink + + +
+ + + diff --git a/web_ui/src/app/main.tsx b/web_ui/src/app/main.tsx new file mode 100644 index 0000000..bc97f66 --- /dev/null +++ b/web_ui/src/app/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "../lib/ui/base.css"; +// The single-SPA app has no Qt host injecting :root { --gl-* } at build time +// (that was _inline_bundle()'s job) - the generated token values ship with +// the app unconditionally. Live theme switching becomes a backend topic in +// R2; dark is the only theme the old app ever defaulted to. +import "../lib/tokens/gl-vars-dev.css"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css new file mode 100644 index 0000000..1601d5e --- /dev/null +++ b/web_ui/src/app/styles.css @@ -0,0 +1,158 @@ +/* Single-app shell styles (Qt-removal plan R0). Token-driven only - the + * no-raw-colors gate applies here exactly as it does to island CSS. */ + +html, +body, +#root { + height: 100%; + margin: 0; +} + +.app-shell { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--gl-surface-window); + color: var(--gl-surface-text); + font-family: var(--gl-font-family); +} + +.app-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background-color: var(--gl-surface-node-body); + border-bottom: 1px solid var(--gl-surface-border); +} + +.app-title { + font-size: 13px; + font-weight: 700; +} + +.app-topbar-note { + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.app-conn { + margin-left: auto; + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.app-conn-open { + color: var(--gl-surface-text); +} + +.app-canvas-region { + position: relative; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.app-canvas-placeholder { + text-align: center; +} + +.app-canvas-placeholder-title { + margin: 0 0 4px; + font-size: 14px; + font-weight: 700; + color: var(--gl-surface-text-muted); + letter-spacing: 0.12em; +} + +.app-canvas-placeholder-body { + margin: 0; + font-size: 12px; + color: var(--gl-surface-text-muted); +} + +.app-system-panel { + position: absolute; + top: 16px; + right: 16px; + width: 220px; + padding: 12px 14px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 10px; +} + +.app-system-title { + margin: 0 0 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: var(--gl-surface-text-muted); +} + +.app-system-rows { + margin: 0 0 10px; +} + +.app-system-row { + display: flex; + justify-content: space-between; + font-size: 11px; + padding: 2px 0; +} + +.app-system-row dt { + color: var(--gl-surface-text-muted); +} + +.app-system-row dd { + margin: 0; +} + +.app-ping-button { + width: 100%; + padding: 6px 10px; + font-size: 11px; + font-weight: 600; + font-family: inherit; + color: var(--gl-surface-text); + background-color: var(--gl-neutral-button-background); + border: 1px solid var(--gl-neutral-button-border); + border-radius: 7px; + cursor: pointer; +} + +.app-ping-button:hover:enabled { + background-color: var(--gl-neutral-button-hover); +} + +.app-ping-button:disabled { + opacity: 0.5; + cursor: default; +} + +.app-ping-result { + margin: 8px 0 0; + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.app-ping-error { + margin: 8px 0 0; + font-size: 11px; + color: var(--gl-status-error, var(--gl-surface-text)); +} + +.app-composer-region { + padding: 10px 14px; + border-top: 1px solid var(--gl-surface-border); + background-color: var(--gl-surface-node-body); +} + +.app-composer-placeholder { + font-size: 11px; + color: var(--gl-surface-text-muted); + text-align: center; +} diff --git a/web_ui/src/lib/api-contract/topics.ts b/web_ui/src/lib/api-contract/topics.ts new file mode 100644 index 0000000..ba66649 --- /dev/null +++ b/web_ui/src/lib/api-contract/topics.ts @@ -0,0 +1,70 @@ +/* GENERATED - do not hand-edit. Source of truth: graphlink_app/graphlink_island_codegen.py::GENERATED_ARTIFACTS. + * Regenerate with graphlink_island_codegen.py; a pytest fails if this file + * drifts from what regenerating it now would produce. */ + + +import { type AboutState, validateAboutState } from "../bridge-core/generated/about-state"; +import { type ChatLibraryState, validateChatLibraryState } from "../bridge-core/generated/chat-library-state"; +import { type CommandPaletteState, validateCommandPaletteState } from "../bridge-core/generated/command-palette-state"; +import { type ComposerState, validateComposerState } from "../bridge-core/generated/composer-state"; +import { type ComposerContextState, validateComposerContextState } from "../bridge-core/generated/composer-context-state"; +import { type ComposerPickerState, validateComposerPickerState } from "../bridge-core/generated/composer-picker-state"; +import { type DocumentViewerState, validateDocumentViewerState } from "../bridge-core/generated/document-viewer-state"; +import { type DragSpeedState, validateDragSpeedState } from "../bridge-core/generated/drag-speed-state"; +import { type FontControlState, validateFontControlState } from "../bridge-core/generated/font-control-state"; +import { type GridControlState, validateGridControlState } from "../bridge-core/generated/grid-control-state"; +import { type HelpState, validateHelpState } from "../bridge-core/generated/help-state"; +import { type MinimapState, validateMinimapState } from "../bridge-core/generated/minimap-state"; +import { type NotificationState, validateNotificationState } from "../bridge-core/generated/notification-state"; +import { type PinOverlayState, validatePinOverlayState } from "../bridge-core/generated/pin-overlay-state"; +import { type PluginPickerState, validatePluginPickerState } from "../bridge-core/generated/plugin-picker-state"; +import { type SearchOverlayState, validateSearchOverlayState } from "../bridge-core/generated/search-overlay-state"; +import { type SettingsState, validateSettingsState } from "../bridge-core/generated/settings-state"; +import { type TokenCounterState, validateTokenCounterState } from "../bridge-core/generated/token-counter-state"; +import { type ToolbarState, validateToolbarState } from "../bridge-core/generated/toolbar-state"; + +export const TOPIC_VALIDATORS = { + "about": validateAboutState, + "chat-library": validateChatLibraryState, + "command-palette": validateCommandPaletteState, + "composer": validateComposerState, + "composer-context": validateComposerContextState, + "composer-picker": validateComposerPickerState, + "document-viewer": validateDocumentViewerState, + "drag-speed": validateDragSpeedState, + "font-control": validateFontControlState, + "grid-control": validateGridControlState, + "help": validateHelpState, + "minimap": validateMinimapState, + "notification": validateNotificationState, + "pin-overlay": validatePinOverlayState, + "plugin-picker": validatePluginPickerState, + "search-overlay": validateSearchOverlayState, + "settings": validateSettingsState, + "token-counter": validateTokenCounterState, + "toolbar": validateToolbarState, +} as const; + +export type TopicName = keyof typeof TOPIC_VALIDATORS; + +export interface TopicStates { + "about": AboutState; + "chat-library": ChatLibraryState; + "command-palette": CommandPaletteState; + "composer": ComposerState; + "composer-context": ComposerContextState; + "composer-picker": ComposerPickerState; + "document-viewer": DocumentViewerState; + "drag-speed": DragSpeedState; + "font-control": FontControlState; + "grid-control": GridControlState; + "help": HelpState; + "minimap": MinimapState; + "notification": NotificationState; + "pin-overlay": PinOverlayState; + "plugin-picker": PluginPickerState; + "search-overlay": SearchOverlayState; + "settings": SettingsState; + "token-counter": TokenCounterState; + "toolbar": ToolbarState; +} diff --git a/web_ui/src/lib/ws/transport.test.ts b/web_ui/src/lib/ws/transport.test.ts new file mode 100644 index 0000000..526580e --- /dev/null +++ b/web_ui/src/lib/ws/transport.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WsTransport } from "./transport"; + +class FakeSocket { + static instances: FakeSocket[] = []; + sent: string[] = []; + closed = false; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + + constructor(public url: string) { + FakeSocket.instances.push(this); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.closed = true; + this.onclose?.(); + } + + open() { + this.onopen?.(); + } + + receive(message: unknown) { + this.onmessage?.({ data: JSON.stringify(message) }); + } + + lastSent(): Record { + return JSON.parse(this.sent[this.sent.length - 1]); + } +} + +function makeTransport(opts: { requestTimeoutMs?: number } = {}) { + return new WsTransport("ws://test/ws", { + webSocketFactory: (url) => new FakeSocket(url), + reconnectDelayMs: 10, + ...opts, + }); +} + +beforeEach(() => { + FakeSocket.instances = []; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WsTransport", () => { + it("subscribes pre-registered topics on open", () => { + const t = makeTransport(); + const seen: unknown[] = []; + t.subscribe("system", (p) => seen.push(p)); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + expect(socket.lastSent()).toEqual({ kind: "subscribe", topics: ["system"] }); + + socket.receive({ kind: "state", topic: "system", payload: { app: "graphlink", revision: 1 } }); + expect(seen).toEqual([{ app: "graphlink", revision: 1 }]); + }); + + it("subscribing a NEW topic while open sends subscribe immediately", () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + t.subscribe("canvas", () => {}); + expect(socket.lastSent()).toEqual({ kind: "subscribe", topics: ["canvas"] }); + }); + + it("routes snapshots only to their topic's listeners", () => { + const t = makeTransport(); + const a: unknown[] = []; + const b: unknown[] = []; + t.subscribe("a", (p) => a.push(p)); + t.subscribe("b", (p) => b.push(p)); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + socket.receive({ kind: "state", topic: "a", payload: { x: 1 } }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(0); + }); + + it("intent() is a silent no-op before the socket opens (bridge parity)", () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + t.intent("system", "ping", []); + expect(socket.sent).toHaveLength(0); + socket.open(); + t.intent("system", "ping", ["x"]); + expect(socket.lastSent()).toEqual({ kind: "intent", topic: "system", intent: "ping", args: ["x"] }); + }); + + it("request() resolves on result and rejects on error, matched by id", async () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + + const ok = t.request("system", "ping", ["hi"]); + const first = socket.lastSent(); + expect(first.id).toBeDefined(); + socket.receive({ kind: "result", id: first.id, value: { echo: ["hi"] } }); + await expect(ok).resolves.toEqual({ echo: ["hi"] }); + + const bad = t.request("system", "nope", []); + const second = socket.lastSent(); + socket.receive({ kind: "error", id: second.id, error: "unknown intent" }); + await expect(bad).rejects.toThrow("unknown intent"); + }); + + it("request() times out if the server never answers", async () => { + const t = makeTransport({ requestTimeoutMs: 100 }); + t.connect(); + FakeSocket.instances[0].open(); + const p = t.request("system", "ping", []); + const assertion = expect(p).rejects.toThrow("timed out"); + vi.advanceTimersByTime(150); + await assertion; + }); + + it("reconnects after close and re-subscribes every topic", () => { + const t = makeTransport(); + t.subscribe("system", () => {}); + t.connect(); + const first = FakeSocket.instances[0]; + first.open(); + first.close(); + expect(t.getStatus()).toBe("closed"); + + vi.advanceTimersByTime(50); + expect(FakeSocket.instances).toHaveLength(2); + const second = FakeSocket.instances[1]; + second.open(); + expect(second.lastSent()).toEqual({ kind: "subscribe", topics: ["system"] }); + expect(t.getStatus()).toBe("open"); + }); + + it("dispose() stops reconnecting and rejects pending requests", async () => { + const t = makeTransport(); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + const p = t.request("system", "ping", []); + const assertion = expect(p).rejects.toThrow(); + t.dispose(); + await assertion; + vi.advanceTimersByTime(1000); + expect(FakeSocket.instances).toHaveLength(1), "no reconnect after dispose"; + }); + + it("notifies status listeners through the lifecycle", () => { + const t = makeTransport(); + const statuses: string[] = []; + t.onStatus((s) => statuses.push(s)); + t.connect(); + const socket = FakeSocket.instances[0]; + socket.open(); + socket.close(); + expect(statuses).toEqual(["closed", "connecting", "open", "closed"]); + }); +}); diff --git a/web_ui/src/lib/ws/transport.ts b/web_ui/src/lib/ws/transport.ts new file mode 100644 index 0000000..12ebb21 --- /dev/null +++ b/web_ui/src/lib/ws/transport.ts @@ -0,0 +1,219 @@ +/** + * WebSocket transport for the single-SPA architecture (Qt-removal plan R0). + * + * The successor of `bridge-core/transport.ts` (QWebChannel): same conceptual + * contract - full-state snapshots in, named intents out - carried over a + * plain WebSocket to the Python backend instead of Qt's webchannel. + * + * Server -> client: {kind:"state", topic, payload} (versioned envelope) + * {kind:"result", id, value} + * {kind:"error", id?, error} + * Client -> server: {kind:"subscribe", topics} + * {kind:"intent", topic, intent, args, id?} + * + * Reconnect: exponential-ish backoff, capped; every subscribed topic is + * re-subscribed automatically on reopen so a backend restart re-hydrates + * the UI without any component doing anything. + */ + +export type ConnectionStatus = "connecting" | "open" | "closed"; + +export type StateListener = (payload: Record) => void; +export type StatusListener = (status: ConnectionStatus) => void; + +interface WsLike { + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onclose: (() => void) | null; + onerror: (() => void) | null; + onmessage: ((event: { data: string }) => void) | null; +} + +export interface WsTransportOptions { + /** Injectable for tests; defaults to the browser's WebSocket. */ + webSocketFactory?: (url: string) => WsLike; + /** Base reconnect delay (doubles per attempt, capped at 8x). */ + reconnectDelayMs?: number; + /** request() timeout. */ + requestTimeoutMs?: number; +} + +export function defaultWsUrl(sessionId = "default"): string { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${window.location.host}/ws?session=${encodeURIComponent(sessionId)}`; +} + +export class WsTransport { + private readonly url: string; + private readonly factory: (url: string) => WsLike; + private readonly baseDelay: number; + private readonly requestTimeout: number; + + private socket: WsLike | null = null; + private status: ConnectionStatus = "closed"; + private disposed = false; + private attempts = 0; + private reconnectTimer: ReturnType | null = null; + + private readonly stateListeners = new Map>(); + private readonly statusListeners = new Set(); + private readonly pending = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType } + >(); + private nextId = 1; + + constructor(url: string, options: WsTransportOptions = {}) { + this.url = url; + this.factory = options.webSocketFactory ?? ((u) => new WebSocket(u) as unknown as WsLike); + this.baseDelay = options.reconnectDelayMs ?? 500; + this.requestTimeout = options.requestTimeoutMs ?? 10_000; + } + + connect(): void { + if (this.disposed || this.socket) return; + this.setStatus("connecting"); + const socket = this.factory(this.url); + this.socket = socket; + + socket.onopen = () => { + this.attempts = 0; + this.setStatus("open"); + const topics = [...this.stateListeners.keys()]; + if (topics.length > 0) { + socket.send(JSON.stringify({ kind: "subscribe", topics })); + } + }; + socket.onmessage = (event) => this.handleMessage(event.data); + socket.onerror = () => { + // onclose always follows; reconnect logic lives there. + }; + socket.onclose = () => { + this.socket = null; + this.setStatus("closed"); + this.failAllPending(new Error("connection closed")); + if (!this.disposed) this.scheduleReconnect(); + }; + } + + dispose(): void { + this.disposed = true; + if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer); + this.failAllPending(new Error("transport disposed")); + this.socket?.close(); + this.socket = null; + } + + getStatus(): ConnectionStatus { + return this.status; + } + + /** Listen for a topic's snapshots. Subscribing while open sends the + * subscribe immediately so the current snapshot arrives. */ + subscribe(topic: string, listener: StateListener): () => void { + let set = this.stateListeners.get(topic); + const isNewTopic = !set; + if (!set) { + set = new Set(); + this.stateListeners.set(topic, set); + } + set.add(listener); + if (isNewTopic && this.status === "open" && this.socket) { + this.socket.send(JSON.stringify({ kind: "subscribe", topics: [topic] })); + } + return () => { + set.delete(listener); + if (set.size === 0) this.stateListeners.delete(topic); + }; + } + + onStatus(listener: StatusListener): () => void { + this.statusListeners.add(listener); + listener(this.status); + return () => this.statusListeners.delete(listener); + } + + /** Fire-and-forget intent (the @Slot successor). Silently dropped when the + * socket is not open - matching the old bridge's pre-connect no-op call() + * semantics that every island already codes against. */ + intent(topic: string, intent: string, args: unknown[] = []): void { + if (this.status !== "open" || !this.socket) return; + this.socket.send(JSON.stringify({ kind: "intent", topic, intent, args })); + } + + /** Intent with a reply (result or error), for request/response flows. */ + request(topic: string, intent: string, args: unknown[] = []): Promise { + if (this.status !== "open" || !this.socket) { + return Promise.reject(new Error("not connected")); + } + const id = this.nextId++; + const socket = this.socket; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`request timed out: ${topic}/${intent}`)); + }, this.requestTimeout); + this.pending.set(id, { resolve, reject, timer }); + socket.send(JSON.stringify({ kind: "intent", topic, intent, args, id })); + }); + } + + // -- internals --------------------------------------------------------- + + private handleMessage(raw: string): void { + let message: Record; + try { + message = JSON.parse(raw); + } catch { + console.error("[ws] non-JSON frame dropped"); + return; + } + const kind = message.kind; + if (kind === "state") { + const listeners = this.stateListeners.get(message.topic as string); + if (listeners) { + const payload = message.payload as Record; + for (const listener of [...listeners]) listener(payload); + } + return; + } + if (kind === "result" || kind === "error") { + const id = message.id as number | null; + if (id !== null && id !== undefined && this.pending.has(id)) { + const entry = this.pending.get(id)!; + this.pending.delete(id); + clearTimeout(entry.timer); + if (kind === "result") entry.resolve(message.value); + else entry.reject(new Error(String(message.error))); + } else if (kind === "error") { + console.error("[ws] server error:", message.error); + } + return; + } + console.error("[ws] unknown message kind:", kind); + } + + private setStatus(status: ConnectionStatus): void { + if (this.status === status) return; + this.status = status; + for (const listener of [...this.statusListeners]) listener(status); + } + + private scheduleReconnect(): void { + const delay = Math.min(this.baseDelay * 2 ** this.attempts, this.baseDelay * 8); + this.attempts += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private failAllPending(error: Error): void { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(error); + } + this.pending.clear(); + } +} diff --git a/web_ui/vite.config.ts b/web_ui/vite.config.ts index 083450f..fab17a6 100644 --- a/web_ui/vite.config.ts +++ b/web_ui/vite.config.ts @@ -14,17 +14,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // selects which one this invocation builds. const ISLANDS = ["composer", "token-counter", "notification", "command-palette", "settings", "about", "help", "document-viewer", "chat-library", "search-overlay", "pin-overlay", "composer-picker", "composer-context", "toolbar", "plugin-picker", "grid-control", "font-control", "drag-speed", "minimap"]; +// Qt-removal plan R0 (doc/QT_REMOVAL_PLAN.md): "app" is the single-SPA +// target that consolidates the islands. Unlike islands it is SERVED by the +// Python backend (not inlined into a Qt webview), so it has no single-chunk +// constraint and builds to dist/app instead of ../assets/. The +// island targets stay buildable until the R7 cutover deletes the Qt hosts. +const APP_TARGET = "app"; + const island = process.env.GRAPHLINK_ISLAND || "composer"; -if (!ISLANDS.includes(island)) { +const isApp = island === APP_TARGET; +if (!isApp && !ISLANDS.includes(island)) { throw new Error( - `Unknown island "${island}" (set via GRAPHLINK_ISLAND). Known islands: ${ISLANDS.join(", ")}`, + `Unknown island "${island}" (set via GRAPHLINK_ISLAND). Known islands: ${[APP_TARGET, ...ISLANDS].join(", ")}`, ); } export default defineConfig({ plugins: [tailwindcss(), react()], base: "./", - root: resolve(__dirname, "src/islands", island), + root: resolve(__dirname, isApp ? "src/app" : `src/islands/${island}`), // The desktop app's live dev-server mode (GRAPHLINK_FRONTEND_DEV_URL) // allowlists ONE exact origin in its WebEngine request interceptor. These // three settings keep the real served origin pinned to that expectation: @@ -35,9 +43,17 @@ export default defineConfig({ host: "127.0.0.1", port: 5173, strictPort: true, + // App-target dev mode talks to a locally running backend; islands never + // proxy (their transport is QWebChannel, not HTTP). + proxy: isApp + ? { + "/api": "http://127.0.0.1:8765", + "/ws": { target: "ws://127.0.0.1:8765", ws: true }, + } + : undefined, }, build: { - outDir: resolve(__dirname, "../assets", island), + outDir: resolve(__dirname, isApp ? "dist/app" : `../assets/${island}`), emptyOutDir: true, sourcemap: false, }, From a758c7015f2833d67748274f2c0e5912374adc60 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 11:23:18 -0400 Subject: [PATCH 2/3] Fix R0 CI: lint errors in new files + gl-vars guard carve-out for the SPA target - App.tsx: drop the vestigial transportRef (ref write during render - react-hooks/refs error); the memoized transport was already the only consumer. - transport.test.ts: comma-expression assertion rewritten as a real assertion (no-unused-expressions error). - test_gl_vars_dev_css.py: src/app is a documented carve-out from the dev-only token rule - the SPA has no Qt host injecting :root tokens, so the shipped file IS its token source until R2's backend-served theme. Islands remain fully guarded. Local CI parity: npm run check exit 0; full offscreen pytest 1783 passed. Co-Authored-By: Claude Fable 5 --- graphlink_app/tests/test_gl_vars_dev_css.py | 11 +++++++++++ web_ui/src/app/App.tsx | 9 ++------- web_ui/src/lib/ws/transport.test.ts | 3 ++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/graphlink_app/tests/test_gl_vars_dev_css.py b/graphlink_app/tests/test_gl_vars_dev_css.py index aeaebaa..878fd08 100644 --- a/graphlink_app/tests/test_gl_vars_dev_css.py +++ b/graphlink_app/tests/test_gl_vars_dev_css.py @@ -121,7 +121,18 @@ def test_no_island_imports_it_unconditionally(self): ) assert sources, "expected to find TypeScript sources to scan" + app_root = _REPO_ROOT / "web_ui" / "src" / "app" for path in sources: + # Qt-removal plan R0 carve-out: the single-SPA target (src/app) + # imports the token values UNCONDITIONALLY, deliberately. The + # dev-only rule exists because island pages get their :root + # tokens injected by the Qt host at build time, so a shipped + # copy would silently override the active theme. The SPA has no + # Qt host and no injector - the shipped file IS its token source + # until R2 lands the backend-served theme topic. Islands remain + # fully guarded. + if path.is_relative_to(app_root): + continue code = _strip_js_comments(_read(path)) if "gl-vars-dev.css" not in code: continue diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index b6d62f1..8fb95b2 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport"; /** @@ -17,17 +17,12 @@ interface SystemState { } function App() { - const transportRef = useRef(null); const [status, setStatus] = useState("closed"); const [system, setSystem] = useState({}); const [pingMs, setPingMs] = useState(null); const [pingError, setPingError] = useState(null); - const transport = useMemo(() => { - const t = new WsTransport(defaultWsUrl()); - transportRef.current = t; - return t; - }, []); + const transport = useMemo(() => new WsTransport(defaultWsUrl()), []); useEffect(() => { const offStatus = transport.onStatus(setStatus); diff --git a/web_ui/src/lib/ws/transport.test.ts b/web_ui/src/lib/ws/transport.test.ts index 526580e..435d5a0 100644 --- a/web_ui/src/lib/ws/transport.test.ts +++ b/web_ui/src/lib/ws/transport.test.ts @@ -156,7 +156,8 @@ describe("WsTransport", () => { t.dispose(); await assertion; vi.advanceTimersByTime(1000); - expect(FakeSocket.instances).toHaveLength(1), "no reconnect after dispose"; + // No reconnect after dispose: still exactly the one original socket. + expect(FakeSocket.instances).toHaveLength(1); }); it("notifies status listeners through the lifecycle", () => { From 6ba27fd34486a1fee825370acf8151663db25545 Mon Sep 17 00:00:00 2001 From: dovvnloading Date: Wed, 22 Jul 2026 11:40:45 -0400 Subject: [PATCH 3/3] Qt-removal R1: canvas core - React Flow scene on the WS backend Per doc/QT_REMOVAL_PLAN.md phase R1: - backend/canvas.py: SceneDocument (nodes/edges/pins/settings) sourced from the surviving Qt-free models - GridViewSettings republished 1:1 on the grid-control topic (intent names match GridControlBridge's @Slots for the R2 island port), NavigationPinStore reused verbatim for pins. Full intent surface (add/move/remove/connect nodes+edges, pins CRUD, snap, drag factor), edges die with either endpoint, duplicate-connect idempotent. 11 new pytest cases. - graphlink_scene_payload.py + codegen entry: scene topic wire contract -> generated TS type + validator; topics.ts regenerated (20 artifact sets). - web_ui/src/app/canvas: SceneStore (validated snapshots, framework-free, 9 vitest cases incl. the scaleDragPosition drag-speed contract) + SceneCanvas (React Flow): model-driven grid background, snap grid, drag-speed scaling from drag start, connect via node Handles, selection + delete keys, minimap, LOD collapse threshold, pins panel (add view pin / jump / remove), double-click create (RF dblclick-zoom disabled - it swallowed the gesture; found in the live drive). Live acceptance PASSED in the native pywebview window: created 2 nodes, moved one (backend persisted 523,215), connected n0->n1 by handle drag, pinned the view + jumped + removed, minimap tracked throughout; a FRESH WS client then received the complete backend-held scene - the round-trip criterion, proven with a second client rather than a reload. CI parity verified locally: npm run check exit 0 (52 files / 553 vitest), full offscreen pytest 1794 passed. Burn-down: 168 of 168 remaining (R1 adds only Qt-free code; deletion starts as R2+ cuts surfaces over). Co-Authored-By: Claude Fable 5 --- backend/__init__.py | 14 + backend/app.py | 4 + backend/canvas.py | 269 ++++++++++++++++++ backend/tests/test_app_ws.py | 5 +- backend/tests/test_canvas.py | 168 +++++++++++ graphlink_app/graphlink_island_codegen.py | 11 + graphlink_app/graphlink_scene_payload.py | 56 ++++ web_ui/package-lock.json | 243 +++++++++++++++- web_ui/package.json | 1 + web_ui/src/app/App.tsx | 12 +- web_ui/src/app/canvas/SceneCanvas.tsx | 258 +++++++++++++++++ web_ui/src/app/canvas/sceneStore.test.ts | 131 +++++++++ web_ui/src/app/canvas/sceneStore.ts | 141 +++++++++ web_ui/src/app/styles.css | 171 +++++++++++ web_ui/src/lib/api-contract/topics.ts | 3 + .../generated/scene-state.schema.json | 117 ++++++++ .../lib/bridge-core/generated/scene-state.ts | 182 ++++++++++++ 17 files changed, 1778 insertions(+), 8 deletions(-) create mode 100644 backend/canvas.py create mode 100644 backend/tests/test_canvas.py create mode 100644 graphlink_app/graphlink_scene_payload.py create mode 100644 web_ui/src/app/canvas/SceneCanvas.tsx create mode 100644 web_ui/src/app/canvas/sceneStore.test.ts create mode 100644 web_ui/src/app/canvas/sceneStore.ts create mode 100644 web_ui/src/lib/bridge-core/generated/scene-state.schema.json create mode 100644 web_ui/src/lib/bridge-core/generated/scene-state.ts diff --git a/backend/__init__.py b/backend/__init__.py index 6881226..ef31969 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -11,4 +11,18 @@ graphlink_app/tests/test_no_qt_anywhere.py. """ +import sys +from pathlib import Path + BACKEND_VERSION = "0.1.0" + +# The surviving Qt-free domain modules (GridViewSettings, NavigationPinStore, +# payload dataclasses, session layer, ...) live flat inside graphlink_app/ +# and import each other by bare name - the same path convention the Qt entry +# point used. The backend consumes them under that convention until the R7 +# cutover restructures the tree. Qt-free-ness of everything imported from +# there is enforced per-module by test_no_qt_anywhere.py's zero-tolerance +# rule the moment it lands in backend/ imports. +_GRAPHLINK_APP_DIR = str(Path(__file__).resolve().parents[1] / "graphlink_app") +if _GRAPHLINK_APP_DIR not in sys.path: + sys.path.insert(0, _GRAPHLINK_APP_DIR) diff --git a/backend/app.py b/backend/app.py index e17cf80..6f424a0 100644 --- a/backend/app.py +++ b/backend/app.py @@ -31,6 +31,7 @@ from fastapi.staticfiles import StaticFiles from backend import BACKEND_VERSION +from backend.canvas import register_canvas from backend.events import EventBus, SessionBus, UnknownIntentError, UnknownTopicError logger = logging.getLogger(__name__) @@ -56,6 +57,9 @@ def ping(*args): bus.register_intent("system", "ping", ping) + # R1 (doc/QT_REMOVAL_PLAN.md): scene document + grid topics. + register_canvas(bus) + def create_app(spa_dir: Path | None = None) -> FastAPI: app = FastAPI(title="Graphlink backend", version=BACKEND_VERSION) diff --git a/backend/canvas.py b/backend/canvas.py new file mode 100644 index 0000000..4b04c35 --- /dev/null +++ b/backend/canvas.py @@ -0,0 +1,269 @@ +"""Canvas domain state for the new architecture (Qt-removal plan R1). + +The backend half of the React Flow canvas: the scene document (nodes, edges, +navigation pins, canvas settings) plus the grid-appearance topic, all +session-scoped and WS-published as full snapshots. + +Model sources are the surviving Qt-free modules, per the plan's R1 line +"sourced from the existing Qt-free scene model files": +- `GridViewSettings` (graphlink_grid_view_settings) - the exact model + `ChatView.drawBackground()` reads today, republished here unchanged so the + R2 grid-control port keeps its intent names 1:1 with GridControlBridge. +- `NavigationPinStore`/`NavigationPinRecord` (graphlink_navigation_pins) - + the pin domain store, reused verbatim (validation, ordering, ids). + +R1 nodes are PLACEHOLDERS by design (plan acceptance: "create/move/connect +placeholder nodes"); real node types arrive one-per-increment in R3. Scene +persistence to the session DB is R6 - for R1 the backend's in-memory +document IS the source of truth the window can reload against. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass, field +from typing import Any + +from graphlink_grid_view_settings import ( + GRID_SIZE_PRESETS, + GRID_STYLE_PRESETS, + GridViewSettings, +) +from graphlink_navigation_pins import NavigationPinRecord, NavigationPinStore + +from backend.events import SessionBus + +# Dark-theme grid swatches. The Qt bridge derived 3 of 5 from the live +# QPalette; the backend is Qt-free by law (test_no_qt_anywhere.py), so until +# the R2 theme service exists these are the dark theme's actual values, +# frozen here as data, not styling. +GRID_COLOR_PRESETS = ["#404040", "#555555", "#4a90d9", "#2f5b3c", "#5b2f4f"] + +DRAG_FACTOR_MIN = 0.05 +DRAG_FACTOR_MAX = 1.0 + + +class SceneError(ValueError): + """A scene intent referenced something that does not exist or is invalid. + Raised so the WS layer reports it to the caller instead of crashing.""" + + +@dataclass +class SceneNode: + id: str + x: float + y: float + title: str + kind: str = "placeholder" + + +@dataclass +class SceneEdge: + id: str + source: str + target: str + + +@dataclass +class SceneDocument: + """The canvas document for one session. Plain data + invariants; the + R6 serializer will read/write exactly this shape.""" + + nodes: dict[str, SceneNode] = field(default_factory=dict) + edges: dict[str, SceneEdge] = field(default_factory=dict) + pins: NavigationPinStore = field(default_factory=NavigationPinStore) + grid: GridViewSettings = field(default_factory=GridViewSettings) + snap_to_grid: bool = False + drag_factor: float = 1.0 + _counter: itertools.count = field(default_factory=itertools.count, repr=False) + + # -- nodes ------------------------------------------------------------- + + def add_node(self, x: float, y: float, title: str = "") -> SceneNode: + node_id = f"n{next(self._counter)}" + node = SceneNode(id=node_id, x=float(x), y=float(y), title=title or f"Node {node_id[1:]}") + self.nodes[node_id] = node + return node + + def move_node(self, node_id: str, x: float, y: float) -> SceneNode: + node = self.nodes.get(node_id) + if node is None: + raise SceneError(f"unknown node: {node_id}") + node.x, node.y = float(x), float(y) + return node + + def remove_nodes(self, node_ids: list[str]) -> None: + for node_id in node_ids: + if self.nodes.pop(node_id, None) is not None: + # Edges die with either endpoint - same invariant ChatScene + # enforced on node removal. + self.edges = { + eid: e + for eid, e in self.edges.items() + if e.source != node_id and e.target != node_id + } + + # -- edges ------------------------------------------------------------- + + def connect(self, source: str, target: str) -> SceneEdge: + if source not in self.nodes or target not in self.nodes: + raise SceneError(f"cannot connect unknown nodes: {source} -> {target}") + if source == target: + raise SceneError("cannot connect a node to itself") + for edge in self.edges.values(): + if edge.source == source and edge.target == target: + return edge # idempotent, matching ChatScene's duplicate guard + edge_id = f"e{next(self._counter)}" + edge = SceneEdge(id=edge_id, source=source, target=target) + self.edges[edge_id] = edge + return edge + + def remove_edges(self, edge_ids: list[str]) -> None: + for edge_id in edge_ids: + self.edges.pop(edge_id, None) + + # -- settings ---------------------------------------------------------- + + def set_drag_factor(self, factor: float) -> None: + self.drag_factor = max(DRAG_FACTOR_MIN, min(DRAG_FACTOR_MAX, float(factor))) + + # -- snapshots --------------------------------------------------------- + + def scene_payload(self) -> dict[str, Any]: + return { + "nodes": [ + {"id": n.id, "x": n.x, "y": n.y, "title": n.title, "kind": n.kind} + for n in self.nodes.values() + ], + "edges": [ + {"id": e.id, "source": e.source, "target": e.target} + for e in self.edges.values() + ], + "pins": [ + { + "id": p.pin_id, + "title": p.title, + "note": p.note, + "x": p.position[0], + "y": p.position[1], + } + for p in self.pins.records + ], + "snapToGrid": self.snap_to_grid, + "dragFactor": self.drag_factor, + } + + def grid_payload(self) -> dict[str, Any]: + # Field-for-field the GridControlStatePayload shape (whole-percent + # opacity, presets on the wire) so the generated validator the + # grid-control island already uses validates this topic untouched. + return { + "gridSize": self.grid.grid_size, + "gridOpacityPercent": round(self.grid.grid_opacity * 100), + "gridStyle": self.grid.grid_style, + "gridColor": self.grid.grid_color, + "sizePresets": list(GRID_SIZE_PRESETS), + "stylePresets": list(GRID_STYLE_PRESETS), + "colorPresets": list(GRID_COLOR_PRESETS), + } + + +def register_canvas(bus: SessionBus) -> SceneDocument: + """Give a session its canvas document + the scene/grid topics and every + R1 intent. Intent names for grid mirror GridControlBridge's @Slot names + 1:1 so the R2 island port is a transport swap, not a redesign.""" + + document = SceneDocument() + + bus.register_topic("scene", document.scene_payload) + bus.register_topic("grid-control", document.grid_payload) + + async def publish_scene(): + await bus.publish("scene") + + async def publish_grid(): + await bus.publish("grid-control") + + # -- scene intents (async: they publish after mutating) --------------- + + async def add_node(x, y, title=""): + node = document.add_node(x, y, title) + await publish_scene() + return node.id + + async def move_node(node_id, x, y): + document.move_node(node_id, x, y) + await publish_scene() + + async def remove_nodes(node_ids): + document.remove_nodes(list(node_ids)) + await publish_scene() + + async def connect_nodes(source, target): + edge = document.connect(source, target) + await publish_scene() + return edge.id + + async def remove_edges(edge_ids): + document.remove_edges(list(edge_ids)) + await publish_scene() + + async def add_pin(title, x, y, note=""): + record = NavigationPinRecord.create(title=title, x=x, y=y, note=note) + document.pins.add(record) + await publish_scene() + return record.pin_id + + async def move_pin(pin_id, x, y): + document.pins.move(pin_id, x, y) + await publish_scene() + + async def remove_pin(pin_id): + document.pins.remove(pin_id) + await publish_scene() + + async def set_snap_to_grid(enabled): + document.snap_to_grid = bool(enabled) + await publish_scene() + + async def set_drag_factor(factor): + document.set_drag_factor(factor) + await publish_scene() + + bus.register_intent("scene", "addNode", add_node) + bus.register_intent("scene", "moveNode", move_node) + bus.register_intent("scene", "removeNodes", remove_nodes) + bus.register_intent("scene", "connectNodes", connect_nodes) + bus.register_intent("scene", "removeEdges", remove_edges) + bus.register_intent("scene", "addPin", add_pin) + bus.register_intent("scene", "movePin", move_pin) + bus.register_intent("scene", "removePin", remove_pin) + bus.register_intent("scene", "setSnapToGrid", set_snap_to_grid) + bus.register_intent("scene", "setDragFactor", set_drag_factor) + + # -- grid intents (names == GridControlBridge @Slot names) ------------- + + async def set_grid_size(size): + document.grid.grid_size = int(size) + await publish_grid() + + async def set_grid_opacity_percent(percent): + document.grid.grid_opacity = max(0, min(100, int(percent))) / 100.0 + await publish_grid() + + async def set_grid_style(style): + if style not in GRID_STYLE_PRESETS: + raise SceneError(f"unknown grid style: {style}") + document.grid.grid_style = str(style) + await publish_grid() + + async def set_grid_color(color_hex): + document.grid.grid_color = str(color_hex) + await publish_grid() + + bus.register_intent("grid-control", "setGridSize", set_grid_size) + bus.register_intent("grid-control", "setGridOpacityPercent", set_grid_opacity_percent) + bus.register_intent("grid-control", "setGridStyle", set_grid_style) + bus.register_intent("grid-control", "setGridColor", set_grid_color) + + return document diff --git a/backend/tests/test_app_ws.py b/backend/tests/test_app_ws.py index f18607c..b1b2586 100644 --- a/backend/tests/test_app_ws.py +++ b/backend/tests/test_app_ws.py @@ -44,8 +44,9 @@ def test_subscribe_without_topics_sends_every_registered_topic(): client = make_client() with client.websocket_connect("/ws") as ws: ws.send_json({"kind": "subscribe"}) - message = ws.receive_json() - assert message["topic"] == "system", "R0 registers exactly the system topic" + # R1 surface: grid-control + scene (canvas) alongside system, sorted. + topics = [ws.receive_json()["topic"] for _ in range(3)] + assert topics == ["grid-control", "scene", "system"] def test_ping_round_trip_returns_echo_and_server_time(): diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py new file mode 100644 index 0000000..d685c67 --- /dev/null +++ b/backend/tests/test_canvas.py @@ -0,0 +1,168 @@ +"""Canvas domain tests (Qt-removal plan R1): scene document invariants, +intent surface, grid payload compatibility with the generated validator's +shape, and snapshot publishing.""" + +import asyncio + +import pytest + +from backend.canvas import ( + DRAG_FACTOR_MAX, + DRAG_FACTOR_MIN, + SceneDocument, + SceneError, + register_canvas, +) +from backend.events import SessionBus + + +# -- document invariants ---------------------------------------------------- + + +def test_add_move_and_remove_nodes(): + doc = SceneDocument() + a = doc.add_node(10, 20, "A") + assert doc.nodes[a.id].x == 10 + doc.move_node(a.id, -5.5, 7.25) + assert (doc.nodes[a.id].x, doc.nodes[a.id].y) == (-5.5, 7.25) + doc.remove_nodes([a.id]) + assert doc.nodes == {} + + +def test_move_unknown_node_raises_scene_error(): + with pytest.raises(SceneError): + SceneDocument().move_node("nope", 0, 0) + + +def test_connect_validates_and_is_idempotent(): + doc = SceneDocument() + a, b = doc.add_node(0, 0), doc.add_node(100, 0) + edge = doc.connect(a.id, b.id) + assert doc.connect(a.id, b.id).id == edge.id, "duplicate connect returns the same edge" + with pytest.raises(SceneError): + doc.connect(a.id, a.id) + with pytest.raises(SceneError): + doc.connect(a.id, "ghost") + + +def test_removing_a_node_removes_its_edges(): + doc = SceneDocument() + a, b, c = doc.add_node(0, 0), doc.add_node(1, 1), doc.add_node(2, 2) + doc.connect(a.id, b.id) + keep = doc.connect(b.id, c.id) + doc.remove_nodes([a.id]) + assert list(doc.edges) == [keep.id], "edges die with either endpoint" + + +def test_drag_factor_is_bounded(): + doc = SceneDocument() + doc.set_drag_factor(99) + assert doc.drag_factor == DRAG_FACTOR_MAX + doc.set_drag_factor(0.0001) + assert doc.drag_factor == DRAG_FACTOR_MIN + + +def test_grid_payload_matches_generated_validator_shape(): + payload = SceneDocument().grid_payload() + # Field-for-field the GridControlStatePayload contract (minus the + # envelope, which the bus stamps): the R2 island port depends on this. + assert set(payload) == { + "gridSize", + "gridOpacityPercent", + "gridStyle", + "gridColor", + "sizePresets", + "stylePresets", + "colorPresets", + } + assert isinstance(payload["gridOpacityPercent"], int) + assert len(payload["colorPresets"]) == 5 + + +# -- intent surface over the bus -------------------------------------------- + + +class Recorder: + def __init__(self): + self.messages = [] + + async def send_json(self, data): + self.messages.append(data) + + def topics_seen(self): + return [m["topic"] for m in self.messages if m["kind"] == "state"] + + +def make_bus(): + bus = SessionBus("canvas-test") + document = register_canvas(bus) + recorder = Recorder() + bus.attach(recorder) + return bus, document, recorder + + +def test_scene_intents_mutate_and_publish(): + async def run(): + bus, document, recorder = make_bus() + node_id = await bus.dispatch_intent("scene", "addNode", [40, 60, "hello"]) + assert document.nodes[node_id].title == "hello" + other = await bus.dispatch_intent("scene", "addNode", [0, 0]) + edge_id = await bus.dispatch_intent("scene", "connectNodes", [node_id, other]) + assert edge_id in document.edges + await bus.dispatch_intent("scene", "moveNode", [node_id, 1, 2]) + assert (document.nodes[node_id].x, document.nodes[node_id].y) == (1, 2) + assert recorder.topics_seen().count("scene") == 4, "every mutation publishes" + + asyncio.run(run()) + + +def test_pin_intents_round_trip_through_the_store(): + async def run(): + bus, document, _ = make_bus() + pin_id = await bus.dispatch_intent("scene", "addPin", ["Start here", 5, 9, "note!"]) + payload = document.scene_payload() + assert payload["pins"] == [ + {"id": pin_id, "title": "Start here", "note": "note!", "x": 5.0, "y": 9.0} + ] + await bus.dispatch_intent("scene", "movePin", [pin_id, 50, 90]) + assert document.scene_payload()["pins"][0]["x"] == 50.0 + await bus.dispatch_intent("scene", "removePin", [pin_id]) + assert document.scene_payload()["pins"] == [] + + asyncio.run(run()) + + +def test_grid_intents_use_the_bridge_slot_names_and_publish_grid_topic(): + async def run(): + bus, document, recorder = make_bus() + await bus.dispatch_intent("grid-control", "setGridSize", [50]) + await bus.dispatch_intent("grid-control", "setGridOpacityPercent", [140]) + await bus.dispatch_intent("grid-control", "setGridStyle", ["Lines"]) + await bus.dispatch_intent("grid-control", "setGridColor", ["#404040"]) + assert document.grid.grid_size == 50 + assert document.grid.grid_opacity == 1.0, "opacity clamps to 100%" + assert document.grid.grid_style == "Lines" + assert recorder.topics_seen().count("grid-control") == 4 + + asyncio.run(run()) + + +def test_unknown_grid_style_is_rejected(): + async def run(): + bus, _, _ = make_bus() + with pytest.raises(SceneError): + await bus.dispatch_intent("grid-control", "setGridStyle", ["Sparkles"]) + + asyncio.run(run()) + + +def test_snap_and_drag_factor_intents_publish_scene(): + async def run(): + bus, document, _ = make_bus() + await bus.dispatch_intent("scene", "setSnapToGrid", [True]) + await bus.dispatch_intent("scene", "setDragFactor", [0.5]) + payload = document.scene_payload() + assert payload["snapToGrid"] is True + assert payload["dragFactor"] == 0.5 + + asyncio.run(run()) diff --git a/graphlink_app/graphlink_island_codegen.py b/graphlink_app/graphlink_island_codegen.py index 60d699c..7435b25 100644 --- a/graphlink_app/graphlink_island_codegen.py +++ b/graphlink_app/graphlink_island_codegen.py @@ -471,6 +471,17 @@ def _validator_for(name: str, fields: dict[str, Any]) -> str: "schema_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "minimap-state.schema.json", "ts_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "minimap-state.ts", }, + { + # Qt-removal plan R1: the scene topic (backend/canvas.py's + # SceneDocument) - the first payload born for the WS bus rather than + # a QWebChannel island; same registry, same pipeline. + "dataclass": None, # resolved lazily in main() to avoid importing + "dataclass_import": ("graphlink_scene_payload", "SceneStatePayload"), + "title": "SceneState", + "source": "graphlink_app/graphlink_scene_payload.py::SceneStatePayload", + "schema_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "scene-state.schema.json", + "ts_path": _REPO_ROOT / "web_ui" / "src" / "lib" / "bridge-core" / "generated" / "scene-state.ts", + }, ] diff --git a/graphlink_app/graphlink_scene_payload.py b/graphlink_app/graphlink_scene_payload.py new file mode 100644 index 0000000..3b05daf --- /dev/null +++ b/graphlink_app/graphlink_scene_payload.py @@ -0,0 +1,56 @@ +"""The scene topic's wire contract (Qt-removal plan R1) - the canvas +document the React Flow canvas renders: nodes, edges, navigation pins, and +canvas settings, published as full snapshots over the WS event bus. + +THIS IS A WIRE FORMAT, NOT A DOMAIN MODEL (the graphlink_composer_payload.py +convention): the domain lives in backend/canvas.py's SceneDocument, which +sources GridViewSettings and NavigationPinStore - this module only fixes the +JSON shape and generates the TS type + runtime validator the SPA consumes. + +R1 nodes are placeholders (`kind: "placeholder"`); R3 extends `kind` per +migrated node type - additive only, per the schema-versioning contract. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class SceneNodeRow: + id: str + x: float + y: float + title: str + kind: str + + +@dataclass +class SceneEdgeRow: + id: str + source: str + target: str + + +@dataclass +class ScenePinRow: + id: str + title: str + note: str + x: float + y: float + + +@dataclass +class SceneStatePayload: + """The complete published snapshot, including the envelope fields the + event bus stamps onto every topic's payload.""" + + schemaVersion: int + revision: int + nodes: list[SceneNodeRow] + edges: list[SceneEdgeRow] + pins: list[ScenePinRow] + snapToGrid: bool + dragFactor: float + minCompatibleSchemaVersion: int | None = None diff --git a/web_ui/package-lock.json b/web_ui/package-lock.json index a26fa43..57a61a0 100644 --- a/web_ui/package-lock.json +++ b/web_ui/package-lock.json @@ -8,6 +8,7 @@ "name": "@graphlink/web-ui", "version": "0.1.0", "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -2038,6 +2039,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2139,7 +2189,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "peerDependencies": { @@ -2536,6 +2586,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2792,6 +2884,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2851,6 +2949,112 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -6033,6 +6237,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6379,6 +6592,34 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/web_ui/package.json b/web_ui/package.json index 9881ee1..05e9e90 100644 --- a/web_ui/package.json +++ b/web_ui/package.json @@ -17,6 +17,7 @@ "check": "npm run check:schema && npm run typecheck && npm run lint && npm run test && npm run build" }, "dependencies": { + "@xyflow/react": "^12.11.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/web_ui/src/app/App.tsx b/web_ui/src/app/App.tsx index 8fb95b2..f955c03 100644 --- a/web_ui/src/app/App.tsx +++ b/web_ui/src/app/App.tsx @@ -1,5 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { ConnectionStatus, WsTransport, defaultWsUrl } from "../lib/ws/transport"; +import { SceneCanvas } from "./canvas/SceneCanvas"; +import { SceneStore } from "./canvas/sceneStore"; /** * The single-app shell (Qt-removal plan R0). @@ -23,19 +25,22 @@ function App() { const [pingError, setPingError] = useState(null); const transport = useMemo(() => new WsTransport(defaultWsUrl()), []); + const sceneStore = useMemo(() => new SceneStore(transport), [transport]); useEffect(() => { const offStatus = transport.onStatus(setStatus); const offSystem = transport.subscribe("system", (payload) => { setSystem(payload as SystemState); }); + sceneStore.connect(); transport.connect(); return () => { offStatus(); offSystem(); + sceneStore.dispose(); transport.dispose(); }; - }, [transport]); + }, [transport, sceneStore]); async function ping() { setPingError(null); @@ -60,10 +65,7 @@ function App() {
-
-

Canvas

-

React Flow node graph lands in R1.

-
+

SYSTEM

diff --git a/web_ui/src/app/canvas/SceneCanvas.tsx b/web_ui/src/app/canvas/SceneCanvas.tsx new file mode 100644 index 0000000..9fb4b8f --- /dev/null +++ b/web_ui/src/app/canvas/SceneCanvas.tsx @@ -0,0 +1,258 @@ +import { + Background, + BackgroundVariant, + Handle, + MiniMap, + Position, + ReactFlow, + ReactFlowProvider, + useReactFlow, + useStore, + type Connection, + type Edge, + type Node, + type NodeChange, + type NodeProps, + applyNodeChanges, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { SceneState } from "../../lib/bridge-core/generated/scene-state"; +import { SceneStore, scaleDragPosition } from "./sceneStore"; + +/** + * The React Flow canvas (Qt-removal plan R1) - the QGraphicsScene/ChatView + * successor. R1 scope: pan/zoom, model-driven grid (size/style/color/opacity + * + snap), node drag with the drag-speed factor, edges, selection + delete, + * minimap, an LOD threshold, navigation pins. Placeholder nodes only; real + * node types land per-increment in R3. + */ + +// Below this zoom the node body collapses to its title bar - the R1 seed of +// the Qt canvas's LOD thresholds (full LOD tiers return with real nodes). +const LOD_ZOOM_THRESHOLD = 0.5; + +const GRID_VARIANTS: Record = { + Dots: BackgroundVariant.Dots, + Lines: BackgroundVariant.Lines, + Cross: BackgroundVariant.Cross, +}; + +type PlaceholderNode = Node<{ title: string }, "placeholder">; + +function PlaceholderNodeView({ data, selected }: NodeProps) { + const zoom = useStore((s) => s.transform[2]); + const collapsed = zoom < LOD_ZOOM_THRESHOLD; + return ( +
+ {/* Connection endpoints mirror the Qt canvas's flow: children hang off + the bottom of a parent (vertical layout), so target on top, source + on bottom. */} + +
{data.title}
+ {!collapsed &&
placeholder — real nodes land in R3
} + +
+ ); +} + +const NODE_TYPES = { placeholder: PlaceholderNodeView }; + +function toFlowNodes(scene: SceneState): PlaceholderNode[] { + return scene.nodes.map((n) => ({ + id: n.id, + type: "placeholder" as const, + position: { x: n.x, y: n.y }, + data: { title: n.title }, + })); +} + +function toFlowEdges(scene: SceneState): Edge[] { + return scene.edges.map((e) => ({ id: e.id, source: e.source, target: e.target })); +} + +function CanvasInner({ store }: { store: SceneStore }) { + const scene = useSyncExternalStore(store.subscribe, store.getScene); + const grid = useSyncExternalStore(store.subscribe, store.getGrid); + const { setCenter } = useReactFlow(); + + // Local node state exists so dragging is fluid; backend snapshots are the + // truth and reconcile in whenever nothing is being dragged. dragStartRef + // powers the drag-speed scaling contract (see scaleDragPosition). + const [nodes, setNodes] = useState([]); + const dragStartRef = useRef>(new Map()); + const draggingRef = useRef(false); + + useEffect(() => { + if (!draggingRef.current) setNodes(toFlowNodes(scene)); + }, [scene]); + + const edges = useMemo(() => toFlowEdges(scene), [scene]); + + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + const scaled = changes.map((change) => { + if (change.type !== "position" || !change.position) return change; + if (change.dragging) { + draggingRef.current = true; + let start = dragStartRef.current.get(change.id); + if (!start) { + const node = nodes.find((n) => n.id === change.id); + start = node ? { ...node.position } : { ...change.position }; + dragStartRef.current.set(change.id, start); + } + return { + ...change, + position: scaleDragPosition(start, change.position, scene.dragFactor), + }; + } + // Drag end: commit the node's final (already-scaled) position. + draggingRef.current = false; + const settled = nodes.find((n) => n.id === change.id); + if (settled) store.moveNode(change.id, settled.position.x, settled.position.y); + dragStartRef.current.delete(change.id); + return change; + }); + setNodes((current) => applyNodeChanges(scaled, current)); + }, + [nodes, scene.dragFactor, store], + ); + + const onConnect = useCallback( + (connection: Connection) => { + if (connection.source && connection.target) { + store.connectNodes(connection.source, connection.target); + } + }, + [store], + ); + + const onDelete = useCallback( + ({ nodes: deletedNodes, edges: deletedEdges }: { nodes: Node[]; edges: Edge[] }) => { + store.removeNodes(deletedNodes.map((n) => n.id)); + // Skip edges an already-deleted node takes with it server-side. + const dying = new Set(deletedNodes.map((n) => n.id)); + store.removeEdges( + deletedEdges.filter((e) => !dying.has(e.source) && !dying.has(e.target)).map((e) => e.id), + ); + }, + [store], + ); + + const { screenToFlowPosition } = useReactFlow(); + const onDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Double-click on empty canvas creates a node there - the R1 stand-in + // for the plugin picker / context menu creation paths (R2/R8). + const target = event.target as HTMLElement; + if (!target.closest(".react-flow__node")) { + const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + store.addNode(position.x, position.y); + } + }, + [screenToFlowPosition, store], + ); + + const jumpToPin = useCallback( + (x: number, y: number) => setCenter(x, y, { zoom: 1, duration: 300 }), + [setCenter], + ); + + return ( +
+ + + + + +
+ ); +} + +function PinsPanel({ + store, + onJump, +}: { + store: SceneStore; + onJump: (x: number, y: number) => void; +}) { + const scene = useSyncExternalStore(store.subscribe, store.getScene); + const { getViewport } = useReactFlow(); + + const addPinHere = useCallback(() => { + // Pin the current view center - the R1 equivalent of the Qt canvas's + // "Pin this location" context-menu verb. + const viewport = getViewport(); + const centerX = (window.innerWidth / 2 - viewport.x) / viewport.zoom; + const centerY = (window.innerHeight / 2 - viewport.y) / viewport.zoom; + store.addPin(`Pin ${scene.pins.length + 1}`, centerX, centerY); + }, [getViewport, scene.pins.length, store]); + + return ( + + ); +} + +export function SceneCanvas({ store }: { store: SceneStore }) { + return ( + + + + ); +} diff --git a/web_ui/src/app/canvas/sceneStore.test.ts b/web_ui/src/app/canvas/sceneStore.test.ts new file mode 100644 index 0000000..dc5b452 --- /dev/null +++ b/web_ui/src/app/canvas/sceneStore.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import { SceneStore, initialSceneState, scaleDragPosition } from "./sceneStore"; +import type { WsTransport } from "../../lib/ws/transport"; + +type StateListener = (payload: Record) => void; + +function makeFakeTransport() { + const listeners = new Map(); + const intents: Array<{ topic: string; intent: string; args: unknown[] }> = []; + const transport = { + subscribe: vi.fn((topic: string, listener: StateListener) => { + listeners.set(topic, listener); + return () => listeners.delete(topic); + }), + intent: vi.fn((topic: string, intent: string, args: unknown[] = []) => { + intents.push({ topic, intent, args }); + }), + } as unknown as WsTransport; + return { transport, listeners, intents }; +} + +function validScenePayload(overrides: Record = {}) { + return { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 3, + nodes: [{ id: "n0", x: 1, y: 2, title: "A", kind: "placeholder" }], + edges: [], + pins: [], + snapToGrid: true, + dragFactor: 0.5, + ...overrides, + }; +} + +describe("SceneStore", () => { + it("accepts a VALID scene snapshot and notifies subscribers", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + const seen = vi.fn(); + store.subscribe(seen); + + listeners.get("scene")!(validScenePayload()); + expect(seen).toHaveBeenCalledTimes(1); + expect(store.getScene().nodes[0].title).toBe("A"); + expect(store.getScene().dragFactor).toBe(0.5); + }); + + it("REJECTS a malformed snapshot and keeps the previous state", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + listeners.get("scene")!({ revision: "not-a-scene" }); + expect(store.getScene()).toEqual(initialSceneState); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("routes grid snapshots through the grid validator", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + listeners.get("grid-control")!({ + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 1, + gridSize: 50, + gridOpacityPercent: 80, + gridStyle: "Lines", + gridColor: "#404040", + sizePresets: [10, 20, 50, 100], + stylePresets: ["Dots", "Lines", "Cross"], + colorPresets: [], + }); + expect(store.getGrid().gridSize).toBe(50); + expect(store.getGrid().gridStyle).toBe("Lines"); + }); + + it("sends intents with the backend's registered names and shapes", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.addNode(10, 20, "hello"); + store.moveNode("n1", 3, 4); + store.connectNodes("n1", "n2"); + store.addPin("P", 5, 6, "note"); + store.setSnapToGrid(true); + store.setDragFactor(0.25); + expect(intents).toEqual([ + { topic: "scene", intent: "addNode", args: [10, 20, "hello"] }, + { topic: "scene", intent: "moveNode", args: ["n1", 3, 4] }, + { topic: "scene", intent: "connectNodes", args: ["n1", "n2"] }, + { topic: "scene", intent: "addPin", args: ["P", 5, 6, "note"] }, + { topic: "scene", intent: "setSnapToGrid", args: [true] }, + { topic: "scene", intent: "setDragFactor", args: [0.25] }, + ]); + }); + + it("suppresses empty removal intents", () => { + const { transport, intents } = makeFakeTransport(); + const store = new SceneStore(transport); + store.removeNodes([]); + store.removeEdges([]); + expect(intents).toEqual([]); + }); + + it("dispose() unsubscribes every topic", () => { + const { transport, listeners } = makeFakeTransport(); + const store = new SceneStore(transport); + store.connect(); + expect(listeners.size).toBe(2); + store.dispose(); + expect(listeners.size).toBe(0); + }); +}); + +describe("scaleDragPosition (the drag-speed contract)", () => { + it("factor 1 leaves motion unscaled", () => { + expect(scaleDragPosition({ x: 0, y: 0 }, { x: 100, y: 40 }, 1)).toEqual({ x: 100, y: 40 }); + }); + + it("factor 0.5 halves the delta from the drag start", () => { + expect(scaleDragPosition({ x: 10, y: 10 }, { x: 110, y: 50 }, 0.5)).toEqual({ x: 60, y: 30 }); + }); + + it("scales relative to the start, not the origin", () => { + expect(scaleDragPosition({ x: -20, y: 8 }, { x: -20, y: 8 }, 0.25)).toEqual({ x: -20, y: 8 }); + }); +}); diff --git a/web_ui/src/app/canvas/sceneStore.ts b/web_ui/src/app/canvas/sceneStore.ts new file mode 100644 index 0000000..18846c7 --- /dev/null +++ b/web_ui/src/app/canvas/sceneStore.ts @@ -0,0 +1,141 @@ +/** + * Scene-topic client store (Qt-removal plan R1). + * + * Binds the WS transport's "scene" topic to a validated, subscribable local + * snapshot, and exposes the intent surface backend/canvas.py registers. + * Deliberately framework-free (plain listeners, no React import) so the + * store logic is unit-testable without rendering; React consumes it through + * useSyncExternalStore in SceneCanvas. + */ + +import { TOPIC_VALIDATORS } from "../../lib/api-contract/topics"; +import type { SceneState } from "../../lib/bridge-core/generated/scene-state"; +import type { GridControlState } from "../../lib/bridge-core/generated/grid-control-state"; +import type { WsTransport } from "../../lib/ws/transport"; + +export const initialSceneState: SceneState = { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 0, + nodes: [], + edges: [], + pins: [], + snapToGrid: false, + dragFactor: 1, +}; + +export const initialGridState: GridControlState = { + schemaVersion: 1, + minCompatibleSchemaVersion: 1, + revision: 0, + gridSize: 10, + gridOpacityPercent: 30, + gridStyle: "Dots", + gridColor: "#555555", + sizePresets: [10, 20, 50, 100], + stylePresets: ["Dots", "Lines", "Cross"], + colorPresets: [], +}; + +type Listener = () => void; + +export class SceneStore { + private scene: SceneState = initialSceneState; + private grid: GridControlState = initialGridState; + private readonly listeners = new Set(); + private readonly unsubscribers: Array<() => void> = []; + + constructor(private readonly transport: WsTransport) {} + + connect(): void { + this.unsubscribers.push( + this.transport.subscribe("scene", (payload) => { + const validated = TOPIC_VALIDATORS["scene"](payload); + if (validated.ok) { + this.scene = validated.value; + this.emit(); + } else { + console.error("[scene] rejected snapshot:", validated.errors); + } + }), + this.transport.subscribe("grid-control", (payload) => { + const validated = TOPIC_VALIDATORS["grid-control"](payload); + if (validated.ok) { + this.grid = validated.value; + this.emit(); + } else { + console.error("[grid-control] rejected snapshot:", validated.errors); + } + }), + ); + } + + dispose(): void { + for (const unsubscribe of this.unsubscribers) unsubscribe(); + this.unsubscribers.length = 0; + } + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getScene = (): SceneState => this.scene; + getGrid = (): GridControlState => this.grid; + + private emit(): void { + for (const listener of [...this.listeners]) listener(); + } + + // -- intents (backend/canvas.py's registered surface, 1:1) --------------- + + addNode(x: number, y: number, title = ""): void { + this.transport.intent("scene", "addNode", [x, y, title]); + } + + moveNode(id: string, x: number, y: number): void { + this.transport.intent("scene", "moveNode", [id, x, y]); + } + + removeNodes(ids: string[]): void { + if (ids.length > 0) this.transport.intent("scene", "removeNodes", [ids]); + } + + connectNodes(source: string, target: string): void { + this.transport.intent("scene", "connectNodes", [source, target]); + } + + removeEdges(ids: string[]): void { + if (ids.length > 0) this.transport.intent("scene", "removeEdges", [ids]); + } + + addPin(title: string, x: number, y: number, note = ""): void { + this.transport.intent("scene", "addPin", [title, x, y, note]); + } + + removePin(id: string): void { + this.transport.intent("scene", "removePin", [id]); + } + + setSnapToGrid(enabled: boolean): void { + this.transport.intent("scene", "setSnapToGrid", [enabled]); + } + + setDragFactor(factor: number): void { + this.transport.intent("scene", "setDragFactor", [factor]); + } +} + +/** start + (proposed - start) * factor: the drag-speed contract carried over + * from the Qt canvas (ChatView's drag factor scaled item motion the same + * way). Exported standalone for direct unit testing. */ +export function scaleDragPosition( + start: { x: number; y: number }, + proposed: { x: number; y: number }, + factor: number, +): { x: number; y: number } { + return { + x: start.x + (proposed.x - start.x) * factor, + y: start.y + (proposed.y - start.y) * factor, + }; +} diff --git a/web_ui/src/app/styles.css b/web_ui/src/app/styles.css index 1601d5e..df9e521 100644 --- a/web_ui/src/app/styles.css +++ b/web_ui/src/app/styles.css @@ -156,3 +156,174 @@ body, color: var(--gl-surface-text-muted); text-align: center; } + +/* -- R1 canvas (React Flow) ---------------------------------------------- */ + +.scene-canvas { + position: absolute; + inset: 0; +} + +.scene-canvas .react-flow { + background-color: var(--gl-surface-window); +} + +.scene-node { + min-width: 160px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 8px; + overflow: hidden; + font-size: 12px; +} + +.scene-node.selected { + border-color: var(--gl-surface-border-strong, var(--gl-surface-text-muted)); +} + +.scene-node-title { + padding: 6px 10px; + font-weight: 600; + color: var(--gl-surface-text); + background-color: var(--gl-surface-inset, var(--gl-surface-node-body)); + border-bottom: 1px solid var(--gl-surface-border); +} + +.scene-node.collapsed .scene-node-title { + border-bottom: none; +} + +.scene-node-body { + padding: 8px 10px; + color: var(--gl-surface-text-muted); + font-size: 11px; +} + +.scene-canvas .react-flow__edge-path { + stroke: var(--gl-surface-border-strong, var(--gl-surface-text-muted)); + stroke-width: 1.5; +} + +.scene-canvas .react-flow__edge.selected .react-flow__edge-path { + stroke: var(--gl-surface-text); +} + +.scene-minimap { + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 8px; +} + +.scene-minimap .react-flow__minimap-mask { + fill: var(--gl-surface-window); + fill-opacity: 0.55; +} + +.scene-pins { + position: absolute; + left: 16px; + top: 16px; + width: 190px; + padding: 10px 12px; + background-color: var(--gl-surface-node-body); + border: 1px solid var(--gl-surface-border); + border-radius: 10px; + z-index: 5; +} + +.scene-pins-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.scene-pins-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: var(--gl-surface-text-muted); +} + +.scene-pins-add { + font-size: 10px; + font-weight: 600; + font-family: inherit; + padding: 3px 8px; + color: var(--gl-surface-text); + background-color: var(--gl-neutral-button-background); + border: 1px solid var(--gl-neutral-button-border); + border-radius: 6px; + cursor: pointer; +} + +.scene-pins-add:hover { + background-color: var(--gl-neutral-button-hover); +} + +.scene-pins-empty { + margin: 4px 0 0; + font-size: 11px; + color: var(--gl-surface-text-muted); +} + +.scene-pins-list { + margin: 0; + padding: 0; + list-style: none; +} + +.scene-pins-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} + +.scene-pins-jump { + flex: 1; + text-align: left; + font-size: 11px; + font-family: inherit; + padding: 4px 6px; + color: var(--gl-surface-text); + background: transparent; + border: none; + border-radius: 5px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scene-pins-jump:hover { + background-color: var(--gl-neutral-button-hover); +} + +.scene-pins-remove { + font-size: 12px; + font-family: inherit; + line-height: 1; + padding: 2px 6px; + color: var(--gl-surface-text-muted); + background: transparent; + border: none; + border-radius: 5px; + cursor: pointer; +} + +.scene-pins-remove:hover { + color: var(--gl-surface-text); + background-color: var(--gl-neutral-button-hover); +} + +.scene-node-handle { + width: 8px; + height: 8px; + background-color: var(--gl-neutral-button-background); + border: 1px solid var(--gl-surface-text-muted); +} + +.scene-node-handle:hover { + background-color: var(--gl-neutral-button-hover); +} diff --git a/web_ui/src/lib/api-contract/topics.ts b/web_ui/src/lib/api-contract/topics.ts index ba66649..cacfbaf 100644 --- a/web_ui/src/lib/api-contract/topics.ts +++ b/web_ui/src/lib/api-contract/topics.ts @@ -18,6 +18,7 @@ import { type MinimapState, validateMinimapState } from "../bridge-core/generate import { type NotificationState, validateNotificationState } from "../bridge-core/generated/notification-state"; import { type PinOverlayState, validatePinOverlayState } from "../bridge-core/generated/pin-overlay-state"; import { type PluginPickerState, validatePluginPickerState } from "../bridge-core/generated/plugin-picker-state"; +import { type SceneState, validateSceneState } from "../bridge-core/generated/scene-state"; import { type SearchOverlayState, validateSearchOverlayState } from "../bridge-core/generated/search-overlay-state"; import { type SettingsState, validateSettingsState } from "../bridge-core/generated/settings-state"; import { type TokenCounterState, validateTokenCounterState } from "../bridge-core/generated/token-counter-state"; @@ -39,6 +40,7 @@ export const TOPIC_VALIDATORS = { "notification": validateNotificationState, "pin-overlay": validatePinOverlayState, "plugin-picker": validatePluginPickerState, + "scene": validateSceneState, "search-overlay": validateSearchOverlayState, "settings": validateSettingsState, "token-counter": validateTokenCounterState, @@ -63,6 +65,7 @@ export interface TopicStates { "notification": NotificationState; "pin-overlay": PinOverlayState; "plugin-picker": PluginPickerState; + "scene": SceneState; "search-overlay": SearchOverlayState; "settings": SettingsState; "token-counter": TokenCounterState; diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.schema.json b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json new file mode 100644 index 0000000..49f2f3c --- /dev/null +++ b/web_ui/src/lib/bridge-core/generated/scene-state.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "dragFactor": { + "type": "number" + }, + "edges": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "id", + "source", + "target" + ], + "type": "object" + }, + "type": "array" + }, + "minCompatibleSchemaVersion": { + "type": "integer" + }, + "nodes": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "title": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "id", + "x", + "y", + "title", + "kind" + ], + "type": "object" + }, + "type": "array" + }, + "pins": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "note": { + "type": "string" + }, + "title": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "note", + "x", + "y" + ], + "type": "object" + }, + "type": "array" + }, + "revision": { + "type": "integer" + }, + "schemaVersion": { + "type": "integer" + }, + "snapToGrid": { + "type": "boolean" + } + }, + "required": [ + "schemaVersion", + "revision", + "nodes", + "edges", + "pins", + "snapToGrid", + "dragFactor" + ], + "title": "SceneState", + "type": "object" +} diff --git a/web_ui/src/lib/bridge-core/generated/scene-state.ts b/web_ui/src/lib/bridge-core/generated/scene-state.ts new file mode 100644 index 0000000..d44cc6e --- /dev/null +++ b/web_ui/src/lib/bridge-core/generated/scene-state.ts @@ -0,0 +1,182 @@ +/* GENERATED - do not hand-edit. Source of truth: graphlink_app/graphlink_scene_payload.py::SceneStatePayload. + * Regenerate with graphlink_island_codegen.py; a pytest fails if this file + * drifts from what regenerating it now would produce. */ + +export interface SceneNodeRow { + id: string; + x: number; + y: number; + title: string; + kind: string; +} + +export interface SceneEdgeRow { + id: string; + source: string; + target: string; +} + +export interface ScenePinRow { + id: string; + title: string; + note: string; + x: number; + y: number; +} + +export interface SceneState { + schemaVersion: number; + revision: number; + nodes: SceneNodeRow[]; + edges: SceneEdgeRow[]; + pins: ScenePinRow[]; + snapToGrid: boolean; + dragFactor: number; + minCompatibleSchemaVersion?: number | null; +} + +export type ValidationResult = + | { ok: true; value: T } + | { ok: false; errors: string[] }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Unknown keys are tolerated on purpose. The JSON Schema marks the contract +// additionalProperties:false because Python and the schema must not drift, but +// an incoming payload carrying a field this build has never heard of is the +// normal, expected shape of a NEWER compatible sender - rejecting it here would +// defeat the additive-forward-compatibility the version negotiation exists to +// provide. Missing or wrongly-typed KNOWN fields are still hard errors. + +function checkSceneNodeRow(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["id"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } + } + { + const fieldValue = value["x"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.x: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.x` + ": expected number"); } + } + { + const fieldValue = value["y"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.y: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.y` + ": expected number"); } + } + { + const fieldValue = value["title"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } + } + { + const fieldValue = value["kind"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.kind: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.kind` + ": expected string"); } + } +} + +function checkSceneEdgeRow(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["id"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } + } + { + const fieldValue = value["source"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.source: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.source` + ": expected string"); } + } + { + const fieldValue = value["target"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.target: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.target` + ": expected string"); } + } +} + +function checkScenePinRow(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["id"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.id: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.id` + ": expected string"); } + } + { + const fieldValue = value["title"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.title: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.title` + ": expected string"); } + } + { + const fieldValue = value["note"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.note: missing required field`); + else { if (typeof fieldValue !== "string") errors.push(`${path}.note` + ": expected string"); } + } + { + const fieldValue = value["x"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.x: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.x` + ": expected number"); } + } + { + const fieldValue = value["y"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.y: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.y` + ": expected number"); } + } +} + +function checkSceneState(value: unknown, path: string, errors: string[]): void { + if (!isRecord(value)) { errors.push(`${path}: expected object`); return; } + { + const fieldValue = value["schemaVersion"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.schemaVersion: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.schemaVersion` + ": expected number"); } + } + { + const fieldValue = value["revision"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.revision: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.revision` + ": expected number"); } + } + { + const fieldValue = value["nodes"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.nodes: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.nodes` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkSceneNodeRow(item, `${path}.nodes` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["edges"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.edges: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.edges` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkSceneEdgeRow(item, `${path}.edges` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["pins"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.pins: missing required field`); + else { if (!Array.isArray(fieldValue)) errors.push(`${path}.pins` + ": expected array"); + else (fieldValue as unknown[]).forEach((item, i) => { checkScenePinRow(item, `${path}.pins` + `[${i}]`, errors); }); } + } + { + const fieldValue = value["snapToGrid"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.snapToGrid: missing required field`); + else { if (typeof fieldValue !== "boolean") errors.push(`${path}.snapToGrid` + ": expected boolean"); } + } + { + const fieldValue = value["dragFactor"]; + if (fieldValue === undefined || fieldValue === null) errors.push(`${path}.dragFactor: missing required field`); + else { if (typeof fieldValue !== "number") errors.push(`${path}.dragFactor` + ": expected number"); } + } + { + const fieldValue = value["minCompatibleSchemaVersion"]; + if (fieldValue !== undefined && fieldValue !== null) { if (typeof fieldValue !== "number") errors.push(`${path}.minCompatibleSchemaVersion` + ": expected number"); } + } +} + +export function validateSceneState(value: unknown): ValidationResult { + const errors: string[] = []; + checkSceneState(value, "$", errors); + return errors.length === 0 + ? { ok: true, value: value as SceneState } + : { ok: false, errors }; +}