From 9d63844f3a28fde70b19500422f17379e99e588a Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 16:00:30 -0400 Subject: Refound Spinner as an austere frontier ledger --- crates/fidget-spinner-cli/src/ui.rs | 1603 +++++++++++++++++++++++++---------- 1 file changed, 1149 insertions(+), 454 deletions(-) (limited to 'crates/fidget-spinner-cli/src/ui.rs') diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs index 29b5058..98cc95d 100644 --- a/crates/fidget-spinner-cli/src/ui.rs +++ b/crates/fidget-spinner-cli/src/ui.rs @@ -1,79 +1,113 @@ -use std::collections::BTreeMap; use std::io; use std::net::SocketAddr; use axum::Router; -use axum::extract::{Query, State}; +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; use camino::Utf8PathBuf; -use fidget_spinner_core::{DagNode, FieldValueType, NodeClass, ProjectSchema, TagName}; -use linkify::{LinkFinder, LinkKind}; +use fidget_spinner_core::{ + AttachmentTargetRef, ExperimentAnalysis, ExperimentOutcome, ExperimentStatus, FrontierRecord, + FrontierVerdict, MetricUnit, RunDimensionValue, Slug, VertexRef, +}; +use fidget_spinner_store_sqlite::{ + ExperimentDetail, ExperimentSummary, FrontierOpenProjection, FrontierSummary, + HypothesisCurrentState, HypothesisDetail, ProjectStatus, StoreError, VertexSummary, +}; use maud::{DOCTYPE, Markup, PreEscaped, html}; -use serde::Deserialize; -use serde_json::Value; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use time::macros::format_description; -use crate::{open_store, to_pretty_json}; +use crate::open_store; #[derive(Clone)] struct NavigatorState { project_root: Utf8PathBuf, - limit: u32, + limit: Option, } -#[derive(Debug, Default, Deserialize)] -struct NavigatorQuery { - tag: Option, -} - -struct NavigatorEntry { - node: DagNode, - frontier_label: Option, -} - -struct TagFacet { - name: TagName, - description: String, - count: usize, +struct AttachmentDisplay { + kind: &'static str, + href: String, + title: String, + summary: Option, } pub(crate) fn serve( project_root: Utf8PathBuf, bind: SocketAddr, - limit: u32, -) -> Result<(), fidget_spinner_store_sqlite::StoreError> { + limit: Option, +) -> Result<(), StoreError> { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_io() .build() - .map_err(fidget_spinner_store_sqlite::StoreError::from)?; + .map_err(StoreError::from)?; runtime.block_on(async move { let state = NavigatorState { project_root, limit, }; let app = Router::new() - .route("/", get(navigator)) + .route("/", get(project_home)) + .route("/frontier/{selector}", get(frontier_detail)) + .route("/hypothesis/{selector}", get(hypothesis_detail)) + .route("/experiment/{selector}", get(experiment_detail)) + .route("/artifact/{selector}", get(artifact_detail)) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind(bind) .await - .map_err(fidget_spinner_store_sqlite::StoreError::from)?; + .map_err(StoreError::from)?; println!("navigator: http://{bind}/"); - axum::serve(listener, app).await.map_err(|error| { - fidget_spinner_store_sqlite::StoreError::Io(io::Error::other(error.to_string())) - }) + axum::serve(listener, app) + .await + .map_err(|error| StoreError::Io(io::Error::other(error.to_string()))) }) } -async fn navigator( +async fn project_home(State(state): State) -> Response { + render_response(render_project_home(state)) +} + +async fn frontier_detail( State(state): State, - Query(query): Query, + Path(selector): Path, ) -> Response { - match render_navigator(state, query) { + render_response(render_frontier_detail(state, selector)) +} + +async fn hypothesis_detail( + State(state): State, + Path(selector): Path, +) -> Response { + render_response(render_hypothesis_detail(state, selector)) +} + +async fn experiment_detail( + State(state): State, + Path(selector): Path, +) -> Response { + render_response(render_experiment_detail(state, selector)) +} + +async fn artifact_detail( + State(state): State, + Path(selector): Path, +) -> Response { + render_response(render_artifact_detail(state, selector)) +} + +fn render_response(result: Result) -> Response { + match result { Ok(markup) => Html(markup.into_string()).into_response(), + Err(StoreError::UnknownFrontierSelector(_)) + | Err(StoreError::UnknownHypothesisSelector(_)) + | Err(StoreError::UnknownExperimentSelector(_)) + | Err(StoreError::UnknownArtifactSelector(_)) => { + (StatusCode::NOT_FOUND, "not found".to_owned()).into_response() + } Err(error) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("navigator render failed: {error}"), @@ -82,565 +116,1226 @@ async fn navigator( } } -fn render_navigator( - state: NavigatorState, - query: NavigatorQuery, -) -> Result { +fn render_project_home(state: NavigatorState) -> Result { + let store = open_store(state.project_root.as_std_path())?; + let project_status = store.status()?; + let frontiers = store.list_frontiers()?; + let title = format!("{} navigator", project_status.display_name); + let content = html! { + (render_project_status(&project_status)) + (render_frontier_grid(&frontiers, state.limit)) + }; + Ok(render_shell( + &title, + Some(&project_status.display_name.to_string()), + None, + content, + )) +} + +fn render_frontier_detail(state: NavigatorState, selector: String) -> Result { + let store = open_store(state.project_root.as_std_path())?; + let projection = store.frontier_open(&selector)?; + let title = format!("{} · frontier", projection.frontier.label); + let subtitle = format!( + "{} hypotheses active · {} experiments open", + projection.active_hypotheses.len(), + projection.open_experiments.len() + ); + let content = html! { + (render_frontier_header(&projection.frontier)) + (render_frontier_brief(&projection)) + (render_frontier_active_sets(&projection)) + (render_hypothesis_current_state_grid( + &projection.active_hypotheses, + state.limit, + )) + (render_open_experiment_grid( + &projection.open_experiments, + state.limit, + )) + }; + Ok(render_shell(&title, Some(&subtitle), None, content)) +} + +fn render_hypothesis_detail(state: NavigatorState, selector: String) -> Result { + let store = open_store(state.project_root.as_std_path())?; + let detail = store.read_hypothesis(&selector)?; + let frontier = store.read_frontier(&detail.record.frontier_id.to_string())?; + let title = format!("{} · hypothesis", detail.record.title); + let subtitle = detail.record.summary.to_string(); + let content = html! { + (render_hypothesis_header(&detail, &frontier)) + (render_prose_block("Body", detail.record.body.as_str())) + (render_vertex_relation_sections(&detail.parents, &detail.children, state.limit)) + (render_artifact_section(&detail.artifacts, state.limit)) + (render_experiment_section( + "Open Experiments", + &detail.open_experiments, + state.limit, + )) + (render_experiment_section( + "Closed Experiments", + &detail.closed_experiments, + state.limit, + )) + }; + Ok(render_shell( + &title, + Some(&subtitle), + Some((frontier.label.as_str(), frontier_href(&frontier.slug))), + content, + )) +} + +fn render_experiment_detail(state: NavigatorState, selector: String) -> Result { let store = open_store(state.project_root.as_std_path())?; - let selected_tag = query.tag.map(TagName::new).transpose()?; - let schema = store.schema().clone(); - let frontiers = store - .list_frontiers()? - .into_iter() - .map(|frontier| (frontier.id, frontier.label.to_string())) - .collect::>(); - - let recent_nodes = load_recent_nodes(&store, None, state.limit)?; - let visible_nodes = load_recent_nodes(&store, selected_tag.clone(), state.limit)?; - let tag_facets = store - .list_tags()? - .into_iter() - .map(|tag| TagFacet { - count: recent_nodes - .iter() - .filter(|node| node.tags.contains(&tag.name)) - .count(), - description: tag.description.to_string(), - name: tag.name, - }) - .collect::>(); - let entries = visible_nodes - .into_iter() - .map(|node| NavigatorEntry { - frontier_label: node - .frontier_id - .and_then(|frontier_id| frontiers.get(&frontier_id).cloned()), - node, - }) - .collect::>(); - - let title = selected_tag.as_ref().map_or_else( - || "all recent nodes".to_owned(), - |tag| format!("tag: {tag}"), + let detail = store.read_experiment(&selector)?; + let frontier = store.read_frontier(&detail.record.frontier_id.to_string())?; + let title = format!("{} · experiment", detail.record.title); + let subtitle = detail.record.summary.as_ref().map_or_else( + || detail.record.status.as_str().to_owned(), + ToString::to_string, ); - let project_name = store.config().display_name.to_string(); + let content = html! { + (render_experiment_header(&detail, &frontier)) + (render_vertex_relation_sections(&detail.parents, &detail.children, state.limit)) + (render_artifact_section(&detail.artifacts, state.limit)) + @if let Some(outcome) = detail.record.outcome.as_ref() { + (render_experiment_outcome(outcome)) + } @else { + section.card { + h2 { "Outcome" } + p.muted { "Open experiment. No outcome recorded yet." } + } + } + }; + Ok(render_shell( + &title, + Some(&subtitle), + Some((frontier.label.as_str(), frontier_href(&frontier.slug))), + content, + )) +} - Ok(html! { - (DOCTYPE) - html { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; - title { "Fidget Spinner Navigator" } - style { (PreEscaped(stylesheet().to_owned())) } +fn render_artifact_detail(state: NavigatorState, selector: String) -> Result { + let store = open_store(state.project_root.as_std_path())?; + let detail = store.read_artifact(&selector)?; + let attachments = detail + .attachments + .iter() + .map(|target| resolve_attachment_display(&store, *target)) + .collect::, StoreError>>()?; + let title = format!("{} · artifact", detail.record.label); + let subtitle = detail.record.summary.as_ref().map_or_else( + || detail.record.kind.as_str().to_owned(), + ToString::to_string, + ); + let content = html! { + section.card { + h2 { "Artifact" } + div.kv-grid { + (render_kv("Kind", detail.record.kind.as_str())) + (render_kv("Slug", detail.record.slug.as_str())) + (render_kv("Locator", detail.record.locator.as_str())) + @if let Some(media_type) = detail.record.media_type.as_ref() { + (render_kv("Media type", media_type.as_str())) + } + (render_kv("Updated", &format_timestamp(detail.record.updated_at))) } - body { - main class="shell" { - aside class="rail" { - h1 { "Navigator" } - p class="project" { (project_name) } - nav class="tag-list" { - a - href="/" - class={ "tag-link " (if selected_tag.is_none() { "selected" } else { "" }) } { - span class="tag-name" { "all" } - span class="tag-count" { (recent_nodes.len()) } - } - @for facet in &tag_facets { - a - href={ "/?tag=" (facet.name.as_str()) } - class={ "tag-link " (if selected_tag.as_ref() == Some(&facet.name) { "selected" } else { "" }) } { - span class="tag-name" { (facet.name.as_str()) } - span class="tag-count" { (facet.count) } - span class="tag-description" { (facet.description.as_str()) } - } - } - } + @if let Some(summary) = detail.record.summary.as_ref() { + p.prose { (summary) } + } + p.muted { + "Artifact bodies are intentionally out of band. Spinner only preserves references." + } + } + section.card { + h2 { "Attachments" } + @if attachments.is_empty() { + p.muted { "No attachments." } + } @else { + div.link-list { + @for attachment in &attachments { + (render_attachment_chip(attachment)) } - section class="feed" { - header class="feed-header" { - h2 { (title) } - p class="feed-meta" { - (entries.len()) " shown" - " · " - (recent_nodes.len()) " recent" - " · " - (state.limit) " max" + } + } + } + }; + Ok(render_shell(&title, Some(&subtitle), None, content)) +} + +fn render_frontier_grid(frontiers: &[FrontierSummary], limit: Option) -> Markup { + html! { + section.card { + h2 { "Frontiers" } + @if frontiers.is_empty() { + p.muted { "No frontiers yet." } + } @else { + div.card-grid { + @for frontier in limit_items(frontiers, limit) { + article.mini-card { + div.card-header { + a.title-link href=(frontier_href(&frontier.slug)) { (frontier.label) } + span.status-chip class=(frontier_status_class(frontier.status.as_str())) { + (frontier.status.as_str()) } } - @if entries.is_empty() { - article class="empty-state" { - h3 { "No matching nodes" } - p { "Try clearing the tag filter or recording new notes." } - } - } @else { - @for entry in &entries { - (render_entry(entry, &schema)) - } + p.prose { (frontier.objective) } + div.meta-row { + span { (format!("{} active hypotheses", frontier.active_hypothesis_count)) } + span { (format!("{} open experiments", frontier.open_experiment_count)) } + } + div.meta-row.muted { + span { "updated " (format_timestamp(frontier.updated_at)) } } } } } } - }) + } + } } -fn load_recent_nodes( - store: &fidget_spinner_store_sqlite::ProjectStore, - tag: Option, - limit: u32, -) -> Result, fidget_spinner_store_sqlite::StoreError> { - let summaries = store.list_nodes(fidget_spinner_store_sqlite::ListNodesQuery { - tags: tag.into_iter().collect(), - limit, - ..fidget_spinner_store_sqlite::ListNodesQuery::default() - })?; - summaries - .into_iter() - .map(|summary| { - store.get_node(summary.id)?.ok_or( - fidget_spinner_store_sqlite::StoreError::NodeNotFound(summary.id), - ) - }) - .collect() -} - -fn render_entry(entry: &NavigatorEntry, schema: &ProjectSchema) -> Markup { - let body = entry.node.payload.field("body").and_then(Value::as_str); - let mut keys = entry - .node - .payload - .fields - .keys() - .filter(|name| name.as_str() != "body") - .cloned() - .collect::>(); - keys.sort_unstable(); +fn render_project_status(status: &ProjectStatus) -> Markup { + html! { + section.card { + h1 { (status.display_name) } + p.prose { + "Austere experimental ledger. Frontier overview is the only sanctioned dump; everything else is deliberate traversal." + } + div.kv-grid { + (render_kv("Project root", status.project_root.as_str())) + (render_kv("Store format", &status.store_format_version.to_string())) + (render_kv("Frontiers", &status.frontier_count.to_string())) + (render_kv("Hypotheses", &status.hypothesis_count.to_string())) + (render_kv("Experiments", &status.experiment_count.to_string())) + (render_kv("Open experiments", &status.open_experiment_count.to_string())) + (render_kv("Artifacts", &status.artifact_count.to_string())) + } + } + } +} + +fn render_frontier_header(frontier: &FrontierRecord) -> Markup { + html! { + section.card { + h1 { (frontier.label) } + p.prose { (frontier.objective) } + div.meta-row { + span { "slug " code { (frontier.slug) } } + span.status-chip class=(frontier_status_class(frontier.status.as_str())) { + (frontier.status.as_str()) + } + span.muted { "updated " (format_timestamp(frontier.updated_at)) } + } + } + } +} +fn render_frontier_brief(projection: &FrontierOpenProjection) -> Markup { + let frontier = &projection.frontier; html! { - article class="entry" id={ "node-" (entry.node.id) } { - header class="entry-header" { - div class="entry-title-row" { - span class={ "class-badge class-" (entry.node.class.as_str()) } { - (entry.node.class.as_str()) + section.card { + h2 { "Frontier Brief" } + @if let Some(situation) = frontier.brief.situation.as_ref() { + div.block { + h3 { "Situation" } + p.prose { (situation) } + } + } @else { + p.muted { "No situation summary recorded." } + } + div.split { + div.subcard { + h3 { "Roadmap" } + @if frontier.brief.roadmap.is_empty() { + p.muted { "No roadmap ordering recorded." } + } @else { + ol.roadmap-list { + @for item in &frontier.brief.roadmap { + @let title = hypothesis_title_for_roadmap_item(projection, item.hypothesis_id); + li { + a href=(hypothesis_href_from_id(item.hypothesis_id)) { + (format!("{}.", item.rank)) " " + (title) + } + @if let Some(summary) = item.summary.as_ref() { + span.muted { " · " (summary) } + } + } + } } - h3 class="entry-title" { - a href={ "#node-" (entry.node.id) } { (entry.node.title.as_str()) } + } + } + div.subcard { + h3 { "Unknowns" } + @if frontier.brief.unknowns.is_empty() { + p.muted { "No explicit unknowns." } + } @else { + ul.simple-list { + @for unknown in &frontier.brief.unknowns { + li { (unknown) } + } + } + } + } + } + } + } +} + +fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup { + html! { + section.card { + h2 { "Active Surface" } + div.split { + div.subcard { + h3 { "Active Tags" } + @if projection.active_tags.is_empty() { + p.muted { "No active tags." } + } @else { + div.chip-row { + @for tag in &projection.active_tags { + span.tag-chip { (tag) } + } } } - div class="entry-meta" { - span { (render_timestamp(entry.node.updated_at)) } - @if let Some(label) = &entry.frontier_label { - span { "frontier: " (label.as_str()) } + } + div.subcard { + h3 { "Live Metrics" } + @if projection.active_metric_keys.is_empty() { + p.muted { "No live metrics." } + } @else { + table.metric-table { + thead { + tr { + th { "Key" } + th { "Unit" } + th { "Objective" } + th { "Refs" } + } + } + tbody { + @for metric in &projection.active_metric_keys { + tr { + td { (metric.key) } + td { (metric.unit.as_str()) } + td { (metric.objective.as_str()) } + td { (metric.reference_count) } + } + } + } } - @if !entry.node.tags.is_empty() { - span class="tag-strip" { - @for tag in &entry.node.tags { - a class="entry-tag" href={ "/?tag=" (tag.as_str()) } { (tag.as_str()) } + } + } + } + } + } +} + +fn render_hypothesis_current_state_grid( + states: &[HypothesisCurrentState], + limit: Option, +) -> Markup { + html! { + section.card { + h2 { "Active Hypotheses" } + @if states.is_empty() { + p.muted { "No active hypotheses." } + } @else { + div.card-grid { + @for state in limit_items(states, limit) { + article.mini-card { + div.card-header { + a.title-link href=(hypothesis_href(&state.hypothesis.slug)) { + (state.hypothesis.title) + } + @if let Some(verdict) = state.hypothesis.latest_verdict { + span.status-chip class=(verdict_class(verdict)) { + (verdict.as_str()) + } + } + } + p.prose { (state.hypothesis.summary) } + @if !state.hypothesis.tags.is_empty() { + div.chip-row { + @for tag in &state.hypothesis.tags { + span.tag-chip { (tag) } + } + } + } + div.meta-row { + span { (format!("{} open", state.open_experiments.len())) } + @if let Some(latest) = state.latest_closed_experiment.as_ref() { + span { + "latest " + a href=(experiment_href(&latest.slug)) { (latest.title) } + } + } @else { + span.muted { "no closed experiments" } + } + } + @if !state.open_experiments.is_empty() { + div.related-block { + h3 { "Open" } + div.link-list { + @for experiment in &state.open_experiments { + (render_experiment_link_chip(experiment)) + } + } + } + } + @if let Some(latest) = state.latest_closed_experiment.as_ref() { + div.related-block { + h3 { "Latest Closed" } + (render_experiment_summary_line(latest)) } } } } } - @if let Some(summary) = &entry.node.summary { - p class="entry-summary" { (summary.as_str()) } + } + } + } +} + +fn render_open_experiment_grid(experiments: &[ExperimentSummary], limit: Option) -> Markup { + html! { + section.card { + h2 { "Open Experiments" } + @if experiments.is_empty() { + p.muted { "No open experiments." } + } @else { + div.card-grid { + @for experiment in limit_items(experiments, limit) { + (render_experiment_card(experiment)) + } + } + } + } + } +} + +fn render_hypothesis_header(detail: &HypothesisDetail, frontier: &FrontierRecord) -> Markup { + html! { + section.card { + h1 { (detail.record.title) } + p.prose { (detail.record.summary) } + div.meta-row { + span { "frontier " a href=(frontier_href(&frontier.slug)) { (frontier.label) } } + span { "slug " code { (detail.record.slug) } } + @if detail.record.archived { + span.status-chip.archived { "archived" } } - @if let Some(body) = body { - section class="entry-body" { - (render_string_value(body)) + span.muted { "updated " (format_timestamp(detail.record.updated_at)) } + } + @if !detail.record.tags.is_empty() { + div.chip-row { + @for tag in &detail.record.tags { + span.tag-chip { (tag) } } } - @if !keys.is_empty() { - dl class="field-list" { - @for key in &keys { - @if let Some(value) = entry.node.payload.field(key) { - (render_field(entry.node.class, schema, key, value)) - } - } + } + } + } +} + +fn render_experiment_header(detail: &ExperimentDetail, frontier: &FrontierRecord) -> Markup { + html! { + section.card { + h1 { (detail.record.title) } + @if let Some(summary) = detail.record.summary.as_ref() { + p.prose { (summary) } + } + div.meta-row { + span { + "frontier " + a href=(frontier_href(&frontier.slug)) { (frontier.label) } + } + span { + "hypothesis " + a href=(hypothesis_href(&detail.owning_hypothesis.slug)) { + (detail.owning_hypothesis.title) } } - @if !entry.node.diagnostics.items.is_empty() { - section class="diagnostics" { - h4 { "diagnostics" } - ul { - @for item in &entry.node.diagnostics.items { - li { - span class="diag-severity" { (format!("{:?}", item.severity).to_ascii_lowercase()) } - " " - (item.message.as_str()) + span.status-chip class=(experiment_status_class(detail.record.status)) { + (detail.record.status.as_str()) + } + @if let Some(verdict) = detail + .record + .outcome + .as_ref() + .map(|outcome| outcome.verdict) + { + span.status-chip class=(verdict_class(verdict)) { (verdict.as_str()) } + } + span.muted { "updated " (format_timestamp(detail.record.updated_at)) } + } + @if !detail.record.tags.is_empty() { + div.chip-row { + @for tag in &detail.record.tags { + span.tag-chip { (tag) } + } + } + } + } + } +} + +fn render_experiment_outcome(outcome: &ExperimentOutcome) -> Markup { + html! { + section.card { + h2 { "Outcome" } + div.kv-grid { + (render_kv("Verdict", outcome.verdict.as_str())) + (render_kv("Backend", outcome.backend.as_str())) + (render_kv("Closed", &format_timestamp(outcome.closed_at))) + } + (render_command_recipe(&outcome.command)) + (render_metric_panel("Primary metric", std::slice::from_ref(&outcome.primary_metric), outcome)) + @if !outcome.supporting_metrics.is_empty() { + (render_metric_panel("Supporting metrics", &outcome.supporting_metrics, outcome)) + } + @if !outcome.dimensions.is_empty() { + section.subcard { + h3 { "Dimensions" } + table.metric-table { + thead { tr { th { "Key" } th { "Value" } } } + tbody { + @for (key, value) in &outcome.dimensions { + tr { + td { (key) } + td { (render_dimension_value(value)) } } } } } } } + section.subcard { + h3 { "Rationale" } + p.prose { (outcome.rationale) } + } + @if let Some(analysis) = outcome.analysis.as_ref() { + (render_experiment_analysis(analysis)) + } + } } } -fn render_field(class: NodeClass, schema: &ProjectSchema, key: &str, value: &Value) -> Markup { - let value_type = schema - .field_spec(class, key) - .and_then(|field| field.value_type); - let is_plottable = schema - .field_spec(class, key) - .is_some_and(|field| field.is_plottable()); +fn render_experiment_analysis(analysis: &ExperimentAnalysis) -> Markup { html! { - dt { - (key) - @if let Some(value_type) = value_type { - span class="field-type" { (value_type.as_str()) } - } - @if is_plottable { - span class="field-type plottable" { "plot" } + section.subcard { + h3 { "Analysis" } + p.prose { (analysis.summary) } + div.code-block { + (analysis.body) + } + } + } +} + +fn render_command_recipe(command: &fidget_spinner_core::CommandRecipe) -> Markup { + html! { + section.subcard { + h3 { "Command" } + div.kv-grid { + (render_kv( + "argv", + &command + .argv + .iter() + .map(ToString::to_string) + .collect::>() + .join(" "), + )) + @if let Some(working_directory) = command.working_directory.as_ref() { + (render_kv("cwd", working_directory.as_str())) } } - dd { - @match value_type { - Some(FieldValueType::String) => { - @if let Some(text) = value.as_str() { - (render_string_value(text)) - } @else { - (render_json_value(value)) + @if !command.env.is_empty() { + table.metric-table { + thead { tr { th { "Env" } th { "Value" } } } + tbody { + @for (key, value) in &command.env { + tr { + td { (key) } + td { (value) } + } } } - Some(FieldValueType::Numeric) => { - @if let Some(number) = value.as_f64() { - code class="numeric" { (number) } - } @else { - (render_json_value(value)) + } + } + } + } +} + +fn render_metric_panel( + title: &str, + metrics: &[fidget_spinner_core::MetricValue], + outcome: &ExperimentOutcome, +) -> Markup { + html! { + section.subcard { + h3 { (title) } + table.metric-table { + thead { + tr { + th { "Key" } + th { "Value" } + } + } + tbody { + @for metric in metrics { + tr { + td { (metric.key) } + td { (format_metric_value(metric.value, metric_unit_for(metric, outcome))) } } } - Some(FieldValueType::Boolean) => { - @if let Some(boolean) = value.as_bool() { - span class={ "boolean " (if boolean { "true" } else { "false" }) } { - (if boolean { "true" } else { "false" }) - } + } + } + } + } +} + +fn metric_unit_for( + metric: &fidget_spinner_core::MetricValue, + outcome: &ExperimentOutcome, +) -> MetricUnit { + if metric.key == outcome.primary_metric.key { + return MetricUnit::Custom; + } + MetricUnit::Custom +} + +fn render_vertex_relation_sections( + parents: &[VertexSummary], + children: &[VertexSummary], + limit: Option, +) -> Markup { + html! { + section.card { + h2 { "Influence Network" } + div.split { + div.subcard { + h3 { "Parents" } + @if parents.is_empty() { + p.muted { "No parent influences." } } @else { - (render_json_value(value)) + div.link-list { + @for parent in limit_items(parents, limit) { + (render_vertex_chip(parent)) + } + } } } - Some(FieldValueType::Timestamp) => { - @if let Some(raw) = value.as_str() { - time datetime=(raw) { (render_timestamp_value(raw)) } + div.subcard { + h3 { "Children" } + @if children.is_empty() { + p.muted { "No downstream influences." } } @else { - (render_untyped_value(value)) + div.link-list { + @for child in limit_items(children, limit) { + (render_vertex_chip(child)) + } + } } } - None => (render_untyped_value(value)), } } } } -fn render_string_value(text: &str) -> Markup { - let finder = LinkFinder::new(); +fn render_artifact_section( + artifacts: &[fidget_spinner_store_sqlite::ArtifactSummary], + limit: Option, +) -> Markup { html! { - div class="rich-text" { - @for line in text.lines() { - p { - @for span in finder.spans(line) { - @match span.kind() { - Some(LinkKind::Url) => a href=(span.as_str()) { (span.as_str()) }, - _ => (span.as_str()), + section.card { + h2 { "Artifacts" } + @if artifacts.is_empty() { + p.muted { "No attached artifacts." } + } @else { + div.card-grid { + @for artifact in limit_items(artifacts, limit) { + article.mini-card { + div.card-header { + a.title-link href=(artifact_href(&artifact.slug)) { (artifact.label) } + span.status-chip.classless { (artifact.kind.as_str()) } + } + @if let Some(summary) = artifact.summary.as_ref() { + p.prose { (summary) } + } + div.meta-row { + span.muted { (artifact.locator) } } } } } } } + } } -fn render_json_value(value: &Value) -> Markup { - let text = to_pretty_json(value).unwrap_or_else(|_| value.to_string()); +fn render_experiment_section( + title: &str, + experiments: &[ExperimentSummary], + limit: Option, +) -> Markup { html! { - pre class="json-value" { (text) } + section.card { + h2 { (title) } + @if experiments.is_empty() { + p.muted { "None." } + } @else { + div.card-grid { + @for experiment in limit_items(experiments, limit) { + (render_experiment_card(experiment)) + } + } + } + } } } -fn render_untyped_value(value: &Value) -> Markup { - match value { - Value::String(text) => render_string_value(text), - Value::Number(number) => html! { - code class="numeric" { (number) } - }, - Value::Bool(boolean) => html! { - span class={ "boolean " (if *boolean { "true" } else { "false" }) } { - (if *boolean { "true" } else { "false" }) +fn render_experiment_card(experiment: &ExperimentSummary) -> Markup { + html! { + article.mini-card { + div.card-header { + a.title-link href=(experiment_href(&experiment.slug)) { (experiment.title) } + span.status-chip class=(experiment_status_class(experiment.status)) { + (experiment.status.as_str()) + } + @if let Some(verdict) = experiment.verdict { + span.status-chip class=(verdict_class(verdict)) { (verdict.as_str()) } } - }, - _ => render_json_value(value), + } + @if let Some(summary) = experiment.summary.as_ref() { + p.prose { (summary) } + } + @if let Some(metric) = experiment.primary_metric.as_ref() { + div.meta-row { + span.metric-pill { + (metric.key) ": " + (format_metric_value(metric.value, metric.unit)) + } + } + } + @if !experiment.tags.is_empty() { + div.chip-row { + @for tag in &experiment.tags { + span.tag-chip { (tag) } + } + } + } + div.meta-row.muted { + span { "updated " (format_timestamp(experiment.updated_at)) } + } + } } } -fn render_timestamp(timestamp: OffsetDateTime) -> String { - timestamp - .format(&format_description!( - "[year]-[month]-[day] [hour]:[minute]:[second]Z" - )) - .unwrap_or_else(|_| timestamp.to_string()) +fn render_experiment_summary_line(experiment: &ExperimentSummary) -> Markup { + html! { + div.link-list { + (render_experiment_link_chip(experiment)) + @if let Some(metric) = experiment.primary_metric.as_ref() { + span.metric-pill { + (metric.key) ": " + (format_metric_value(metric.value, metric.unit)) + } + } + } + } } -fn render_timestamp_value(raw: &str) -> String { - OffsetDateTime::parse(raw, &Rfc3339) - .map(render_timestamp) - .unwrap_or_else(|_| raw.to_owned()) +fn render_experiment_link_chip(experiment: &ExperimentSummary) -> Markup { + html! { + a.link-chip href=(experiment_href(&experiment.slug)) { + span { (experiment.title) } + @if let Some(verdict) = experiment.verdict { + span.status-chip class=(verdict_class(verdict)) { (verdict.as_str()) } + } + } + } } -fn stylesheet() -> &'static str { - r#" - :root { - color-scheme: light; - --bg: #f6f3ec; - --panel: #fffdf8; - --line: #d8d1c4; - --text: #22201a; - --muted: #746e62; - --accent: #2d5c4d; - --accent-soft: #dbe8e2; - --tag: #ece5d8; - --warn: #8b5b24; +fn render_vertex_chip(summary: &VertexSummary) -> Markup { + let href = match summary.vertex { + VertexRef::Hypothesis(_) => hypothesis_href(&summary.slug), + VertexRef::Experiment(_) => experiment_href(&summary.slug), + }; + let kind = match summary.vertex { + VertexRef::Hypothesis(_) => "hypothesis", + VertexRef::Experiment(_) => "experiment", + }; + html! { + a.link-chip href=(href) { + span.kind-chip { (kind) } + span { (summary.title) } + @if let Some(summary_text) = summary.summary.as_ref() { + span.muted { " — " (summary_text) } + } + } } +} - * { box-sizing: border-box; } - - body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 15px/1.5 "Iosevka Web", "IBM Plex Mono", "SFMono-Regular", monospace; +fn render_attachment_chip(attachment: &AttachmentDisplay) -> Markup { + html! { + a.link-chip href=(&attachment.href) { + span.kind-chip { (attachment.kind) } + span { (&attachment.title) } + @if let Some(summary) = attachment.summary.as_ref() { + span.muted { " — " (summary) } + } + } } +} - a { - color: var(--accent); - text-decoration: none; +fn render_prose_block(title: &str, body: &str) -> Markup { + html! { + section.card { + h2 { (title) } + p.prose { (body) } + } } +} - a:hover { - text-decoration: underline; +fn render_shell( + title: &str, + subtitle: Option<&str>, + breadcrumb: Option<(&str, String)>, + content: Markup, +) -> Markup { + html! { + (DOCTYPE) + html { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) } + style { (PreEscaped(styles())) } + } + body { + main.shell { + header.page-header { + div.eyebrow { + a href="/" { "home" } + @if let Some((label, href)) = breadcrumb { + span.sep { "/" } + a href=(href) { (label) } + } + } + h1.page-title { (title) } + @if let Some(subtitle) = subtitle { + p.page-subtitle { (subtitle) } + } + } + (content) + } + } + } } +} - .shell { - display: grid; - grid-template-columns: 18rem minmax(0, 1fr); - min-height: 100vh; +fn render_kv(label: &str, value: &str) -> Markup { + html! { + div.kv { + div.kv-label { (label) } + div.kv-value { (value) } + } } +} - .rail { - border-right: 1px solid var(--line); - padding: 1.25rem 1rem; - position: sticky; - top: 0; - align-self: start; - height: 100vh; - overflow: auto; - background: rgba(255, 253, 248, 0.85); - backdrop-filter: blur(6px); +fn render_dimension_value(value: &RunDimensionValue) -> String { + match value { + RunDimensionValue::String(value) => value.to_string(), + RunDimensionValue::Numeric(value) => format_float(*value), + RunDimensionValue::Boolean(value) => value.to_string(), + RunDimensionValue::Timestamp(value) => value.to_string(), } +} - .project, .feed-meta, .entry-meta, .entry-summary, .tag-description { - color: var(--muted); +fn format_metric_value(value: f64, unit: MetricUnit) -> String { + match unit { + MetricUnit::Bytes => format!("{} B", format_integerish(value)), + MetricUnit::Seconds => format!("{value:.3} s"), + MetricUnit::Count => format_integerish(value), + MetricUnit::Ratio => format!("{value:.4}"), + MetricUnit::Custom => format_float(value), } +} - .tag-list { - display: grid; - gap: 0.5rem; +fn format_float(value: f64) -> String { + if value.fract() == 0.0 { + format_integerish(value) + } else { + format!("{value:.4}") } +} - .tag-link { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.2rem 0.75rem; - padding: 0.55rem 0.7rem; - border: 1px solid var(--line); - background: var(--panel); +fn format_integerish(value: f64) -> String { + let negative = value.is_sign_negative(); + let digits = format!("{:.0}", value.abs()); + let mut grouped = String::with_capacity(digits.len() + (digits.len() / 3)); + for (index, ch) in digits.chars().rev().enumerate() { + if index != 0 && index % 3 == 0 { + grouped.push(','); + } + grouped.push(ch); } + let grouped: String = grouped.chars().rev().collect(); + if negative { + format!("-{grouped}") + } else { + grouped + } +} + +fn format_timestamp(value: OffsetDateTime) -> String { + const TIMESTAMP: &[time::format_description::FormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour]:[minute]"); + value.format(TIMESTAMP).unwrap_or_else(|_| { + value + .format(&Rfc3339) + .unwrap_or_else(|_| value.unix_timestamp().to_string()) + }) +} + +fn frontier_href(slug: &Slug) -> String { + format!("/frontier/{}", encode_path_segment(slug.as_str())) +} + +fn hypothesis_href(slug: &Slug) -> String { + format!("/hypothesis/{}", encode_path_segment(slug.as_str())) +} - .tag-link.selected { - border-color: var(--accent); - background: var(--accent-soft); +fn hypothesis_href_from_id(id: fidget_spinner_core::HypothesisId) -> String { + format!("/hypothesis/{}", encode_path_segment(&id.to_string())) +} + +fn hypothesis_title_for_roadmap_item( + projection: &FrontierOpenProjection, + hypothesis_id: fidget_spinner_core::HypothesisId, +) -> String { + projection + .active_hypotheses + .iter() + .find(|state| state.hypothesis.id == hypothesis_id) + .map(|state| state.hypothesis.title.to_string()) + .unwrap_or_else(|| hypothesis_id.to_string()) +} + +fn experiment_href(slug: &Slug) -> String { + format!("/experiment/{}", encode_path_segment(slug.as_str())) +} + +fn artifact_href(slug: &Slug) -> String { + format!("/artifact/{}", encode_path_segment(slug.as_str())) +} + +fn resolve_attachment_display( + store: &fidget_spinner_store_sqlite::ProjectStore, + target: AttachmentTargetRef, +) -> Result { + match target { + AttachmentTargetRef::Frontier(id) => { + let frontier = store.read_frontier(&id.to_string())?; + Ok(AttachmentDisplay { + kind: "frontier", + href: frontier_href(&frontier.slug), + title: frontier.label.to_string(), + summary: Some(frontier.objective.to_string()), + }) + } + AttachmentTargetRef::Hypothesis(id) => { + let detail = store.read_hypothesis(&id.to_string())?; + Ok(AttachmentDisplay { + kind: "hypothesis", + href: hypothesis_href(&detail.record.slug), + title: detail.record.title.to_string(), + summary: Some(detail.record.summary.to_string()), + }) + } + AttachmentTargetRef::Experiment(id) => { + let detail = store.read_experiment(&id.to_string())?; + Ok(AttachmentDisplay { + kind: "experiment", + href: experiment_href(&detail.record.slug), + title: detail.record.title.to_string(), + summary: detail.record.summary.as_ref().map(ToString::to_string), + }) + } } +} - .tag-name { - font-weight: 700; - overflow-wrap: anywhere; +fn encode_path_segment(value: &str) -> String { + utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() +} + +fn frontier_status_class(status: &str) -> &'static str { + match status { + "exploring" => "status-exploring", + "paused" => "status-parked", + "archived" => "status-archived", + _ => "status-neutral", } +} - .tag-count { - color: var(--muted); +fn experiment_status_class(status: ExperimentStatus) -> &'static str { + match status { + ExperimentStatus::Open => "status-open", + ExperimentStatus::Closed => "status-neutral", } +} - .tag-description { - grid-column: 1 / -1; - font-size: 0.9rem; - overflow-wrap: anywhere; +fn verdict_class(verdict: FrontierVerdict) -> &'static str { + match verdict { + FrontierVerdict::Accepted => "status-accepted", + FrontierVerdict::Kept => "status-kept", + FrontierVerdict::Parked => "status-parked", + FrontierVerdict::Rejected => "status-rejected", } +} + +fn limit_items(items: &[T], limit: Option) -> &[T] { + let Some(limit) = limit else { + return items; + }; + let Ok(limit) = usize::try_from(limit) else { + return items; + }; + let end = items.len().min(limit); + &items[..end] +} - .feed { - padding: 1.5rem; +fn styles() -> &'static str { + r#" + :root { + color-scheme: dark; + --bg: #091019; + --panel: #0f1823; + --panel-2: #131f2d; + --border: #1e3850; + --text: #d8e6f3; + --muted: #87a0b8; + --accent: #6dc7ff; + --accepted: #7ce38b; + --kept: #8de0c0; + --parked: #d9c17d; + --rejected: #ee7a7a; + } + * { box-sizing: border-box; } + body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 "Iosevka Web", "Iosevka", "JetBrains Mono", monospace; + } + a { + color: var(--accent); + text-decoration: none; + } + a:hover { text-decoration: underline; } + .shell { + width: min(1500px, 100%); + margin: 0 auto; + padding: 20px; display: grid; - gap: 1rem; - min-width: 0; + gap: 16px; } - - .feed-header { - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--line); + .page-header { + display: grid; + gap: 8px; + padding: 16px 18px; + border: 1px solid var(--border); + background: var(--panel); } - - .entry, .empty-state { + .eyebrow { + display: flex; + gap: 10px; + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .sep { color: #4d6478; } + .page-title { + margin: 0; + font-size: clamp(22px, 3.8vw, 34px); + line-height: 1.1; + } + .page-subtitle { + margin: 0; + color: var(--muted); + max-width: 90ch; + } + .card { + border: 1px solid var(--border); background: var(--panel); - border: 1px solid var(--line); - padding: 1rem 1.1rem; + padding: 16px 18px; + display: grid; + gap: 12px; + } + .subcard { + border: 1px solid #1a2b3c; + background: var(--panel-2); + padding: 12px 14px; + display: grid; + gap: 10px; min-width: 0; - overflow: hidden; } - - .entry-header { + .block { display: grid; gap: 10px; } + .split { display: grid; - gap: 0.35rem; - margin-bottom: 0.75rem; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } - - .entry-title-row { + .card-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + } + .mini-card { + border: 1px solid #1a2b3c; + background: var(--panel-2); + padding: 12px 14px; + display: grid; + gap: 9px; + min-width: 0; + } + .card-header { display: flex; + gap: 10px; + align-items: center; flex-wrap: wrap; - gap: 0.75rem; - align-items: baseline; } - - .entry-title { + .title-link { + font-size: 16px; + font-weight: 700; + color: #f2f8ff; + } + h1, h2, h3 { margin: 0; - font-size: 1.05rem; - min-width: 0; - overflow-wrap: anywhere; + line-height: 1.15; } - - .entry-meta { + h2 { font-size: 19px; } + h3 { font-size: 14px; color: #c9d8e6; } + .prose { + margin: 0; + color: #dce9f6; + max-width: 92ch; + white-space: pre-wrap; + } + .muted { color: var(--muted); } + .meta-row { display: flex; flex-wrap: wrap; - gap: 0.75rem; - font-size: 0.9rem; + gap: 14px; + align-items: center; + font-size: 13px; + } + .kv-grid { + display: grid; + gap: 10px 14px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + .kv { + display: grid; + gap: 4px; min-width: 0; } - - .class-badge, .field-type, .entry-tag { - display: inline-block; - padding: 0.08rem 0.4rem; - border: 1px solid var(--line); - background: var(--tag); - font-size: 0.82rem; + .kv-label { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; } - - .field-type.plottable { - background: var(--accent-soft); - border-color: var(--accent); + .kv-value { + overflow-wrap: anywhere; } - - .tag-strip { - display: inline-flex; + .chip-row, .link-list { + display: flex; flex-wrap: wrap; - gap: 0.35rem; + gap: 8px; } - - .entry-body { - margin-bottom: 0.9rem; - min-width: 0; + .tag-chip, .kind-chip, .status-chip, .metric-pill, .link-chip { + border: 1px solid #24425b; + background: rgba(109, 199, 255, 0.06); + padding: 4px 8px; + font-size: 12px; + line-height: 1.2; } - - .rich-text p { - margin: 0 0 0.55rem; - overflow-wrap: anywhere; - word-break: break-word; - max-width: 100%; + .link-chip { + display: inline-flex; + gap: 8px; + align-items: center; } - - .rich-text p:last-child { - margin-bottom: 0; + .kind-chip { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; } - - .field-list { - display: grid; - grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr); - gap: 0.55rem 1rem; - margin: 0; + .status-chip { + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 700; + } + .status-accepted { color: var(--accepted); border-color: rgba(124, 227, 139, 0.35); } + .status-kept { color: var(--kept); border-color: rgba(141, 224, 192, 0.35); } + .status-parked { color: var(--parked); border-color: rgba(217, 193, 125, 0.35); } + .status-rejected { color: var(--rejected); border-color: rgba(238, 122, 122, 0.35); } + .status-open { color: var(--accent); border-color: rgba(109, 199, 255, 0.35); } + .status-exploring { color: var(--accent); border-color: rgba(109, 199, 255, 0.35); } + .status-neutral, .classless { color: #a7c0d4; border-color: #2a4358; } + .status-archived { color: #7f8da0; border-color: #2b3540; } + .metric-table { width: 100%; - min-width: 0; + border-collapse: collapse; + font-size: 13px; } - - .field-list dt { + .metric-table th, + .metric-table td { + padding: 7px 8px; + border-top: 1px solid #1b2d3e; + text-align: left; + vertical-align: top; + } + .metric-table th { + color: var(--muted); font-weight: 700; - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - align-items: center; - overflow-wrap: anywhere; - min-width: 0; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 12px; } - - .field-list dd { - margin: 0; - min-width: 0; + .related-block { + display: grid; + gap: 8px; } - - .json-value { + .roadmap-list, .simple-list { margin: 0; - padding: 0.6rem 0.7rem; - background: #f3eee4; - overflow: auto; + padding-left: 18px; + display: grid; + gap: 6px; + } + .code-block { white-space: pre-wrap; overflow-wrap: anywhere; + border: 1px solid #1a2b3c; + background: #0b131c; + padding: 12px 14px; } - - .boolean.true { color: var(--accent); } - .boolean.false { color: #8a2f2f; } - .numeric { font-size: 1rem; } - - .diagnostics { - margin-top: 1rem; - padding-top: 0.8rem; - border-top: 1px dashed var(--line); - } - - .diagnostics h4 { - margin: 0 0 0.4rem; - font-size: 0.9rem; - text-transform: lowercase; - } - - .diagnostics ul { - margin: 0; - padding-left: 1.1rem; - } - - .diag-severity { - color: var(--warn); - font-weight: 700; + code { + font-family: inherit; + font-size: 0.95em; } - - @media (max-width: 900px) { - .shell { - grid-template-columns: 1fr; - } - - .rail { - position: static; - height: auto; - border-right: 0; - border-bottom: 1px solid var(--line); - padding: 1rem 0.85rem; - } - - .field-list { - grid-template-columns: minmax(0, 1fr); - } - - .feed { - padding: 1rem; - } - - .entry, .empty-state { - padding: 0.85rem 0.9rem; - } + @media (max-width: 720px) { + .shell { padding: 12px; } + .card, .page-header { padding: 14px; } + .subcard, .mini-card { padding: 12px; } + .card-grid, .split, .kv-grid { grid-template-columns: 1fr; } } "# } -- cgit v1.2.3