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:
Patrick Buckley
2026-03-03 20:11:49 -08:00
committed by GitHub
parent b6e0f0fcca
commit 29c00c0cdf
20 changed files with 1179 additions and 1562 deletions
+6 -3
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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]
+10
View File
@@ -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."""
+154
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+21
View File
@@ -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">
+48 -477
View File
@@ -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; }
}
+1 -1
View File
@@ -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(
{
+2
View File
@@ -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),
+176
View File
@@ -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();
});
}
+453
View File
@@ -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; }
}
+64
View File
@@ -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;
}
}
});
+22
View File
@@ -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";
}
})();
+34
View File
@@ -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);
}
+36
View File
@@ -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
View File
@@ -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();
+33
View File
@@ -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
View File
@@ -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; }
}