feat: browser-style panel nav, repo file routing, migration 0007
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
Nickel Type Check / Nickel Type Checking (push) Has been cancelled

graph, search, api_catalog pages: back/forward history stack (PanelNav/dpNav).
  File artifact paths open in external tabs via card.repo (Gitea source URL) or
  card.docs (cargo docs for .rs) — openFile/openFileInPanel removed from all pages.
  Tera | safe required for URL values inside <script> blocks (auto-escape of slashes).

  card.ncl: repo field added.
  insert_brand_ctx: injects card_repo/card_docs into Tera context.
  #[onto_api] proc-macro: source_file = file!() emitted; ApiRouteEntry.source_file
  populated in primary catalog handler.

  migration 0007-card-repo-field: check card.ncl for repo field; skip if absent.
This commit is contained in:
Jesús Pérez 2026-03-29 08:32:50 +01:00
parent da083fb9ec
commit 75892a8eea
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
38 changed files with 4697 additions and 1064 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ nushell-*
github-com github-com
.coder .coder
target target
artifacts/
distribution distribution
.qodo .qodo
# enviroment to load on bin/build # enviroment to load on bin/build

View File

@ -212,7 +212,7 @@ let d = import "../ontology/defaults/core.ncl" in
name = "Ontoref Daemon", name = "Ontoref Daemon",
pole = 'Yang, pole = 'Yang,
level = 'Practice, level = 'Practice,
description = "Runtime support daemon for the ontoref protocol. Provides NCL export caching, file watching, actor registry, notification barrier, HTTP API (11 pages), MCP server (29 tools, stdio + streamable-HTTP), Q&A NCL persistence, quick-actions catalog, passive drift observation, unified auth/session management, per-file ontology version counters (GET /projects/{slug}/ontology/versions), and annotated API catalog (GET /api/catalog). API catalog populated at link time via #[onto_api] proc-macro + inventory — zero runtime overhead. Launched via ADR-004 NCL pipe bootstrap: nickel export config.ncl | ontoref-daemon.bin --config-stdin.", description = "Runtime support daemon for the ontoref protocol. Provides NCL export caching, file watching, actor registry, notification barrier, HTTP API (11 pages), MCP server (29 tools, stdio + streamable-HTTP), Q&A NCL persistence, quick-actions catalog, passive drift observation, unified auth/session management, per-file ontology version counters (GET /projects/{slug}/ontology/versions), and annotated API catalog (GET /api/catalog). API catalog populated at link time via #[onto_api] proc-macro + inventory — zero runtime overhead. Launched via ADR-004 NCL pipe bootstrap: nickel export config.ncl | ontoref-daemon.bin --config-stdin. Graph, search, and api_catalog UI pages carry browser-style panel navigation (back/forward history stack). File artifact paths open in external tabs: card.repo (Gitea source URL) for most files, card.docs (cargo docs) for .rs files — no inline file loading. card_repo/card_docs injected into Tera context from insert_brand_ctx; | safe filter required for URL values inside <script> blocks.",
invariant = false, invariant = false,
artifact_paths = [ artifact_paths = [
"crates/ontoref-daemon/", "crates/ontoref-daemon/",

View File

@ -52,7 +52,7 @@ let d = import "../ontology/defaults/state.ncl" in
from = "modes-and-web-present", from = "modes-and-web-present",
to = "fully-self-described", to = "fully-self-described",
condition = "At least 3 ADRs accepted, reflection/backlog.ncl present, describe project returns complete picture.", condition = "At least 3 ADRs accepted, reflection/backlog.ncl present, describe project returns complete picture.",
catalyst = "ADR-001ADR-006 authored (6 ADRs present). Auth model, project onboarding, and session management nodes added in 2026-03-13. Personal/career/project-card schemas, 5 content modes, search bookmarks, and ADR-006 (Nu 0.111 compat) added in session 2026-03-15. Session 2026-03-23: api-catalog-surface node added (#[onto_api] proc-macro + inventory catalog), describe-query-layer updated (diff + api subcommands), adopt-ontoref-tooling updated (update_ontoref mode + manifest/connections templates + enrichment prompt), ontoref-daemon updated (11 pages, 29 MCP tools, per-file versioning, API catalog endpoint). Session 2026-03-26: config-surface node added — typed DaemonNclConfig (parse-at-boundary pattern), #[derive(ConfigFields)] coherence registry, override-layer mutation API (PUT /config/{section}), NCL contracts (.ontoref/contracts.ncl: LogConfig + DaemonConfig), manifest config_surface with multi-consumer sections. ADR-007 (inventory/onto_api) extended to ConfigFields; ADR-008 (NCL-first config validation + override-layer mutation). Session 2026-03-26 (2nd): manifest-self-description node added — capability_type (id/name/summary/rationale/how/artifacts/adrs/nodes), requirement_type (env_target: Production/Development/Both; kind: Tool/Service/EnvVar/Infrastructure; impact/provision), critical_dep_type (failure_impact required; mitigation). describe requirements new subcommand. describe guides extended with capabilities/requirements/critical_deps. Bug fix: collect-identity read manifest.kind? (never existed) instead of manifest.repo_kind?; description field added to manifest_type. Ontoref self-described with 3 capabilities, 5 requirements, 3 critical deps. ADR-009.", catalyst = "ADR-001ADR-006 authored (6 ADRs present). Auth model, project onboarding, and session management nodes added in 2026-03-13. Personal/career/project-card schemas, 5 content modes, search bookmarks, and ADR-006 (Nu 0.111 compat) added in session 2026-03-15. Session 2026-03-23: api-catalog-surface node added (#[onto_api] proc-macro + inventory catalog), describe-query-layer updated (diff + api subcommands), adopt-ontoref-tooling updated (update_ontoref mode + manifest/connections templates + enrichment prompt), ontoref-daemon updated (11 pages, 29 MCP tools, per-file versioning, API catalog endpoint). Session 2026-03-26: config-surface node added — typed DaemonNclConfig (parse-at-boundary pattern), #[derive(ConfigFields)] coherence registry, override-layer mutation API (PUT /config/{section}), NCL contracts (.ontoref/contracts.ncl: LogConfig + DaemonConfig), manifest config_surface with multi-consumer sections. ADR-007 (inventory/onto_api) extended to ConfigFields; ADR-008 (NCL-first config validation + override-layer mutation). Session 2026-03-26 (2nd): manifest-self-description node added — capability_type (id/name/summary/rationale/how/artifacts/adrs/nodes), requirement_type (env_target: Production/Development/Both; kind: Tool/Service/EnvVar/Infrastructure; impact/provision), critical_dep_type (failure_impact required; mitigation). describe requirements new subcommand. describe guides extended with capabilities/requirements/critical_deps. Bug fix: collect-identity read manifest.kind? (never existed) instead of manifest.repo_kind?; description field added to manifest_type. Ontoref self-described with 3 capabilities, 5 requirements, 3 critical deps. ADR-009. Session 2026-03-29: graph/search/api_catalog UI pages gain browser-style panel navigation (PanelNav/dpNav back/forward history stack, cursor-into-array model). File artifact paths route to external tabs via card.repo (Gitea source URL format {repo}/src/branch/main/{path}) or card.docs (cargo docs URL for .rs files when configured) — inline file loading removed from all three pages. card.ncl gains repo field. insert_brand_ctx injects card_repo/card_docs into Tera context. Tera | safe filter applied to URL values in <script> blocks to prevent HTML entity escaping of slashes.",
blocker = "none", blocker = "none",
horizon = 'Weeks, horizon = 'Weeks,
}, },

View File

@ -7,6 +7,58 @@ ADRs referenced below live in `adrs/` as typed Nickel records.
## [Unreleased] ## [Unreleased]
### Browser-style panel navigation + repo file routing
Graph, search, and api_catalog pages now share a uniform browser-style navigation model:
back/forward history stack with cursor-into-array semantics. File artifact paths open in
external browser tabs rather than being loaded inline.
#### `crates/ontoref-daemon/templates/pages/graph.html`
- `.artifact-link` click handler changed from removed `openFile()` to `srcOpen()`.
- `panelNav._replay` `type: "file"` case changed to `srcOpen(e.id)`.
#### `crates/ontoref-daemon/templates/pages/search.html`
- `openFileInPanel` async function removed entirely (was loading file content inline via `/api/file`).
- `srcOpen(path)` function added: opens `{card_repo}/src/branch/main/{path}` for most files;
opens `card_docs` for `.rs` files when configured.
- `CARD_REPO` / `CARD_DOCS` JS constants injected via Tera (`| safe` filter required — Tera
auto-escapes all `{{ }}` interpolations regardless of `<script>` context).
- `.s-file-link` click delegation updated to call `srcOpen`.
- `dpNav._replay` `type: "file"` case calls `srcOpen`.
#### `crates/ontoref-daemon/templates/pages/api_catalog.html`
- `ensureFileModal` and `openFile` removed.
- `srcOpen` added with same logic as graph and search pages.
- `CARD_REPO` / `CARD_DOCS` constants injected.
- `#detail-source-btn` click delegation calls `srcOpen`.
#### `crates/ontoref-daemon/src/ui/handlers.rs`
- `insert_brand_ctx` reads `card.repo` and `card.docs` from config NCL and injects
`card_repo` / `card_docs` into the Tera context for all pages.
#### `card.ncl`
- `repo = "https://repo.jesusperez.pro/jesus/ontoref"` added.
#### `reflection/migrations/0007-card-repo-field.ncl` — new migration
- Check: `card.ncl` absent → pass (not applicable); present + `repo =` found → pass; present
without `repo =` → pending.
- Instructions: add `repo` and optionally `docs` fields; explains how `srcOpen` uses both.
#### on+re update
| Artifact | Change |
|----------|--------|
| `.ontology/core.ncl` | `ontoref-daemon` description updated — browser-style panel nav, file routing via `card.repo`/`card.docs` |
| `.ontology/state.ncl` | `self-description-coverage` catalyst updated with session 2026-03-29 |
---
### Protocol Migration System — progressive NCL checks for consumer project upgrades (ADR-010) ### Protocol Migration System — progressive NCL checks for consumer project upgrades (ADR-010)
Replaces the template-prompt approach with an ordered, idempotent migration system. Applied state Replaces the template-prompt approach with an ordered, idempotent migration system. Applied state

View File

