From 41637177a375393d9dbf007e05c553468673188c Mon Sep 17 00:00:00 2001 From: Sunil Pedapudi Date: Sun, 7 Jun 2026 20:09:21 -0700 Subject: [PATCH 01/13] feat(server): self-serve the console UI on the web port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve the built console SPA from the web port alongside gRPC-Web and the health probes so one harmonograf-server process hands a browser the whole console — no separate static host or frontend dev server. - static_site.py wraps the gRPC-Web/health ASGI app: GET/HEAD for non-API paths serve index.html + real files from the web root, with an index.html SPA fallback for unknown paths (e.g. #/session/ deep links). gRPC-Web content-type requests, gRPC service-method paths, and health routes always pass through untouched. The bundle is located via --web-root, then the packaged _console dir (importlib.resources), then ../frontend/dist; a clear warning + graceful degradation when none is found. - Runtime endpoint config: the served index.html gets a window.__HARMONOGRAF_API__ global injected (derived from Host/X-Forwarded-* or --public-base-url), and transport.ts now resolves the gRPC-Web base URL runtime-global → VITE env → compiled-in default, so one built bundle works behind any host/port without a rebuild. - App.tsx parses #/session/ on load and hashchange and selects that session in the UI store (eagerly, so it survives ListSessions auto-select), via a small sessionRoute helper. - Packaging: make console builds the frontend into harmonograf_server/_console/; pyproject ships it in the wheel via the hatchling artifacts glob, located at runtime through importlib.resources. Adds server + frontend tests; documents the approach in server/README.md. --- .gitignore | 3 + Makefile | 24 +- frontend/src/App.tsx | 29 ++ .../__tests__/rpc/sessionDeepLink.test.tsx | 87 +++++ frontend/src/__tests__/rpc/transport.test.ts | 33 ++ frontend/src/lib/sessionRoute.ts | 17 + frontend/src/rpc/transport.ts | 34 +- server/README.md | 72 ++++ server/harmonograf_server/cli.py | 25 ++ server/harmonograf_server/config.py | 12 + server/harmonograf_server/main.py | 13 +- server/harmonograf_server/static_site.py | 301 +++++++++++++++++ server/pyproject.toml | 10 + server/tests/test_static_site.py | 317 ++++++++++++++++++ 14 files changed, 969 insertions(+), 8 deletions(-) create mode 100644 frontend/src/__tests__/rpc/sessionDeepLink.test.tsx create mode 100644 frontend/src/__tests__/rpc/transport.test.ts create mode 100644 frontend/src/lib/sessionRoute.ts create mode 100644 server/README.md create mode 100644 server/harmonograf_server/static_site.py create mode 100644 server/tests/test_static_site.py diff --git a/.gitignore b/.gitignore index 191f790..277696e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ tests/reference_agents/presentation_agent/output/ # Python build artifacts client/build/ +# Console SPA staged into the server wheel by `make console` (built output; +# the canonical source lives in frontend/, see server/pyproject.toml). +server/harmonograf_server/_console/ # Demo output tarballs waffle.tar.gz diff --git a/Makefile b/Makefile index e04850a..258ec5f 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CLIENT_PB := $(ROOT)/client/harmonograf_client/pb FRONTEND_PB := $(ROOT)/frontend/src/pb .PHONY: help proto proto-python proto-ts \ - server-install client-install frontend-install install \ + server-install client-install frontend-install install console \ server-run frontend-dev demo demo-presentation demo-presentation-orchestrated demo-standalone .demo-agents-stage \ test server-test client-test frontend-test e2e \ lint format clean stats @@ -24,6 +24,7 @@ help: @echo " proto-python Regenerate Python stubs under server/ and client/" @echo " proto-ts Regenerate TypeScript stubs under frontend/" @echo " install Install all components" + @echo " console Build the frontend and stage it into the server wheel" @echo " server-run Run the server in dev mode" @echo " frontend-dev Run the Vite dev server" @echo " demo Boot server + frontend + adk web (shows both presentation_agent variants)" @@ -107,6 +108,26 @@ client-install: frontend-install: @cd $(ROOT)/frontend && pnpm install --frozen-lockfile +# --------------------------------------------------------------------------- +# Console bundle (ship the built SPA inside the server wheel) +# --------------------------------------------------------------------------- + +# Where the wheel expects the bundled console (see server/pyproject.toml +# force-include + static_site.py importlib.resources lookup). +CONSOLE_DIR := $(ROOT)/server/harmonograf_server/_console + +# Build the frontend and stage it into the server package so the wheel ships +# the UI. Run this before packaging the server (`uv build server`, etc.). +# The server's static_site.py also auto-locates ../frontend/dist in a repo +# checkout, so `make console` is only required for producing a distributable +# wheel — local `make server-run` works without it. +console: + @cd $(ROOT)/frontend && pnpm install --frozen-lockfile && pnpm build + @rm -rf $(CONSOLE_DIR) + @mkdir -p $(CONSOLE_DIR) + @cp -R $(ROOT)/frontend/dist/. $(CONSOLE_DIR)/ + @echo "Console staged into $(CONSOLE_DIR) (shipped in the server wheel)." + # --------------------------------------------------------------------------- # Run # --------------------------------------------------------------------------- @@ -322,4 +343,5 @@ clean: @find $(ROOT) -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null || true @find $(ROOT) -type d -name .pytest_cache -prune -exec rm -rf {} + 2>/dev/null || true @rm -rf $(ROOT)/frontend/dist + @rm -rf $(CONSOLE_DIR) @echo "Clean done. Run 'make proto' to regenerate stubs." diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aac8bfc..d9c230b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react'; import { Shell } from './components/shell/Shell'; import { StressPage } from './gantt/StressPage'; +import { useUiStore } from './state/uiStore'; +import { useSessionsStore } from './state/sessionsStore'; +import { sessionIdFromHash } from './lib/sessionRoute'; // Minimal hash router. The stress harness is dev-only and intentionally not // linked anywhere user-facing. Visit /#/stress to open it. @@ -14,8 +17,34 @@ function useHashRoute(): string { return hash; } +// Reads a `#/session/` deep link and selects that session in the UI store. +// Runs on the initial hash and on every hashchange. Because the deep-linked id +// may not have arrived from ListSessions yet (sessions still loading), it +// records the desired id and (re)applies the selection whenever the sessions +// list changes — selecting the session as soon as it appears. Setting +// currentSessionId here also pre-empts SessionsSyncer's newest-session +// auto-select, so a deep link opens straight into its trace. +function useSessionDeepLink(hash: string): void { + const setCurrentSession = useUiStore((s) => s.setCurrentSession); + const sessions = useSessionsStore((s) => s.sessions); + + const wantedId = sessionIdFromHash(hash); + + useEffect(() => { + if (!wantedId) return; + // Already selected — nothing to do. + const current = useUiStore.getState().currentSessionId; + if (current === wantedId) return; + // If the session is known, select it now. If not, select it eagerly + // anyway: WatchSession/ListSessions will populate it, and this still + // blocks SessionsSyncer's newest-first auto-select from racing ahead. + setCurrentSession(wantedId); + }, [wantedId, sessions, setCurrentSession]); +} + export default function App() { const hash = useHashRoute(); + useSessionDeepLink(hash); if (hash.startsWith('#/stress')) return ; return ; } diff --git a/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx b/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx new file mode 100644 index 0000000..28bbdd7 --- /dev/null +++ b/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx @@ -0,0 +1,87 @@ +// Coverage for the `#/session/` deep-link routing added to App.tsx: +// * sessionIdFromHash parses the hash form (and rejects others), +// * mounting App with a `#/session/` hash selects that session in the +// UI store — even before the session has arrived from ListSessions, and +// re-applies once the sessions list updates. +// +// Shell + StressPage are mocked to inert markers so the test exercises only +// the routing/selection logic without pulling in the whole UI tree. + +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { sessionIdFromHash } from '../../lib/sessionRoute'; +import { useUiStore } from '../../state/uiStore'; +import { useSessionsStore } from '../../state/sessionsStore'; +import type { RpcSession } from '../../state/sessionsStore'; + +// Inert stand-ins — App's routing only needs to mount *something*. +vi.mock('../../components/shell/Shell', () => ({ + Shell: () =>
, +})); +vi.mock('../../gantt/StressPage', () => ({ + StressPage: () =>
, +})); + +import App from '../../App'; + +function setHash(hash: string): void { + window.location.hash = hash; +} + +beforeEach(() => { + useUiStore.setState({ currentSessionId: null }); + useSessionsStore.setState({ sessions: [], loading: false, error: null }); +}); + +afterEach(() => { + setHash(''); +}); + +describe('sessionIdFromHash', () => { + it('parses #/session/', () => { + expect(sessionIdFromHash('#/session/abc-123')).toBe('abc-123'); + expect(sessionIdFromHash('#/session/abc-123/')).toBe('abc-123'); + }); + + it('url-decodes the id', () => { + expect(sessionIdFromHash('#/session/run%2F42')).toBe('run/42'); + }); + + it('returns null for non-session hashes', () => { + expect(sessionIdFromHash('#/')).toBeNull(); + expect(sessionIdFromHash('#/stress')).toBeNull(); + expect(sessionIdFromHash('#/session/')).toBeNull(); + expect(sessionIdFromHash('')).toBeNull(); + }); +}); + +describe('App #/session/ deep link', () => { + it('selects the deep-linked session on mount, even before it loads', () => { + setHash('#/session/sess-xyz'); + render(); + // Selected eagerly so SessionsSyncer's newest-first auto-select can't race. + expect(useUiStore.getState().currentSessionId).toBe('sess-xyz'); + }); + + it('keeps the deep-linked selection once the session appears in the list', () => { + setHash('#/session/sess-late'); + render(); + expect(useUiStore.getState().currentSessionId).toBe('sess-late'); + + act(() => { + useSessionsStore.setState({ + sessions: [{ id: 'sess-late' } as RpcSession], + loading: false, + error: null, + }); + }); + expect(useUiStore.getState().currentSessionId).toBe('sess-late'); + }); + + it('does not select anything for the default route', () => { + setHash('#/'); + render(); + expect(useUiStore.getState().currentSessionId).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/rpc/transport.test.ts b/frontend/src/__tests__/rpc/transport.test.ts new file mode 100644 index 0000000..23db5dc --- /dev/null +++ b/frontend/src/__tests__/rpc/transport.test.ts @@ -0,0 +1,33 @@ +// Coverage for transport.apiBaseUrl()'s resolution precedence: +// 1. window.__HARMONOGRAF_API__ (runtime, injected by the serving server) +// 2. VITE_HARMONOGRAF_API (build-time env var) +// 3. the compiled-in default. +// +// The runtime global is the new path that lets a single built bundle work +// behind any host/port without a rebuild (see static_site.py). + +import { afterEach, describe, expect, it } from 'vitest'; +import { apiBaseUrl } from '../../rpc/transport'; + +const DEFAULT_BASE_URL = 'http://127.0.0.1:7532'; + +afterEach(() => { + delete window.__HARMONOGRAF_API__; +}); + +describe('apiBaseUrl', () => { + it('prefers the runtime window.__HARMONOGRAF_API__ global', () => { + window.__HARMONOGRAF_API__ = 'http://example.test:9000'; + expect(apiBaseUrl()).toBe('http://example.test:9000'); + }); + + it('ignores an empty runtime global and falls through', () => { + window.__HARMONOGRAF_API__ = ''; + // No VITE env set in the test runner, so this lands on the default. + expect(apiBaseUrl()).toBe(DEFAULT_BASE_URL); + }); + + it('falls back to the compiled-in default when nothing is configured', () => { + expect(apiBaseUrl()).toBe(DEFAULT_BASE_URL); + }); +}); diff --git a/frontend/src/lib/sessionRoute.ts b/frontend/src/lib/sessionRoute.ts new file mode 100644 index 0000000..ea80038 --- /dev/null +++ b/frontend/src/lib/sessionRoute.ts @@ -0,0 +1,17 @@ +// Hash-route parsing for the `#/session/` deep link consumed by App.tsx. +// Kept in its own module so App.tsx only exports its component (react-refresh). + +// Parse a `#/session/` deep link, returning the (decoded) session id or +// null. Tolerates a trailing slash and an empty id. Accepts both +// `#/session/` and a bare `/session/` defensively. +export function sessionIdFromHash(hash: string): string | null { + const m = /^#?\/session\/([^/]+)\/?$/.exec(hash); + if (!m) return null; + const raw = m[1]; + if (!raw) return null; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} diff --git a/frontend/src/rpc/transport.ts b/frontend/src/rpc/transport.ts index 2438289..7545940 100644 --- a/frontend/src/rpc/transport.ts +++ b/frontend/src/rpc/transport.ts @@ -1,10 +1,17 @@ // Connect-Web transport. We speak gRPC-Web so Connect-Web's createGrpcWebTransport // is the right wire format for the Python harmonograf_server (sonora grpcASGI, -// served by hypercorn — no Envoy/grpcwebproxy needed). The base URL is taken -// from the Vite env var VITE_HARMONOGRAF_API, falling back to the server's -// default --web-port (7532) on loopback. To point at a non-default server, -// set the env var before running `pnpm dev`, e.g.: -// VITE_HARMONOGRAF_API=http://127.0.0.1:17532 pnpm dev +// served by hypercorn — no Envoy/grpcwebproxy needed). The base URL is resolved +// in three steps, most-specific first: +// +// 1. window.__HARMONOGRAF_API__ — a runtime global the server injects into +// the index.html it serves itself (see server/harmonograf_server/ +// static_site.py). This lets a single built bundle work behind any +// host/port without a rebuild, because the server fills in the URL the +// browser actually used to reach it. +// 2. VITE_HARMONOGRAF_API — a build-time env var, useful for `pnpm dev` +// against a non-default server: +// VITE_HARMONOGRAF_API=http://127.0.0.1:17532 pnpm dev +// 3. the compiled-in default --web-port (7532) on loopback. import { createGrpcWebTransport } from '@connectrpc/connect-web'; import type { Transport } from '@connectrpc/connect'; @@ -12,11 +19,26 @@ import type { Transport } from '@connectrpc/connect'; // Must track server/harmonograf_server/cli.py --web-port default. const DEFAULT_BASE_URL = 'http://127.0.0.1:7532'; +// The runtime global the server injects when it serves the bundle itself. +declare global { + interface Window { + __HARMONOGRAF_API__?: string; + } +} + export function apiBaseUrl(): string { - // Vite injects env vars prefixed with VITE_. + // 1. Runtime global injected by the serving harmonograf_server. + if (typeof window !== 'undefined') { + const fromRuntime = window.__HARMONOGRAF_API__; + if (typeof fromRuntime === 'string' && fromRuntime.length > 0) { + return fromRuntime; + } + } + // 2. Build-time env var (Vite injects env vars prefixed with VITE_). const fromEnv = typeof import.meta !== 'undefined' && (import.meta as unknown as { env?: Record }).env?.VITE_HARMONOGRAF_API; + // 3. Compiled-in default. return fromEnv || DEFAULT_BASE_URL; } diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..6793500 --- /dev/null +++ b/server/README.md @@ -0,0 +1,72 @@ +# harmonograf-server + +gRPC fan-in for multi-agent telemetry and control, plus the self-served +console UI. + +## Ports + +- `--port` (default `7531`) — native gRPC, for agents/SDKs. +- `--web-port` (default `7532`) — gRPC-Web (for browsers) **and** the console + SPA + health endpoints, all on one ASGI app. + +## Self-served console UI + +The web port serves the built console single-page app alongside gRPC-Web and +the `/healthz` / `/readyz` probes. One `harmonograf-server` process hands a +browser the whole console — no separate static host or frontend dev server. + +- `GET /` and `GET /index.html` → the SPA's `index.html`. +- `GET /assets/*` and any other real file in the web root → served directly. +- Any other non-API path that isn't a real file → falls back to `index.html`, + so client-side hash routes such as `#/session/` load when deep-linked. +- gRPC-Web requests (`application/grpc-web*`), gRPC service-method paths + (`/./`), and the health routes are never intercepted — + they always reach the gRPC-Web / health app. + +### Runtime endpoint config (one bundle, any host/port) + +When serving `index.html`, the server injects a `window.__HARMONOGRAF_API__` +global with the gRPC-Web base URL the browser should use to reach this server. +The frontend's `transport.ts` reads the endpoint in this precedence: + +1. `window.__HARMONOGRAF_API__` (runtime — injected by this server), +2. `VITE_HARMONOGRAF_API` (build-time env var, for `pnpm dev`), +3. the compiled-in default (`http://127.0.0.1:7532`). + +So a single built bundle works behind any host/port with no rebuild. The +injected URL is derived per-request from the `Host` / `X-Forwarded-*` headers, +or set explicitly with `--public-base-url` when behind a reverse proxy. + +### Locating the bundle + +In precedence order: + +1. `--web-root ` (explicit override), +2. the packaged console shipped in the wheel + (`harmonograf_server/_console/`, located via `importlib.resources`), +3. a dev fallback to `../frontend/dist` in a repo checkout. + +If no bundle is found the server logs a warning and keeps serving gRPC-Web + +health (graceful degradation). + +## Packaging: shipping the console in the wheel + +`pip/uv install harmonograf-server` carries the console so consumers don't need +to build the frontend. The build flow: + +1. `make console` builds the frontend (`pnpm build`) and stages the output + into `server/harmonograf_server/_console/`. +2. `uv build` (hatchling) collects that directory into the wheel via the + `tool.hatch.build.targets.wheel.artifacts` glob in `pyproject.toml` + (`artifacts` overrides the repo `.gitignore`, which ignores build output). +3. At runtime `static_site.py` locates the packaged `_console/` directory via + `importlib.resources`. + +`make console` is only required when producing a distributable wheel — +`make server-run` in a repo checkout works without it (it falls back to +`../frontend/dist`). The `_console/` directory is build output and is +git-ignored. + +**Release note:** a release/publish workflow must run `make console` before +`uv build` so the wheel includes the UI. CI currently runs tests only (no +wheel build); the server test suite is independent of a built bundle. diff --git a/server/harmonograf_server/cli.py b/server/harmonograf_server/cli.py index b0ee99c..4de8334 100644 --- a/server/harmonograf_server/cli.py +++ b/server/harmonograf_server/cli.py @@ -90,6 +90,29 @@ def build_parser() -> argparse.ArgumentParser: "disables auth. /healthz and /readyz are always unauthenticated." ), ) + # ---- console UI serving (static_site.py) ------------------------ + p.add_argument( + "--web-root", + default="", + help=( + "directory to serve the built console SPA from on the web port " + "(alongside gRPC-Web + health); empty auto-locates the packaged " + "console, then ../frontend/dist. If no bundle is found the server " + "logs a warning and serves gRPC-Web + health only." + ), + ) + p.add_argument( + "--public-base-url", + default="", + help=( + "public gRPC-Web base URL the browser should use to reach this " + "server; injected into the served index.html as " + "window.__HARMONOGRAF_API__. Empty derives it per-request from the " + "Host / X-Forwarded-* headers, so one bundle works behind any " + "host/port (set this only behind a reverse proxy that rewrites the " + "path or terminates on a different public URL)." + ), + ) p.add_argument( "--legacy-plan-attribution-window-ms", type=float, @@ -159,6 +182,8 @@ def config_from_args(argv: list[str] | None = None) -> ServerConfig: log_format=args.log_format, metrics_interval_seconds=args.metrics_interval_seconds, auth_token=args.auth_token, + web_root=args.web_root, + public_base_url=args.public_base_url, legacy_plan_attribution_window_ms=args.legacy_plan_attribution_window_ms, heartbeat_timeout_seconds=args.heartbeat_timeout_seconds, payload_max_bytes=args.payload_max_bytes, diff --git a/server/harmonograf_server/config.py b/server/harmonograf_server/config.py index 64e4d62..88be683 100644 --- a/server/harmonograf_server/config.py +++ b/server/harmonograf_server/config.py @@ -24,6 +24,18 @@ class ServerConfig: metrics_interval_seconds: float = 30.0 # 0 disables periodic metrics auth_token: str = "" # empty string disables shared-secret auth + # ---- console UI serving (static_site.py) ------------------------- + # The web port serves the built console SPA alongside gRPC-Web + + # health. ``web_root`` overrides where the bundle is read from; empty + # auto-locates (packaged _console dir, then ../frontend/dist). When no + # bundle is found the server logs a warning and serves gRPC-Web + + # health only. ``public_base_url`` (if set) is injected verbatim into + # the served index.html as the SPA's gRPC-Web endpoint + # (window.__HARMONOGRAF_API__); empty derives it per-request from the + # Host / X-Forwarded-* headers so one bundle works behind any host/port. + web_root: str = "" + public_base_url: str = "" + # Opt-in legacy time-window fallback for plan-revision attribution # (pre-strict-id-dedup behaviour from before harmonograf#99). Default # 0 disables the fallback — plan revisions without a trigger_event_id diff --git a/server/harmonograf_server/main.py b/server/harmonograf_server/main.py index fc55223..3237af2 100644 --- a/server/harmonograf_server/main.py +++ b/server/harmonograf_server/main.py @@ -39,6 +39,7 @@ from harmonograf_server.metrics import metrics_loop from harmonograf_server.retention import retention_loop from harmonograf_server.rpc.telemetry import TelemetryServicer, heartbeat_sweeper +from harmonograf_server.static_site import build_static_router from harmonograf_server.storage import make_store @@ -177,7 +178,17 @@ async def start(self) -> None: # CORS middleware sits outside auth so preflights succeed even # before the browser attaches the bearer token. grpc_web_app = asgi_cors(grpc_web_app) - self._web_app = build_health_router(self.store, grpc_web_app) + # Health router answers /healthz + /readyz and forwards everything + # else to gRPC-Web. The static layer wraps that: it serves the + # built console SPA (with runtime endpoint injection + SPA fallback) + # for browser GET/HEAD paths and forwards gRPC-Web + health through. + health_app = build_health_router(self.store, grpc_web_app) + self._web_app = build_static_router( + health_app, + web_root=self.cfg.web_root, + web_port=self.cfg.web_port, + public_base_url=self.cfg.public_base_url, + ) self._web_shutdown = asyncio.Event() hc = HypercornConfig() hc.bind = [f"{self.cfg.host}:{self.cfg.web_port}"] diff --git a/server/harmonograf_server/static_site.py b/server/harmonograf_server/static_site.py new file mode 100644 index 0000000..1b7a5f9 --- /dev/null +++ b/server/harmonograf_server/static_site.py @@ -0,0 +1,301 @@ +"""Serve the built console SPA on the web port, beside gRPC-Web + health. + +A single ``harmonograf-server`` process should be able to hand a browser +the whole console without a separate static host or a frontend dev +server. This module wraps the gRPC-Web/health ASGI app with a thin static +layer that: + + * serves ``index.html`` at ``/`` (and as the SPA fallback), + * serves hashed asset files under ``/assets/*`` and any other real file + in the web root (favicon, etc.), + * falls back to ``index.html`` for any *other* GET/HEAD path that is not + a real file — so client-side hash routes like ``/#/session/`` load + the SPA when deep-linked, and + * never touches gRPC-Web requests (content-type ``application/grpc-web*``) + or the health routes — those flow straight through to ``inner``. + +The served ``index.html`` is rewritten on the fly to inject the server's +own gRPC-Web base URL as ``window.__HARMONOGRAF_API__`` so one built +bundle works behind any host/port without a rebuild (see ``transport.ts``). + +Locating the bundle (in precedence order): + + 1. an explicit ``web_root`` (the ``--web-root`` CLI flag), + 2. the packaged console shipped inside the wheel + (``harmonograf_server/_console``), located via ``importlib.resources``, + 3. a dev fallback to ``../../frontend/dist`` relative to this file + (the repo checkout). + +If none of those contains an ``index.html`` the layer logs a warning once +and forwards every request to ``inner`` — the server keeps serving +gRPC-Web + health, just without the bundled UI. +""" + +from __future__ import annotations + +import logging +import os +import re +from importlib import resources +from typing import Awaitable, Callable, Optional + +logger = logging.getLogger("harmonograf_server.static_site") + + +ASGIApp = Callable[..., Awaitable[None]] + +# Minimal extension → content-type map. Covers everything Vite emits plus +# the static assets we ship; anything unknown falls back to +# application/octet-stream. +_CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", +} + +# Paths that must always reach the inner gRPC-Web/health app, never the +# static layer. gRPC-Web is additionally identified by content-type below. +_HEALTH_PATHS = frozenset({"/healthz", "/readyz"}) + +# gRPC service-method routes look like ``/./`` (e.g. +# ``/harmonograf.v1.Harmonograf/GetStats``). Real gRPC-Web traffic is POST +# with an ``application/grpc-web*`` content-type and is already excluded, but +# we additionally never hijack a gRPC-shaped path as an SPA route regardless +# of method — so the inner auth/gRPC-Web app always owns it (no SPA fallback +# masking a 401, no surprises for non-binary gRPC-Web). A leading segment +# containing a dot is the tell; the SPA never serves such paths. +_GRPC_PATH_RE = re.compile(r"^/[A-Za-z_][\w.]*\.[A-Za-z_]\w*/[A-Za-z_]\w*/?$") + + +def locate_web_root(web_root: Optional[str] = None) -> Optional[str]: + """Return an absolute path to a directory containing ``index.html``, + or ``None`` if no console bundle can be found. + + Precedence: explicit ``web_root`` → packaged ``_console`` → repo + ``frontend/dist`` dev fallback. + """ + + candidates: list[str] = [] + if web_root: + candidates.append(os.path.abspath(os.path.expanduser(web_root))) + + # Packaged console inside the installed wheel. + try: + pkg_console = resources.files("harmonograf_server") / "_console" + # ``files()`` returns a Traversable; for a normal filesystem install + # str() yields a usable path. Guard against zipped installs by + # checking existence below. + candidates.append(str(pkg_console)) + except (ModuleNotFoundError, AttributeError): # pragma: no cover - defensive + pass + + # Dev fallback: repo checkout's frontend/dist (../../frontend/dist). + here = os.path.dirname(os.path.abspath(__file__)) + candidates.append( + os.path.abspath(os.path.join(here, "..", "..", "frontend", "dist")) + ) + + for cand in candidates: + if cand and os.path.isfile(os.path.join(cand, "index.html")): + return cand + return None + + +def _content_type(path: str) -> str: + _, ext = os.path.splitext(path) + return _CONTENT_TYPES.get(ext.lower(), "application/octet-stream") + + +def _is_grpc_web(scope: dict) -> bool: + for k, v in scope.get("headers") or (): + if k.lower() == b"content-type" and v.lower().startswith(b"application/grpc-web"): + return True + return False + + +def _safe_join(root: str, url_path: str) -> Optional[str]: + """Resolve ``url_path`` against ``root``, refusing any traversal that + escapes ``root``. Returns the absolute filesystem path or ``None``.""" + + rel = url_path.lstrip("/") + # Normalize and reject anything that climbs out of root. + candidate = os.path.normpath(os.path.join(root, rel)) + root_abs = os.path.abspath(root) + candidate_abs = os.path.abspath(candidate) + if candidate_abs != root_abs and not candidate_abs.startswith(root_abs + os.sep): + return None + return candidate_abs + + +def _inject_runtime_config(html: str, api_base_url: str) -> str: + """Insert ``window.__HARMONOGRAF_API__`` so the bundle learns its + gRPC-Web endpoint at runtime. Injected just before the first module + script so it runs before the app boots.""" + + # JS string-literal escaping for the URL (it is operator-controlled but + # be defensive about quotes / closing tags anyway). + safe = ( + api_base_url.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("window.__HARMONOGRAF_API__ = "{safe}";' + needle = "\n' + "
\n" +) + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _make_web_root(tmp_path) -> str: + root = tmp_path / "dist" + (root / "assets").mkdir(parents=True) + (root / "index.html").write_text(INDEX_HTML, encoding="utf-8") + (root / "assets" / "index.js").write_text("console.log('hi');\n", encoding="utf-8") + (root / "favicon.svg").write_text("", encoding="utf-8") + return str(root) + + +# ---- pure ASGI wrapper --------------------------------------------------- + + +async def _collect(app, scope): + """Drive an ASGI app once and return (status, headers, body).""" + sent: list[dict] = [] + + async def send(msg): + sent.append(msg) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + await app(scope, receive, send) + start = next(m for m in sent if m["type"] == "http.response.start") + body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body") + headers = {k.lower(): v for k, v in start["headers"]} + return start["status"], headers, body + + +def _http_scope(path: str, *, method: str = "GET", host: str = "console.test:9000", headers=None): + hdrs = [(b"host", host.encode())] + if headers: + hdrs.extend(headers) + return {"type": "http", "method": method, "path": path, "headers": hdrs, "scheme": "http"} + + +@pytest.mark.asyncio +async def test_serves_index_with_injected_runtime_config(tmp_path): + root = _make_web_root(tmp_path) + + async def inner(scope, receive, send): # pragma: no cover - must not be hit + raise AssertionError("inner should not be called for GET /") + + app = build_static_router(inner, web_root=root, web_port=7532) + status, headers, body = await _collect(app, _http_scope("/")) + assert status == 200 + assert headers[b"content-type"].startswith(b"text/html") + text = body.decode() + # Runtime endpoint global injected, derived from the request Host. + assert 'window.__HARMONOGRAF_API__ = "http://console.test:9000"' in text + # The original module script is still present. + assert "/assets/index.js" in text + + +@pytest.mark.asyncio +async def test_public_base_url_overrides_host_derivation(tmp_path): + root = _make_web_root(tmp_path) + + async def inner(scope, receive, send): # pragma: no cover + raise AssertionError("inner should not be called") + + app = build_static_router( + inner, web_root=root, web_port=7532, public_base_url="https://obs.example/" + ) + _, _, body = await _collect(app, _http_scope("/")) + assert 'window.__HARMONOGRAF_API__ = "https://obs.example"' in body.decode() + + +@pytest.mark.asyncio +async def test_serves_static_asset(tmp_path): + root = _make_web_root(tmp_path) + + async def inner(scope, receive, send): # pragma: no cover + raise AssertionError("inner should not be called") + + app = build_static_router(inner, web_root=root, web_port=7532) + status, headers, body = await _collect(app, _http_scope("/assets/index.js")) + assert status == 200 + assert headers[b"content-type"].startswith(b"text/javascript") + assert b"console.log" in body + + +@pytest.mark.asyncio +async def test_spa_fallback_for_unknown_path(tmp_path): + root = _make_web_root(tmp_path) + + async def inner(scope, receive, send): # pragma: no cover + raise AssertionError("inner should not be called for SPA fallback") + + app = build_static_router(inner, web_root=root, web_port=7532) + status, headers, body = await _collect(app, _http_scope("/some/deep/route")) + assert status == 200 + assert headers[b"content-type"].startswith(b"text/html") + assert "window.__HARMONOGRAF_API__" in body.decode() + + +@pytest.mark.asyncio +async def test_health_and_grpc_paths_pass_through(tmp_path): + root = _make_web_root(tmp_path) + forwarded: list[str] = [] + + async def inner(scope, receive, send): + forwarded.append(scope["path"]) + + app = build_static_router(inner, web_root=root, web_port=7532) + # Health endpoints belong to the inner app. + await _collect_passthrough(app, _http_scope("/healthz")) + await _collect_passthrough(app, _http_scope("/readyz")) + # gRPC-shaped service path is never hijacked, even on GET. + await _collect_passthrough(app, _http_scope("/harmonograf.v1.Harmonograf/GetStats")) + # A gRPC-Web POST (by content-type) also passes through. + await _collect_passthrough( + app, + _http_scope( + "/harmonograf.v1.Harmonograf/GetStats", + method="POST", + headers=[(b"content-type", b"application/grpc-web+proto")], + ), + ) + + # Re-run against a forwarding inner to assert paths actually reached it. + app2 = build_static_router(inner, web_root=root, web_port=7532) + + async def noop_send(_msg): + pass + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + for scope in ( + _http_scope("/healthz"), + _http_scope("/harmonograf.v1.Harmonograf/GetStats"), + ): + await app2(scope, receive, noop_send) + assert "/healthz" in forwarded + assert forwarded.count("/harmonograf.v1.Harmonograf/GetStats") >= 1 + + +async def _collect_passthrough(app, scope): + """Assert the request reaches inner (which here records nothing/sends nothing).""" + sent: list[dict] = [] + + async def send(msg): # pragma: no cover - inner may not send + sent.append(msg) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + await app(scope, receive, send) + + +@pytest.mark.asyncio +async def test_no_bundle_forwards_everything(monkeypatch): + """With no locatable bundle, every request flows to inner unchanged. + + We force ``locate_web_root`` to ``None`` because a repo checkout normally + has a built ``frontend/dist`` the locator would fall back to. + """ + import harmonograf_server.static_site as ss + + monkeypatch.setattr(ss, "locate_web_root", lambda *_a, **_k: None) + + forwarded: list[str] = [] + + async def inner(scope, receive, send): + forwarded.append(scope["path"]) + + app = build_static_router(inner, web_root="", web_port=7532) + + async def send(_msg): # pragma: no cover + pass + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + await app(_http_scope("/"), receive, send) + await app(_http_scope("/anything"), receive, send) + assert forwarded == ["/", "/anything"] + + +def test_locate_web_root_prefers_explicit(tmp_path): + root = _make_web_root(tmp_path) + assert locate_web_root(root) == os.path.abspath(root) + # A bogus explicit root falls through (here to the repo dev fallback or + # None); the function must not return the bogus path. + assert locate_web_root(str(tmp_path / "nope")) != str(tmp_path / "nope") + + +# ---- end-to-end through Harmonograf ------------------------------------- + + +async def _wait_for_port(port: int, timeout: float = 3.0) -> None: + deadline = asyncio.get_event_loop().time() + timeout + last_err: Exception | None = None + while asyncio.get_event_loop().time() < deadline: + try: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return + except OSError as e: + last_err = e + await asyncio.sleep(0.05) + raise TimeoutError(f"port {port} not ready after {timeout}s: {last_err}") + + +async def _http_get(port: int, path: str) -> tuple[int, bytes, bytes]: + await _wait_for_port(port) + reader, writer = await asyncio.open_connection("127.0.0.1", port) + req = ( + f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + writer.write(req.encode()) + await writer.drain() + resp = b"" + while True: + chunk = await reader.read(4096) + if not chunk: + break + resp += chunk + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + head, _, body = resp.partition(b"\r\n\r\n") + status = int(head.split(b"\r\n", 1)[0].split()[1]) + return status, head, body + + +@pytest.mark.asyncio +async def test_end_to_end_serves_spa_and_keeps_health(tmp_path): + root = _make_web_root(tmp_path) + cfg = ServerConfig( + host="127.0.0.1", + grpc_port=_free_port(), + web_port=_free_port(), + store_backend="memory", + data_dir="", + grace_seconds=0.5, + metrics_interval_seconds=0.0, + web_root=root, + ) + app = await Harmonograf.from_config(cfg) + await app.start() + try: + # GET / → SPA with injected config. + status, head, body = await _http_get(cfg.web_port, "/") + assert status == 200 + assert b"text/html" in head.lower() + text = body.decode() + assert "window.__HARMONOGRAF_API__" in text + assert f"127.0.0.1:{cfg.web_port}" in text + + # Static asset. + status, head, body = await _http_get(cfg.web_port, "/assets/index.js") + assert status == 200 + assert b"javascript" in head.lower() + + # SPA fallback for an unknown deep path. + status, _, body = await _http_get(cfg.web_port, "/deeplink/here") + assert status == 200 + assert b"window.__HARMONOGRAF_API__" in body + + # Health still works alongside the SPA. + status, _, body = await _http_get(cfg.web_port, "/healthz") + assert status == 200 + assert b"ok" in body + status, _, body = await _http_get(cfg.web_port, "/readyz") + assert status == 200 + assert b"ready" in body + finally: + await app.stop() From 14654f6abd1fd0db19ae61d265fed3170e3f6ea8 Mon Sep 17 00:00:00 2001 From: Sunil Pedapudi Date: Mon, 8 Jun 2026 23:10:41 -0700 Subject: [PATCH 02/13] fix(server+frontend): address review on console-UI serving Follow-up fixes from PR #294 review: - frontend: apply the #/session/ deep link once per target instead of re-firing on every ListSessions poll. Previously a user who deep-linked to session A and then picked session B (which updates the store, not the hash) was dragged back to A on the next poll. Adds a regression test. - server: cache index.html at startup (immutable build artifact) instead of a blocking disk read per request on the event loop shared with gRPC-Web streaming; off-load asset reads via asyncio.to_thread. - server: send Cache-Control: immutable for content-hashed /assets/*; keep index.html no-cache. - server: a missing file-like path (*.js/*.svg) now returns 404 instead of the SPA index.html fallback (avoids MIME-mismatch errors; also fixes favicon.svg). - server: emit the injected window.__HARMONOGRAF_API__ via json.dumps (+ --- frontend/src/App.tsx | 33 +++-- .../__tests__/rpc/sessionDeepLink.test.tsx | 27 ++++ server/README.md | 8 +- server/harmonograf_server/static_site.py | 131 +++++++++++++----- server/tests/test_static_site.py | 69 +++++++++ 5 files changed, 213 insertions(+), 55 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9c230b..8a18360 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Shell } from './components/shell/Shell'; import { StressPage } from './gantt/StressPage'; import { useUiStore } from './state/uiStore'; -import { useSessionsStore } from './state/sessionsStore'; import { sessionIdFromHash } from './lib/sessionRoute'; // Minimal hash router. The stress harness is dev-only and intentionally not @@ -18,28 +17,28 @@ function useHashRoute(): string { } // Reads a `#/session/` deep link and selects that session in the UI store. -// Runs on the initial hash and on every hashchange. Because the deep-linked id -// may not have arrived from ListSessions yet (sessions still loading), it -// records the desired id and (re)applies the selection whenever the sessions -// list changes — selecting the session as soon as it appears. Setting -// currentSessionId here also pre-empts SessionsSyncer's newest-session -// auto-select, so a deep link opens straight into its trace. +// Applies each distinct deep-link target exactly once — on the initial hash and +// again whenever the hash changes to a new session. The selection is eager: the +// id need not have arrived from ListSessions yet (WatchSession/ListSessions will +// populate it), and setting currentSessionId here also pre-empts SessionsSyncer's +// newest-first auto-select, so a deep link opens straight into its trace. +// +// Crucially this does NOT re-fire on every sessions poll: doing so would yank the +// user back to the deep-linked session ~every 5s after they navigate away via the +// picker (which updates the store, not the hash), trapping them on that session. function useSessionDeepLink(hash: string): void { const setCurrentSession = useUiStore((s) => s.setCurrentSession); - const sessions = useSessionsStore((s) => s.sessions); - const wantedId = sessionIdFromHash(hash); + const appliedId = useRef(null); useEffect(() => { if (!wantedId) return; - // Already selected — nothing to do. - const current = useUiStore.getState().currentSessionId; - if (current === wantedId) return; - // If the session is known, select it now. If not, select it eagerly - // anyway: WatchSession/ListSessions will populate it, and this still - // blocks SessionsSyncer's newest-first auto-select from racing ahead. + // Apply this target once; let a later manual selection (picker) stick. + if (appliedId.current === wantedId) return; + appliedId.current = wantedId; + if (useUiStore.getState().currentSessionId === wantedId) return; setCurrentSession(wantedId); - }, [wantedId, sessions, setCurrentSession]); + }, [wantedId, setCurrentSession]); } export default function App() { diff --git a/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx b/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx index 28bbdd7..58236f6 100644 --- a/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx +++ b/frontend/src/__tests__/rpc/sessionDeepLink.test.tsx @@ -79,6 +79,33 @@ describe('App #/session/ deep link', () => { expect(useUiStore.getState().currentSessionId).toBe('sess-late'); }); + it('does not bounce back to the deep link after a manual selection', () => { + // Regression: the effect must apply the deep link once, not re-fire on + // every ListSessions poll. Otherwise a user who deep-links to sess-a and + // then picks sess-b (which updates the store, not the hash) gets dragged + // back to sess-a on the next poll. + setHash('#/session/sess-a'); + const { rerender } = render(); + expect(useUiStore.getState().currentSessionId).toBe('sess-a'); + + // User picks another session via the picker. + act(() => { + useUiStore.getState().setCurrentSession('sess-b'); + }); + + // A ListSessions poll lands (new sessions array) and App re-renders. + act(() => { + useSessionsStore.setState({ + sessions: [{ id: 'sess-a' } as RpcSession, { id: 'sess-b' } as RpcSession], + loading: false, + error: null, + }); + }); + rerender(); + + expect(useUiStore.getState().currentSessionId).toBe('sess-b'); + }); + it('does not select anything for the default route', () => { setHash('#/'); render(); diff --git a/server/README.md b/server/README.md index 6793500..9edfc08 100644 --- a/server/README.md +++ b/server/README.md @@ -17,8 +17,12 @@ browser the whole console — no separate static host or frontend dev server. - `GET /` and `GET /index.html` → the SPA's `index.html`. - `GET /assets/*` and any other real file in the web root → served directly. -- Any other non-API path that isn't a real file → falls back to `index.html`, - so client-side hash routes such as `#/session/` load when deep-linked. + Content-hashed `/assets/*` are sent `Cache-Control: immutable`; `index.html` + is sent `no-cache` so a redeploy is picked up. +- Any other **extension-less** non-API path → falls back to `index.html`, so + client-side hash routes such as `#/session/` load when deep-linked. A + missing *file-like* path (`*.js`, `*.svg`, …) returns `404` rather than HTML, + to avoid handing the browser a MIME-mismatched `index.html`. - gRPC-Web requests (`application/grpc-web*`), gRPC service-method paths (`/./`), and the health routes are never intercepted — they always reach the gRPC-Web / health app. diff --git a/server/harmonograf_server/static_site.py b/server/harmonograf_server/static_site.py index 1b7a5f9..518841a 100644 --- a/server/harmonograf_server/static_site.py +++ b/server/harmonograf_server/static_site.py @@ -8,9 +8,10 @@ * serves ``index.html`` at ``/`` (and as the SPA fallback), * serves hashed asset files under ``/assets/*`` and any other real file in the web root (favicon, etc.), - * falls back to ``index.html`` for any *other* GET/HEAD path that is not - a real file — so client-side hash routes like ``/#/session/`` load - the SPA when deep-linked, and + * falls back to ``index.html`` for any *other* extension-less GET/HEAD + path that is not a real file — so client-side hash routes like + ``/#/session/`` load the SPA when deep-linked, while a missing + file-like path (``*.js``/``*.svg`` …) returns 404 rather than HTML, and * never touches gRPC-Web requests (content-type ``application/grpc-web*``) or the health routes — those flow straight through to ``inner``. @@ -33,6 +34,8 @@ from __future__ import annotations +import asyncio +import json import logging import os import re @@ -121,6 +124,17 @@ def _content_type(path: str) -> str: return _CONTENT_TYPES.get(ext.lower(), "application/octet-stream") +def _looks_like_file(path: str) -> bool: + """True when the last path segment has a file extension (e.g. ``.js``, + ``.svg``). Such a request is for a concrete asset, so when it isn't found + we return 404 rather than the SPA fallback — handing back ``index.html`` + (text/html) for a missing ``.js``/``.css`` only yields confusing MIME + errors in the browser. Navigation/hash routes have no extension and still + fall through to the SPA.""" + + return "." in os.path.basename(path) + + def _is_grpc_web(scope: dict) -> bool: for k, v in scope.get("headers") or (): if k.lower() == b"content-type" and v.lower().startswith(b"application/grpc-web"): @@ -147,14 +161,13 @@ def _inject_runtime_config(html: str, api_base_url: str) -> str: gRPC-Web endpoint at runtime. Injected just before the first module script so it runs before the app boots.""" - # JS string-literal escaping for the URL (it is operator-controlled but - # be defensive about quotes / closing tags anyway). - safe = ( - api_base_url.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("window.__HARMONOGRAF_API__ = "{safe}";' + # Emit a valid JS string literal. ``json.dumps`` handles all string + # escaping (quotes, backslashes, control chars, U+2028/U+2029); the URL is + # largely operator-controlled but can be host/X-Forwarded-Host-derived, so + # we additionally neutralise `` element early. + literal = json.dumps(api_base_url).replace("window.__HARMONOGRAF_API__ = {literal};" needle = "' + out = _inject_runtime_config(html, '"> + + + + diff --git a/docs/design/zicato-ui-study/compose.html b/docs/design/zicato-ui-study/compose.html new file mode 100644 index 0000000..a0445ad --- /dev/null +++ b/docs/design/zicato-ui-study/compose.html @@ -0,0 +1,678 @@ + + + + + +harmonograf — the composed console (interactive) + + + + + + + + + +
+ +

the composed console

+ + + + theme + +
+ +
+ + + + diff --git a/docs/design/zicato-ui-study/figures.html b/docs/design/zicato-ui-study/figures.html new file mode 100644 index 0000000..1baab28 --- /dev/null +++ b/docs/design/zicato-ui-study/figures.html @@ -0,0 +1,367 @@ + + + + + +harmonograf × zicato — figures (components) study + + + + + + + + +
+ +

figures — the component catalogue

+ + mono · outline-first · tabular-nums + theme + +
+ +
+

+ The supporting surfaces, each redrawn in-language: pills are mono, outline-first (filling the accent only on + hover); numerics use tabular-nums; tables follow the zicato .dn-board-table idiom; + hover-for-detail uses the singleton hovercard. An in-flight item is neutral --accent, never red. +

+ + +
+

OrchestrationTimeline & TaskStages

+

+ The run lifecycle as a line-art phase rail (the reignGantt idiom): completed phases read + --good, the current phase is the one --accent (breathing), a failed phase + --bad, planned phases faint. The plan's revision count rides as a mono --caution badge. +

+
+
+

orchestration timeline

+
+
+
+ 00:00.0elapsed 00:14.7 +
+
+
+
+

task stages · plan v3

+
+
+ in progress + plan revisions ×3 + mode: parallel +
+
+

+ revision v3 steered by refine_steer + after a judge flagged scope drift. +

+
+
+
+
+ + +
+

LiveActivity — the active-items board

+

+ The live table in the zicato .dn-board-table idiom: hairline cells, an uppercase header on + --rule-soft, numeric columns right-aligned with tabular-nums, the live row tinted + color-mix(--accent 8%). Kind reads its categorical hue; status is a mono outline-first pill. +

+
+ +
+ + + + + + +
whenagentkinddetailelapsedstatus
+
+
+
+ + +
+

Interventions — goldfive steering

+

+ goldfive's steers and judge verdicts. Judge severity maps to roles (--good on-task, + --caution warning, --bad critical); refine reads magenta, plan + cyan, reflective grey — all from the theme ANSI. Each row opens its detail in the hovercard. The + categorical hues here are identity, never status, so red appears only for a critical verdict. +

+
+
+
+
+ judge · on-task + judge · warning + judge · critical + refine + plan + reflective +
+
+ + +
+

SessionPicker (⌘K)

+

+ The command palette, in-language: a --panel card with hairline rows, mono titles, an attention count + as a quiet --bad pill (only when a session is genuinely awaiting a human), and the selected row on + --rule-soft. The accent rings the search field on focus. Shown inline here (live it floats over a + blurred backdrop). +

+
+
+
+ +
+
+
+
+
+
+ +
+ harmonograf UI study · figures · component catalogue · mono outline-first pills · tabular-nums · token-only colour + · verify in paper + monokai. +
+ + + + + + + + + + + + diff --git a/docs/design/zicato-ui-study/gantt.html b/docs/design/zicato-ui-study/gantt.html new file mode 100644 index 0000000..a31d5b1 --- /dev/null +++ b/docs/design/zicato-ui-study/gantt.html @@ -0,0 +1,462 @@ + + + + + +harmonograf × zicato — Gantt (the hero) study + + + + + + + + +
+ +

gantt — the execution timeline (the hero)

+ + KIND = hue · STATUS = treatment + theme + +
+ +
+

+ The execution Gantt is harmonograf's instrument. It preserves the elegant 2-axis encoding: + KIND = hue (9 span kinds, each a terminal ANSI slot from the active theme) and + STATUS = treatment (running → the one --accent + breathing; failed → --bad + + an ✕; pending → faint; cancelled → hatch; planned → dashed). Status is the only place + good/bad/accent appear, so a span is never red for its identity — only when it failed. Below, the same realistic + multi-agent trace is drawn two ways. +

+ + +
+

Option A — fit-to-width SVG line-art (recommended)

+

+ A pure viewBox SVG at width:100%: hairline gridlines (--rule-soft, 0.6w), + rx:3 bars at reduced fill-opacity lifting to 1 on hover, the agent gutter + lifelines, a context-window + band (--caution tint) along the coordinator, TRANSFER hand-off edges, the accent + "now" cursor, the ✕ on a failed span, the ◷ on an awaiting-human span, a dashed + PLANNED ghost ahead of now, and the goldfive lane in its --hg-gf-* call-category hues + (judge → --good, refine → magenta). Token-only: a theme swap re-skins every mark with no re-render. + Hover any bar. +

+
+
+
+
+ +
+

Option B — keep the canvas, restyle via tokens

+

+ harmonograf's production Gantt is a 3-layer <canvas> (background · blocks · overlay) where + "React never drives the render loop" — already half the zicato digest-gating discipline. This option keeps that + renderer and only changes its inputs: every fillStyle reads a resolved --hg-kind-* / + --hg-gf-* / --v2-* token (via getComputedStyle) at draw time, so a theme + switch re-colors on the next frame. Same trace, same encoding as Option A — every kind hue, status treatment, + transfer edge and planned ghost — rendered to a real canvas here to prove the re-skin. +

+
+

+ Trade-off. Canvas keeps harmonograf's existing hit-testing, dirty-rect batching and 60fps live cursor, but + marks aren't in the DOM (no per-bar :focus-visible, no CSS hover — those are hand-rolled). The SVG + option is fit-to-width and accessible by construction (every bar is role="img"-able, focusable, + hoverable) but redraws the whole figure on a structural change. For the hero surface the SVG line-art is the + recommendation; the canvas stays the right call for very long, deeply-zoomed live runs. +

+
+ + +
+

The encoding, in full

+

Both renderers read the same map. KIND picks the hue (ANSI slot); STATUS applies the treatment.

+
+ + + +
span kindANSI slotswatch
+
+
+
+ +
+ harmonograf UI study · gantt (the hero) · realistic mock multi-agent trace · token-only colour · fit-to-width · + verify in paper + monokai. +
+ + + + + + + + + + + + diff --git a/docs/design/zicato-ui-study/grammar.html b/docs/design/zicato-ui-study/grammar.html new file mode 100644 index 0000000..11a7da5 --- /dev/null +++ b/docs/design/zicato-ui-study/grammar.html @@ -0,0 +1,767 @@ + + + + + +harmonograf — the figure grammar (new marks) + + + + + + + + + +
+

harmonograf — the figure grammar

+ thirteen new marks · drift & correction · plans · sequence · steering · all token-only, all fit-to-width +
+ theme + +
+ +
+ +
+

Premise

+

+ zicato's figure language is built on oscillation around a reference rule — good below, bad above, + one accent spine. harmonograf's domain — drift and correction — is oscillation around a reference. + These prototypes extend the grammar with harmonograf's nouns: the plan-rule replaces the champion-rule, the + intervention replaces the gate, and the run's own trace becomes the brand. Conventions inherited whole: + hairline gridlines, rx:3 bars, status earns color / identity never does, --accent + only for now & selection, every figure width:100% + viewBox. +

+
+ +
+

01 · the harmonograph fingerprint — a session identicon that is data

+
+

+ Each session gets a glyph drawn by the instrument itself: x driven by plan progress, y by execution + drift, damped. Aligned runs trace tight decaying figures; detuned runs precess; a corrected run visibly + tightens mid-figure where the intervention landed; a failing run spirals outward. The dot is the pen — + and its color is status, earned: accent while live, good when done, bad when failed. For session + pickers, fleet cards, tab titles — an identicon operators learn to read. +

+
+
+
+ +
+

02 · the drift seismograph — the headline figure

+
+

+ Per agent: a dashed on-plan rule, a tolerance band hugging it (good-soft), and the goldfive + judge series as a thin ink trace. The trace stays neutral until it escapes the band — the excursion + area fills bad-soft, severity is distance, not color. Interventions are caution ticks; watch the trace + decay back after each one. Answers the operator's only question — “is it wandering, did my correction take?” — + with zero labels. +

+
+
+ judge drift series + tolerance band + excursion + intervention tick + now +
+
+
+ +
+

03 · the judge heartbeat strip — word-sized verdicts

+
+

+ A genDots-style strip under a lane: one glyph per judge call — · on-task, + ▲ warning, ✕ critical — in the goldfive category hue at quiet opacity. The EKG of + the run; trellis it across sessions for the fleet view. +

+
+
+
+ +
+

04 · the intervention ladder — escalation made literal

+
+

+ goldfive's escalation ladder drawn as a ladder: rungs in ascending severity, the session's interventions as + dots placed on their rung over time, a faint step-path connecting them. A healthy run lives on the bottom + rung; a run climbing the ladder is visibly escalating. The escalate-rung dot is bad — earned, terminal. +

+
+

healthy session — stays low

+

escalating session — climbs

+
+
+
+ +
+

05 · correction slopegraphs — did the steering work?

+
+

+ At every intervention, a two-point slope: drift before → drift after. Down-slope good, up-slope bad, + flat flat — zicato's pairedSlopegraph verbatim, new noun. A row of these in the Interventions + panel is an efficacy record of the operator and of goldfive's own refinements. +

+
+
+
+ +
+

06 · the drift sankey — what causes what

+
+

+ drift kind → intervention → outcome, ribbon width ∝ count. The figure that says “tool-misuse resolves + with a nudge, goal-drift recurs even after replans” — the exact shape zicato's meta-loop wants as input. + Outcome nodes earn good / bad; everything upstream stays neutral ink. +

+
+
+
+ +
+

07 · plan strata — revisions as sediment

+
+

+ Plans are geology: newest on top. Each revision is a stratum; tasks keep their column, so persistence reads + as vertical alignment. A dropped task ends with ✕ at the seam; an added one enters with + ○. Each dashed seam is annotated with the drift kind that forced the replan — the why is + on the figure. The live stratum carries the accent spine. +

+
+
+
+ +
+

08 · the replan slopegraph — one seam, magnified

+
+

+ Plan v2 → v3: kept tasks run flat, reordered tasks cross, dropped tasks terminate (✕), added + tasks enter (○, accent-dashed). Crossing density is plan churn — ten seconds tells you + whether the replan was a tweak or an upheaval. +

+
+
+
+ +
+

09 · the plan reel — the spine across revisions

+
+

+ zicato's roundTimeline spine, one node per plan revision, annotated with remaining work — + a loss-floor that should descend. Seam triggers ride above in caution; a revision that added work would + step up and read as bad, earned by direction. +

+
+
+
+ +
+

10 · the delegation score — fork, run beneath, rejoin

+
+

+ Not a UML sequence diagram — a score. A delegation branches off the parent lane, runs beneath it, and + rejoins with its verdict glyph at the merge; band thickness ∝ tokens spent, so an expensive delegation is + visibly heavy. Peer transfers are dashed hand-off arcs in TRANSFER yellow. elimFlow's convergence idiom, + inverted into fork-and-return. +

+
+
+
+ +
+

11 · the transfer chord — who talks to whom, and in which direction

+
+

+ Agents on an arc, transfer counts as chords, width ∝ frequency, quiet agent-ramp hues. A plain chord hides the + one thing the operator most needs — who initiates vs. who receives — so this one is directional three + ways at once: (1) each agent's sector is split into an outbound half (solid, source side) and an + inbound half (hollow tick, target side); (2) every ribbon is an asymmetric teardrop — pinched at + the source, broad at the target — so the chord visibly flows toward whom it reaches; (3) it carries the + source→target hue and ends in a small arrowhead on the target arc. A coordinator-and-spokes system fans + out from one sector; if it looks like a web, something's off. The session-summary topology check. +

+
+
+ outbound (source) half + inbound (target) half + pinched ▸ source · broad ▸ target · ▸ arrowhead at the receiver +
+
+
+ +
+

12 · conductor's marks — steering in the margin

+
+

+ Operator actions live in the margin, like a conductor's pencil on the score: a magenta ◷ + where the run paused for a human, an accent tick where a steering message landed, an approval gate on + the lane (○ deciding → ✓ continues). The discipline: operator presence annotates the work, never recolors + it — identity doesn't earn color, even human identity. +

+
+
+
+ +
+

13 · the context tide — breathing room

+
+

+ Context-window occupancy as a tide under the lane, rising toward the dashed ceiling; the surface mixes toward + caution as it nears the limit. A compaction is a relief seam — the tide visibly drops and the run + breathes again. +

+
+
+
+ +
+

14 · the plan DAG — interdependent processes, Adamiecki's own subject

+
+

+ The harmonogram's whole point in 1896 was interdependence — so the plan's dependency graph is a + first-class figure, not a footnote. Tasks are quiet nodes layered by depth; dependencies are hairline edges; + the critical path is the one accent spine — zicato's champion-spine idiom pointed at scheduling. + Status is earned per node (✓ done, ● running on the spine, ○ pending); a task dropped at a replan stays + as a dashed ghost with its severed edge — the DAG remembers the seam. Pairs with 07 strata (same tasks, + time-axis vs dependency-axis). +

+
+
+ critical path + dependency + ✓ done● running + ○ pending✕ dropped at replan (ghost) +
+
+
+ +
+

Which of these earn promotion

+

+ Build order, if these graduate to the console: 02 seismograph (the product thesis in one mark) → + 07 strata + 08 replan slope + 14 plan DAG (the data already sits in planHistoryStore) → + 10 delegation score (the Gantt already draws transfer edges; this is its summary form) → + 01 fingerprint (the dessert — session picker, fleet cards, and the brand made data). + 03/05 ride inside existing panels; 06/11 are session-summary surfaces; 04/12/13 are overlays on figures + that already exist. +

+
+ +
+ + + + + + + + diff --git a/docs/design/zicato-ui-study/index.html b/docs/design/zicato-ui-study/index.html new file mode 100644 index 0000000..76878d7 --- /dev/null +++ b/docs/design/zicato-ui-study/index.html @@ -0,0 +1,238 @@ + + + + + +harmonograf × zicato — UI study + + + + + + + + +
+ +

harmonograf — a UI study in the zicato design language

+ + theme + +
+ +
+

+ harmonograf is a multi-agent coordination console — a Gantt-forward instrument for watching many agents, 9 span kinds + and goldfive's steering unfold over wall-clock. This study redraws it in zicato's design language: Tufte + data-ink line-art, a calm mono ground, theme-adaptive by pure CSS swap. It is a design artifact — standalone + HTML/CSS/JS, no build step, opens from file:// — theme-switchable across all 16 Console palettes. +

+ +
+

The thesis — a full categorical palette, sourced from terminal ANSI

+
+

zicato spends exactly one green accent on a neutral ground — perfect for a binary promote/reject surface. + harmonograf is a categorical instrument: it needs many distinguishable hues at once. So this study + keeps the full categorical palette — but sources every categorical hue from the active theme's + terminal ANSI palette (the Gogh scheme each zicato theme derives from). Switch the theme: the whole + categorical encoding re-skins, ANSI and all.

+

That keeps harmonograf's elegant 2-axis encoding intact while honoring zicato's "earned by direction" + rule. KIND is an identity hue; STATUS is a treatment. good / bad / accent appear only for status — so a + span is never red for what it is, only when it failed.

+
+
+

KIND = hue (ANSI slot)

+
LLM_CALL — blue
+
TOOL_CALL — cyan
+
USER_MESSAGE — green
+
TRANSFER — yellow
+
WAIT_FOR_HUMAN — magenta
+
+
+

STATUS = treatment (role)

+
RUNNING — accent + breathe (now)
+
✕ FAILED — bad + glyph
+
COMPLETED ok — good
+
PENDING — faint (neutral, not red)
+
CANCELLED — hatch
+
+
+
+
+ +
+

The surfaces

+
+ +
shell →
+
The chrome: AppBar → console topbar (the harmonograf α mark + plain-letter wordmark, breadcrumbs, + status / RUN pill), NavRail, CurrentTaskStrip, TransportBar, inspector Drawer. Before/after per element.
+
+ +
gantt →
+
The hero. The execution timeline in line-art: categorical kind hues, status treatments, + the accent now-cursor, agent lifelines, context band. Both the SVG and canvas options.
+
+ +
sequence →
+
How the agents talk to one another. The agent-interaction sequence diagram + (lifelines down, time down, each message a directional connector) paired with the directional transfer + chord for topology; the horizontal score is the Gantt-companion alternative. Lifelines stay neutral; + messages carry the kind hue; direction is explicit.
+
+ +
figures →
+
The component catalogue: OrchestrationTimeline, TaskStages, LiveActivity + (.dn-board-table), Interventions (goldfive steering), the ⌘K SessionPicker.
+
+ +
brand →
+
harmonograf's own mark. A logo study for the standalone console — the double namesake + (Adamiecki's 1896 harmonogram × the Victorian harmonograph), four line-art marks generated live from the + damped-oscillator equations, wordmark, favicon cuts, the accent question.
+
+ +
grammar →
+
Fourteen new figures. Drift seismograph, intervention ladder, correction slopegraphs, + drift sankey, plan strata, replan slopegraph, plan DAG (critical path as the spine), delegation score, + transfer chord, conductor's marks, context tide, the harmonograph session fingerprint.
+
+ +
compose →
+
The full console, interactive. Three whole-UI proposals — A·observatory (rail-switched + hero views + inspector), B·score (sequence-first with a gantt minimap and drift margin), C·mission control + (fingerprint fleet) — with clickable spans, ⌘K session switching, and the grammar figures composed in. + Opens from file://, no server needed.
+
+
+
+ +
+

Sixteen themes — the re-skin

+

Every mark on every page reads --v2-* roles + --ansi-* categorical from + _tokens.css. Click any palette to set it across the whole study (persists). The strip is + ground · panel · ink · good · bad · accent.

+
+
+
+ +
+ harmonograf UI study · the bar is zicato's own tournament-viz-study · token-only colour · fit-to-width · + reduced-motion-aware · verified in paper + monokai. Foundation & decisions: + STYLE-GUIDE.md · palette: gogh-palettes.json. +
+ + + + + diff --git a/docs/design/zicato-ui-study/sequence.html b/docs/design/zicato-ui-study/sequence.html new file mode 100644 index 0000000..5f7218e --- /dev/null +++ b/docs/design/zicato-ui-study/sequence.html @@ -0,0 +1,433 @@ + + + + + +harmonograf — sequence: how the agents talk to one another + + + + + + + + + +
+ +

sequence — how the agents talk to one another

+ + theme + +
+ +
+ +
+

Premise

+

+ This view answers one question: who said what to whom, and when — the agent-interaction picture, not + the execution overlay. The classic answer is a sequence diagram: one lifeline per agent, time flowing + down, each message a horizontal connector from sender to receiver with a clear arrowhead. We keep that + grammar — it is the right one — but Tufte it into the zicato language. The rules: lifelines are identity and + stay neutral (a hairline, a quiet name chip — identity never earns color); messages carry the KIND hue + (transfer yellow, user green, agent-message bright-green); activations are quiet kind-hue bars on the + lifeline; a delegation opens a sub-lifeline, runs beneath, and returns with its verdict; the accent + appears only at now. Paired with it, the directional transfer chord summarises the same + conversation as a topology — who initiates, who receives — so the time-ordered diagram and the shape of the + communication agree. +

+
+ +
+

The sequence diagram RECOMMENDED

+
+

+ One vertical lifeline per agent, time flowing down — the canonical sequence-diagram orientation, so it + reads as one at a glance. Every message is a horizontal connector from the sender's lifeline to the + receiver's, with a solid arrowhead at the receiving end (a synchronous call) or a dashed return + (a result/reply), the label riding above in the KIND hue — dashed TRANSFER yellow for hand-offs, user green, + agent-message bright-green. Activations hug each lifeline as kind-hue bars (the agent is busy). The + tester delegation opens a child lifeline, runs beneath, and returns ✓. The reviewer's approval is a + gate (○ deciding). The failed check is the only red. It paginates vertically like a transcript, which + is exactly how an operator deep-reads a long exchange. +

+
+
+

time-ordered — who said what to whom, when

+
+
+
+

topology — who initiates, who receives

+
+

+ The transfer chord, made directional: each agent's arc splits into a solid outbound half and a + dashed inbound half; every ribbon is pinched at its source and broad at its target, carries the + source→target hue, and lands an arrowhead on the receiver. A coordinator-and-spokes run fans out from one + sector; a web means everyone is talking to everyone. +

+
+
+
+ transfer (hand-off) + user → agent + agent message + return / result + llm activation + tool activation + ○ gate✕ failed + now +
+
+
+ +
+

Alternative — the score (time runs right, like the Gantt)

+
+

+ The same trace laid horizontally, sharing the Gantt's time axis exactly. Agents are horizontal + staves; work sits ON the staff as quiet kind-hue activations; messages are thin vertical connectors with + open arrowheads. Its strength is being the Gantt's connective tissue — drop it over the same axis and the two + views scrub together — but as a sequence diagram it is weaker: vertical message connectors between five + closely-stacked staves are cramped, and it reads as an execution overlay more than a conversation. Keep it as + the Gantt-companion lane; the vertical sequence diagram above is the view for reading the interaction. +

+
+
+ transfer + user → agent + agent message + llm activation + tool activation + ○ gate✕ failed + now +
+
+
+ +
+

Before — the inherited frame

+
+

+ The textbook UML sequence diagram has the right orientation — lifelines down, messages across — but + spends every mark on structure: actor boxes, a heavy lifeline grid, filled arrowheads, activation + rectangles, an alt frame. None of it encodes kind, status, or cost. The recommended view keeps + its grammar and drops the chartjunk. +

+
+
+
+ +
+ + + + + + + + diff --git a/docs/design/zicato-ui-study/shell.html b/docs/design/zicato-ui-study/shell.html new file mode 100644 index 0000000..19ec504 --- /dev/null +++ b/docs/design/zicato-ui-study/shell.html @@ -0,0 +1,427 @@ + + + + + +harmonograf × zicato — shell (chrome) study + + + + + + + + +
+ +

shell — chrome restyled in the zicato design language

+ + accent reserved · now / live / selected only + theme + +
+ +
+

+ harmonograf's chrome today is Material Design 3: --md-sys-color-* tokens, a system-ui sans, + elevation containers with drop shadows, a blue primary, and several animation:…infinite pulses. + This study redraws every chrome surface in zicato's language — one calm --paper ground with a + single --panel lift, hairline --rule separators instead of elevation, an all-mono voice, + and --accent reserved for now / live / selected. Switch the theme above: the whole shell + re-skins across all 16 Console palettes with no re-render. +

+ + +
+

The assembled console — after

+

+ AppBar → topbar (the harmonograf brand — the Lissajous-α mark, dot at its tail, beside the plain-letter wordmark — breadcrumbs, + typeface switch, scale pill, status / RUN pill) · NavRail · CurrentTaskStrip · TransportBar (LIVE pill + clock) + · inspector Drawer (tabs · attrs · code). One ground, one lift, hairlines, mono, accent reserved. +

+
+
+
+ + + + + ⊟ 100% ⟲ + + live + running· 3 agents + +
+
+ task + Refactor the checkout flow to use the new pricing service + running + planner + · tool: read_file +
+ +
+
execution gantt
+
+
+ +
+
+ + + + +
+ + LIVE + 00:00:14.320 + + zoom + +
+
+
+
+ accent — now / live / selected + good — connected + rule — hairline separators (no elevation) + panel — the one lift +
+
+ + +
+

AppBar → topbar

+

+ The MD3 AppBar is a flat container with a sans title and a surface-tinted session button. The console topbar + leads with the harmonograf lockup — the mirrored-Lissajous α mark beside plain-letter harmonograf (one alpha per lockup; see the brand study), + then mono breadcrumbs, the typeface switch and scale pill, and a status pill whose RUN badge pulses the one + accent — the chrome's only keyframe (gated under prefers-reduced-motion). +

+
+
+

before · MD3

+
+ harmonograf + + +
+
+
+

after · zicato

+
+ + + + + live + running +
+
+
+
+ + +
+

NavRail & CurrentTaskStrip

+

+ MD3 marks the selected rail item with a secondary-container fill (green pill) and animates the + running task chip with an infinite opacity pulse. In-language: the selected item is a quiet + color-mix(--accent 14%, --panel) tint with accent text; the strip's running state is a mono + outline-first accent pill — not red (an in-flight task is neutral, never a failure). +

+
+
+

before · MD3

+
+ +
+ taskRefactor checkout flow + runningplanner +
+
+
+
+

after · zicato

+
+ +
+ taskRefactor checkout flow + runningplanner +
+
+
+
+
+ + +
+

TransportBar

+

+ Transport controls become mono outline-first buttons (fill the accent only on hover). The LIVE badge keeps a + single pulsing dot — re-pointed from the MD3 blue primary to --accent — and the clock uses + tabular-nums so digits never jitter. The "return to live" affordance, an MD3 amber container, maps + to a --caution outline. +

+
+
+

before · MD3

+
+ + LIVE00:00:14.32 + +
+
+
+

after · zicato

+
+ + + LIVE00:00:14.320 + +
+
+
+
+ + +
+

Inspector Drawer

+

+ The drawer keeps its tabbed shape but loses the elevated surface-container-highest code block in + favor of a hairline-framed --panel-2 pre. Tabs underline in --accent when active; the + JSON tint reads from tokens (--good strings, --caution numbers, --bad + booleans, --accent keys) so it re-skins with the theme instead of shipping fixed syntax colors. +

+
+
+

before · MD3

+
+
tool_call · read_file
+
+
+
agent
planner
kind
TOOL_CALL
path
service.ts
+ { "lines": 420, "ok": true } +
+
+
+
+

after · zicato

+
+
+
+
+ keys · active tab + strings + numbers + booleans +
+
+
+ +
+ harmonograf UI study · shell · standalone, theme-switchable across 16 Console palettes · token-only colour + (the only hex is the scoped .md3 before-replica) · verify in paper + monokai. +
+ + + + + + + From 5ea6a2ffb2b13fc83e04e6e8ef7f72d80c70a883 Mon Sep 17 00:00:00 2001 From: Sunil Pedapudi Date: Wed, 10 Jun 2026 23:04:43 -0700 Subject: [PATCH 04/13] design(zicato-ui-study): reel drives the DAG + drift/judge/ladder time-track stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the instruments view around two coordinated reading stacks. Plan stack — the plan reel now sits directly above the plan DAG, sharing horizontal alignment, and each reel revision is a button that drives the DAG: clicking a version redraws the DAG for that version's task set (added tasks in the accent "new" treatment, tasks dropped since the prior revision ghosted and dashed like the existing live ghost). A `tid` on each DAG node bridges it to the strata task-ids so a revision's `has`/`added` set reframes the graph. The click is local UI state — it repaints only the DAG host + the reel's active marks, not the whole console (digest-gated, no flash). Defaults to the latest revision; `?ver=` deep-links one. Time-track stack — the judge heartbeat is folded INTO the drift seismograph as one instrument on a shared time axis: the drift traces as before, with judge firings overlaid (on-task ticks in the judge KIND hue, warnings/criticals in the STATUS treatment). The standalone heartbeat panel is gone from the view (still a small figure for the inspector). The intervention ladder sits directly below the seismograph and shares its exact x-domain (padL/padR/width/T via one trackGeom helper), so an intervention at time t lines up vertically under the drift reading at t, with a single shared bottom axis. Preserves the 16-theme ANSI system and KIND=hue / STATUS=treatment grammar; standalone HTML/CSS/JS, no build; theme switch re-skins without a re-render. Verified headless across monokai, paper, dracula, belafonte-night and all three plan revisions. --- docs/design/zicato-ui-study/README.md | 16 +- docs/design/zicato-ui-study/compose.html | 433 +++++++++++++--------- docs/design/zicato-ui-study/index.html | 9 +- docs/design/zicato-ui-study/sequence.html | 61 ++- 4 files changed, 322 insertions(+), 197 deletions(-) diff --git a/docs/design/zicato-ui-study/README.md b/docs/design/zicato-ui-study/README.md index 5a6bb3b..2ec5b76 100644 --- a/docs/design/zicato-ui-study/README.md +++ b/docs/design/zicato-ui-study/README.md @@ -23,10 +23,18 @@ figures.html OrchestrationTimeline · TaskStages · LiveActivity · Interven grammar.html fourteen new figures: seismograph · ladder · sankey · strata · plan DAG · delegation score · chord · fingerprint · … brand.html harmonograf's own mark — the Lissajous α, wordmark, lockups -compose.html THE COMPOSED CONSOLE — interactive, three whole-UI proposals: - A·observatory (rail-switched views + inspector), B·score - (sequence-first + minimap + drift margin), C·mission control - (fingerprint fleet). Clickable spans, ⌘K session switch. +compose.html THE COMPOSED CONSOLE — interactive. The gantt as its own hero + view + one coalesced "instruments" view leading with the plan: + the plan REEL sits above the plan DAG and DRIVES it — click a + revision and the DAG redraws that version's task set (added in + accent, dropped-since-prior ghosted/dashed). Then sequence · + projectable topology chord · and ONE coordinated time-track + stack: the drift seismograph with the judge heartbeat folded in + (one time axis) over the time-aligned intervention ladder. + Sessions ONLY via the ⌘K fingerprint picker (no sidebar). + Clickable spans → inspector. ?ver= deep-links a reel revision. + (The earlier A/B/C whole-UI proposals + fleet sidebar live in + git history.) _tokens.css 16 themes: zicato --v2 roles + --ansi-* + the --hg-* categorical _study.css shared page chrome + the .hg-* zicato-language component CSS diff --git a/docs/design/zicato-ui-study/compose.html b/docs/design/zicato-ui-study/compose.html index a0445ad..1c8fc8b 100644 --- a/docs/design/zicato-ui-study/compose.html +++ b/docs/design/zicato-ui-study/compose.html @@ -11,20 +11,15 @@ @@ -92,9 +86,8 @@

the composed console

- + the gantt stands alone · the instruments coalesce plans (DAG + reel), sequence, topology & drift · sessions live in the ⌘K picker - theme
@@ -208,8 +201,12 @@

the composed console

heart: (()=>{ const r=lcg(7), e=[]; for(let i=0;i<30;i++){ const t=2+i*2.5+r(); e.push([t, (i===12||i===13)?'warn':(i===15?'crit':'ok')]); } return e; })(), plan: { - nodes: { 'design-api':{x:0,y:0,st:'done'}, 'impl-core':{x:1,y:0,st:'done'}, 'wire-cli':{x:2,y:-1,st:'running'}, - 'run-suite':{x:2,y:1,st:'done'}, 'polish-ux':{x:3,y:0,st:'pending'}, 'write-docs':{x:1,y:2,st:'ghost'} }, + // tid bridges each DAG node to its strata task-id (T1…T7) so the reel can + // drive the DAG: the live DAG below == version v3's task set {T1,T2,T4,T6,T7} + // plus write-docs (T3) carried as the dropped-at-replan ghost. + nodes: { 'design-api':{x:0,y:0,st:'done',tid:'T1'}, 'impl-core':{x:1,y:0,st:'done',tid:'T2'}, + 'wire-cli':{x:2,y:-1,st:'running',tid:'T6'}, 'run-suite':{x:2,y:1,st:'done',tid:'T4'}, + 'polish-ux':{x:3,y:0,st:'pending',tid:'T7'}, 'write-docs':{x:1,y:2,st:'ghost',tid:'T3'} }, edges: [ ['design-api','impl-core',1],['impl-core','wire-cli',1],['impl-core','run-suite',0], ['wire-cli','polish-ux',1],['run-suite','polish-ux',0],['design-api','write-docs','ghost'] ], strata: [ {v:'v3 · live', has:['T1','T2','T4','T6','T7'], added:['T7'], live:true}, @@ -343,7 +340,10 @@

the composed console

g+=`now ${s.now}s`; g+=``; return g; } -/* ----- the directional transfer chord (communication topology) ----- */ +/* ----- the directional transfer chord (communication topology) ----- + Scaling discipline: ribbon width grows with √count and CAPS, so a chatty + pair can never flood the figure; hover an agent to PROJECT its conversations + (everything else fades), click to pin, click empty space to clear. ----- */ function topoChordSVG(s, W){ W=W||320; const agents=['user','planner','coder','tester','reviewer'].filter(a=>s.agents.includes(a)||a==='user'); const idx=a=>agents.indexOf(a); @@ -360,41 +360,106 @@

the composed console

:`var(--hg-agent-${['planner','coder','tester','reviewer'].indexOf(a)+1})`; const outN=agents.map(()=>0), inN=agents.map(()=>0); flows.forEach(([a,b,c])=>{outN[a]+=c;inN[b]+=c;}); const outUsed=agents.map(()=>0), inUsed=agents.map(()=>0); - let g=``; + let g=``; agents.forEach((a,i)=>{ const th=ang(i), oA=th+SECTOR/2,oB=th+0.05,iA=th-0.05,iB=th-SECTOR/2; const arc=(thA,thB,attrs)=>{ const [xA,yA]=onArc(thA,R),[xB,yB]=onArc(thB,R); - return ``; }; + return ``; }; if(outN[i]) g+=arc(oA,oB,`stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9"`); if(inN[i]) g+=arc(iA,iB,`stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9" stroke-dasharray="1.5 2.4"`); }); + let cLabels=''; flows.forEach(([a,b,c],fi)=>{ const ths=ang(a),thd=ang(b); const sF=(outUsed[a]+c/2)/Math.max(outN[a],1); outUsed[a]+=c; const dF=(inUsed[b]+c/2)/Math.max(inN[b],1); inUsed[b]+=c; const sTh=ths+0.07+(SECTOR/2-0.07)*sF, dTh=thd-0.07-(SECTOR/2-0.07)*dF; - const wS=1.6,wT=Math.max(5,c*2.6), [sx,sy]=onArc(sTh,R-4),[dx,dy]=onArc(dTh,R-9); + // √count with a hard cap: heavy traffic thickens slowly and can never flood + const wS=1.6, wT=Math.min(15, 4+Math.sqrt(c)*3.4), [sx,sy]=onArc(sTh,R-4),[dx,dy]=onArc(dTh,R-9); const tS=[Math.sin(sTh),Math.cos(sTh)],tD=[Math.sin(dTh),Math.cos(dTh)]; const s1=[sx+tS[0]*wS/2,sy+tS[1]*wS/2],s2=[sx-tS[0]*wS/2,sy-tS[1]*wS/2]; const t1=[dx+tD[0]*wT/2,dy+tD[1]*wT/2],t2=[dx-tD[0]*wT/2,dy-tD[1]*wT/2]; const mid=[(sx+dx)/2+(cx-(sx+dx)/2)*0.62,(sy+dy)/2+((cyA-34)-(sy+dy)/2)*0.5], gid=`tch${s.id}${fi}`; + const FT=`data-f="${agents[a]}" data-t="${agents[b]}"`; g+=` - `; - g+=`${agents[a]} → ${agents[b]} · ${c} (${agents[a]} initiates)`; + `; + g+=`${agents[a]} → ${agents[b]} · ${c} (${agents[a]} initiates)`; const outward=[Math.cos(dTh),-Math.sin(dTh)], tip=onArc(dTh,R-1), base=[tip[0]-outward[0]*8,tip[1]-outward[1]*8]; - g+=``; + g+=``; + cLabels+=`${c}×`; }); agents.forEach((a,i)=>{ const th=ang(i),[x,y]=onArc(th,R); - g+=``; const [lx,ly]=onArc(th,R+14), anc= lxcx+10?'start':'middle'); - g+=`${a}`; }); - g+=``; return g; + g+=` + + + ${a}`; }); + g+=cLabels+``; return g; +} +/* hover an agent → project (dim everything not involving it); click → pin */ +function wireChord(scope){ + scope.querySelectorAll('svg.topo-chord').forEach(svg=>{ + let pinned=null; + const apply=f=>{ + svg.querySelectorAll('[data-ribbon],[data-ahead]').forEach(p=>{ + p.style.opacity = (!f || p.dataset.f===f || p.dataset.t===f) ? '' : .05; }); + svg.querySelectorAll('[data-count]').forEach(tx=>{ + tx.style.opacity = (f && (tx.dataset.f===f || tx.dataset.t===f)) ? 1 : ''; }); + svg.querySelectorAll('[data-agent]').forEach(n=>{ + n.style.opacity = (!f || n.dataset.agent===f) ? '' : .3; }); + }; + svg.querySelectorAll('g[data-agent]').forEach(h=>{ + h.addEventListener('mouseenter',()=>{ if(!pinned) apply(h.dataset.agent); }); + h.addEventListener('mouseleave',()=>{ if(!pinned) apply(null); }); + h.addEventListener('click',e=>{ e.stopPropagation(); + pinned = pinned===h.dataset.agent ? null : h.dataset.agent; apply(pinned); }); + h.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); h.click(); } }); + }); + svg.addEventListener('click',()=>{ if(pinned){ pinned=null; apply(null); } }); + const want=new URLSearchParams(location.search).get('chord'); + if(want && svg.querySelector(`g[data-agent="${want}"]`)){ pinned=want; apply(want); } + }); +} +/* Shared time-track geometry — the seismograph, the heartbeat overlay, and the + intervention ladder ALL derive padL/padR/X from this, so a reading at time t + sits at the same x in every panel of the stack. */ +function trackGeom(s, W){ const padL=W<400?40:76, padR=14; + return { padL, padR, X:t=>padL+(W-padL-padR)*t/s.T }; } +function timeAxisSVG(s, W, H){ const {padL,padR,X}=trackGeom(s,W); let g=''; + for(let i=0;i<=4;i++){ const gx=X(s.T*i/4); + g+=``; } + return g; +} +/* ONE combined drift+judge instrument: the seismograph traces as before, with + the judge-firing HEARTBEAT folded in as a strip along the same time axis — + ok ticks in the judge (goldfive) KIND hue, warnings/criticals carried by the + STATUS treatment (caution / bad). One axis, two readings. */ +/* wire the reel: clicking/keying a version sets state.planVer and repaints ONLY + the plan block (DAG + reel marks) — no console-wide re-render. */ +function wireReel(scope){ + scope.querySelectorAll('.reel-stop[data-ver]').forEach(stop=>{ + const pick=()=>{ const vi=+stop.dataset.ver; if(vi===state.planVer) return; state.planVer=vi; repaintPlan(); }; + stop.addEventListener('click', pick); + stop.addEventListener('keydown', e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); pick(); } }); + }); } -function seismoSVG(s, lanes, W){ - W=W||940; const rowH=54, padL=W<400?40:76, padR=14, H=lanes.length*rowH+24, X=t=>padL+(W-padL-padR)*t/s.T, TOL=8; - let g=``; +function seismoSVG(s, lanes, W, opts){ opts=opts||{}; + W=W||940; const {padL,padR,X}=trackGeom(s,W), TOL=8; + const heart=opts.heart!==false, heartH=heart?30:0, rowH=54; + const top=heartH, H=top+lanes.length*rowH+(opts.axis===false?6:24); + let g=``; for(let i=0;i<=4;i++){ const gx=X(s.T*i/4); - g+=`${Math.round(s.T*i/4)}s`; } + g+=``; + if(opts.axis!==false) g+=`${Math.round(s.T*i/4)}s`; } + // judge heartbeat strip — KIND hue baseline, STATUS treatment on warn/crit + if(heart){ const hy=heartH-10; + g+=`judges`; + g+=``; + s.heart.forEach(([t,k])=>{ if(t>s.T) return; const x=X(t); + if(k==='ok') g+=`judge fired · on-task @ ${t.toFixed(0)}s`; + if(k==='warn') g+=`▲`; + if(k==='crit') g+=`✕`; }); + g+=``; } lanes.forEach((a,li)=>{ const keys=s.judges[a]; if(!keys) return; - const base=10+li*rowH+rowH-16, rnd=lcg(40+li), yOf=v=>base-v*1.9; + const base=top+10+li*rowH+rowH-16, rnd=lcg(40+li), yOf=v=>base-v*1.9; g+=`${a}`; g+=``; g+=``; @@ -409,87 +474,122 @@

the composed console

g+=`intervention · ${lbl}`; g+=`${lbl.split(' ')[0]}`; }); }); const nx=X(Math.min(s.now,s.T)); - g+=``; + g+=``; g+=``; return g; } +/* heartbeat as its own small figure is still available (e.g. the inspector), + but the console folds it into the seismograph above. */ function heartSVG(s, W){ W=W||940; const H=40, padL=W<400?40:76, X=t=>padL+(W-padL-14)*t/s.T; let g=``; - g+=`judges`; + g+=`judges`; g+=``; s.heart.forEach(([t,k])=>{ if(t>s.T) return; const x=X(t); - if(k==='ok') g+=``; + if(k==='ok') g+=``; if(k==='warn') g+=`▲`; if(k==='crit') g+=`✕`; }); g+=``; return g; } -function ladderSVG(dots, W){ W=W||440; const H=128, padL=66, X=t=>padL+(W-padL-12)*t/96; - const RUNGS=['nudge','refine','replan','escalate'], rungY=i=>100-i*26; - let g=``; +/* The intervention ladder, time-scale-LOCKED to the seismograph: pass the same + session + width and it derives an identical x-domain (padL/padR/X via + trackGeom over s.T), so a rung-dot at time t sits directly beneath the drift + reading at t. The legacy fixed-width call (no session) still works. */ +function ladderSVG(dots, W, s){ W=W||440; const H=124; + const geo = s ? trackGeom(s, W) : { padL:66, X:t=>66+(W-66-12)*t/96 }; + const padL=geo.padL, X=geo.X; + const RUNGS=['nudge','refine','replan','escalate'], rungY=i=>96-i*25; + let g=``; + if(s){ for(let i=0;i<=4;i++){ const gx=X(s.T*i/4); + g+=``; } } RUNGS.forEach((r,i)=>{ const y=rungY(i); - g+=`${r}`; }); + g+=`${r}`; }); if(dots.length>1) g+=``; dots.forEach(d=>{ g+=`${RUNGS[d[1]]} @ ${d[0]}s`; }); if(!dots.length) g+=`no interventions — never left the ground`; + if(s){ for(let i=0;i<=4;i++){ const gx=X(s.T*i/4); + g+=`${Math.round(s.T*i/4)}s`; } } g+=``; return g; } -function dagSVG(p, W){ W=W||940; const H=216, BW=118, BH=24; +/* The DAG can render either the LIVE plan (no version arg) or a specific reel + version's plan: pass {strata, vi} and each node is reframed by that version's + task set — `added` tasks get the accent "new" treatment, tasks dropped since + the previous (older) revision are ghosted/dashed exactly like the live ghost. */ +function dagSVG(p, W, sel){ W=W||940; const H=216, BW=118, BH=24; const LX=[80,300,520,740], LY=v=>92+v*44; - let g=``; + // per-node role under the selected version: present / added / dropped / absent + let role=null; + if(sel){ const cur=p.strata[sel.vi], prev=p.strata[sel.vi+1]; + role=id=>{ const tid=p.nodes[id].tid; + if(!tid) return cur.has.length?'absent':'present'; + if(cur.has.includes(tid)) return cur.added.includes(tid)?'added':'present'; + if(prev && prev.has.includes(tid)) return 'dropped'; // present last revision, gone now + return 'absent'; }; } + const ghosted=id=>{ if(!sel) return p.nodes[id].st==='ghost'; const r=role(id); return r==='dropped'||r==='absent'; }; + let g=``; p.edges.forEach(([a,b,crit])=>{ const A=p.nodes[a], B=p.nodes[b]; const x0=LX[A.x]+BW/2, y0=LY(A.y), x1=LX[B.x]-BW/2, y1=LY(B.y), c=(x0+x1)/2; - const stroke = crit===1?'var(--accent)':'var(--ink-faint)'; - const extra = crit==='ghost'?` stroke-dasharray="3 4" opacity=".45"`:crit===1?` stroke-width="2"`:` stroke-width="1" opacity=".6"`; - g+=`${a} → ${b}${crit===1?' · critical path':''}`; }); - Object.entries(p.nodes).forEach(([id,n])=>{ const x=LX[n.x], y=LY(n.y), ghost=n.st==='ghost'; - const stroke = n.st==='done'?'var(--good)':n.st==='running'?'var(--accent)':ghost?'var(--rule)':'var(--ink-faint)'; - const glyph = n.st==='done'?['✓','var(--good)']:n.st==='running'?['●','var(--accent)']:ghost?['✕','var(--bad)']:['○','var(--ink-faint)']; - g+=`${id} · ${ghost?'dropped at replan v2':n.st}`; + const eGhost = ghosted(a)||ghosted(b)||crit==='ghost'; + const stroke = eGhost?'var(--ink-faint)':crit===1?'var(--accent)':'var(--ink-faint)'; + const extra = eGhost?` stroke-dasharray="3 4" opacity=".45"`:crit===1?` stroke-width="2"`:` stroke-width="1" opacity=".6"`; + g+=`${a} → ${b}${crit===1&&!eGhost?' · critical path':''}`; }); + Object.entries(p.nodes).forEach(([id,n])=>{ const x=LX[n.x], y=LY(n.y); + const r = sel?role(id):null, ghost=ghosted(id), added=r==='added'; + const stroke = ghost?'var(--rule)':added?'var(--accent)':n.st==='done'?'var(--good)':n.st==='running'?'var(--accent)':'var(--ink-faint)'; + const glyph = ghost?(r==='dropped'||(!sel&&n.st==='ghost')?['✕','var(--bad)']:['·','var(--ink-faint)']) + :added?['○','var(--accent)']:n.st==='done'?['✓','var(--good)']:n.st==='running'?['●','var(--accent)']:['○','var(--ink-faint)']; + const sw = (n.st==='running'&&!ghost)||added?2:1.2; + const tip = ghost?(r==='dropped'?`dropped at ${p.strata[sel.vi].v} (was in ${p.strata[sel.vi+1].v})`:!sel?'dropped at replan v2':'not in this revision') + :added?`added in ${p.strata[sel.vi].v}`:n.st; + g+=`${id}${n.tid?' · '+n.tid:''} · ${tip}`; g+=`${id}`; g+=`${glyph[0]}`; }); - g+=`the accent spine = the critical path · ghost = dropped at replan`; return g; -} -function strataSVG(p, W){ W=W||940; const rowH=44, tasks={T1:0,T2:1,T3:2,T4:3,T5:4,T6:5,T7:6}; - const tx=i=>92+i*116, BW=88, BH=13, H=p.strata.length*rowH+26; - let g=``; - Object.entries(tasks).forEach(([t,i])=>{ g+=`${t}`; }); - p.strata.forEach((st,si)=>{ const y0=18+si*rowH, cy=y0+rowH/2; - g+=`${st.v}`; - if(st.live) g+=``; - st.has.forEach(t=>{ const x=tx(tasks[t]); const op = st.live?.8:(.45-si*.08); - g+=`${t} in ${st.v}`; - if(st.added.includes(t)) g+=`○`; }); - if(si>0){ const newer=p.strata[si-1]; - st.has.filter(t=>!newer.has.includes(t)).forEach(t=>{ g+=`✕`; }); } - if(st.seam){ const sy=y0-2; g+=`${st.seam}`; } }); - g+=``; return g; + const cap = sel ? `${p.strata[sel.vi].v} · ○ accent = added at this revision · dashed = dropped since the prior revision` + : 'the accent spine = the critical path · ghost = dropped at replan'; + g+=`${cap}`; return g; } -function reelSVG(p, W){ W=W||940; const H=86, xs=[0.12,0.36,0.6,0.84].map(f=>Math.round(W*f)); - let g=``; - g+=``; +/* The plan reel is the version timeline (v0…v3) with its replan-trigger seams. + It reads oldest→newest left to right, while p.strata is stored newest-first — + so version index `i` here maps to strata index `(strata.length-1)-i`. Each + stop is a button (data-ver=strata index) that drives the DAG; `selVi` marks + which strata version is active. */ +function reelSVG(p, W, selVi){ W=W||940; const H=92, xs=[0.12,0.36,0.6,0.84].map(f=>Math.round(W*f)); + const n=p.strata.length; // strata is newest-first; the reel reads oldest→newest + let g=``; + g+=``; const trig=['','scope-creep','goal-drift','tool-misuse']; - xs.forEach((x,i)=>{ if(i>0) g+=`↯ ${trig[i]}`; - g+=``; - g+=`v${i}${p.rem[i]} tasks left`; - if(i>0) g+=`−${p.rem[i-1]-p.rem[i]}`; }); + xs.forEach((x,i)=>{ + // stops 1..3 are real strata versions (newest at the rightmost); stop 0 is the v0 origin + const sIdx = i===0 ? null : (n-1)-(i-1); // i=1→v1=strata[n-1]; i=3→v3=strata[0] + const active = sIdx!=null && sIdx===selVi, latest=i===xs.length-1; + if(i>0) g+=`↯ ${trig[i]}`; + const dotFill = active?'var(--accent)':latest?'var(--accent)':'var(--panel)'; + const dotStroke = active||latest?'var(--accent)':'var(--ink-soft)'; + g+=``; + if(active) g+=``; + g+=``; + g+=``; + g+=`v${i}`; + g+=`${p.rem[i]} tasks left`; + if(i>0) g+=`−${p.rem[i-1]-p.rem[i]}`; }); g+=``; return g; } -/* ===== app state + shell ===== */ -const PROPOSALS = { observatory: 'A · observatory', score: 'B · score', mission: 'C · mission control' }; -const PROP_NOTES = { - observatory: 'one session, one hero view — the rail switches gantt / sequence / drift / plans; click a span for the inspector', - score: 'sequence-first — the sequence diagram is the view, the gantt is a minimap, drift + topology + plans ride the margin', - mission: 'fleet-first — fingerprints are the navigation; each session gets the full compact instrument set', -}; +/* ===== app state + shell ===== + Sessions are navigated ONLY through the ⌘K picker (fingerprint rows) — no + sidebar. The rail keeps the GANTT as its own hero view; every other reading + — plans (DAG + reel, leading), sequence, topology, drift, interventions — + coalesces into one scrolling INSTRUMENTS view. */ const state = { - proposal: 'observatory', view: 'gantt', sessionId: 'checkout-refactor', + view: 'gantt', sessionId: 'checkout-refactor', drawerSpan: null, drawerOpen: true, live: true, elapsed: 80, picker: false, + planVer: 0, // selected reel version as a strata index (0 = newest/live); drives the DAG }; (function initState(){ const P=new URLSearchParams(location.search); - const pr=P.get('proposal'); if(pr && PROPOSALS[pr]) state.proposal=pr; - const v=P.get('view'); if(v && ['gantt','sequence','drift','plans'].includes(v)) state.view=v; - // deep-link a span's inspector (reachable from every proposal) - const sp=P.get('span'); if(sp){ state.drawerSpan=sp; state.drawerOpen=true; if(state.proposal==='observatory') state.view='sequence'; } })(); + const v=P.get('view'); if(v && ['gantt','instruments'].includes(v)) state.view=v; + const s=P.get('session'); if(s && SESSIONS[s]) state.sessionId=s; + // deep-link a span's inspector + const sp=P.get('span'); if(sp){ state.drawerSpan=sp; state.drawerOpen=true; } + // deep-link a reel version (strata index) to drive the DAG + const pv=P.get('ver'); if(pv!=null && SESSIONS[state.sessionId].plan.strata[+pv]) state.planVer=+pv; })(); const S = () => SESSIONS[state.sessionId]; function statusPill(s){ return s.status==='live' ? `● live` @@ -525,7 +625,7 @@

the composed console

zoom: fit · scrubbing disabled in the mock
`; } -function drawerHTML(float){ const s=S(); const sp = s.spans.find(x=>x.id===state.drawerSpan); +function drawerHTML(){ const s=S(); const sp = s.spans.find(x=>x.id===state.drawerSpan); let body; if(sp){ body = `
@@ -553,7 +653,6 @@

${sp?sp.label:'session'}

${body}
`; - if(float){ if(!state.drawerSpan) return ''; return `
${inner}
`; } return `
${inner}
`; } function pickerHTML(){ if(!state.picker) return ''; @@ -562,94 +661,72 @@

sessions
${Object.values(SESSIONS).map(s=>``).join('')}`; + ${s.status}`).join('')} +
+ fingerprint: x = plan progress · y = drift, damped — tight = harmonic, scribble = drifting, + outward spiral = diverging; the dot is status, earned
`; } -/* ----- proposal A: the observatory ----- */ -function mainObservatory(){ const s=S(); - if(state.view==='gantt') return `

execution — gantt

${ganttSVG(s,{selected:state.drawerSpan})}
-

judge heartbeat

${heartSVG(s)}`; - if(state.view==='sequence') return `

sequence — who said what to whom, when

-

time-ordered

${seqDiagramSVG(s)}
-

topology — who initiates, who receives

${topoChordSVG(s,300)}
`; - if(state.view==='drift') return `

drift seismograph

${seismoSVG(s,['coder','reviewer','planner'])} -

intervention ladder

${ladderSVG(s.ladder,460)}
-

judge heartbeat

${heartSVG(s,460)}
`; - return `

plan DAG — interdependence (the critical path is the spine)

${dagSVG(s.plan)} -

plan strata — revisions as sediment

${strataSVG(s.plan)} -

plan reel — remaining work across revisions

${reelSVG(s.plan)}`; +/* ----- the plan block: the reel sits DIRECTLY ABOVE the DAG, sharing the same + horizontal alignment, and each reel version drives the DAG below it. The DAG + lives in its own host so a reel click re-renders ONLY the DAG (digest-gated — + no full re-render, no flash). ----- */ +function planBlockHTML(s){ const sel=state.planVer, ver=s.plan.strata[sel]; + return `

plan reel — pick a revision to drive the DAG below

+
${reelSVG(s.plan, 940, sel)}
+

plan DAG — ${ver.v}'s plan · interdependence (the critical path is the spine)

+
${dagSVG(s.plan, 940, {vi:sel})}
`; +} +/* re-render ONLY the DAG host + the reel's active marks — local UI state, no + structural repaint of the rest of the console. */ +function repaintPlan(){ const s=S(), host=document.getElementById('dag-host'); + if(!host) return; host.innerHTML=dagSVG(s.plan, 940, {vi:state.planVer}); + const h3=host.previousElementSibling; if(h3&&h3.tagName==='H3') + h3.textContent=`plan DAG — ${s.plan.strata[state.planVer].v}'s plan · interdependence (the critical path is the spine)`; + const reel=document.querySelector('.reel-host'); if(reel){ reel.innerHTML=reelSVG(s.plan,940,state.planVer); wireReel(reel); } +} +/* ----- the two rail views ----- */ +function mainHybrid(){ const s=S(); + if(state.view==='gantt') + // the gantt stands alone: the one hero reading, full size, nothing competing + return `

execution — gantt

${ganttSVG(s,{selected:state.drawerSpan})}
+

judge heartbeat

${heartSVG(s)}`; + // the instruments: everything that is NOT the gantt, coalesced into one + // mission-control scroll — THE PLAN LEADS (reel → DAG), then conversation, + // topology, and ONE coordinated time-track stack: drift+judge seismograph + // over the time-aligned intervention ladder. + return `
${fpSVG(s,58)}
${s.id}
+
${s.goal}
+ ${statusPill(s)} + ${s.spans.length} spans${s.now}s
+ ${planBlockHTML(s)} +

sequence — who said what to whom, when

${seqDiagramSVG(s,{W:520,H:380})}
+

topology — who initiates, who receives

${topoChordSVG(s,300)} +

hover an agent to project its + conversations (click to pin · click away to clear) · ribbon width grows with √count and caps, so heavy + traffic can never flood the figure

+

drift seismograph + judge heartbeat — one instrument, one time axis

+
${seismoSVG(s,['coder','reviewer','planner'],940,{axis:false})} +
interventions, time-aligned beneath the drift above ↓
+ ${ladderSVG(s.ladder,940,s)}
`; } function railHTML(){ - const items=[['gantt','▤'],['sequence','♒'],['drift','〰'],['plans','⧉']]; + const items=[['gantt','▤'],['instruments','⧉']]; return ``; } -function appObservatory(){ - return topbarHTML()+stripHTML()+`
${railHTML()} -
${mainObservatory()}
${drawerHTML()}
`+transportHTML(); -} -/* ----- proposal B: the score (sequence-first) ----- - The corrected sequence diagram IS the view; the gantt is a minimap; drift, - topology, plans, and interventions ride the margin so every piece of session - information the other proposals surface is reachable here too. */ -function appScore(){ const s=S(); - return topbarHTML()+`
-
${ganttSVG(s,{mini:true})}
-

the sequence diagram — who said what to whom, when

${seqDiagramSVG(s,{W:760,H:430})} -

plan DAG — interdependence

${dagSVG(s.plan)} -

plan strata — revisions as sediment

${strataSVG(s.plan)}
- -
`+drawerHTML(true)+transportHTML(); -} -/* ----- proposal C: mission control ----- */ -function appMission(){ const s=S(); - return topbarHTML()+`
- -
-
${fpSVG(s,58)}
${s.id}
-
${s.goal}
- ${statusPill(s)} - ${s.spans.length} spans${s.now}s
-

execution

${ganttSVG(s,{compact:true,selected:state.drawerSpan})}
-

sequence — who said what to whom

${seqDiagramSVG(s,{W:520,H:360})}
-

topology — who initiates

${topoChordSVG(s,300)}
-

drift

${seismoSVG(s,['coder'])} -

judge heartbeat

${heartSVG(s)} -

intervention ladder

${ladderSVG(s.ladder,460)}
-

plan reel

${reelSVG(s.plan,460)}
-

plan DAG — interdependence

${dagSVG(s.plan)} -

plan strata — revisions as sediment

${strataSVG(s.plan)} -
`+drawerHTML(true)+transportHTML(); +function appHybrid(){ + return topbarHTML()+stripHTML()+`
${railHTML()} +
${mainHybrid()}
${drawerHTML()}
`+transportHTML(); } /* ----- render + wire ----- */ function render(){ const app=document.getElementById('app'); - app.innerHTML = (state.proposal==='observatory'?appObservatory():state.proposal==='score'?appScore():appMission()) + pickerHTML(); - document.getElementById('prop-note').textContent = PROP_NOTES[state.proposal]; - const sw=document.getElementById('prop-switch'); - sw.innerHTML = Object.entries(PROPOSALS).map(([id,lbl])=> - ``).join(''); - sw.querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{ state.proposal=b.dataset.prop; state.drawerSpan=null; state.drawerOpen = b.dataset.prop==='observatory'; render(); })); + app.innerHTML = appHybrid() + pickerHTML(); mountBrand(); app.querySelectorAll('.hg-rail-item').forEach(b=>b.addEventListener('click',()=>{ state.view=b.dataset.view; render(); })); app.querySelectorAll('[data-span]').forEach(el=>el.addEventListener('click',()=>{ - // the inspector payload is reachable in every proposal: A docks it in the - // rail drawer, B & C float it as an overlay. state.drawerSpan=el.dataset.span; state.drawerOpen=true; render(); })); const dc=app.querySelector('#drawer-close'); if(dc) dc.addEventListener('click',()=>{ if(state.drawerSpan){ state.drawerSpan=null; } else { state.drawerOpen=false; } render(); }); @@ -658,10 +735,10 @@

plan strata — revisions as sediment

${strataSVG(s.plan)} const pb=app.querySelector('#picker-backdrop'); if(pb){ pb.addEventListener('click',e=>{ if(e.target===pb){ state.picker=false; render(); } }); pb.querySelectorAll('[data-sess]').forEach(b=>b.addEventListener('click',()=>{ - state.sessionId=b.dataset.sess; state.picker=false; state.drawerSpan=null; state.elapsed=S().now; render(); })); + state.sessionId=b.dataset.sess; state.picker=false; state.drawerSpan=null; state.planVer=0; state.elapsed=S().now; render(); })); const pi=app.querySelector('#picker-input'); if(pi) pi.focus(); } - app.querySelectorAll('.fleet-item').forEach(b=>b.addEventListener('click',()=>{ - state.sessionId=b.dataset.sess; state.drawerSpan=null; state.elapsed=S().now; render(); })); + wireChord(app); + const rh=app.querySelector('.reel-host'); if(rh) wireReel(rh); } document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ if(state.picker){ state.picker=false; render(); } else if(state.drawerSpan){ state.drawerSpan=null; render(); } } diff --git a/docs/design/zicato-ui-study/index.html b/docs/design/zicato-ui-study/index.html index 76878d7..ccc6ec1 100644 --- a/docs/design/zicato-ui-study/index.html +++ b/docs/design/zicato-ui-study/index.html @@ -127,10 +127,11 @@

The surfaces

compose →
-
The full console, interactive. Three whole-UI proposals — A·observatory (rail-switched - hero views + inspector), B·score (sequence-first with a gantt minimap and drift margin), C·mission control - (fingerprint fleet) — with clickable spans, ⌘K session switching, and the grammar figures composed in. - Opens from file://, no server needed.
+
The full console, interactive. The gantt stands alone as the hero view; one + coalesced instruments view leads with the plan — the reel sits above the DAG and drives it (click a + revision; the DAG redraws that version, added in accent, dropped ghosted) — then sequence, the projectable + topology chord, and one time-track stack: the drift seismograph with the judge heartbeat folded in, over a + time-aligned intervention ladder. Sessions switch only through the ⌘K fingerprint picker; spans → inspector.
diff --git a/docs/design/zicato-ui-study/sequence.html b/docs/design/zicato-ui-study/sequence.html index 5f7218e..c5cc7a9 100644 --- a/docs/design/zicato-ui-study/sequence.html +++ b/docs/design/zicato-ui-study/sequence.html @@ -21,6 +21,11 @@ .sq-lbl { fill: var(--ink-soft); font-size: 9px; } .sq-chip { fill: var(--panel); stroke: var(--rule); } .gm-ribbon { fill: var(--ink); fill-opacity: .14; } + /* chord interactivity: hover/click an agent to PROJECT its conversations */ + .topo-chord g[data-agent] { cursor: pointer; } + .topo-chord [data-ribbon], .topo-chord [data-ahead], .topo-chord [data-agent] { transition: opacity .15s ease; } + .tch-count { fill: var(--ink); font-size: 9px; font-variant-numeric: tabular-nums; pointer-events: none; + paint-order: stroke; stroke: var(--paper); stroke-width: 3px; opacity: 0; transition: opacity .15s ease; } .uml * { vector-effect: non-scaling-stroke; } .uml .box { fill: var(--panel); stroke: var(--ink-soft); stroke-width: 1.2; } .uml .frame { fill: none; stroke: var(--ink-soft); stroke-width: 1.2; } @@ -88,7 +93,11 @@

topology — who initiates, who receives

The transfer chord, made directional: each agent's arc splits into a solid outbound half and a dashed inbound half; every ribbon is pinched at its source and broad at its target, carries the source→target hue, and lands an arrowhead on the receiver. A coordinator-and-spokes run fans out from one - sector; a web means everyone is talking to everyone. + sector; a web means everyone is talking to everyone. Built to scale: ribbon width grows with + √count and caps, so a chatty pair can never flood the figure — and the chord is interactive: + hover an agent to project its conversations (everything else fades to a whisper, counts appear on + the survivors), click to pin the projection, click empty space to clear. On a 12-agent web, projection is + how the figure stays readable.

@@ -344,44 +353,74 @@

Before — the inherited frame

const outN=agents.map(()=>0), inN=agents.map(()=>0); flows.forEach(([a,b,c])=>{ outN[a]+=c; inN[b]+=c; }); const outUsed=agents.map(()=>0), inUsed=agents.map(()=>0); - let g=``; + let g=``; agents.forEach((a,i)=>{ const th=ang(i); const oA=th+SECTOR/2, oB=th+0.05, iA=th-0.05, iB=th-SECTOR/2; const arcStroke=(thA,thB,r,attrs)=>{ const [xA,yA]=onArc(thA,r), [xB,yB]=onArc(thB,r); - return ``; }; + return ``; }; if(outN[i]) g+=arcStroke(oA,oB,R, `stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9"`); if(inN[i]) g+=arcStroke(iA,iB,R, `stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9" stroke-dasharray="1.5 2.4"`); }); + let counts=''; flows.forEach(([a,b,c],fi)=>{ const ths=ang(a), thd=ang(b); const sFrac=(outUsed[a]+c/2)/Math.max(outN[a],1); outUsed[a]+=c; const dFrac=(inUsed[b]+c/2)/Math.max(inN[b],1); inUsed[b]+=c; const sTh=ths+0.07+(SECTOR/2-0.07)*sFrac, dTh=thd-0.07-(SECTOR/2-0.07)*dFrac; - const wS=1.6, wT=Math.max(5, c*2.4); + // √count with a hard cap: heavy traffic thickens slowly and can never flood + const wS=1.6, wT=Math.min(15, 4+Math.sqrt(c)*3.4); const [sx,sy]=onArc(sTh,R-4), [dx,dy]=onArc(dTh,R-9); const tS=[Math.sin(sTh),Math.cos(sTh)], tD=[Math.sin(dTh),Math.cos(dTh)]; const s1=[sx+tS[0]*wS/2, sy+tS[1]*wS/2], s2=[sx-tS[0]*wS/2, sy-tS[1]*wS/2]; const t1=[dx+tD[0]*wT/2, dy+tD[1]*wT/2], t2=[dx-tD[0]*wT/2, dy-tD[1]*wT/2]; const mid=[(sx+dx)/2 + (cx-(sx+dx)/2)*0.62, (sy+dy)/2 + ((cyA-34)-(sy+dy)/2)*0.5]; - const gid=`sqchg${fi}`; + const gid=`sqchg${fi}`, FT=`data-f="${agents[a]}" data-t="${agents[b]}"`; g+=` - `; - g+=``; + g+=` ${agents[a]} → ${agents[b]} · ${c} transfers (${agents[a]} initiates)`; const outward=[Math.cos(dTh),-Math.sin(dTh)], tip=onArc(dTh,R-1); const base=[tip[0]-outward[0]*8, tip[1]-outward[1]*8]; - g+=``; + g+=``; + counts+=`${c}×`; }); agents.forEach((a,i)=>{ const th=ang(i), [x,y]=onArc(th,R); - g+=``; const [lx,ly]=onArc(th,R+15), anchor= lxcx+10?'start':'middle'); - g+=`${a}`; }); - g+=``; return g; + g+=` + + + ${a}`; }); + g+=counts+``; return g; +} +/* hover an agent → project (dim everything not involving it); click → pin */ +function wireChord(scope){ + scope.querySelectorAll('svg.topo-chord').forEach(svg=>{ + let pinned=null; + const apply=f=>{ + svg.querySelectorAll('[data-ribbon],[data-ahead]').forEach(p=>{ + p.style.opacity = (!f || p.dataset.f===f || p.dataset.t===f) ? '' : .05; }); + svg.querySelectorAll('[data-count]').forEach(tx=>{ + tx.style.opacity = (f && (tx.dataset.f===f || tx.dataset.t===f)) ? 1 : ''; }); + svg.querySelectorAll('[data-agent]').forEach(n=>{ + n.style.opacity = (!f || n.dataset.agent===f) ? '' : .3; }); + }; + svg.querySelectorAll('g[data-agent]').forEach(h=>{ + h.addEventListener('mouseenter',()=>{ if(!pinned) apply(h.dataset.agent); }); + h.addEventListener('mouseleave',()=>{ if(!pinned) apply(null); }); + h.addEventListener('click',e=>{ e.stopPropagation(); + pinned = pinned===h.dataset.agent ? null : h.dataset.agent; apply(pinned); }); + h.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); h.click(); } }); + }); + svg.addEventListener('click',()=>{ if(pinned){ pinned=null; apply(null); } }); + const want=new URLSearchParams(location.search).get('chord'); + if(want && svg.querySelector(`g[data-agent="${want}"]`)){ pinned=want; apply(want); } + }); } function mountChord(){ document.getElementById('fig-chord').innerHTML = chordSVG({ W:360, H:300, agents:CHORD_AGENTS, flows:CHORD_FLOWS }); + wireChord(document.getElementById('fig-chord')); } /* ===== Alternative: the score (horizontal) ===== */ From bb57da43d497d16d3c59f3dfaab02c43c741b92c Mon Sep 17 00:00:00 2001 From: Sunil Pedapudi Date: Mon, 15 Jun 2026 09:00:22 -0700 Subject: [PATCH 05/13] design(zicato-ui-study): small solid arrowheads, thin gradient chords, span-connecting gantt edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - arrowheads (chord, gantt edges, sequence diagram) → small SOLID triangles - topology chords → thin gradient strokes (width min(4, 0.9+√count·1.1), non-scaling) instead of filled teardrops; arc caps 4.5px non-scaling - gantt transfer/delegation edges connect span-end → span-start with a landing arrowhead - judge heartbeat derived from the drift so its marks align with the seismograph excursions Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/zicato-ui-study/_study.css | 2 +- docs/design/zicato-ui-study/compose.html | 74 +++++++++++++++-------- docs/design/zicato-ui-study/grammar.html | 41 ++++++------- docs/design/zicato-ui-study/sequence.html | 49 ++++++++------- 4 files changed, 94 insertions(+), 72 deletions(-) diff --git a/docs/design/zicato-ui-study/_study.css b/docs/design/zicato-ui-study/_study.css index 0bccd36..0a57084 100644 --- a/docs/design/zicato-ui-study/_study.css +++ b/docs/design/zicato-ui-study/_study.css @@ -334,7 +334,7 @@ svg.fig text { font-family: var(--mono); } .hg-gantt-now-label { fill: var(--accent); font-size: 9.5px; font-variant-numeric: tabular-nums; } .hg-gantt-glyph-fail { fill: var(--bad); font-size: 10px; } .hg-gantt-glyph-wait { fill: var(--caution); font-size: 10px; } -.hg-gantt-edge { stroke: var(--ink-faint); stroke-width: 1; fill: none; vector-effect: non-scaling-stroke; opacity: .5; } +.hg-gantt-edge { stroke: var(--ink-faint); stroke-width: 1; fill: none; vector-effect: non-scaling-stroke; opacity: .62; } .hg-gantt-edge.is-transfer { stroke: var(--hg-kind-transfer); stroke-dasharray: 3 2; } .hg-gantt-ctxband { fill: var(--caution); fill-opacity: .14; } .hg-gantt-ctxband-line { stroke: var(--caution); stroke-width: 0.8; fill: none; vector-effect: non-scaling-stroke; opacity: .6; } diff --git a/docs/design/zicato-ui-study/compose.html b/docs/design/zicato-ui-study/compose.html index 1c8fc8b..eb9638f 100644 --- a/docs/design/zicato-ui-study/compose.html +++ b/docs/design/zicato-ui-study/compose.html @@ -163,6 +163,17 @@

the composed console

for(let i=1;i>>0; return ()=>((s=(s*1664525+1013904223)>>>0)/4294967296); } +/* The judge heartbeat is DERIVED from the drift the judges watch, so every + firing mark sits exactly above the drift reading that earned it: a steady ok + cadence, a warn (▲) when the worst watched lane crosses the tolerance band, + a crit (✕) at a deep excursion. Same lanes, same time mapping as the + seismograph — the heartbeat and the drift trace cannot disagree. */ +function judgeBeats(s, lanes){ lanes=lanes||['coder','reviewer','planner']; const TOL=8, beats=[]; + const end=Math.min(s.now, s.T); + for(let t=2; t<=end+0.001; t+=2.6){ + let d=0; lanes.forEach(a=>{ if(s.judges[a]) d=Math.max(d, lerpKeys(s.judges[a], t)); }); + beats.push([t, d>=TOL*1.5 ? 'crit' : d>=TOL ? 'warn' : 'ok']); } + return beats; } const PRIMARY = { id: 'checkout-refactor', status: 'live', T: 96, now: 80, @@ -198,8 +209,6 @@

the composed console

ticks: { coder: [[35,'nudge · operator'],[42,'refine · goldfive']], reviewer: [], planner: [] }, ladder: [[35,0],[42,1]], ctx: [[0,.10],[18,.22],[30,.44],[44,.62],[54,.78],[55,.34],[68,.5],[80,.62],[96,.66]], - heart: (()=>{ const r=lcg(7), e=[]; for(let i=0;i<30;i++){ const t=2+i*2.5+r(); - e.push([t, (i===12||i===13)?'warn':(i===15?'crit':'ok')]); } return e; })(), plan: { // tid bridges each DAG node to its strata task-id (T1…T7) so the reel can // drive the DAG: the live DAG below == version v3's task set {T1,T2,T4,T6,T7} @@ -277,12 +286,25 @@

the composed console

g+=`${sp.agent} · ${sp.label} · ${sp.t0}–${sp.t1}s · ${sp.status}`; if(!o.mini && sp.status==='failed') g+=`✕`; if(!o.mini && sp.status==='awaiting') g+=`◷`; }); - if(!o.mini){ s.transfers.forEach(tr=>{ if(!Y[tr.from]||!Y[tr.to]) return; const x=X(tr.t); - g+=`transfer ${tr.from} → ${tr.to}`; }); - if(s.delegation && Y[s.delegation.to]){ const d=s.delegation; - g+=`delegate → ${d.to} · ${d.tokens} tok`; - g+=`rejoin · ${d.verdict}`; - g+=`✓`; } } + if(!o.mini){ + // Edges connect the END of the source span to the START of the receiver's + // next span: a smooth S anchored on the bar edges, with an open line-art + // chevron landing on the receiver, so a hand-off visibly joins the two bars. + // (The chevron is classless on purpose — a CSS class rule would override its + // presentation attributes; classless, its own dash/colour/opacity hold.) + const spanStart=(ag,t)=>{ let b=null; s.spans.forEach(sp=>{ if(sp.agent===ag&&Y[sp.agent]&&sp.t0>=t-3&&(!b||sp.t0{ const vs=Math.sign(y1-y0)||1, ya=y0+vs*5, yb=y1-vs*5; + const dx=Math.max(10,Math.abs(x1-x0)*0.5), ad=x1>=x0?1:-1; + return `${tip}` + + ``; }; + s.transfers.forEach(tr=>{ if(!Y[tr.from]||!Y[tr.to]||tr.t>s.T) return; const dst=spanStart(tr.to,tr.t); + g+=edge(X(tr.t), Y[tr.from], X(dst?dst.t0:tr.t), Y[tr.to], ' is-transfer', + 'var(--hg-kind-transfer)', `transfer ${tr.from} → ${tr.to} · ${tr.t}s`); }); + if(s.delegation && Y[s.delegation.to]){ const d=s.delegation, out=spanStart(d.to,d.t0); + if(out && d.t0✓`; } } } const nx=X(Math.min(s.now,s.T)); g+=``; if(!o.mini) g+=`now ${s.now}s`; @@ -329,10 +351,9 @@

