mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
Extract shared frontend design system into turnstone/shared_static/ (#17)
* Extract shared frontend design system into turnstone/shared_static/ The server UI and console UI had ~60% CSS overlap and significant JS duplication. Extract shared assets into a new turnstone/shared_static/ package mounted at /shared/ in both servers: - base.css: design tokens, reset, typography, login/toast/kb overlays, dashboard table, state dots, health bar, scrollbar, reduced motion - auth.js: authFetch, login overlay with focus trap, logout (hooks for page-specific post-login/logout callbacks) - theme.js: dark/light toggle with system preference detection - toast.js: notification queue with configurable timeout - utils.js: escapeHtml, formatTokens, ctxClass, formatUptime, formatCount - kb.js: keyboard shortcuts overlay with configurable content, focus management, and focus restore on dismiss Console proxy updated: JS shim injection moved from proxy_static (app.js prepend) to proxy_index (inline <script> in HTML) so it runs before any external scripts. New /shared/ path rewriting and proxy_shared_static route added. ~1540 lines removed from page-specific files, 775 lines in shared package. 13 new tests (788 total). * Fix /shared/ auth and remove __init__.py from shared_static Address PR #17 review feedback: 1. Add /shared/ to PUBLIC_PREFIXES in auth.py so shared CSS/JS loads before authentication (required for login overlay to render) 2. Remove turnstone/shared_static/__init__.py to prevent exposing Python package internals (__init__.py, __pycache__) via the StaticFiles mount. Not needed for packaging since pyproject.toml uses explicit glob includes. 3 new auth tests for /shared/ public path access.
This commit is contained in:
@@ -58,19 +58,22 @@ turnstone/
|
||||
console/
|
||||
collector.py ClusterCollector — aggregates state from all nodes via Redis + HTTP
|
||||
server.py Cluster dashboard HTTP server + SSE + CLI entry point
|
||||
static/ Cluster dashboard web UI (HTML, CSS, JS)
|
||||
static/ Cluster dashboard web UI (page-specific HTML, CSS, JS)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
spinner.py Braille character spinner (daemon thread)
|
||||
static/
|
||||
index.html Single-page app shell (links to CSS and JS)
|
||||
style.css All UI styles (dark/light themes, dashboard, approval blocks)
|
||||
app.js All client-side JavaScript (SSE, workstreams, dashboard, markdown)
|
||||
style.css Page-specific UI styles (dashboard layout, approval blocks)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
|
||||
tools/
|
||||
*.json 14 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
|
||||
Both UIs share a common design system extracted into `turnstone/shared_static/`: design tokens, login overlay, toast notifications, theme toggle, keyboard shortcuts, and utility functions. Each UI imports `base.css` and the shared JS modules at `/shared/`, then adds only page-specific code at `/static/`.
|
||||
|
||||
---
|
||||
|
||||
## Core Loop
|
||||
|
||||
+6
-5
@@ -217,19 +217,20 @@ The console reverse-proxies each node's server UI at `/node/{node_id}/`. This al
|
||||
|
||||
| Route | Behavior |
|
||||
|-------|----------|
|
||||
| `GET /node/{node_id}/` | Fetches the server's `index.html`, rewrites static asset paths, injects a console-return banner and a JS proxy shim |
|
||||
| `GET /node/{node_id}/static/{path}` | Proxies static files; injects a JS shim into `app.js` |
|
||||
| `GET /node/{node_id}/` | Fetches the server's `index.html`, rewrites static and shared asset paths, injects a console-return banner and an inline JS proxy shim |
|
||||
| `GET /node/{node_id}/static/{path}` | Proxies page-specific static files |
|
||||
| `GET /node/{node_id}/shared/{path}` | Proxies shared static files (`base.css`, `auth.js`, etc.) |
|
||||
| `GET /node/{node_id}/api/{path}` | Proxies GET API requests; detects SSE endpoints and streams them |
|
||||
| `POST /node/{node_id}/api/{path}` | Proxies POST API requests with body forwarding |
|
||||
| `GET /node/{node_id}/{path}` | Proxies non-API endpoints (health, metrics) |
|
||||
|
||||
### URL Rewriting
|
||||
|
||||
The server UI uses root-relative URLs (`/api/send`, `/static/app.js`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
|
||||
The server UI uses root-relative URLs (`/api/send`, `/static/app.js`, `/shared/base.css`, etc.). Since `<base>` tags cannot rewrite root-relative URLs, the console uses a JS shim approach:
|
||||
|
||||
1. **HTML rewriting** — when serving `index.html`, replaces `href="/static/"` and `src="/static/"` with the proxy prefix (`/node/{node_id}/static/`).
|
||||
1. **HTML rewriting** — when serving `index.html`, replaces `href=` and `src=` references to both `/static/` and `/shared/` with the proxy prefix (`/node/{node_id}/static/` and `/node/{node_id}/shared/` respectively).
|
||||
|
||||
2. **JS shim injection** — when serving `app.js`, prepends an IIFE that overrides `window.fetch()` and `window.EventSource()` to prepend the proxy prefix to any root-relative URL. This intercepts all API calls and SSE connections transparently.
|
||||
2. **Inline JS shim** — injects an inline `<script>` block into the proxied HTML (after the console-return banner, before any external scripts) that overrides `window.fetch()` and `window.EventSource()` to prepend the proxy prefix to any root-relative URL. Running the shim inline ensures it executes before any external scripts load, so all API calls and SSE connections are intercepted transparently.
|
||||
|
||||
3. **Console-return banner** — injects a thin inline-styled `<div>` after `<body>` with a "← Console" link and the node ID, providing navigation back to the dashboard.
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ include = [
|
||||
"turnstone/console/static/*.html",
|
||||
"turnstone/console/static/*.css",
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
@@ -42,6 +42,12 @@ class TestIsPublicPath:
|
||||
def test_static_subdir(self):
|
||||
assert is_public_path("/static/fonts/mono.woff2") is True
|
||||
|
||||
def test_shared_css_public(self):
|
||||
assert is_public_path("/shared/base.css") is True
|
||||
|
||||
def test_shared_js_public(self):
|
||||
assert is_public_path("/shared/utils.js") is True
|
||||
|
||||
def test_api_workstreams_not_public(self):
|
||||
assert is_public_path("/api/workstreams") is False
|
||||
|
||||
@@ -721,6 +727,10 @@ class TestServerAuth:
|
||||
allowed = resp.headers.get("access-control-allow-headers", "")
|
||||
assert "authorization" in allowed.lower()
|
||||
|
||||
def test_shared_css_no_token_200(self):
|
||||
resp = self.client.get("/shared/base.css")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestConsoleAuth:
|
||||
"""Test console server with auth enabled using TestClient."""
|
||||
|
||||
@@ -1125,3 +1125,157 @@ class TestConsoleVersionEndpoints:
|
||||
assert status == 200
|
||||
assert data["version_drift"] is True
|
||||
assert "0.3.0" in data["versions"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared static serving
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSharedStatic:
|
||||
"""Tests for /shared/ static file serving."""
|
||||
|
||||
@pytest.fixture()
|
||||
def client(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_overview.return_value = {
|
||||
"nodes": 0,
|
||||
"workstreams": 0,
|
||||
"states": {},
|
||||
"aggregate": {},
|
||||
}
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
broker=MagicMock(),
|
||||
auth_config=AuthConfig(),
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
def test_shared_base_css(self, client):
|
||||
resp = client.get("/shared/base.css")
|
||||
assert resp.status_code == 200
|
||||
assert "text/css" in resp.headers.get("content-type", "")
|
||||
|
||||
def test_shared_utils_js(self, client):
|
||||
resp = client.get("/shared/utils.js")
|
||||
assert resp.status_code == 200
|
||||
assert "javascript" in resp.headers.get("content-type", "")
|
||||
|
||||
def test_shared_auth_js(self, client):
|
||||
resp = client.get("/shared/auth.js")
|
||||
assert resp.status_code == 200
|
||||
assert "javascript" in resp.headers.get("content-type", "")
|
||||
|
||||
def test_shared_toast_js(self, client):
|
||||
resp = client.get("/shared/toast.js")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_shared_theme_js(self, client):
|
||||
resp = client.get("/shared/theme.js")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_shared_kb_js(self, client):
|
||||
resp = client.get("/shared/kb.js")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_shared_nonexistent_returns_404(self, client):
|
||||
resp = client.get("/shared/nonexistent.js")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_index_imports_shared_base_css(self, client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert '/shared/base.css"' in resp.text
|
||||
|
||||
def test_index_imports_shared_scripts(self, client):
|
||||
resp = client.get("/")
|
||||
body = resp.text
|
||||
assert "/shared/utils.js" in body
|
||||
assert "/shared/toast.js" in body
|
||||
assert "/shared/theme.js" in body
|
||||
assert "/shared/auth.js" in body
|
||||
assert "/shared/kb.js" in body
|
||||
|
||||
def test_shared_scripts_load_before_app_js(self, client):
|
||||
"""Shared scripts must appear before page-specific app.js."""
|
||||
body = client.get("/").text
|
||||
shared_pos = body.find("/shared/utils.js")
|
||||
app_pos = body.find("/static/app.js")
|
||||
assert shared_pos < app_pos
|
||||
|
||||
|
||||
class TestProxySharedStatic:
|
||||
"""Tests for proxy rewriting of /shared/ paths."""
|
||||
|
||||
def test_html_rewriting_includes_shared_paths(self):
|
||||
"""Verify proxy_index rewrites /shared/ paths like /static/ paths."""
|
||||
sample_html = (
|
||||
'<link rel="stylesheet" href="/shared/base.css">\n'
|
||||
'<link rel="stylesheet" href="/static/style.css">\n'
|
||||
'<script src="/shared/utils.js"></script>\n'
|
||||
'<script src="/static/app.js"></script>'
|
||||
)
|
||||
prefix = "/node/test-node"
|
||||
rewritten = sample_html.replace('href="/static/', f'href="{prefix}/static/')
|
||||
rewritten = rewritten.replace('src="/static/', f'src="{prefix}/static/')
|
||||
rewritten = rewritten.replace('href="/shared/', f'href="{prefix}/shared/')
|
||||
rewritten = rewritten.replace('src="/shared/', f'src="{prefix}/shared/')
|
||||
assert "/node/test-node/shared/base.css" in rewritten
|
||||
assert "/node/test-node/shared/utils.js" in rewritten
|
||||
assert "/node/test-node/static/style.css" in rewritten
|
||||
assert "/node/test-node/static/app.js" in rewritten
|
||||
assert 'href="/shared/' not in rewritten
|
||||
assert 'src="/shared/' not in rewritten
|
||||
|
||||
def test_proxy_shim_injected_in_html(self):
|
||||
"""Verify shim is injected as inline script in proxied HTML."""
|
||||
import json
|
||||
|
||||
from turnstone.console.server import _CONSOLE_BANNER_TEMPLATE, _JS_PROXY_SHIM
|
||||
|
||||
sample_html = "<html><body><div>content</div></body></html>"
|
||||
prefix = "/node/test-node"
|
||||
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", "test-node")
|
||||
shim = (
|
||||
"<script>"
|
||||
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
|
||||
+ "</script>"
|
||||
)
|
||||
result = sample_html.replace("<body>", "<body>" + banner + shim, 1)
|
||||
assert "<script>" in result
|
||||
assert "/node/test-node" in result
|
||||
assert "window.fetch" in result
|
||||
assert "window.EventSource" in result
|
||||
|
||||
def test_proxy_shared_static_unknown_node_returns_404(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console.server import _load_static, create_app
|
||||
from turnstone.core.auth import AuthConfig
|
||||
|
||||
_load_static()
|
||||
collector = MagicMock(spec=ClusterCollector)
|
||||
collector.get_overview.return_value = {
|
||||
"nodes": 0,
|
||||
"workstreams": 0,
|
||||
"states": {},
|
||||
"aggregate": {},
|
||||
}
|
||||
collector.get_node_detail.return_value = None
|
||||
app = create_app(
|
||||
collector=collector,
|
||||
broker=MagicMock(),
|
||||
auth_config=AuthConfig(),
|
||||
)
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
resp = client.get("/node/unknown/shared/base.css")
|
||||
assert resp.status_code == 404
|
||||
client.close()
|
||||
|
||||
+38
-12
@@ -51,6 +51,7 @@ log = logging.getLogger("turnstone.console.server")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent / "static"
|
||||
_SHARED_DIR = Path(__file__).parent.parent / "shared_static"
|
||||
_HTML = ""
|
||||
_CSS = ""
|
||||
_JS = ""
|
||||
@@ -420,9 +421,16 @@ async def proxy_index(request: Request) -> Response:
|
||||
# Rewrite static asset paths
|
||||
page = page.replace('href="/static/', f'href="{prefix}/static/')
|
||||
page = page.replace('src="/static/', f'src="{prefix}/static/')
|
||||
# Inject console-return banner after <body>
|
||||
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
|
||||
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
|
||||
# Inject console-return banner + proxy shim after <body>
|
||||
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id))
|
||||
page = page.replace("<body>", "<body>" + banner, 1)
|
||||
shim = (
|
||||
"<script>"
|
||||
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
|
||||
+ "</script>"
|
||||
)
|
||||
page = page.replace("<body>", "<body>" + banner + shim, 1)
|
||||
return HTMLResponse(page)
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("Proxy index error for %s: %s", node_id, exc)
|
||||
@@ -438,23 +446,39 @@ async def proxy_static(request: Request) -> Response:
|
||||
return JSONResponse({"error": "Node not found"}, status_code=404)
|
||||
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
safe_node = urllib.parse.quote(node_id, safe="")
|
||||
prefix = f"/node/{safe_node}"
|
||||
try:
|
||||
resp = await client.get(f"{server_url}/static/{path}")
|
||||
content_type = resp.headers.get("content-type", "application/octet-stream")
|
||||
body = resp.content
|
||||
# Inject proxy shim into app.js
|
||||
if path == "app.js":
|
||||
shim = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
|
||||
body = shim.encode("utf-8") + body
|
||||
content_type = "application/javascript; charset=utf-8"
|
||||
return Response(content=body, status_code=resp.status_code, media_type=content_type)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("Proxy static error for %s/%s: %s", node_id, path, exc)
|
||||
return JSONResponse({"error": "Node unreachable"}, status_code=502)
|
||||
|
||||
|
||||
async def proxy_shared_static(request: Request) -> Response:
|
||||
"""GET /node/{node_id}/shared/{path} — proxy shared static files."""
|
||||
node_id = request.path_params["node_id"]
|
||||
path = request.path_params["path"]
|
||||
server_url = _get_server_url(request, node_id)
|
||||
if not server_url:
|
||||
return JSONResponse({"error": "Node not found"}, status_code=404)
|
||||
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
try:
|
||||
resp = await client.get(f"{server_url}/shared/{path}")
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("Proxy shared static error for %s/%s: %s", node_id, path, exc)
|
||||
return JSONResponse({"error": "Node unreachable"}, status_code=502)
|
||||
|
||||
|
||||
async def proxy_api(request: Request) -> Response:
|
||||
"""Proxy API requests to target node. Detects SSE vs regular."""
|
||||
node_id = request.path_params["node_id"]
|
||||
@@ -620,9 +644,11 @@ def create_app(
|
||||
Route("/api/auth/login", auth_login, methods=["POST"]),
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
|
||||
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"),
|
||||
# Proxy routes — serve server UI through console port
|
||||
Route("/node/{node_id}/", proxy_index),
|
||||
Route("/node/{node_id}/static/{path:path}", proxy_static),
|
||||
Route("/node/{node_id}/shared/{path:path}", proxy_shared_static),
|
||||
Route("/node/{node_id}/api/{path:path}", proxy_api, methods=["GET", "POST"]),
|
||||
Route("/node/{node_id}/{path:path}", proxy_non_api),
|
||||
],
|
||||
|
||||
+17
-305
@@ -1,49 +1,28 @@
|
||||
// --- Theme ---
|
||||
function toggleTheme() {
|
||||
var next = document.documentElement.dataset.theme === "light" ? "" : "light";
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem("turnstone-theme", next || "dark");
|
||||
// --- Shared hooks ---
|
||||
window.onLoginSuccess = function () {
|
||||
connectSSE();
|
||||
if (currentView === "overview") loadOverview();
|
||||
else if (currentView === "node") drillDownToNode(currentNodeId);
|
||||
else if (currentView === "filtered") loadFilteredWorkstreams();
|
||||
};
|
||||
window.onLogout = function () {
|
||||
if (evtSource) {
|
||||
evtSource.close();
|
||||
evtSource = null;
|
||||
}
|
||||
};
|
||||
window.onThemeChange = function (next) {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
if (btn) btn.textContent = next === "light" ? "\u2600" : "\u263E";
|
||||
}
|
||||
(function initTheme() {
|
||||
var stored = localStorage.getItem("turnstone-theme");
|
||||
if (stored === "light") {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
} else if (
|
||||
!stored &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
) {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
};
|
||||
// Set initial theme button text
|
||||
(function () {
|
||||
var btn = document.getElementById("theme-toggle");
|
||||
if (btn)
|
||||
btn.textContent =
|
||||
document.documentElement.dataset.theme === "light" ? "\u2600" : "\u263E";
|
||||
})();
|
||||
|
||||
/* Auth-aware fetch — shows login overlay on 401, retries on 429 */
|
||||
async function authFetch(url, opts) {
|
||||
var maxRetries = 2;
|
||||
for (var attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
var r = await fetch(url, opts);
|
||||
if (r.status === 401) {
|
||||
showLogin();
|
||||
throw new Error("auth");
|
||||
}
|
||||
if (r.status === 429 && attempt < maxRetries) {
|
||||
var retryAfter = parseInt(r.headers.get("Retry-After") || "1", 10);
|
||||
showToast("Rate limited \u2014 retrying in " + retryAfter + "s");
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, retryAfter * 1000);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
var currentView = "overview"; // "overview" | "node" | "filtered"
|
||||
var currentNodeId = null;
|
||||
@@ -65,68 +44,6 @@ var STATE_DISPLAY = {
|
||||
};
|
||||
var STATE_ORDER = ["running", "thinking", "attention", "error", "idle"];
|
||||
|
||||
// --- Helpers ---
|
||||
function escapeHtml(s) {
|
||||
var el = document.createElement("span");
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
}
|
||||
function formatTokens(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n || 0);
|
||||
}
|
||||
function ctxClass(ratio) {
|
||||
if (ratio <= 0) return "ctx-idle";
|
||||
var pct = ratio * 100;
|
||||
if (pct < 30) return "ctx-low";
|
||||
if (pct < 50) return "ctx-mid";
|
||||
if (pct < 80) return "ctx-high";
|
||||
return "ctx-danger";
|
||||
}
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return "";
|
||||
if (seconds < 60) return seconds + "s";
|
||||
var min = Math.floor(seconds / 60);
|
||||
if (min < 60) return min + "m";
|
||||
var hr = Math.floor(min / 60);
|
||||
return hr + "h " + (min % 60) + "m";
|
||||
}
|
||||
function formatCount(n) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// --- Toast ---
|
||||
var _toastQueue = [];
|
||||
var _toastTimer = null;
|
||||
var _toastShowing = false;
|
||||
function showToast(message) {
|
||||
var el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
if (_toastShowing) {
|
||||
_toastQueue.push(message);
|
||||
return;
|
||||
}
|
||||
_displayToast(el, message);
|
||||
}
|
||||
function _displayToast(el, message) {
|
||||
el.textContent = message;
|
||||
el.classList.add("visible");
|
||||
_toastShowing = true;
|
||||
if (_toastTimer) clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(function () {
|
||||
el.classList.remove("visible");
|
||||
_toastShowing = false;
|
||||
_toastTimer = null;
|
||||
if (_toastQueue.length) {
|
||||
setTimeout(function () {
|
||||
_displayToast(el, _toastQueue.shift());
|
||||
}, 300);
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// --- SSE Connection ---
|
||||
function connectSSE() {
|
||||
if (evtSource) {
|
||||
@@ -990,163 +907,6 @@ function renderWsTable(container, wsList) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Login Overlay ---
|
||||
var _loginTrapHandler = null;
|
||||
var _loginBusy = false;
|
||||
|
||||
function initLogin() {
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "login-overlay";
|
||||
overlay.style.display = "none";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "login-title");
|
||||
overlay.innerHTML =
|
||||
'<div id="login-box">' +
|
||||
'<h2 id="login-title">turnstone console</h2>' +
|
||||
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
|
||||
'<label for="login-token" class="sr-only">Auth token</label>' +
|
||||
'<input id="login-token" type="password" placeholder="Enter auth token" autocomplete="off">' +
|
||||
'<button id="login-submit">Sign in</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("login-submit").onclick = submitLogin;
|
||||
document
|
||||
.getElementById("login-token")
|
||||
.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") submitLogin();
|
||||
if (e.key === "Escape") {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl && errEl.style.display !== "none") {
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.style.display = "flex";
|
||||
document.body.style.overflow = "hidden";
|
||||
var logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.style.display = "none";
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
}
|
||||
setTimeout(function () {
|
||||
var inp = document.getElementById("login-token");
|
||||
if (inp) {
|
||||
inp.value = "";
|
||||
inp.focus();
|
||||
}
|
||||
}, 50);
|
||||
// Focus trap
|
||||
if (_loginTrapHandler)
|
||||
document.removeEventListener("keydown", _loginTrapHandler);
|
||||
_loginTrapHandler = function (e) {
|
||||
if (e.key === "Tab") {
|
||||
var box = document.getElementById("login-box");
|
||||
var focusable = box.querySelectorAll("input, button");
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", _loginTrapHandler);
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (overlay) overlay.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
if (_loginTrapHandler) {
|
||||
document.removeEventListener("keydown", _loginTrapHandler);
|
||||
_loginTrapHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
if (_loginBusy) return;
|
||||
var token = (document.getElementById("login-token").value || "").trim();
|
||||
if (!token) {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.textContent = "Token is required";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
document.getElementById("login-token").focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_loginBusy = true;
|
||||
var btn = document.getElementById("login-submit");
|
||||
var inp = document.getElementById("login-token");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Signing in\u2026";
|
||||
inp.disabled = true;
|
||||
|
||||
fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: token }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) throw new Error("invalid");
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
_loginBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sign in";
|
||||
inp.disabled = false;
|
||||
hideLogin();
|
||||
document.getElementById("logout-btn").style.display = "";
|
||||
connectSSE();
|
||||
if (currentView === "overview") loadOverview();
|
||||
else if (currentView === "node") drillDownToNode(currentNodeId);
|
||||
else if (currentView === "filtered") loadFilteredWorkstreams();
|
||||
})
|
||||
.catch(function (err) {
|
||||
_loginBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sign in";
|
||||
inp.disabled = false;
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.textContent =
|
||||
err.message === "invalid"
|
||||
? "Invalid token"
|
||||
: "Connection failed \u2014 try again";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetch("/api/auth/logout", { method: "POST" }).then(function () {
|
||||
if (evtSource) {
|
||||
evtSource.close();
|
||||
evtSource = null;
|
||||
}
|
||||
showLogin();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Navigation ---
|
||||
window.addEventListener("popstate", function (e) {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
@@ -1165,54 +925,6 @@ window.addEventListener("popstate", function (e) {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Keyboard shortcuts help ---
|
||||
function showKbHelp() {
|
||||
var existing = document.getElementById("kb-overlay");
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "kb-overlay";
|
||||
overlay.innerHTML =
|
||||
'<div id="kb-box" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">' +
|
||||
"<h2>Keyboard shortcuts</h2>" +
|
||||
'<div class="kb-section">Navigation</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Activate card / row</span><span class="kb-key">Enter</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Activate card / row</span><span class="kb-key">Space</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Navigate rows</span><span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span></div>' +
|
||||
'<div class="kb-section">General</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Show this help</span><span class="kb-key">?</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Close overlay</span><span class="kb-key">Esc</span></div>' +
|
||||
'<div class="kb-hint">Press <span class="kb-key">Esc</span> to close</div>' +
|
||||
"</div>";
|
||||
overlay.onclick = function (e) {
|
||||
if (e.target === overlay) hideKbHelp();
|
||||
};
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("kb-box").focus();
|
||||
}
|
||||
function hideKbHelp() {
|
||||
var el = document.getElementById("kb-overlay");
|
||||
if (el) el.remove();
|
||||
}
|
||||
document.addEventListener("keydown", function (e) {
|
||||
// Don't trigger when typing in inputs or when login overlay is open
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
var login = document.getElementById("login-overlay");
|
||||
if (login && login.style.display !== "none") return;
|
||||
if (e.key === "?" && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
showKbHelp();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
var kb = document.getElementById("kb-overlay");
|
||||
if (kb) {
|
||||
e.preventDefault();
|
||||
hideKbHelp();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- New Workstream Modal ---
|
||||
var _newWsTrapHandler = null;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -80,6 +81,26 @@
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<script>
|
||||
window.TURNSTONE_AUTH_TITLE = "turnstone console";
|
||||
window.TURNSTONE_TOAST_TIMEOUT = 4000;
|
||||
window.TURNSTONE_KB_SHORTCUTS = [
|
||||
{ title: "Navigation", keys: [
|
||||
{ desc: "Activate card / row", badge: '<span class="kb-key">Enter</span>' },
|
||||
{ desc: "Activate card / row", badge: '<span class="kb-key">Space</span>' },
|
||||
{ desc: "Navigate rows", badge: '<span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span>' }
|
||||
]},
|
||||
{ title: "General", keys: [
|
||||
{ desc: "Show this help", badge: '<span class="kb-key">?</span>' },
|
||||
{ desc: "Close overlay", badge: '<span class="kb-key">Esc</span>' }
|
||||
]}
|
||||
];
|
||||
</script>
|
||||
<script src="/shared/utils.js"></script>
|
||||
<script src="/shared/toast.js"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/auth.js"></script>
|
||||
<script src="/shared/kb.js"></script>
|
||||
|
||||
<div id="new-ws-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="new-ws-title">
|
||||
<div id="new-ws-box">
|
||||
|
||||
@@ -1,129 +1,12 @@
|
||||
/* ==========================================================================
|
||||
turnstone console — "Instrument Panel" aesthetic
|
||||
Deep charcoal surfaces, warm amber indicators, precision typography
|
||||
turnstone console — page-specific styles
|
||||
Extends shared base.css with console-only layout and components
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* Surface palette — deep charcoal with blue undertone */
|
||||
--bg: #0b0f19;
|
||||
--bg-surface: #111827;
|
||||
--bg-highlight: #1c2333;
|
||||
--bg-elevated: #1f2a3d;
|
||||
|
||||
/* Text hierarchy */
|
||||
--fg: #d1d5e4;
|
||||
--fg-dim: #8a93ad;
|
||||
--fg-bright: #e8ecf4;
|
||||
|
||||
/* Accent — warm amber (the signature color) */
|
||||
--accent: #e5a042;
|
||||
--accent-dim: rgba(229, 160, 66, 0.15);
|
||||
--accent-glow: rgba(229, 160, 66, 0.08);
|
||||
|
||||
/* Semantic indicators */
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--yellow: #fbbf24;
|
||||
--cyan: #67e8f9;
|
||||
--magenta: #c084fc;
|
||||
|
||||
/* Glow variants for LED effects */
|
||||
--green-glow: rgba(52, 211, 153, 0.25);
|
||||
--red-glow: rgba(248, 113, 113, 0.25);
|
||||
--yellow-glow: rgba(251, 191, 36, 0.25);
|
||||
--accent-glow-strong: rgba(229, 160, 66, 0.3);
|
||||
--cyan-glow: rgba(103, 232, 249, 0.2);
|
||||
|
||||
/* Structure */
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--border-strong: rgba(255, 255, 255, 0.1);
|
||||
--code-bg: #0d1117;
|
||||
--radius: 6px;
|
||||
--radius-sm: 3px;
|
||||
--dash-grid: 72px 120px 90px 100px 1fr 60px 48px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
--font-display: 'Outfit', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f3f4f6;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-highlight: #e9ecf0;
|
||||
--bg-elevated: #f9fafb;
|
||||
--fg: #1e293b;
|
||||
--fg-dim: #576275;
|
||||
--fg-bright: #0f172a;
|
||||
--accent: #8c5e1b;
|
||||
--accent-dim: rgba(140, 94, 27, 0.1);
|
||||
--accent-glow: rgba(140, 94, 27, 0.05);
|
||||
--green: #047857;
|
||||
--red: #dc2626;
|
||||
--yellow: #b45309;
|
||||
--cyan: #0e7490;
|
||||
--magenta: #7c3aed;
|
||||
--green-glow: rgba(4, 120, 87, 0.25);
|
||||
--red-glow: rgba(220, 38, 38, 0.25);
|
||||
--yellow-glow: rgba(180, 83, 9, 0.25);
|
||||
--accent-glow-strong: rgba(140, 94, 27, 0.15);
|
||||
--cyan-glow: rgba(14, 116, 144, 0.2);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
--code-bg: #f0f1f5;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Subtle noise texture for depth */
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(229, 160, 66, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(103, 232, 249, 0.02) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Header — thin instrument bar
|
||||
Header overrides — wider padding for console layout
|
||||
========================================================================== */
|
||||
#header {
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
#header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
|
||||
}
|
||||
#header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#header { padding: 10px 20px; gap: 16px; }
|
||||
.header-dim {
|
||||
color: var(--fg-dim);
|
||||
font-weight: 400;
|
||||
@@ -133,30 +16,6 @@ body {
|
||||
color: var(--fg-dim);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
#status-bar {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
#status-bar.disconnected { color: var(--red); }
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--fg-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-bright);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
#theme-toggle { color: var(--fg); }
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -373,7 +232,7 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
.node-row:nth-child(odd) { background: var(--bg); }
|
||||
.node-row:nth-child(even) { background: rgba(255, 255, 255, 0.01); }
|
||||
.node-row:nth-child(even) { background: var(--row-alt); }
|
||||
.node-row:hover {
|
||||
background: var(--bg-highlight);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
@@ -428,6 +287,8 @@ body {
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Version drift */
|
||||
.node-cell-version {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
@@ -460,6 +321,7 @@ body {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.node-cell-num {
|
||||
color: var(--fg-dim);
|
||||
font-size: 11px;
|
||||
@@ -564,53 +426,8 @@ body {
|
||||
.node-group-single .node-row { padding-left: 16px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Dashboard table — workstream detail views
|
||||
Dashboard table — console overrides
|
||||
========================================================================== */
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--code-bg);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
.dash-header-title {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.dash-header-summary { color: var(--fg-dim); font-size: 11px; }
|
||||
|
||||
.dash-colheaders {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dash-grid);
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.dash-col-tokens, .dash-col-ctx { text-align: right; }
|
||||
|
||||
.dash-table { min-height: 40px; }
|
||||
|
||||
.dash-row {
|
||||
position: relative;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: default;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.dash-row.has-link { cursor: pointer; }
|
||||
.dash-row.has-link::after {
|
||||
content: "\2197";
|
||||
@@ -624,44 +441,7 @@ body {
|
||||
}
|
||||
.dash-row.has-link:hover::after,
|
||||
.dash-row.has-link:focus-visible::after { opacity: 0.6; }
|
||||
.dash-row:nth-child(odd) { background: var(--bg); }
|
||||
.dash-row:nth-child(even) { background: rgba(255, 255, 255, 0.01); }
|
||||
.dash-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.dash-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.dash-row[data-state="running"] { border-left-color: var(--green); }
|
||||
.dash-row[data-state="thinking"] { border-left-color: var(--cyan); }
|
||||
.dash-row[data-state="attention"] { border-left-color: var(--yellow); }
|
||||
.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.6; }
|
||||
.dash-row[data-state="error"] { border-left-color: var(--red); }
|
||||
|
||||
.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 9px 16px 3px; align-items: center; font-size: 12px; }
|
||||
.dash-row-sub { padding: 0 16px 8px 88px; font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-row-sub.sub-attention { color: var(--yellow); }
|
||||
|
||||
/* State dots with LED glow */
|
||||
.dash-cell-state { display: flex; align-items: center; gap: 6px; font-size: 11px; }
|
||||
.dash-state-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); animation: pulse 2s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); animation: pulse 2.2s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1.8s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="idle"] { background: var(--fg-dim); opacity: 0.4; }
|
||||
.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; box-shadow: 0 0 6px var(--red-glow); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.dash-state-label { white-space: nowrap; font-weight: 500; }
|
||||
.dash-state-label[data-state="running"] { color: var(--green); }
|
||||
.dash-state-label[data-state="thinking"] { color: var(--cyan); }
|
||||
.dash-state-label[data-state="attention"] { color: var(--yellow); }
|
||||
.dash-state-label[data-state="idle"] { color: var(--fg-dim); }
|
||||
.dash-state-label[data-state="error"] { color: var(--red); }
|
||||
|
||||
.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); }
|
||||
.dash-cell-model { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-node {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
@@ -672,15 +452,6 @@ body {
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.dash-cell-node:hover { text-decoration: underline; color: var(--fg-bright); }
|
||||
.dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); }
|
||||
.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-ctx { text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-ctx.ctx-low { color: var(--green); }
|
||||
.dash-cell-ctx.ctx-mid { color: var(--yellow); }
|
||||
.dash-cell-ctx.ctx-high { color: var(--red); }
|
||||
.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: 600; }
|
||||
.dash-cell-ctx.ctx-idle { color: var(--fg-dim); }
|
||||
|
||||
/* ==========================================================================
|
||||
Node link
|
||||
@@ -729,246 +500,9 @@ body {
|
||||
.pagination button:disabled { opacity: 0.25; cursor: not-allowed; }
|
||||
|
||||
/* ==========================================================================
|
||||
Empty state
|
||||
Toast override — position above cluster status bar
|
||||
========================================================================== */
|
||||
.dashboard-empty {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Focus indicators
|
||||
========================================================================== */
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Scrollbar — thin, minimal
|
||||
========================================================================== */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion
|
||||
========================================================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dash-state-dot[data-state="running"],
|
||||
.dash-state-dot[data-state="thinking"],
|
||||
.dash-state-dot[data-state="attention"] { animation: none; opacity: 1; }
|
||||
.node-group-chevron { transition: none; }
|
||||
.health-bar-fill { transition: none; }
|
||||
.csb-state, .node-row, .node-group-header, .dash-row { transition: none; }
|
||||
.header-btn, .node-link, .dash-cell-node,
|
||||
#login-box input, #login-box button,
|
||||
.pagination button, #toast { transition: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
@media (max-width: 700px) {
|
||||
:root { --dash-grid: 68px 110px 1fr 56px 44px; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node { display: none; }
|
||||
.node-colheaders, .node-row { grid-template-columns: 1fr 40px 40px 40px 60px; }
|
||||
.node-group-header { grid-template-columns: 1fr 40px 40px 40px 60px; }
|
||||
.node-group-header .node-group-cell:last-child { display: none; }
|
||||
.ncol-version, .node-cell-version { display: none; }
|
||||
.ncol-health, .node-cell-health { display: none; }
|
||||
#main { padding: 16px; padding-bottom: 60px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
:root { --dash-grid: 50px 1fr 50px; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; }
|
||||
#cluster-status-bar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 6px; }
|
||||
.csb-states { flex-wrap: wrap; gap: 2px; }
|
||||
.csb-state { padding: 4px 6px; font-size: 11px; }
|
||||
.csb-divider { display: none; }
|
||||
.csb-metrics { width: 100%; justify-content: center; }
|
||||
#main { padding-bottom: 80px; }
|
||||
#header h1 { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Login overlay — cinematic
|
||||
========================================================================== */
|
||||
#login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#login-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 36px;
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px -20px var(--accent-dim);
|
||||
position: relative;
|
||||
}
|
||||
#login-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
#login-box h2 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#login-box input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#login-box input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
#login-box input::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
#login-box button {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#login-box button:hover { filter: brightness(1.1); }
|
||||
#login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
|
||||
#login-box button:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 380px) { #login-box { padding: 28px 20px; } }
|
||||
|
||||
/* ==========================================================================
|
||||
Keyboard shortcuts overlay
|
||||
========================================================================== */
|
||||
#kb-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
#kb-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
#kb-box h2 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kb-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 12px; }
|
||||
.kb-key {
|
||||
color: var(--fg-bright);
|
||||
background: var(--bg-highlight);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-desc { color: var(--fg-dim); font-family: var(--font-display); }
|
||||
.kb-section {
|
||||
font-family: var(--font-display);
|
||||
color: var(--fg-dim);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kb-section:first-child { margin-top: 0; }
|
||||
#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 16px; font-family: var(--font-display); }
|
||||
|
||||
/* ==========================================================================
|
||||
Toast notification
|
||||
========================================================================== */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg-bright);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
#toast { bottom: 56px; z-index: 200; color: var(--fg-bright); border-color: var(--border-strong); }
|
||||
|
||||
/* ==========================================================================
|
||||
Header accent button (+ new)
|
||||
@@ -1114,3 +648,40 @@ body {
|
||||
#new-ws-submit:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
|
||||
#new-ws-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
@media (max-width: 380px) { #new-ws-box { padding: 24px 18px; } }
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
@media (max-width: 700px) {
|
||||
:root { --dash-grid: 68px 110px 1fr 56px 44px; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node { display: none; }
|
||||
.node-colheaders, .node-row { grid-template-columns: 1fr 40px 40px 40px 60px; }
|
||||
.node-group-header { grid-template-columns: 1fr 40px 40px 40px 60px; }
|
||||
.node-group-header .node-group-cell:last-child { display: none; }
|
||||
.ncol-version, .node-cell-version { display: none; }
|
||||
.ncol-health, .node-cell-health { display: none; }
|
||||
#main { padding: 16px; padding-bottom: 60px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
:root { --dash-grid: 50px 1fr 50px; }
|
||||
.dash-col-model, .dash-cell-model, .dash-col-node, .dash-cell-node, .dash-col-task, .dash-cell-task, .dash-col-ctx, .dash-cell-ctx { display: none; }
|
||||
#cluster-status-bar { height: auto; flex-wrap: wrap; padding: 8px 12px; gap: 6px; }
|
||||
.csb-states { flex-wrap: wrap; gap: 2px; }
|
||||
.csb-state { padding: 4px 6px; font-size: 11px; }
|
||||
.csb-divider { display: none; }
|
||||
.csb-metrics { width: 100%; justify-content: center; }
|
||||
#main { padding-bottom: 80px; }
|
||||
#header h1 { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — console-specific
|
||||
========================================================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.node-group-chevron { transition: none; }
|
||||
.health-bar-fill { transition: none; }
|
||||
.csb-state, .node-row, .node-group-header { transition: none; }
|
||||
.node-link, .dash-cell-node, .pagination button { transition: none; }
|
||||
.dash-row.has-link::after, .node-group-header::before { transition: none; }
|
||||
#new-ws-box select, #new-ws-box input, #new-ws-buttons button { transition: none; }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ AUTH_COOKIE = "turnstone_auth"
|
||||
PUBLIC_PATHS: frozenset[str] = frozenset(
|
||||
{"/", "/health", "/metrics", "/api/auth/login", "/api/auth/logout"}
|
||||
)
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/",)
|
||||
PUBLIC_PREFIXES: tuple[str, ...] = ("/static/", "/shared/")
|
||||
|
||||
WRITE_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
|
||||
@@ -52,6 +52,7 @@ if TYPE_CHECKING:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent / "ui" / "static"
|
||||
_SHARED_DIR = Path(__file__).parent / "shared_static"
|
||||
_HTML = (_STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
_CSS = (_STATIC_DIR / "style.css").read_text(encoding="utf-8")
|
||||
_JS = (_STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||
@@ -1022,6 +1023,7 @@ def create_app(
|
||||
Route("/api/auth/login", auth_login, methods=["POST"]),
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
|
||||
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"),
|
||||
],
|
||||
middleware=[
|
||||
Middleware(MetricsMiddleware),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* Shared auth system — turnstone design system
|
||||
Configure: window.TURNSTONE_AUTH_TITLE (default "turnstone")
|
||||
Hooks: window.onLoginSuccess() and window.onLogout() */
|
||||
|
||||
var _AUTH_TITLE = window.TURNSTONE_AUTH_TITLE || "turnstone";
|
||||
var _loginTrapHandler = null;
|
||||
var _loginBusy = false;
|
||||
|
||||
async function authFetch(url, opts) {
|
||||
var maxRetries = 2;
|
||||
for (var attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
var r = await fetch(url, opts);
|
||||
if (r.status === 401) {
|
||||
showLogin();
|
||||
throw new Error("auth");
|
||||
}
|
||||
if (r.status === 429 && attempt < maxRetries) {
|
||||
var retryAfter = parseInt(r.headers.get("Retry-After") || "1", 10);
|
||||
showToast("Rate limited \u2014 retrying in " + retryAfter + "s");
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, retryAfter * 1000);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
function initLogin() {
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "login-overlay";
|
||||
overlay.style.display = "none";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "login-title");
|
||||
overlay.innerHTML =
|
||||
'<div id="login-box">' +
|
||||
'<h2 id="login-title">' +
|
||||
escapeHtml(_AUTH_TITLE) +
|
||||
"</h2>" +
|
||||
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
|
||||
'<label for="login-token" class="sr-only">Auth token</label>' +
|
||||
'<input id="login-token" type="password" placeholder="Enter auth token" autocomplete="off">' +
|
||||
'<button id="login-submit">Sign in</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("login-submit").onclick = submitLogin;
|
||||
document
|
||||
.getElementById("login-token")
|
||||
.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") submitLogin();
|
||||
if (e.key === "Escape") {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl && errEl.style.display !== "none") {
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.style.display = "flex";
|
||||
document.body.style.overflow = "hidden";
|
||||
var logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.style.display = "none";
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
}
|
||||
setTimeout(function () {
|
||||
var inp = document.getElementById("login-token");
|
||||
if (inp) {
|
||||
inp.value = "";
|
||||
inp.focus();
|
||||
}
|
||||
}, 50);
|
||||
if (_loginTrapHandler)
|
||||
document.removeEventListener("keydown", _loginTrapHandler);
|
||||
_loginTrapHandler = function (e) {
|
||||
if (e.key === "Tab") {
|
||||
var box = document.getElementById("login-box");
|
||||
var focusable = box.querySelectorAll("input, button");
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", _loginTrapHandler);
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (overlay) overlay.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
if (_loginTrapHandler) {
|
||||
document.removeEventListener("keydown", _loginTrapHandler);
|
||||
_loginTrapHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
if (_loginBusy) return;
|
||||
var token = (document.getElementById("login-token").value || "").trim();
|
||||
if (!token) {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.textContent = "Token is required";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
document.getElementById("login-token").focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_loginBusy = true;
|
||||
var btn = document.getElementById("login-submit");
|
||||
var inp = document.getElementById("login-token");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Signing in\u2026";
|
||||
inp.disabled = true;
|
||||
|
||||
fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: token }),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) throw new Error("invalid");
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
_loginBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sign in";
|
||||
inp.disabled = false;
|
||||
hideLogin();
|
||||
var logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.style.display = "";
|
||||
if (typeof window.onLoginSuccess === "function") window.onLoginSuccess();
|
||||
})
|
||||
.catch(function (err) {
|
||||
_loginBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sign in";
|
||||
inp.disabled = false;
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.textContent =
|
||||
err.message === "invalid"
|
||||
? "Invalid token"
|
||||
: "Connection failed \u2014 try again";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetch("/api/auth/logout", { method: "POST" }).then(function () {
|
||||
if (typeof window.onLogout === "function") window.onLogout();
|
||||
showLogin();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
/* ==========================================================================
|
||||
turnstone — shared design system ("Instrument Panel" aesthetic)
|
||||
Deep charcoal surfaces, warm amber indicators, precision typography
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* Surface palette — deep charcoal with blue undertone */
|
||||
--bg: #0b0f19;
|
||||
--bg-surface: #111827;
|
||||
--bg-highlight: #1c2333;
|
||||
--bg-elevated: #1f2a3d;
|
||||
|
||||
/* Text hierarchy */
|
||||
--fg: #d1d5e4;
|
||||
--fg-dim: #8a93ad;
|
||||
--fg-bright: #e8ecf4;
|
||||
|
||||
/* Accent — warm amber (the signature color) */
|
||||
--accent: #e5a042;
|
||||
--accent-dim: rgba(229, 160, 66, 0.15);
|
||||
--accent-glow: rgba(229, 160, 66, 0.08);
|
||||
|
||||
/* Semantic indicators */
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--yellow: #fbbf24;
|
||||
--cyan: #67e8f9;
|
||||
--magenta: #c084fc;
|
||||
|
||||
/* Glow variants for LED effects */
|
||||
--green-glow: rgba(52, 211, 153, 0.25);
|
||||
--red-glow: rgba(248, 113, 113, 0.25);
|
||||
--yellow-glow: rgba(251, 191, 36, 0.25);
|
||||
--accent-glow-strong: rgba(229, 160, 66, 0.3);
|
||||
--cyan-glow: rgba(103, 232, 249, 0.2);
|
||||
|
||||
/* Structure */
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--border-strong: rgba(255, 255, 255, 0.1);
|
||||
--code-bg: #0d1117;
|
||||
--radius: 6px;
|
||||
--radius-sm: 3px;
|
||||
--dash-grid: 72px 120px 90px 100px 1fr 60px 48px;
|
||||
--row-alt: rgba(255, 255, 255, 0.01);
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
--font-display: 'Outfit', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f3f4f6;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-highlight: #e9ecf0;
|
||||
--bg-elevated: #f9fafb;
|
||||
--fg: #1e293b;
|
||||
--fg-dim: #576275;
|
||||
--fg-bright: #0f172a;
|
||||
--accent: #8c5e1b;
|
||||
--accent-dim: rgba(140, 94, 27, 0.1);
|
||||
--accent-glow: rgba(140, 94, 27, 0.05);
|
||||
--green: #047857;
|
||||
--red: #dc2626;
|
||||
--yellow: #b45309;
|
||||
--cyan: #0e7490;
|
||||
--magenta: #7c3aed;
|
||||
--green-glow: rgba(4, 120, 87, 0.25);
|
||||
--red-glow: rgba(220, 38, 38, 0.25);
|
||||
--yellow-glow: rgba(180, 83, 9, 0.25);
|
||||
--accent-glow-strong: rgba(140, 94, 27, 0.15);
|
||||
--cyan-glow: rgba(14, 116, 144, 0.2);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
--code-bg: #f0f1f5;
|
||||
--row-alt: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(229, 160, 66, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(103, 232, 249, 0.02) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Header — base styles (page-specific CSS may override padding/gap)
|
||||
========================================================================== */
|
||||
#header {
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
#header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
|
||||
}
|
||||
#header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#status-bar { font-size: 11px; color: var(--fg-dim); margin-left: auto; }
|
||||
#status-bar.disconnected { color: var(--red); }
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--fg-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-bright);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Dashboard table — shared between server and console
|
||||
========================================================================== */
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--code-bg);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
.dash-header-title {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.dash-header-summary { color: var(--fg-dim); font-size: 11px; }
|
||||
|
||||
.dash-colheaders {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dash-grid);
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.dash-col-tokens, .dash-col-ctx { text-align: right; }
|
||||
|
||||
.dash-table { min-height: 40px; }
|
||||
.dash-row {
|
||||
position: relative;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: default;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.dash-row:nth-child(odd) { background: var(--bg); }
|
||||
.dash-row:nth-child(even) { background: var(--row-alt); }
|
||||
.dash-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.dash-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.dash-row[data-state="running"] { border-left-color: var(--green); }
|
||||
.dash-row[data-state="thinking"] { border-left-color: var(--cyan); }
|
||||
.dash-row[data-state="attention"] { border-left-color: var(--yellow); }
|
||||
.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.6; }
|
||||
.dash-row[data-state="error"] { border-left-color: var(--red); }
|
||||
.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 9px 16px 3px; align-items: center; font-size: 12px; }
|
||||
.dash-row-sub { padding: 0 16px 8px 88px; font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-row-sub.sub-attention { color: var(--yellow); }
|
||||
|
||||
/* State dots with LED glow */
|
||||
.dash-cell-state { display: flex; align-items: center; gap: 6px; font-size: 11px; }
|
||||
.dash-state-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); animation: pulse 2s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); animation: pulse 2.2s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1.8s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="idle"] { background: var(--fg-dim); opacity: 0.4; }
|
||||
.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; box-shadow: 0 0 6px var(--red-glow); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.dash-state-label { white-space: nowrap; font-weight: 500; }
|
||||
.dash-state-label[data-state="running"] { color: var(--green); }
|
||||
.dash-state-label[data-state="thinking"] { color: var(--cyan); }
|
||||
.dash-state-label[data-state="attention"] { color: var(--yellow); }
|
||||
.dash-state-label[data-state="idle"] { color: var(--fg-dim); }
|
||||
.dash-state-label[data-state="error"] { color: var(--red); }
|
||||
|
||||
.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); }
|
||||
.dash-cell-model { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-node { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); }
|
||||
.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-ctx { text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-ctx.ctx-low { color: var(--green); }
|
||||
.dash-cell-ctx.ctx-mid { color: var(--yellow); }
|
||||
.dash-cell-ctx.ctx-high { color: var(--red); }
|
||||
.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: 600; }
|
||||
.dash-cell-ctx.ctx-idle { color: var(--fg-dim); }
|
||||
|
||||
/* ==========================================================================
|
||||
Scrollbar — thin, minimal
|
||||
========================================================================== */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
|
||||
|
||||
/* ==========================================================================
|
||||
Focus indicators
|
||||
========================================================================== */
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Screen reader utility
|
||||
========================================================================== */
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* ==========================================================================
|
||||
Login overlay
|
||||
========================================================================== */
|
||||
#login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#login-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 36px;
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px -20px var(--accent-dim);
|
||||
position: relative;
|
||||
}
|
||||
#login-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
#login-box h2 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#login-box input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
#login-box input::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
#login-box button {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#login-box button:hover { filter: brightness(1.1); }
|
||||
#login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
|
||||
#login-box button:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
@media (max-width: 380px) { #login-box { padding: 28px 20px; } }
|
||||
|
||||
/* ==========================================================================
|
||||
Keyboard shortcuts overlay
|
||||
========================================================================== */
|
||||
#kb-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
#kb-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
#kb-box h2 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kb-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 12px; }
|
||||
.kb-key {
|
||||
color: var(--fg-bright);
|
||||
background: var(--bg-highlight);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-desc { color: var(--fg-dim); font-family: var(--font-display); }
|
||||
.kb-section {
|
||||
font-family: var(--font-display);
|
||||
color: var(--fg-dim);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kb-section:first-child { margin-top: 0; }
|
||||
#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 16px; font-family: var(--font-display); }
|
||||
|
||||
/* ==========================================================================
|
||||
Toast notification
|
||||
========================================================================== */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--bg-elevated, var(--bg-surface));
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Empty state
|
||||
========================================================================== */
|
||||
.dashboard-empty {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — base rules
|
||||
========================================================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dash-state-dot[data-state="running"],
|
||||
.dash-state-dot[data-state="thinking"],
|
||||
.dash-state-dot[data-state="attention"] { animation: none; opacity: 1; }
|
||||
.dash-row, .header-btn, #toast { transition: none; }
|
||||
#login-box input, #login-box button { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Shared keyboard shortcuts overlay — turnstone design system
|
||||
Configure: window.TURNSTONE_KB_SHORTCUTS = [{title, keys: [{desc, badge}]}] */
|
||||
|
||||
var _kbPreviousFocus = null;
|
||||
|
||||
function showKbHelp() {
|
||||
_kbPreviousFocus = document.activeElement;
|
||||
var existing = document.getElementById("kb-overlay");
|
||||
if (existing) existing.remove();
|
||||
var shortcuts = window.TURNSTONE_KB_SHORTCUTS || [];
|
||||
var html =
|
||||
'<div id="kb-box" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" tabindex="-1">' +
|
||||
"<h2>Keyboard shortcuts</h2>";
|
||||
shortcuts.forEach(function (section) {
|
||||
html += '<div class="kb-section">' + escapeHtml(section.title) + "</div>";
|
||||
section.keys.forEach(function (k) {
|
||||
html +=
|
||||
'<div class="kb-row"><span class="kb-desc">' +
|
||||
escapeHtml(k.desc) +
|
||||
"</span>" +
|
||||
k.badge +
|
||||
"</div>";
|
||||
});
|
||||
});
|
||||
html +=
|
||||
'<div class="kb-hint">Press <span class="kb-key">Esc</span> to close</div>' +
|
||||
"</div>";
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "kb-overlay";
|
||||
overlay.innerHTML = html;
|
||||
overlay.onclick = function (e) {
|
||||
if (e.target === overlay) hideKbHelp();
|
||||
};
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("kb-box").focus();
|
||||
}
|
||||
|
||||
function hideKbHelp() {
|
||||
var el = document.getElementById("kb-overlay");
|
||||
if (el) el.remove();
|
||||
if (_kbPreviousFocus && _kbPreviousFocus.focus) {
|
||||
_kbPreviousFocus.focus();
|
||||
_kbPreviousFocus = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
var login = document.getElementById("login-overlay");
|
||||
if (login && login.style.display !== "none") return;
|
||||
if (e.key === "?" && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
showKbHelp();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
var kb = document.getElementById("kb-overlay");
|
||||
if (kb) {
|
||||
e.preventDefault();
|
||||
hideKbHelp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Shared theme toggle — turnstone design system
|
||||
Hook: window.onThemeChange(nextTheme) called after toggle */
|
||||
|
||||
function toggleTheme() {
|
||||
var next = document.documentElement.dataset.theme === "light" ? "" : "light";
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem("turnstone-theme", next || "dark");
|
||||
if (typeof window.onThemeChange === "function") window.onThemeChange(next);
|
||||
}
|
||||
|
||||
(function initTheme() {
|
||||
var stored = localStorage.getItem("turnstone-theme");
|
||||
if (stored === "light") {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
} else if (
|
||||
!stored &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: light)").matches
|
||||
) {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Shared toast notification — turnstone design system
|
||||
Configure timeout via window.TURNSTONE_TOAST_TIMEOUT (default 3000ms) */
|
||||
|
||||
var _toastQueue = [];
|
||||
var _toastTimer = null;
|
||||
var _toastShowing = false;
|
||||
var _TOAST_TIMEOUT = window.TURNSTONE_TOAST_TIMEOUT || 3000;
|
||||
|
||||
function showToast(message) {
|
||||
var el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
if (_toastShowing) {
|
||||
_toastQueue.push(message);
|
||||
return;
|
||||
}
|
||||
_displayToast(el, message);
|
||||
}
|
||||
|
||||
function _displayToast(el, message) {
|
||||
el.textContent = message;
|
||||
el.classList.add("show");
|
||||
_toastShowing = true;
|
||||
if (_toastTimer) clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(function () {
|
||||
el.classList.remove("show");
|
||||
_toastShowing = false;
|
||||
_toastTimer = null;
|
||||
if (_toastQueue.length) {
|
||||
setTimeout(function () {
|
||||
_displayToast(el, _toastQueue.shift());
|
||||
}, 300);
|
||||
}
|
||||
}, _TOAST_TIMEOUT);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Shared utility functions — turnstone design system */
|
||||
|
||||
function escapeHtml(text) {
|
||||
var el = document.createElement("span");
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function formatTokens(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n || 0);
|
||||
}
|
||||
|
||||
function ctxClass(ratio) {
|
||||
if (ratio <= 0) return "ctx-idle";
|
||||
var pct = ratio * 100;
|
||||
if (pct < 30) return "ctx-low";
|
||||
if (pct < 50) return "ctx-mid";
|
||||
if (pct < 80) return "ctx-high";
|
||||
return "ctx-danger";
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return "";
|
||||
if (seconds < 60) return seconds + "s";
|
||||
var min = Math.floor(seconds / 60);
|
||||
if (min < 60) return min + "m";
|
||||
var hr = Math.floor(min / 60);
|
||||
return hr + "h " + (min % 60) + "m";
|
||||
}
|
||||
|
||||
function formatCount(n) {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n);
|
||||
}
|
||||
+46
-338
@@ -25,56 +25,6 @@ let dashboardVisible = false;
|
||||
let _historyNavigation = false; // true while popstate is driving navigation
|
||||
let _lastHealth = null;
|
||||
|
||||
/* Auth-aware fetch — shows login overlay on 401, retries on 429 */
|
||||
async function authFetch(url, opts) {
|
||||
var maxRetries = 2;
|
||||
for (var attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
var r = await fetch(url, opts);
|
||||
if (r.status === 401) {
|
||||
showLogin();
|
||||
throw new Error("auth");
|
||||
}
|
||||
if (r.status === 429 && attempt < maxRetries) {
|
||||
var retryAfter = parseInt(r.headers.get("Retry-After") || "1", 10);
|
||||
showToast("Rate limited \u2014 retrying in " + retryAfter + "s");
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, retryAfter * 1000);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
var _toastQueue = [];
|
||||
var _toastTimer = null;
|
||||
var _toastShowing = false;
|
||||
function showToast(message) {
|
||||
var el = document.getElementById("toast");
|
||||
if (!el) return;
|
||||
if (_toastShowing) {
|
||||
_toastQueue.push(message);
|
||||
return;
|
||||
}
|
||||
_displayToast(el, message);
|
||||
}
|
||||
function _displayToast(el, message) {
|
||||
el.textContent = message;
|
||||
el.classList.add("show");
|
||||
_toastShowing = true;
|
||||
if (_toastTimer) clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(function () {
|
||||
el.classList.remove("show");
|
||||
_toastShowing = false;
|
||||
_toastTimer = null;
|
||||
if (_toastQueue.length) {
|
||||
setTimeout(function () {
|
||||
_displayToast(el, _toastQueue.shift());
|
||||
}, 300);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function pollHealth() {
|
||||
authFetch("/health")
|
||||
.then(function (r) {
|
||||
@@ -117,200 +67,53 @@ function pollHealth() {
|
||||
}
|
||||
setInterval(pollHealth, 30000);
|
||||
|
||||
var _loginTrapHandler = null;
|
||||
var _loginBusy = false;
|
||||
|
||||
function initLogin() {
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "login-overlay";
|
||||
overlay.style.display = "none";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-labelledby", "login-title");
|
||||
overlay.innerHTML =
|
||||
'<div id="login-box">' +
|
||||
'<h2 id="login-title">turnstone</h2>' +
|
||||
'<div id="login-error" role="alert" aria-live="assertive"></div>' +
|
||||
'<label for="login-token" class="sr-only">Auth token</label>' +
|
||||
'<input id="login-token" type="password" placeholder="Enter auth token" autocomplete="off">' +
|
||||
'<button id="login-submit">Sign in</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("login-submit").onclick = submitLogin;
|
||||
document
|
||||
.getElementById("login-token")
|
||||
.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") submitLogin();
|
||||
if (e.key === "Escape") {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl && errEl.style.display !== "none") {
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (!overlay) return;
|
||||
overlay.style.display = "flex";
|
||||
document.body.style.overflow = "hidden";
|
||||
var logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) logoutBtn.style.display = "none";
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
}
|
||||
setTimeout(function () {
|
||||
var inp = document.getElementById("login-token");
|
||||
if (inp) {
|
||||
inp.value = "";
|
||||
inp.focus();
|
||||
}
|
||||
}, 50);
|
||||
// Focus trap
|
||||
if (_loginTrapHandler)
|
||||
document.removeEventListener("keydown", _loginTrapHandler);
|
||||
_loginTrapHandler = function (e) {
|
||||
if (e.key === "Tab") {
|
||||
var box = document.getElementById("login-box");
|
||||
var focusable = box.querySelectorAll("input, button");
|
||||
var first = focusable[0];
|
||||
var last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", _loginTrapHandler);
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
var overlay = document.getElementById("login-overlay");
|
||||
if (overlay) overlay.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
if (_loginTrapHandler) {
|
||||
document.removeEventListener("keydown", _loginTrapHandler);
|
||||
_loginTrapHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
if (_loginBusy) return;
|
||||
var token = (document.getElementById("login-token").value || "").trim();
|
||||
if (!token) {
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.textContent = "Token is required";
|
||||
errEl.style.display = "block";
|
||||
}
|
||||
document.getElementById("login-token").focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_loginBusy = true;
|
||||
var btn = document.getElementById("login-submit");
|
||||
var inp = document.getElementById("login-token");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Signing in\u2026";
|
||||
inp.disabled = true;
|
||||
|
||||
fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: token }),
|
||||
})
|
||||
// --- Shared hooks ---
|
||||
window.onLoginSuccess = function () {
|
||||
authFetch("/api/workstreams")
|
||||
.then(function (r) {
|
||||
if (r.status === 401 || r.status === 403) throw new Error("invalid");
|
||||
if (!r.ok) throw new Error("server");
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
_loginBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sign in";
|
||||
inp.disabled = false;
|
||||
hideLogin();
|
||||
document.getElementById("logout-btn").style.display = "";
|
||||
// Re-initialize: fetch workstreams, connect SSE
|
||||
authFetch("/api/workstreams")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
data.workstreams.forEach(function (ws) {
|
||||
workstreams[ws.id] = { name: ws.name, state: ws.state };
|
||||
});
|
||||
var wsIds = Object.keys(workstreams);
|
||||
if (wsIds.length) {
|
||||
currentWsId = wsIds[0];
|
||||
renderTabBar();
|
||||
}
|
||||
connectGlobalSSE();
|
||||
var params = new URLSearchParams(location.search);
|
||||
var targetWs = params.get("ws_id");
|
||||
if (targetWs && workstreams[targetWs]) {
|
||||
history.replaceState(
|
||||
{ turnstone: "workstream", wsId: targetWs },
|
||||
"",
|
||||
location.pathname,
|
||||
);
|
||||
_historyNavigation = true;
|
||||
try {
|
||||
switchTab(targetWs);
|
||||
} finally {
|
||||
_historyNavigation = false;
|
||||
}
|
||||
} else {
|
||||
if (currentWsId) connectContentSSE(currentWsId);
|
||||
history.replaceState(
|
||||
{ turnstone: "dashboard" },
|
||||
"",
|
||||
location.pathname,
|
||||
);
|
||||
showDashboard();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
_loginBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Sign in";
|
||||
inp.disabled = false;
|
||||
var errEl = document.getElementById("login-error");
|
||||
if (errEl) {
|
||||
errEl.textContent =
|
||||
err.message === "invalid"
|
||||
? "Invalid token"
|
||||
: "Connection failed \u2014 try again";
|
||||
errEl.style.display = "block";
|
||||
.then(function (data) {
|
||||
data.workstreams.forEach(function (ws) {
|
||||
workstreams[ws.id] = { name: ws.name, state: ws.state };
|
||||
});
|
||||
var wsIds = Object.keys(workstreams);
|
||||
if (wsIds.length) {
|
||||
currentWsId = wsIds[0];
|
||||
renderTabBar();
|
||||
}
|
||||
connectGlobalSSE();
|
||||
var params = new URLSearchParams(location.search);
|
||||
var targetWs = params.get("ws_id");
|
||||
if (targetWs && workstreams[targetWs]) {
|
||||
history.replaceState(
|
||||
{ turnstone: "workstream", wsId: targetWs },
|
||||
"",
|
||||
location.pathname,
|
||||
);
|
||||
_historyNavigation = true;
|
||||
try {
|
||||
switchTab(targetWs);
|
||||
} finally {
|
||||
_historyNavigation = false;
|
||||
}
|
||||
} else {
|
||||
if (currentWsId) connectContentSSE(currentWsId);
|
||||
history.replaceState({ turnstone: "dashboard" }, "", location.pathname);
|
||||
showDashboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
fetch("/api/auth/logout", { method: "POST" }).then(function () {
|
||||
if (contentEvtSource) {
|
||||
contentEvtSource.close();
|
||||
contentEvtSource = null;
|
||||
}
|
||||
if (globalEvtSource) {
|
||||
globalEvtSource.close();
|
||||
globalEvtSource = null;
|
||||
}
|
||||
showLogin();
|
||||
});
|
||||
}
|
||||
};
|
||||
window.onLogout = function () {
|
||||
if (contentEvtSource) {
|
||||
contentEvtSource.close();
|
||||
contentEvtSource = null;
|
||||
}
|
||||
if (globalEvtSource) {
|
||||
globalEvtSource.close();
|
||||
globalEvtSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Dashboard helpers ---
|
||||
var STATE_DISPLAY = {
|
||||
@@ -320,38 +123,9 @@ var STATE_DISPLAY = {
|
||||
idle: { symbol: "\u00b7", label: "idle" },
|
||||
error: { symbol: "\u2716", label: "err" },
|
||||
};
|
||||
function formatTokens(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + "M";
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
|
||||
return String(n || 0);
|
||||
}
|
||||
function ctxClass(ratio) {
|
||||
if (ratio <= 0) return "ctx-idle";
|
||||
var pct = ratio * 100;
|
||||
if (pct < 30) return "ctx-low";
|
||||
if (pct < 50) return "ctx-mid";
|
||||
if (pct < 80) return "ctx-high";
|
||||
return "ctx-danger";
|
||||
}
|
||||
function formatUptime(seconds) {
|
||||
if (seconds < 60) return seconds + "s";
|
||||
var min = Math.floor(seconds / 60);
|
||||
if (min < 60) return min + "m";
|
||||
var hr = Math.floor(min / 60);
|
||||
return hr + "h " + (min % 60) + "m";
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
function toggleTheme() {
|
||||
var current = document.documentElement.dataset.theme;
|
||||
var next = current === "light" ? "" : "light";
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem("turnstone-theme", next || "dark");
|
||||
updateThemeMenuItem();
|
||||
}
|
||||
// --- Theme hooks ---
|
||||
function updateThemeMenuItem() {
|
||||
var isLight = document.documentElement.dataset.theme === "light";
|
||||
// Show the target state icon+label (what you will switch to)
|
||||
document.getElementById("theme-menu-icon").textContent = isLight
|
||||
? "\u263E"
|
||||
: "\u2600";
|
||||
@@ -367,11 +141,10 @@ function updateThemeMenuItem() {
|
||||
: "Switch to light mode (currently dark)",
|
||||
);
|
||||
}
|
||||
(function () {
|
||||
if (localStorage.getItem("turnstone-theme") === "light")
|
||||
document.documentElement.dataset.theme = "light";
|
||||
window.onThemeChange = function () {
|
||||
updateThemeMenuItem();
|
||||
})();
|
||||
};
|
||||
updateThemeMenuItem();
|
||||
|
||||
// --- Hamburger menu ---
|
||||
function toggleHamburger() {
|
||||
@@ -582,12 +355,6 @@ function inlineMarkdown(text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// === Tab / Workstream management ===
|
||||
|
||||
function renderTabBar() {
|
||||
@@ -1967,65 +1734,6 @@ document.addEventListener("keydown", function (e) {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Keyboard shortcuts help ---
|
||||
function showKbHelp() {
|
||||
var existing = document.getElementById("kb-overlay");
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
var overlay = document.createElement("div");
|
||||
overlay.id = "kb-overlay";
|
||||
overlay.innerHTML =
|
||||
'<div id="kb-box" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">' +
|
||||
"<h2>Keyboard shortcuts</h2>" +
|
||||
'<div class="kb-section">Workstreams</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Toggle dashboard</span><span class="kb-key">Ctrl+D</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">New workstream</span><span class="kb-key">Ctrl+T</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Close workstream</span><span class="kb-key">Ctrl+W</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Switch to tab 1\u20139</span><span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span></div>' +
|
||||
'<div class="kb-section">Tool approval</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Approve</span><span class="kb-key">y</span> / <span class="kb-key">Enter</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Deny</span><span class="kb-key">n</span> / <span class="kb-key">Esc</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Always approve</span><span class="kb-key">a</span></div>' +
|
||||
'<div class="kb-section">Chat</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Send message</span><span class="kb-key">Enter</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">New line</span><span class="kb-key">Shift+Enter</span></div>' +
|
||||
'<div class="kb-section">Navigation</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Navigate table rows</span><span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span></div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Close dashboard / menu</span><span class="kb-key">Esc</span></div>' +
|
||||
'<div class="kb-section">General</div>' +
|
||||
'<div class="kb-row"><span class="kb-desc">Show this help</span><span class="kb-key">?</span></div>' +
|
||||
'<div class="kb-hint">Press <span class="kb-key">Esc</span> to close</div>' +
|
||||
"</div>";
|
||||
overlay.onclick = function (e) {
|
||||
if (e.target === overlay) hideKbHelp();
|
||||
};
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById("kb-box").focus();
|
||||
}
|
||||
function hideKbHelp() {
|
||||
var el = document.getElementById("kb-overlay");
|
||||
if (el) el.remove();
|
||||
}
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
var login = document.getElementById("login-overlay");
|
||||
if (login && login.style.display !== "none") return;
|
||||
if (e.key === "?" && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
showKbHelp();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
var kb = document.getElementById("kb-overlay");
|
||||
if (kb) {
|
||||
e.preventDefault();
|
||||
hideKbHelp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Init: fetch workstream list, then connect ---
|
||||
initLogin();
|
||||
pollHealth();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/shared/base.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -89,6 +90,38 @@
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<script>
|
||||
window.TURNSTONE_AUTH_TITLE = "turnstone";
|
||||
window.TURNSTONE_KB_SHORTCUTS = [
|
||||
{ title: "Workstreams", keys: [
|
||||
{ desc: "Toggle dashboard", badge: '<span class="kb-key">Ctrl+D</span>' },
|
||||
{ desc: "New workstream", badge: '<span class="kb-key">Ctrl+T</span>' },
|
||||
{ desc: "Close workstream", badge: '<span class="kb-key">Ctrl+W</span>' },
|
||||
{ desc: "Switch to tab 1\u20139", badge: '<span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span>' }
|
||||
]},
|
||||
{ title: "Tool approval", keys: [
|
||||
{ desc: "Approve", badge: '<span class="kb-key">y</span> / <span class="kb-key">Enter</span>' },
|
||||
{ desc: "Deny", badge: '<span class="kb-key">n</span> / <span class="kb-key">Esc</span>' },
|
||||
{ desc: "Always approve", badge: '<span class="kb-key">a</span>' }
|
||||
]},
|
||||
{ title: "Chat", keys: [
|
||||
{ desc: "Send message", badge: '<span class="kb-key">Enter</span>' },
|
||||
{ desc: "New line", badge: '<span class="kb-key">Shift+Enter</span>' }
|
||||
]},
|
||||
{ title: "Navigation", keys: [
|
||||
{ desc: "Navigate table rows", badge: '<span class="kb-key">\u2191</span> <span class="kb-key">\u2193</span>' },
|
||||
{ desc: "Close dashboard / menu", badge: '<span class="kb-key">Esc</span>' }
|
||||
]},
|
||||
{ title: "General", keys: [
|
||||
{ desc: "Show this help", badge: '<span class="kb-key">?</span>' }
|
||||
]}
|
||||
];
|
||||
</script>
|
||||
<script src="/shared/utils.js"></script>
|
||||
<script src="/shared/toast.js"></script>
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/auth.js"></script>
|
||||
<script src="/shared/kb.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+10
-421
@@ -1,135 +1,17 @@
|
||||
/* ==========================================================================
|
||||
turnstone server UI — "Instrument Panel" aesthetic
|
||||
Shared design system with turnstone console
|
||||
turnstone server UI — page-specific styles
|
||||
Design tokens, reset, shared components live in /shared/base.css
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* Surface palette — deep charcoal with blue undertone */
|
||||
--bg: #0b0f19;
|
||||
--bg-surface: #111827;
|
||||
--bg-highlight: #1c2333;
|
||||
--bg-elevated: #1f2a3d;
|
||||
|
||||
/* Text hierarchy */
|
||||
--fg: #d1d5e4;
|
||||
--fg-dim: #8a93ad;
|
||||
--fg-bright: #e8ecf4;
|
||||
|
||||
/* Accent — warm amber (the signature color) */
|
||||
--accent: #e5a042;
|
||||
--accent-dim: rgba(229, 160, 66, 0.15);
|
||||
--accent-glow: rgba(229, 160, 66, 0.08);
|
||||
|
||||
/* Semantic indicators */
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--yellow: #fbbf24;
|
||||
--cyan: #67e8f9;
|
||||
--magenta: #c084fc;
|
||||
|
||||
/* Glow variants for LED effects */
|
||||
--green-glow: rgba(52, 211, 153, 0.25);
|
||||
--red-glow: rgba(248, 113, 113, 0.25);
|
||||
--yellow-glow: rgba(251, 191, 36, 0.25);
|
||||
--accent-glow-strong: rgba(229, 160, 66, 0.3);
|
||||
--cyan-glow: rgba(103, 232, 249, 0.2);
|
||||
|
||||
/* Structure */
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--border-strong: rgba(255, 255, 255, 0.1);
|
||||
--code-bg: #0d1117;
|
||||
--radius: 6px;
|
||||
--radius-sm: 3px;
|
||||
--dash-grid: 72px 120px 90px 100px 1fr 60px 48px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
--font-display: 'Outfit', 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #f3f4f6;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-highlight: #e9ecf0;
|
||||
--bg-elevated: #f9fafb;
|
||||
--fg: #1e293b;
|
||||
--fg-dim: #576275;
|
||||
--fg-bright: #0f172a;
|
||||
--accent: #8c5e1b;
|
||||
--accent-dim: rgba(140, 94, 27, 0.1);
|
||||
--accent-glow: rgba(140, 94, 27, 0.05);
|
||||
--green: #047857;
|
||||
--red: #dc2626;
|
||||
--yellow: #b45309;
|
||||
--cyan: #0e7490;
|
||||
--magenta: #7c3aed;
|
||||
--green-glow: rgba(4, 120, 87, 0.25);
|
||||
--red-glow: rgba(220, 38, 38, 0.25);
|
||||
--yellow-glow: rgba(180, 83, 9, 0.25);
|
||||
--accent-glow-strong: rgba(140, 94, 27, 0.15);
|
||||
--cyan-glow: rgba(14, 116, 144, 0.2);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-strong: rgba(0, 0, 0, 0.12);
|
||||
--code-bg: #f0f1f5;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(229, 160, 66, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(103, 232, 249, 0.02) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Header
|
||||
Header — server-specific elements
|
||||
========================================================================== */
|
||||
#header {
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
#header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
|
||||
}
|
||||
#header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#model-name {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#status-bar { font-size: 11px; color: var(--fg-dim); margin-left: auto; }
|
||||
#status-bar.disconnected { color: var(--red); }
|
||||
#health-indicator {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
@@ -158,24 +40,6 @@ body {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--fg-dim);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg-bright);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Hamburger menu
|
||||
========================================================================== */
|
||||
@@ -290,11 +154,6 @@ body {
|
||||
.ws-tab .tab-indicator[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1s ease-in-out infinite; will-change: opacity; }
|
||||
.ws-tab .tab-indicator[data-state="error"] { background: var(--red); box-shadow: 0 0 4px var(--red-glow); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.ws-tab .tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -641,17 +500,8 @@ body {
|
||||
#btn-plan-reject { background: var(--red); color: var(--bg); }
|
||||
|
||||
/* ==========================================================================
|
||||
Scrollbar
|
||||
Focus indicators — server-specific overrides
|
||||
========================================================================== */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
|
||||
|
||||
/* ==========================================================================
|
||||
Focus indicators
|
||||
========================================================================== */
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
#input-area textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
.approval-btn:focus-visible { outline-offset: 1px; }
|
||||
|
||||
@@ -730,96 +580,9 @@ body {
|
||||
.dashboard-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.dashboard-card .card-title { font-size: 13px; color: var(--fg-bright); font-weight: 500; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dashboard-card .card-meta { font-size: 11px; color: var(--fg-dim); }
|
||||
.dashboard-empty { color: var(--fg-dim); font-size: 12px; padding: 12px 0; font-family: var(--font-display); font-style: italic; opacity: 0.7; }
|
||||
|
||||
/* ==========================================================================
|
||||
Dashboard table — shared with console
|
||||
========================================================================== */
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--code-bg);
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
.dash-header-title {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.dash-header-summary { color: var(--fg-dim); font-size: 11px; }
|
||||
|
||||
.dash-colheaders {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dash-grid);
|
||||
padding: 6px 16px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.dash-col-tokens, .dash-col-ctx { text-align: right; }
|
||||
|
||||
.dash-table { min-height: 40px; }
|
||||
.dash-row {
|
||||
position: relative;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.dash-row:nth-child(odd) { background: var(--bg); }
|
||||
.dash-row:nth-child(even) { background: rgba(255, 255, 255, 0.01); }
|
||||
.dash-row:hover { background: var(--bg-highlight); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.dash-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.dash-row[data-state="running"] { border-left-color: var(--green); }
|
||||
.dash-row[data-state="thinking"] { border-left-color: var(--cyan); }
|
||||
.dash-row[data-state="attention"] { border-left-color: var(--yellow); }
|
||||
.dash-row[data-state="idle"] { border-left-color: var(--fg-dim); opacity: 0.6; }
|
||||
.dash-row[data-state="error"] { border-left-color: var(--red); }
|
||||
.dash-row-main { display: grid; grid-template-columns: var(--dash-grid); padding: 9px 16px 3px; align-items: center; font-size: 12px; }
|
||||
.dash-row-sub { padding: 0 16px 8px 88px; font-size: 11px; color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-row-sub.sub-attention { color: var(--yellow); }
|
||||
|
||||
/* State dots with LED glow */
|
||||
.dash-cell-state { display: flex; align-items: center; gap: 6px; font-size: 11px; }
|
||||
.dash-state-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dash-state-dot[data-state="running"] { background: var(--green); border-radius: 2px; box-shadow: 0 0 6px var(--green-glow); animation: pulse 2s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="thinking"] { background: var(--cyan); box-shadow: 0 0 6px var(--cyan-glow); animation: pulse 2.2s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1.8s infinite; will-change: opacity; }
|
||||
.dash-state-dot[data-state="idle"] { background: var(--fg-dim); opacity: 0.4; }
|
||||
.dash-state-dot[data-state="error"] { background: var(--red); border-radius: 0; box-shadow: 0 0 6px var(--red-glow); }
|
||||
.dash-state-label { white-space: nowrap; font-weight: 500; }
|
||||
.dash-state-label[data-state="running"] { color: var(--green); }
|
||||
.dash-state-label[data-state="thinking"] { color: var(--cyan); }
|
||||
.dash-state-label[data-state="attention"] { color: var(--yellow); }
|
||||
.dash-state-label[data-state="idle"] { color: var(--fg-dim); }
|
||||
.dash-state-label[data-state="error"] { color: var(--red); }
|
||||
|
||||
.dash-cell-name { font-weight: 500; color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-name { color: var(--fg-dim); }
|
||||
.dash-cell-model { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-node { color: var(--fg-dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cell-task { color: var(--fg-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-row[data-state="idle"] .dash-cell-task { color: var(--fg-dim); }
|
||||
.dash-cell-tokens { text-align: right; color: var(--fg-dim); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-ctx { text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.dash-cell-ctx.ctx-low { color: var(--green); }
|
||||
.dash-cell-ctx.ctx-mid { color: var(--yellow); }
|
||||
.dash-cell-ctx.ctx-high { color: var(--red); }
|
||||
.dash-cell-ctx.ctx-danger { color: var(--red); font-weight: 600; }
|
||||
.dash-cell-ctx.ctx-idle { color: var(--fg-dim); }
|
||||
/* Server dashboard row — clickable */
|
||||
.dash-row { cursor: pointer; }
|
||||
|
||||
/* Dashboard footer */
|
||||
.dash-footer {
|
||||
@@ -857,192 +620,18 @@ body {
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion
|
||||
Reduced motion — page-specific
|
||||
========================================================================== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ws-tab .tab-indicator[data-state="thinking"],
|
||||
.ws-tab .tab-indicator[data-state="running"],
|
||||
.ws-tab .tab-indicator[data-state="attention"],
|
||||
.dash-state-dot[data-state="running"],
|
||||
.dash-state-dot[data-state="thinking"],
|
||||
.dash-state-dot[data-state="attention"] { animation: none; opacity: 1; }
|
||||
.ws-tab .tab-indicator[data-state="attention"] { animation: none; opacity: 1; }
|
||||
.tool-output-stream { animation: none; border-left-color: var(--accent); }
|
||||
.thinking-indicator::after { animation: none; content: '...'; }
|
||||
.ws-tab, .ws-tab .tab-close, #new-tab-btn,
|
||||
.header-btn, .hmenu-item, .dashboard-card,
|
||||
.hmenu-item, .dashboard-card,
|
||||
.approval-btn, .approval-feedback-input,
|
||||
#plan-buttons button, #input-area button,
|
||||
.dashboard-new-btn, .dashboard-input,
|
||||
.dash-row, #toast { transition: none; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Login overlay
|
||||
========================================================================== */
|
||||
#login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#login-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 36px;
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03),
|
||||
0 24px 48px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px -20px var(--accent-dim);
|
||||
position: relative;
|
||||
}
|
||||
#login-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
#login-box h2 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#login-box input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#login-box input:focus-visible { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
#login-box input::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
#login-box button {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
#login-box button:hover { filter: brightness(1.1); }
|
||||
#login-box button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
|
||||
#login-box button:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
#login-error { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 380px) { #login-box { padding: 28px 20px; } }
|
||||
|
||||
/* ==========================================================================
|
||||
Keyboard shortcuts overlay
|
||||
========================================================================== */
|
||||
#kb-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
#kb-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
#kb-box h2 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kb-row { display: flex; justify-content: space-between; padding: 5px 0; font-size: 12px; }
|
||||
.kb-key {
|
||||
color: var(--fg-bright);
|
||||
background: var(--bg-highlight);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-desc { color: var(--fg-dim); font-family: var(--font-display); }
|
||||
.kb-section {
|
||||
font-family: var(--font-display);
|
||||
color: var(--fg-dim);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.kb-section:first-child { margin-top: 0; }
|
||||
#kb-box .kb-hint { color: var(--fg-dim); font-size: 11px; text-align: center; margin-top: 16px; font-family: var(--font-display); }
|
||||
|
||||
/* ==========================================================================
|
||||
Toast notification
|
||||
========================================================================== */
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--bg-elevated, var(--bg-surface));
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
max-width: 90vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
#health-indicator, #hamburger-btn { transition: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user