@ -98,8 +98,14 @@ Counter increments on every watcher-triggered reload. `GET /projects/{slug}/onto
**ADRNode Linkage** — nodes declare which ADRs validate them via `adrs: Array String`. **ADRNode Linkage** — nodes declare which ADRs validate them via `adrs: Array String`.
`describe` surfaces a **Validated by** section per node (CLI and `--fmt md`). The graph UI `describe` surfaces a **Validated by** section per node (CLI and `--fmt md`). The graph UI
renders each ADR as a clickable link that opens the full ADR content in a modal via renders each ADR as a clickable link that opens the full ADR content via `GET /api/adr/{id}`.
`GET /api/adr/{id}`.
**Browser-Style Panel Navigation** — graph, search, and api_catalog UI pages carry a
back/forward history stack (cursor-into-array model). Clicking nodes, ADRs, or search results
pushes to history; clicking artifacts opens the source file in the configured repository or
docs. `card.repo` in `card.ncl` resolves to `{repo}/src/branch/main/{path}` (Gitea format).
For `.rs` files, `card.docs` redirects to the cargo docs URL instead. `insert_brand_ctx`
injects both as `card_repo`/`card_docs` into every Tera template.
**Passive Drift Observation** — background file watcher that detects divergence between Yang **Passive Drift Observation** — background file watcher that detects divergence between Yang
code artifacts and Yin ontology. Watches `crates/`, `.ontology/`, `adrs/`, `reflection/modes/`. code artifacts and Yin ontology. Watches `crates/`, `.ontology/`, `adrs/`, `reflection/modes/`.
@ -137,7 +143,8 @@ NuCmd`) whose result IS the applied state — no state file, fully idempotent. `
migrations with applied/pending status; `migrate pending` lists only what is missing; `migrate show <id>` migrations with applied/pending status; `migrate pending` lists only what is missing; `migrate show <id>`
renders runtime-interpolated instructions (project_root and project_name auto-detected). NuCmd checks are renders runtime-interpolated instructions (project_root and project_name auto-detected). NuCmd checks are
valid Nushell (no bash `&&`, `$env.VAR` not `$VAR`). Grep checks targeting ADR files scope to valid Nushell (no bash `&&`, `$env.VAR` not `$VAR`). Grep checks targeting ADR files scope to
`adr-[0-9][0-9][0-9]-*.ncl` to exclude schema/template infrastructure files. ([ADR-010](adrs/adr-010-protocol-migration-system.ncl)) `adr-[0-9][0-9][0-9]-*.ncl` to exclude schema/template infrastructure files. 7 migrations shipped;
`0007-card-repo-field` checks for `repo =` in `card.ncl`. ([ADR-010](adrs/adr-010-protocol-migration-system.ncl))
**Manifest Self-Interrogation** — `manifest_type` gains three typed arrays that answer self-knowledge **Manifest Self-Interrogation** — `manifest_type` gains three typed arrays that answer self-knowledge
queries agents and operators need on cold start: `capabilities[]` (what the project does, why it was queries agents and operators need on cold start: `capabilities[]` (what the project does, why it was

View File

@ -9,6 +9,7 @@ d.ProjectCard & {
status = 'Active, status = 'Active,
source = 'Local, source = 'Local,
url = "https://ontoref.jesusperez.pro", url = "https://ontoref.jesusperez.pro",
repo = "https://repo.jesusperez.pro/jesus/ontoref",
started_at = "2025", started_at = "2025",
tags = ["nickel", "ontology", "governance", "protocol", "architecture"], tags = ["nickel", "ontology", "governance", "protocol", "architecture"],
tools = ["Nickel", "Nushell"], tools = ["Nickel", "Nushell"],

File diff suppressed because one or more lines are too long

View File

@ -312,14 +312,17 @@ pub fn router(state: AppState) -> axum::Router {
// ADR read + validation endpoints // ADR read + validation endpoints
.route("/validate/adrs", get(validate_adrs)) .route("/validate/adrs", get(validate_adrs))
.route("/adr/{id}", get(get_adr)) .route("/adr/{id}", get(get_adr))
// File content (for graph UI artifact links)
.route("/file", get(get_file_content))
// Ontology extension endpoints // Ontology extension endpoints
.route("/ontology", get(list_ontology_extensions)) .route("/ontology", get(list_ontology_extensions))
.route("/ontology/{file}", get(get_ontology_extension)) .route("/ontology/{file}", get(get_ontology_extension))
// Graph endpoints (impact analysis + federation) // Graph endpoints (impact analysis + federation)
.route("/graph/impact", get(graph_impact)) .route("/graph/impact", get(graph_impact))
.route("/graph/node/{id}", get(graph_node)) .route("/graph/node/{id}", get(graph_node))
// Backlog JSON endpoint // Backlog endpoints
.route("/backlog-json", get(backlog_json)) .route("/backlog-json", get(backlog_json))
.route("/backlog/propose-status", post(backlog_propose_status))
// Q&A read endpoint // Q&A read endpoint
.route("/qa-json", get(qa_json)) .route("/qa-json", get(qa_json))
// Push-based sync endpoint: projects export their NCL and POST here. // Push-based sync endpoint: projects export their NCL and POST here.
@ -777,12 +780,21 @@ async fn actor_deregister(State(state): State<AppState>, Path(token): Path<Strin
actors = "agent, developer, ci", actors = "agent, developer, ci",
tags = "actors" tags = "actors"
)] )]
async fn actor_touch(State(state): State<AppState>, Path(token): Path<String>) -> StatusCode { async fn actor_touch(
State(state): State<AppState>,
Path(token): Path<String>,
) -> impl IntoResponse {
state.touch_activity(); state.touch_activity();
if state.actors.touch(&token) { if state.actors.touch(&token) {
StatusCode::NO_CONTENT (StatusCode::NO_CONTENT, axum::Json(serde_json::Value::Null)).into_response()
} else { } else {
StatusCode::NOT_FOUND // Return 200 instead of 404 so browsers don't log a console error.
// The `expired` flag tells the client to re-register silently.
(
StatusCode::OK,
axum::Json(serde_json::json!({ "expired": true })),
)
.into_response()
} }
} }
@ -1789,6 +1801,80 @@ async fn get_adr(
) )
} }
// ── File content endpoint
// ─────────────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct FileQuery {
path: String,
slug: Option<String>,
}
#[ontoref_derive::onto_api(
method = "GET",
path = "/file",
description = "Read a project-relative file and return its content as text (for UI artifact \
links)",
auth = "none",
actors = "developer",
params = "path:string:required:Project-relative file path, slug:string:optional:Project slug",
tags = "graph"
)]
async fn get_file_content(
State(state): State<AppState>,
Query(q): Query<FileQuery>,
) -> impl IntoResponse {
state.touch_activity();
let (root, _, _) = resolve_project_ctx(&state, q.slug.as_deref());
// Prevent path traversal — canonicalize both root and requested path.
let Ok(canonical_root) = root.canonicalize() else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "cannot resolve project root" })),
);
};
let requested = canonical_root.join(q.path.trim_start_matches('/'));
let Ok(canonical_req) = requested.canonicalize() else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "file not found" })),
);
};
if !canonical_req.starts_with(&canonical_root) {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": "path outside project root" })),
);
}
let ext = canonical_req
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let lang = match ext {
"rs" => "rust",
"toml" => "toml",
"ncl" => "nickel",
"nu" => "nushell",
"md" => "markdown",
"json" => "json",
"html" => "html",
_ => "text",
};
match std::fs::read_to_string(&canonical_req) {
Ok(content) => (
StatusCode::OK,
Json(serde_json::json!({ "content": content, "lang": lang, "path": q.path })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
),
}
}
// ── Ontology extension endpoints ───────────────────────────────────────────── // ── Ontology extension endpoints ─────────────────────────────────────────────
const CORE_FILES: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"]; const CORE_FILES: &[&str] = &["core.ncl", "state.ncl", "gate.ncl"];
@ -1938,6 +2024,113 @@ async fn backlog_json(
} }
} }
// ── Backlog propose-status ──────────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct BacklogProposeRequest {
pub id: String,
pub proposed_status: String,
#[serde(default)]
pub proposed_by: String,
/// Target project slug (defaults to primary).
#[serde(default)]
pub slug: Option<String>,
}
#[ontoref_derive::onto_api(
method = "POST",
path = "/backlog/propose-status",
description = "Propose a status change for a backlog item. Creates a backlog_review \
notification that requires admin approval via the UI or CLI.",
auth = "viewer",
actors = "agent, developer",
params = "id:string:required:Backlog item id (e.g. bl-001) | \
proposed_status:string:required:Target status (Open/InProgress/Done/Cancelled) | \
proposed_by:string:optional:Actor label shown in the notification | \
slug:string:optional:Project slug (defaults to primary)",
tags = "backlog"
)]
async fn backlog_propose_status(
State(state): State<AppState>,
Json(body): Json<BacklogProposeRequest>,
) -> impl IntoResponse {
state.touch_activity();
let (root, cache, import_path) = resolve_project_ctx(&state, body.slug.as_deref());
let slug = body
.slug
.clone()
.unwrap_or_else(|| state.registry.primary_slug().to_string());
let backlog_path = root.join("reflection").join("backlog.ncl");
let items = if backlog_path.exists() {
match cache.export(&backlog_path, import_path.as_deref()).await {
Ok((json, _)) => json
.get("items")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default(),
Err(_) => vec![],
}
} else {
vec![]
};
let item = items
.iter()
.find(|it| it.get("id").and_then(|v| v.as_str()) == Some(body.id.as_str()));
let item_title = item
.and_then(|it| it.get("title").and_then(|v| v.as_str()))
.unwrap_or(body.id.as_str())
.to_string();
let current_status = item
.and_then(|it| it.get("status").and_then(|v| v.as_str()))
.unwrap_or("Unknown")
.to_string();
let by = if body.proposed_by.is_empty() {
"agent".to_string()
} else {
body.proposed_by.clone()
};
let payload = serde_json::json!({
"item_id": body.id,
"item_title": item_title,
"current_status": current_status,
"proposed_status": body.proposed_status,
"proposed_by": by,
"actions": [
{ "id": "approve", "label": format!("Approve → {}", body.proposed_status), "mode": "backlog_approve" },
{ "id": "reject", "label": "Reject", "mode": "backlog_reject" }
]
});
let proj = state.registry.get(&slug);
let notifications = proj
.as_ref()
.map(|p| &p.notifications)
.unwrap_or(&state.notifications);
notifications.push_custom(
&slug,
"backlog_review",
format!("{}: {}{}", body.id, item_title, body.proposed_status),
Some(payload),
Some(body.proposed_by),
None,
);
(
StatusCode::ACCEPTED,
Json(serde_json::json!({
"status": "proposed",
"item_id": body.id,
"proposed_status": body.proposed_status,
})),
)
}
// ── Q&A endpoints ─────────────────────────────────────────────────────── // ── Q&A endpoints ───────────────────────────────────────────────────────
#[ontoref_derive::onto_api( #[ontoref_derive::onto_api(
@ -3563,7 +3756,7 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(touch_miss.status(), StatusCode::NOT_FOUND); assert_eq!(touch_miss.status(), StatusCode::OK);
} }
#[tokio::test] #[tokio::test]

View File

@ -430,6 +430,34 @@ fn apply_keys_overlay(
} }
} }
#[cfg(feature = "ui")]
fn init_tera(
templates_dir: Option<&std::path::Path>,
) -> Option<Arc<tokio::sync::RwLock<tera::Tera>>> {
let tdir = templates_dir?;
let glob = format!("{}/**/*.html", tdir.display());
match tera::Tera::new(&glob) {
Ok(mut t) => {
let css_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
t.register_function(
"css_version",
move |_: &std::collections::HashMap<String, tera::Value>| {
Ok(tera::Value::String(css_ts.to_string()))
},
);
info!(templates_dir = %tdir.display(), "Tera templates loaded");
Some(Arc::new(tokio::sync::RwLock::new(t)))
}
Err(e) => {
warn!(error = %e, templates_dir = %tdir.display(), "Tera init failed — UI disabled");
None
}
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Parse CLI first so we can redirect logs to stderr in stdio MCP mode. // Parse CLI first so we can redirect logs to stderr in stdio MCP mode.
@ -455,7 +483,10 @@ async fn main() {
} }
if cli.dump_api_catalog { if cli.dump_api_catalog {
println!("{}", ontoref_ontology::api::dump_catalog_json()); println!(
"{}",
ontoref_ontology::api::dump_catalog_ncl("ontoref-daemon")
);
return; return;
} }
@ -638,24 +669,8 @@ async fn main() {
// Initialize Tera template engine from the configured templates directory. // Initialize Tera template engine from the configured templates directory.
#[cfg(feature = "ui")] #[cfg(feature = "ui")]
let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> = { let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> =
if let Some(ref tdir) = cli.templates_dir { init_tera(cli.templates_dir.as_deref());
let glob = format!("{}/**/*.html", tdir.display());
match tera::Tera::new(&glob) {
Ok(t) => {
info!(templates_dir = %tdir.display(), "Tera templates loaded");
Some(Arc::new(tokio::sync::RwLock::new(t)))
}
Err(e) => {
warn!(error = %e, templates_dir = %tdir.display(), "Tera init failed — UI disabled");
None
}
}
} else {
info!("--templates-dir not set — web UI disabled");
None
}
};
// Optional DB connection with health check // Optional DB connection with health check
#[cfg(feature = "db")] #[cfg(feature = "db")]

View File

@ -197,6 +197,10 @@ async fn search_modes(
// ── Detail HTML builders ───────────────────────────────────────────────────── // ── Detail HTML builders ─────────────────────────────────────────────────────
const VIEWABLE_EXTS: &[&str] = &[
"ncl", "toml", "rs", "nu", "md", "json", "html", "yaml", "yml",
];
fn node_html(n: &serde_json::Value) -> String { fn node_html(n: &serde_json::Value) -> String {
let desc = str_field(n, "description"); let desc = str_field(n, "description");
let level = str_field(n, "level"); let level = str_field(n, "level");
@ -210,6 +214,11 @@ fn node_html(n: &serde_json::Value) -> String {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>()) .map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default(); .unwrap_or_default();
let adrs: Vec<&str> = n
.get("adrs")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut h = String::new(); let mut h = String::new();
h.push_str(&para(desc)); h.push_str(&para(desc));
@ -220,14 +229,40 @@ fn node_html(n: &serde_json::Value) -> String {
h.push_str(&badge("invariant", "badge-warning")); h.push_str(&badge("invariant", "badge-warning"));
} }
h.push_str("</div>"); h.push_str("</div>");
if !adrs.is_empty() {
h.push_str(&section_header("Validated by"));
h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">");
for adr in adrs {
h.push_str(&format!(
"<li><span class=\"text-success mr-1\">◆</span><button class=\"s-adr-link \
text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 \
cursor-pointer text-left\" data-adr=\"{id}\">{id}</button></li>",
id = esc(adr)
));
}
h.push_str("</ul>");
}
if !artifacts.is_empty() { if !artifacts.is_empty() {
h.push_str(&section_header("Artifacts")); h.push_str(&section_header("Artifacts"));
h.push_str("<ul class=\"text-xs font-mono space-y-0.5\">"); h.push_str("<ul class=\"text-xs font-mono space-y-1\">");
for a in artifacts { for a in artifacts {
h.push_str(&format!( let ext = a.rsplit('.').next().unwrap_or("");
"<li class=\"text-base-content/60\">{}</li>", if VIEWABLE_EXTS.contains(&ext) {
esc(a) h.push_str(&format!(
)); "<li><button class=\"s-file-link text-primary hover:underline \
underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left \
break-all\" data-path=\"{path}\">{label}</button></li>",
path = esc(a),
label = esc(a)
));
} else {
h.push_str(&format!(
"<li class=\"text-base-content/60\">{}</li>",
esc(a)
));
}
} }
h.push_str("</ul>"); h.push_str("</ul>");
} }

View File

@ -62,6 +62,73 @@ pub fn add_item(
Ok(next_id) Ok(next_id)
} }
/// Update editable fields (title, kind, priority, detail) for an item.
pub struct ItemUpdate<'a> {
pub title: &'a str,
pub kind: &'a str,
pub priority: &'a str,
pub status: &'a str,
pub detail: &'a str,
/// Comma-separated ADR ids; empty string = leave unchanged.
pub related_adrs: &'a str,
/// Comma-separated mode ids; empty string = leave unchanged.
pub related_modes: &'a str,
/// Enum value without tick; empty string = leave unchanged.
pub graduates_to: &'a str,
pub today: &'a str,
}
pub fn update_item(path: &Path, id: &str, upd: &ItemUpdate<'_>) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
let adrs_val = csv_to_ncl_array(upd.related_adrs);
let modes_val = csv_to_ncl_array(upd.related_modes);
let mut fields: Vec<(&str, String)> = vec![
("title", format!("\"{}\"", escape_ncl(upd.title))),
("kind", format!("'{}", upd.kind)),
("priority", format!("'{}", upd.priority)),
("status", format!("'{}", upd.status)),
("detail", format!("\"{}\"", escape_ncl(upd.detail))),
("updated", format!("\"{}\"", upd.today)),
];
if !upd.related_adrs.is_empty() {
fields.push(("related_adrs", adrs_val));
}
if !upd.related_modes.is_empty() {
fields.push(("related_modes", modes_val));
}
if !upd.graduates_to.is_empty() {
fields.push(("graduates_to", format!("'{}", upd.graduates_to)));
}
let pairs: Vec<(&str, &str)> = fields.iter().map(|(k, v)| (*k, v.as_str())).collect();
let updated = mutate_item_fields(&content, id, &pairs);
super::ncl_write::atomic_write(path, &updated)?;
Ok(())
}
/// Convert `"adr-001,adr-002"` → `["adr-001", "adr-002"]` (Nickel array
/// literal).
fn csv_to_ncl_array(csv: &str) -> String {
if csv.trim().is_empty() {
return "[]".to_string();
}
let items: Vec<String> = csv
.split(',')
.map(|s| format!("\"{}\"", escape_ncl(s.trim())))
.collect();
format!("[{}]", items.join(", "))
}
/// Remove the item block with the given id from the items array.
pub fn delete_item(path: &Path, id: &str) -> anyhow::Result<()> {
let content = std::fs::read_to_string(path)?;
let updated = remove_item_block(&content, id)?;
super::ncl_write::atomic_write(path, &updated)?;
Ok(())
}
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
/// Replace the value of one or more fields within the item block identified by /// Replace the value of one or more fields within the item block identified by
@ -152,6 +219,56 @@ fn insert_before_array_close(content: &str, block: &str) -> anyhow::Result<Strin
Ok(result) Ok(result)
} }
/// Advance `i` past the `},` closing line and an optional trailing blank.
/// Returns the new index after skipping.
fn skip_past_block_close(lines: &[&str], mut i: usize) -> usize {
while i < lines.len() {
let closing = lines[i].trim();
i += 1;
if closing == "}," {
break;
}
}
if lines.get(i).is_some_and(|l| l.trim().is_empty()) {
i += 1;
}
i
}
/// Remove the entire `{ ... },` block that contains `id = "<id>"`.
///
/// Scans for the opening `{` on the line before the id field (or the same
/// block boundary), collects lines until the closing `},`, and drops them.
fn remove_item_block(content: &str, id: &str) -> anyhow::Result<String> {
let id_needle = format!("\"{}\"", id);
let lines: Vec<&str> = content.lines().collect();
let mut result: Vec<&str> = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
let is_block_start = trimmed == "{"
&& (1..=4).any(|d| {
lines
.get(i + d)
.is_some_and(|l| l.contains(&id_needle) && l.contains('='))
});
if is_block_start {
i = skip_past_block_close(&lines, i);
continue;
}
result.push(lines[i]);
i += 1;
}
let out = result.join("\n");
if content.ends_with('\n') && !out.ends_with('\n') {
Ok(out + "\n")
} else {
Ok(out)
}
}
/// Minimal escaping for string values embedded in Nickel double-quoted strings. /// Minimal escaping for string values embedded in Nickel double-quoted strings.
fn escape_ncl(s: &str) -> String { fn escape_ncl(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"") s.replace('\\', "\\\\").replace('"', "\\\"")

File diff suppressed because it is too large Load Diff

View File

@ -63,6 +63,7 @@ fn multi_router(state: AppState) -> axum::Router {
.route("/manage/logout", get(handlers::manage_logout)) .route("/manage/logout", get(handlers::manage_logout))
// Per-project routes — AuthUser extractor enforces auth per project // Per-project routes — AuthUser extractor enforces auth per project
.route("/{slug}/", get(handlers::dashboard_mp)) .route("/{slug}/", get(handlers::dashboard_mp))
.route("/{slug}/stats", get(handlers::dashboard_stats_mp))
.route("/{slug}/graph", get(handlers::graph_mp)) .route("/{slug}/graph", get(handlers::graph_mp))
.route("/{slug}/sessions", get(handlers::sessions_mp)) .route("/{slug}/sessions", get(handlers::sessions_mp))
.route("/{slug}/notifications", get(handlers::notifications_mp)) .route("/{slug}/notifications", get(handlers::notifications_mp))
@ -77,6 +78,12 @@ fn multi_router(state: AppState) -> axum::Router {
post(handlers::backlog_update_status_mp), post(handlers::backlog_update_status_mp),
) )
.route("/{slug}/backlog/add", post(handlers::backlog_add_mp)) .route("/{slug}/backlog/add", post(handlers::backlog_add_mp))
.route("/{slug}/backlog/edit", post(handlers::backlog_edit_mp))
.route("/{slug}/backlog/delete", post(handlers::backlog_delete_mp))
.route(
"/{slug}/backlog/propose-status",
post(handlers::backlog_propose_status_mp),
)
.route( .route(
"/{slug}/notifications/{id}/action", "/{slug}/notifications/{id}/action",
post(handlers::notification_action_mp), post(handlers::notification_action_mp),
@ -94,6 +101,10 @@ fn multi_router(state: AppState) -> axum::Router {
.route("/{slug}/api", get(handlers::api_catalog_page_mp)) .route("/{slug}/api", get(handlers::api_catalog_page_mp))
.route("/{slug}/config", get(handlers::config_page_mp)) .route("/{slug}/config", get(handlers::config_page_mp))
.route("/{slug}/adrs", get(handlers::adrs_page_mp)) .route("/{slug}/adrs", get(handlers::adrs_page_mp))
.route(
"/{slug}/migrations/pending",
get(handlers::pending_migrations_fragment_mp),
)
.route("/{slug}/actions", get(handlers::actions_page_mp)) .route("/{slug}/actions", get(handlers::actions_page_mp))
.route("/{slug}/actions/run", post(handlers::actions_run_mp)) .route("/{slug}/actions/run", post(handlers::actions_run_mp))
.route("/{slug}/qa", get(handlers::qa_page_mp)) .route("/{slug}/qa", get(handlers::qa_page_mp))

File diff suppressed because it is too large Load Diff

View File

@ -66,7 +66,9 @@
{% if not current_role or current_role == "admin" %} {% if not current_role or current_role == "admin" %}
<div class="card-actions justify-end pt-1"> <div class="card-actions justify-end pt-1">
<form method="post" action="{{ base_url }}/actions/run"> <form hx-post="{{ base_url }}/actions/run"
hx-target="this"
hx-swap="outerHTML">
<input type="hidden" name="action_id" value="{{ action.id }}"> <input type="hidden" name="action_id" value="{{ action.id }}">
<button type="submit" class="btn btn-xs btn-primary gap-1"> <button type="submit" class="btn btn-xs btn-primary gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -28,10 +28,10 @@
<span class="badge badge-lg badge-neutral">{{ route_count }} routes</span> <span class="badge badge-lg badge-neutral">{{ route_count }} routes</span>
</div> </div>
{% if not is_primary and route_count == 0 %} {% if route_count == 0 %}
<div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm"> <div class="flex flex-col items-center justify-center py-16 text-base-content/40 text-sm">
<p>No API surface declared for <code class="font-mono">{{ slug }}</code>.</p> <p>No API surface declared for <code class="font-mono">{{ slug }}</code>.</p>
<p class="mt-2">Add route declarations to the project's ontology to surface them here.</p> <p class="mt-2">Export an <code class="font-mono">api-catalog*.json</code> file to the project root to surface routes here.</p>
</div> </div>
{% else %} {% else %}
@ -52,38 +52,29 @@
<option value="POST">POST</option> <option value="POST">POST</option>
<option value="PUT">PUT</option> <option value="PUT">PUT</option>
<option value="DELETE">DELETE</option> <option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select> </select>
<span id="filter-count" class="text-xs text-base-content/40 self-center hidden"></span>
</div> </div>
<!-- Routes table --> <!-- Catalog accordions -->
<div class="overflow-x-auto" id="routes-container"> <div id="catalogs-container"></div>
<table class="table table-sm w-full bg-base-200 rounded-lg" id="routes-table">
<thead>
<tr class="text-base-content/50 text-xs uppercase tracking-wider">
<th class="w-16">Method</th>
<th>Path</th>
<th>Description</th>
<th class="w-20">Auth</th>
<th>Actors</th>
<th>Tags</th>
</tr>
</thead>
<tbody id="routes-body">
</tbody>
</table>
</div>
<!-- Route detail modal --> <!-- Route detail modal -->
<dialog id="route-modal" class="modal"> <dialog id="route-modal" class="modal">
<div class="modal-box w-full max-w-2xl"> <div class="modal-box w-full max-w-2xl">
<form method="dialog"> <form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"></button> <button class="btn btn-sm btn-ghost absolute right-3 top-3"></button>
</form> </form>
<div class="flex items-baseline gap-2 mb-3 pr-8"> <div class="flex items-baseline gap-2 mb-3 pr-8">
<span id="detail-method" class="font-mono font-bold text-lg"></span> <span id="detail-method" class="font-mono font-bold text-lg"></span>
<span id="detail-path" class="font-mono text-base-content/60 text-sm break-all"></span> <span id="detail-path" class="font-mono text-base-content/60 text-sm break-all"></span>
</div> </div>
<p id="detail-desc" class="text-sm text-base-content/80 mb-4"></p> <p id="detail-desc" class="text-sm text-base-content/80 mb-4"></p>
<div id="detail-catalog-badge" class="mb-3 hidden">
<span class="text-xs text-base-content/40">Catalog: </span>
<span id="detail-catalog" class="badge badge-xs badge-ghost font-mono"></span>
</div>
<div id="detail-params" class="hidden mb-4"> <div id="detail-params" class="hidden mb-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Parameters</h3> <h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Parameters</h3>
<table class="table table-xs w-full"> <table class="table table-xs w-full">
@ -91,6 +82,11 @@
<tbody id="detail-params-body"></tbody> <tbody id="detail-params-body"></tbody>
</table> </table>
</div> </div>
<div id="detail-source" class="hidden mb-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-1">Source</h3>
<button id="detail-source-btn"
class="font-mono text-xs text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left break-all"></button>
</div>
<div class="flex gap-4 text-xs text-base-content/40 border-t border-base-content/10 pt-3"> <div class="flex gap-4 text-xs text-base-content/40 border-t border-base-content/10 pt-3">
<span>Feature: <code id="detail-feature" class="font-mono"></code></span> <span>Feature: <code id="detail-feature" class="font-mono"></code></span>
<span id="detail-auth-wrap">Auth: <span id="detail-auth"></span></span> <span id="detail-auth-wrap">Auth: <span id="detail-auth"></span></span>
@ -102,10 +98,36 @@
</dialog> </dialog>
<script> <script>
const ROUTES = {{ catalog_json | safe }}; const CATALOGS = {{ catalogs_json | safe }};
const MULTI = CATALOGS.length > 1;
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
function srcOpen(path) {
if (!path) return;
var ext = path.split('.').pop().toLowerCase();
if (ext === 'rs' && CARD_DOCS) {
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
return;
}
if (CARD_REPO) {
window.open(CARD_REPO.replace(/\/$/, '') + '/src/branch/main/' + path, '_blank', 'noopener');
return;
}
}
// Flat array: each route carries _catalog and _flatIdx for showDetail lookup.
const ALL_ROUTES = [];
CATALOGS.forEach(function(cat) {
(cat.routes || []).forEach(function(r) {
ALL_ROUTES.push(Object.assign({}, r, { _catalog: cat.name }));
});
});
let currentRoutes = ALL_ROUTES.slice();
function methodClass(m) { function methodClass(m) {
return `method-${m.toLowerCase()}`; return 'method-' + m.toLowerCase();
} }
function authBadge(auth) { function authBadge(auth) {
@ -113,79 +135,163 @@ function authBadge(auth) {
none: 'badge badge-ghost badge-xs font-mono', none: 'badge badge-ghost badge-xs font-mono',
viewer: 'badge badge-info badge-xs font-mono', viewer: 'badge badge-info badge-xs font-mono',
admin: 'badge badge-error badge-xs font-mono', admin: 'badge badge-error badge-xs font-mono',
}[auth] ?? 'badge badge-ghost badge-xs font-mono'; }[auth] || 'badge badge-ghost badge-xs font-mono';
return `<span class="${cls}">${auth}</span>`; return '<span class="' + cls + '">' + auth + '</span>';
} }
function actorBadges(actors) { function actorBadges(actors) {
if (!actors || actors.length === 0) return '<span class="text-base-content/30"></span>'; if (!actors || actors.length === 0) return '<span class="text-base-content/30"></span>';
return actors.map(a => `<span class="badge badge-xs badge-ghost font-mono">${a}</span>`).join(' '); return actors.map(function(a) {
return '<span class="badge badge-xs badge-ghost font-mono">' + a + '</span>';
}).join(' ');
} }
function tagBadges(tags) { function tagBadges(tags) {
if (!tags || tags.length === 0) return ''; if (!tags || tags.length === 0) return '';
return tags.map(t => `<span class="badge badge-xs badge-outline">${t}</span>`).join(' '); return tags.map(function(t) {
return '<span class="badge badge-xs badge-outline">' + t + '</span>';
}).join(' ');
} }
let visibleRoutes = ROUTES; function routeRow(r, idx) {
return '<tr class="hover cursor-pointer route-row" onclick="showDetail(' + idx + ')">' +
'<td class="font-mono font-bold ' + methodClass(r.method) + '">' + r.method + '</td>' +
'<td class="font-mono text-sm">' + r.path + '</td>' +
'<td class="text-sm text-base-content/70">' + r.description + '</td>' +
'<td>' + authBadge(r.auth) + '</td>' +
'<td class="flex flex-wrap gap-1">' + actorBadges(r.actors) + '</td>' +
'<td>' + tagBadges(r.tags) + '</td>' +
'</tr>';
}
function tableFor(routes, startIdx) {
if (routes.length === 0) return '';
return '<div class="overflow-x-auto">' +
'<table class="table table-sm w-full bg-base-200 rounded-lg">' +
'<thead><tr class="text-base-content/50 text-xs uppercase tracking-wider">' +
'<th class="w-16">Method</th><th>Path</th><th>Description</th>' +
'<th class="w-20">Auth</th><th>Actors</th><th>Tags</th>' +
'</tr></thead>' +
'<tbody>' +
routes.map(function(r, i) { return routeRow(r, startIdx + i); }).join('') +
'</tbody></table></div>';
}
function renderRoutes(routes) { function renderRoutes(routes) {
visibleRoutes = routes; currentRoutes = routes;
const tbody = document.getElementById('routes-body'); const container = document.getElementById('catalogs-container');
tbody.innerHTML = routes.map((r, i) => ` const filterCount = document.getElementById('filter-count');
<tr class="hover cursor-pointer route-row" onclick="showDetail(${i})"> const isFiltered = routes.length < ALL_ROUTES.length;
<td class="font-mono font-bold ${methodClass(r.method)}">${r.method}</td>
<td class="font-mono text-sm">${r.path}</td> if (isFiltered) {
<td class="text-sm text-base-content/70">${r.description}</td> filterCount.textContent = routes.length + ' match' + (routes.length !== 1 ? 'es' : '');
<td>${authBadge(r.auth)}</td> filterCount.classList.remove('hidden');
<td class="flex flex-wrap gap-1">${actorBadges(r.actors)}</td> } else {
<td>${tagBadges(r.tags)}</td> filterCount.classList.add('hidden');
</tr> }
`).join('');
if (!MULTI) {
// Single catalog: plain table, no accordion.
container.innerHTML = tableFor(routes, 0);
return;
}
// Group by catalog, preserving flat index for showDetail.
var groups = {};
var order = [];
routes.forEach(function(r, i) {
if (!groups[r._catalog]) { groups[r._catalog] = []; order.push(r._catalog); }
groups[r._catalog].push({ route: r, idx: i });
});
container.innerHTML = order.map(function(name) {
var entries = groups[name];
var rows = entries.map(function(e) { return routeRow(e.route, e.idx); }).join('');
var count = entries.length;
return '<details class="collapse collapse-arrow bg-base-200 mb-2" open>' +
'<summary class="collapse-title font-mono font-semibold text-sm min-h-0 py-3">' +
name +
' <span class="badge badge-xs badge-ghost ml-2">' + count + '</span>' +
'</summary>' +
'<div class="collapse-content px-0">' +
'<div class="overflow-x-auto">' +
'<table class="table table-sm w-full">' +
'<thead><tr class="text-base-content/50 text-xs uppercase tracking-wider">' +
'<th class="w-16">Method</th><th>Path</th><th>Description</th>' +
'<th class="w-20">Auth</th><th>Actors</th><th>Tags</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table></div>' +
'</div>' +
'</details>';
}).join('');
} }
function showDetail(index) { function showDetail(idx) {
const r = visibleRoutes[index]; var r = currentRoutes[idx];
if (!r) return; if (!r) return;
document.getElementById('detail-method').textContent = r.method; document.getElementById('detail-method').textContent = r.method;
document.getElementById('detail-method').className = `font-mono font-bold text-lg ${methodClass(r.method)}`; document.getElementById('detail-method').className = 'font-mono font-bold text-lg ' + methodClass(r.method);
document.getElementById('detail-path').textContent = r.path; document.getElementById('detail-path').textContent = r.path;
document.getElementById('detail-desc').textContent = r.description; document.getElementById('detail-desc').textContent = r.description;
document.getElementById('detail-feature').textContent = r.feature || 'default'; document.getElementById('detail-feature').textContent = r.feature || 'default';
document.getElementById('detail-auth').innerHTML = authBadge(r.auth); document.getElementById('detail-auth').innerHTML = authBadge(r.auth);
const paramsDiv = document.getElementById('detail-params'); var catBadgeWrap = document.getElementById('detail-catalog-badge');
const tbody = document.getElementById('detail-params-body'); if (MULTI) {
document.getElementById('detail-catalog').textContent = r._catalog;
catBadgeWrap.classList.remove('hidden');
} else {
catBadgeWrap.classList.add('hidden');
}
var paramsDiv = document.getElementById('detail-params');
var tbody = document.getElementById('detail-params-body');
if (r.params && r.params.length > 0) { if (r.params && r.params.length > 0) {
tbody.innerHTML = r.params.map(p => ` tbody.innerHTML = r.params.map(function(p) {
<tr> return '<tr>' +
<td class="font-mono text-xs">${p.name}</td> '<td class="font-mono text-xs">' + p.name + '</td>' +
<td class="text-xs text-base-content/60">${p.type || ''}</td> '<td class="text-xs text-base-content/60">' + (p.type || '') + '</td>' +
<td class="text-xs text-base-content/50">${p.constraint || ''}</td> '<td class="text-xs text-base-content/50">' + (p.constraint || '') + '</td>' +
<td class="text-xs">${p.description || ''}</td> '<td class="text-xs">' + (p.description || '') + '</td>' +
</tr> '</tr>';
`).join(''); }).join('');
paramsDiv.classList.remove('hidden'); paramsDiv.classList.remove('hidden');
} else { } else {
paramsDiv.classList.add('hidden'); paramsDiv.classList.add('hidden');
} }
var sourceDiv = document.getElementById('detail-source');
var sourceBtn = document.getElementById('detail-source-btn');
if (r.source_file) {
sourceBtn.textContent = r.source_file;
sourceBtn.dataset.path = r.source_file;
sourceDiv.classList.remove('hidden');
} else {
sourceDiv.classList.add('hidden');
}
document.getElementById('route-modal').showModal(); document.getElementById('route-modal').showModal();
} }
document.addEventListener('click', function(e) {
var btn = e.target.closest('#detail-source-btn');
if (btn && btn.dataset.path) { srcOpen(btn.dataset.path); }
});
function filterRoutes() { function filterRoutes() {
const text = document.getElementById('filter-input').value.toLowerCase(); var text = document.getElementById('filter-input').value.toLowerCase();
const auth = document.getElementById('filter-auth').value; var auth = document.getElementById('filter-auth').value;
const method = document.getElementById('filter-method').value; var method = document.getElementById('filter-method').value;
const filtered = ROUTES.filter(r => { var filtered = ALL_ROUTES.filter(function(r) {
const textMatch = !text || r.path.toLowerCase().includes(text) || r.description.toLowerCase().includes(text); var textMatch = !text || r.path.toLowerCase().includes(text) || r.description.toLowerCase().includes(text);
const authMatch = !auth || r.auth === auth; var authMatch = !auth || r.auth === auth;
const methodMatch = !method || r.method === method; var methodMatch = !method || r.method === method;
return textMatch && authMatch && methodMatch; return textMatch && authMatch && methodMatch;
}); });
renderRoutes(filtered); renderRoutes(filtered);
} }
renderRoutes(ROUTES); renderRoutes(ALL_ROUTES);
</script> </script>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@ -54,7 +54,8 @@
</div> </div>
</div> </div>
<!-- Items table --> <!-- Items table — always rendered so HTMX always has a swap target -->
<div id="backlog-items-container">
{% if items %} {% if items %}
<div class="overflow-x-auto rounded-lg border border-base-content/10"> <div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full"> <table class="table table-sm w-full">
@ -68,83 +69,7 @@
<th class="w-24 text-right">Actions</th> <th class="w-24 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="backlog-tbody"> {% include "partials/backlog_tbody.html" %}
{% for it in items %}
<tr class="backlog-row hover:bg-base-200/50"
data-status="{{ it.status }}"
data-priority="{{ it.priority }}">
<td class="font-mono text-xs text-base-content/50">{{ it.id }}</td>
<td>
<span class="badge badge-xs
{% if it.status == "Open" %}badge-info
{% elif it.status == "InProgress" %}badge-warning
{% elif it.status == "Done" %}badge-success
{% else %}badge-ghost{% endif %}">{{ it.status }}</span>
</td>
<td>
<span class="badge badge-xs
{% if it.priority == "Critical" %}badge-error
{% elif it.priority == "High" %}badge-warning
{% else %}badge-ghost{% endif %}">{{ it.priority }}</span>
</td>
<td>
<span class="badge badge-xs badge-ghost">{{ it.kind }}</span>
</td>
<td>
<div class="font-medium text-sm leading-tight">{{ it.title }}</div>
{% if it.detail %}
<div class="text-xs text-base-content/50 leading-tight mt-0.5 line-clamp-1">{{ it.detail }}</div>
{% endif %}
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-xs btn-ghost"></button>
<ul tabindex="0"
class="dropdown-content menu menu-xs bg-base-200 shadow rounded-box z-50 w-40 p-1">
{% if it.status != "InProgress" %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="InProgress">
<button type="submit" class="w-full text-left">→ In Progress</button>
</form>
</li>
{% endif %}
{% if it.status != "Done" %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Done">
<button type="submit" class="w-full text-left">✓ Done</button>
</form>
</li>
{% endif %}
{% if it.status != "Open" %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Open">
<button type="submit" class="w-full text-left">↩ Reopen</button>
</form>
</li>
{% endif %}
<li>
<form method="post" action="{{ base_url }}/backlog/status">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Cancelled">
<button type="submit" class="w-full text-left text-error">✕ Cancel</button>
</form>
</li>
<li class="border-t border-base-content/10 mt-1 pt-1">
<a href="{{ base_url }}/notifications?kind=backlog_delegation&title={{ it.id | urlencode }}%3A%20{{ it.title | urlencode }}&payload=%7B%22item_id%22%3A%22{{ it.id | urlencode }}%22%2C%22status%22%3A%22{{ it.status | urlencode }}%22%2C%22priority%22%3A%22{{ it.priority | urlencode }}%22%7D"
class="w-full text-left">↗ Send to project</a>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
@ -152,6 +77,7 @@
No backlog items yet. No backlog items yet.
</div> </div>
{% endif %} {% endif %}
</div>
{% endif %} {% endif %}
@ -180,7 +106,11 @@
<dialog id="add-modal" class="modal"> <dialog id="add-modal" class="modal">
<div class="modal-box max-w-lg"> <div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-4">New Backlog Item</h3> <h3 class="font-bold text-base mb-4">New Backlog Item</h3>
<form method="post" action="{{ base_url }}/backlog/add" class="space-y-3"> <form hx-post="{{ base_url }}/backlog/add"
hx-target="#backlog-items-container"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){document.getElementById('add-modal').close();this.reset()}"
class="space-y-3">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Title</span></label> <label class="label py-1"><span class="label-text text-sm">Title</span></label>
<input type="text" name="title" required placeholder="Short description of the item" <input type="text" name="title" required placeholder="Short description of the item"
@ -222,10 +152,171 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form> <form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog> </dialog>
<!-- Edit item modal -->
<dialog id="edit-modal" class="modal">
<div class="modal-box max-w-xl">
<h3 class="font-bold text-base mb-4">Edit Backlog Item <span id="edit-modal-id" class="font-mono text-xs text-base-content/40"></span></h3>
<form hx-post="{{ base_url }}/backlog/edit"
hx-target="#backlog-items-container"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful){document.getElementById('edit-modal').close()}"
class="space-y-3">
<input type="hidden" name="id" id="edit-id">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Title</span></label>
<input type="text" name="title" id="edit-title" required
class="input input-bordered input-sm w-full">
</div>
<div class="grid grid-cols-3 gap-3">
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Status</span></label>
<select name="status" id="edit-status" class="select select-bordered select-sm">
<option value="Open">Open</option>
<option value="InProgress">In Progress</option>
<option value="Done">Done</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Kind</span></label>
<select name="kind" id="edit-kind" class="select select-bordered select-sm">
<option value="Todo">Todo</option>
<option value="Wish">Wish</option>
<option value="Idea">Idea</option>
<option value="Bug">Bug</option>
<option value="Debt">Debt</option>
</select>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Priority</span></label>
<select name="priority" id="edit-priority" class="select select-bordered select-sm">
<option value="Medium">Medium</option>
<option value="High">High</option>
<option value="Critical">Critical</option>
<option value="Low">Low</option>
</select>
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Detail</span></label>
<textarea name="detail" id="edit-detail" rows="3"
class="textarea textarea-bordered textarea-sm w-full"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-1">
<span class="label-text text-sm">Related ADRs</span>
<span class="label-text-alt text-base-content/40">comma-separated</span>
</label>
<input type="text" name="related_adrs" id="edit-related-adrs"
placeholder="adr-001-title, adr-002-title"
class="input input-bordered input-sm w-full font-mono text-xs">
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-sm">Related Modes</span>
<span class="label-text-alt text-base-content/40">comma-separated</span>
</label>
<input type="text" name="related_modes" id="edit-related-modes"
placeholder="develop, coder-workflow"
class="input input-bordered input-sm w-full font-mono text-xs">
</div>
</div>
<div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Graduates to</span></label>
<select name="graduates_to" id="edit-graduates-to" class="select select-bordered select-sm">
<option value="">— unchanged —</option>
<option value="Adr">ADR</option>
<option value="Mode">Mode</option>
<option value="StateTransition">State Transition</option>
<option value="PrItem">PR Item</option>
</select>
</div>
<div class="modal-action mt-2 flex justify-between">
<div class="flex gap-2">
<span id="edit-delete-confirm" class="hidden items-center gap-2">
<span class="text-xs text-error font-medium">Delete this item?</span>
<button type="button" class="btn btn-xs btn-error" onclick="editModalDeleteConfirmed()">Yes, delete</button>
<button type="button" class="btn btn-xs btn-ghost" onclick="editDeleteCancel()">Cancel</button>
</span>
<button type="button" id="edit-delete-btn"
class="btn btn-sm btn-error btn-outline"
onclick="editDeleteAsk()">✕ Delete</button>
<button type="button"
class="btn btn-sm btn-ghost"
onclick="editModalSendToProject()">↗ Send to project</button>
</div>
<div class="flex gap-2">
<button type="button" onclick="document.getElementById('edit-modal').close()"
class="btn btn-sm btn-ghost">Cancel</button>
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
<script> <script>
// ── Edit modal ────────────────────────────────────────────────────────────────
function openEditModal(row) {
document.getElementById('edit-id').value = row.dataset.id;
document.getElementById('edit-modal-id').textContent = row.dataset.id;
document.getElementById('edit-title').value = row.dataset.title;
document.getElementById('edit-detail').value = row.dataset.detail;
document.getElementById('edit-related-adrs').value = row.dataset.relatedAdrs || '';
document.getElementById('edit-related-modes').value = row.dataset.relatedModes || '';
function sel(id, val) {
var el = document.getElementById(id);
for (var o of el.options) o.selected = (o.value === val);
}
sel('edit-kind', row.dataset.kind);
sel('edit-priority', row.dataset.priorityVal);
sel('edit-status', row.dataset.statusVal);
sel('edit-graduates-to', row.dataset.graduatesTo || '');
editDeleteCancel();
document.getElementById('edit-modal').showModal();
}
function editDeleteAsk() {
document.getElementById('edit-delete-btn').classList.add('hidden');
var c = document.getElementById('edit-delete-confirm');
c.classList.remove('hidden');
c.classList.add('flex');
}
function editDeleteCancel() {
document.getElementById('edit-delete-confirm').classList.add('hidden');
document.getElementById('edit-delete-confirm').classList.remove('flex');
document.getElementById('edit-delete-btn').classList.remove('hidden');
}
function editModalDeleteConfirmed() {
var id = document.getElementById('edit-id').value;
document.getElementById('edit-modal').close();
editDeleteCancel();
htmx.ajax('POST', '{{ base_url | safe }}/backlog/delete', {
target: '#backlog-items-container',
swap: 'innerHTML',
values: { id: id }
});
}
function editModalSendToProject() {
var id = document.getElementById('edit-id').value;
var title = document.getElementById('edit-title').value;
var status = document.getElementById('edit-status').value;
var priority = document.getElementById('edit-priority').value;
var payload = JSON.stringify({ item_id: id, status: status, priority: priority });
var base = '{{ base_url | safe }}';
var url = base + '/notifications?kind=backlog_delegation'
+ '&title=' + encodeURIComponent(id + ': ' + title)
+ '&payload=' + encodeURIComponent(payload);
document.getElementById('edit-modal').close();
window.location.href = url;
}
// ── Filter logic ────────────────────────────────────────────────────────────── // ── Filter logic ──────────────────────────────────────────────────────────────
let activeStatus = 'all'; let activeStatus = 'all';
let activePriority = 'all'; let activePriority = 'all';

View File

@ -71,6 +71,25 @@
{% endif %} {% endif %}
</div> </div>
{% if pending_migrations %}
<details class="collapse collapse-arrow bg-warning/10 border border-warning/30 rounded-lg mt-3 max-w-2xl">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer text-warning">
○ {{ pending_migrations | length }} pending migration{% if pending_migrations | length > 1 %}s{% endif %}
</summary>
<div class="collapse-content px-3 pb-3">
<div class="space-y-1">
{% for m in pending_migrations %}
<div class="flex gap-2 items-start">
<span class="badge badge-xs badge-warning font-mono flex-shrink-0 mt-0.5">{{ m.id }}</span>
<span class="text-xs text-base-content/70">{{ m.description }}</span>
</div>
{% endfor %}
</div>
<p class="text-xs text-base-content/40 mt-2">Run <code class="font-mono">ontoref migrate show &lt;id&gt;</code> for instructions.</p>
</div>
</details>
{% endif %}
{% if layers or op_modes %} {% if layers or op_modes %}
<details class="collapse collapse-arrow bg-base-200 rounded-lg mt-3 max-w-2xl"> <details class="collapse collapse-arrow bg-base-200 rounded-lg mt-3 max-w-2xl">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer"> <summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">
@ -104,14 +123,8 @@
{% endif %} {% endif %}
</div> </div>
<!-- Daemon stats --> <!-- Daemon stats — polled every 10s via HTMX -->
<div class="stats stats-horizontal shadow w-full mb-4 bg-base-200 overflow-x-auto"> {% include "partials/dashboard_stats.html" %}
{{ m::stat(title="Uptime", value=uptime_secs ~ "s", desc="seconds since start") }}
{{ m::stat(title="Cache entries", value=cache_entries) }}
{{ m::stat(title="Cache hit rate", value=cache_hit_rate, desc=cache_hits ~ " hits / " ~ cache_misses ~ " misses", accent="success") }}
{{ m::stat(title="Sessions", value=active_actors, accent="primary") }}
{{ m::stat(title="Notifications", value=notification_count, accent="warning") }}
</div>
<!-- Project status --> <!-- Project status -->
{% if backlog %} {% if backlog %}

View File

@ -3,11 +3,26 @@
{% block title %}Ontology Graph — Ontoref{% endblock title %} {% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %} {% block nav_graph %}active{% endblock nav_graph %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %} {% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% block main_class %}container mx-auto px-4 py-6{% endblock main_class %}
{% block head %} {% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="/assets/vendor/cytoscape-navigator.js"></script> <script src="/assets/vendor/cytoscape-navigator.js"></script>
<style> <style>
#graph-fullscreen-wrapper:fullscreen,
#graph-fullscreen-wrapper:-webkit-full-screen {
display: flex;
flex-direction: column;
background: oklch(var(--b1));
padding: 12px 16px;
box-sizing: border-box;
}
#graph-fullscreen-wrapper:fullscreen #graph-root,
#graph-fullscreen-wrapper:-webkit-full-screen #graph-root {
flex: 1 1 auto;
height: 0; /* flex child — grows to fill remaining space */
}
#graph-root { #graph-root {
display: flex; display: flex;
height: calc(100vh - 148px); height: calc(100vh - 148px);
@ -15,11 +30,6 @@
gap: 0; gap: 0;
user-select: none; user-select: none;
} }
#graph-root:fullscreen,
#graph-root:-webkit-full-screen {
height: 100dvh;
background: oklch(var(--b1));
}
#cy-wrapper { #cy-wrapper {
flex: 1 1 auto; flex: 1 1 auto;
@ -172,6 +182,57 @@
<div class="divider my-0"></div> <div class="divider my-0"></div>
<!-- Extended levels -->
<section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Extended levels — code &amp; system layers</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#6366f1;border-color:#6366f1;color:#fff;min-width:80px">⬢ Crates</span>
<div>
<p class="font-medium leading-snug">Workspace crate</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A Rust crate declared in the workspace <code>Cargo.toml</code>. Edges show intra-workspace <code>DependsOn</code> relationships — external crates are omitted.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#6366f1;border-color:#6366f1;color:#fff;min-width:80px;opacity:0.8">⬡ API Crate</span>
<div>
<p class="font-medium leading-snug">API surface crate</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A crate that has exported <code>#[onto_api]</code> routes. Generated from <code>artifacts/api-catalog-*.ncl</code>.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#10b981;border-color:#10b981;color:#fff;min-width:80px">⊢ Routes</span>
<div>
<p class="font-medium leading-snug">HTTP route</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">An individual HTTP endpoint annotated with <code>#[onto_api]</code>. Each route is owned by exactly one API Crate node via a <code>Contains</code> edge.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;border-color:#ec4899;color:#fff;min-width:80px">⬡ State</span>
<div>
<p class="font-medium leading-snug">FSM dimension</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A lifecycle dimension from <code>.ontology/state.ncl</code>. Shows <code>current_state</code><code>desired_state</code>. <code>CoupledWith</code> edges link co-dependent dimensions. Invariant flag is set when current equals desired.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#14b8a6;border-color:#14b8a6;color:#fff;min-width:80px">⊓ Config</span>
<div>
<p class="font-medium leading-snug">Config section</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A named section in <code>.ontoref/config.ncl</code> as declared in <code>manifest.config_surface.sections</code>. Includes contract reference when a Nickel type contract is attached.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f97316;border-color:#f97316;color:#fff;min-width:80px">◈ Deps</span>
<div>
<p class="font-medium leading-snug">External requirement</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">A tool, service, or infrastructure dependency from <code>manifest.requirements</code>. Invariant nodes are <em>required</em> (missing them breaks the project). Optional nodes degrade gracefully.</p>
</div>
</div>
</div>
</section>
<div class="divider my-0"></div>
<!-- Poles --> <!-- Poles -->
<section> <section>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Poles — which force drives the node</p> <p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-3">Poles — which force drives the node</p>
@ -197,6 +258,27 @@
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that hold both sides simultaneously and move through the tension. Spiral principles cannot be resolved into Yang or Yin — they are the engine of change itself.</p> <p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that hold both sides simultaneously and move through the tension. Spiral principles cannot be resolved into Yang or Yin — they are the engine of change itself.</p>
</div> </div>
</div> </div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#ec4899;color:#fff;border-color:#ec4899;min-width:64px">State</span>
<div>
<p class="font-medium leading-snug">Lifecycle · FSM · temporal</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent the project's operational state model — FSM dimensions tracking where the project currently is and where it intends to go.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#14b8a6;color:#fff;border-color:#14b8a6;min-width:64px">Config</span>
<div>
<p class="font-medium leading-snug">Configuration · settings · tuneable</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that describe the project's runtime configuration surface — sections that can be overridden without recompiling, validated by Nickel contracts at load time.</p>
</div>
</div>
<div class="flex items-start gap-3">
<span class="btn btn-xs flex-shrink-0 pointer-events-none select-none" style="background:#f97316;color:#fff;border-color:#f97316;min-width:64px">Env</span>
<div>
<p class="font-medium leading-snug">Environment · external · required tooling</p>
<p class="text-base-content/60 leading-relaxed mt-0.5">Nodes that represent external dependencies the project cannot provide for itself — tools, services, and infrastructure that must be present in the environment.</p>
</div>
</div>
</div> </div>
</section> </section>
@ -267,44 +349,77 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form> <form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog> </dialog>
<!-- ADR modal -->
<dialog id="adr-modal" class="modal">
<div class="modal-box w-11/12 max-w-2xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg" id="adr-modal-title">ADR</h3>
<form method="dialog"><button class="btn btn-sm btn-circle btn-ghost"></button></form>
</div>
<div id="adr-modal-body" class="text-sm space-y-3 overflow-y-auto max-h-[60vh]">
<span class="loading loading-spinner loading-sm"></span>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Toolbar --> <div id="graph-fullscreen-wrapper">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm"> <!-- Toolbar row 1: title + inline node search + layout controls -->
<h1 class="text-xl font-bold">Ontology Graph</h1> <div class="flex items-center justify-between mb-1 gap-2">
<div class="flex flex-wrap gap-1 items-center"> <h1 class="text-lg font-bold shrink-0">Ontology Graph</h1>
<!-- Level filters (toggle = hide that level) --> <div class="relative flex-1 max-w-xs min-w-0" id="search-wrap">
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button> <input id="graph-search" type="search" placeholder="Search nodes…"
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button> class="input input-bordered input-xs w-full pr-14 font-mono text-xs"
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button> autocomplete="off" autocorrect="off" spellcheck="false">
<div class="w-px h-4 bg-base-content/20 mx-1"></div> <span class="absolute right-2 top-1.5 text-xs text-base-content/30 pointer-events-none select-none"
<!-- Pole filters --> id="search-count"></span>
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button> <ul id="search-dropdown"
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button> class="hidden absolute left-0 top-full mt-1 w-full bg-base-100 border border-base-content/15 rounded-lg shadow-xl z-50 max-h-56 overflow-y-auto text-xs">
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button> </ul>
<div class="w-px h-4 bg-base-content/20 mx-1"></div> </div>
<!-- Layout --> <div class="flex items-center gap-1 shrink-0">
<div class="join"> <div class="join">
<button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button> <button id="btn-bfs" class="join-item btn btn-xs btn-primary">Hierarchy</button>
<button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button> <button id="btn-cose" class="join-item btn btn-xs btn-ghost">Force</button>
</div> </div>
<button id="btn-reset" class="btn btn-xs btn-ghost">Reset</button> <button id="btn-reset" class="btn btn-xs btn-ghost">Reset</button>
<button id="btn-legend" class="btn btn-xs btn-ghost" title="Graph legend">?</button> <button id="btn-legend" class="btn btn-xs btn-ghost" title="Graph legend">?</button>
</div> </div>
</div> </div>
<!-- Toolbar row 2: filters, grouped by category -->
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 mb-2 text-xs">
<!-- Ontology core -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Ontology</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs btn-warning" data-level="Axiom">◆ Axiom</button>
<button class="filter-btn btn btn-xs btn-error" data-level="Tension">● Tension</button>
<button class="filter-btn btn btn-xs btn-success" data-level="Practice">▪ Practice</button>
</div>
<div class="w-px h-4 bg-base-content/15 self-center"></div>
<!-- Code surface -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Code</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs" data-level="WorkspaceCrate" style="background:#6366f1;border-color:#6366f1;color:#fff">⬢ Crates</button>
<button class="filter-btn btn btn-xs" data-level="Crate" style="background:#6366f1;border-color:#6366f1;color:#fff;opacity:0.8">⬡ API Crate</button>
<button class="filter-btn btn btn-xs" data-level="Route" style="background:#10b981;border-color:#10b981;color:#fff">⊢ Routes</button>
</div>
<div class="w-px h-4 bg-base-content/15 self-center"></div>
<!-- System -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">System</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs" data-level="Dimension" style="background:#ec4899;border-color:#ec4899;color:#fff">⬡ State</button>
<button class="filter-btn btn btn-xs" data-level="ConfigSection" style="background:#14b8a6;border-color:#14b8a6;color:#fff">⊓ Config</button>
<button class="filter-btn btn btn-xs" data-level="Requirement" style="background:#f97316;border-color:#f97316;color:#fff">◈ Deps</button>
</div>
<div class="w-px h-4 bg-base-content/15 self-center"></div>
<!-- Poles -->
<span class="text-base-content/40 font-medium uppercase tracking-wide text-[10px]">Pole</span>
<div class="flex gap-1">
<button class="filter-btn btn btn-xs" style="background:#f59e0b;color:#111;border-color:#f59e0b" data-pole="Yang">Yang</button>
<button class="filter-btn btn btn-xs" style="background:#3b82f6;color:#fff;border-color:#3b82f6" data-pole="Yin">Yin</button>
<button class="filter-btn btn btn-xs" style="background:#8b5cf6;color:#fff;border-color:#8b5cf6" data-pole="Spiral">Spiral</button>
<button class="filter-btn btn btn-xs" style="background:#ec4899;color:#fff;border-color:#ec4899" data-pole="State">State</button>
<button class="filter-btn btn btn-xs" style="background:#14b8a6;color:#fff;border-color:#14b8a6" data-pole="Config">Config</button>
<button class="filter-btn btn btn-xs" style="background:#f97316;color:#fff;border-color:#f97316" data-pole="Env">Env</button>
</div>
</div>
<!-- Split: graph | drag handle | detail --> <!-- Split: graph | drag handle | detail -->
<div id="graph-root"> <div id="graph-root">
<div id="cy-wrapper" class="bg-base-200"> <div id="cy-wrapper" class="bg-base-200">
@ -326,30 +441,66 @@
<div id="resize-handle"></div> <div id="resize-handle"></div>
<div id="detail-panel" class="bg-base-200 p-4 hidden"> <div id="detail-panel" class="bg-base-200 p-4 hidden">
<div class="flex justify-between items-start mb-2"> <!-- Panel nav header -->
<h3 class="font-bold text-base leading-tight" id="d-name"></h3> <div class="flex items-center gap-0.5 mb-2">
<button id="btn-close-panel" class="btn btn-xs btn-ghost ml-2 flex-shrink-0"></button> <button id="nav-back" class="btn btn-xs btn-ghost px-1.5" title="Back" disabled></button>
<button id="nav-forward" class="btn btn-xs btn-ghost px-1.5" title="Forward" disabled></button>
<h3 class="font-bold text-base leading-tight flex-1 truncate mx-1" id="d-name"></h3>
<button id="btn-close-panel" class="btn btn-xs btn-ghost flex-shrink-0"></button>
</div> </div>
<div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
<p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p> <!-- Node detail view -->
<div id="d-artifacts" class="hidden mb-3"> <div id="d-node-view">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Artifacts</p> <div class="flex flex-wrap gap-1 mb-2" id="d-badges"></div>
<ul id="d-artifact-list" class="text-xs font-mono text-base-content/60 space-y-1 break-all"></ul> <p class="text-xs text-base-content/70 mb-3 leading-relaxed" id="d-description"></p>
<div id="d-artifacts" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Artifacts</p>
<ul id="d-artifact-list" class="text-xs font-mono text-base-content/60 space-y-1 break-all"></ul>
</div>
<div id="d-adrs" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Validated by</p>
<ul id="d-adr-list" class="text-xs font-mono space-y-1"></ul>
</div>
<div id="d-edges" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
</div>
<div id="d-extra" class="hidden space-y-3"></div>
</div> </div>
<div id="d-adrs" class="hidden mb-3">
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Validated by</p> <!-- Content view: file or ADR rendered in-panel -->
<ul id="d-adr-list" class="text-xs font-mono space-y-1"></ul> <div id="d-content-view" class="hidden">
</div> <p class="text-xs text-base-content/40 mb-3 font-mono break-all leading-relaxed" id="d-content-subtitle"></p>
<div id="d-edges" class="hidden"> <div id="d-content-body" class="text-sm space-y-3"></div>
<p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-1">Connections</p>
<ul id="d-edge-list" class="text-xs text-base-content/60 space-y-1"></ul>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
<script> <script>
// ── Source link helper ────────────────────────────────────────────────────────
// Opens file paths in the configured repo (Gitea/GitHub /src/branch/main/{path}).
// Falls back to cargo-doc root for .rs files when docs URL is set.
// option 1 (load file) is intentionally not used.
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
function srcOpen(path) {
if (!path) return;
const ext = path.split('.').pop();
if (ext === 'rs' && CARD_DOCS) {
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
return;
}
if (CARD_REPO) {
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
return;
}
// no URL configured — nothing to open
}
const GRAPH = {{ graph_json | safe }}; const GRAPH = {{ graph_json | safe }};
// ── Icons ───────────────────────────────────────────────────── // ── Icons ─────────────────────────────────────────────────────
@ -361,13 +512,19 @@ document.getElementById('cy-fit').innerHTML = SVG_FIT;
document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER; document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER;
// ── Graph data ──────────────────────────────────────────────── // ── Graph data ────────────────────────────────────────────────
const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6" }; const POLE_COLOR = { Yang: "#f59e0b", Yin: "#3b82f6", Spiral: "#8b5cf6", Api: "#10b981", Code: "#6366f1", State: "#ec4899", Config: "#14b8a6", Env: "#f97316" };
const LEVEL_SHAPE = { const LEVEL_SHAPE = {
Axiom: "diamond", Axiom: "diamond",
Tension: "ellipse", Tension: "ellipse",
Practice: "round-rectangle", Practice: "round-rectangle",
Project: "hexagon", Project: "hexagon",
Moment: "triangle", Moment: "triangle",
Crate: "round-hexagon",
Route: "tag",
Dimension: "hexagon",
WorkspaceCrate: "barrel",
ConfigSection: "cut-rectangle",
Requirement: "round-tag",
}; };
const EDGE_STYLE = { const EDGE_STYLE = {
ManifestsIn: { color: "#6b7280" }, ManifestsIn: { color: "#6b7280" },
@ -382,6 +539,7 @@ const EDGE_STYLE = {
SpiralsWith: { color: "#8b5cf6" }, SpiralsWith: { color: "#8b5cf6" },
LimitedBy: { color: "#f43f5e" }, LimitedBy: { color: "#f43f5e" },
ValidatedBy: { color: "#84cc16" }, ValidatedBy: { color: "#84cc16" },
CoupledWith: { color: "#ec4899", dashed: true },
}; };
const nodes = (GRAPH.nodes || []).map(n => ({ const nodes = (GRAPH.nodes || []).map(n => ({
@ -396,6 +554,26 @@ const nodes = (GRAPH.nodes || []).map(n => ({
adrs: n.adrs || [], adrs: n.adrs || [],
color: POLE_COLOR[n.pole] || "#6b7280", color: POLE_COLOR[n.pole] || "#6b7280",
shape: LEVEL_SHAPE[n.level] || "ellipse", shape: LEVEL_SHAPE[n.level] || "ellipse",
// Route
method: n.method || "",
auth: n.auth || "",
actors: n.actors || [],
tags: n.tags || [],
params: n.params || [],
feature: n.feature || "",
// Dimension (FSM)
current_state: n.current_state || "",
desired_state: n.desired_state || "",
horizon: n.horizon || "",
// WorkspaceCrate
features: n.features || [],
ws_deps: n.ws_deps || [],
// ConfigSection
contract: n.contract || "",
// Requirement
env_kind: n.kind || "",
env_target: n.env || "",
required: !!n.required,
} }
})); }));
@ -491,6 +669,14 @@ const cy = cytoscape({
"text-background-shape": "round-rectangle", "text-background-shape": "round-rectangle",
} }
}, },
{
selector: "node.search-dim",
style: { "opacity": 0.12 }
},
{
selector: "node.search-match",
style: { "border-color": "#ffffff", "border-width": 3, "opacity": 1 }
},
], ],
layout: buildBfsLayout(false), layout: buildBfsLayout(false),
wheelSensitivity: 0.3, wheelSensitivity: 0.3,
@ -592,8 +778,8 @@ document.getElementById('cy-fit').addEventListener('click', () => {
}); });
// ── Full-screen ─────────────────────────────────────────────── // ── Full-screen ───────────────────────────────────────────────
const graphRoot = document.getElementById('graph-root'); const graphFsWrapper = document.getElementById('graph-fullscreen-wrapper');
const btnFullscreen = document.getElementById('cy-fullscreen'); const btnFullscreen = document.getElementById('cy-fullscreen');
function isFullscreen() { function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement); return !!(document.fullscreenElement || document.webkitFullscreenElement);
@ -601,8 +787,8 @@ function isFullscreen() {
btnFullscreen.addEventListener('click', () => { btnFullscreen.addEventListener('click', () => {
if (!isFullscreen()) { if (!isFullscreen()) {
const req = graphRoot.requestFullscreen || graphRoot.webkitRequestFullscreen; const req = graphFsWrapper.requestFullscreen || graphFsWrapper.webkitRequestFullscreen;
if (req) req.call(graphRoot); if (req) req.call(graphFsWrapper);
} else { } else {
const exit = document.exitFullscreen || document.webkitExitFullscreen; const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document); if (exit) exit.call(document);
@ -620,8 +806,15 @@ document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange); document.addEventListener('webkitfullscreenchange', onFullscreenChange);
// ── Filters ─────────────────────────────────────────────────── // ── Filters ───────────────────────────────────────────────────
const hiddenLevels = new Set(); const hiddenLevels = new Set(["Crate", "Route", "Dimension", "WorkspaceCrate", "ConfigSection", "Requirement"]);
const hiddenPoles = new Set(); const hiddenPoles = new Set();
// Reflect initial hidden state on buttons.
document.querySelectorAll(".filter-btn[data-level]").forEach(btn => {
if (hiddenLevels.has(btn.dataset.level)) {
btn.style.opacity = "0.4";
btn.style.textDecoration = "line-through";
}
});
function applyFilters() { function applyFilters() {
cy.nodes().forEach(n => { cy.nodes().forEach(n => {
@ -695,28 +888,94 @@ document.getElementById("btn-reset").addEventListener("click", () => {
closePanel(); closePanel();
}); });
// ── Node detail panel ───────────────────────────────────────── // ── Panel refs ────────────────────────────────────────────────
const panel = document.getElementById("detail-panel"); const panel = document.getElementById("detail-panel");
const dName = document.getElementById("d-name"); const dName = document.getElementById("d-name");
const dBadges = document.getElementById("d-badges"); const dBadges = document.getElementById("d-badges");
const dDesc = document.getElementById("d-description"); const dDesc = document.getElementById("d-description");
const dArtifacts = document.getElementById("d-artifacts"); const dArtifacts = document.getElementById("d-artifacts");
const dList = document.getElementById("d-artifact-list"); const dList = document.getElementById("d-artifact-list");
const dAdrs = document.getElementById("d-adrs"); const dAdrs = document.getElementById("d-adrs");
const dAdrList = document.getElementById("d-adr-list"); const dAdrList = document.getElementById("d-adr-list");
const dEdges = document.getElementById("d-edges"); const dEdges = document.getElementById("d-edges");
const dEdgeList = document.getElementById("d-edge-list"); const dEdgeList = document.getElementById("d-edge-list");
const dExtra = document.getElementById("d-extra");
const dNodeView = document.getElementById("d-node-view");
const dContentView = document.getElementById("d-content-view");
const dContentSub = document.getElementById("d-content-subtitle");
const dContentBody = document.getElementById("d-content-body");
const navBack = document.getElementById("nav-back");
const navForward = document.getElementById("nav-forward");
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
// ── Panel history (browser-style back/forward) ────────────────
const panelNav = {
stack: [],
cursor: -1,
push(entry) {
this.stack = this.stack.slice(0, this.cursor + 1);
this.stack.push(entry);
this.cursor = this.stack.length - 1;
this._sync();
},
back() {
if (this.cursor <= 0) return;
this.cursor--;
this._replay();
this._sync();
},
forward() {
if (this.cursor >= this.stack.length - 1) return;
this.cursor++;
this._replay();
this._sync();
},
reset() {
this.stack = [];
this.cursor = -1;
this._sync();
},
_replay() {
const e = this.stack[this.cursor];
if (e.type === "node") openNode(e.id, { push: false, animate: true });
else if (e.type === "adr") openAdr(e.id, { push: false });
else if (e.type === "file") srcOpen(e.id);
},
_sync() {
navBack.disabled = this.cursor <= 0;
navForward.disabled = this.cursor >= this.stack.length - 1;
}
};
navBack.addEventListener("click", () => panelNav.back());
navForward.addEventListener("click", () => panelNav.forward());
// ── Panel view switching ──────────────────────────────────────
function showNodeView() {
dNodeView.classList.remove("hidden");
dContentView.classList.add("hidden");
}
function showContentView(subtitle) {
dNodeView.classList.add("hidden");
dContentView.classList.remove("hidden");
dContentSub.textContent = subtitle || "";
}
function closePanel() { function closePanel() {
panel.classList.add("hidden"); panel.classList.add("hidden");
cy.elements().removeClass("faded highlighted"); cy.elements().removeClass("faded highlighted");
panelNav.reset();
} }
cy.on("tap", "node", evt => { // ── Open node in panel ────────────────────────────────────────
const node = evt.target; function openNode(id, { push = true, animate = true } = {}) {
const d = node.data(); const node = cy.getElementById(id);
if (!node.length) return;
const d = node.data();
panel.classList.remove("hidden"); panel.classList.remove("hidden");
showNodeView();
dName.textContent = d.label; dName.textContent = d.label;
dBadges.innerHTML = dBadges.innerHTML =
@ -728,9 +987,15 @@ cy.on("tap", "node", evt => {
if (d.artifact_paths.length) { if (d.artifact_paths.length) {
dArtifacts.classList.remove("hidden"); dArtifacts.classList.remove("hidden");
dList.innerHTML = d.artifact_paths.map(p => dList.innerHTML = d.artifact_paths.map(p => {
`<li class="break-all"><code>${p}</code></li>` const ext = p.split(".").pop();
).join(""); const canView = ["ncl","toml","rs","nu","md","json","html"].includes(ext);
return `<li class="flex items-center gap-1.5 break-all">` +
(canView
? `<button class="artifact-link font-mono text-xs text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 text-left cursor-pointer" data-path="${p}">${p}</button>`
: `<code class="text-xs text-base-content/60">${p}</code>`) +
`</li>`;
}).join("");
} else { } else {
dArtifacts.classList.add("hidden"); dArtifacts.classList.add("hidden");
} }
@ -739,7 +1004,7 @@ cy.on("tap", "node", evt => {
dAdrs.classList.remove("hidden"); dAdrs.classList.remove("hidden");
dAdrList.innerHTML = d.adrs.map(a => dAdrList.innerHTML = d.adrs.map(a =>
`<li><span class="text-success mr-1"></span>` + `<li><span class="text-success mr-1"></span>` +
`<button class="adr-link font-mono text-base-content/70 hover:text-primary underline-offset-2 hover:underline cursor-pointer bg-transparent border-none p-0" data-adr="${a}">${a}</button></li>` `<button class="adr-link font-mono text-xs text-base-content/70 hover:text-primary underline-offset-2 hover:underline cursor-pointer bg-transparent border-none p-0" data-adr="${a}">${a}</button></li>`
).join(""); ).join("");
} else { } else {
dAdrs.classList.add("hidden"); dAdrs.classList.add("hidden");
@ -749,33 +1014,39 @@ cy.on("tap", "node", evt => {
if (conn.length) { if (conn.length) {
dEdges.classList.remove("hidden"); dEdges.classList.remove("hidden");
dEdgeList.innerHTML = conn.map(e => { dEdgeList.innerHTML = conn.map(e => {
const isSrc = e.data("source") === d.id; const isSrc = e.data("source") === d.id;
const other = isSrc const otherId = isSrc ? e.data("target") : e.data("source");
? cy.getElementById(e.data("target")).data("label") const other = cy.getElementById(otherId);
: cy.getElementById(e.data("source")).data("label"); const otherLbl = other.data("label") || otherId;
const arrow = isSrc ? "→" : "←"; const arrow = isSrc ? "→" : "←";
return `<li class="flex gap-1"><span class="opacity-40 flex-shrink-0">${arrow}</span>` + return `<li class="flex gap-1 items-baseline">` +
`<span class="text-base-content/80 flex-shrink-0">${e.data("kind")}</span>` + `<span class="opacity-40 flex-shrink-0">${arrow}</span>` +
`<span class="opacity-60 break-all">${other}</span></li>`; `<span class="text-base-content/80 flex-shrink-0 text-xs font-medium">${e.data("kind")}</span>` +
`<button class="node-jump-link text-xs opacity-60 break-all bg-transparent border-none p-0 cursor-pointer hover:text-primary hover:underline underline-offset-2 text-left" data-node-id="${otherId}">${otherLbl}</button>` +
`</li>`;
}).join(""); }).join("");
} else { } else {
dEdges.classList.add("hidden"); dEdges.classList.add("hidden");
} }
// Dim non-neighbours renderNodeExtra(d);
cy.elements().addClass("faded").removeClass("highlighted"); cy.elements().addClass("faded").removeClass("highlighted");
node.closedNeighborhood().removeClass("faded").addClass("highlighted"); node.closedNeighborhood().removeClass("faded").addClass("highlighted");
// Animate center + zoom to selected node if (animate) {
cy.animate( cy.animate(
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) }, { center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
{ duration: 350, easing: 'ease-in-out-cubic' } { duration: 350, easing: "ease-in-out-cubic" }
); );
}); }
cy.on("tap", evt => { if (push) panelNav.push({ type: "node", id });
if (evt.target === cy) closePanel(); }
});
cy.on("tap", "node", evt => { openNode(evt.target.data("id")); });
cy.on("tap", evt => { if (evt.target === cy) closePanel(); });
document.getElementById("btn-close-panel").addEventListener("click", closePanel); document.getElementById("btn-close-panel").addEventListener("click", closePanel);
@ -792,6 +1063,8 @@ handle.addEventListener("mousedown", e => {
e.preventDefault(); e.preventDefault();
}); });
const graphRoot = document.getElementById("graph-root");
document.addEventListener("mousemove", e => { document.addEventListener("mousemove", e => {
if (!resizing) return; if (!resizing) return;
const rect = graphRoot.getBoundingClientRect(); const rect = graphRoot.getBoundingClientRect();
@ -814,63 +1087,241 @@ document.addEventListener("mouseup", () => {
document.body.style.cursor = ""; document.body.style.cursor = "";
}); });
// ── ADR modal ───────────────────────────────────────────────── // ── Shared helpers ────────────────────────────────────────────
const adrModal = document.getElementById("adr-modal"); function esc(s) { return String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
const adrModalTitle = document.getElementById("adr-modal-title");
const adrModalBody = document.getElementById("adr-modal-body");
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
function renderAdrBody(data) { function renderKvSection(data, skipKeys = []) {
if (data.error) { return Object.entries(data)
return `<p class="text-error">${data.error}</p>`; .filter(([k, v]) => !skipKeys.includes(k) && v !== "" && v !== null && v !== undefined)
}
const rows = Object.entries(data)
.filter(([k]) => k !== "id")
.map(([k, v]) => { .map(([k, v]) => {
const label = k.replace(/_/g, " "); const label = k.replace(/_/g, " ");
let val; let val;
if (Array.isArray(v)) { if (Array.isArray(v)) {
if (v.length === 0) return null; if (v.length === 0) return null;
val = `<ul class="list-disc pl-4 space-y-0.5">${v.map(item => val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70">${v.map(item =>
typeof item === "object" typeof item === "object"
? `<li><pre class="text-xs whitespace-pre-wrap">${JSON.stringify(item, null, 2)}</pre></li>` ? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded mt-0.5">${JSON.stringify(item, null, 2)}</pre></li>`
: `<li>${item}</li>` : `<li>${item}</li>`
).join("")}</ul>`; ).join("")}</ul>`;
} else if (typeof v === "object" && v !== null) { } else if (typeof v === "object" && v !== null) {
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${JSON.stringify(v, null, 2)}</pre>`; val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${JSON.stringify(v, null, 2)}</pre>`;
} else if (typeof v === "boolean") {
val = `<span class="badge badge-xs ${v ? "badge-success" : "badge-ghost"}">${v}</span>`;
} else { } else {
val = `<span class="text-base-content/80">${v}</span>`; val = `<span class="text-base-content/80">${v}</span>`;
} }
return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-0.5">${label}</p>${val}</div>`; return `<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${label}</p>${val}</div>`;
}) })
.filter(Boolean) .filter(Boolean)
.join(""); .join("");
return rows || `<p class="text-base-content/50">No details available.</p>`;
} }
async function fetchAdr(id) { // ── Open ADR in panel ─────────────────────────────────────────
adrModalTitle.textContent = id; async function openAdr(id, { push = true } = {}) {
adrModalBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`; panel.classList.remove("hidden");
adrModal.showModal(); showContentView("Architecture Decision Record");
dName.textContent = id;
dContentBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
const slug = GRAPH_SLUG ? `&slug=${encodeURIComponent(GRAPH_SLUG)}` : ""; const params = new URLSearchParams({ ...(GRAPH_SLUG ? { slug: GRAPH_SLUG } : {}) });
try { try {
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${slug}`); const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
const data = await res.json(); const data = await res.json();
adrModalBody.innerHTML = renderAdrBody(data); dContentBody.innerHTML = data.error
? `<p class="text-error text-sm">${esc(data.error)}</p>`
: renderKvSection(data, ["id"]) || `<p class="text-base-content/50 text-sm">No details.</p>`;
} catch (err) { } catch (err) {
adrModalBody.innerHTML = `<p class="text-error">Failed to load ADR: ${err}</p>`; dContentBody.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
}
if (push) panelNav.push({ type: "adr", id });
}
// openFile is replaced by srcOpen — opens in repo/docs tab, no panel loading.
// ── Node-type extra sections in the side panel ────────────────
function renderNodeExtra(d) {
const el = dExtra;
el.innerHTML = "";
el.classList.add("hidden");
const sec = (label, content) =>
`<div><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-1">${label}</p>${content}</div>`;
const badge = (text, color) =>
`<span class="badge badge-xs" style="background:${color};color:#fff;border:none">${text}</span>`;
let html = "";
if (d.level === "Route") {
const authColor = { none: "#6b7280", viewer: "#f59e0b", bearer: "#3b82f6", admin: "#ef4444" };
const authBadge = badge(d.auth || "none", authColor[d.auth] || "#6b7280");
const methodBadge = badge(d.method, "#10b981");
html += sec("Endpoint",
`<div class="flex flex-wrap gap-1">${methodBadge}${authBadge}` +
(d.feature ? `<span class="badge badge-xs badge-outline">feature:${d.feature}</span>` : "") +
`</div>`);
if (d.actors?.length)
html += sec("Actors", d.actors.map(a => `<span class="badge badge-xs badge-ghost">${a}</span>`).join(" "));
if (d.tags?.length)
html += sec("Tags", d.tags.map(t => `<code class="text-xs bg-base-300 px-1 rounded">${t}</code>`).join(" "));
if (d.params?.length) {
const rows = d.params.map(p =>
`<li class="flex flex-wrap gap-1 items-baseline">` +
`<code class="text-xs font-bold">${p.name}</code>` +
`<span class="text-xs text-base-content/40">${p.kind}</span>` +
`<span class="badge badge-xs badge-ghost">${p.constraint}</span>` +
(p.description ? `<span class="text-xs text-base-content/50">— ${p.description}</span>` : "") +
`</li>`
).join("");
html += sec("Parameters", `<ul class="space-y-1">${rows}</ul>`);
}
} else if (d.level === "Dimension") {
const reached = d.current_state && d.current_state === d.desired_state;
const statusBadge = reached
? `<span class="badge badge-xs badge-success">reached</span>`
: `<span class="badge badge-xs badge-warning">in progress</span>`;
html += sec("FSM State",
`<div class="space-y-1 text-xs">` +
`<div class="flex gap-2 items-center">${statusBadge}<span class="badge badge-xs badge-ghost">${d.horizon}</span></div>` +
`<div><span class="text-base-content/40">current: </span><code>${d.current_state}</code></div>` +
`<div><span class="text-base-content/40">desired: </span><code>${d.desired_state}</code></div>` +
`</div>`);
} else if (d.level === "WorkspaceCrate") {
if (d.features?.length)
html += sec("Features", d.features.map(f =>
`<span class="badge badge-xs badge-outline font-mono">${f}</span>`).join(" "));
if (d.ws_deps?.length) {
html += sec("Depends on",
d.ws_deps.map(dep => {
const depId = dep;
const depNode = cy.nodes(`[id = "${depId}"]`);
const label = depNode.length ? depNode.data("label") : depId.replace("ws:", "");
return `<button class="node-jump-link badge badge-xs badge-ghost cursor-pointer" data-node-id="${depId}">${label}</button>`;
}).join(" "));
}
} else if (d.level === "ConfigSection") {
if (d.contract)
html += sec("Contract",
`<code class="text-xs bg-base-300 px-1.5 py-0.5 rounded">${d.contract}</code>`);
} else if (d.level === "Requirement") {
html += sec("Details",
`<div class="flex flex-wrap gap-1">` +
(d.env_kind ? badge(d.env_kind, "#6366f1") : "") +
(d.env_target ? badge(d.env_target, "#8b5cf6") : "") +
(d.required ? `<span class="badge badge-xs badge-error">required</span>`
: `<span class="badge badge-xs badge-ghost">optional</span>`) +
`</div>`);
}
if (html) {
el.innerHTML = html;
el.classList.remove("hidden");
} }
} }
// ── Click delegation ──────────────────────────────────────────
document.addEventListener("click", e => { document.addEventListener("click", e => {
const btn = e.target.closest(".adr-link"); // Close search dropdown on outside click
if (btn) fetchAdr(btn.dataset.adr); if (!e.target.closest("#search-wrap")) {
searchDropdown.classList.add("hidden");
}
const adrBtn = e.target.closest(".adr-link");
if (adrBtn) { openAdr(adrBtn.dataset.adr); return; }
const artBtn = e.target.closest(".artifact-link");
if (artBtn) { srcOpen(artBtn.dataset.path); return; }
const jumpBtn = e.target.closest(".node-jump-link");
if (jumpBtn) { openNode(jumpBtn.dataset.nodeId, { push: true, animate: true }); }
}); });
// ── Inline node search ────────────────────────────────────────
const graphSearch = document.getElementById("graph-search");
const searchCount = document.getElementById("search-count");
const searchDropdown = document.getElementById("search-dropdown");
let searchMatches = [];
function clearGraphSearch() {
cy.nodes().removeClass("search-match search-dim");
searchMatches = [];
searchCount.textContent = "";
searchDropdown.classList.add("hidden");
searchDropdown.innerHTML = "";
}
function doGraphSearch(q) {
clearGraphSearch();
if (!q) return;
const ql = q.toLowerCase();
searchMatches = cy.nodes(":visible").filter(n =>
(n.data("label") || "").toLowerCase().includes(ql) ||
(n.data("id") || "").toLowerCase().includes(ql) ||
(n.data("description") || "").toLowerCase().includes(ql)
).toArray();
searchCount.textContent = `${searchMatches.length}`;
if (searchMatches.length === 0) return;
// Highlight in graph
cy.nodes().addClass("search-dim");
searchMatches.forEach(n => n.removeClass("search-dim").addClass("search-match"));
// Dropdown list (cap 12)
const shown = searchMatches.slice(0, 12);
searchDropdown.innerHTML = shown.map((n, i) =>
`<li class="search-hit cursor-pointer px-3 py-2 hover:bg-base-300 transition-colors flex items-center gap-2" data-idx="${i}">` +
`<span class="badge badge-xs flex-shrink-0" style="background:${n.data("color")};border:none;color:#fff">${esc(n.data("level"))}</span>` +
`<span class="truncate">${esc(n.data("label"))}</span>` +
`</li>`
).join("");
if (searchMatches.length > 12) {
searchDropdown.innerHTML +=
`<li class="px-3 py-1.5 text-xs text-base-content/40">+${searchMatches.length - 12} more…</li>`;
}
searchDropdown.classList.remove("hidden");
}
graphSearch.addEventListener("input", () => {
const q = graphSearch.value.trim();
if (!q) { clearGraphSearch(); return; }
doGraphSearch(q);
});
graphSearch.addEventListener("keydown", e => {
if (e.key === "Escape") { graphSearch.value = ""; clearGraphSearch(); e.preventDefault(); return; }
if (e.key === "Enter" && searchMatches.length > 0) {
openNode(searchMatches[0].data("id"));
searchDropdown.classList.add("hidden");
e.preventDefault();
}
});
searchDropdown.addEventListener("click", e => {
const li = e.target.closest(".search-hit");
if (!li) return;
openNode(searchMatches[parseInt(li.dataset.idx, 10)].data("id"));
searchDropdown.classList.add("hidden");
});
// ── URL param auto-open ───────────────────────────────────────
const urlNode = new URLSearchParams(location.search).get("node");
if (urlNode) {
cy.ready(() => {
// Wait for layout to finish before centering
setTimeout(() => openNode(urlNode, { push: true, animate: true }), 600);
});
}
// ── Keyboard shortcuts ──────────────────────────────────────── // ── Keyboard shortcuts ────────────────────────────────────────
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
// Skip when focus is inside an input, textarea, or dialog
const tag = document.activeElement?.tagName; const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return; if (document.querySelector('dialog[open]')) return;