the composed console

:k==='delegate'?'var(--ink-soft)':isReturn?'var(--ink-faint)':KIND('agent-message'); const dash = k==='transfer'?` stroke-dasharray="5 3"`:isReturn?` stroke-dasharray="4 3"`:''; g+=`${f} → ${o2} · ${lbl} · ${t}s`; - const ah = isReturn - ? `` - : ``; - g+=ah; + // a small SOLID arrowhead at the receiver; the SOLID-vs-DASHED connector + // (and the hue) carries the call/return distinction + g+=``; g+=`${lbl}`; }); const wait=s.spans.find(x=>x.status==='awaiting'); if(wait&&X[wait.agent]) g+=`gate · deciding…`; @@ -364,26 +385,29 @@

the composed console

agents.forEach((a,i)=>{ const th=ang(i), oA=th+SECTOR/2,oB=th+0.05,iA=th-0.05,iB=th-SECTOR/2; const arc=(thA,thB,attrs)=>{ const [xA,yA]=onArc(thA,R),[xB,yB]=onArc(thB,R); return ``; }; - if(outN[i]) g+=arc(oA,oB,`stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9"`); - if(inN[i]) g+=arc(iA,iB,`stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9" stroke-dasharray="1.5 2.4"`); + if(outN[i]) g+=arc(oA,oB,`stroke="${aColor(a)}" stroke-width="4.5" stroke-linecap="round" vector-effect="non-scaling-stroke" opacity=".9"`); + if(inN[i]) g+=arc(iA,iB,`stroke="${aColor(a)}" stroke-width="4.5" stroke-linecap="round" vector-effect="non-scaling-stroke" opacity=".9" stroke-dasharray="1.5 2.4"`); }); let cLabels=''; flows.forEach(([a,b,c],fi)=>{ const ths=ang(a),thd=ang(b); const sF=(outUsed[a]+c/2)/Math.max(outN[a],1); outUsed[a]+=c; const dF=(inUsed[b]+c/2)/Math.max(inN[b],1); inUsed[b]+=c; const sTh=ths+0.07+(SECTOR/2-0.07)*sF, dTh=thd-0.07-(SECTOR/2-0.07)*dF; - // √count with a hard cap: heavy traffic thickens slowly and can never flood - const wS=1.6, wT=Math.min(15, 4+Math.sqrt(c)*3.4), [sx,sy]=onArc(sTh,R-4),[dx,dy]=onArc(dTh,R-9); - const tS=[Math.sin(sTh),Math.cos(sTh)],tD=[Math.sin(dTh),Math.cos(dTh)]; - const s1=[sx+tS[0]*wS/2,sy+tS[1]*wS/2],s2=[sx-tS[0]*wS/2,sy-tS[1]*wS/2]; - const t1=[dx+tD[0]*wT/2,dy+tD[1]*wT/2],t2=[dx-tD[0]*wT/2,dy-tD[1]*wT/2]; + // ribbon = a THIN stroked curve, not a fat filled teardrop: width is a + // restrained √count, hard-capped, and non-scaling so it stays a crisp + // hairline at any container width. Direction still reads from the gradient + // (faint source → bright target), the solid/dashed arc split and the + // arrowhead — the swelling teardrop was a redundant fourth cue. + const w=Math.min(4, 0.9+Math.sqrt(c)*1.1), [sx,sy]=onArc(sTh,R-4),[dx,dy]=onArc(dTh,R-6); + const tD=[Math.sin(dTh),Math.cos(dTh)]; const mid=[(sx+dx)/2+(cx-(sx+dx)/2)*0.62,(sy+dy)/2+((cyA-34)-(sy+dy)/2)*0.5], gid=`tch${s.id}${fi}`; const FT=`data-f="${agents[a]}" data-t="${agents[b]}"`; g+=` - `; - g+=`${agents[a]} → ${agents[b]} · ${c} (${agents[a]} initiates)`; - const outward=[Math.cos(dTh),-Math.sin(dTh)], tip=onArc(dTh,R-1), base=[tip[0]-outward[0]*8,tip[1]-outward[1]*8]; - g+=``; + `; + g+=`${agents[a]} → ${agents[b]} · ${c} (${agents[a]} initiates)`; + const outward=[Math.cos(dTh),-Math.sin(dTh)], tip=[dx+outward[0]*4,dy+outward[1]*4]; + // a small SOLID arrowhead tipping the chord onto the receiver + g+=``; cLabels+=`${c}×`; }); agents.forEach((a,i)=>{ const th=ang(i),[x,y]=onArc(th,R); @@ -453,7 +477,7 @@

the composed console

if(heart){ const hy=heartH-10; g+=`judges`; g+=``; - s.heart.forEach(([t,k])=>{ if(t>s.T) return; const x=X(t); + judgeBeats(s, lanes).forEach(([t,k])=>{ if(t>s.T) return; const x=X(t); if(k==='ok') g+=`judge fired · on-task @ ${t.toFixed(0)}s`; if(k==='warn') g+=`▲`; if(k==='crit') g+=`✕`; }); @@ -483,7 +507,7 @@

the composed console

let g=``; g+=`judges`; g+=``; - s.heart.forEach(([t,k])=>{ if(t>s.T) return; const x=X(t); + judgeBeats(s).forEach(([t,k])=>{ if(t>s.T) return; const x=X(t); if(k==='ok') g+=``; if(k==='warn') g+=`▲`; if(k==='crit') g+=`✕`; }); diff --git a/docs/design/zicato-ui-study/grammar.html b/docs/design/zicato-ui-study/grammar.html index 11a7da5..8e4b9dd 100644 --- a/docs/design/zicato-ui-study/grammar.html +++ b/docs/design/zicato-ui-study/grammar.html @@ -211,16 +211,16 @@

11 · the transfer chord — who talks to whom, and in which directionwho initiates vs. who receives — so this one is directional three ways at once: (1) each agent's sector is split into an outbound half (solid, source side) and an - inbound half (hollow tick, target side); (2) every ribbon is an asymmetric teardrop — pinched at - the source, broad at the target — so the chord visibly flows toward whom it reaches; (3) it carries the - source→target hue and ends in a small arrowhead on the target arc. A coordinator-and-spokes system fans + inbound half (hollow tick, target side); (2) every chord is a thin line whose hue fades from a + faint source to a bright target, so it visibly flows toward whom it reaches; (3) and it ends in a + small arrowhead on the target arc. A coordinator-and-spokes system fans out from one sector; if it looks like a web, something's off. The session-summary topology check.

outbound (source) half inbound (target) half - pinched ▸ source · broad ▸ target · ▸ arrowhead at the receiver + faint ▸ source · bright ▸ target · ▸ arrowhead at the receiver
@@ -621,8 +621,8 @@

Which of these earn promotion

const arcStroke=(thA,thB,r,attrs)=>{ const [xA,yA]=onArc(thA,r), [xB,yB]=onArc(thB,r); const large=0; const sweep= thA>thB?1:0; return ``; }; - if(outN[i]) g+=arcStroke(oA,oB,R, `stroke="${aColor(i)}" stroke-width="5" stroke-linecap="round" opacity=".9"`); - if(inN[i]) g+=arcStroke(iA,iB,R, `stroke="${aColor(i)}" stroke-width="5" stroke-linecap="round" opacity=".9" stroke-dasharray="1.5 2.4"`); + if(outN[i]) g+=arcStroke(oA,oB,R, `stroke="${aColor(i)}" stroke-width="4.5" stroke-linecap="round" vector-effect="non-scaling-stroke" opacity=".9"`); + if(inN[i]) g+=arcStroke(iA,iB,R, `stroke="${aColor(i)}" stroke-width="4.5" stroke-linecap="round" vector-effect="non-scaling-stroke" opacity=".9" stroke-dasharray="1.5 2.4"`); }); // ribbons: asymmetric teardrop, src→dst hue gradient, arrowhead on the target flows.forEach(([a,b,c],fi)=>{ const ths=ang(a), thd=ang(b); @@ -631,26 +631,25 @@

Which of these earn promotion

const dFrac=(inUsed[b]+c/2)/Math.max(inN[b],1); inUsed[b]+=c; const sTh=ths+0.06+ (SECTOR/2-0.06)*sFrac; // along the outbound half const dTh=thd-0.06- (SECTOR/2-0.06)*dFrac; // along the inbound half - const wS=1.6, wT=Math.max(5, c*1.9); // pinched at source, broad at target - const [sx,sy]=onArc(sTh,R-4), [dx,dy]=onArc(dTh,R-9); // target inset so the arrowhead sits on the arc - // tangent at each end spreads the ribbon edges along the arc - const tS=[Math.sin(sTh),Math.cos(sTh)], tD=[Math.sin(dTh),Math.cos(dTh)]; - const s1=[sx+tS[0]*wS/2, sy+tS[1]*wS/2], s2=[sx-tS[0]*wS/2, sy-tS[1]*wS/2]; - const t1=[dx+tD[0]*wT/2, dy+tD[1]*wT/2], t2=[dx-tD[0]*wT/2, dy-tD[1]*wT/2]; - // a single shared control point near the centre so the teardrop body bends inward + // ribbon = a THIN stroked curve, not a fat filled teardrop: width is a + // restrained √count, hard-capped and non-scaling so it stays a crisp + // hairline. Direction reads from the gradient (faint source → bright + // target), the solid/dashed arc split and the arrowhead. + const w=Math.min(4, 0.9+Math.sqrt(c)*1.1); + const [sx,sy]=onArc(sTh,R-4), [dx,dy]=onArc(dTh,R-6); // target inset so the arrowhead sits on the arc + const tD=[Math.sin(dTh),Math.cos(dTh)]; + // a single shared control point near the centre so the chord bends inward const mid=[(sx+dx)/2 + (cx-(sx+dx)/2)*0.62, (sy+dy)/2 + ((cyA-34)-(sy+dy)/2)*0.5]; const gid=`chgrad${fi}`; g+=` - `; - g+=` + `; + g+=` ${agents[a]} → ${agents[b]} · ${c} transfers (${agents[a]} initiates)`; - // arrowhead landing on the target arc, pointing outward at the receiver + // a small SOLID arrowhead tipping the chord onto the target arc const outward=[Math.cos(dTh),-Math.sin(dTh)]; // unit vector from centre outward at dst - const tip=onArc(dTh,R-1); - const base=[tip[0]-outward[0]*8, tip[1]-outward[1]*8]; - g+=``; + const tip=[dx+outward[0]*4, dy+outward[1]*4]; + g+=``; }); // nodes + labels agents.forEach((a,i)=>{ const th=ang(i), [x,y]=onArc(th,R); diff --git a/docs/design/zicato-ui-study/sequence.html b/docs/design/zicato-ui-study/sequence.html index c5cc7a9..effa37e 100644 --- a/docs/design/zicato-ui-study/sequence.html +++ b/docs/design/zicato-ui-study/sequence.html @@ -74,8 +74,8 @@

The sequence diagram One vertical lifeline per agent, time flowing down — the canonical sequence-diagram orientation, so it reads as one at a glance. Every message is a horizontal connector from the sender's lifeline to the - receiver's, with a solid arrowhead at the receiving end (a synchronous call) or a dashed return - (a result/reply), the label riding above in the KIND hue — dashed TRANSFER yellow for hand-offs, user green, + receiver's, ending in a small solid arrowhead at the receiver — a solid connector for a synchronous + call, a dashed return for a result/reply, the label riding above in the KIND hue — dashed TRANSFER yellow for hand-offs, user green, agent-message bright-green. Activations hug each lifeline as kind-hue bars (the agent is busy). The tester delegation opens a child lifeline, runs beneath, and returns ✓. The reviewer's approval is a gate (○ deciding). The failed check is the only red. It paginates vertically like a transcript, which @@ -91,8 +91,8 @@

topology — who initiates, who receives

The transfer chord, made directional: each agent's arc splits into a solid outbound half and a - dashed inbound half; every ribbon is pinched at its source and broad at its target, carries the - source→target hue, and lands an arrowhead on the receiver. A coordinator-and-spokes run fans out from one + dashed inbound half; every chord is a thin line that fades from a faint source to a bright target + and lands an arrowhead on the receiver. A coordinator-and-spokes run fans out from one sector; a web means everyone is talking to everyone. Built to scale: ribbon width grows with √count and caps, so a chatty pair can never flood the figure — and the chord is interactive: hover an agent to project its conversations (everything else fades to a whisper, counts appear on @@ -120,7 +120,7 @@

Alternative — the score (time runs right, like the Gantt)

The same trace laid horizontally, sharing the Gantt's time axis exactly. Agents are horizontal staves; work sits ON the staff as quiet kind-hue activations; messages are thin vertical connectors with - open arrowheads. Its strength is being the Gantt's connective tissue — drop it over the same axis and the two + small solid arrowheads. Its strength is being the Gantt's connective tissue — drop it over the same axis and the two views scrub together — but as a sequence diagram it is weaker: vertical message connectors between five closely-stacked staves are cramped, and it reads as an execution overlay more than a conversation. Keep it as the Gantt-companion lane; the vertical sequence diagram above is the view for reading the interaction. @@ -273,10 +273,8 @@

Before — the inherited frame

connector in the KIND hue. Activations hug each lifeline. The delegation opens a child lifeline that runs beneath the parent and returns. */ function arrowHead(x, y, dir, color, open){ // dir: +1 head points right, -1 left - const b = open - ? `` - : ``; - return b; + // a small SOLID arrowhead; the SOLID-vs-DASHED connector carries call/return + return ``; } function mountStaff(){ const W=760, padT=58, H=520; @@ -358,31 +356,32 @@

Before — the inherited frame

const oA=th+SECTOR/2, oB=th+0.05, iA=th-0.05, iB=th-SECTOR/2; const arcStroke=(thA,thB,r,attrs)=>{ const [xA,yA]=onArc(thA,r), [xB,yB]=onArc(thB,r); return ``; }; - if(outN[i]) g+=arcStroke(oA,oB,R, `stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9"`); - if(inN[i]) g+=arcStroke(iA,iB,R, `stroke="${aColor(a)}" stroke-width="5" stroke-linecap="round" opacity=".9" stroke-dasharray="1.5 2.4"`); + if(outN[i]) g+=arcStroke(oA,oB,R, `stroke="${aColor(a)}" stroke-width="4.5" stroke-linecap="round" vector-effect="non-scaling-stroke" opacity=".9"`); + if(inN[i]) g+=arcStroke(iA,iB,R, `stroke="${aColor(a)}" stroke-width="4.5" stroke-linecap="round" vector-effect="non-scaling-stroke" opacity=".9" stroke-dasharray="1.5 2.4"`); }); let counts=''; flows.forEach(([a,b,c],fi)=>{ const ths=ang(a), thd=ang(b); const sFrac=(outUsed[a]+c/2)/Math.max(outN[a],1); outUsed[a]+=c; const dFrac=(inUsed[b]+c/2)/Math.max(inN[b],1); inUsed[b]+=c; const sTh=ths+0.07+(SECTOR/2-0.07)*sFrac, dTh=thd-0.07-(SECTOR/2-0.07)*dFrac; - // √count with a hard cap: heavy traffic thickens slowly and can never flood - const wS=1.6, wT=Math.min(15, 4+Math.sqrt(c)*3.4); - const [sx,sy]=onArc(sTh,R-4), [dx,dy]=onArc(dTh,R-9); - const tS=[Math.sin(sTh),Math.cos(sTh)], tD=[Math.sin(dTh),Math.cos(dTh)]; - const s1=[sx+tS[0]*wS/2, sy+tS[1]*wS/2], s2=[sx-tS[0]*wS/2, sy-tS[1]*wS/2]; - const t1=[dx+tD[0]*wT/2, dy+tD[1]*wT/2], t2=[dx-tD[0]*wT/2, dy-tD[1]*wT/2]; + // ribbon = a THIN stroked curve, not a fat filled teardrop: width is a + // restrained √count, hard-capped, and non-scaling so it stays a crisp + // hairline at any container width. Direction still reads from the gradient + // (faint source → bright target), the solid/dashed arc split and the + // arrowhead — the swelling teardrop was a redundant fourth cue. + const w=Math.min(4, 0.9+Math.sqrt(c)*1.1); + const [sx,sy]=onArc(sTh,R-4), [dx,dy]=onArc(dTh,R-6); + const tD=[Math.sin(dTh),Math.cos(dTh)]; const mid=[(sx+dx)/2 + (cx-(sx+dx)/2)*0.62, (sy+dy)/2 + ((cyA-34)-(sy+dy)/2)*0.5]; const gid=`sqchg${fi}`, FT=`data-f="${agents[a]}" data-t="${agents[b]}"`; g+=` - `; - g+=` + `; + g+=` ${agents[a]} → ${agents[b]} · ${c} transfers (${agents[a]} initiates)`; - const outward=[Math.cos(dTh),-Math.sin(dTh)], tip=onArc(dTh,R-1); - const base=[tip[0]-outward[0]*8, tip[1]-outward[1]*8]; - g+=``; + const outward=[Math.cos(dTh),-Math.sin(dTh)], tip=[dx+outward[0]*4, dy+outward[1]*4]; + // a small SOLID arrowhead tipping the chord onto the receiver + g+=``; counts+=`${c}×`; }); agents.forEach((a,i)=>{ const th=ang(i), [x,y]=onArc(th,R); @@ -442,7 +441,7 @@

Before — the inherited frame

if(st==='awaiting') g+=`◷`; }); function arrow(x, y0, y1, color, dash, lbl){ const d=y1>y0?1:-1, bend=14*d; let s=`${lbl}`; - s+=``; + s+=``; return s; } MSGS.forEach(([t,f,o,k,lbl])=>{ const x=X(t); if(k==='delegate'||k==='result') return; From dbc0a029fd8469d348b551ea2f254b56096899e0 Mon Sep 17 00:00:00 2001 From: Sunil Pedapudi Date: Mon, 15 Jun 2026 09:00:22 -0700 Subject: [PATCH 06/13] =?UTF-8?q?fix(ui):=20trajectory=20header=20stays=20?= =?UTF-8?q?"rev=20N=20of=20M"=20=E2=80=94=20plan=20picker=20supersedes=20t?= =?UTF-8?q?he=20multi-plan=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's "Plan N · " prefix (#195, written for a merged-trajectory model) was superseded when the plan picker (#196) landed: the picker scopes the view model to one plan and conveys plan identity via its chip bar, so a header prefix is redundant. #196 left #195's two prefix-tests stale → they failed. Header now stays a plain "rev N of M" (planPrefix = ''); the two stale tests assert no-prefix while keeping their rev-math coverage (denominator reads the highest revisionIndex, not the array length). Fixes the 2 pre-existing TrajectoryView.headerMath failures → full suite 740/740. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TrajectoryView.headerMath.test.tsx | 41 ++++++++++--------- .../components/shell/views/TrajectoryView.tsx | 32 ++++----------- 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/frontend/src/__tests__/components/TrajectoryView.headerMath.test.tsx b/frontend/src/__tests__/components/TrajectoryView.headerMath.test.tsx index 258ea8e..ea95f92 100644 --- a/frontend/src/__tests__/components/TrajectoryView.headerMath.test.tsx +++ b/frontend/src/__tests__/components/TrajectoryView.headerMath.test.tsx @@ -146,26 +146,25 @@ describe(' header "rev N of M" math', () => { expect(headerHint()).toBe('rev 2 of 2'); }); - it('uses revisionIndex (not length-1) when the rev numbering has a gap (R0 + R2, no R1)', () => { - // Goldfive planners can mint a fresh plan_id mid-stream, leaving the - // rev sequence non-contiguous when the trajectory merges both plans. - // The header denominator must reflect the highest revisionIndex - // observed, not `vm.revs.length - 1`. + it('reads the highest revisionIndex (not array position) for a plan that starts at a non-zero rev', () => { + // A goldfive planner can mint a fresh plan_id mid-stream that begins at a + // non-zero revisionIndex (here p2 jumps straight to rev 2). The default + // selection is the latest plan (p2), and the header denominator must come + // from its revisionIndex (2), not the scoped vm.revs length-1 (0). // - // Multi-plan note (Item 4 of UX cleanup batch): two distinct plan - // ids (p1, p2) means the header now prefixes the rev label with - // "Plan {N} · " so operators can see which plan the current rev - // belongs to (the H1 plan picker lands the picker UI on top of this - // label). The numerator/denominator math is unchanged. + // Multi-plan identity is conveyed by the plan-picker chip-bar (see + // TrajectoryView.planPicker test), NOT a header prefix: an earlier + // "Plan {N} · " prefix (#195, written for a merged-trajectory model) was + // superseded when the picker (#196) landed and scoped the view model to a + // single selected plan. So the header stays a plain "rev N of M". mockStore.tasks.upsertPlan(mkPlan('p1', [mkTask('t1')], 0)); mockStore.tasks.upsertPlan( mkPlan('p2', [mkTask('t1'), mkTask('t2')], 2, 'user steer', 'user_steer'), ); render(); - // vm.revs.length === 2, but max revisionIndex === 2 → "rev 2 of 2", - // not the buggy "rev 1 of 1". Multi-plan prefix surfaces "Plan 2" - // because the latest rev belongs to the second distinct plan id. - expect(headerHint()).toBe('Plan 2 · rev 2 of 2'); + // Selected = latest = p2, revisionIndex 2 → "rev 2 of 2" (not "rev 2 of 0"). + expect(headerHint()).toBe('rev 2 of 2'); + expect(headerHint()).not.toContain('Plan'); }); it('switches between "no plan yet" and a "rev N of M" hint as plans land', () => { @@ -194,10 +193,11 @@ describe(' header "rev N of M" math', () => { expect(headerHint()).not.toContain('Plan'); }); - it('prefixes "Plan N · " when multiple plan ids are present (Item 4)', () => { - // Three distinct plan ids → header surfaces the index of the plan - // the current rev belongs to, so an operator viewing rev 2 can tell - // which plan they're scoped to ahead of the H1 plan picker landing. + it('keeps a plain "rev N of M" header on multi-plan sessions (plan identity comes from the picker chips)', () => { + // Three distinct plan ids → the plan-picker chip-bar (see + // TrajectoryView.planPicker test) shows which plan is selected, so the + // header does NOT duplicate that with a "Plan N · " prefix. The earlier + // prefix (#195) was superseded when the picker (#196) landed. mockStore.tasks.upsertPlan(mkPlan('p1', [mkTask('t1')], 0)); mockStore.tasks.upsertPlan( mkPlan('p2', [mkTask('t1'), mkTask('t2')], 1, 'split', 'plan_divergence'), @@ -206,7 +206,8 @@ describe(' header "rev N of M" math', () => { mkPlan('p3', [mkTask('t1'), mkTask('t2'), mkTask('t3')], 2, 'next', 'goldfive'), ); render(); - // Latest rev belongs to the third distinct plan id → "Plan 3". - expect(headerHint()).toBe('Plan 3 · rev 2 of 2'); + // Latest = p3, revisionIndex 2 → "rev 2 of 2", no "Plan 3 · " prefix. + expect(headerHint()).toBe('rev 2 of 2'); + expect(headerHint()).not.toContain('Plan'); }); }); diff --git a/frontend/src/components/shell/views/TrajectoryView.tsx b/frontend/src/components/shell/views/TrajectoryView.tsx index 562dab1..7165f82 100644 --- a/frontend/src/components/shell/views/TrajectoryView.tsx +++ b/frontend/src/components/shell/views/TrajectoryView.tsx @@ -530,30 +530,14 @@ export function TrajectoryView() { const currentRevisionIndex = currentRev?.revisionIndex ?? revIdx; const compareRevisionIndex = compareRev?.revisionIndex ?? compareRevIdx ?? null; - // Multi-plan awareness for the header (Item 4 of UX cleanup batch). - // Compute the distinct plan_ids spanned by ``vm.revs`` — when the - // session contains more than one we prefix the rev label with "Plan - // {N}" so operators can tell which plan the current revision belongs - // to. Plans are numbered by first appearance in the time-sorted rev - // list; this composes naturally with the H1 plan picker landing in a - // sibling PR (the picker selects a plan and the header reflects which - // plan is selected). Single-plan sessions retain the unchanged - // "rev N of M" label. - const distinctPlanIds: string[] = []; - const planIdSeen = new Set(); - for (const rev of vm.revs) { - if (!planIdSeen.has(rev.id)) { - planIdSeen.add(rev.id); - distinctPlanIds.push(rev.id); - } - } - const multiPlan = distinctPlanIds.length > 1; - const currentPlanIndex = currentRev - ? distinctPlanIds.indexOf(currentRev.id) + 1 - : 0; - const planPrefix = multiPlan && currentPlanIndex > 0 - ? `Plan ${currentPlanIndex} · ` - : ''; + // Multi-plan identity in the header is conveyed by the plan-picker + // chip-bar (rendered for multi-plan sessions — see TrajectoryView.planPicker + // test): the selected chip shows which of the session's plans the trajectory + // is scoped to, and ``vm`` is scoped to that plan (see buildViewModel above). + // An earlier "Plan {N} · " header prefix (#195, written for a merged-trajectory + // model) was superseded when the picker (#196) landed — the chips make a + // header prefix redundant — so the header stays a plain "rev N of M". + const planPrefix = ''; // Only produce diff marks when the user has explicitly pinned a compare // rev — otherwise every task would be flagged "added" against a null prev. const marks = From 346cdf9387a32a16b4bcedb85f2107613157d7e1 Mon Sep 17 00:00:00 2001 From: Sunil Pedapudi Date: Mon, 15 Jun 2026 09:00:22 -0700 Subject: [PATCH 07/13] =?UTF-8?q?feat(frontend):=20parallel=20zicato=20con?= =?UTF-8?q?sole=20with=20md3=E2=86=94zicato=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the zicato-language UI study into the real React app as a second, toggle-able console. Default uiMode is 'md3' so production is unchanged. - uiStore: persisted uiMode ('md3'|'zicato', default 'md3') + setUiMode/ toggleUiMode, and a scoped zicatoTheme (16 study themes via data-zicato-theme, never touches the MD3 :root theme). - App.tsx branches on uiMode → ZicatoConsole vs Shell (#/stress unaffected); toggle controls in the MD3 AppBar (⧉) and the zicato topbar. - ZicatoConsole shell (topbar/strip/rail/main/inspector/transport) + the two rail views (gantt, instruments); reuses the MD3 SessionPicker (⌘K). - adapter.ts: useZicatoSession maps the live SessionStore + plan hooks into the study's render inputs (reactive; graceful EMPTY_SESSION fallback). - Six figures ported to React: GanttZ, ChordZ, SeismographZ/JudgeHeartbeatZ, LadderZ, PlanZ (reel→DAG), SequenceZ, FingerprintZ; svgUtils + token CSS. - zicato.smoke.test: console mounts against an empty store; token/agent mappers verified. Build/lint/tests green (740/740). NOT yet verified against a live session — see docs/zicato-frontend-port.md for the full state + resume guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/zicato-frontend-port.md | 303 +++++ frontend/src/App.tsx | 7 +- .../components/zicato.smoke.test.tsx | 91 ++ frontend/src/components/shell/AppBar.tsx | 10 + frontend/src/components/zicato/Brand.tsx | 63 + frontend/src/components/zicato/ChordZ.tsx | 347 +++++ .../src/components/zicato/FingerprintZ.tsx | 119 ++ frontend/src/components/zicato/GanttViewZ.tsx | 30 + frontend/src/components/zicato/GanttZ.tsx | 393 ++++++ .../components/zicato/InstrumentsViewZ.tsx | 79 ++ frontend/src/components/zicato/LadderZ.tsx | 128 ++ frontend/src/components/zicato/PlanZ.tsx | 413 ++++++ .../src/components/zicato/SeismographZ.tsx | 314 +++++ frontend/src/components/zicato/SequenceZ.tsx | 366 ++++++ .../src/components/zicato/ZicatoConsole.tsx | 538 ++++++++ frontend/src/components/zicato/adapter.ts | 1147 +++++++++++++++++ frontend/src/components/zicato/svgUtils.ts | 140 ++ .../src/components/zicato/zicato-tokens.css | 715 ++++++++++ frontend/src/components/zicato/zicato.css | 1041 +++++++++++++++ frontend/src/state/uiStore.ts | 118 ++ 20 files changed, 6360 insertions(+), 2 deletions(-) create mode 100644 docs/zicato-frontend-port.md create mode 100644 frontend/src/__tests__/components/zicato.smoke.test.tsx create mode 100644 frontend/src/components/zicato/Brand.tsx create mode 100644 frontend/src/components/zicato/ChordZ.tsx create mode 100644 frontend/src/components/zicato/FingerprintZ.tsx create mode 100644 frontend/src/components/zicato/GanttViewZ.tsx create mode 100644 frontend/src/components/zicato/GanttZ.tsx create mode 100644 frontend/src/components/zicato/InstrumentsViewZ.tsx create mode 100644 frontend/src/components/zicato/LadderZ.tsx create mode 100644 frontend/src/components/zicato/PlanZ.tsx create mode 100644 frontend/src/components/zicato/SeismographZ.tsx create mode 100644 frontend/src/components/zicato/SequenceZ.tsx create mode 100644 frontend/src/components/zicato/ZicatoConsole.tsx create mode 100644 frontend/src/components/zicato/adapter.ts create mode 100644 frontend/src/components/zicato/svgUtils.ts create mode 100644 frontend/src/components/zicato/zicato-tokens.css create mode 100644 frontend/src/components/zicato/zicato.css diff --git a/docs/zicato-frontend-port.md b/docs/zicato-frontend-port.md new file mode 100644 index 0000000..b3cc55b --- /dev/null +++ b/docs/zicato-frontend-port.md @@ -0,0 +1,303 @@ +# Zicato console — frontend port: state & resume guide + +**Last updated:** 2026-06-13 · **Branch:** `design/sequence-compose-parity` · **Status:** ⚠️ **first cut — builds & passes CI, NOT yet verified against real data. Do not consider production-ready.** + +This document is the single place to resume work on porting the **zicato-language UI +study** into the real React frontend as a parallel, toggle-able console. It covers +what exists, what's verified vs. not, how to run it for real, the architecture, and a +file-level map with code pointers. + +--- + +## 1. TL;DR + +harmonograf's production console (`frontend/`) is a React + TypeScript + Vite app built +on `@material/web` (MD3), `zustand`, and connectrpc. A separate **design study** in +`docs/design/zicato-ui-study/` redraws that console in "zicato's design language" +(Tufte/line-art, monospace-forward, single-accent-on-calm-ground, theme-switchable). + +This work **ports that study into the real app as a second, parallel console** that the +user can toggle to/from the MD3 one: + +- A new `uiMode: 'md3' | 'zicato'` flag (persisted, **default `md3`**) branches the app + at the top level. Production behaviour is unchanged until a user opts in. +- The zicato console is a full parallel component tree under + `frontend/src/components/zicato/`, reading **real session data** (the same RPC + hooks/zustand stores the MD3 console uses) through a dedicated **adapter** that maps the + live session model into the study's render inputs. +- All six study figures were ported to React: hero **gantt**, topology **chord**, drift + + judge **seismograph**, intervention **ladder**, **plan** reel→DAG, vertical **sequence** + diagram, and the session **fingerprint**. + +It compiles, lints, the full test suite is green (740/740), and both consoles mount in a +headless browser. **It has not been rendered against a live session** — that is the +critical, not-yet-done verification step (see §3 and §8). + +--- + +## 2. Status at a glance + +| Area | State | +| --- | --- | +| `pnpm build` (`tsc -b && vite build`) | ✅ passes | +| `pnpm lint` (eslint) | ✅ clean | +| `pnpm test` (vitest) | ✅ 740/740 (incl. 6 zicato smoke tests) | +| Empty-state mount (headless chrome, no server) | ✅ both consoles render, no crash, toggle present | +| **Render against a live session (real data)** | ❌ **never done** — the adapter mappings + every figure are unexercised against real RPC data | +| Interactive QA (span→inspector, chord hover/pin, reel→DAG, ⌘K, theme switch, toggle round-trip) | ❌ not exercised with content | +| Committed to git | ✅ (on `design/sequence-compose-parity`; not merged) | +| Affects the live MD3 UI | ❌ no — gated behind a default-off toggle | + +The 6 smoke tests (`frontend/src/__tests__/components/zicato.smoke.test.tsx`) assert the +console **renders against an empty store without throwing** and that token/agent mappers +are correct. They are a scaffold gate, **not** a correctness check of the figures. + +--- + +## 3. How to run it for real (the most important next step) + +The figures have only ever been seen empty. To validate them you need a **live session**. +Two paths: + +### A. Full stack (server serves the built SPA) + +```bash +# 1. build the console into the server's static dir +make console # → harmonograf_server/_console (or frontend/dist) + +# 2. run the server (serves gRPC-Web + the console SPA on the web port) +make server-run # or: python -m harmonograf_server (see server/harmonograf_server/cli.py) + +# 3. drive an orchestration to produce a live session +ls examples/ # → standalone_observability +# run the example so spans/plans/drift stream into a session + +# 4. open the served console in a browser, then flip to zicato: +# AppBar ⧉ button (top-right), OR set localStorage 'harmonograf.uiMode'='zicato' +``` + +### B. Vite dev server pointed at a running backend + +```bash +make server-run # backend on its web port (e.g. 127.0.0.1:17532) +VITE_HARMONOGRAF_API=http://127.0.0.1:17532 make frontend-dev # vite dev w/ HMR +# transport.ts resolves: window.__HARMONOGRAF_API__ → VITE_HARMONOGRAF_API → compiled default +``` + +Then, **in either case**, walk every figure against real data and fix what's wrong: +gantt spans/lanes/now-line/transfers, chord projection, seismograph drift + judge beats, +ladder, plan reel→DAG, sequence diagram, fingerprint, span→inspector, ⌘K session switch, +and all 16 zicato themes. The adapter (`adapter.ts`) is where most real-data bugs will be. + +> The toggle persists in `localStorage['harmonograf.uiMode']`. To force the zicato console +> for a screenshot without clicking, seed that key before the bundle loads. + +--- + +## 4. Architecture + +### 4.1 The toggle (md3 ↔ zicato) + +The whole feature hangs off one persisted store flag; nothing else about the MD3 app changes. + +- **Store** — `frontend/src/state/uiStore.ts` + - `UI_MODE_KEY = 'harmonograf.uiMode'` (L83), `ZICATO_THEME_KEY = 'harmonograf.zicatoTheme'` (L84) + - `uiMode` / `setUiMode` / `toggleUiMode` in the `UiState` interface (L367–369), plus `zicatoTheme` (L370+) + - Initial values + reducers: `uiMode: readUiMode()` (L536), `setUiMode`/`toggleUiMode` (L538–547), `zicatoTheme: readZicatoTheme()` (L548). Persistence uses the file's existing per-key `read*`/`write*` helper pattern. **Default is `md3`.** +- **Branch** — `frontend/src/App.tsx` L48–51: `const uiMode = useUiStore(s => s.uiMode)`; `#/stress` is checked first (dev route unaffected), then `uiMode === 'zicato' → `, else ``. The session deep-link hook stays **above** the branch so links work in both consoles. +- **Toggle controls (both directions):** + - MD3 → zicato: `frontend/src/components/shell/AppBar.tsx` L73–81 — `⧉` icon button, `onClick={() => setUiMode('zicato')}`, `data-testid="ui-mode-toggle"`. + - zicato → MD3: `ZicatoConsole.tsx` topbar — `data-testid="ui-mode-toggle-z"` (L205), wired via `onToMd3={() => setUiMode('md3')}` (L523). + +### 4.2 The console shell + +`frontend/src/components/zicato/ZicatoConsole.tsx` (538 lines) ports `compose.html`'s +`appHybrid` shell. The top-level component is `ZicatoConsole()` (L508): + +``` +
// L520 — theme scoped here + // session list poll (reused from MD3) + // brand · session crumb (⌘K) · status · theme picker · ⧉→md3 + // goal + live/done/failed pill +
+ // rail: 'gantt' | 'instruments' +
{ view==='gantt' ? : }
+ // docked span/session drawer +
+ // transport bar (live clock) + // ⌘K picker — REUSED from MD3 +
+``` + +Sub-components in the same file: `ZicatoThemePicker` (L110), `ZicatoTopbar` (L163), +`statusPill` (L215), `ZicatoStrip` (L221), `ZicatoRail` + `ZicatoView` type (L247/L249), +`ZicatoTransport` (L279), `ZicatoInspector` (L387). View selection is **local React state** +(`useState('gantt')`, L518) — not in the store. `useSessionWatch(sessionId)` +(L516) keeps one stream alive for all sub-views; `useGlobalShortcuts()` (L509) is owned here. + +### 4.3 Data flow — the adapter (the linchpin) + +`frontend/src/components/zicato/adapter.ts` (1147 lines) is the boundary between the real +session model and the study's render inputs. **All real-data correctness lives here.** + +- **Public hook:** `useZicatoSession(sessionId): ZSession` (L1012). It: + - gets the live store via `getSessionStore(sessionId)`; + - is **reactive**: subscribes to every registry (`spans`, `agents`, `tasks`, `drifts`, + `delegations`, `contextSeries`) plus a 1s timer, snapshotting `Date.now()` in a reducer + (not render) so the play-head/`now` advances (L1020–1041); + - pulls goal/title from `useSessionsStore`, annotations from `useAnnotationStore`, plan + inputs from `usePlanHistory` / `useCumulativePlan` / `useSupersedesMap`, and + `trajectorySelectedPlanId` / `selectedRevision` from `useUiStore`; + - returns `EMPTY_SESSION` when there's no store, or a graceful partial when the session + has no spans/agents yet (L1062–1067) — **figures must never crash on empty/loading.** +- **Output type:** `ZSession` (L191) — `{ id, goal, status, T, now, agents[], spans[], transfers[], delegation, edges[], judges, ticks, ladder, ctx, plan, fp, empty }`. +- **Fallback:** `EMPTY_SESSION` (L239, `empty: true`, all arrays empty, `T: 30`, `now: 0`). +- **Builders** (each maps a slice of `SessionStore`): `buildAgents` (L373), `buildSpans` + (L419), `buildTransfers` (L450), `buildDelegation` (L465), `buildEdges` (L496, derives + transfer/delegation/return edges — same algo as MD3 GraphView), `buildJudges` (L623), + `buildTicks` (L642), `buildLadder` (L664), `buildCtx` (L703), `buildPlan` (L735), + `deriveFingerprint` (L947), `deriveStatus` (after L1147). +- **Encoding mappers:** `toKindToken` (L285, `LLM_CALL→'llm-call'` …), `toStatusToken` + (L311, `AWAITING_HUMAN→'awaiting'` …), `gfClassForSpan` (L335), `colorVar` (L994, + agent → `--hg-agent-*` token), `severityToValue` (L269, goldfive drift severity → value). + +`svgUtils.ts` (140 lines) holds the shared render helpers ported from the study: +`KIND` (L13), `gfVar` (L16), `statusFill` (L26), `lerpKeys` (L36), `lcg` (L55, seeded RNG), +`timeScale` (L65, the shared `padL/padR/X` so the seismograph + heartbeat + ladder share an +axis), `uniqueId` (L83), `hgAlphaPath` (L97, the brand α-Lissajous), `judgeBeats` (L122, +**derives the judge heartbeat from the drift** so the marks sit over the excursions). + +### 4.4 The figures (one component per study renderer) + +| Component | File:line | Ports study renderer | Props | +| --- | --- | --- | --- | +| `GanttZ` | `GanttZ.tsx:32` | `ganttSVG` | `{ z, selectedSpanId, onSpanSelect }` | +| `ChordZ` | `ChordZ.tsx:38` | `topoChordSVG` | `{ z, W=300 }` | +| `SeismographZ` | `SeismographZ.tsx:51` | `seismoSVG` | `{ z, W=940, axis }` | +| `JudgeHeartbeatZ` | `SeismographZ.tsx:272` | `heartSVG` | `{ z, W=940 }` | +| `LadderZ` | `LadderZ.tsx:25` | `ladderSVG` | `{ z, W=940 }` | +| `PlanZ` | `PlanZ.tsx:33` | `reelSVG` + `dagSVG` (reel drives DAG) | `{ z }` | +| `SequenceZ` | `SequenceZ.tsx:150` | `seqDiagramSVG` | `{ z, W=520, H=380 }` | +| `FingerprintZ` | `FingerprintZ.tsx:59` | `fpSVG` | `{ fp, status, id, size }` | +| `BrandMark` / `Wordmark` | `Brand.tsx:10/34` | brand block | — | + +The two rail views compose them: +- `GanttViewZ.tsx:13` — `` (wired to `selectedSpanId`/`selectSpan` from the store) + ``. +- `InstrumentsViewZ.tsx:25` — session head (`FingerprintZ`) → `` → panes-2 (`SequenceZ` | `ChordZ`) → the coordinated time-track stack (`SeismographZ` over `LadderZ`). + +### 4.5 Theming + +The zicato console has its **own** palette, independent of MD3: +- `zicatoTheme` in `uiStore` (16 study themes), applied via `data-zicato-theme` on `.zk-root` + (`ZicatoConsole.tsx:520`) — it **never touches** the MD3 ``, so there is + no MD3 regression. Theme id list: `ZICATO_THEME_IDS` (`ZicatoConsole.tsx:41`). +- Tokens: `frontend/src/components/zicato/zicato-tokens.css` (715 lines — the per-theme role + tokens `--paper/--panel/--ink/--good/--bad/--accent/--caution`, the `--ansi-*` ramp, and + the categorical `--hg-kind-*` / `--hg-agent-*` / `--hg-gf-*` / `--hgraf-brand` tokens). +- Component CSS: `frontend/src/components/zicato/zicato.css` (1041 lines). +- Both are imported at the top of `ZicatoConsole.tsx` (L15–16). + +--- + +## 5. File map + +``` +frontend/src/ + App.tsx ← uiMode branch (L48–51) + state/uiStore.ts ← uiMode + zicatoTheme (keys L83-84, type L367, init L536-548) + components/shell/AppBar.tsx ← ⧉ toggle to zicato (L73-81) + components/zicato/ + ZicatoConsole.tsx 538 shell: topbar/strip/rail/main/inspector/transport/picker + adapter.ts 1147 real session → ZSession (useZicatoSession L1012); ALL data mapping + svgUtils.ts 140 shared render helpers (timeScale, judgeBeats, hgAlphaPath, …) + GanttZ.tsx 393 hero gantt + ChordZ.tsx 347 topology chord (thin gradient strokes, projection) + SeismographZ.tsx 314 drift + judge seismograph (+ JudgeHeartbeatZ) + LadderZ.tsx 128 intervention ladder (time-locked to the seismograph) + PlanZ.tsx 413 plan reel → DAG + SequenceZ.tsx 366 vertical sequence diagram + FingerprintZ.tsx 119 session fingerprint identicon + Brand.tsx 63 α mark + wordmark + GanttViewZ.tsx 30 rail view: gantt + InstrumentsViewZ.tsx 79 rail view: instruments + zicato-tokens.css 715 per-theme token contract + zicato.css 1041 component CSS + __tests__/components/zicato.smoke.test.tsx the scaffold gate (6 tests) +``` + +--- + +## 6. The design study (visual source of truth) + +`docs/design/zicato-ui-study/` is the standalone, theme-switchable HTML/CSS/vanilla-JS +study the components port from. When a figure looks wrong, compare against the study — +the React components were written to match it 1:1. Key files: + +- `compose.html` — the interactive composed console (the React shell + views mirror it). +- `grammar.html` — the figure catalogue (chord, seismograph, ladder, plan DAG, fingerprint, …). +- `sequence.html` — the sequence diagram + chord. +- `_tokens.css` / `_study.css` — the token contract the React `zicato-tokens.css`/`zicato.css` port. +- `STYLE-GUIDE.md`, `BRIEF.md`, `README.md`. + +**Encoding rules (preserved in the port):** KIND = categorical/ANSI hue, STATUS = treatment +(running → accent + breathe, failed → `--bad` + ✕, pending → faint, planned → dashed, +awaiting → wait-for-human dash). `good`/`bad`/`accent` are earned by status, never identity. + +**Recent study refinements** (also in this branch): arrowheads are **small solid triangles**; +chords are **thin gradient strokes** (width `min(4, 0.9+√count·1.1)`, non-scaling) rather than +filled teardrops; gantt transfer/delegation edges connect **span-end → span-start** with a +landing arrowhead; the judge heartbeat is **derived from the drift** so its marks line up +with the seismograph. + +--- + +## 7. Side fix included on this branch — TrajectoryView header + +While integrating, two **pre-existing** failures in +`frontend/src/__tests__/components/TrajectoryView.headerMath.test.tsx` were resolved (MD3 +code, unrelated to zicato). Root cause: PR #195 added a `"Plan N · "` header prefix (for a +*merged-trajectory* model) + tests; PR #196 (newest) shipped the **plan picker**, which +scopes the view model to one plan and shows plan identity via chips — **superseding** the +prefix — but left #195's 2 prefix-tests stale. Fix: the header stays a plain `"rev N of M"` +(`planPrefix = ''` in `TrajectoryView.tsx`, ~L533), and the 2 stale tests were updated to +assert no-prefix while keeping their rev-math coverage. If the prefix is actually wanted, +that's the opposite call (un-scope the plan list for the header **and** flip +`TrajectoryView.planPicker.test.tsx`'s no-prefix assertion). + +--- + +## 8. Known issues, risks & TODO (for whoever resumes) + +1. **Verify against real data (blocking).** Nothing here has rendered a real session. Expect + adapter bugs first: field mappings, time scale (`T`/`now`), edge derivation, plan + reel→DAG, judge drift/beats. Start at `adapter.ts` builders (§4.3). +2. **Interactions unverified:** span → `ZicatoInspector`, chord hover/pin projection, + reel→DAG drive, ⌘K session switch (`SessionPicker` is reused), theme switching across all + 16 zicato themes, and the toggle round-trip (only the button presence is asserted). +3. **No visual regression / a11y / cross-browser** pass yet. +4. **Bundle size:** `vite build` warns the single chunk is >500 kB (~785 kB raw / ~229 kB + gzip). Pre-existing (heavy deps: MD3, `shiki`, protobuf). Advisory only. If load time + matters, code-split: lazy-load `shiki` and the zicato console behind the toggle + (`React.lazy` on the `App.tsx` branch). +5. **Not merged.** Lives on `design/sequence-compose-parity`. + +--- + +## 9. Commands + +```bash +# from frontend/ +pnpm build # tsc -b && vite build +pnpm lint # eslint . +pnpm test # vitest run (740 tests) +pnpm dev # vite dev (set VITE_HARMONOGRAF_API to point at a backend) + +# from repo root (Makefile) +make console # build the SPA into the server static dir +make server-run # run the server (serves gRPC-Web + the console) +make frontend-dev # vite dev +make test # all suites +``` + +Related memory/notes: `docs/design/zicato-ui-study/` (the study), and the project's design +intent. The toggle is intentionally default-off; keep it that way until §3/§8 are done. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a18360..9df9f7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Shell } from './components/shell/Shell'; import { StressPage } from './gantt/StressPage'; +import { ZicatoConsole } from './components/zicato/ZicatoConsole'; import { useUiStore } from './state/uiStore'; import { sessionIdFromHash } from './lib/sessionRoute'; @@ -43,7 +44,9 @@ function useSessionDeepLink(hash: string): void { export default function App() { const hash = useHashRoute(); - useSessionDeepLink(hash); - if (hash.startsWith('#/stress')) return ; + useSessionDeepLink(hash); // keep ABOVE the branch — deep links select in both consoles + const uiMode = useUiStore((s) => s.uiMode); + if (hash.startsWith('#/stress')) return ; // dev route unaffected + if (uiMode === 'zicato') return ; return ; } diff --git a/frontend/src/__tests__/components/zicato.smoke.test.tsx b/frontend/src/__tests__/components/zicato.smoke.test.tsx new file mode 100644 index 0000000..af3b6c1 --- /dev/null +++ b/frontend/src/__tests__/components/zicato.smoke.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SessionStore } from '../../gantt/index'; + +// Keep the smoke test hermetic: no real RPC, no global key handlers, no +// session-list poll. The console must render against an empty store without +// throwing — that is the scaffold gate. +const emptyStore = new SessionStore(); + +vi.mock('../../rpc/hooks', () => ({ + useSessionWatch: () => ({ + store: emptyStore, + connected: false, + initialBurstComplete: false, + error: null, + sessionStatus: 'UNKNOWN', + lastEventAtMs: 0, + }), + getSessionStore: () => undefined, + useSendControl: () => async () => {}, +})); +vi.mock('../../rpc/SessionsSyncer', () => ({ SessionsSyncer: () => null })); +vi.mock('../../components/SessionPicker/SessionPicker', () => ({ + SessionPicker: () => null, +})); +vi.mock('../../lib/shortcuts', () => ({ useGlobalShortcuts: () => {} })); + +import { ZicatoConsole } from '../../components/zicato/ZicatoConsole'; +import { + EMPTY_SESSION, + toKindToken, + toStatusToken, + colorVar, + type ZAgent, +} from '../../components/zicato/adapter'; + +describe('zicato console scaffold', () => { + it('renders ZicatoConsole against an empty store without throwing', () => { + render(); + expect(screen.getByTestId('zicato-console')).toBeTruthy(); + }); + + it('mounts the md3 toggle so the user can switch back', () => { + render(); + expect(screen.getByTestId('ui-mode-toggle-z')).toBeTruthy(); + }); + + it('renders both rail views (gantt + instruments)', () => { + render(); + // Two rail items, each labelled by its view name. + expect(screen.getByText('gantt')).toBeTruthy(); + expect(screen.getByText('instruments')).toBeTruthy(); + }); + + it('exposes a safe EMPTY_SESSION shape (empty arrays, empty:true)', () => { + expect(EMPTY_SESSION.empty).toBe(true); + expect(EMPTY_SESSION.spans).toEqual([]); + expect(EMPTY_SESSION.agents).toEqual([]); + expect(EMPTY_SESSION.edges).toEqual([]); + expect(EMPTY_SESSION.transfers).toEqual([]); + expect(EMPTY_SESSION.ladder).toEqual([]); + expect(EMPTY_SESSION.ctx).toEqual([]); + expect(EMPTY_SESSION.judges).toEqual({}); + expect(EMPTY_SESSION.ticks).toEqual({}); + expect(EMPTY_SESSION.plan.planId).toBeNull(); + expect(EMPTY_SESSION.delegation).toBeNull(); + expect(EMPTY_SESSION.T).toBe(30); + expect(EMPTY_SESSION.now).toBe(0); + }); + + it('normalizes kind + status tokens', () => { + expect(toKindToken('LLM_CALL')).toBe('llm-call'); + expect(toKindToken('WAIT_FOR_HUMAN')).toBe('wait-for-human'); + expect(toStatusToken('AWAITING_HUMAN')).toBe('awaiting'); + expect(toStatusToken('RUNNING')).toBe('running'); + }); + + it('maps agents to the --hg-agent-* token ramp', () => { + const a: ZAgent = { id: 'c:coder', label: 'coder', ordinal: 1, synthetic: null }; + const user: ZAgent = { id: '__user__', label: 'user', ordinal: 0, synthetic: 'user' }; + const gf: ZAgent = { + id: '__goldfive__', + label: 'goldfive', + ordinal: 0, + synthetic: 'goldfive', + }; + expect(colorVar(a)).toBe('var(--hg-agent-1)'); + expect(colorVar(user)).toBe('var(--hg-agent-user)'); + expect(colorVar(gf)).toBe('var(--hg-agent-goldfive)'); + }); +}); diff --git a/frontend/src/components/shell/AppBar.tsx b/frontend/src/components/shell/AppBar.tsx index 1bee61d..136f9e9 100644 --- a/frontend/src/components/shell/AppBar.tsx +++ b/frontend/src/components/shell/AppBar.tsx @@ -24,6 +24,7 @@ export function AppBar() { const toggleRail = useUiStore((s) => s.toggleNavRail); const setNavSection = useUiStore((s) => s.setNavSection); const toggleLegend = useUiStore((s) => s.toggleLegend); + const setUiMode = useUiStore((s) => s.setUiMode); const sessionId = useUiStore((s) => s.currentSessionId); const rpcSessions = useSessionsStore((s) => s.sessions); const rpcError = useSessionsStore((s) => s.error); @@ -69,6 +70,15 @@ export function AppBar() { ⌘K
+ +
+ {ZICATO_THEME_IDS.map((id) => ( + + ))} +
+
+ ); +} + +// ── Topbar (port of topbarHTML 623-633 + md3 toggle) ───────────────────────── + +function ZicatoTopbar({ + z, + onToMd3, +}: { + z: ZSession; + onToMd3: () => void; +}) { + const openPicker = useUiStore((s) => s.openSessionPicker); + const inFlight = z.spans.filter( + (x) => x.status === 'running' || x.status === 'awaiting', + ).length; + const running = z.status === 'live'; + return ( +
+ + + + + + ~/ws + ▸ + + ⌘K + + + + + {running ? 'streaming' : 'connected'} + + + {z.agents.length} agents + · {inFlight} in flight + + + + +
+ ); +} + +// ── Strip (port of stripHTML 635-639) ──────────────────────────────────────── + +function statusPill(z: ZSession) { + if (z.status === 'live') return ● live; + if (z.status === 'done') return ✓ done; + return ✕ failed; +} + +function ZicatoStrip({ z }: { z: ZSession }) { + const run = + z.spans.find((x) => x.status === 'running') ?? + z.spans.find((x) => x.status === 'awaiting'); + return ( +
+ goal + {z.goal || '—'} + {run && ( + <> + + {bareAgentName(run.agent) || run.agent} + + + {run.status === 'awaiting' ? '◷ awaiting human' : `● ${run.label}`} + + + )} + + {statusPill(z)} +
+ ); +} + +// ── Rail (port of railHTML 737-741) ────────────────────────────────────────── + +type ZicatoView = 'gantt' | 'instruments'; + +function ZicatoRail({ + view, + onView, +}: { + view: ZicatoView; + onView: (v: ZicatoView) => void; +}) { + const items: [ZicatoView, string][] = [ + ['gantt', '▤'], + ['instruments', '⧉'], + ]; + return ( + + ); +} + +// ── Transport (reuse of TransportBar wiring 6-66, restyled in-language) ────── + +function ZicatoTransport() { + const liveFollow = useUiStore((s) => s.liveFollow); + const jumpToLive = useUiStore((s) => s.jumpToLive); + const agentsPaused = useUiStore((s) => s.agentsPaused); + const setAgentsPaused = useUiStore((s) => s.setAgentsPaused); + const sessionId = useUiStore((s) => s.currentSessionId); + const send = useSendControl(); + const watch = useSessionWatch(sessionId); + const agents = watch.store.agents.list; + + const [tick, setTick] = useState(0); + const [prevSessionId, setPrevSessionId] = useState(sessionId); + if (prevSessionId !== sessionId) { + setPrevSessionId(sessionId); + setTick(0); + } + useEffect(() => { + if (!sessionId) return; + const i = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(i); + }, [sessionId]); + const elapsedSeconds = sessionId ? tick : 0; + + const handlePause = async () => { + if (!sessionId) return; + setAgentsPaused(true); + for (const a of agents) { + await send({ sessionId, agentId: a.id, kind: 'PAUSE' }).catch(() => {}); + } + }; + const handleResume = async () => { + if (!sessionId) return; + setAgentsPaused(false); + jumpToLive(); + for (const a of agents) { + await send({ sessionId, agentId: a.id, kind: 'RESUME' }).catch(() => {}); + } + }; + + return ( +
+ + + {agentsPaused ? ( + + ) : ( + + )} + + + + {agentsPaused ? ( + paused + ) : liveFollow && sessionId ? ( + + + ) : ( + viewport locked + )} + + {sessionId ? formatDuration(elapsedSeconds) : '—'} + + + zoom: fit +
+ ); +} + +// ── Inspector (mirror of drawerHTML 652-681, zicato-styled) ────────────────── + +function escapeJson(s: string): string { + return s.replace(/"/g, '\\"'); +} + +function ZicatoInspector() { + const drawerOpen = useUiStore((s) => s.drawerOpen); + const selectedSpanId = useUiStore((s) => s.selectedSpanId); + const closeDrawer = useUiStore((s) => s.closeDrawer); + const sessionId = useUiStore((s) => s.currentSessionId); + const store = getSessionStore(sessionId); + const [, bump] = useReducer((x: number) => x + 1, 0); + useEffect(() => { + if (!store) return; + return store.spans.subscribe(() => bump()); + }, [store]); + + const span = selectedSpanId ? store?.spans.get(selectedSpanId) : undefined; + + let body: ReactElement; + if (span) { + const status = toStatusToken(span.status); + const gf = gfClassForSpan(span); + const t0 = span.startMs / 1000; + const t1 = span.endMs != null ? span.endMs / 1000 : t0; + const statusClass = + status === 'failed' + ? 'bad' + : status === 'running' + ? 'accent' + : status === 'awaiting' + ? 'caution' + : 'good'; + const agentLabel = + actorDisplayLabel(span.agentId) ?? bareAgentName(span.agentId) ?? span.agentId; + body = ( + <> +
+ {span.kind} + {status} +
+
+
agent
+
{agentLabel}
+
label
+
{span.name}
+
start
+
{t0.toFixed(1)}s
+
end
+
{span.endMs != null ? `${t1.toFixed(1)}s` : '—'}
+
duration
+
{(t1 - t0).toFixed(1)}s
+ {gf && ( + <> +
gf category
+
{gf}
+ + )} +
+

