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:
parent
e1e782177d
commit
7187bf5ace
3 changed files with 611 additions and 8 deletions
|
|
@ -8,6 +8,7 @@ mod import;
|
|||
mod serve;
|
||||
mod show_context;
|
||||
mod summarize;
|
||||
mod web;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
|
|
|||
|
|
@ -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
593
skald/src/web.rs
Normal 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; }
|
||||
"#;
|
||||
Loading…
Add table
Add a link
Reference in a new issue