View File

@ -17,84 +17,13 @@
</div> </div>
</div> </div>
{% if error %} <div id="manage-error" class="{% if error %}alert alert-error mb-4 text-sm{% endif %}">
<div class="alert alert-error mb-4 text-sm"> {% if error %}<span>{{ error }}</span>{% endif %}
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ error }}</span>
</div> </div>
{% endif %}
<!-- Registered Projects --> <!-- Registered Projects -->
<div class="mb-6"> <div class="mb-6" id="manage-projects-section">
<h2 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">Registered Projects</h2> {% include "partials/manage_projects_section.html" %}
{% if projects %}
<div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
<th>Slug</th>
<th>Root</th>
<th>Mode</th>
<th>Auth</th>
<th>Persisted</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for p in projects %}
<tr class="hover:bg-base-200/50">
<td class="font-mono font-medium">
{% if not p.push_only %}
<a href="/ui/{{ p.slug }}/" class="link link-hover text-primary">{{ p.slug }}</a>
{% else %}
<span class="text-base-content/60">{{ p.slug }}</span>
{% endif %}
</td>
<td class="font-mono text-xs text-base-content/60 max-w-xs truncate" title="{{ p.root }}">{{ p.root }}</td>
<td>
{% if p.opmode == "daemon" %}
<span class="badge badge-xs badge-success gap-1">
<span class="w-1 h-1 rounded-full bg-current inline-block"></span>daemon
</span>
{% else %}
<span class="badge badge-xs badge-info">push</span>
{% endif %}
</td>
<td>
{% if p.auth %}
<span class="badge badge-xs badge-warning" title="{{ p.roles | join(sep=', ') }}">
{{ p.key_count }} key{% if p.key_count != 1 %}s{% endif %}
</span>
{% else %}
<span class="badge badge-xs badge-ghost">open</span>
{% endif %}
</td>
<td>
{% if p.in_projects_ncl %}
<span class="badge badge-xs badge-ghost text-success"></span>
{% else %}
<span class="badge badge-xs badge-warning" title="In memory only — will be lost on restart">memory only</span>
{% endif %}
</td>
<td class="text-right">
<form method="post" action="/ui/manage/remove" class="inline"
onsubmit="return confirm('Remove project {{ p.slug }}?')">
<input type="hidden" name="slug" value="{{ p.slug }}">
<button type="submit" class="btn btn-xs btn-error btn-outline">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
No projects registered. Add one below.
</div>
{% endif %}
</div> </div>
<!-- Add Project Form --> <!-- Add Project Form -->
@ -102,7 +31,11 @@
<div class="card-body p-5"> <div class="card-body p-5">
<h2 class="card-title text-sm font-semibold">Add Project</h2> <h2 class="card-title text-sm font-semibold">Add Project</h2>
<p class="text-xs text-base-content/40 -mt-1">Slug and import paths are read from <code>.ontoref/project.ncl</code>. Change is persisted to <code>~/.config/ontoref/projects.ncl</code>.</p> <p class="text-xs text-base-content/40 -mt-1">Slug and import paths are read from <code>.ontoref/project.ncl</code>. Change is persisted to <code>~/.config/ontoref/projects.ncl</code>.</p>
<form method="post" action="/ui/manage/add" class="flex flex-col sm:flex-row gap-3 mt-2"> <form hx-post="/ui/manage/add"
hx-target="#manage-projects-section"
hx-swap="innerHTML"
hx-on::after-request="if(event.detail.successful)this.reset()"
class="flex flex-col sm:flex-row gap-3 mt-2">
<div class="form-control flex-1"> <div class="form-control flex-1">
<label class="label py-1"> <label class="label py-1">
<span class="label-text text-xs text-base-content/60">Absolute root path</span> <span class="label-text text-xs text-base-content/60">Absolute root path</span>