+ payload +

+ + {'{ '} + "span_id" + : + "{escapeJson(span.id)}" + , + {'\n '} + "kind" + : + "{span.kind}" + , + {'\n '} + "status" + : + "{span.status}" + {'}'} + + + ); + } else { + body = ( +

+ click any span for its detail · Esc closes +

+ ); + } + + return ( +
+
+
+ {span ? span.name : 'session'} + +
+
{body}
+
+
+ ); +} + +// ── The console shell ──────────────────────────────────────────────────────── + +export function ZicatoConsole() { + useGlobalShortcuts(); // ⌘K / Esc / j-k — owned here (not inherited from Shell) + + const sessionId = useUiStore((s) => s.currentSessionId); + const setUiMode = useUiStore((s) => s.setUiMode); + const zicatoTheme = useUiStore((s) => s.zicatoTheme); + + // Keep the stream alive for all sub-views + inspector (single watch). + useSessionWatch(sessionId); + + const [view, setView] = useState('gantt'); // local; map-shell §6 + const z = useZicatoSession(sessionId); + + return ( +
+ + setUiMode('md3')} /> + +
+ +
+ {view === 'gantt' && } + {view === 'instruments' && } +
+ +
+ + +
+ ); +} diff --git a/frontend/src/components/zicato/adapter.ts b/frontend/src/components/zicato/adapter.ts new file mode 100644 index 0000000..862de20 --- /dev/null +++ b/frontend/src/components/zicato/adapter.ts @@ -0,0 +1,1147 @@ +// adapter.ts — the REAL-data adapter for the zicato console. +// +// Maps the live `SessionStore` (gantt/index.ts) + the sessions list + plan +// hooks + annotations into the per-figure input bundle (`ZSession`) the zicato +// SVG renderers consume. NO study mock data. +// +// Time is converted ms → seconds here (the study renderers are in seconds). +// Every mapper has a documented graceful fallback so a figure never throws on +// absent data (see EMPTY_SESSION + the per-mapper notes). +// +// The single composing hook `useZicatoSession(sessionId)` is the only thing the +// views call; the pure mappers are exported so figure agents / unit tests can +// exercise them in isolation. The caller (ZicatoConsole) is responsible for +// holding the `useSessionWatch` that keeps the stream alive — this hook reads +// the store via `getSessionStore`. + +import { useEffect, useMemo, useReducer } from 'react'; +import { getSessionStore } from '../../rpc/hooks'; +import { useSessionsStore } from '../../state/sessionsStore'; +import { useUiStore } from '../../state/uiStore'; +import { + usePlanHistory, + useCumulativePlan, + useSupersedesMap, +} from '../../state/planHistoryHooks'; +import type { + CumulativePlan, + PlanRevisionRecord, + SupersessionLink, +} from '../../state/planHistoryStore'; +import { useAnnotationStore, type Annotation } from '../../state/annotationStore'; +import { deriveInterventionsFromStore } from '../../lib/interventions'; +import { bareAgentName, type SessionStore } from '../../gantt/index'; +import type { + Span, + SpanKind, + SpanStatus, + Task, +} from '../../gantt/types'; +import { + isSyntheticActor, + actorDisplayLabel, + USER_ACTOR_ID, + GOLDFIVE_ACTOR_ID, +} from '../../theme/agentColors'; + +// ── 3.1 Output types — the figure-input contract (frozen) ──────────────────── + +/** Lower-kebab span kind matching the `--hg-kind-*` token names. */ +export type ZKindToken = + | 'invocation' + | 'llm-call' + | 'tool-call' + | 'user-message' + | 'agent-message' + | 'transfer' + | 'wait-for-human' + | 'planned' + | 'custom'; + +/** Lower treatment keys (status → treatment, not hue). */ +export type ZStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'awaiting' + | 'planned'; + +/** `--hg-gf-*` category for goldfive spans, or null for non-goldfive spans. */ +export type ZGfClass = + | 'judge-on-task' + | 'judge-warning' + | 'judge-critical' + | 'judge-neutral' + | 'refine' + | 'plan' + | 'reflective' + | null; + +export type ZSessionStatus = 'live' | 'done' | 'failed'; + +/** One agent lane. ordinal → `--hg-agent-N` (1..8); synthetics → -user/-goldfive. */ +export interface ZAgent { + /** real agentId (':' | '__user__' | '__goldfive__'). */ + id: string; + /** bareAgentName / actorDisplayLabel. */ + label: string; + /** 1..8 stable lane index; synthetic actors get 0 (resolved by colorVar). */ + ordinal: number; + synthetic: 'user' | 'goldfive' | null; +} + +/** One span bar (study `s.spans[]` shape, real-data-backed). */ +export interface ZSpan { + id: string; + /** agent = ZAgent.id */ + agent: string; + kind: ZKindToken; + status: ZStatus; + gf: ZGfClass; + /** seconds, session-relative */ + t0: number; + /** seconds, session-relative; equals `now` for running spans */ + t1: number; + /** span.name */ + label: string; +} + +/** Hand-off chord. seconds, agent ids. */ +export interface ZTransfer { + t: number; + from: string; + to: string; +} + +/** Optional single delegation block. */ +export interface ZDelegation { + from: string; + to: string; + t0: number; + t1: number; + tokens: number | null; + verdict: string | null; +} + +export type ZArrowKind = 'transfer' | 'delegation' | 'return'; + +/** Derived gantt/sequence edge. */ +export interface ZEdge { + t: number; + from: string; + to: string; + kind: ZArrowKind; +} + +// Plan DAG + reel (study p.* shape). +export interface ZPlanNode { + tid: string; + title: string; + x: number; + y: number; + st: 'done' | 'running' | 'pending' | 'added' | 'ghost'; +} +export interface ZPlanEdge { + from: string; + to: string; + crit: boolean; +} +export interface ZStratum { + v: number; + has: Set; + added: Set; + /** revision trigger label ('' if none) */ + seam: string; + live: boolean; +} +export interface ZPlan { + nodes: ZPlanNode[]; + edges: ZPlanEdge[]; + /** newest-first (study convention) */ + strata: ZStratum[]; + /** tasks-remaining per version, oldest→newest */ + rem: number[]; + planId: string | null; +} + +// Seismograph / ladder. +/** agentId → [[t_sec, driftValue], …] */ +export type ZJudges = Record; +/** agentId → [[t_sec, label], …] */ +export type ZTicks = Record; +/** [[t_sec, rungIdx 0..3], …] */ +export type ZLadder = [number, number][]; +/** [[t_sec, fraction 0..1], …] */ +export type ZCtx = [number, number][]; + +/** Fingerprint Lissajous params (deterministic derivation). */ +export interface ZFingerprint { + fx: number; + fy: number; + px: number; + d: number; + corrAt: number | null; + grow: boolean; + T: number; +} + +/** The full per-session bundle the views destructure. */ +export interface ZSession { + id: string; + /** goal = SessionSummary.title / Session.title (no separate goal field). */ + goal: string; + status: ZSessionStatus; + /** seconds */ + T: number; + /** seconds */ + now: number; + /** lane order (join-time, synthetic actors included) */ + agents: ZAgent[]; + spans: ZSpan[]; + transfers: ZTransfer[]; + delegation: ZDelegation | null; + /** derived transfer/delegation/return edges (GraphView algo) */ + edges: ZEdge[]; + judges: ZJudges; + ticks: ZTicks; + ladder: ZLadder; + ctx: ZCtx; + plan: ZPlan; + fp: ZFingerprint; + /** true when no store/session → render placeholders */ + empty: boolean; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const EMPTY_PLAN: ZPlan = { + nodes: [], + edges: [], + strata: [], + rem: [], + planId: null, +}; + +/** A tight default Lissajous knot (on-plan look) for sessions with no data. */ +const DEFAULT_FP: ZFingerprint = { + fx: 2, + fy: 3, + px: 0, + d: 0.05, + corrAt: null, + grow: false, + T: 30, +}; + +/** The placeholder bundle returned when no store/session is available. */ +export const EMPTY_SESSION: ZSession = { + id: '', + goal: '', + status: 'live', + T: 30, + now: 0, + agents: [], + spans: [], + transfers: [], + delegation: null, + edges: [], + judges: {}, + ticks: {}, + ladder: [], + ctx: [], + plan: EMPTY_PLAN, + fp: { ...DEFAULT_FP }, + empty: true, +}; + +/** Stable empty annotation list — keeps the zustand selector reference cached. */ +const EMPTY_ANNOTATIONS: readonly Annotation[] = Object.freeze([]); + +// DAG layout constants (study dagSVG). +const DAG_LX = [80, 300, 520, 740] as const; +const DAG_LY = (v: number): number => 92 + v * 44; + +// ── 4-adjacent helper: severity → drift value (also re-exported from svgUtils) ─ + +/** Map a goldfive drift severity string to a seismograph drift value. */ +export function severityToValue(sev: string): number { + switch (sev) { + case 'critical': + return 14; + case 'warning': + return 9; + case 'info': + return 4; + default: + return 2; + } +} + +// ── 3.3 Pure mappers ───────────────────────────────────────────────────────── + +/** UPPER_SNAKE SpanKind → lower-kebab kind token. 'LLM_CALL' → 'llm-call'. */ +export function toKindToken(k: SpanKind): ZKindToken { + switch (k) { + case 'INVOCATION': + return 'invocation'; + case 'LLM_CALL': + return 'llm-call'; + case 'TOOL_CALL': + return 'tool-call'; + case 'USER_MESSAGE': + return 'user-message'; + case 'AGENT_MESSAGE': + return 'agent-message'; + case 'TRANSFER': + return 'transfer'; + case 'WAIT_FOR_HUMAN': + return 'wait-for-human'; + case 'PLANNED': + return 'planned'; + case 'CUSTOM': + return 'custom'; + default: + return 'custom'; + } +} + +/** UPPER_SNAKE SpanStatus → lower treatment key. 'AWAITING_HUMAN' → 'awaiting'. */ +export function toStatusToken(s: SpanStatus): ZStatus { + switch (s) { + case 'PENDING': + return 'pending'; + case 'RUNNING': + return 'running'; + case 'COMPLETED': + return 'completed'; + case 'FAILED': + return 'failed'; + case 'CANCELLED': + return 'cancelled'; + case 'AWAITING_HUMAN': + return 'awaiting'; + default: + return 'completed'; + } +} + +/** + * Read a span's goldfive category from its attributes (if any). Looks for a + * string attribute under a goldfive-category key and maps it onto a `--hg-gf-*` + * class. Returns null for non-goldfive spans (the common case). + */ +export function gfClassForSpan(sp: Span): ZGfClass { + const candidates = [ + 'goldfive.category', + 'goldfive_category', + 'gf.category', + 'gf_category', + 'goldfive.class', + ]; + let raw = ''; + for (const key of candidates) { + const attr = sp.attributes[key]; + if (attr && attr.kind === 'string' && attr.value) { + raw = attr.value; + break; + } + } + if (!raw) return null; + const v = raw.toLowerCase().replace(/_/g, '-'); + switch (v) { + case 'judge-on-task': + case 'judge-warning': + case 'judge-critical': + case 'judge-neutral': + case 'refine': + case 'plan': + case 'reflective': + return v; + default: + return null; + } +} + +/** + * agents → lanes. Join-time order (store.agents.list). Each non-synthetic agent + * gets a stable ordinal in 1..8 (`(index % 8) + 1`); the user/goldfive synthetic + * actors are flagged so colorVar can route them to the -user/-goldfive tokens. + * The goldfive row is canonicalized via store.resolveGoldfiveActorId(). + */ +export function buildAgents(store: SessionStore): ZAgent[] { + const goldfiveId = store.resolveGoldfiveActorId(); + const out: ZAgent[] = []; + let nonSyntheticIdx = 0; + for (const a of store.agents.list) { + const synthetic = syntheticKind(a.id, goldfiveId); + if (synthetic) { + out.push({ + id: a.id, + label: actorDisplayLabel(a.id) ?? synthetic, + ordinal: 0, + synthetic, + }); + } else { + const ordinal = (nonSyntheticIdx % 8) + 1; + nonSyntheticIdx += 1; + out.push({ + id: a.id, + label: bareAgentName(a.id) || a.id, + ordinal, + synthetic: null, + }); + } + } + return out; +} + +function syntheticKind( + agentId: string, + goldfiveId: string, +): 'user' | 'goldfive' | null { + if (agentId === USER_ACTOR_ID) return 'user'; + if (agentId === GOLDFIVE_ACTOR_ID || agentId === goldfiveId) return 'goldfive'; + if (agentId.endsWith(':goldfive')) return 'goldfive'; + // Fall back to the synthetic-actor helper for any other synthetic ids. + const label = actorDisplayLabel(agentId); + if (label === 'user') return 'user'; + if (label === 'goldfive') return 'goldfive'; + return isSyntheticActor(agentId) ? 'goldfive' : null; +} + +/** + * spans → bars. Queries the full session range. t0 = startMs/1000; running spans + * (endMs == null) extend to `nowSec`. Replaced spans are kept (the renderer dims + * them via status/opacity). Fallback: no spans → []. + */ +export function buildSpans( + store: SessionStore, + _agents: ZAgent[], + nowSec: number, +): ZSpan[] { + void _agents; + const maxEnd = store.spans.maxEndMs(); + const spans = store.spans.queryRange(0, maxEnd + 1); + const out: ZSpan[] = []; + for (const sp of spans) { + const t0 = sp.startMs / 1000; + const t1 = sp.endMs != null ? sp.endMs / 1000 : nowSec; + out.push({ + id: sp.id, + agent: sp.agentId, + kind: toKindToken(sp.kind), + status: toStatusToken(sp.status), + gf: gfClassForSpan(sp), + t0, + t1: Math.max(t0, t1), + label: sp.name, + }); + } + out.sort((a, b) => a.t0 - b.t0); + return out; +} + +/** + * transfers → chords. Derived from the edge set (transfer-kind edges). Fallback: + * none derivable → []. + */ +export function buildTransfers(store: SessionStore): ZTransfer[] { + const out: ZTransfer[] = []; + for (const e of buildEdges(store)) { + if (e.kind === 'transfer') { + out.push({ t: e.t, from: e.from, to: e.to }); + } + } + return out; +} + +/** + * delegation → optional single block. Picks the first DelegationRecord and + * bounds it by the to-agent's first/last span when available. tokens/verdict are + * left null in cut 1 (deferred, no canonical attribute). Fallback: no record → null. + */ +export function buildDelegation(store: SessionStore): ZDelegation | null { + const recs = store.delegations.list(); + if (recs.length === 0) return null; + const d = recs[0]; + if (!d.fromAgentId || !d.toAgentId) return null; + const t0 = d.observedAtMs / 1000; + // Approximate the delegated span window from the to-agent's spans after t0. + const sub = store.spans + .queryAgent(d.toAgentId, d.observedAtMs, Number.POSITIVE_INFINITY) + .filter((s) => s.kind === 'INVOCATION'); + let t1 = t0; + for (const s of sub) { + const end = s.endMs != null ? s.endMs / 1000 : t0; + if (end > t1) t1 = end; + } + return { + from: d.fromAgentId, + to: d.toAgentId, + t0, + t1: Math.max(t0, t1), + tokens: null, + verdict: null, + }; +} + +/** + * edges → transfer/delegation/return. Ported from GraphView.computeSequence + * (map-data §5.6): Method1 (INVOCATION + INVOKED link), 1b (TRANSFER span carries + * the link), 2 (cross-agent parent), 3 (store.delegations.list), + return edges. + * Deduped on `→@round(ms/500)`. Times in seconds. Fallback: none → []. + */ +export function buildEdges(store: SessionStore): ZEdge[] { + const allSpans = [...store.spans.all()]; + const spanById = new Map(); + for (const s of allSpans) spanById.set(s.id, s); + + interface Raw { + fromId: string; + toId: string; + kind: 'transfer' | 'delegation'; + yMs: number; + } + const forward: Raw[] = []; + const covered = new Set(); + const keyFor = (from: string, to: string, ms: number): string => + `${from}→${to}@${Math.round(ms / 500)}`; + + // Method 1 — INVOCATION span with INVOKED link to a different agent. + for (const s of allSpans) { + if (s.kind !== 'INVOCATION') continue; + for (const link of s.links) { + if (link.relation !== 'INVOKED') continue; + if (!link.targetAgentId || link.targetAgentId === s.agentId) continue; + const anchorMs = s.startMs; + covered.add(keyFor(link.targetAgentId, s.agentId, anchorMs)); + forward.push({ + fromId: link.targetAgentId, + toId: s.agentId, + kind: 'transfer', + yMs: anchorMs, + }); + } + } + // Method 1b — a TRANSFER span itself carries the INVOKED link. + for (const s of allSpans) { + if (s.kind !== 'TRANSFER') continue; + for (const link of s.links) { + if (link.relation !== 'INVOKED') continue; + if (!link.targetAgentId || link.targetAgentId === s.agentId) continue; + const targetSpan = link.targetSpanId + ? spanById.get(link.targetSpanId) + : undefined; + const anchorMs = targetSpan ? targetSpan.startMs : s.startMs; + const k = keyFor(s.agentId, link.targetAgentId, anchorMs); + if (covered.has(k)) continue; + covered.add(k); + forward.push({ + fromId: s.agentId, + toId: link.targetAgentId, + kind: 'transfer', + yMs: anchorMs, + }); + } + } + // Method 2 — cross-agent INVOCATION parents (delegation). + for (const s of allSpans) { + if (s.kind !== 'INVOCATION') continue; + if (!s.parentSpanId) continue; + const parent = spanById.get(s.parentSpanId); + if (!parent || parent.agentId === s.agentId) continue; + const k = keyFor(parent.agentId, s.agentId, s.startMs); + if (covered.has(k)) continue; + covered.add(k); + forward.push({ + fromId: parent.agentId, + toId: s.agentId, + kind: 'delegation', + yMs: s.startMs, + }); + } + // Method 3 — goldfive DelegationObserved events. + for (const d of store.delegations.list()) { + if (!d.fromAgentId || !d.toAgentId) continue; + if (d.fromAgentId === d.toAgentId) continue; + const k = keyFor(d.fromAgentId, d.toAgentId, d.observedAtMs); + if (covered.has(k)) continue; + covered.add(k); + forward.push({ + fromId: d.fromAgentId, + toId: d.toAgentId, + kind: 'delegation', + yMs: d.observedAtMs, + }); + } + + // Return edges — for each finished INVOCATION, find the most recent forward + // arrow that targeted its agent before it started; emit a return at endMs. + const forwardByDest = new Map(); + for (const e of forward) { + const arr = forwardByDest.get(e.toId) ?? []; + arr.push(e); + forwardByDest.set(e.toId, arr); + } + const returns: Raw[] = []; + for (const s of allSpans) { + if (s.kind !== 'INVOCATION') continue; + if (s.endMs === null) continue; + const incoming = forwardByDest.get(s.agentId) ?? []; + const caller = incoming + .filter((e) => e.yMs <= s.startMs) + .sort((a, b) => b.yMs - a.yMs)[0]; + if (!caller) continue; + if (caller.fromId === s.agentId) continue; + returns.push({ + fromId: s.agentId, + toId: caller.fromId, + kind: 'delegation', + yMs: s.endMs, + }); + } + + const out: ZEdge[] = []; + for (const e of forward) { + out.push({ t: e.yMs / 1000, from: e.fromId, to: e.toId, kind: e.kind }); + } + for (const e of returns) { + out.push({ t: e.yMs / 1000, from: e.fromId, to: e.toId, kind: 'return' }); + } + out.sort((a, b) => a.t - b.t); + return out; +} + +/** + * judges → per-agent drift trace keyframes. From store.drifts.list(): push + * [recordedAtMs/1000, severityToValue(severity)] per drift, grouped by agentId + * (falling back to the goldfive lane when a drift has no agent). Fallback: no + * drift → {} (seismograph baseline only). + */ +export function buildJudges(store: SessionStore, agents: ZAgent[]): ZJudges { + void agents; + const goldfiveId = store.resolveGoldfiveActorId(); + const out: ZJudges = {}; + for (const d of store.drifts.list()) { + const agent = d.agentId || goldfiveId; + if (!agent) continue; + const arr = out[agent] ?? (out[agent] = []); + arr.push([d.recordedAtMs / 1000, severityToValue(d.severity)]); + } + for (const k of Object.keys(out)) out[k].sort((a, b) => a[0] - b[0]); + return out; +} + +/** + * ticks → intervention markers per agent. deriveInterventionsFromStore grouped + * by targetAgentId (fallback the goldfive lane), [atMs/1000, kind]. Fallback: no + * interventions → {}. + */ +export function buildTicks( + store: SessionStore, + annotations: readonly Annotation[], +): ZTicks { + const goldfiveId = store.resolveGoldfiveActorId() || GOLDFIVE_ACTOR_ID; + const rows = deriveInterventionsFromStore(store, annotations); + const out: ZTicks = {}; + for (const r of rows) { + const agent = r.targetAgentId || goldfiveId; + const arr = out[agent] ?? (out[agent] = []); + arr.push([r.atMs / 1000, r.kind || r.source]); + } + for (const k of Object.keys(out)) out[k].sort((a, b) => a[0] - b[0]); + return out; +} + +/** + * ladder → intervention-ladder dots. Maps each intervention row's kind/severity + * to a rung: nudge(0) / refine(1) / replan(2) / escalate(3). escalate when the + * severity is critical or the row came from a goldfive escalation. Fallback: no + * interventions → [] ("never left the ground"). + */ +export function buildLadder( + store: SessionStore, + annotations: readonly Annotation[], +): ZLadder { + const rows = deriveInterventionsFromStore(store, annotations); + const out: ZLadder = []; + for (const r of rows) { + out.push([r.atMs / 1000, rungForRow(r.source, r.kind, r.severity, r.outcome)]); + } + out.sort((a, b) => a[0] - b[0]); + return out; +} + +function rungForRow( + source: string, + kind: string, + severity: string, + outcome: string, +): number { + if (severity === 'critical') return 3; // escalate + const k = (kind || '').toLowerCase(); + if (k.includes('escalat')) return 3; + if (source === 'refine' || k.includes('refine')) return 1; + if ( + source === 'transition' || + k.includes('replan') || + k.includes('revis') || + outcome.startsWith('plan_revised') + ) { + return 2; + } + return 0; // nudge / steer / comment +} + +/** + * ctx → context-window utilisation curve for the busiest non-synthetic agent. + * store.contextSeries.forAgent(id) → [tMs/1000, tokens/limitTokens]. Fallback: no + * samples → []. + */ +export function buildCtx(store: SessionStore, agents: ZAgent[]): ZCtx { + // Pick the busiest non-synthetic agent (most spans). + let bestId: string | null = null; + let bestCount = -1; + for (const a of agents) { + if (a.synthetic) continue; + const count = store.spans.queryAgent( + a.id, + 0, + Number.POSITIVE_INFINITY, + ).length; + if (count > bestCount) { + bestCount = count; + bestId = a.id; + } + } + if (!bestId) return []; + const samples = store.contextSeries.forAgent(bestId); + const out: ZCtx = []; + for (const s of samples) { + if (s.limitTokens <= 0) continue; + out.push([s.tMs / 1000, Math.min(1, s.tokens / s.limitTokens)]); + } + return out; +} + +/** + * plan → DAG + reel. Lays cumulative.tasks into 4 cols by topo-depth (LX/LY), + * derives node state from task.status + revision meta, edges from cumulative.edges + * with a longest-path "critical" heuristic, and strata (newest-first) + rem from + * the revision history. Fallback: no planId → empty ZPlan. + */ +export function buildPlan( + history: readonly PlanRevisionRecord[], + cumulative: CumulativePlan | null, + supersedes: Map, + selectedRevision: number | null, +): ZPlan { + if (!cumulative || !cumulative.id) return { ...EMPTY_PLAN }; + const planId = cumulative.id; + const tasks = cumulative.tasks; + const meta = cumulative.taskRevisionMeta; + // The newest revision index — from the history (CumulativePlan extends + // TaskPlan and has no explicit latestRevisionIndex field). + const latestRev = history.reduce((m, r) => Math.max(m, r.revision), 0); + const selRev = selectedRevision; + + // ── topological depth for column placement ─────────────────────────────── + const depth = topoDepths( + tasks.map((t) => t.id), + cumulative.edges, + ); + const perCol: Record = {}; + const nodes: ZPlanNode[] = tasks.map((t) => { + const d = Math.min(DAG_LX.length - 1, depth.get(t.id) ?? 0); + const rowInCol = perCol[d] ?? 0; + perCol[d] = rowInCol + 1; + return { + tid: t.id, + title: t.title || t.id, + x: DAG_LX[d], + y: DAG_LY(rowInCol), + st: nodeState(t, meta.get(t.id)?.isSuperseded ?? false, supersedes), + }; + }); + + // ── critical-path heuristic: longest dependency chain ──────────────────── + const critEdges = criticalEdgeSet( + tasks.map((t) => t.id), + cumulative.edges, + ); + const edges: ZPlanEdge[] = cumulative.edges.map((e) => ({ + from: e.fromTaskId, + to: e.toTaskId, + crit: critEdges.has(`${e.fromTaskId}→${e.toTaskId}`), + })); + + // ── strata (newest-first) + rem (oldest→newest) ────────────────────────── + const strata: ZStratum[] = []; + const rem: number[] = []; + const ordered = [...history].sort((a, b) => a.revision - b.revision); + let prevIds = new Set(); + for (const rec of ordered) { + const ids = new Set(rec.plan.tasks.map((t) => t.id)); + const added = new Set(); + for (const id of ids) if (!prevIds.has(id)) added.add(id); + strata.push({ + v: rec.revision, + has: ids, + added, + seam: seamLabel(rec.kind), + live: rec.revision === latestRev, + }); + rem.push( + rec.plan.tasks.filter((t) => !isTerminalTaskStatus(t.status)).length, + ); + prevIds = ids; + } + strata.reverse(); // newest-first (study convention) + + // Tag added nodes for the selected revision so DagZ can render added/ghost. + if (selRev != null) { + const stratum = strata.find((s) => s.v === selRev); + if (stratum) { + for (const n of nodes) { + if (stratum.added.has(n.tid) && n.st !== 'done') n.st = 'added'; + else if (!stratum.has.has(n.tid)) n.st = 'ghost'; + } + } + } + + return { nodes, edges, strata, rem, planId }; +} + +function nodeState( + task: Task, + isSuperseded: boolean, + supersedes: Map, +): ZPlanNode['st'] { + if (isSuperseded || supersedes.has(task.id)) return 'ghost'; + switch (task.status) { + case 'COMPLETED': + return 'done'; + case 'RUNNING': + return 'running'; + case 'FAILED': + case 'CANCELLED': + return 'ghost'; + default: + return 'pending'; + } +} + +function isTerminalTaskStatus(status: string): boolean { + return ( + status === 'COMPLETED' || status === 'FAILED' || status === 'CANCELLED' + ); +} + +function seamLabel(kind: string): string { + const k = (kind || '').toLowerCase(); + if (!k) return ''; + if (k.includes('off_topic') || k.includes('off-topic')) return 'goal-drift'; + if (k.includes('scope')) return 'scope-creep'; + if (k.includes('tool')) return 'tool-misuse'; + if (k.includes('user_steer') || k.includes('user-steer')) return 'user-steer'; + return k.replace(/_/g, '-'); +} + +/** Topological depth (0-based) for each node id over the given edges. */ +function topoDepths( + ids: string[], + edges: readonly { fromTaskId: string; toTaskId: string }[], +): Map { + const adj = new Map(); + const indeg = new Map(); + for (const id of ids) { + adj.set(id, []); + indeg.set(id, 0); + } + for (const e of edges) { + if (!adj.has(e.fromTaskId) || !indeg.has(e.toTaskId)) continue; + adj.get(e.fromTaskId)!.push(e.toTaskId); + indeg.set(e.toTaskId, (indeg.get(e.toTaskId) ?? 0) + 1); + } + const depth = new Map(); + const queue: string[] = []; + for (const id of ids) { + if ((indeg.get(id) ?? 0) === 0) { + depth.set(id, 0); + queue.push(id); + } + } + while (queue.length) { + const cur = queue.shift()!; + const cd = depth.get(cur) ?? 0; + for (const next of adj.get(cur) ?? []) { + const nd = cd + 1; + const indegNext = (indeg.get(next) ?? 0) - 1; + indeg.set(next, indegNext); + if ((depth.get(next) ?? -1) < nd) depth.set(next, nd); + if (indegNext === 0) queue.push(next); + } + } + for (const id of ids) if (!depth.has(id)) depth.set(id, 0); + return depth; +} + +/** + * The set of edges on the longest path through the DAG (the "critical path"). + * Returns a set of `→` keys. A pragmatic heuristic matching the study + * dagSVG spine. + */ +function criticalEdgeSet( + ids: string[], + edges: readonly { fromTaskId: string; toTaskId: string }[], +): Set { + const adj = new Map(); + for (const id of ids) adj.set(id, []); + for (const e of edges) { + if (adj.has(e.fromTaskId)) adj.get(e.fromTaskId)!.push(e.toTaskId); + } + const depth = topoDepths(ids, edges); + // best[id] = [length, predecessor] of the longest path ending at id. + const best = new Map(); + const order = [...ids].sort( + (a, b) => (depth.get(a) ?? 0) - (depth.get(b) ?? 0), + ); + for (const id of order) best.set(id, { len: 0, prev: null }); + let endId: string | null = null; + let endLen = -1; + for (const id of order) { + const cur = best.get(id)!; + for (const next of adj.get(id) ?? []) { + const cand = cur.len + 1; + const nb = best.get(next); + if (nb && cand > nb.len) { + best.set(next, { len: cand, prev: id }); + } + } + if (cur.len > endLen) { + endLen = cur.len; + endId = id; + } + } + const out = new Set(); + let walk = endId; + while (walk) { + const node = best.get(walk); + if (!node || node.prev == null) break; + out.add(`${node.prev}→${walk}`); + walk = node.prev; + } + return out; +} + +/** + * fingerprint → deterministic Lissajous params (no Math.random). Base fx=2, + * fy=3, px=0. Damping `d` rises with on-plan-ness (fewer revisions + more + * completed tasks → tighter knot). grow=true when revisions keep adding scope. + * corrAt is the first revision time (a correction kink) when there is exactly one + * steer. Fallback: no revisions → tight default Lissajous. (Documented first + * pass — the ONE figure with no direct real source.) + */ +export function deriveFingerprint( + history: readonly PlanRevisionRecord[], + cumulative: CumulativePlan | null, + status: ZSessionStatus, + T: number, +): ZFingerprint { + void status; + const revisions = Math.max(0, history.length - 1); // beyond the initial plan + const tasks = cumulative?.tasks ?? []; + const total = tasks.length; + const completed = tasks.filter((t) => t.status === 'COMPLETED').length; + const completion = total > 0 ? completed / total : 0; + + // More revisions / lower completion → looser knot (smaller damping). + // Few revisions + high completion → tighter (higher damping). + const base = 0.02; + const d = Math.max( + 0.01, + base + 0.06 * completion - 0.018 * Math.min(revisions, 4), + ); + + // Diverging: scope grows monotonically across revisions. + let grow = false; + if (history.length >= 2) { + const sizes = [...history] + .sort((a, b) => a.revision - b.revision) + .map((r) => r.plan.tasks.length); + grow = sizes.every((s, i) => i === 0 || s >= sizes[i - 1]) && + sizes[sizes.length - 1] > sizes[0]; + } + + // Exactly one steer → a single correction kink at its revision time. + let corrAt: number | null = null; + if (revisions === 1) { + const steer = [...history] + .sort((a, b) => a.revision - b.revision) + .find((r) => r.revision > 0); + if (steer) { + const tSec = steer.plan.createdAtMs / 1000; + corrAt = T > 0 ? Math.max(0, Math.min(T, tSec)) : tSec; + } + } + + return { fx: 2, fy: 3, px: 0, d, corrAt, grow, T: T > 0 ? T : 30 }; +} + +/** synthetic → `--hg-agent-user`/`-goldfive`; else `--hg-agent-`. */ +export function colorVar(a: ZAgent): string { + if (a.synthetic === 'user') return 'var(--hg-agent-user)'; + if (a.synthetic === 'goldfive') return 'var(--hg-agent-goldfive)'; + const ord = a.ordinal >= 1 && a.ordinal <= 8 ? a.ordinal : 1; + return `var(--hg-agent-${ord})`; +} + +// ── 3.2 / 3.4 The composing hook ───────────────────────────────────────────── + +/** + * The per-session bundle for the zicato views. Reads the live SessionStore via + * getSessionStore (the caller holds the watch), subscribes to the registry + * channels for reactivity, and composes the figure inputs inside a useMemo. + * + * Time derivation (map-data §1.4 gotcha — do NOT trust store.nowMs): + * T = store.spans.maxEndMs() / 1000 + * now = max(T, (Date.now() - wallClockStartMs) / 1000), clamped ≥ 0. + */ +export function useZicatoSession(sessionId: string | null): ZSession { + const store = getSessionStore(sessionId); + + // Reactivity + a captured wall-clock timestamp. We snapshot `Date.now()` in + // the reducer / lazy initializer (NOT in render), so the memo derives `now` + // purely from `tick.nowMs`. Every `bump()` (registry change OR the 1s timer) + // writes a fresh nowMs, which drives both reactivity and the advancing + // play-head for running spans. + const [tick, bump] = useReducer( + (s: { n: number; nowMs: number }) => ({ n: s.n + 1, nowMs: Date.now() }), + 0, + (): { n: number; nowMs: number } => ({ n: 0, nowMs: Date.now() }), + ); + useEffect(() => { + if (!store) return; + const uns = [ + store.spans.subscribe(() => bump()), + store.agents.subscribe(() => bump()), + store.tasks.subscribe(() => bump()), + store.drifts.subscribe(() => bump()), + store.delegations.subscribe(() => bump()), + store.contextSeries.subscribe(() => bump()), + ]; + const timer = setInterval(() => bump(), 1000); + return () => { + clearInterval(timer); + for (const un of uns) un(); + }; + }, [store]); + + // Read the per-session annotation list. Falls back to a STABLE empty array + // (EMPTY_ANNOTATIONS) so the zustand selector returns a cached reference when + // there are none — a fresh `[]` each render would trip useSyncExternalStore's + // infinite-loop guard. + const annotations = useAnnotationStore((s) => + sessionId ? s.bySession.get(sessionId) ?? EMPTY_ANNOTATIONS : EMPTY_ANNOTATIONS, + ); + + // Session goal (title) from the sessions list, AppBar-style fallback. + const sessions = useSessionsStore((s) => s.sessions); + const goal = useMemo(() => { + if (!sessionId) return ''; + return sessions.find((s) => s.id === sessionId)?.title ?? ''; + }, [sessions, sessionId]); + + // Plan inputs are hook-fed (frozen plan hooks; safe with null planId). + const trajectoryPlanId = useUiStore((s) => s.trajectorySelectedPlanId); + const planId = + trajectoryPlanId ?? store?.tasks.listPlans()[0]?.id ?? null; + const history = usePlanHistory(sessionId, planId); + const cumulative = useCumulativePlan(sessionId, planId); + const supersedes = useSupersedesMap(sessionId, planId); + const selectedRevision = useUiStore((s) => s.selectedRevision); + + return useMemo(() => { + if (!store) return EMPTY_SESSION; + const maxEndMs = store.spans.maxEndMs(); + const spanCount = [...store.spans.all()].length; + if (spanCount === 0 && store.agents.list.length === 0) { + // Graceful empty: no observable data yet. + const fp = deriveFingerprint(history, cumulative, 'live', 30); + return { ...EMPTY_SESSION, id: sessionId ?? '', goal, fp }; + } + + const T = maxEndMs / 1000; + const wallNowSec = (tick.nowMs - store.wallClockStartMs) / 1000; + const now = Math.max(0, Math.max(T, wallNowSec)); + + const status = deriveStatus(store); + const agents = buildAgents(store); + const spans = buildSpans(store, agents, now); + const edges = buildEdges(store); + const transfers: ZTransfer[] = edges + .filter((e) => e.kind === 'transfer') + .map((e) => ({ t: e.t, from: e.from, to: e.to })); + const delegation = buildDelegation(store); + const judges = buildJudges(store, agents); + const ticks = buildTicks(store, annotations); + const ladder = buildLadder(store, annotations); + const ctx = buildCtx(store, agents); + const plan = buildPlan(history, cumulative, supersedes, selectedRevision); + const fp = deriveFingerprint(history, cumulative, status, T || 30); + + return { + id: sessionId ?? '', + goal, + status, + T: T > 0 ? T : 30, + now, + agents, + spans, + transfers, + delegation, + edges, + judges, + ticks, + ladder, + ctx, + plan, + fp, + empty: false, + }; + // tick.nowMs is included so each registry subscribe() bump + the 1s timer + // (both write a fresh nowMs) recompute the bundle and advance `now`. + }, [ + store, + sessionId, + tick.nowMs, + annotations, + goal, + history, + cumulative, + supersedes, + selectedRevision, + ]); +} + +/** + * Map the live session into the study's `'live' | 'done' | 'failed'` status. + * A session with any failed (non-replaced) span and no running spans reads as + * failed; once everything is terminal it reads done; otherwise live. + */ +function deriveStatus(store: SessionStore): ZSessionStatus { + let anyRunning = false; + let anyFailed = false; + let anyOpen = false; + for (const s of store.spans.all()) { + if (s.status === 'RUNNING') anyRunning = true; + if (s.status === 'FAILED' && !s.replaced) anyFailed = true; + if (s.status === 'PENDING' || s.status === 'AWAITING_HUMAN') anyOpen = true; + if (s.endMs === null) anyOpen = true; + } + if (anyRunning || anyOpen) return 'live'; + if (anyFailed) return 'failed'; + return 'done'; +} diff --git a/frontend/src/components/zicato/svgUtils.ts b/frontend/src/components/zicato/svgUtils.ts new file mode 100644 index 0000000..5571320 --- /dev/null +++ b/frontend/src/components/zicato/svgUtils.ts @@ -0,0 +1,140 @@ +// svgUtils.ts — shared pure helpers for the zicato SVG renderers. Ported from +// compose.html (KIND/lerpKeys/lcg 161-176, brand 149-157, trackGeom 448-449, +// judgeBeats 171-176). No React, no data fetching — just geometry + color math +// the figure components import. `colorVar`/`severityToValue` are owned by +// adapter.ts and re-exported here so there is exactly one definition of each. + +import type { ZKindToken, ZGfClass, ZSpan, ZJudges } from './adapter'; +import { colorVar, severityToValue } from './adapter'; + +export { colorVar, severityToValue }; + +/** KIND = hue. `'llm-call'` → `var(--hg-kind-llm-call)`. (compose.html:161) */ +export const KIND = (k: ZKindToken): string => `var(--hg-kind-${k})`; + +/** `--hg-gf-*` for a goldfive category. */ +export function gfVar(gf: Exclude): string { + return `var(--hg-gf-${gf})`; +} + +/** + * The fill a span bar takes (KIND-first encoding): + * failed → --bad, goldfive → gfVar, planned → CSS-driven (KIND fallback), + * else the kind hue. + * (compose.html ganttSVG fill decision, 85.) + */ +export function statusFill(sp: ZSpan): string { + if (sp.status === 'failed') return 'var(--bad)'; + if (sp.gf) return gfVar(sp.gf); + return KIND(sp.kind); +} + +/** + * Piecewise-linear interpolation over `[[t, v], …]` keyframes. (compose.html + * 162-164.) Clamps to the endpoints outside the keyframe range. + */ +export function lerpKeys(keys: [number, number][], t: number): number { + if (keys.length === 0) return 0; + if (t <= keys[0][0]) return keys[0][1]; + for (let i = 1; i < keys.length; i++) { + if (t <= keys[i][0]) { + const [t0, v0] = keys[i - 1]; + const [t1, v1] = keys[i]; + if (t1 === t0) return v1; + return v0 + ((v1 - v0) * (t - t0)) / (t1 - t0); + } + } + return keys[keys.length - 1][1]; +} + +/** + * Deterministic LCG PRNG seeded by `seed`. Returns a `() => number` in [0,1). + * Used for the seismograph's drift jitter so the trace is stable across renders + * (NEVER Math.random in render). (compose.html:165.) + */ +export function lcg(seed: number): () => number { + let s = seed >>> 0; + return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); +} + +/** + * Time→x scale shared by the seismograph + ladder so a reading at t lines up + * vertically. (compose.html trackGeom 448-449.) `padL` defaults narrow for tiny + * widths. Callers may override padL/padR. + */ +export function timeScale( + T: number, + W: number, + padL = W < 400 ? 40 : 76, + padR = 14, +): { padL: number; padR: number; X: (t: number) => number } { + const denom = T > 0 ? T : 1; + return { + padL, + padR, + X: (t: number) => padL + ((W - padL - padR) * t) / denom, + }; +} + +/** + * A stable, DOM-safe id derived from a seed string. Used for per-instance + * `` gradient ids (ChordZ) so multiple chords on one page don't collide. + */ +export function uniqueId(seed: string): string { + let h = 2166136261 >>> 0; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return `zk-${h.toString(36)}`; +} + +/** + * The harmonograf α-mark path 'd': ONE period of a 2:3 Lissajous mirrored about + * the vertical axis (reads as a lowercase α). (compose.html hgAlphaPath 149-152.) + * Geometry only — BrandMark renders the JSX around it. + */ +export function hgAlphaPath(cx: number, cy: number, A: number, B: number): string { + const pts: string[] = []; + for (let i = 0; i <= 240; i++) { + const t = (2 * Math.PI * i) / 240; + pts.push( + `${(cx - A * Math.cos(2 * t)).toFixed(2)},${( + cy + + B * Math.sin(3 * t) + ).toFixed(2)}`, + ); + } + return `M${pts.join(' L')} Z`; +} + +/** + * The study's default seismograph lanes. The adapter overrides this with the + * real busy lanes when available; this is the empty-data fallback. + */ +export const SEISMO_LANES_DEFAULT = ['coder', 'reviewer', 'planner'] as const; + +/** + * The judge heartbeat beats DERIVED from the drift the judges watch, so every + * firing mark sits above the drift reading that earned it. (compose.html + * judgeBeats 171-176.) TOL=8, step 2.6s; ok / warn(▲) / crit(✕). + */ +export function judgeBeats( + judges: ZJudges, + lanes: string[], + T: number, + now: number, +): [number, 'ok' | 'warn' | 'crit'][] { + const TOL = 8; + const beats: [number, 'ok' | 'warn' | 'crit'][] = []; + const end = Math.min(now, T); + for (let t = 2; t <= end + 0.001; t += 2.6) { + let d = 0; + for (const a of lanes) { + const keys = judges[a]; + if (keys && keys.length) d = Math.max(d, lerpKeys(keys, t)); + } + beats.push([t, d >= TOL * 1.5 ? 'crit' : d >= TOL ? 'warn' : 'ok']); + } + return beats; +} diff --git a/frontend/src/components/zicato/zicato-tokens.css b/frontend/src/components/zicato/zicato-tokens.css new file mode 100644 index 0000000..49f134b --- /dev/null +++ b/frontend/src/components/zicato/zicato-tokens.css @@ -0,0 +1,715 @@ +/* ===================================================================== + zicato-tokens.css — the 16-theme colour contract for the in-app + zicato-language console. Ported from + docs/design/zicato-ui-study/_tokens.css, with EVERY selector re-scoped + from `html[data-theme=""]` to `.zk-root[data-zicato-theme=""]` + so the tokens cascade ONLY inside the zicato subtree and never disturb + the MD3 console (which keeps `:root` / `` to itself). + + Layers per theme: zicato --v2-N role contract + study short aliases + (--paper/--panel/--ink/--accent/--flat), the ANSI palette (--ansi-N), + and the harmonograf categorical encoding (--hg-kind-N / --hg-gf-N / + --hg-agent-N). NOTE: the zicato renderers read --hg-gf-N (NOT the MD3 + --hg-goldfive-N). Default theme: monokai. + + GENERATED from the study tokens — do not hand-edit; re-derive from the + study source if the palette changes. The brand accent (--hgraf-brand) + is appended at the end (ported from _study.css 371-374). + ===================================================================== */ + +/* ===================================================================== + _tokens.css — the 16-theme colour contract for the harmonograf + zicato-language UI study. GENERATED — see /tmp/gen_tokens.py in the + build notes (STYLE-GUIDE.md). The ONLY place raw hex appears. + + Each html[data-theme=""] block carries three layers: + + 1. zicato --v2-N ROLE CONTRACT (paper/panel/ink/.../accent/flat), + ported 1:1 from console4.css via single-matchup.html, plus the + study-local short aliases (--paper/--panel/--ink/...) the pages read. + Meaning is FIXED across all 16 themes (DESIGN-LANGUAGE.md §2.1): + good/bad earned by direction; --accent is the ONE structural emphasis. + + 2. the terminal ANSI palette (--ansi-N), wired from _refs/gogh-palettes.json + — the Gogh scheme each zicato theme derives from. 16 slots: + normal black/red/green/yellow/blue/magenta/cyan/white + bright variants. + + 3. the harmonograf CATEGORICAL encoding, ALL derived from (1)+(2): + · span KIND hue → --hg-kind-N (KIND = hue axis, BRIEF §2.1) + · goldfive class → --hg-gf-N + · agent identity → --hg-agent-1..8 (+ user / goldfive synthetic actors): + an ORDINAL ramp, each lane an ANSI slot color-mixed toward --ink-faint + so lanes stay distinguishable without competing with kind hues or accent. + + STATUS (running/failed/pending/...) is NOT here — it is a treatment applied + over the kind hue (accent+breathe / bad+glyph / dashed / hatch), so good/bad/ + accent appear ONLY for status, never for identity. See STYLE-GUIDE.md. + + Default theme: monokai (set on by each page). + ===================================================================== */ + +.zk-root[data-zicato-theme="monokai"] { + --v2-paper:#1e1f1c; --v2-panel:#272822; --v2-panel-2:#23241f; + --v2-ink:#f8f8f2; --v2-ink-soft:#c9cabf; --v2-ink-faint:#8f908a; + --v2-rule:#3a3b34; --v2-rule-soft:#2f302a; + --v2-good:#a6e22e; --v2-good-soft:#2c361a; --v2-bad:#f92672; --v2-caution:#e6db74; + --v2-accent:#66d9ef; --v2-flat:#75715e; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#272822; --ansi-red:#f92672; --ansi-green:#a6e22e; --ansi-yellow:#e6db74; + --ansi-blue:#66d9ef; --ansi-magenta:#ae81ff; --ansi-cyan:#a1efe4; --ansi-white:#f8f8f2; + --ansi-bright-black:#75715e; --ansi-bright-red:#f92672; --ansi-bright-green:#a6e22e; --ansi-bright-yellow:#e6db74; + --ansi-bright-blue:#66d9ef; --ansi-bright-magenta:#ae81ff; --ansi-bright-cyan:#a1efe4; --ansi-bright-white:#f9f8f5; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="solarized-dark"] { + --v2-paper:#04222B; --v2-panel:#0A2D38; --v2-panel-2:#0A2730; + --v2-ink:#93A1A1; --v2-ink-soft:#839496; --v2-ink-faint:#5E7079; + --v2-rule:#0E3540; --v2-rule-soft:#0B2E39; + --v2-good:#8BB80E; --v2-good-soft:#1C3320; --v2-bad:#E0483C; --v2-caution:#C4920A; + --v2-accent:#2AA198; --v2-flat:#46606B; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#073642; --ansi-red:#DC322F; --ansi-green:#859900; --ansi-yellow:#B58900; + --ansi-blue:#268BD2; --ansi-magenta:#D33682; --ansi-cyan:#2AA198; --ansi-white:#EEE8D5; + --ansi-bright-black:#657B83; --ansi-bright-red:#CB4B16; --ansi-bright-green:#586E75; --ansi-bright-yellow:#657B83; + --ansi-bright-blue:#6c71c4; --ansi-bright-magenta:#D33682; --ansi-bright-cyan:#2AA198; --ansi-bright-white:#FDF6E3; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="solarized-light"] { + --v2-paper:#FDF6E3; --v2-panel:#FBF1D6; --v2-panel-2:#F2E9CE; + --v2-ink:#586E75; --v2-ink-soft:#657B83; --v2-ink-faint:#93A1A1; + --v2-rule:#E7DCBE; --v2-rule-soft:#F0E7CC; + --v2-good:#6B9B0B; --v2-good-soft:#E3ECCB; --v2-bad:#DC322F; --v2-caution:#B58900; + --v2-accent:#268BD2; --v2-flat:#93A1A1; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#EEE8D5; --ansi-red:#DC322F; --ansi-green:#859900; --ansi-yellow:#B58900; + --ansi-blue:#268BD2; --ansi-magenta:#D33682; --ansi-cyan:#2AA198; --ansi-white:#073642; + --ansi-bright-black:#93A1A1; --ansi-bright-red:#CB4B16; --ansi-bright-green:#586E75; --ansi-bright-yellow:#839496; + --ansi-bright-blue:#6C71C4; --ansi-bright-magenta:#D33682; --ansi-bright-cyan:#2AA198; --ansi-bright-white:#002B36; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="google-light"] { + --v2-paper:#FFFFFF; --v2-panel:#F4F4F4; --v2-panel-2:#F8F8F8; + --v2-ink:#474A4E; --v2-ink-soft:#5F6368; --v2-ink-faint:#9FA1A4; + --v2-rule:#E2E3E4; --v2-rule-soft:#F1F1F1; + --v2-good:#34A853; --v2-good-soft:#D2ECD9; --v2-bad:#EA4335; --v2-caution:#C99700; + --v2-accent:#1B9CB8; --v2-flat:#9AA0A6; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#1D1F21; --ansi-red:#CC342B; --ansi-green:#198844; --ansi-yellow:#FBA922; + --ansi-blue:#3971ED; --ansi-magenta:#A36AC7; --ansi-cyan:#3971ED; --ansi-white:#C5C8C6; + --ansi-bright-black:#969896; --ansi-bright-red:#CC342B; --ansi-bright-green:#198844; --ansi-bright-yellow:#FBA922; + --ansi-bright-blue:#3971ED; --ansi-bright-magenta:#A36AC7; --ansi-bright-cyan:#3971ED; --ansi-bright-white:#FFFFFF; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="google-dark"] { + --v2-paper:#202124; --v2-panel:#2C2D30; --v2-panel-2:#28292C; + --v2-ink:#FFFFFF; --v2-ink-soft:#E8EAED; --v2-ink-faint:#989A9D; + --v2-rule:#444548; --v2-rule-soft:#323336; + --v2-good:#34A853; --v2-good-soft:#243F2E; --v2-bad:#EA4335; --v2-caution:#FBBC05; + --v2-accent:#24C1E0; --v2-flat:#5F6368; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#202124; --ansi-red:#EA4335; --ansi-green:#34A853; --ansi-yellow:#FBBC04; + --ansi-blue:#4285F4; --ansi-magenta:#A142F4; --ansi-cyan:#24C1E0; --ansi-white:#E8EAED; + --ansi-bright-black:#5F6368; --ansi-bright-red:#EA4335; --ansi-bright-green:#34A853; --ansi-bright-yellow:#FBBC05; + --ansi-bright-blue:#4285F4; --ansi-bright-magenta:#A142F4; --ansi-bright-cyan:#24C1E0; --ansi-bright-white:#FFFFFF; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="lunaria-light"] { + --v2-paper:#EBE4E1; --v2-panel:#E2DCD9; --v2-panel-2:#E6DFDC; + --v2-ink:#363434; --v2-ink-soft:#484646; --v2-ink-faint:#898584; + --v2-rule:#CEC8C5; --v2-rule-soft:#DCD6D3; + --v2-good:#497D46; --v2-good-soft:#C7CDBF; --v2-bad:#783C1F; --v2-caution:#8F750B; + --v2-accent:#3778A9; --v2-flat:#898584; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#3E3C3D; --ansi-red:#783C1F; --ansi-green:#497D46; --ansi-yellow:#8F750B; + --ansi-blue:#3F3566; --ansi-magenta:#793F62; --ansi-cyan:#3778A9; --ansi-white:#D5CFCC; + --ansi-bright-black:#484646; --ansi-bright-red:#B06240; --ansi-bright-green:#7BC175; --ansi-bright-yellow:#DCB735; + --ansi-bright-blue:#5C4F89; --ansi-bright-magenta:#B56895; --ansi-bright-cyan:#64BAFF; --ansi-bright-white:#EBE4E1; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="lunaria-eclipse"] { + --v2-paper:#323F46; --v2-panel:#3B484F; --v2-panel-2:#38454C; + --v2-ink:#DFE2ED; --v2-ink-soft:#C9CDD7; --v2-ink-faint:#8D949D; + --v2-rule:#4D5960; --v2-rule-soft:#404C53; + --v2-good:#BEDBC1; --v2-good-soft:#516161; --v2-bad:#BA9088; --v2-caution:#F1DFB4; + --v2-accent:#C8429F; --v2-flat:#6B767D; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#323F46; --ansi-red:#83615B; --ansi-green:#7F9781; --ansi-yellow:#A69875; + --ansi-blue:#53516F; --ansi-magenta:#856880; --ansi-cyan:#7D96B2; --ansi-white:#C9CDD7; + --ansi-bright-black:#3D4950; --ansi-bright-red:#BA9088; --ansi-bright-green:#BEDBC1; --ansi-bright-yellow:#F1DFB4; + --ansi-bright-blue:#767495; --ansi-bright-magenta:#BE9CB8; --ansi-bright-cyan:#BCDBFF; --ansi-bright-white:#DFE2ED; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="belafonte-day"] { + --v2-paper:#D5CCBA; --v2-panel:#CCC3B2; --v2-panel-2:#D0C6B5; + --v2-ink:#34292D; --v2-ink-soft:#45373C; --v2-ink-faint:#7F736E; + --v2-rule:#BBB1A3; --v2-rule-soft:#C8BFAF; + --v2-good:#6E6A4E; --v2-good-soft:#C3BCA7; --v2-bad:#BE100E; --v2-caution:#B57A1E; + --v2-accent:#426A79; --v2-flat:#5E5252; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#20111B; --ansi-red:#BE100E; --ansi-green:#858162; --ansi-yellow:#EAA549; + --ansi-blue:#426A79; --ansi-magenta:#97522C; --ansi-cyan:#989A9C; --ansi-white:#968C83; + --ansi-bright-black:#5E5252; --ansi-bright-red:#BE100E; --ansi-bright-green:#858162; --ansi-bright-yellow:#EAA549; + --ansi-bright-blue:#426A79; --ansi-bright-magenta:#97522C; --ansi-bright-cyan:#989A9C; --ansi-bright-white:#D5CCBA; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="belafonte-night"] { + --v2-paper:#20111B; --v2-panel:#271821; --v2-panel-2:#25161F; + --v2-ink:#D5CCBA; --v2-ink-soft:#968C83; --v2-ink-faint:#675B59; + --v2-rule:#35272E; --v2-rule-soft:#2B1C24; + --v2-good:#A6A07A; --v2-good-soft:#362A2B; --v2-bad:#D6403E; --v2-caution:#EAA549; + --v2-accent:#6F8E97; --v2-flat:#5E5252; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#20111B; --ansi-red:#BE100E; --ansi-green:#858162; --ansi-yellow:#EAA549; + --ansi-blue:#426A79; --ansi-magenta:#97522C; --ansi-cyan:#989A9C; --ansi-white:#968C83; + --ansi-bright-black:#5E5252; --ansi-bright-red:#BE100E; --ansi-bright-green:#858162; --ansi-bright-yellow:#EAA549; + --ansi-bright-blue:#426A79; --ansi-bright-magenta:#97522C; --ansi-bright-cyan:#989A9C; --ansi-bright-white:#D5CCBA; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="paper"] { + --v2-paper:#F2EEDE; --v2-panel:#E6E2D3; --v2-panel-2:#E8E4D5; + --v2-ink:#1A1A1A; --v2-ink-soft:#33312B; --v2-ink-faint:#85837A; + --v2-rule:#C6C3B6; --v2-rule-soft:#DCD9CA; + --v2-good:#216609; --v2-good-soft:#CCD6B8; --v2-bad:#CC3E28; --v2-caution:#B58900; + --v2-accent:#1E6FCC; --v2-flat:#6E6A5E; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#000000; --ansi-red:#cc3e28; --ansi-green:#216609; --ansi-yellow:#b58900; + --ansi-blue:#1e6fcc; --ansi-magenta:#5c21a5; --ansi-cyan:#158c86; --ansi-white:#aaaaaa; + --ansi-bright-black:#555555; --ansi-bright-red:#cc3e28; --ansi-bright-green:#216609; --ansi-bright-yellow:#b58900; + --ansi-bright-blue:#1e6fcc; --ansi-bright-magenta:#5c21a5; --ansi-bright-cyan:#158c86; --ansi-bright-white:#aaaaaa; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="zenburn"] { + --v2-paper:#3A3A3A; --v2-panel:#424241; --v2-panel-2:#404040; + --v2-ink:#DCDCCC; --v2-ink-soft:#C5C5B8; --v2-ink-faint:#83837C; + --v2-rule:#575754; --v2-rule-soft:#494947; + --v2-good:#8FB28F; --v2-good-soft:#2E3A2E; --v2-bad:#CC9393; --v2-caution:#FFD7A7; + --v2-accent:#8CD0D3; --v2-flat:#757575; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#333333; --ansi-red:#cc9393; --ansi-green:#7f9f7f; --ansi-yellow:#efef87; + --ansi-blue:#c3bf97; --ansi-magenta:#bca3a3; --ansi-cyan:#93b3a3; --ansi-white:#f0efd0; + --ansi-bright-black:#757575; --ansi-bright-red:#dfaf87; --ansi-bright-green:#9fc59f; --ansi-bright-yellow:#ffff87; + --ansi-bright-blue:#d7d7af; --ansi-bright-magenta:#d7afaf; --ansi-bright-cyan:#93bea3; --ansi-bright-white:#dcdccc; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="selenized-black"] { + --v2-paper:#181818; --v2-panel:#202020; --v2-panel-2:#1E1E1E; + --v2-ink:#DEDEDE; --v2-ink-soft:#B9B9B9; --v2-ink-faint:#606060; + --v2-rule:#353535; --v2-rule-soft:#262626; + --v2-good:#83C746; --v2-good-soft:#243218; --v2-bad:#FF5E56; --v2-caution:#EFC541; + --v2-accent:#56D8C9; --v2-flat:#3B3B3B; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#252525; --ansi-red:#ed4a46; --ansi-green:#70b433; --ansi-yellow:#dbb32d; + --ansi-blue:#368aeb; --ansi-magenta:#eb6eb7; --ansi-cyan:#3fc5b7; --ansi-white:#777777; + --ansi-bright-black:#3b3b3b; --ansi-bright-red:#ff5e56; --ansi-bright-green:#83c746; --ansi-bright-yellow:#efc541; + --ansi-bright-blue:#4f9cfe; --ansi-bright-magenta:#ff81ca; --ansi-bright-cyan:#56d8c9; --ansi-bright-white:#dedede; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="relaxed"] { + --v2-paper:#353A44; --v2-panel:#3D424B; --v2-panel-2:#3C404A; + --v2-ink:#F7F7F7; --v2-ink-soft:#D9D9D9; --v2-ink-faint:#7F8287; + --v2-rule:#53575F; --v2-rule-soft:#444851; + --v2-good:#A0AC77; --v2-good-soft:#353D2E; --v2-bad:#BC5653; --v2-caution:#EBC17A; + --v2-accent:#7EAAC7; --v2-flat:#636363; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#151515; --ansi-red:#BC5653; --ansi-green:#909D63; --ansi-yellow:#EBC17A; + --ansi-blue:#6A8799; --ansi-magenta:#B06698; --ansi-cyan:#7EAAC7; --ansi-white:#D9D9D9; + --ansi-bright-black:#636363; --ansi-bright-red:#BC5653; --ansi-bright-green:#A0AC77; --ansi-bright-yellow:#EBC17A; + --ansi-bright-blue:#7EAAC7; --ansi-bright-magenta:#B06698; --ansi-bright-cyan:#ACBBD0; --ansi-bright-white:#F7F7F7; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="espresso"] { + --v2-paper:#323232; --v2-panel:#3A3A3A; --v2-panel-2:#383838; + --v2-ink:#FFFFFF; --v2-ink-soft:#D9D9D9; --v2-ink-faint:#8A8A8A; + --v2-rule:#4C4C4C; --v2-rule-soft:#3E3E3E; + --v2-good:#A5C261; --v2-good-soft:#353D24; --v2-bad:#D25252; --v2-caution:#FFC66D; + --v2-accent:#6C99BB; --v2-flat:#6E6E6E; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#353535; --ansi-red:#D25252; --ansi-green:#A5C261; --ansi-yellow:#FFC66D; + --ansi-blue:#6C99BB; --ansi-magenta:#D197D9; --ansi-cyan:#BED6FF; --ansi-white:#EEEEEC; + --ansi-bright-black:#535353; --ansi-bright-red:#F00C0C; --ansi-bright-green:#C2E075; --ansi-bright-yellow:#E1E48B; + --ansi-bright-blue:#8AB7D9; --ansi-bright-magenta:#EFB5F7; --ansi-bright-cyan:#DCF4FF; --ansi-bright-white:#FFFFFF; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="dracula"] { + --v2-paper:#282A36; --v2-panel:#343746; --v2-panel-2:#2E303D; + --v2-ink:#F8F8F2; --v2-ink-soft:#D4D4CF; --v2-ink-faint:#6272A4; + --v2-rule:#44475A; --v2-rule-soft:#353848; + --v2-good:#50FA7B; --v2-good-soft:#213A2A; --v2-bad:#FF5555; --v2-caution:#F1FA8C; + --v2-accent:#BD93F9; --v2-flat:#6272A4; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#262626; --ansi-red:#E64747; --ansi-green:#42E66C; --ansi-yellow:#E4F34A; + --ansi-blue:#9B6BDF; --ansi-magenta:#E356A7; --ansi-cyan:#75D7EC; --ansi-white:#F8F8F2; + --ansi-bright-black:#7A7A7A; --ansi-bright-red:#FF5555; --ansi-bright-green:#50FA7B; --ansi-bright-yellow:#F1FA8C; + --ansi-bright-blue:#BD93F9; --ansi-bright-magenta:#FF79C6; --ansi-bright-cyan:#8BE9FD; --ansi-bright-white:#F9F9FB; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +.zk-root[data-zicato-theme="ubuntu"] { + --v2-paper:#300A24; --v2-panel:#3D1530; --v2-panel-2:#371129; + --v2-ink:#EEEEEC; --v2-ink-soft:#CBC3C9; --v2-ink-faint:#8A7383; + --v2-rule:#4B2640; --v2-rule-soft:#3E1A32; + --v2-good:#8AE234; --v2-good-soft:#2E3320; --v2-bad:#CC0000; --v2-caution:#FCE94F; + --v2-accent:#34E2E2; --v2-flat:#847683; + --paper:var(--v2-paper); --panel:var(--v2-panel); --panel-2:var(--v2-panel-2); + --ink:var(--v2-ink); --ink-soft:var(--v2-ink-soft); --ink-faint:var(--v2-ink-faint); + --rule:var(--v2-rule); --rule-soft:var(--v2-rule-soft); + --good:var(--v2-good); --good-soft:var(--v2-good-soft); --bad:var(--v2-bad); --caution:var(--v2-caution); + --accent:var(--v2-accent); --flat:var(--v2-flat); --zicato-accent:var(--v2-accent); + --bad-soft:color-mix(in srgb, var(--v2-bad) 22%, var(--v2-panel)); + --ansi-black:#2E3436; --ansi-red:#CC0000; --ansi-green:#4E9A06; --ansi-yellow:#C4A000; + --ansi-blue:#3465A4; --ansi-magenta:#75507B; --ansi-cyan:#06989A; --ansi-white:#D3D7CF; + --ansi-bright-black:#555753; --ansi-bright-red:#EF2929; --ansi-bright-green:#8AE234; --ansi-bright-yellow:#FCE94F; + --ansi-bright-blue:#729FCF; --ansi-bright-magenta:#AD7FA8; --ansi-bright-cyan:#34E2E2; --ansi-bright-white:#EEEEEC; + --hg-kind-invocation:var(--ansi-bright-black); + --hg-kind-llm-call:var(--ansi-blue); + --hg-kind-tool-call:var(--ansi-cyan); + --hg-kind-user-message:var(--ansi-green); + --hg-kind-agent-message:var(--ansi-bright-green); + --hg-kind-transfer:var(--ansi-yellow); + --hg-kind-wait-for-human:var(--ansi-magenta); + --hg-kind-planned:var(--ansi-bright-black); + --hg-kind-custom:var(--ansi-white); + --hg-gf-judge-on-task:var(--v2-good); --hg-gf-judge-warning:var(--v2-caution); --hg-gf-judge-critical:var(--v2-bad); --hg-gf-judge-neutral:var(--v2-flat); + --hg-gf-refine:var(--ansi-magenta); + --hg-gf-plan:var(--ansi-cyan); + --hg-gf-reflective:var(--ansi-bright-black); + --hg-agent-1:color-mix(in oklab, var(--ansi-blue) 62%, var(--v2-ink-faint)); + --hg-agent-2:color-mix(in oklab, var(--ansi-cyan) 62%, var(--v2-ink-faint)); + --hg-agent-3:color-mix(in oklab, var(--ansi-green) 62%, var(--v2-ink-faint)); + --hg-agent-4:color-mix(in oklab, var(--ansi-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-5:color-mix(in oklab, var(--ansi-magenta) 62%, var(--v2-ink-faint)); + --hg-agent-6:color-mix(in oklab, var(--ansi-bright-blue) 62%, var(--v2-ink-faint)); + --hg-agent-7:color-mix(in oklab, var(--ansi-bright-green) 62%, var(--v2-ink-faint)); + --hg-agent-8:color-mix(in oklab, var(--ansi-bright-yellow) 62%, var(--v2-ink-faint)); + --hg-agent-user:color-mix(in oklab, var(--v2-caution) 55%, var(--v2-ink-faint)); + --hg-agent-goldfive:color-mix(in oklab, var(--v2-accent) 55%, var(--v2-ink-faint)); +} + +/* ---- harmonograf brand accent (the "now blue" dot) — ported from + _study.css 371-374, re-scoped onto .zk-root. A FIXED pair pinned per + ground, independent of the active theme's --accent. */ +.zk-root { --hgraf-brand: #5EA3EC; } +.zk-root[data-zicato-theme="solarized-light"], +.zk-root[data-zicato-theme="google-light"], +.zk-root[data-zicato-theme="lunaria-light"], +.zk-root[data-zicato-theme="belafonte-day"], +.zk-root[data-zicato-theme="paper"] { --hgraf-brand: #2F7FD0; } diff --git a/frontend/src/components/zicato/zicato.css b/frontend/src/components/zicato/zicato.css new file mode 100644 index 0000000..6bd40b4 --- /dev/null +++ b/frontend/src/components/zicato/zicato.css @@ -0,0 +1,1041 @@ +/* ===================================================================== + zicato.css — chrome + line-art component vocabulary for the in-app + zicato-language console. Ported from + docs/design/zicato-ui-study/_study.css (sections 0-7) + the figure-local + classes in compose.html's