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
.coder
target
artifacts/
distribution
.qodo
# enviroment to load on bin/build

View File

@ -212,7 +212,7 @@ let d = import "../ontology/defaults/core.ncl" in
name = "Ontoref Daemon",
pole = 'Yang,
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,
artifact_paths = [
"crates/ontoref-daemon/",

View File

@ -52,7 +52,7 @@ let d = import "../ontology/defaults/state.ncl" in
from = "modes-and-web-present",
to = "fully-self-described",
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",
horizon = 'Weeks,
},

View File

@ -7,6 +7,58 @@ ADRs referenced below live in `adrs/` as typed Nickel records.
## [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)
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`.
`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
`GET /api/adr/{id}`.
renders each ADR as a clickable link that opens the full ADR content via `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
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>`
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
`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
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,
source = 'Local,
url = "https://ontoref.jesusperez.pro",
repo = "https://repo.jesusperez.pro/jesus/ontoref",
started_at = "2025",
tags = ["nickel", "ontology", "governance", "protocol", "architecture"],
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
.route("/validate/adrs", get(validate_adrs))
.route("/adr/{id}", get(get_adr))
// File content (for graph UI artifact links)
.route("/file", get(get_file_content))
// Ontology extension endpoints
.route("/ontology", get(list_ontology_extensions))
.route("/ontology/{file}", get(get_ontology_extension))
// Graph endpoints (impact analysis + federation)
.route("/graph/impact", get(graph_impact))
.route("/graph/node/{id}", get(graph_node))
// Backlog JSON endpoint
// Backlog endpoints
.route("/backlog-json", get(backlog_json))
.route("/backlog/propose-status", post(backlog_propose_status))
// Q&A read endpoint
.route("/qa-json", get(qa_json))
// 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",
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();
if state.actors.touch(&token) {
StatusCode::NO_CONTENT
(StatusCode::NO_CONTENT, axum::Json(serde_json::Value::Null)).into_response()
} 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 ─────────────────────────────────────────────
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 ───────────────────────────────────────────────────────
#[ontoref_derive::onto_api(
@ -3563,7 +3756,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(touch_miss.status(), StatusCode::NOT_FOUND);
assert_eq!(touch_miss.status(), StatusCode::OK);
}
#[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]
async fn main() {
// 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 {
println!("{}", ontoref_ontology::api::dump_catalog_json());
println!(
"{}",
ontoref_ontology::api::dump_catalog_ncl("ontoref-daemon")
);
return;
}
@ -638,24 +669,8 @@ async fn main() {
// Initialize Tera template engine from the configured templates directory.
#[cfg(feature = "ui")]
let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> = {
if let Some(ref tdir) = cli.templates_dir {
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
}
};
let tera_instance: Option<Arc<tokio::sync::RwLock<tera::Tera>>> =
init_tera(cli.templates_dir.as_deref());
// Optional DB connection with health check
#[cfg(feature = "db")]

View File

@ -197,6 +197,10 @@ async fn search_modes(
// ── Detail HTML builders ─────────────────────────────────────────────────────
const VIEWABLE_EXTS: &[&str] = &[
"ncl", "toml", "rs", "nu", "md", "json", "html", "yaml", "yml",
];
fn node_html(n: &serde_json::Value) -> String {
let desc = str_field(n, "description");
let level = str_field(n, "level");
@ -210,6 +214,11 @@ fn node_html(n: &serde_json::Value) -> String {
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.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();
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("</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() {
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 {
h.push_str(&format!(
"<li class=\"text-base-content/60\">{}</li>",
esc(a)
));
let ext = a.rsplit('.').next().unwrap_or("");
if VIEWABLE_EXTS.contains(&ext) {
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>");
}

View File

@ -62,6 +62,73 @@ pub fn add_item(
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 ──────────────────────────────────────────────────────────────────
/// 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)
}
/// 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.
fn escape_ncl(s: &str) -> String {
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))
// Per-project routes — AuthUser extractor enforces auth per project
.route("/{slug}/", get(handlers::dashboard_mp))
.route("/{slug}/stats", get(handlers::dashboard_stats_mp))
.route("/{slug}/graph", get(handlers::graph_mp))
.route("/{slug}/sessions", get(handlers::sessions_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),
)
.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(
"/{slug}/notifications/{id}/action",
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}/config", get(handlers::config_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/run", post(handlers::actions_run_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" %}
<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 }}">
<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">

View File

@ -28,10 +28,10 @@
<span class="badge badge-lg badge-neutral">{{ route_count }} routes</span>
</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">
<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>
{% else %}
@ -52,38 +52,29 @@
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
<span id="filter-count" class="text-xs text-base-content/40 self-center hidden"></span>
</div>
<!-- Routes table -->
<div class="overflow-x-auto" id="routes-container">
<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>
<!-- Catalog accordions -->
<div id="catalogs-container"></div>
<!-- Route detail modal -->
<dialog id="route-modal" class="modal">
<div class="modal-box w-full max-w-2xl">
<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>
<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-path" class="font-mono text-base-content/60 text-sm break-all"></span>
</div>
<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">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50 mb-2">Parameters</h3>
<table class="table table-xs w-full">
@ -91,6 +82,11 @@
<tbody id="detail-params-body"></tbody>
</table>
</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">
<span>Feature: <code id="detail-feature" class="font-mono"></code></span>
<span id="detail-auth-wrap">Auth: <span id="detail-auth"></span></span>
@ -102,10 +98,36 @@
</dialog>
<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) {
return `method-${m.toLowerCase()}`;
return 'method-' + m.toLowerCase();
}
function authBadge(auth) {
@ -113,79 +135,163 @@ function authBadge(auth) {
none: 'badge badge-ghost badge-xs font-mono',
viewer: 'badge badge-info badge-xs font-mono',
admin: 'badge badge-error badge-xs font-mono',
}[auth] ?? 'badge badge-ghost badge-xs font-mono';
return `<span class="${cls}">${auth}</span>`;
}[auth] || 'badge badge-ghost badge-xs font-mono';
return '<span class="' + cls + '">' + auth + '</span>';
}
function actorBadges(actors) {
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) {
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) {
visibleRoutes = routes;
const tbody = document.getElementById('routes-body');
tbody.innerHTML = routes.map((r, i) => `
<tr class="hover cursor-pointer route-row" onclick="showDetail(${i})">
<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>
`).join('');
currentRoutes = routes;
const container = document.getElementById('catalogs-container');
const filterCount = document.getElementById('filter-count');
const isFiltered = routes.length < ALL_ROUTES.length;
if (isFiltered) {
filterCount.textContent = routes.length + ' match' + (routes.length !== 1 ? 'es' : '');
filterCount.classList.remove('hidden');
} else {
filterCount.classList.add('hidden');
}
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) {
const r = visibleRoutes[index];
function showDetail(idx) {
var r = currentRoutes[idx];
if (!r) return;
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-desc').textContent = r.description;
document.getElementById('detail-feature').textContent = r.feature || 'default';
document.getElementById('detail-auth').innerHTML = authBadge(r.auth);
const paramsDiv = document.getElementById('detail-params');
const tbody = document.getElementById('detail-params-body');
var catBadgeWrap = document.getElementById('detail-catalog-badge');
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) {
tbody.innerHTML = r.params.map(p => `
<tr>
<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/50">${p.constraint || ''}</td>
<td class="text-xs">${p.description || ''}</td>
</tr>
`).join('');
tbody.innerHTML = r.params.map(function(p) {
return '<tr>' +
'<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/50">' + (p.constraint || '') + '</td>' +
'<td class="text-xs">' + (p.description || '') + '</td>' +
'</tr>';
}).join('');
paramsDiv.classList.remove('hidden');
} else {
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.addEventListener('click', function(e) {
var btn = e.target.closest('#detail-source-btn');
if (btn && btn.dataset.path) { srcOpen(btn.dataset.path); }
});
function filterRoutes() {
const text = document.getElementById('filter-input').value.toLowerCase();
const auth = document.getElementById('filter-auth').value;
const method = document.getElementById('filter-method').value;
const filtered = ROUTES.filter(r => {
const textMatch = !text || r.path.toLowerCase().includes(text) || r.description.toLowerCase().includes(text);
const authMatch = !auth || r.auth === auth;
const methodMatch = !method || r.method === method;
var text = document.getElementById('filter-input').value.toLowerCase();
var auth = document.getElementById('filter-auth').value;
var method = document.getElementById('filter-method').value;
var filtered = ALL_ROUTES.filter(function(r) {
var textMatch = !text || r.path.toLowerCase().includes(text) || r.description.toLowerCase().includes(text);
var authMatch = !auth || r.auth === auth;
var methodMatch = !method || r.method === method;
return textMatch && authMatch && methodMatch;
});
renderRoutes(filtered);
}
renderRoutes(ROUTES);
renderRoutes(ALL_ROUTES);
</script>
{% endif %}
{% endblock content %}

View File

@ -54,7 +54,8 @@
</div>
</div>
<!-- Items table -->
<!-- Items table — always rendered so HTMX always has a swap target -->
<div id="backlog-items-container">
{% if items %}
<div class="overflow-x-auto rounded-lg border border-base-content/10">
<table class="table table-sm w-full">
@ -68,83 +69,7 @@
<th class="w-24 text-right">Actions</th>
</tr>
</thead>
<tbody id="backlog-tbody">
{% 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>
{% include "partials/backlog_tbody.html" %}
</table>
</div>
{% else %}
@ -152,6 +77,7 @@
No backlog items yet.
</div>
{% endif %}
</div>
{% endif %}
@ -180,7 +106,11 @@
<dialog id="add-modal" class="modal">
<div class="modal-box max-w-lg">
<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">
<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"
@ -222,10 +152,171 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</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 %}
{% block scripts %}
<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 ──────────────────────────────────────────────────────────────
let activeStatus = 'all';
let activePriority = 'all';

View File

@ -71,6 +71,25 @@
{% endif %}
</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 %}
<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">
@ -104,14 +123,8 @@
{% endif %}
</div>
<!-- Daemon stats -->
<div class="stats stats-horizontal shadow w-full mb-4 bg-base-200 overflow-x-auto">
{{ 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>
<!-- Daemon stats — polled every 10s via HTMX -->
{% include "partials/dashboard_stats.html" %}
<!-- Project status -->
{% if backlog %}

View File

@ -3,11 +3,26 @@
{% block title %}Ontology Graph — Ontoref{% endblock title %}
{% block nav_graph %}active{% endblock nav_graph %}
{% block nav_group_knowledge %}active{% endblock nav_group_knowledge %}
{% block main_class %}container mx-auto px-4 py-6{% endblock main_class %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
<script src="/assets/vendor/cytoscape-navigator.js"></script>
<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 {
display: flex;
height: calc(100vh - 148px);
@ -15,11 +30,6 @@
gap: 0;
user-select: none;
}
#graph-root:fullscreen,
#graph-root:-webkit-full-screen {
height: 100dvh;
background: oklch(var(--b1));
}
#cy-wrapper {
flex: 1 1 auto;
@ -172,6 +182,57 @@
<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 -->
<section>
<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>
</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>
</section>
@ -267,44 +349,77 @@
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</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 class="mb-2 flex flex-wrap items-center justify-between gap-2 text-sm">
<h1 class="text-xl font-bold">Ontology Graph</h1>
<div class="flex flex-wrap gap-1 items-center">
<!-- Level filters (toggle = hide that level) -->
<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 class="w-px h-4 bg-base-content/20 mx-1"></div>
<!-- Pole filters -->
<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>
<div class="w-px h-4 bg-base-content/20 mx-1"></div>
<!-- Layout -->
<div id="graph-fullscreen-wrapper">
<!-- Toolbar row 1: title + inline node search + layout controls -->
<div class="flex items-center justify-between mb-1 gap-2">
<h1 class="text-lg font-bold shrink-0">Ontology Graph</h1>
<div class="relative flex-1 max-w-xs min-w-0" id="search-wrap">
<input id="graph-search" type="search" placeholder="Search nodes…"
class="input input-bordered input-xs w-full pr-14 font-mono text-xs"
autocomplete="off" autocorrect="off" spellcheck="false">
<span class="absolute right-2 top-1.5 text-xs text-base-content/30 pointer-events-none select-none"
id="search-count"></span>
<ul id="search-dropdown"
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">
</ul>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="join">
<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>
</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>
</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 -->
<div id="graph-root">
<div id="cy-wrapper" class="bg-base-200">
@ -326,30 +441,66 @@
<div id="resize-handle"></div>
<div id="detail-panel" class="bg-base-200 p-4 hidden">
<div class="flex justify-between items-start mb-2">
<h3 class="font-bold text-base leading-tight" id="d-name"></h3>
<button id="btn-close-panel" class="btn btn-xs btn-ghost ml-2 flex-shrink-0"></button>
<!-- Panel nav header -->
<div class="flex items-center gap-0.5 mb-2">
<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 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>
<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>
<!-- Node detail view -->
<div id="d-node-view">
<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>
<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 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">
<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>
<!-- Content view: file or ADR rendered in-panel -->
<div id="d-content-view" class="hidden">
<p class="text-xs text-base-content/40 mb-3 font-mono break-all leading-relaxed" id="d-content-subtitle"></p>
<div id="d-content-body" class="text-sm space-y-3"></div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
<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 }};
// ── Icons ─────────────────────────────────────────────────────
@ -361,13 +512,19 @@ document.getElementById('cy-fit').innerHTML = SVG_FIT;
document.getElementById('cy-fullscreen').innerHTML = SVG_FS_ENTER;
// ── 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 = {
Axiom: "diamond",
Tension: "ellipse",
Practice: "round-rectangle",
Project: "hexagon",
Moment: "triangle",
Axiom: "diamond",
Tension: "ellipse",
Practice: "round-rectangle",
Project: "hexagon",
Moment: "triangle",
Crate: "round-hexagon",
Route: "tag",
Dimension: "hexagon",
WorkspaceCrate: "barrel",
ConfigSection: "cut-rectangle",
Requirement: "round-tag",
};
const EDGE_STYLE = {
ManifestsIn: { color: "#6b7280" },
@ -382,6 +539,7 @@ const EDGE_STYLE = {
SpiralsWith: { color: "#8b5cf6" },
LimitedBy: { color: "#f43f5e" },
ValidatedBy: { color: "#84cc16" },
CoupledWith: { color: "#ec4899", dashed: true },
};
const nodes = (GRAPH.nodes || []).map(n => ({
@ -396,6 +554,26 @@ const nodes = (GRAPH.nodes || []).map(n => ({
adrs: n.adrs || [],
color: POLE_COLOR[n.pole] || "#6b7280",
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",
}
},
{
selector: "node.search-dim",
style: { "opacity": 0.12 }
},
{
selector: "node.search-match",
style: { "border-color": "#ffffff", "border-width": 3, "opacity": 1 }
},
],
layout: buildBfsLayout(false),
wheelSensitivity: 0.3,
@ -592,8 +778,8 @@ document.getElementById('cy-fit').addEventListener('click', () => {
});
// ── Full-screen ───────────────────────────────────────────────
const graphRoot = document.getElementById('graph-root');
const btnFullscreen = document.getElementById('cy-fullscreen');
const graphFsWrapper = document.getElementById('graph-fullscreen-wrapper');
const btnFullscreen = document.getElementById('cy-fullscreen');
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement);
@ -601,8 +787,8 @@ function isFullscreen() {
btnFullscreen.addEventListener('click', () => {
if (!isFullscreen()) {
const req = graphRoot.requestFullscreen || graphRoot.webkitRequestFullscreen;
if (req) req.call(graphRoot);
const req = graphFsWrapper.requestFullscreen || graphFsWrapper.webkitRequestFullscreen;
if (req) req.call(graphFsWrapper);
} else {
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) exit.call(document);
@ -620,8 +806,15 @@ document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
// ── Filters ───────────────────────────────────────────────────
const hiddenLevels = new Set();
const hiddenLevels = new Set(["Crate", "Route", "Dimension", "WorkspaceCrate", "ConfigSection", "Requirement"]);
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() {
cy.nodes().forEach(n => {
@ -695,28 +888,94 @@ document.getElementById("btn-reset").addEventListener("click", () => {
closePanel();
});
// ── Node detail panel ─────────────────────────────────────────
const panel = document.getElementById("detail-panel");
const dName = document.getElementById("d-name");
const dBadges = document.getElementById("d-badges");
const dDesc = document.getElementById("d-description");
const dArtifacts = document.getElementById("d-artifacts");
const dList = document.getElementById("d-artifact-list");
const dAdrs = document.getElementById("d-adrs");
const dAdrList = document.getElementById("d-adr-list");
const dEdges = document.getElementById("d-edges");
const dEdgeList = document.getElementById("d-edge-list");
// ── Panel refs ────────────────────────────────────────────────
const panel = document.getElementById("detail-panel");
const dName = document.getElementById("d-name");
const dBadges = document.getElementById("d-badges");
const dDesc = document.getElementById("d-description");
const dArtifacts = document.getElementById("d-artifacts");
const dList = document.getElementById("d-artifact-list");
const dAdrs = document.getElementById("d-adrs");
const dAdrList = document.getElementById("d-adr-list");
const dEdges = document.getElementById("d-edges");
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() {
panel.classList.add("hidden");
cy.elements().removeClass("faded highlighted");
panelNav.reset();
}
cy.on("tap", "node", evt => {
const node = evt.target;
const d = node.data();
// ── Open node in panel ────────────────────────────────────────
function openNode(id, { push = true, animate = true } = {}) {
const node = cy.getElementById(id);
if (!node.length) return;
const d = node.data();
panel.classList.remove("hidden");
showNodeView();
dName.textContent = d.label;
dBadges.innerHTML =
@ -728,9 +987,15 @@ cy.on("tap", "node", evt => {
if (d.artifact_paths.length) {
dArtifacts.classList.remove("hidden");
dList.innerHTML = d.artifact_paths.map(p =>
`<li class="break-all"><code>${p}</code></li>`
).join("");
dList.innerHTML = d.artifact_paths.map(p => {
const ext = p.split(".").pop();
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 {
dArtifacts.classList.add("hidden");
}
@ -739,7 +1004,7 @@ cy.on("tap", "node", evt => {
dAdrs.classList.remove("hidden");
dAdrList.innerHTML = d.adrs.map(a =>
`<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("");
} else {
dAdrs.classList.add("hidden");
@ -749,33 +1014,39 @@ cy.on("tap", "node", evt => {
if (conn.length) {
dEdges.classList.remove("hidden");
dEdgeList.innerHTML = conn.map(e => {
const isSrc = e.data("source") === d.id;
const other = isSrc
? cy.getElementById(e.data("target")).data("label")
: cy.getElementById(e.data("source")).data("label");
const arrow = isSrc ? "→" : "←";
return `<li class="flex gap-1"><span class="opacity-40 flex-shrink-0">${arrow}</span>` +
`<span class="text-base-content/80 flex-shrink-0">${e.data("kind")}</span>` +
`<span class="opacity-60 break-all">${other}</span></li>`;
const isSrc = e.data("source") === d.id;
const otherId = isSrc ? e.data("target") : e.data("source");
const other = cy.getElementById(otherId);
const otherLbl = other.data("label") || otherId;
const arrow = isSrc ? "→" : "←";
return `<li class="flex gap-1 items-baseline">` +
`<span class="opacity-40 flex-shrink-0">${arrow}</span>` +
`<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("");
} else {
dEdges.classList.add("hidden");
}
// Dim non-neighbours
renderNodeExtra(d);
cy.elements().addClass("faded").removeClass("highlighted");
node.closedNeighborhood().removeClass("faded").addClass("highlighted");
// Animate center + zoom to selected node
cy.animate(
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
{ duration: 350, easing: 'ease-in-out-cubic' }
);
});
if (animate) {
cy.animate(
{ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.2) },
{ duration: 350, easing: "ease-in-out-cubic" }
);
}
cy.on("tap", evt => {
if (evt.target === cy) closePanel();
});
if (push) panelNav.push({ type: "node", id });
}
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);
@ -792,6 +1063,8 @@ handle.addEventListener("mousedown", e => {
e.preventDefault();
});
const graphRoot = document.getElementById("graph-root");
document.addEventListener("mousemove", e => {
if (!resizing) return;
const rect = graphRoot.getBoundingClientRect();
@ -814,63 +1087,241 @@ document.addEventListener("mouseup", () => {
document.body.style.cursor = "";
});
// ── ADR modal ─────────────────────────────────────────────────
const adrModal = document.getElementById("adr-modal");
const adrModalTitle = document.getElementById("adr-modal-title");
const adrModalBody = document.getElementById("adr-modal-body");
const GRAPH_SLUG = document.getElementById("graph-slug").value || null;
// ── Shared helpers ────────────────────────────────────────────
function esc(s) { return String(s ?? "").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
function renderAdrBody(data) {
if (data.error) {
return `<p class="text-error">${data.error}</p>`;
}
const rows = Object.entries(data)
.filter(([k]) => k !== "id")
function renderKvSection(data, skipKeys = []) {
return Object.entries(data)
.filter(([k, v]) => !skipKeys.includes(k) && 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">${v.map(item =>
val = `<ul class="list-disc pl-4 space-y-0.5 text-base-content/70">${v.map(item =>
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>`
).join("")}</ul>`;
} 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>`;
} 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">${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)
.join("");
return rows || `<p class="text-base-content/50">No details available.</p>`;
}
async function fetchAdr(id) {
adrModalTitle.textContent = id;
adrModalBody.innerHTML = `<span class="loading loading-spinner loading-sm"></span>`;
adrModal.showModal();
// ── Open ADR in panel ─────────────────────────────────────────
async function openAdr(id, { push = true } = {}) {
panel.classList.remove("hidden");
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 {
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${slug}`);
const res = await fetch(`/api/adr/${encodeURIComponent(id)}?${params}`);
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) {
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 => {
const btn = e.target.closest(".adr-link");
if (btn) fetchAdr(btn.dataset.adr);
// Close search dropdown on outside click
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 ────────────────────────────────────────
document.addEventListener('keydown', e => {
// Skip when focus is inside an input, textarea, or dialog
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (document.querySelector('dialog[open]')) return;

View File

@ -17,84 +17,13 @@
</div>
</div>
{% if error %}
<div 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>{{ error }}</span>
<div id="manage-error" class="{% if error %}alert alert-error mb-4 text-sm{% endif %}">
{% if error %}<span>{{ error }}</span>{% endif %}
</div>
{% endif %}
<!-- Registered Projects -->
<div class="mb-6">
<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 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 class="mb-6" id="manage-projects-section">
{% include "partials/manage_projects_section.html" %}
</div>
<!-- Add Project Form -->
@ -102,7 +31,11 @@
<div class="card-body p-5">
<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>
<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">
<label class="label py-1">
<span class="label-text text-xs text-base-content/60">Absolute root path</span>

View File

@ -64,13 +64,20 @@
<!-- DAG action buttons -->
<div class="flex flex-wrap gap-1 mt-1.5">
{% 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 }}">
<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
{% 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 }}
</button>
</form>
@ -79,7 +86,7 @@
{% else %}
<details class="mt-1">
<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>
{% endif %}
{% endif %}
@ -107,7 +114,10 @@
<dialog id="emit-modal" class="modal">
<div class="modal-box max-w-lg">
<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">
<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>

View File

@ -79,7 +79,7 @@
<div class="flex items-center gap-0.5 flex-shrink-0">
{% if p.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">
<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"/>
@ -87,21 +87,21 @@
</button>
{% endif %}
<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">
<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"/>
</svg>
</a>
<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">
<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"/>
</svg>
</a>
<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">
<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"/>
@ -178,11 +178,18 @@
{% if p.op_modes %}
<span class="badge badge-sm badge-ghost">{{ p.op_modes | length }} modes</span>
{% endif %}
<span id="mig-badge-{{ p.slug }}" class="badge badge-sm badge-warning hidden"></span>
</div>
<!-- Accordion panels -->
<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 %}
<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">

View File

@ -77,9 +77,15 @@
<!-- 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>
<!-- Right: detail panel -->
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-y-auto p-5 hidden min-w-[200px]">
<!-- filled by JS -->
<!-- Right: detail panel with nav bar -->
<div id="detail-panel" class="flex-1 bg-base-200 rounded-r-lg overflow-hidden flex flex-col hidden min-w-[200px]">
<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>
<!-- Empty right side when nothing selected -->
@ -103,14 +109,136 @@ const input = document.getElementById('search-input');
const resultsList = document.getElementById('results-list');
const resultsCount = document.getElementById('results-count');
const detail = document.getElementById('detail-panel');
const dpContent = document.getElementById('dp-content');
const detailEmpty = document.getElementById('detail-empty');
const resetBtn = document.getElementById('btn-reset-search');
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 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 searchTimer = null;
@ -231,7 +359,7 @@ function renderBookmarks() {
<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="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">
<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"/>
@ -276,9 +404,8 @@ function renderBookmarks() {
}
function showDetailBm(bm) {
detail.classList.remove('hidden');
detailEmpty.classList.add('hidden');
detail.innerHTML = `
showPanel();
dpContent.innerHTML = `
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<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>` : ''}
</div>
</div>
<button id="bm-detail-star" class="btn btn-ghost btn-xs btn-circle text-warning"
title="Remove bookmark">
<button id="bm-detail-star" class="btn btn-ghost btn-xs text-warning" title="Remove bookmark">
<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"/>
</svg>
@ -300,8 +426,7 @@ function showDetailBm(bm) {
`;
document.getElementById('bm-detail-star').addEventListener('click', async () => {
await toggleBookmark({ id: bm.node_id, kind: bm.kind, title: bm.title, level: bm.level });
detail.classList.add('hidden');
detailEmpty.classList.remove('hidden');
hidePanel();
});
}
@ -379,12 +504,19 @@ function renderResults() {
<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>
<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'}"
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 class="flex items-center gap-0.5 flex-shrink-0 mt-0.5">
${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="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 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>
</li>`;
}).join('');
@ -422,12 +554,11 @@ async function copyResultToClipboard(r, btn) {
} catch (_) {}
}
function showDetail(idx) {
const r = results[idx];
function _showResultContent(r, push) {
const starred = isBookmarked(r);
detail.classList.remove('hidden');
detailEmpty.classList.add('hidden');
detail.innerHTML = `
showPanel();
const canView = r.path && /\.(ncl|toml|rs|nu|md|json|html|yaml|yml)$/.test(r.path);
dpContent.innerHTML = `
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h2 class="font-bold text-base leading-tight">${esc(r.title)}</h2>
@ -438,14 +569,18 @@ function showDetail(idx) {
</div>
</div>
<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"
title="Copy to clipboard">
${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">
<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">
<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"/>
</svg>
</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'}">
<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"/>
@ -453,7 +588,11 @@ function showDetail(idx) {
</button>
</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>
`;
document.getElementById('detail-copy').addEventListener('click', async e => {
@ -461,17 +600,22 @@ function showDetail(idx) {
});
document.getElementById('detail-star').addEventListener('click', async () => {
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() {
results = [];
resultsList.innerHTML = '';
resultsCount.classList.add('hidden');
detail.classList.add('hidden');
detail.innerHTML = '';
detailEmpty.classList.remove('hidden');
hidePanel();
selectedItem = null;
}
function reset() { input.value = ''; saveQuery(''); clearResults(); input.focus(); }
@ -502,7 +646,12 @@ document.addEventListener('mouseup', () => {
// ── Helpers ────────────────────────────────────────────────────────────────
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;'); }
// ── 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),*],
tags: &[#(#tag_lits),*],
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],
/// Non-empty when the endpoint is only compiled under a feature flag.
pub feature: &'static str,
/// Source file path (relative to workspace root) captured via `file!()`.
pub source_file: &'static str,
}
inventory::collect!(ApiRouteEntry);
/// Serialize all statically-registered [`ApiRouteEntry`] items to a
/// 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 {
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)));
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
CYTOSCAPE_NAVIGATOR_VERSION := "2.0.1"
HTMX_VERSION := "2.0.7"
# Export this daemon's API catalog to api-catalog.json.
# 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
# non-primary slug — consumer projects that run as separate binaries use this
# 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:
cargo run -p ontoref-daemon --no-default-features -- --dump-api-catalog > api-catalog.json
@echo "exported $(cat api-catalog.json | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))') routes to api-catalog.json"
mkdir -p artifacts
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
[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
[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" \
-o assets/vendor/cytoscape-navigator.js
@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 = [

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) 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 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)"
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)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)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 $" (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)"
@ -564,6 +566,22 @@ def "main describe api" [--actor: string = "", --tag: string = "", --auth: strin
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 ───────────────────────────────────────────────────────────────────
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 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 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 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 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 ""
}
# 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 ────────────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────────
# "What changed in the ontology since the last commit?"
# 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 tools", "describe features", "describe impact", "describe why",
"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 = [],

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,
}