View File

@ -64,13 +64,20 @@
<!-- DAG action buttons --> <!-- DAG action buttons -->
<div class="flex flex-wrap gap-1 mt-1.5"> <div class="flex flex-wrap gap-1 mt-1.5">
{% for act in p.actions %} {% for act in p.actions %}
<form method="post" action="{{ base_url }}/notifications/{{ n.id }}/action" class="inline"> <form hx-post="{{ base_url }}/notifications/{{ n.id }}/action"
hx-target="this"
hx-swap="outerHTML"
class="inline">
<input type="hidden" name="action_id" value="{{ act.id }}"> <input type="hidden" name="action_id" value="{{ act.id }}">
<button type="submit" class="btn btn-xs <button type="submit" class="btn btn-xs
{% if act.mode == 'auto' %}btn-error {% if act.mode == 'backlog_approve' %}btn-success
{% elif act.mode == 'backlog_reject' %}btn-ghost
{% elif act.mode == 'auto' %}btn-error
{% elif act.mode == 'semi' %}btn-warning {% elif act.mode == 'semi' %}btn-warning
{% else %}btn-ghost{% endif %} gap-1"> {% else %}btn-ghost{% endif %} gap-1">
{% if act.mode == 'auto' %}▶{% elif act.mode == 'semi' %}◑{% else %}→{% endif %} {% if act.mode == 'backlog_approve' %}✓
{% elif act.mode == 'backlog_reject' %}✕
{% elif act.mode == 'auto' %}▶{% elif act.mode == 'semi' %}◑{% else %}→{% endif %}
{{ act.label }} {{ act.label }}
</button> </button>
</form> </form>
@ -79,7 +86,7 @@
{% else %} {% else %}
<details class="mt-1"> <details class="mt-1">
<summary class="text-xs text-base-content/40 cursor-pointer">payload</summary> <summary class="text-xs text-base-content/40 cursor-pointer">payload</summary>
<pre class="text-xs text-base-content/60 mt-1 bg-base-300 rounded p-2 max-w-xs overflow-auto">{{ p }}</pre> <pre class="text-xs text-base-content/60 mt-1 bg-base-300 rounded p-2 max-w-xs overflow-auto">{{ p | json_encode(pretty=true) }}</pre>
</details> </details>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -107,7 +114,10 @@
<dialog id="emit-modal" class="modal"> <dialog id="emit-modal" class="modal">
<div class="modal-box max-w-lg"> <div class="modal-box max-w-lg">
<h3 class="font-bold text-base mb-4">Emit Notification</h3> <h3 class="font-bold text-base mb-4">Emit Notification</h3>
<form method="post" action="{{ base_url }}/notifications/emit" class="space-y-3"> <form hx-post="{{ base_url }}/notifications/emit"
hx-swap="none"
hx-on::after-request="if(event.detail.successful){document.getElementById('emit-modal').close();this.reset()}"
class="space-y-3">
<div class="form-control"> <div class="form-control">
<label class="label py-1"><span class="label-text text-sm">Target project</span></label> <label class="label py-1"><span class="label-text text-sm">Target project</span></label>
<select name="target_slug" class="select select-bordered select-sm" required> <select name="target_slug" class="select select-bordered select-sm" required>

