web: v0.1 read-only inspector — story list + drill into bible/chapters/runs

Read-only inspector built on axum + maud (per CLAUDE.md locked
stack). No JS, no htmx yet (v0.2). Single inline stylesheet:
dark serif aesthetic — looks like a writer's tool, not a
developer's CRUD app.

Routes:
- GET /                                      — welcome panel
- GET /stories/:id                           — story detail
- GET /stories/:id/chapters/:n               — chapter prose + summary
- GET /stories/:id/runs                      — generation log

Sidebar always shows the story list with chapter count, word
total, summary coverage ratio (e.g. '5/7 summ'), status badge.

Story detail panel:
- metabar (status / chapter count / word count / character count
  / canon fact count / 'updated 3h ago' / generation-log link)
- Chapters list with summary-present indicators (✓ summary / ○ no
  summary)
- Bible — characters (split real / fictional, key facts truncated
  to 220 chars)
- Bible — canon (collapsible <details> per category)

Chapter view:
- Summary aside box (if generated; otherwise CLI hint)
- Full prose body, paragraph-split, serif typography, 68ch column

Generation log view:
- table of every gen/cleanup/audit/summary run for the story,
  oldest to newest, with status colored (succeeded/failed/running)

Wired into 'skald serve' alongside /health.

Smoke test: http://lucy:7780/ when image redeploys.
This commit is contained in:
Kayos 2026-05-13 11:41:41 -07:00
parent e1e782177d
commit 7187bf5ace
3 changed files with 611 additions and 8 deletions

View file

@ -8,6 +8,7 @@ mod import;
mod serve;
mod show_context;
mod summarize;
mod web;
use std::path::PathBuf;
use std::process::ExitCode;

View file

