use crate::read_model::{feed_for_session, permalink, verify_chain, FeedEvent};
use crate::{CliError, CliResult};
use clap::Args;
use rusqlite::Connection;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Args)]
pub struct DbServeArgs {
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
#[arg(long, default_value_t = 8787)]
pub port: u16,
#[arg(long, help = "serve one request, then exit")]
pub once: bool,
#[arg(
long,
help = "import outputs/ artifacts before each render (opt-in; OFF by default — does not change the ADR 025 DB-read-only default)"
)]
pub auto_import: bool,
#[arg(
long,
default_value = "outputs",
help = "runtime outputs root: used for --auto-import imports and, when --allow-writes is set, as the artifact root the browser composer send writes under (ADR 031 D3)"
)]
pub root: PathBuf,
#[arg(
long,
help = "enable browser-originated writes (ADR 031); OFF by default — the dashboard is read-only unless set."
)]
pub allow_writes: bool,
#[arg(
long,
default_value = "herdr",
help = "herdr executable the composer send shells out to (ADR 031)."
)]
pub herdr_bin: String,
}
struct WriteConfig {
token: String,
authority: String,
herdr_bin: String,
}
struct DashboardSession {
session_id: String,
title: String,
phase: String,
mode: String,
workflow_status: String,
lead_agent_id: String,
artifact_ref: String,
updated_at: String,
next_action: String,
blockers: String,
asks_for_zevs: String,
risk_or_residual_uncertainty: String,
expected_wait: String,
}
pub fn serve(path: &Path, args: DbServeArgs) -> CliResult<()> {
if args.host != "127.0.0.1" {
return Err(CliError::usage(
"db dashboard server binds only to 127.0.0.1 in v0.2",
));
}
crate::db::open_database(path)?;
let listener = TcpListener::bind((args.host.as_str(), args.port)).map_err(|error| {
CliError::failure(format!(
"failed to bind dashboard server on {}:{}: {error}",
args.host, args.port
))
})?;
let address = listener.local_addr().map_err(|error| {
CliError::failure(format!(
"failed to read dashboard listener address: {error}"
))
})?;
println!("listening on http://{address}/");
std::io::stdout()
.flush()
.map_err(|error| CliError::failure(format!("failed to flush dashboard URL: {error}")))?;
let writes = args.allow_writes.then(|| WriteConfig {
token: crate::dashboard_write::mint_csrf_token(),
authority: address.to_string(),
herdr_bin: args.herdr_bin.clone(),
});
if args.once {
let (stream, _) = listener.accept().map_err(|error| {
CliError::failure(format!("failed to accept dashboard request: {error}"))
})?;
return handle_connection(stream, path, args.auto_import, &args.root, writes.as_ref());
}
for stream in listener.incoming() {
let stream = stream.map_err(|error| {
CliError::failure(format!("failed to accept dashboard request: {error}"))
})?;
handle_connection(stream, path, args.auto_import, &args.root, writes.as_ref())?;
}
Ok(())
}
fn handle_connection(
mut stream: TcpStream,
path: &Path,
auto_import: bool,
root: &Path,
writes: Option<&WriteConfig>,
) -> CliResult<()> {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.map_err(|error| CliError::failure(format!("failed to set read timeout: {error}")))?;
let (head, raw_body) = read_http_request(&mut stream)?;
let request = crate::dashboard_write::parse_request(&head, raw_body);
let (status, content_type, body, location) =
if request.method == "POST" && request.route == "/send" {
match writes {
Some(config) => match crate::dashboard_write::authorize_write(
&request,
&config.authority,
&config.token,
) {
Ok(()) => {
match crate::dashboard_write::handle_send(
&request,
path,
root,
&config.herdr_bin,
) {
crate::dashboard_write::WriteOutcome::Redirect(loc) => (
"303 See Other",
"text/plain; charset=utf-8",
String::new(),
Some(loc),
),
crate::dashboard_write::WriteOutcome::Error { status, message } => (
status,
"text/html; charset=utf-8",
format!(
"<!doctype html><meta charset=\"utf-8\"><pre>{}</pre>",
escape_html(&message)
),
None,
),
}
}
Err(error) => (
"403 Forbidden",
"text/plain; charset=utf-8",
format!("{}\n", error.message),
None,
),
},
None => (
"405 Method Not Allowed",
"text/plain; charset=utf-8",
"writes disabled\n".to_string(),
None,
),
}
} else if !matches!(request.method.as_str(), "GET" | "HEAD") {
(
"405 Method Not Allowed",
"text/plain; charset=utf-8",
"method not allowed\n".to_string(),
None,
)
} else if matches!(request.route.as_str(), "/" | "/index.html") {
if auto_import {
crate::db::import_outputs_root(path, root)?;
}
let connection = crate::db::open_read_database(path)?;
(
"200 OK",
"text/html; charset=utf-8",
render_dashboard(
&connection,
query_param(&request.query, "session").as_deref(),
writes.map(|config| config.token.as_str()),
)?,
None,
)
} else if matches!(request.route.as_str(), "/audit") {
if auto_import {
crate::db::import_outputs_root(path, root)?;
}
let connection = crate::db::open_read_database(path)?;
(
"200 OK",
"text/html; charset=utf-8",
render_audit(
&connection,
query_param(&request.query, "session").as_deref(),
)?,
None,
)
} else {
(
"404 Not Found",
"text/plain; charset=utf-8",
"not found\n".to_string(),
None,
)
};
match location {
Some(target) => redirect_response(&mut stream, &target),
None => write_response(&mut stream, status, content_type, &body),
}
}
fn redirect_response(stream: &mut TcpStream, location: &str) -> CliResult<()> {
let response = format!(
"HTTP/1.1 303 See Other\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream
.write_all(response.as_bytes())
.map_err(|error| CliError::failure(format!("failed to write redirect: {error}")))
}
fn query_param(query: &str, name: &str) -> Option<String> {
query.split('&').find_map(|part| {
let (key, value) = part.split_once('=')?;
(key == name).then(|| percent_decode(value))
})
}
fn read_http_request(stream: &mut TcpStream) -> CliResult<(String, Vec<u8>)> {
let mut buf = Vec::new();
let mut chunk = [0_u8; 1024];
let header_end = loop {
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
if buf.len() > 16_384 {
break buf.len();
}
let count = stream.read(&mut chunk).map_err(|error| {
CliError::failure(format!("failed to read dashboard request: {error}"))
})?;
if count == 0 {
break buf.len();
}
buf.extend_from_slice(&chunk[..count]);
};
let head = String::from_utf8_lossy(&buf[..header_end.min(buf.len())]).to_string();
let mut body = buf.get(header_end..).unwrap_or(&[]).to_vec();
let content_length = head
.split("\r\n")
.filter_map(|line| line.split_once(':'))
.find(|(key, _)| key.trim().eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0)
.min(1_048_576);
while body.len() < content_length {
let count = stream.read(&mut chunk).map_err(|error| {
CliError::failure(format!("failed to read dashboard body: {error}"))
})?;
if count == 0 {
break;
}
body.extend_from_slice(&chunk[..count]);
}
body.truncate(content_length);
Ok((head, body))
}
fn write_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
body: &str,
) -> CliResult<()> {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.map_err(|error| CliError::failure(format!("failed to write dashboard response: {error}")))
}
pub(crate) fn known_targets(connection: &Connection, session_id: &str) -> CliResult<Vec<String>> {
let mut statement = connection
.prepare(
"SELECT DISTINCT agent_id || ':' || address AS target FROM (
SELECT target_agent_id AS agent_id, target_address AS address
FROM audit_records WHERE session_id = ?1
UNION
SELECT source_agent_id AS agent_id, source_address AS address
FROM audit_records WHERE session_id = ?1
)
WHERE agent_id IS NOT NULL AND address IS NOT NULL
ORDER BY target",
)
.map_err(|error| CliError::failure(format!("failed to prepare known_targets: {error}")))?;
let rows = statement
.query_map([session_id], |row| row.get::<_, String>(0))
.map_err(|error| CliError::failure(format!("failed to query known_targets: {error}")))?;
let mut targets = Vec::new();
for row in rows {
targets.push(
row.map_err(|error| CliError::failure(format!("failed to read target: {error}")))?,
);
}
Ok(targets)
}
pub(crate) fn session_exists(connection: &Connection, session_id: &str) -> CliResult<bool> {
let count: i64 = connection
.query_row(
"SELECT COUNT(*) FROM sessions WHERE session_id = ?1",
[session_id],
|row| row.get(0),
)
.map_err(|error| CliError::failure(format!("failed to check session: {error}")))?;
Ok(count > 0)
}
fn render_dashboard(
connection: &Connection,
selected_session_id: Option<&str>,
csrf_token: Option<&str>,
) -> CliResult<String> {
let sessions = load_sessions(connection)?;
let selected = selected_session_id
.and_then(|session_id| {
sessions
.iter()
.find(|session| session.session_id == session_id)
})
.or_else(|| sessions.first());
let selected_id = selected.map(|session| session.session_id.as_str());
let feed = match selected_id {
Some(id) => feed_for_session(connection, id)?,
None => Vec::new(),
};
let mut html = String::new();
html.push_str("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.push_str("<title>zynk dashboard</title><style>");
html.push_str(STYLES);
html.push_str("</style></head><body>");
html.push_str("<div class=\"app-shell\">");
html.push_str("<aside class=\"sidebar\"><div class=\"brand\">zynk</div><nav>");
if sessions.is_empty() {
html.push_str("<p class=\"empty\">No sessions in this database.</p>");
} else {
for session in &sessions {
html.push_str(&format!(
"<a class=\"session-link\" href=\"/?session={}\"><span>{}</span><span class=\"badge status-{}\">{}</span></a>",
escape_url_component(&session.session_id),
escape_html(&session.session_id),
escape_class(&session.workflow_status),
escape_html(&session.workflow_status),
));
}
}
html.push_str("</nav></aside>");
html.push_str("<main class=\"timeline\"><header class=\"timeline-header\">");
html.push_str("<div><h1>Timeline</h1>");
if let Some(session) = selected {
html.push_str(&format!(
"<p>{} / {} / {}</p>",
escape_html(&session.session_id),
escape_html(&session.phase),
escape_html(&session.mode)
));
}
html.push_str("</div></header>");
if let (Some(token), Some(session)) = (csrf_token, selected) {
let targets = known_targets(connection, &session.session_id)?;
let mut options = String::new();
for target in &targets {
options.push_str(&format!(
"<option value=\"{}\">{}</option>",
escape_html(target),
escape_html(target),
));
}
html.push_str(&format!(
"<form class=\"composer\" method=\"post\" action=\"/send\">\
<input type=\"hidden\" name=\"csrf\" value=\"{}\">\
<input type=\"hidden\" name=\"session\" value=\"{}\">\
<select name=\"to\" required>{}</select>\
<input name=\"type\" value=\"status-update\">\
<input name=\"body\" placeholder=\"message\u{2026}\" required>\
<button>Send</button></form>",
escape_html(token),
escape_html(&session.session_id),
options,
));
if targets.is_empty() {
html.push_str("<p class=\"composer-note\">No known targets for this session yet.</p>");
}
html.push_str(
"<script>document.querySelector('.composer').addEventListener('submit',function(e){e.preventDefault();var f=e.target;fetch('/send',{method:'POST',headers:{'X-Zynk-CSRF':f.csrf.value,'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams(new FormData(f))}).then(function(r){if(r.redirected){location=r.url}else{r.text().then(function(t){document.body.innerHTML=t})}});});</script>",
);
}
if feed.is_empty() {
html.push_str("<section class=\"empty-state\">No feed entries yet.</section>");
} else {
for event in &feed {
render_feed_event(&mut html, event);
}
}
html.push_str("</main>");
html.push_str("<aside class=\"detail-panel\"><h2>Status</h2>");
if let Some(session) = selected {
html.push_str(&format!(
"<dl><dt>Session</dt><dd>{}</dd><dt>State</dt><dd>{}</dd><dt>Next</dt><dd>{}</dd><dt>Ask</dt><dd>{}</dd><dt>Blockers</dt><dd>{}</dd><dt>Risk</dt><dd>{}</dd><dt>Expected wait</dt><dd>{}</dd><dt>Artifact</dt><dd>{}</dd><dt>Lead</dt><dd>{}</dd><dt>Updated</dt><dd>{}</dd></dl>",
escape_html(&session.title),
escape_html(&session.workflow_status),
escape_html(&session.next_action),
escape_html(&session.asks_for_zevs),
escape_html(&session.blockers),
escape_html(&session.risk_or_residual_uncertainty),
escape_html(&session.expected_wait),
escape_html(&session.artifact_ref),
escape_html(&session.lead_agent_id),
escape_html(&session.updated_at),
));
}
html.push_str("</aside></div></body></html>");
Ok(html)
}
fn render_feed_event(html: &mut String, event: &FeedEvent) {
html.push_str(&format!(
"<article class=\"feed-item kind-{}\"><div class=\"timestamp\">{}</div>",
escape_class(&event.kind),
escape_html(&event.timestamp),
));
let who = event.actor_agent_id.as_deref().unwrap_or("system");
let label = event.subtype.as_deref().unwrap_or(event.kind.as_str());
html.push_str(&format!(
"<h2>{} <span class=\"kind\">{}</span>",
escape_html(who),
escape_html(label),
));
if let Some(mid) = &event.mid {
html.push_str(&format!(" <span class=\"mid\">{}</span>", escape_html(mid)));
}
html.push_str("</h2>");
match &event.body {
Some(body) => html.push_str(&format!("<p class=\"body\">{}</p>", escape_html(body))),
None if event.kind == "message" => {
html.push_str("<p class=\"redacted\">\u{2298} redacted \u{00b7} hash-only</p>")
}
None => {
if let Some(summary) = &event.summary {
html.push_str(&format!("<p>{}</p>", escape_html(summary)));
}
}
}
if event.proof_audit_id.is_some() {
html.push_str("<div class=\"proof-strip\">");
html.push_str(&format!(
"<span class=\"proof proof-{}\">{} / {}</span>",
escape_class(event.delivery_status.as_deref().unwrap_or("unknown")),
escape_html(event.delivery_status.as_deref().unwrap_or("unknown")),
escape_html(event.verified_by.as_deref().unwrap_or("unknown")),
));
if let (Some(source), Some(target)) = (&event.source_address, &event.target_address) {
html.push_str(&format!(
"<span class=\"addr\">{} \u{2192} {} \u{00b7} {}</span>",
escape_html(source),
escape_html(target),
escape_html(event.transport.as_deref().unwrap_or("?")),
));
}
if let Some(link) = permalink(event) {
html.push_str(&format!(
"<span class=\"permalink\">{}</span>",
escape_html(&link)
));
}
html.push_str("</div>");
}
html.push_str("</article>");
}
fn render_audit(connection: &Connection, selected_session_id: Option<&str>) -> CliResult<String> {
let sessions = load_sessions(connection)?;
let selected = selected_session_id
.and_then(|id| sessions.iter().find(|session| session.session_id == id))
.or_else(|| sessions.first());
let mut html = String::new();
html.push_str("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.push_str("<title>zynk audit</title><style>");
html.push_str(STYLES);
html.push_str("</style></head><body><div class=\"app-shell\"><main class=\"timeline\">");
html.push_str("<header class=\"timeline-header\"><div><h1>Audit Trail</h1></div></header>");
if let Some(session) = selected {
let verification = verify_chain(connection, &session.session_id)?;
let label = if verification.ok {
format!(
"chain intact \u{00b7} {} verified",
verification.verified_count
)
} else {
format!(
"chain anomaly at {}",
verification.broken_at.unwrap_or_default()
)
};
html.push_str(&format!("<p class=\"verify\">{}</p>", escape_html(&label)));
let mut statement = connection
.prepare(
"SELECT audit_id, COALESCE(previous_audit_id, 'genesis'), record_type,
delivery_status, verified_by, payload_hash, timestamp
FROM audit_records WHERE session_id = ?1 ORDER BY timestamp, audit_id",
)
.map_err(|error| CliError::failure(format!("failed to query audit view: {error}")))?;
let rows = statement
.query_map([session.session_id.as_str()], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
))
})
.map_err(|error| CliError::failure(format!("failed to read audit view: {error}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| CliError::failure(format!("failed to read audit view: {error}")))?;
for (audit_id, previous, record_type, delivery, verified, hash, timestamp) in rows {
html.push_str(&format!(
"<article class=\"feed-item\"><div class=\"timestamp\">{}</div><h2>{} <span class=\"kind\">{}</span></h2><p>\u{2190} {} \u{00b7} {} / {} \u{00b7} {}</p></article>",
escape_html(×tamp),
escape_html(&audit_id),
escape_html(&record_type),
escape_html(&previous),
escape_html(&delivery),
escape_html(&verified),
escape_html(&hash),
));
}
} else {
html.push_str("<section class=\"empty-state\">No session.</section>");
}
html.push_str("</main></div></body></html>");
Ok(html)
}
fn load_sessions(connection: &Connection) -> CliResult<Vec<DashboardSession>> {
let mut statement = connection
.prepare(
"SELECT
s.session_id,
s.title,
COALESCE(se.phase, s.phase),
COALESCE(se.mode, s.mode),
COALESCE(se.workflow_status, s.workflow_status),
COALESCE(s.lead_agent_id, 'unknown'),
COALESCE(s.artifact_ref, 'unknown'),
COALESCE(se.timestamp, s.updated_at),
COALESCE(se.next_action, 'unknown'),
COALESCE(se.blockers, 'unknown'),
COALESCE(se.asks_for_zevs, 'unknown'),
COALESCE(se.risk_or_residual_uncertainty, 'unknown'),
COALESCE(se.expected_wait, 'unknown')
FROM sessions AS s
LEFT JOIN status_events AS se
ON se.status_event_id = (
SELECT status_event_id
FROM status_events
WHERE session_id = s.session_id
ORDER BY timestamp DESC, status_event_id DESC
LIMIT 1
)
ORDER BY COALESCE(se.timestamp, s.updated_at) DESC, s.session_id",
)
.map_err(|error| {
CliError::failure(format!("failed to query dashboard sessions: {error}"))
})?;
let sessions = statement
.query_map([], |row| {
Ok(DashboardSession {
session_id: row.get(0)?,
title: row.get(1)?,
phase: row.get(2)?,
mode: row.get(3)?,
workflow_status: row.get(4)?,
lead_agent_id: row.get(5)?,
artifact_ref: row.get(6)?,
updated_at: row.get(7)?,
next_action: row.get(8)?,
blockers: row.get(9)?,
asks_for_zevs: row.get(10)?,
risk_or_residual_uncertainty: row.get(11)?,
expected_wait: row.get(12)?,
})
})
.map_err(|error| CliError::failure(format!("failed to read dashboard sessions: {error}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
CliError::failure(format!("failed to read dashboard sessions: {error}"))
})?;
Ok(sessions)
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn escape_class(value: &str) -> String {
escape_html(value)
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect()
}
pub(crate) fn escape_url_component(value: &str) -> String {
let mut escaped = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
escaped.push(byte as char);
} else {
escaped.push_str(&format!("%{byte:02X}"));
}
}
escaped
}
pub(crate) fn percent_decode(value: &str) -> String {
let mut decoded = Vec::new();
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let Ok(hex) = std::str::from_utf8(&bytes[index + 1..index + 3]) {
if let Ok(byte) = u8::from_str_radix(hex, 16) {
decoded.push(byte);
index += 3;
continue;
}
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&decoded).to_string()
}
const STYLES: &str = r#"
:root { color-scheme: light; --ink: #1c2024; --muted: #667085; --line: #d6dbe1; --panel: #f7f8fa; --accent: #0f766e; --warn: #9a3412; --ok: #166534; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #ffffff; letter-spacing: 0; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(220px, 18vw) minmax(0, 1fr) minmax(260px, 22vw); }
.sidebar, .detail-panel { background: var(--panel); border-color: var(--line); padding: 18px; overflow: auto; }
.sidebar { border-right: 1px solid var(--line); }
.detail-panel { border-left: 1px solid var(--line); }
.brand { font-weight: 700; font-size: 18px; margin-bottom: 18px; }
.session-link { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; color: inherit; text-decoration: none; padding: 9px 0; border-bottom: 1px solid var(--line); }
.badge, .proof { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; font-size: 12px; white-space: nowrap; }
.status-working, .proof-observed { border-color: #86efac; color: var(--ok); }
.status-blocked, .status-waiting-for-operator, .proof-failed { border-color: #fdba74; color: var(--warn); }
.proof-sent { border-color: #5eead4; color: var(--accent); }
.status-idle, .status-done, .proof-drafted, .proof-unknown { border-color: #d0d5dd; color: var(--muted); }
.timeline { padding: 20px clamp(18px, 3vw, 42px); overflow: auto; }
.timeline-header { display: flex; justify-content: space-between; align-items: end; border-bottom: 1px solid var(--line); margin-bottom: 18px; padding-bottom: 12px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 15px; margin: 4px 0; }
p { color: var(--muted); margin: 4px 0; }
.timeline-item { max-width: 860px; border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin: 0 0 12px; background: #fff; }
.timestamp { color: var(--muted); font-size: 12px; }
.proof-strip { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
dl { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 9px 12px; margin: 0; }
dt { color: var(--muted); }
dd { margin: 0; overflow-wrap: anywhere; }
.empty, .empty-state { color: var(--muted); }
.feed-item { max-width: 860px; border: 1px solid var(--line); border-radius: 8px; padding: 14px 16px; margin: 0 0 12px; background: #fff; }
.feed-item .body { color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; margin: 6px 0; }
.feed-item .redacted { color: var(--muted); font-style: italic; }
.kind { color: var(--muted); font-weight: 400; font-size: 12px; }
.mid { color: var(--muted); font-size: 12px; }
.addr, .permalink { color: var(--muted); font-size: 12px; }
.verify { color: var(--ok); font-weight: 600; }
@media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; } .sidebar, .detail-panel { border: 0; border-bottom: 1px solid var(--line); } }
"#;