View File

@ -79,7 +79,7 @@
<div class="flex items-center gap-0.5 flex-shrink-0"> <div class="flex items-center gap-0.5 flex-shrink-0">
{% if p.card %} {% if p.card %}
<button onclick="openCard('{{ p.slug }}')" title="Project card" <button onclick="openCard('{{ p.slug }}')" title="Project card"
class="btn btn-ghost btn-xs btn-circle"> class="btn btn-ghost btn-xs">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
@ -87,21 +87,21 @@
</button> </button>
{% endif %} {% endif %}
<a href="/ui/{{ p.slug }}/search" title="Search" <a href="/ui/{{ p.slug }}/search" title="Search"
class="btn btn-ghost btn-xs btn-circle"> class="btn btn-ghost btn-xs">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/> d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg> </svg>
</a> </a>
<a href="/ui/{{ p.slug }}/backlog" title="Backlog" <a href="/ui/{{ p.slug }}/backlog" title="Backlog"
class="btn btn-ghost btn-xs btn-circle"> class="btn btn-ghost btn-xs">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/> d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg> </svg>
</a> </a>
<a href="/ui/{{ p.slug }}/" title="Open dashboard" <a href="/ui/{{ p.slug }}/" title="Open dashboard"
class="btn btn-ghost btn-xs btn-circle"> class="btn btn-ghost btn-xs">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/> d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
@ -178,11 +178,18 @@
{% if p.op_modes %} {% if p.op_modes %}
<span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span> <span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span>
{% endif %} {% endif %}
<span id="mig-badge-{{ p.slug }}" class="badge badge-sm badge-warning hidden"></span>
</div> </div>
<!-- Accordion panels --> <!-- Accordion panels -->
<div class="space-y-1"> <div class="space-y-1">
<div id="mig-panel-{{ p.slug }}"
hx-get="/ui/{{ p.slug }}/migrations/pending"
hx-trigger="revealed"
hx-swap="innerHTML"></div>
{% if p.layers or p.op_modes %} {% if p.layers or p.op_modes %}
<details class="collapse collapse-arrow bg-base-300/40 rounded-lg"> <details class="collapse collapse-arrow bg-base-300/40 rounded-lg">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer"> <summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer">