@ -1,8 +1,9 @@
//! HTTP server (v0.1).
//! HTTP server.
//!
//! Today this is intentionally tiny: connect to postgres, run any
//! pending migrations, expose `/health`, and stay alive. The web
//! GUI + clawdforge wiring lands in v0.2.
//! Mounts the web inspector (maud + axum) at `/` plus a `/health`
//! probe. The clawdforge-touching CLI subcommands (summarize,
//! continue) talk to the DB directly — they don't go through this
//! HTTP layer.
use std::time::Duration;
@ -16,6 +17,8 @@ use skald_core::db;
use sqlx::PgPool;
use tokio::signal::unix::{SignalKind, signal};
use crate::web::{self, WebState};
#[derive(Clone)]
struct AppState {
pool: PgPool,
@ -28,13 +31,19 @@ pub async fn run(database_url: &str, listen: &str) -> anyhow::Result<()> {
let pool = db::connect_and_migrate(database_url).await?;
tracing::info!("database connected, migrations applied");
let state = AppState {
pool,
let app_state = AppState {
pool: pool.clone(),
started_at: Utc::now(),
};
let router = Router::new()
let health_router = Router::new()
.route("/health", get(health))
.with_state(state);
.with_state(app_state);
let web_router = web::router(WebState { pool });
let router = Router::new()
.merge(health_router)
.merge(web_router);
let listener = tokio::net::TcpListener::bind(listen).await?;
tracing::info!(listen, "api listening");

593
skald/src/web.rs Normal file
View file

@ -0,0 +1,593 @@
//! Web inspector. v0.1 = read-only. Story list in the sidebar,
//! drill into each story to see its chapters / bible / characters /
//! generation log.
//!
//! Stack: axum + maud + (eventually htmx). v0.1 uses full-page
//! reloads on nav; htmx swaps for tab switching come in v0.2.
//!
//! Tone: dark serif inspector. Looks like a writer's tool, not a
//! developer's CRUD app. Single inline stylesheet, no JS yet.
use std::sync::Arc;
use axum::Router;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::Html;
use axum::routing::get;
use chrono::{DateTime, Utc};
use maud::{DOCTYPE, Markup, html};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Clone)]
pub struct WebState {
pub pool: PgPool,
}
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/stories/{id}", get(story_detail))
.route("/stories/{id}/chapters/{n}", get(chapter_view))
.route("/stories/{id}/runs", get(runs_view))
.with_state(Arc::new(state))
}
// ─── data fetches ────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct StoryRow {
id: Uuid,
title: String,
status: String,
word_count_actual: i32,
updated_at: DateTime<Utc>,
chapter_count: i64,
character_count: i64,
summary_count: i64,
canon_fact_count: i64,
}
async fn fetch_stories(pool: &PgPool) -> Vec<StoryRow> {
let rows: Vec<(Uuid, String, String, i32, DateTime<Utc>, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> = sqlx::query_as(
r#"
SELECT
s.id,
s.title,
s.status,
s.word_count_actual,
s.updated_at,
(SELECT count(*) FROM chapters c WHERE c.story_id = s.id),
(SELECT count(*) FROM characters ch WHERE ch.story_id = s.id),
(SELECT count(*) FROM chapter_summaries cs JOIN chapters c2 ON c2.id = cs.chapter_id WHERE c2.story_id = s.id),
(SELECT count(*) FROM canon_facts cf WHERE cf.story_id = s.id)
FROM stories s
ORDER BY s.updated_at DESC
"#
)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|(id, title, status, words, updated_at, ch, chars, sums, facts)| StoryRow {
id,
title,
status,
word_count_actual: words,
updated_at,
chapter_count: ch.unwrap_or(0),
character_count: chars.unwrap_or(0),
summary_count: sums.unwrap_or(0),
canon_fact_count: facts.unwrap_or(0),
})
.collect()
}
// ─── handlers ────────────────────────────────────────────────────
async fn index(State(state): State<Arc<WebState>>) -> Html<String> {
let stories = fetch_stories(&state.pool).await;
Html(render_shell(&stories, None, welcome_panel()).into_string())
}
async fn story_detail(
State(state): State<Arc<WebState>>,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let stories = fetch_stories(&state.pool).await;
let Some(story) = stories.iter().find(|s| s.id == id).cloned() else {
return Err(StatusCode::NOT_FOUND);
};
let chapters: Vec<(i32, Option<String>, i32, bool)> = sqlx::query_as(
r#"
SELECT c.n, c.title, c.word_count, cs.chapter_id IS NOT NULL
FROM chapters c
LEFT JOIN chapter_summaries cs ON cs.chapter_id = c.id
WHERE c.story_id = $1
ORDER BY c.n
"#
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let characters: Vec<(String, String, String)> = sqlx::query_as(
"SELECT name, kind, key_facts FROM characters WHERE story_id = $1 ORDER BY kind, name",
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let canon_facts: Vec<(String, String, String)> = sqlx::query_as(
"SELECT category, title, body FROM canon_facts WHERE story_id = $1 ORDER BY category, title",
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let panel = story_panel(&story, &chapters, &characters, &canon_facts);
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
}
async fn chapter_view(
State(state): State<Arc<WebState>>,
Path((id, n)): Path<(Uuid, i32)>,
) -> Result<Html<String>, StatusCode> {
let stories = fetch_stories(&state.pool).await;
let row: Option<(Option<String>, String, i32, Option<String>)> = sqlx::query_as(
r#"
SELECT c.title, c.body_md, c.word_count, cs.body
FROM chapters c
LEFT JOIN chapter_summaries cs ON cs.chapter_id = c.id
WHERE c.story_id = $1 AND c.n = $2
"#
)
.bind(id)
.bind(n)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some((title, body_md, word_count, summary)) = row else {
return Err(StatusCode::NOT_FOUND);
};
let panel = chapter_panel(id, n, title.as_deref(), &body_md, word_count, summary.as_deref());
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
}
async fn runs_view(
State(state): State<Arc<WebState>>,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let stories = fetch_stories(&state.pool).await;
if !stories.iter().any(|s| s.id == id) {
return Err(StatusCode::NOT_FOUND);
}
let runs: Vec<(Uuid, String, String, DateTime<Utc>, Option<DateTime<Utc>>, Option<String>)> =
sqlx::query_as(
"SELECT id, kind, status, started_at, ended_at, error
FROM generation_runs
WHERE story_id = $1
ORDER BY started_at DESC
LIMIT 200",
)
.bind(id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let panel = runs_panel(id, &runs);
Ok(Html(render_shell(&stories, Some(id), panel).into_string()))
}
// ─── templates ───────────────────────────────────────────────────
fn render_shell(stories: &[StoryRow], current: Option<Uuid>, main: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "skald" }
style { (STYLESHEET) }
}
body {
header.topbar {
a.brand href="/" { "skald" }
span.tagline { "an old-norse poet's inspector" }
}
main.layout {
aside.sidebar {
h2 { "Stories" }
@if stories.is_empty() {
p.empty { "No stories imported yet. Run "
code { "skald import-markdown" } " to add one." }
} @else {
ul.story-list {
@for s in stories {
li.story-row.(if Some(s.id) == current { "active" } else { "" }) {
a href=(format!("/stories/{}", s.id)) {
span.title { (s.title) }
span.meta {
(s.chapter_count) " chap · "
(kfmt(s.word_count_actual)) " words · "
(s.summary_count) "/" (s.chapter_count) " summ"
}
span.status."status-".(s.status) { (s.status) }
}
}
}
}
}
}
section.panel { (main) }
}
footer.footbar {
span { "skald · v0.1 inspector · "
a href="http://192.168.0.5:3001/cobb/skald" { "cobb/skald" }
}
}
}
}
}
}
fn welcome_panel() -> Markup {
html! {
div.welcome {
h1 { "Welcome." }
p.lead { "Pick a story from the sidebar to inspect it." }
p {
"Skald is the database; the binary is the tooling. Every story is rows — "
"chapters, characters, canon, generation runs. This inspector shows them as they are."
}
p.muted {
"Authors, the continue button, and the cleanup audit panel land in v0.3 once "
"the persona layer is wired."
}
}
}
}
fn story_panel(
s: &StoryRow,
chapters: &[(i32, Option<String>, i32, bool)],
characters: &[(String, String, String)],
canon_facts: &[(String, String, String)],
) -> Markup {
let real_chars: Vec<_> = characters.iter().filter(|c| c.1 == "real").collect();
let fictional_chars: Vec<_> = characters.iter().filter(|c| c.1 == "fictional").collect();
html! {
article.story {
h1 { (s.title) }
div.metabar {
span.status."status-".(s.status) { (s.status) }
span.meta-item { (s.chapter_count) " chapters" }
span.meta-item { (kfmt(s.word_count_actual)) " words" }
span.meta-item { (s.character_count) " characters" }
span.meta-item { (s.canon_fact_count) " canon facts" }
span.meta-item.muted { "updated " (rel_time(s.updated_at)) }
a.runs-link href=(format!("/stories/{}/runs", s.id)) { "→ generation log" }
}
section.chapters {
h2 { "Chapters" }
ol.chapter-list {
@for (n, title, wc, has_summary) in chapters {
li {
a href=(format!("/stories/{}/chapters/{}", s.id, n)) {
span.n { "Chapter " (n) }
@if let Some(t) = title {
span.ch-title { (strip_chapter_prefix(t, *n)) }
}
span.wc { (kfmt(*wc)) " w" }
@if *has_summary {
span.summary-flag title="summary present" { "✓ summary" }
} @else {
span.summary-flag.missing title="no summary yet" { "○ no summary" }
}
}
}
}
}
}
@if !real_chars.is_empty() || !fictional_chars.is_empty() {
section.bible-section {
h2 { "Bible — characters" }
@if !real_chars.is_empty() {
h3 { "Real" }
ul.char-list {
@for c in &real_chars {
li {
span.cname { (c.0) }
span.cfacts { (truncate(&c.2, 220)) }
}
}
}
}
@if !fictional_chars.is_empty() {
h3 { "Fictional" }
ul.char-list {
@for c in &fictional_chars {
li {
span.cname { (c.0) }
span.cfacts { (truncate(&c.2, 220)) }
}
}
}
}
}
}
@if !canon_facts.is_empty() {
section.bible-section {
h2 { "Bible — canon" }
@for (category, title, body) in canon_facts {
details {
summary {
span.category { (category) }
span.ctitle { (title) }
}
div.cbody { (body) }
}
}
}
}
}
}
}
fn chapter_panel(
story_id: Uuid,
n: i32,
title: Option<&str>,
body_md: &str,
word_count: i32,
summary: Option<&str>,
) -> Markup {
let display_title = title.map(|t| strip_chapter_prefix(t, n).to_string());
html! {
article.chapter {
a.back href=(format!("/stories/{}", story_id)) { "← back to story" }
h1.ch-title {
span.n { "Chapter " (n) }
@if let Some(t) = display_title {
"" (t)
}
}
div.metabar {
span.meta-item { (kfmt(word_count)) " words" }
}
@if let Some(s) = summary {
aside.summary-box {
h3 { "Summary" }
p { (s) }
}
} @else {
aside.summary-box.empty {
p {
"No summary yet. Run "
code { "skald summarize --story " (story_id) }
" to generate one."
}
}
}
div.prose {
@for para in body_md.split("\n\n") {
@let trimmed = para.trim();
@if !trimmed.is_empty() && trimmed != "---" {
p { (trimmed) }
}
}
}
}
}
}
fn runs_panel(story_id: Uuid, runs: &[(Uuid, String, String, DateTime<Utc>, Option<DateTime<Utc>>, Option<String>)]) -> Markup {
html! {
article.runs {
a.back href=(format!("/stories/{}", story_id)) { "← back to story" }
h1 { "Generation log" }
p.muted { "Every LLM call made for this story, oldest to newest." }
table.runs-table {
thead {
tr {
th { "kind" }
th { "status" }
th { "started" }
th { "duration" }
th { "error" }
}
}
tbody {
@for (_id, kind, status, started, ended, error) in runs {
tr.(format!("run-{}", status)) {
td.kind { (kind) }
td.(format!("status-{}", status)) { (status) }
td.muted { (rel_time(*started)) }
td.muted {
@if let Some(e) = ended {
((e.signed_duration_since(*started).num_seconds()) ) "s"
} @else {
""
}
}
td.error {
@if let Some(e) = error { (truncate(e, 80)) }
}
}
}
}
}
}
}
}
// ─── helpers ─────────────────────────────────────────────────────
fn kfmt(n: i32) -> String {
if n >= 1000 {
format!("{:.1}k", n as f64 / 1000.0)
} else {
n.to_string()
}
}
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let mut out: String = s.chars().take(max_chars).collect();
out.push('…');
out
}
/// "Chapter One — Saturday, April 19, 1986" → "Saturday, April 19, 1986"
fn strip_chapter_prefix(title: &str, _n: i32) -> &str {
if let Some(idx) = title.find("") {
let after = &title[idx + "".len()..];
if !after.is_empty() {
return after;
}
}
title
}
fn rel_time(t: DateTime<Utc>) -> String {
let now = Utc::now();
let delta = now.signed_duration_since(t);
let secs = delta.num_seconds();
if secs < 60 {
format!("{}s ago", secs.max(0))
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}
// ─── stylesheet (inline) ─────────────────────────────────────────
const STYLESHEET: &str = r#"
:root {
--bg: #0d0d0c;
--surface: #16151a;
--surface-2: #1d1c20;
--border: #2a2830;
--ink: #e8dfc8;
--ink-muted: #8b8579;
--ink-faint: #5d584f;
--accent: #b08d50;
--accent-dim: #76624f;
--crit: #c97474;
--ok: #87a87a;
--warn: #c4a06c;
--serif: "Iowan Old Style", "Constantia", Georgia, "Times New Roman", serif;
--sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
--mono: "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.55; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--ink); }
code { font-family: var(--mono); font-size: 0.9em; background: var(--surface-2); padding: 1px 5px; border-radius: 2px; color: var(--ink-muted); }
.topbar { display: flex; align-items: baseline; gap: 16px; padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--surface); }
.brand { font-family: var(--serif); font-size: 22px; color: var(--accent); letter-spacing: 0.5px; }
.brand:hover { color: var(--ink); }
.tagline { color: var(--ink-faint); font-size: 13px; font-style: italic; }
.footbar { padding: 10px 24px; border-top: 1px solid var(--border); background: var(--surface); color: var(--ink-faint); font-size: 12px; }
.footbar a { color: var(--ink-faint); }
.layout { display: grid; grid-template-columns: 320px 1fr; min-height: calc(100vh - 90px); }
.sidebar { padding: 16px 20px; border-right: 1px solid var(--border); background: var(--surface); overflow-y: auto; }
.sidebar h2 { font-family: var(--serif); font-size: 14px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-muted); margin: 0 0 14px 0; font-weight: 400; }
.story-list { list-style: none; margin: 0; padding: 0; }
.story-row { margin-bottom: 4px; }
.story-row a { display: block; padding: 10px 12px; border-radius: 3px; color: var(--ink); }
.story-row a:hover, .story-row.active a { background: var(--surface-2); }
.story-row.active a { border-left: 2px solid var(--accent); padding-left: 10px; }
.story-row .title { display: block; font-family: var(--serif); font-size: 16px; color: var(--ink); margin-bottom: 2px; }
.story-row .meta { display: block; font-size: 11px; color: var(--ink-faint); }
.story-row .status { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 2px; margin-left: 6px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--ink-faint); }
.panel { padding: 36px 48px; overflow-y: auto; max-width: 1100px; }
.welcome h1 { font-family: var(--serif); font-size: 36px; color: var(--accent); margin: 40px 0 16px 0; font-weight: 400; }
.welcome .lead { font-size: 17px; color: var(--ink); }
.welcome p { max-width: 65ch; }
.welcome .muted { color: var(--ink-faint); font-size: 14px; }
.story h1 { font-family: var(--serif); font-size: 32px; color: var(--ink); margin: 0 0 14px 0; font-weight: 400; }
.metabar { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-muted); margin-bottom: 30px; align-items: center; padding-bottom: 16px; border-bottom: 1px solid var(--border); }
.metabar .status { font-size: 10px; padding: 2px 8px; border-radius: 2px; background: var(--surface-2); text-transform: uppercase; letter-spacing: 0.5px; }
.metabar .runs-link { margin-left: auto; font-size: 12px; color: var(--ink-faint); }
.metabar .runs-link:hover { color: var(--accent); }
.muted { color: var(--ink-faint); }
.story h2, .chapter h2, .runs h2 { font-family: var(--serif); font-size: 14px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-muted); margin: 36px 0 14px 0; font-weight: 400; }
.story h3, .bible-section h3 { font-family: var(--serif); font-size: 15px; color: var(--accent-dim); margin: 18px 0 10px 0; font-weight: 400; letter-spacing: 0.5px; }
.chapter-list { list-style: none; padding: 0; margin: 0; }
.chapter-list li { margin-bottom: 2px; }
.chapter-list a { display: grid; grid-template-columns: 100px 1fr auto 110px; gap: 18px; align-items: baseline; padding: 8px 12px; border-radius: 3px; color: var(--ink); }
.chapter-list a:hover { background: var(--surface-2); }
.chapter-list .n { color: var(--ink-faint); font-size: 13px; font-family: var(--mono); }
.chapter-list .ch-title { font-family: var(--serif); font-size: 15px; }
.chapter-list .wc { color: var(--ink-faint); font-size: 12px; font-family: var(--mono); text-align: right; }
.chapter-list .summary-flag { font-size: 11px; color: var(--ok); }
.chapter-list .summary-flag.missing { color: var(--ink-faint); }
.char-list { list-style: none; padding: 0; margin: 0 0 14px 0; }
.char-list li { display: grid; grid-template-columns: 240px 1fr; gap: 18px; padding: 8px 0; border-bottom: 1px solid var(--surface-2); align-items: baseline; }
.cname { font-family: var(--serif); color: var(--ink); font-size: 14px; }
.cfacts { color: var(--ink-muted); font-size: 13px; line-height: 1.5; }
.bible-section details { border-bottom: 1px solid var(--surface-2); padding: 10px 0; }
.bible-section summary { cursor: pointer; list-style: none; display: flex; gap: 14px; align-items: baseline; padding: 4px 0; }
.bible-section summary::-webkit-details-marker { display: none; }
.bible-section summary::before { content: ""; color: var(--ink-faint); font-size: 11px; margin-right: 4px; }
.bible-section details[open] summary::before { content: ""; }
.bible-section .category { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent-dim); min-width: 140px; }
.bible-section .ctitle { font-family: var(--serif); color: var(--ink); }
.bible-section .cbody { padding: 10px 0 6px 18px; color: var(--ink-muted); white-space: pre-wrap; line-height: 1.65; max-width: 75ch; font-size: 14px; }
.chapter .back, .runs .back { display: inline-block; color: var(--ink-faint); font-size: 12px; margin-bottom: 18px; }
.chapter h1.ch-title { font-family: var(--serif); font-size: 28px; font-weight: 400; color: var(--ink); margin: 0 0 8px 0; }
.chapter h1.ch-title .n { color: var(--accent-dim); font-size: 22px; }
.summary-box { background: var(--surface); border-left: 2px solid var(--accent-dim); padding: 14px 20px; margin: 24px 0 32px 0; max-width: 75ch; }
.summary-box.empty { border-left-color: var(--surface-2); }
.summary-box h3 { font-family: var(--serif); font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--ink-muted); margin: 0 0 8px 0; font-weight: 400; }
.summary-box p { margin: 0; color: var(--ink-muted); font-size: 14px; line-height: 1.65; }
.prose { font-family: var(--serif); font-size: 17px; line-height: 1.75; max-width: 68ch; color: var(--ink); }
.prose p { margin: 0 0 1.1em 0; }
.prose p:first-letter { /* nothing fancy yet */ }
.runs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.runs-table th, .runs-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--surface-2); }
.runs-table th { color: var(--ink-faint); font-weight: 400; text-transform: uppercase; font-size: 11px; letter-spacing: 1px; }
.runs-table td.kind { font-family: var(--mono); font-size: 12px; color: var(--ink-muted); }
.runs-table td.status-succeeded { color: var(--ok); }
.runs-table td.status-failed { color: var(--crit); }
.runs-table td.status-running { color: var(--warn); }
.runs-table td.error { color: var(--crit); font-size: 12px; }
.empty { color: var(--ink-faint); font-style: italic; }
"#;