View File

@ -77,9 +77,15 @@
<!-- Resize handle --> <!-- Resize handle -->
<div id="search-resize" class="w-1.5 bg-base-300 hover:bg-primary/40 cursor-col-resize transition-colors flex-shrink-0"></div> <div id="search-resize" class="w-1.5 bg-base-300 hover:bg-primary/40 cursor-col-resize transition-colors flex-shrink-0"></div>
<!-- Right: detail panel --> <!-- Right: detail panel with nav bar -->
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-y-auto p-5 hidden min-w-[200px]"> <div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-hidden flex flex-col hidden min-w-[200px]">
<!-- filled by JS --> <div class="flex items-center gap-1 px-3 py-2 border-b border-base-content/10 flex-shrink-0">
<button id="dp-back" class="btn btn-xs btn-ghost px-2" title="Back" disabled></button>
<button id="dp-forward" class="btn btn-xs btn-ghost px-2" title="Forward" disabled></button>
</div>
<div id="dp-content" class="flex-1 overflow-y-auto p-5">
<!-- filled by JS -->
</div>
</div> </div>
<!-- Empty right side when nothing selected --> <!-- Empty right side when nothing selected -->
@ -103,14 +109,136 @@ const input = document.getElementById('search-input');
const resultsList = document.getElementById('results-list'); const resultsList = document.getElementById('results-list');
const resultsCount = document.getElementById('results-count'); const resultsCount = document.getElementById('results-count');
const detail = document.getElementById('detail-panel'); const detail = document.getElementById('detail-panel');
const dpContent = document.getElementById('dp-content');
const detailEmpty = document.getElementById('detail-empty'); const detailEmpty = document.getElementById('detail-empty');
const resetBtn = document.getElementById('btn-reset-search'); const resetBtn = document.getElementById('btn-reset-search');
const resizeHandle = document.getElementById('search-resize'); const resizeHandle = document.getElementById('search-resize');
const dpBack = document.getElementById('dp-back');
const dpForward = document.getElementById('dp-forward');
const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col'); const LEFT_PANEL = document.querySelector('#search-pane').closest('div.flex-col');
const CONTAINER = LEFT_PANEL.parentElement; const CONTAINER = LEFT_PANEL.parentElement;
const BASE_URL = "{{ base_url }}"; // ── Panel navigation (back/forward) ────────────────────────────────────────
const dpNav = {
stack: [],
cursor: -1,
push(entry) {
this.stack = this.stack.slice(0, this.cursor + 1);
this.stack.push(entry);
this.cursor = this.stack.length - 1;
this._sync();
},
back() {
if (this.cursor <= 0) return;
this.cursor--;
this._replay();
this._sync();
},
forward() {
if (this.cursor >= this.stack.length - 1) return;
this.cursor++;
this._replay();
this._sync();
},
reset() { this.stack = []; this.cursor = -1; this._sync(); },
_replay() {
const e = this.stack[this.cursor];
if (e.type === 'result') _showResultContent(e.r, false);
else if (e.type === 'file') srcOpen(e.path);
else if (e.type === 'adr') openAdrInPanel(e.id, e.title, false);
},
_sync() {
dpBack.disabled = this.cursor <= 0;
dpForward.disabled = this.cursor >= this.stack.length - 1;
}
};
dpBack.addEventListener('click', () => dpNav.back());
dpForward.addEventListener('click', () => dpNav.forward());
function showPanel() {
detail.classList.remove('hidden');
detailEmpty.classList.add('hidden');
}
function hidePanel() {
detail.classList.add('hidden');
detailEmpty.classList.remove('hidden');
dpNav.reset();
}
// ── ADR inline loader ─────────────────────────────────────────────────────
async function openAdrInPanel(id, title = '', push = true) {
showPanel();
dpContent.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
const params = new URLSearchParams({});
if (SLUG) params.set('slug', SLUG);
try {
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
const data = await res.json();
if (data.error) {
dpContent.innerHTML = `<p class="text-error text-sm">${esc(data.error)}</p>`;
} else {
const entries = Object.entries(data)
.filter(([k, v]) => k !== 'id' && v !== '' && v !== null && v !== undefined)
.map(([k, v]) => {
const label = k.replace(/_/g, ' ');
let val;
if (Array.isArray(v)) {
if (v.length === 0) return null;
val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70 text-xs">${
v.map(item => typeof item === 'object'
? `<li><pre class="text-xs whitespace-pre-wrap bg-base-300 p-1.5 rounded">${esc(JSON.stringify(item, null, 2))}</pre></li>`
: `<li>${esc(String(item))}</li>`
).join('')}</ul>`;
} else if (typeof v === 'object') {
val = `<pre class="text-xs whitespace-pre-wrap bg-base-300 p-2 rounded">${esc(JSON.stringify(v, null, 2))}</pre>`;
} else if (typeof v === 'boolean') {
val = `<span class="badge badge-xs ${v ? 'badge-success' : 'badge-ghost'}">${v}</span>`;
} else {
val = `<span class="text-base-content/80 text-xs">${esc(String(v))}</span>`;
}
return `<div class="mb-3"><p class="text-xs font-semibold text-base-content/40 uppercase tracking-wide mb-0.5">${esc(label)}</p>${val}</div>`;
})
.filter(Boolean)
.join('');
dpContent.innerHTML = entries || '<p class="text-base-content/50 text-sm">No details.</p>';
}
} catch (err) {
dpContent.innerHTML = `<p class="text-error text-sm">Failed: ${esc(String(err))}</p>`;
}
if (push) dpNav.push({ type: 'adr', id, title });
}
// ── Click delegation inside detail panel ─────────────────────────────────
document.getElementById('detail-panel').addEventListener('click', e => {
const fileBtn = e.target.closest('.s-file-link');
if (fileBtn) { srcOpen(fileBtn.dataset.path); return; }
const adrBtn = e.target.closest('.s-adr-link');
if (adrBtn) { openAdrInPanel(adrBtn.dataset.adr, adrBtn.dataset.adr); }
});
const BASE_URL = "{{ base_url }}";
const CARD_REPO = "{{ card_repo | default(value='') | safe }}";
const CARD_DOCS = "{{ card_docs | default(value='') | safe }}";
function srcOpen(path) {
if (!path) return;
const ext = path.split('.').pop().toLowerCase();
if (ext === 'rs' && CARD_DOCS) {
window.open(CARD_DOCS.replace(/\/$/, ''), '_blank', 'noopener');
return;
}
if (CARD_REPO) {
window.open(`${CARD_REPO.replace(/\/$/, '')}/src/branch/main/${path}`, '_blank', 'noopener');
return;
}
}
let results = []; let results = [];
let searchTimer = null; let searchTimer = null;
@ -231,7 +359,7 @@ function renderBookmarks() {
<div class="px-3 py-2 flex items-center gap-2"> <div class="px-3 py-2 flex items-center gap-2">
<span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${esc(b.kind)}</span> <span class="badge badge-xs ${kindCls(b.kind)} flex-shrink-0">${esc(b.kind)}</span>
<span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span> <span class="text-xs font-medium truncate flex-1">${esc(b.title)}</span>
<button class="btn-unbm btn btn-ghost btn-xs btn-circle flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error" <button class="btn-unbm btn btn-ghost btn-xs flex-shrink-0 opacity-40 hover:opacity-100 hover:text-error"
data-nid="${esc(b.node_id)}" title="Remove"> data-nid="${esc(b.node_id)}" title="Remove">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
@ -276,9 +404,8 @@ function renderBookmarks() {
} }
function showDetailBm(bm) { function showDetailBm(bm) {
detail.classList.remove('hidden'); showPanel();
detailEmpty.classList.add('hidden'); dpContent.innerHTML = `
detail.innerHTML = `
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h2 class="font-bold text-base leading-tight">${esc(bm.title)}</h2> <h2 class="font-bold text-base leading-tight">${esc(bm.title)}</h2>
@ -287,8 +414,7 @@ function showDetailBm(bm) {
${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''} ${bm.level ? `<span class="badge badge-xs badge-ghost">${esc(bm.level)}</span>` : ''}
</div> </div>
</div> </div>
<button id="bm-detail-star" class="btn btn-ghost btn-xs btn-circle text-warning" <button id="bm-detail-star" class="btn btn-ghost btn-xs text-warning" title="Remove bookmark">
title="Remove bookmark">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/> <path d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
</svg> </svg>
@ -300,8 +426,7 @@ function showDetailBm(bm) {
`; `;
document.getElementById('bm-detail-star').addEventListener('click', async () => { document.getElementById('bm-detail-star').addEventListener('click', async () => {
await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level }); await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level });
detail.classList.add('hidden'); hidePanel();
detailEmpty.classList.remove('hidden');
}); });
} }
@ -379,12 +504,19 @@ function renderResults() {
<div class="text-sm font-medium leading-tight truncate">${esc(r.title)}</div> <div class="text-sm font-medium leading-tight truncate">${esc(r.title)}</div>
<div class="text-xs text-base-content/50 leading-tight truncate mt-0.5">${esc(r.description)}</div> <div class="text-xs text-base-content/50 leading-tight truncate mt-0.5">${esc(r.description)}</div>
</div> </div>
<button class="btn-star btn btn-ghost btn-xs btn-circle flex-shrink-0 mt-0.5 ${starred ? 'text-warning' : 'text-base-content/20 hover:text-warning'}" <div class="flex items-center gap-0.5 flex-shrink-0 mt-0.5">
data-idx="${i}" title="${starred ? 'Remove bookmark' : 'Bookmark'}"> ${r.kind === 'node' ? `<a href="${graphUrl(r.id)}" class="btn btn-ghost btn-xs text-base-content/20 hover:text-primary" title="View in graph">
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-3c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z"/>
</svg> </svg>
</button> </a>` : ''}
<button class="btn-star btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/20 hover:text-warning'}"
data-idx="${i}" title="${starred ? 'Remove bookmark' : 'Bookmark'}">
<svg class="w-3.5 h-3.5" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
</svg>
</button>
</div>
</div> </div>
</li>`; </li>`;
}).join(''); }).join('');
@ -422,12 +554,11 @@ async function copyResultToClipboard(r, btn) {
} catch (_) {} } catch (_) {}
} }
function showDetail(idx) { function _showResultContent(r, push) {
const r = results[idx];
const starred = isBookmarked(r); const starred = isBookmarked(r);
detail.classList.remove('hidden'); showPanel();
detailEmpty.classList.add('hidden'); const canView = r.path && /\.(ncl|toml|rs|nu|md|json|html|yaml|yml)$/.test(r.path);
detail.innerHTML = ` dpContent.innerHTML = `
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2> <h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2>
@ -438,14 +569,18 @@ function showDetail(idx) {
</div> </div>
</div> </div>
<div class="flex items-center gap-1 flex-shrink-0 mt-0.5"> <div class="flex items-center gap-1 flex-shrink-0 mt-0.5">
<button id="detail-copy" class="btn btn-ghost btn-xs btn-circle text-base-content/25 hover:text-base-content" ${r.kind === 'node' ? `<a href="${graphUrl(r.id)}" class="btn btn-ghost btn-xs text-base-content/25 hover:text-primary" title="View in graph">
title="Copy to clipboard"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12-3c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z"/>
</svg>
</a>` : ''}
<button id="detail-copy" class="btn btn-ghost btn-xs text-base-content/25 hover:text-base-content" title="Copy to clipboard">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3"/> d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3"/>
</svg> </svg>
</button> </button>
<button id="detail-star" class="btn btn-ghost btn-xs btn-circle ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}" <button id="detail-star" class="btn btn-ghost btn-xs ${starred ? 'text-warning' : 'text-base-content/25 hover:text-warning'}"
title="${starred ? 'Remove bookmark' : 'Bookmark this'}"> title="${starred ? 'Remove bookmark' : 'Bookmark this'}">
<svg class="w-4 h-4" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="${starred ? 'currentColor' : 'none'}" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3a2 2 0 00-2 2v16l7-3 7 3V5a2 2 0 00-2-2H5z"/>
@ -453,7 +588,11 @@ function showDetail(idx) {
</button> </button>
</div> </div>
</div> </div>
<p class="text-xs font-mono text-base-content/30 mb-4 truncate">${esc(r.path)}</p> ${r.path ? `<p class="text-xs font-mono text-base-content/30 mb-4 break-all">${
canView
? `<button class="s-file-link text-primary hover:underline underline-offset-2 bg-transparent border-none p-0 cursor-pointer text-left" data-path="${esc(r.path)}">${esc(r.path)}</button>`
: esc(r.path)
}</p>` : ''}
<div class="space-y-1 text-sm">${r.detail_html}</div> <div class="space-y-1 text-sm">${r.detail_html}</div>
`; `;
document.getElementById('detail-copy').addEventListener('click', async e => { document.getElementById('detail-copy').addEventListener('click', async e => {
@ -461,17 +600,22 @@ function showDetail(idx) {
}); });
document.getElementById('detail-star').addEventListener('click', async () => { document.getElementById('detail-star').addEventListener('click', async () => {
await toggleBookmark(r); await toggleBookmark(r);
showDetail(idx); _showResultContent(r, false);
}); });
if (push) dpNav.push({ type: 'result', r });
}
function showDetail(idx) {
const r = results[idx];
if (!r) return;
_showResultContent(r, true);
} }
function clearResults() { function clearResults() {
results = []; results = [];
resultsList.innerHTML = ''; resultsList.innerHTML = '';
resultsCount.classList.add('hidden'); resultsCount.classList.add('hidden');
detail.classList.add('hidden'); hidePanel();
detail.innerHTML = '';
detailEmpty.classList.remove('hidden');
selectedItem = null; selectedItem = null;
} }
function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); } function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); }
@ -502,7 +646,12 @@ document.addEventListener('mouseup', () => {
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; } function kindCls(kind) { return { node: 'badge-primary', adr: 'badge-secondary', mode: 'badge-accent' }[kind] || 'badge-neutral'; }
function poleColor(p) { return { Yang: '#f59e0b', Yin: '#3b82f6', Spiral: '#8b5cf6' }[p] || '#6b7280'; } function poleColor(p) { return { Yang: '#f59e0b', Yin: '#3b82f6', Spiral: '#8b5cf6', State: '#ec4899', Config: '#14b8a6', Env: '#f97316' }[p] || '#6b7280'; }
function graphUrl(id) {
const params = new URLSearchParams({ node: id });
if (SLUG) params.set('slug', SLUG);
return `/graph?${params}`;
}
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── Init ─────────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,22 @@
{% import "macros/ui.html" as m %}
{% if items %}
<div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
<th class="w-20">ID</th>
<th class="w-24">Status</th>
<th class="w-20">Priority</th>
<th class="w-16">Kind</th>
<th>Title</th>
<th class="w-24 text-right">Actions</th>
</tr>
</thead>
{% include "partials/backlog_tbody.html" %}
</table>
</div>
{% else %}
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
No backlog items yet.
</div>
{% endif %}

View File

@ -0,0 +1,118 @@
<tbody id="backlog-tbody">
{% for it in items %}
<tr class="backlog-row hover:bg-base-200/50 cursor-pointer"
data-status="{{ it.status }}"
data-priority="{{ it.priority }}"
data-id="{{ it.id }}"
data-title="{{ it.title }}"
data-kind="{{ it.kind }}"
data-priority-val="{{ it.priority }}"
data-status-val="{{ it.status }}"
data-detail="{{ it.detail | default(value='') }}"
data-related-adrs="{{ it.related_adrs | default(value=[]) | join(sep=',') }}"
data-related-modes="{{ it.related_modes | default(value=[]) | join(sep=',') }}"
data-graduates-to="{{ it.graduates_to | default(value='') }}"
onclick="openEditModal(this)"
onmousedown="event.stopPropagation()">
<td class="font-mono text-xs text-base-content/50">{{ it.id }}</td>
<td>
<span class="badge badge-xs
{% if it.status == "Open" %}badge-info
{% elif it.status == "InProgress" %}badge-warning
{% elif it.status == "Done" %}badge-success
{% else %}badge-ghost{% endif %}">{{ it.status }}</span>
</td>
<td>
<span class="badge badge-xs
{% if it.priority == "Critical" %}badge-error
{% elif it.priority == "High" %}badge-warning
{% else %}badge-ghost{% endif %}">{{ it.priority }}</span>
</td>
<td>
<span class="badge badge-xs badge-ghost">{{ it.kind }}</span>
</td>
<td>
<div class="font-medium text-sm leading-tight">{{ it.title }}</div>
{% if it.detail %}
<div class="text-xs text-base-content/50 leading-tight mt-0.5 line-clamp-1">{{ it.detail }}</div>
{% endif %}
</td>
<td class="text-right" onclick="event.stopPropagation()">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-xs btn-ghost"></button>
<ul tabindex="0"
class="dropdown-content menu menu-xs bg-base-200 shadow rounded-box z-50 w-40 p-1">
{% if it.status != "InProgress" %}
<li>
<form hx-post="{{ base_url }}/backlog/status"
hx-target="#backlog-items-container" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="InProgress">
<button type="submit" class="w-full text-left">→ In Progress</button>
</form>
</li>
{% endif %}
{% if it.status != "Done" %}
<li>
<form hx-post="{{ base_url }}/backlog/status"
hx-target="#backlog-items-container" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Done">
<button type="submit" class="w-full text-left">✓ Done</button>
</form>
</li>
{% endif %}
{% if it.status != "Open" %}
<li>
<form hx-post="{{ base_url }}/backlog/status"
hx-target="#backlog-items-container" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Open">
<button type="submit" class="w-full text-left">↩ Reopen</button>
</form>
</li>
{% endif %}
<li>
<form hx-post="{{ base_url }}/backlog/status"
hx-target="#backlog-items-container" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="status" value="Cancelled">
<button type="submit" class="w-full text-left text-error">✕ Cancel</button>
</form>
</li>
<li class="border-t border-base-content/10 mt-1 pt-1">
{% if it.status != "InProgress" %}
<form hx-post="{{ base_url }}/backlog/propose-status"
hx-target="this" hx-swap="outerHTML">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="proposed_status" value="InProgress">
<button type="submit" class="w-full text-left text-warning">⏳ Propose: In Progress</button>
</form>
{% endif %}
{% if it.status != "Done" %}
<form hx-post="{{ base_url }}/backlog/propose-status"
hx-target="this" hx-swap="outerHTML">
<input type="hidden" name="id" value="{{ it.id }}">
<input type="hidden" name="proposed_status" value="Done">
<button type="submit" class="w-full text-left text-warning">⏳ Propose: Done</button>
</form>
{% endif %}
</li>
<li class="border-t border-base-content/10 mt-1 pt-1">
<form hx-post="{{ base_url }}/backlog/delete"
hx-target="#backlog-items-container" hx-swap="innerHTML"
hx-confirm="Delete {{ it.id }}? This cannot be undone.">
<input type="hidden" name="id" value="{{ it.id }}">
<button type="submit" class="w-full text-left text-error">✕ Delete</button>
</form>
</li>
<li>
<a href="{{ base_url }}/notifications?kind=backlog_delegation&title={{ it.id | urlencode }}%3A%20{{ it.title | urlencode }}&payload=%7B%22item_id%22%3A%22{{ it.id | urlencode }}%22%2C%22status%22%3A%22{{ it.status | urlencode }}%22%2C%22priority%22%3A%22{{ it.priority | urlencode }}%22%7D"
class="w-full text-left">↗ Send to project</a>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,12 @@
{% import "macros/ui.html" as m %}
<div class="stats stats-horizontal shadow w-full mb-4 bg-base-200 overflow-x-auto"
id="dash-stats"
hx-get="{{ base_url }}/stats"
hx-trigger="every 10s"
hx-swap="outerHTML">
{{ m::stat(title="Uptime", value=uptime_secs ~ "s", desc="seconds since start") }}
{{ m::stat(title="Cache entries", value=cache_entries) }}
{{ m::stat(title="Cache hit rate", value=cache_hit_rate, desc=cache_hits ~ " hits / " ~ cache_misses ~ " misses", accent="success") }}
{{ m::stat(title="Sessions", value=active_actors, accent="primary") }}
{{ m::stat(title="Notifications", value=notification_count, accent="warning") }}
</div>

View File

@ -0,0 +1,80 @@
<h2 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">Registered Projects</h2>
{% if projects %}
<div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/40 uppercase tracking-wider">
<th>Slug</th>
<th>Root</th>
<th>Mode</th>
<th>Auth</th>
<th>Persisted</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for p in projects %}
<tr class="hover:bg-base-200/50">
<td class="font-mono font-medium">
{% if not p.push_only %}
<a href="/ui/{{ p.slug }}/" class="link link-hover text-primary">{{ p.slug }}</a>
{% else %}
<span class="text-base-content/60">{{ p.slug }}</span>
{% endif %}
</td>
<td class="font-mono text-xs text-base-content/60 max-w-xs truncate" title="{{ p.root }}">{{ p.root }}</td>
<td>
{% if p.opmode == "daemon" %}
<span class="badge badge-xs badge-success gap-1">
<span class="w-1 h-1 rounded-full bg-current inline-block"></span>daemon
</span>
{% else %}
<span class="badge badge-xs badge-info">push</span>
{% endif %}
</td>
<td>
{% if p.auth %}
<span class="badge badge-xs badge-warning" title="{{ p.roles | join(sep=', ') }}">
{{ p.key_count }} key{% if p.key_count != 1 %}s{% endif %}
</span>
{% else %}
<span class="badge badge-xs badge-ghost">open</span>
{% endif %}
</td>
<td>
{% if p.in_projects_ncl %}
<span class="badge badge-xs badge-ghost text-success"></span>
{% else %}
<span class="badge badge-xs badge-warning" title="In memory only — will be lost on restart">memory only</span>
{% endif %}
</td>
<td class="text-right">
<form hx-post="/ui/manage/remove"
hx-target="#manage-projects-section"
hx-swap="innerHTML"
hx-confirm="Remove project {{ p.slug }}?"
class="inline">
<input type="hidden" name="slug" value="{{ p.slug }}">
<button type="submit" class="btn btn-xs btn-error btn-outline">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-10 text-base-content/30 text-sm border border-base-content/10 rounded-lg">
No projects registered. Add one below.
</div>
{% endif %}
{% if manage_error %}
<div id="manage-error" hx-swap-oob="true" class="alert alert-error mb-4 text-sm">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ manage_error }}</span>
</div>
{% else %}
<div id="manage-error" hx-swap-oob="true"></div>
{% endif %}

View File

@ -0,0 +1,22 @@
{% if migrations and migrations | length > 0 %}
<span id="mig-badge-{{ slug }}" hx-swap-oob="true"
class="badge badge-sm badge-warning">
○ {{ migrations | length }} migration{% if migrations | length > 1 %}s{% endif %}
</span>
<details class="collapse collapse-arrow bg-warning/10 border border-warning/30 rounded-lg">
<summary class="collapse-title text-xs font-semibold py-2 min-h-0 px-3 cursor-pointer text-warning">
○ Pending migrations
</summary>
<div class="collapse-content px-3 pb-3">
<div class="space-y-1">
{% for m in migrations %}
<div class="flex gap-2 items-start">
<span class="badge badge-xs badge-warning font-mono flex-shrink-0 mt-0.5">{{ m.id }}</span>
<span class="text-xs text-base-content/70">{{ m.description }}</span>
</div>
{% endfor %}
</div>
<p class="text-xs text-base-content/40 mt-2">Run <code class="font-mono">ontoref migrate show &lt;id&gt;</code> for instructions.</p>
</div>
</details>
{% endif %}

View File

@ -236,6 +236,8 @@ fn emit_onto_api(attr: OntoApiAttr, item: proc_macro2::TokenStream) -> proc_macr
params: &[#(#param_exprs),*], params: &[#(#param_exprs),*],
tags: &[#(#tag_lits),*], tags: &[#(#tag_lits),*],
feature: #feature, feature: #feature,
// file!() expands at call site — the .rs file where #[onto_api] is placed.
source_file: file!(),
} }
} }

View File

@ -28,18 +28,91 @@ pub struct ApiRouteEntry {
pub tags: &'static [&'static str], pub tags: &'static [&'static str],
/// Non-empty when the endpoint is only compiled under a feature flag. /// Non-empty when the endpoint is only compiled under a feature flag.
pub feature: &'static str, pub feature: &'static str,
/// Source file path (relative to workspace root) captured via `file!()`.
pub source_file: &'static str,
} }
inventory::collect!(ApiRouteEntry); inventory::collect!(ApiRouteEntry);
/// Serialize all statically-registered [`ApiRouteEntry`] items to a /// Serialize all statically-registered [`ApiRouteEntry`] items to a
/// pretty-printed JSON array, sorted by path then method. /// pretty-printed JSON array, sorted by path then method.
///
/// Intended for daemon binaries that expose a `--dump-api-catalog` flag: write
/// the output to `api-catalog.json` in the project root so the ontoref UI can
/// display the API surface of consumer projects that run as separate processes.
pub fn dump_catalog_json() -> String { pub fn dump_catalog_json() -> String {
let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::<ApiRouteEntry>().collect(); let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::<ApiRouteEntry>().collect();
routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method))); routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method)));
serde_json::to_string_pretty(&routes).unwrap_or_else(|_| "[]".to_string()) serde_json::to_string_pretty(&routes).unwrap_or_else(|_| "[]".to_string())
} }
/// Serialize all statically-registered [`ApiRouteEntry`] items to a Nickel
/// record that imports `../reflection/schemas/api-catalog.ncl`.
///
/// The output path is `artifacts/api-catalog-{crate_name}.ncl` relative to
/// the project root. Enum tags are capitalised to match the schema contracts:
/// `"GET"` → `'GET`, `"viewer"` → `'Viewer`, `"developer"` → `'Developer`.
pub fn dump_catalog_ncl(crate_name: &str) -> String {
let mut routes: Vec<&'static ApiRouteEntry> = inventory::iter::<ApiRouteEntry>().collect();
routes.sort_by(|a, b| a.path.cmp(b.path).then(a.method.cmp(b.method)));
let routes_ncl = routes
.iter()
.map(|r| {
let actors = r
.actors
.iter()
.map(|a| format!("'{}", ncl_capitalize(a)))
.collect::<Vec<_>>()
.join(", ");
let tags = r
.tags
.iter()
.map(|t| format!("\"{}\"", ncl_escape(t)))
.collect::<Vec<_>>()
.join(", ");
let params = r
.params
.iter()
.map(|p| {
format!(
"{{ name = \"{}\", kind = \"{}\", constraint = \"{}\", description = \
\"{}\" }}",
ncl_escape(p.name),
ncl_escape(p.kind),
ncl_escape(p.constraint),
ncl_escape(p.description),
)
})
.collect::<Vec<_>>()
.join(", ");
format!(
" {{\n method = '{method},\n path = \"{path}\",\n description = \
\"{desc}\",\n auth = '{auth},\n actors = [{actors}],\n tags = \
[{tags}],\n params = [{params}],\n feature = \"{feature}\",\n \
source_file = \"{source_file}\",\n }}",
method = r.method,
path = ncl_escape(r.path),
desc = ncl_escape(r.description),
auth = ncl_capitalize(r.auth),
feature = ncl_escape(r.feature),
source_file = ncl_escape(r.source_file),
)
})
.collect::<Vec<_>>()
.join(",\n");
format!(
"let schema = import \"../reflection/schemas/api-catalog.ncl\" in\n{{\n crate = \
\"{crate_name}\",\n routes | Array schema.Route = [\n{routes_ncl}\n ],\n}} | \
schema.Catalog\n"
)
}
fn ncl_capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
fn ncl_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}

View File

@ -6,20 +6,22 @@
# Pattern guide: reflection/templates/vendor-frontend-assets-prompt.md # Pattern guide: reflection/templates/vendor-frontend-assets-prompt.md
CYTOSCAPE_NAVIGATOR_VERSION := "2.0.1" CYTOSCAPE_NAVIGATOR_VERSION := "2.0.1"
HTMX_VERSION := "2.0.7"
# Export this daemon's API catalog to api-catalog.json. # Export this daemon's API catalog to api-catalog.json.
# Run after any #[onto_api] annotation is added or changed. # Run after any #[onto_api] annotation is added or changed.
# The file is read by the ontoref UI when this project is registered as a # The file is read by the ontoref UI when this project is registered as a
# non-primary slug — consumer projects that run as separate binaries use this # non-primary slug — consumer projects that run as separate binaries use this
# to expose their API surface in the ontoref UI. # to expose their API surface in the ontoref UI.
[doc("Export #[onto_api] routes to api-catalog.json")] [doc("Export #[onto_api] routes to artifacts/api-catalog-ontoref-daemon.ncl")]
export-api-catalog: export-api-catalog:
cargo run -p ontoref-daemon --no-default-features -- --dump-api-catalog > api-catalog.json mkdir -p artifacts
@echo "exported $(cat api-catalog.json | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') routes to api-catalog.json" cargo run -p ontoref-daemon --no-default-features -- --dump-api-catalog > artifacts/api-catalog-ontoref-daemon.ncl
@echo "exported routes to artifacts/api-catalog-ontoref-daemon.ncl"
# Download/update all vendored frontend JS dependencies # Download/update all vendored frontend JS dependencies
[doc("Vendor all frontend JS dependencies")] [doc("Vendor all frontend JS dependencies")]
vendor-js: vendor-cytoscape-navigator vendor-js: vendor-cytoscape-navigator vendor-htmx
# cytoscape-navigator — minimap extension for Cytoscape.js # cytoscape-navigator — minimap extension for Cytoscape.js
[doc("Vendor cytoscape-navigator (minimap)")] [doc("Vendor cytoscape-navigator (minimap)")]
@ -29,3 +31,12 @@ vendor-cytoscape-navigator:
"https://cdn.jsdelivr.net/npm/cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}/cytoscape-navigator.js" \ "https://cdn.jsdelivr.net/npm/cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}/cytoscape-navigator.js" \
-o assets/vendor/cytoscape-navigator.js -o assets/vendor/cytoscape-navigator.js
@echo "vendored cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}" @echo "vendored cytoscape-navigator@{{CYTOSCAPE_NAVIGATOR_VERSION}}"
# htmx — HTML-first hypermedia library (goes to public/vendor — served via /public/)
[doc("Vendor htmx")]
vendor-htmx:
mkdir -p crates/ontoref-daemon/public/vendor
curl -fsSL \
"https://cdn.jsdelivr.net/npm/htmx.org@{{HTMX_VERSION}}/dist/htmx.min.js" \
-o crates/ontoref-daemon/public/vendor/htmx.min.js
@echo "vendored htmx@{{HTMX_VERSION}}"

View File

@ -1,4 +1,4 @@
let s = import "backlog" in let s = import "schemas/backlog.ncl" in
{ {
items = [ items = [

View File

@ -174,6 +174,8 @@ def "main help" [...args: string] {
fmt-cmd $"($cmd) config-setup" "validate config.ncl schema and probe external services" fmt-cmd $"($cmd) config-setup" "validate config.ncl schema and probe external services"
fmt-cmd $"($cmd) describe diff [--file]" "semantic diff of ontology vs HEAD (nodes/edges added/removed/changed)" fmt-cmd $"($cmd) describe diff [--file]" "semantic diff of ontology vs HEAD (nodes/edges added/removed/changed)"
fmt-cmd $"($cmd) describe api [--actor] [--tag]" "annotated API surface grouped by tag (requires daemon)" fmt-cmd $"($cmd) describe api [--actor] [--tag]" "annotated API surface grouped by tag (requires daemon)"
fmt-cmd $"($cmd) describe state [id]" "FSM dimensions — current/desired state + transitions"
fmt-cmd $"($cmd) describe workspace" "workspace crates + intra-workspace dependency graph"
fmt-cmd $"($cmd) run update_ontoref" "bring project up to current protocol version (adds manifest.ncl, connections.ncl)" fmt-cmd $"($cmd) run update_ontoref" "bring project up to current protocol version (adds manifest.ncl, connections.ncl)"
print "" print ""
@ -184,7 +186,7 @@ def "main help" [...args: string] {
print $" (ansi cyan)mf(ansi reset) → manifest (ansi cyan)dg(ansi reset) → diagram (ansi cyan)md(ansi reset) → mode (ansi cyan)st(ansi reset) → status" print $" (ansi cyan)mf(ansi reset) → manifest (ansi cyan)dg(ansi reset) → diagram (ansi cyan)md(ansi reset) → mode (ansi cyan)st(ansi reset) → status"
print $" (ansi cyan)fm(ansi reset) → form (ansi cyan)s(ansi reset) → search (ansi cyan)ru(ansi reset) → run \(mode\) (ansi cyan)sv(ansi reset) → services" print $" (ansi cyan)fm(ansi reset) → form (ansi cyan)s(ansi reset) → search (ansi cyan)ru(ansi reset) → run \(mode\) (ansi cyan)sv(ansi reset) → services"
print $" (ansi cyan)nv(ansi reset) → nats (ansi cyan)q(ansi reset) → qa query (ansi cyan)f(ansi reset) → search \(alias\) (ansi cyan)df(ansi reset) → describe diff" print $" (ansi cyan)nv(ansi reset) → nats (ansi cyan)q(ansi reset) → qa query (ansi cyan)f(ansi reset) → search \(alias\) (ansi cyan)df(ansi reset) → describe diff"
print $" (ansi cyan)da(ansi reset) → describe api" print $" (ansi cyan)da(ansi reset) → describe api (ansi cyan)dst(ansi reset) → describe state (ansi cyan)dws(ansi reset) → describe workspace"
print "" print ""
print $" (ansi dark_gray)Tip: any group accepts(ansi reset) (ansi cyan)h(ansi reset) (ansi dark_gray)for help,(ansi reset) (ansi cyan)?(ansi reset) (ansi dark_gray)for interactive selector, or bare for picker(ansi reset)" print $" (ansi dark_gray)Tip: any group accepts(ansi reset) (ansi cyan)h(ansi reset) (ansi dark_gray)for help,(ansi reset) (ansi cyan)?(ansi reset) (ansi dark_gray)for interactive selector, or bare for picker(ansi reset)"
print $" (ansi dark_gray)Any command:(ansi reset) (ansi cyan)--fmt|-f(ansi reset) (ansi dark_gray)text*|json|yaml|toml|md(ansi reset) · (ansi cyan)--clip(ansi reset) (ansi dark_gray)copy output to clipboard(ansi reset)" print $" (ansi dark_gray)Any command:(ansi reset) (ansi cyan)--fmt|-f(ansi reset) (ansi dark_gray)text*|json|yaml|toml|md(ansi reset) · (ansi cyan)--clip(ansi reset) (ansi dark_gray)copy output to clipboard(ansi reset)"
@ -564,6 +566,22 @@ def "main describe api" [--actor: string = "", --tag: string = "", --auth: strin
describe api --actor $actor --tag $tag --auth $auth --fmt $f describe api --actor $actor --tag $tag --auth $auth --fmt $f
} }
def "main describe state" [id?: string, --fmt (-f): string = "", --actor: string = ""] {
log-action "describe state" "read"
let f = (resolve-fmt $fmt [text json])
if ($id | is-empty) or ($id == "") {
describe state --fmt $f --actor $actor
} else {
describe state $id --fmt $f --actor $actor
}
}
def "main describe workspace" [--fmt (-f): string = "", --actor: string = ""] {
log-action "describe workspace" "read"
let f = (resolve-fmt $fmt [text json])
describe workspace --fmt $f --actor $actor
}
# ── Diagram ─────────────────────────────────────────────────────────────────── # ── Diagram ───────────────────────────────────────────────────────────────────
def "main diagram" [] { def "main diagram" [] {
@ -720,9 +738,15 @@ def "main d extensions" [--fmt (-f): string = "", --actor: string = "", --dump:
def "main d ext" [--fmt (-f): string = "", --actor: string = "", --dump: string = "", --clip] { main describe extensions --fmt $fmt --actor $actor --dump $dump --clip=$clip } def "main d ext" [--fmt (-f): string = "", --actor: string = "", --dump: string = "", --clip] { main describe extensions --fmt $fmt --actor $actor --dump $dump --clip=$clip }
def "main d diff" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file } def "main d diff" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file }
def "main d api" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt } def "main d api" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt }
def "main d state" [id?: string, --fmt (-f): string = "", --actor: string = ""] { main describe state $id --fmt $fmt --actor $actor }
def "main d st" [id?: string, --fmt (-f): string = "", --actor: string = ""] { main describe state $id --fmt $fmt --actor $actor }
def "main d workspace" [--fmt (-f): string = "", --actor: string = ""] { main describe workspace --fmt $fmt --actor $actor }
def "main d ws" [--fmt (-f): string = "", --actor: string = ""] { main describe workspace --fmt $fmt --actor $actor }
def "main df" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file } def "main df" [--fmt (-f): string = "", --file: string = ""] { main describe diff --fmt $fmt --file $file }
def "main da" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt } def "main da" [--actor: string = "", --tag: string = "", --auth: string = "", --fmt (-f): string = ""] { main describe api --actor $actor --tag $tag --auth $auth --fmt $fmt }
def "main dst" [id?: string, --fmt (-f): string = "", --actor: string = ""] { main describe state $id --fmt $fmt --actor $actor }
def "main dws" [--fmt (-f): string = "", --actor: string = ""] { main describe workspace --fmt $fmt --actor $actor }
def "main bkl" [action?: string] { main backlog $action } def "main bkl" [action?: string] { main backlog $action }
def "main bkl help" [] { help-group "backlog" } def "main bkl help" [] { help-group "backlog" }

View File

@ -0,0 +1,30 @@
{
id = "0007",
slug = "card-repo-field",
description = "Add repo field to card.ncl for UI file-path navigation to the project repository",
check = {
tag = "NuCmd",
cmd = "let f = $\"($env.ONTOREF_PROJECT_ROOT)/card.ncl\"; if not ($f | path exists) { exit 0 }; let r = (do { ^rg -q 'repo\\s*=' $f } | complete); if $r.exit_code == 0 { exit 0 } else { exit 1 }",
expect_exit = 0,
},
instructions = "
Open card.ncl and add a repo field pointing to the project source repository.
The field must be a full URL (Gitea, GitHub, Sourcehut, etc.).
Example:
repo = \"https://repo.example.com/owner/project\",
The daemon injects this value as card_repo into every Tera template.
srcOpen() in graph, search, and api_catalog pages uses it to build the URL:
{repo}/src/branch/main/{path}
If the project also publishes cargo docs, add:
docs = \"https://docs.example.com/project\",
That value is injected as card_docs. For .rs file paths, srcOpen() opens
docs instead of the source file (cargo doc output is more useful than raw source).
Verify card.ncl still exports cleanly:
nickel export --import-path \"$NICKEL_IMPORT_PATH\" card.ncl > /dev/null && echo ok
",
}

View File

@ -163,6 +163,82 @@ export def "backlog roadmap" [] {
print "" print ""
} }
# Propose a status change for a backlog item (requires admin approval).
# Sends a backlog_review notification to the daemon. An admin approves or
# rejects it via the UI notifications page or via `backlog approve`.
export def "backlog propose-status" [
id: string, # Item id (e.g. bl-001)
status: string, # Proposed status: Open | InProgress | Done | Cancelled
--by: string = "", # Actor label shown in the notification
--slug: string = "", # Project slug (defaults to primary)
] {
if not (daemon-available) {
print " error: daemon unavailable — cannot propose status"
return
}
let url = $"(daemon-url)/backlog/propose-status"
let body = {
id: $id,
proposed_status: $status,
proposed_by: $by,
slug: (if $slug == "" { null } else { $slug }),
} | to json
let auth = (bearer-args)
let r = do { ^curl -sf -X POST -H "Content-Type: application/json" ...$auth -d $body $url } | complete
if $r.exit_code != 0 {
print $" error: propose-status failed\n ($r.stderr)"
return
}
let resp = ($r.stdout | from json)
print $" ⏳ proposed ($resp.item_id) → ($resp.proposed_status) — awaiting admin approval"
}
# Approve a pending backlog_review notification by notification id.
# Equivalent to clicking "Approve" in the UI notifications page.
export def "backlog approve" [
notif_id: int, # Notification id (from `backlog pending` or the UI)
--slug: string = "", # Project slug (defaults to primary)
] {
if not (daemon-available) {
print " error: daemon unavailable"
return
}
let s = if $slug == "" { (daemon-url | str replace "//" "//x" | split row "/" | get 0) } else { $slug }
# Resolve slug from config if not provided
let root = ($env.ONTOREF_PROJECT_ROOT? | default $env.ONTOREF_ROOT)
let resolved_slug = if $slug == "" {
let cfg_path = $"($root)/.ontoref/config.ncl"
if ($cfg_path | path exists) {
do { ^nickel export $cfg_path } | complete
| get stdout | from json | get slug? | default "ontoref"
} else { "ontoref" }
} else { $slug }
let url = $"(daemon-url | str replace --regex '/$' '')/ui/($resolved_slug)/notifications/($notif_id)/action"
let auth = (bearer-args)
let r = do { ^curl -sf -X POST -H "Content-Type: application/x-www-form-urlencoded" ...$auth -d "action_id=approve" $url } | complete
if $r.exit_code != 0 {
print $" error: approve failed\n ($r.stderr)"
return
}
print $" ✓ notification ($notif_id) approved"
}
# List pending backlog_review notifications awaiting approval.
export def "backlog pending" [
--slug: string = "",
] {
if not (daemon-available) { return [] }
let url = $"(daemon-url)/notifications/pending"
let auth = (bearer-args)
let r = do { ^curl -sf ...$auth $url } | complete
if $r.exit_code != 0 { return [] }
let all = ($r.stdout | from json | get notifications? | default [])
$all | where { |n| ($n.custom_kind? | default "") == "backlog_review" }
| select id custom_title timestamp? source_actor?
| rename id title timestamp actor
}
# ── Internal ──────────────────────────────────────────────────────────────────── # ── Internal ────────────────────────────────────────────────────────────────────
def backlog-root []: nothing -> string { def backlog-root []: nothing -> string {

View File

@ -1427,6 +1427,171 @@ def render-api-text [data: record]: nothing -> nothing {
} }
} }
# ── describe state ──────────────────────────────────────────────────────────────
# "What FSM dimensions exist and where are they currently?"
# Reads .ontology/state.ncl and prints each dimension with current/desired state,
# horizon, and whether the desired state has been reached.
export def "describe state" [
id?: string, # Dimension id to detail with transitions (omit for list)
--fmt: string = "", # Output format: text* | json
--actor: string = "",
]: nothing -> nothing {
let root = (project-root)
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
let dims = (collect-dimensions $root)
if ($dims | is-empty) {
print " No .ontology/state.ncl found or no dimensions declared."
return
}
if ($id | is-empty) or ($id == "") {
let data = { dimensions: $dims }
emit-output $data $f { ||
print $"(ansi white_bold)FSM Dimensions(ansi reset) ($dims | length) total"
print ""
for d in $dims {
let reached = $d.reached? | default false
let status = if $reached {
$"(ansi green)✓ reached(ansi reset)"
} else {
$"(ansi yellow)→ in progress(ansi reset)"
}
print $" (ansi white_bold)($d.id)(ansi reset) ($status)"
print $" (ansi cyan)($d.name)(ansi reset) horizon: ($d.horizon)"
print $" current: (ansi white)($d.current_state)(ansi reset) desired: (ansi dark_gray)($d.desired_state)(ansi reset)"
print ""
}
}
} else {
let ip = (nickel-import-path $root)
let state = (daemon-export-safe $"($root)/.ontology/state.ncl" --import-path $ip)
if $state == null {
print " Failed to export .ontology/state.ncl"
return
}
let target = ($state.dimensions? | default [] | where id == $id)
if ($target | is-empty) {
let avail = ($dims | get id | str join ", ")
print $" Dimension '($id)' not found. Available: ($avail)"
return
}
let dim = ($target | first)
let transitions = ($dim.transitions? | default [])
let data = {
id: $dim.id,
name: $dim.name,
description: ($dim.description? | default ""),
current_state: $dim.current_state,
desired_state: $dim.desired_state,
horizon: ($dim.horizon? | default ""),
reached: ($dim.current_state == $dim.desired_state),
coupled_with: ($dim.coupled_with? | default []),
transitions: ($transitions | each { |t| {
from: $t.from,
to: $t.to,
condition: ($t.condition? | default ""),
catalyst: ($t.catalyst? | default ""),
blocker: ($t.blocker? | default ""),
}}),
}
emit-output $data $f { ||
let reached = $data.reached
print $"(ansi white_bold)($data.id)(ansi reset)"
print $" (ansi cyan)($data.name)(ansi reset) horizon: ($data.horizon)"
print $" ($data.description)"
print ""
print $" current : (ansi white_bold)($data.current_state)(ansi reset)"
let status_badge = if $reached { $"(ansi green)✓ reached(ansi reset)" } else { $"(ansi yellow)in progress(ansi reset)" }
print $" desired : ($data.desired_state) ($status_badge)"
if ($data.coupled_with | is-not-empty) {
print $" coupled : ($data.coupled_with | str join ', ')"
}
if ($data.transitions | is-not-empty) {
print ""
print $" (ansi white_bold)Transitions(ansi reset)"
for t in $data.transitions {
let marker = if $t.from == $data.current_state { $"(ansi green)▶(ansi reset)" } else { " " }
print $" ($marker) ($t.from) → ($t.to)"
if ($t.condition | is-not-empty) { print $" condition : ($t.condition)" }
if ($t.catalyst | is-not-empty) { print $" catalyst : ($t.catalyst)" }
if ($t.blocker | is-not-empty) and ($t.blocker != "none") {
print $" (ansi yellow)blocker : ($t.blocker)(ansi reset)"
}
}
}
}
}
}
# ── describe workspace ───────────────────────────────────────────────────────────
# "What crates are in this workspace and how do they depend on each other?"
# Reads workspace Cargo.toml + member manifests. Shows only intra-workspace deps —
# external crate dependencies are omitted to keep the output focused.
export def "describe workspace" [
--fmt: string = "", # Output format: text* | json
--actor: string = "",
]: nothing -> nothing {
let root = (project-root)
let a = if ($actor | is-not-empty) { $actor } else { (actor-default) }
let f = if ($fmt | is-not-empty) { $fmt } else if $a == "agent" { "json" } else { "text" }
let ws_toml = $"($root)/Cargo.toml"
if not ($ws_toml | path exists) {
print " No Cargo.toml found at project root — not a Rust workspace."
return
}
let ws = (open $ws_toml)
let member_globs = ($ws | get -o workspace.members | default [])
if ($member_globs | is-empty) {
print " No workspace.members declared in Cargo.toml."
return
}
# Collect all member crate names + features.
mut crates = []
for mg in $member_globs {
let expanded = (glob $"($root)/($mg)/Cargo.toml")
for ct in $expanded {
let c = (open $ct)
let name = ($c | get -o package.name | default ($ct | path dirname | path basename))
let features = ($c | get -o features | default {} | columns)
let all_deps = ($c | get -o dependencies | default {})
$crates = ($crates | append [{ name: $name, features: $features, all_deps: $all_deps, path: ($ct | path dirname | path relative-to $root) }])
}
}
let crate_names = ($crates | get name)
# Build intra-workspace dep edges.
let crates_with_ws_deps = ($crates | each { |cr|
let dep_names = (try { $cr.all_deps | columns } catch { [] })
let ws_deps = ($dep_names | where { |d| $d in $crate_names })
{ name: $cr.name, features: $cr.features, path: $cr.path, depends_on: $ws_deps }
})
let data = { crates: $crates_with_ws_deps }
emit-output $data $f { ||
print $"(ansi white_bold)Workspace Crates(ansi reset) ($crates_with_ws_deps | length) members"
print ""
for cr in $crates_with_ws_deps {
print $" (ansi white_bold)($cr.name)(ansi reset) (ansi dark_gray)($cr.path)(ansi reset)"
if ($cr.features | is-not-empty) {
print $" features : ($cr.features | str join ', ')"
}
if ($cr.depends_on | is-not-empty) {
print $" deps : ($cr.depends_on | each { |d| $'(ansi cyan)($d)(ansi reset)' } | str join ', ')"
}
print ""
}
}
}
# ── describe diff ─────────────────────────────────────────────────────────────── # ── describe diff ───────────────────────────────────────────────────────────────
# "What changed in the ontology since the last commit?" # "What changed in the ontology since the last commit?"
# Compares the current working-tree core.ncl against the HEAD-committed version. # Compares the current working-tree core.ncl against the HEAD-committed version.
@ -1848,6 +2013,7 @@ def scan-ontoref-commands []: nothing -> list<string> {
"describe project", "describe capabilities", "describe constraints", "describe project", "describe capabilities", "describe constraints",
"describe tools", "describe features", "describe impact", "describe why", "describe tools", "describe features", "describe impact", "describe why",
"describe guides", "describe diff", "describe api", "describe guides", "describe diff", "describe api",
"describe state", "describe workspace",
] ]
} }

View File

@ -1,4 +1,4 @@
let s = import "qa" in let s = import "schemas/qa.ncl" in
{ {
entries = [], entries = [],

View File

@ -0,0 +1,36 @@
let method_type = [| 'GET, 'POST, 'PUT, 'DELETE, 'PATCH |] in
let auth_type = [| 'None, 'Viewer, 'Bearer, 'Admin |] in
let actor_type = [| 'Developer, 'Agent, 'Ci, 'Admin |] in
let param_type = {
name | String,
kind | String,
constraint | String | default = "optional",
description | String | default = "",
} in
let route_type = {
method | method_type,
path | String,
description | String | default = "",
auth | auth_type | default = 'None,
actors | Array actor_type | default = [],
params | Array param_type | default = [],
tags | Array String | default = [],
feature | String | default = "",
source_file | String | default = "",
} in
let catalog_type = {
crate | String,
routes | Array route_type,
} in
{
Method = method_type,
Auth = auth_type,
Actor = actor_type,
Param = param_type,
Route = route_type,
Catalog = catalog_type,
}