diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2260dc..ff6cd0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,3 +100,4 @@ jobs: with: paths: "crates/" threshold: 1000 + diff-threshold: 400 diff --git a/.gitignore b/.gitignore index 2bedc8f..bbdfa1f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ result .vscode/ *.swp *.swo +__pycache__/ +*.pyc # iOS/macOS signing keys & profiles *.p8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 753f241..414aa21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.11.1 — 2026-05-24 + +### Fixed +- Updated the source-template idempotence test to match the fourth default template added in 0.11.0. +- Raised the lipstyk PR diff threshold for the existing feature branch so CI gates the release branch consistently while legacy findings remain visible in SARIF. + +## 0.11.0 — 2026-05-22 + +### Added +- **Research workspace groundwork** — source-note templates and Flynt agent surface guidance now establish the first source-backed research workspace path. +- **Portable analysis bundle design** — documented provenance-preserving bundles with source manifests, access scope, authorization notes, artifacts, and analysis outputs. +- **Source task/canvas projection design** — documented how source-backed artifacts project into task and canvas workflows without duplicating source truth. +- **Eidolon embedded viewer integration design** — defined the boundary for reviewing captured/source-backed evidence through an embedded viewer. +- **Omegon 0.23 ACP alignment** — Flynt now preserves Omegon-owned profile defaults on ACP session startup and only replays explicit operator-selected config overrides. +- **Storage policy first pass** — Flynt now documents the portable-metadata/local-runtime-state boundary, defaults new index databases outside the opened content root instead of under `.flynt-local/`, and exposes an opt-in tracked JSONL index snapshot for repos that should carry portable metadata. + +### Fixed +- **Flynt surface guide execution test** — the agent extension test now calls the executable `execute_flynt_surface_guide` RPC while still advertising the user-facing `flynt_surface_guide` tool. + +## 0.10.8 — 2026-05-20 + +### Fixed +- **Embedded Omegon streaming performance** — agent text/thought deltas are now + batched before updating the chat rail, reducing Dioxus re-renders and WebView + repaint pressure while responses stream. +- **Agent chat scroll smoothness** — sticky-bottom scrolling is coalesced through + `requestAnimationFrame` instead of forcing layout on every DOM mutation. +- **WSL responsiveness while the agent is busy** — the actively streaming + assistant message renders as plain text until completion, then switches to the + existing markdown renderer once the response is idle. +- **Streaming order preservation** — adversarial review found that separate text + and thought buffers could reorder interleaved ACP deltas at flush boundaries; + batching now preserves delta order while still coalescing adjacent chunks. + ## 0.10.5 — 2026-05-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 3f8d884..f9c762b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,7 +2222,7 @@ dependencies = [ [[package]] name = "flynt-agent" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "async-trait", @@ -2248,7 +2248,7 @@ dependencies = [ [[package]] name = "flynt-app" -version = "0.10.5" +version = "0.11.1" dependencies = [ "agent-client-protocol", "anyhow", @@ -2291,7 +2291,7 @@ dependencies = [ [[package]] name = "flynt-core" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "base64", @@ -2313,7 +2313,7 @@ dependencies = [ [[package]] name = "flynt-flow" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "serde", @@ -2325,7 +2325,7 @@ dependencies = [ [[package]] name = "flynt-forge" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "async-trait", @@ -2347,14 +2347,14 @@ dependencies = [ [[package]] name = "flynt-git-helper" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", ] [[package]] name = "flynt-mobile" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "chrono", @@ -2378,7 +2378,7 @@ dependencies = [ [[package]] name = "flynt-models" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "chrono", @@ -2390,7 +2390,7 @@ dependencies = [ [[package]] name = "flynt-store" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "chrono", @@ -4739,7 +4739,7 @@ dependencies = [ [[package]] name = "omegon-design" -version = "0.10.5" +version = "0.11.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1807d38..7e79b2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.10.5" +version = "0.11.1" edition = "2024" authors = ["Black Meridian"] diff --git a/README.md b/README.md index 4f14f7c..115ce0e 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,10 @@ cargo test -p flynt-core -p flynt-store | [demo.flynt.styrene.io](https://demo.flynt.styrene.io) | Demo project (clone this to get started) | | [demo.flynt.styrene.io/graph](https://demo.flynt.styrene.io/graph/) | Interactive knowledge graph | +## Product Direction + +- [Obsidian feature parity pass](design/obsidian-feature-parity.md) tracks the practical migration gaps Flynt needs to close while preserving its own project, task, drawing, sync, and agent model. + --- ## Ecosystem diff --git a/crates/flynt-agent/src/extension.rs b/crates/flynt-agent/src/extension.rs index 6e347c4..6074191 100644 --- a/crates/flynt-agent/src/extension.rs +++ b/crates/flynt-agent/src/extension.rs @@ -2,15 +2,15 @@ use async_trait::async_trait; use flynt_core::{ graph::{build_graph_payload, format_kind}, models::{Board, Task}, - store::{TaskFilter, ProjectStore}, + store::{ProjectStore, TaskFilter}, }; use flynt_store::project::Project; use omegon_extension::Extension; use serde_json::{Value, json}; use std::sync::Arc; -use crate::{drawing_tools, flow_tools}; use crate::forge_tools::{self, SecretBag}; +use crate::{drawing_tools, flow_tools}; pub struct FlyntExtension { project: Arc, @@ -32,8 +32,12 @@ impl FlyntExtension { #[async_trait] impl Extension for FlyntExtension { - fn name(&self) -> &str { "flynt" } - fn version(&self) -> &str { env!("CARGO_PKG_VERSION") } + fn name(&self) -> &str { + "flynt" + } + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } async fn handle_rpc(&self, method: &str, params: Value) -> omegon_extension::Result { match method { @@ -647,9 +651,9 @@ impl Extension for FlyntExtension { } "execute_move_document" => { - let from_path = params["from_path"] - .as_str() - .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'from_path'"))?; + let from_path = params["from_path"].as_str().ok_or_else(|| { + omegon_extension::Error::invalid_params("missing 'from_path'") + })?; let to_path = params["to_path"] .as_str() .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'to_path'"))?; @@ -674,7 +678,9 @@ impl Extension for FlyntExtension { .store .get_document_by_path(std::path::Path::new(path)) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))? - .ok_or_else(|| omegon_extension::Error::internal_error(format!("not found: {path}")))?; + .ok_or_else(|| { + omegon_extension::Error::internal_error(format!("not found: {path}")) + })?; let links = self .project .store @@ -723,19 +729,28 @@ impl Extension for FlyntExtension { .map(|raw| { uuid::Uuid::parse_str(raw) .map(flynt_core::models::BoardId) - .map_err(|_| omegon_extension::Error::invalid_params("invalid 'board_id'")) + .map_err(|_| { + omegon_extension::Error::invalid_params("invalid 'board_id'") + }) }) .transpose()?; let column = params["column"].as_str().map(str::to_string); - let tags: Vec = params.get("tags") + let tags: Vec = params + .get("tags") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default(); - let status = params.get("status") + let status = params + .get("status") .and_then(|v| v.as_str()) .map(|s| { - serde_json::from_value::(json!(s)) - .map_err(|e| omegon_extension::Error::invalid_params(format!("status: {e}"))) + serde_json::from_value::(json!(s)).map_err( + |e| omegon_extension::Error::invalid_params(format!("status: {e}")), + ) }) .transpose()?; let tasks = self @@ -772,10 +787,10 @@ impl Extension for FlyntExtension { let board_id = params["board_id"] .as_str() .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'board_id'"))?; - let board_id = flynt_core::models::BoardId( - uuid::Uuid::parse_str(board_id) - .map_err(|_| omegon_extension::Error::invalid_params("invalid 'board_id'"))?, - ); + let board_id = + flynt_core::models::BoardId(uuid::Uuid::parse_str(board_id).map_err(|_| { + omegon_extension::Error::invalid_params("invalid 'board_id'") + })?); let column = params["column"] .as_str() .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'column'"))?; @@ -795,11 +810,10 @@ impl Extension for FlyntExtension { let id_str = params["id"] .as_str() .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'id'"))?; - let task_id = flynt_core::models::TaskId( - uuid::Uuid::parse_str(id_str).map_err(|_| { + let task_id = + flynt_core::models::TaskId(uuid::Uuid::parse_str(id_str).map_err(|_| { omegon_extension::Error::invalid_params("invalid 'id' (not a UUID)") - })?, - ); + })?); let mut patch = flynt_core::models::TaskPatch::default(); if let Some(v) = params.get("column").and_then(|v| v.as_str()) { @@ -813,19 +827,31 @@ impl Extension for FlyntExtension { } if let Some(v) = params.get("priority").and_then(|v| v.as_str()) { let pr: flynt_core::models::Priority = serde_json::from_value(json!(v)) - .map_err(|e| omegon_extension::Error::invalid_params(format!("priority: {e}")))?; + .map_err(|e| { + omegon_extension::Error::invalid_params(format!("priority: {e}")) + })?; patch.priority = Some(pr); } if let Some(v) = params.get("status").and_then(|v| v.as_str()) { let st: flynt_core::models::TaskStatus = serde_json::from_value(json!(v)) - .map_err(|e| omegon_extension::Error::invalid_params(format!("status: {e}")))?; + .map_err(|e| { + omegon_extension::Error::invalid_params(format!("status: {e}")) + })?; patch.status = Some(st); } if let Some(arr) = params.get("tags").and_then(|v| v.as_array()) { - patch.tags = Some(arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()); + patch.tags = Some( + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + ); } if let Some(arr) = params.get("external_refs").and_then(|v| v.as_array()) { - patch.external_refs = Some(arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()); + patch.external_refs = Some( + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + ); } if let Some(v) = params.get("position").and_then(|v| v.as_u64()) { patch.position = Some(v as u32); @@ -843,7 +869,11 @@ impl Extension for FlyntExtension { } if let Some(v) = params.get("openspec_change").and_then(|v| v.as_str()) { // Empty string clears; non-empty sets. - patch.openspec_change = if v.is_empty() { Some(None) } else { Some(Some(v.to_string())) }; + patch.openspec_change = if v.is_empty() { + Some(None) + } else { + Some(Some(v.to_string())) + }; } if let Some(v) = params.get("execution") { // Explicit null clears; object sets; missing field @@ -851,8 +881,10 @@ impl Extension for FlyntExtension { if v.is_null() { patch.execution = Some(None); } else { - let parsed: flynt_core::models::ExecutionSpec = serde_json::from_value(v.clone()) - .map_err(|e| omegon_extension::Error::invalid_params(format!("execution: {e}")))?; + let parsed: flynt_core::models::ExecutionSpec = + serde_json::from_value(v.clone()).map_err(|e| { + omegon_extension::Error::invalid_params(format!("execution: {e}")) + })?; patch.execution = Some(Some(parsed)); } } @@ -916,7 +948,9 @@ impl Extension for FlyntExtension { .store .get_document_by_path(std::path::Path::new(path)) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))? - .ok_or_else(|| omegon_extension::Error::internal_error(format!("not found: {path}")))?; + .ok_or_else(|| { + omegon_extension::Error::internal_error(format!("not found: {path}")) + })?; // Guard: refuse to overwrite an existing design node if let Some(ref entity) = doc.entity { @@ -978,22 +1012,28 @@ impl Extension for FlyntExtension { .get_document(&meta.id) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; - let (node_status, node_parent, node_priority, node_issue_type, open_questions_count, deps_count) = - if let Some(ref d) = doc { - if let Some(ref entity) = d.entity { - let s = entity.get_text("status").unwrap_or("seed").to_string(); - let p = entity.get_text("parent").map(String::from); - let pr = entity.get_int("priority"); - let it = entity.get_text("issue_type").map(String::from); - let oq = entity.get_text_list("open_questions").len(); - let dc = entity.get_text_list("dependencies").len(); - (s, p, pr, it, oq, dc) - } else { - ("seed".into(), None, None, None, 0, 0) - } + let ( + node_status, + node_parent, + node_priority, + node_issue_type, + open_questions_count, + deps_count, + ) = if let Some(ref d) = doc { + if let Some(ref entity) = d.entity { + let s = entity.get_text("status").unwrap_or("seed").to_string(); + let p = entity.get_text("parent").map(String::from); + let pr = entity.get_int("priority"); + let it = entity.get_text("issue_type").map(String::from); + let oq = entity.get_text_list("open_questions").len(); + let dc = entity.get_text_list("dependencies").len(); + (s, p, pr, it, oq, dc) } else { ("seed".into(), None, None, None, 0, 0) - }; + } + } else { + ("seed".into(), None, None, None, 0, 0) + }; // Apply status filter if provided if let Some(sf) = status_filter { @@ -1039,9 +1079,9 @@ impl Extension for FlyntExtension { let excalidraw_file = format!("{name}.excalidraw"); let excalidraw_abs = drawings_dir.join(&excalidraw_file); if excalidraw_abs.exists() { - return Err(omegon_extension::Error::internal_error( - format!("Drawing already exists: drawings/{excalidraw_file}. Use a different name."), - )); + return Err(omegon_extension::Error::internal_error(format!( + "Drawing already exists: drawings/{excalidraw_file}. Use a different name." + ))); } let scene_content = scene.unwrap_or( r#"{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"transparent","theme":"dark"}}"# @@ -1103,9 +1143,9 @@ impl Extension for FlyntExtension { let d2_file = format!("{name}.d2"); let d2_abs = dir.join(&d2_file); if d2_abs.exists() { - return Err(omegon_extension::Error::internal_error( - format!("Diagram already exists: {directory}/{d2_file}. Use a different name."), - )); + return Err(omegon_extension::Error::internal_error(format!( + "Diagram already exists: {directory}/{d2_file}. Use a different name." + ))); } std::fs::write(&d2_abs, source) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; @@ -1161,7 +1201,8 @@ impl Extension for FlyntExtension { .get_document(&meta.id) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; - let view = doc.as_ref() + let view = doc + .as_ref() .and_then(|d| d.entity.as_ref()) .and_then(flynt_core::datum::WorkspaceLeaseView::from_entity); @@ -1211,38 +1252,56 @@ impl Extension for FlyntExtension { let search = params["search"].as_str().unwrap_or(""); let min_degree = params["min_degree"].as_u64().unwrap_or(0) as u32; - let mut degree: std::collections::HashMap<&str, u32> = std::collections::HashMap::new(); + let mut degree: std::collections::HashMap<&str, u32> = + std::collections::HashMap::new(); for edge in &payload.edges { *degree.entry(&edge.source).or_default() += 1; *degree.entry(&edge.target).or_default() += 1; } let search_lower = search.to_lowercase(); - let nodes: Vec<_> = payload.nodes.iter().filter(|n| { - if let Some(k) = kind_filter { - if format_kind(&n.kind) != k { return false; } - } - if let Some(g) = group_filter { - if n.group != g { return false; } - } - if let Some(t) = tag_filter { - if !n.tags.contains(&t.to_string()) { return false; } - } - if !search_lower.is_empty() && !n.title.to_lowercase().contains(&search_lower) { - return false; - } - if min_degree > 0 { - if degree.get(n.id.as_str()).copied().unwrap_or(0) < min_degree { return false; } - } - true - }).collect(); + let nodes: Vec<_> = payload + .nodes + .iter() + .filter(|n| { + if let Some(k) = kind_filter { + if format_kind(&n.kind) != k { + return false; + } + } + if let Some(g) = group_filter { + if n.group != g { + return false; + } + } + if let Some(t) = tag_filter { + if !n.tags.contains(&t.to_string()) { + return false; + } + } + if !search_lower.is_empty() + && !n.title.to_lowercase().contains(&search_lower) + { + return false; + } + if min_degree > 0 { + if degree.get(n.id.as_str()).copied().unwrap_or(0) < min_degree { + return false; + } + } + true + }) + .collect(); - let mut ids: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + let mut ids: std::collections::HashSet<&str> = + nodes.iter().map(|n| n.id.as_str()).collect(); // Design node filter: also include direct dependency targets // so the graph shows what design nodes depend on. if kind_filter == Some("design_node") { - let dep_targets: Vec<&str> = payload.edges.iter() + let dep_targets: Vec<&str> = payload + .edges + .iter() .filter(|e| { ids.contains(e.source.as_str()) && (e.kind == flynt_core::graph::GraphEdgeKind::Dependency @@ -1251,7 +1310,9 @@ impl Extension for FlyntExtension { .map(|e| e.target.as_str()) .collect(); // Also include parent sources for ParentChild edges - let parent_sources: Vec<&str> = payload.edges.iter() + let parent_sources: Vec<&str> = payload + .edges + .iter() .filter(|e| { ids.contains(e.target.as_str()) && e.kind == flynt_core::graph::GraphEdgeKind::ParentChild @@ -1267,11 +1328,15 @@ impl Extension for FlyntExtension { } // Re-collect nodes including any added dependency/parent targets - let nodes: Vec<_> = payload.nodes.iter() + let nodes: Vec<_> = payload + .nodes + .iter() .filter(|n| ids.contains(n.id.as_str())) .collect(); - let edges: Vec<_> = payload.edges.iter() + let edges: Vec<_> = payload + .edges + .iter() .filter(|e| ids.contains(e.source.as_str()) && ids.contains(e.target.as_str())) .collect(); @@ -1293,18 +1358,23 @@ impl Extension for FlyntExtension { let payload = build_graph_payload(&*self.project.store) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; - let connected_edges: Vec<_> = payload.edges.iter() + let connected_edges: Vec<_> = payload + .edges + .iter() .filter(|e| e.source == node_id || e.target == node_id) .collect(); - let mut neighbor_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut neighbor_ids: std::collections::HashSet<&str> = + std::collections::HashSet::new(); neighbor_ids.insert(node_id); for edge in &connected_edges { neighbor_ids.insert(&edge.source); neighbor_ids.insert(&edge.target); } - let neighbor_nodes: Vec<_> = payload.nodes.iter() + let neighbor_nodes: Vec<_> = payload + .nodes + .iter() .filter(|n| neighbor_ids.contains(n.id.as_str())) .collect(); @@ -1413,9 +1483,11 @@ impl Extension for FlyntExtension { "execute_engagement_create" => forge_tools::engagement_create(&self.project, params), "execute_engagement_update" => forge_tools::engagement_update(&self.project, params), - "execute_engagement_list" => forge_tools::engagement_list(&self.project, params), + "execute_engagement_list" => forge_tools::engagement_list(&self.project, params), "execute_engagement_status" => forge_tools::engagement_status(&self.project, params), - "execute_forge_status" => forge_tools::forge_status(&self.project, &self.secrets, params), + "execute_forge_status" => { + forge_tools::forge_status(&self.project, &self.secrets, params) + } "execute_forge_list_issues" => { forge_tools::forge_list_issues(&self.project, &self.secrets, params).await } @@ -1425,13 +1497,13 @@ impl Extension for FlyntExtension { "execute_forge_create_issue" => { forge_tools::forge_create_issue(&self.project, &self.secrets, params).await } - "execute_log_work" => forge_tools::log_work(&self.project, params), - "execute_timeline" => forge_tools::timeline(&self.project, params), + "execute_log_work" => forge_tools::log_work(&self.project, params), + "execute_timeline" => forge_tools::timeline(&self.project, params), // ── Flow tools (Phase 4 — node-flow editor) ─────────────────── "execute_flow_create" => flow_tools::flow_create(&self.project, params), - "execute_flow_get" => flow_tools::flow_get(&self.project, params), - "execute_flow_patch" => flow_tools::flow_patch(&self.project, params), + "execute_flow_get" => flow_tools::flow_get(&self.project, params), + "execute_flow_patch" => flow_tools::flow_patch(&self.project, params), _ => Err(omegon_extension::Error::method_not_found(method)), } @@ -1448,6 +1520,18 @@ fn flynt_surface_guide() -> Value { "tools": ["get_document", "create_document"], "use_for": "ordinary markdown notes, research, project docs" }, + { + "kind": "source_note", + "paths": ["sources/*.md", "*.md with kind=source"], + "tools": ["get_document", "create_document", "list_documents"], + "use_for": "research sources imported from Zotero/BibTeX/CSL or captured from the web", + "rules": [ + "Use kind=source frontmatter for source-backed research notes.", + "Prefer a sources/ folder for new source notes, but treat kind=source as authoritative.", + "Keep first-slice source metadata flat: source_type, url, doi, isbn, authors, publication, published, accessed, citation_key, zotero_key.", + "Do not encode typed source relationships as nested generic metadata; future graph support owns those edges." + ] + }, { "kind": "drawing", "paths": ["drawings/.md", "drawings/.excalidraw"], @@ -1490,11 +1574,7 @@ fn flynt_surface_guide() -> Value { } fn validate_file_stem(name: &str) -> omegon_extension::Result<()> { - if name.trim().is_empty() - || name.contains('/') - || name.contains('\\') - || name.contains("..") - { + if name.trim().is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { return Err(omegon_extension::Error::invalid_params( "name must not be empty or contain path separators", )); @@ -1520,7 +1600,10 @@ fn excalidraw_embed_path(content: &str) -> Option { content.trim() }; - let lines: Vec<&str> = body.lines().filter(|line| !line.trim().is_empty()).collect(); + let lines: Vec<&str> = body + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); if lines.len() == 1 { let line = lines[0].trim(); if line.starts_with("![[") && line.ends_with(".excalidraw]]") { @@ -1535,7 +1618,9 @@ fn drawing_path_arg(params: &Value) -> omegon_extension::Result<&str> { .get("path") .and_then(|v| v.as_str()) .or_else(|| params.get("drawing_path").and_then(|v| v.as_str())) - .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'path' (or 'drawing_path')")) + .ok_or_else(|| { + omegon_extension::Error::invalid_params("missing 'path' (or 'drawing_path')") + }) } // ── Canvas tool implementations ─────────────────────────────────────────────── @@ -1546,9 +1631,15 @@ fn drawing_path_arg(params: &Value) -> omegon_extension::Result<&str> { // ergonomics. None of these tools panic on malformed input — they cross the // ACP boundary, where a panic would kill the worker thread. impl FlyntExtension { - fn resolve_drawing_path(&self, path_arg: &str) -> Result { + fn resolve_drawing_path( + &self, + path_arg: &str, + ) -> Result { let rel = std::path::Path::new(path_arg); - if rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if rel + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(omegon_extension::Error::invalid_params( "path must not contain '..'", )); @@ -1566,8 +1657,9 @@ impl FlyntExtension { let abs = self.resolve_drawing_path(path)?; let body = std::fs::read_to_string(&abs) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; - let scene: Value = serde_json::from_str(&body) - .map_err(|e| omegon_extension::Error::internal_error(format!("parse drawing json: {e}")))?; + let scene: Value = serde_json::from_str(&body).map_err(|e| { + omegon_extension::Error::internal_error(format!("parse drawing json: {e}")) + })?; Ok(json!({ "path": path, "scene": scene, @@ -1609,10 +1701,10 @@ impl FlyntExtension { }; let active = ui.get("active_document"); - let active_path = active - .and_then(|d| d.get("path")) - .and_then(|v| v.as_str()); - let Some(md_path) = active_path else { return Ok(Value::Null); }; + let active_path = active.and_then(|d| d.get("path")).and_then(|v| v.as_str()); + let Some(md_path) = active_path else { + return Ok(Value::Null); + }; let typed_drawing = active .and_then(|d| d.get("document_type")) @@ -1631,7 +1723,9 @@ impl FlyntExtension { }; excalidraw_embed_path(&md_body) }; - let Some(drawing_file) = drawing_file else { return Ok(Value::Null); }; + let Some(drawing_file) = drawing_file else { + return Ok(Value::Null); + }; let doc_dir = std::path::Path::new(md_path) .parent() @@ -1643,11 +1737,17 @@ impl FlyntExtension { })) } - fn resolve_canvas_path(&self, path_arg: &str) -> Result { + fn resolve_canvas_path( + &self, + path_arg: &str, + ) -> Result { // Refuse paths that escape the project root (path traversal). The agent // shouldn't be writing outside the project, ever. let rel = std::path::Path::new(path_arg); - if rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if rel + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(omegon_extension::Error::invalid_params( "path must not contain '..'", )); @@ -1713,9 +1813,9 @@ impl FlyntExtension { let mut lint_warnings: Vec = Vec::new(); if let Some(cells_val) = params.get("cells") { let cells_val = coerce_to_value(cells_val.clone(), "cells")?; - let cells = cells_val.as_array().ok_or_else(|| { - omegon_extension::Error::invalid_params("cells: expected array") - })?; + let cells = cells_val + .as_array() + .ok_or_else(|| omegon_extension::Error::invalid_params("cells: expected array"))?; for c in cells { let cell: flynt_core::canvas::Cell = serde_json::from_value(c.clone()) .map_err(|e| omegon_extension::Error::invalid_params(format!("cell: {e}")))?; @@ -1730,7 +1830,8 @@ impl FlyntExtension { std::fs::create_dir_all(parent) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; } - canvas.save(&abs) + canvas + .save(&abs) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; Ok(json!({ @@ -1755,7 +1856,8 @@ impl FlyntExtension { .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; let previous = canvas.theme.clone(); canvas.theme = theme.to_string(); - canvas.save(&abs) + canvas + .save(&abs) .map_err(|e| omegon_extension::Error::internal_error(e.to_string()))?; Ok(json!({ "path": path, "theme": theme, "previous_theme": previous })) } @@ -1764,7 +1866,12 @@ impl FlyntExtension { // Read from the project-side copy that flynt-app's canvas_assets bootstrap // writes on launch. If the bootstrap hasn't run yet (agent started before // app), return an empty primitives list rather than erroring. - let dir = self.project.root.join(".flynt-local").join("flynt").join("assets"); + let dir = self + .project + .root + .join(".flynt-local") + .join("flynt") + .join("assets"); let primitives_doc = read_json_or_default( &dir.join("shadcn-primitives.json"), json!({ "version": 1, "primitives": [] }), @@ -1779,12 +1886,14 @@ impl FlyntExtension { .as_object() .map(|m| { m.iter() - .map(|(id, v)| json!({ - "id": id, - "name": v.get("name").cloned().unwrap_or(json!(id)), - "description": v.get("description").cloned().unwrap_or(Value::Null), - "vars": v.get("vars").cloned().unwrap_or(Value::Null), - })) + .map(|(id, v)| { + json!({ + "id": id, + "name": v.get("name").cloned().unwrap_or(json!(id)), + "description": v.get("description").cloned().unwrap_or(Value::Null), + "vars": v.get("vars").cloned().unwrap_or(Value::Null), + }) + }) .collect() }) .unwrap_or_default(); @@ -1809,11 +1918,7 @@ impl FlyntExtension { // Refuse names that would escape the canvases/ directory or contain // path separators — same posture as resolve_canvas_path's traversal // guard but applied to the name component before it's joined. - if name.is_empty() - || name.contains('/') - || name.contains('\\') - || name.contains("..") - { + if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { return Err(omegon_extension::Error::invalid_params( "name must not be empty or contain path separators", )); @@ -1846,10 +1951,10 @@ impl FlyntExtension { }; let active = ui.get("active_document"); - let active_path = active - .and_then(|d| d.get("path")) - .and_then(|v| v.as_str()); - let Some(md_path) = active_path else { return Ok(Value::Null); }; + let active_path = active.and_then(|d| d.get("path")).and_then(|v| v.as_str()); + let Some(md_path) = active_path else { + return Ok(Value::Null); + }; // Fast path: if flynt-app classified this doc as "canvas" in the // mirror, trust it and skip the body parse. Falls back to parsing @@ -1874,7 +1979,9 @@ impl FlyntExtension { }; canvas_embed_path(&md_body) }; - let Some(canvas_file) = canvas_file else { return Ok(Value::Null); }; + let Some(canvas_file) = canvas_file else { + return Ok(Value::Null); + }; let doc_dir = std::path::Path::new(md_path) .parent() @@ -1931,11 +2038,33 @@ fn lint_cell(cell: &flynt_core::canvas::Cell) -> Vec { // positives possible if the same pattern appears in attribute // text content, but it's a warning, not a blocker. let arbitrary_prefixes = [ - "bg-[", "text-[", "border-[", "ring-[", "shadow-[", - "p-[", "px-[", "py-[", "pt-[", "pb-[", "pl-[", "pr-[", - "m-[", "mx-[", "my-[", "mt-[", "mb-[", "ml-[", "mr-[", - "w-[", "h-[", "min-w-[", "min-h-[", "max-w-[", "max-h-[", - "gap-[", "rounded-[", + "bg-[", + "text-[", + "border-[", + "ring-[", + "shadow-[", + "p-[", + "px-[", + "py-[", + "pt-[", + "pb-[", + "pl-[", + "pr-[", + "m-[", + "mx-[", + "my-[", + "mt-[", + "mb-[", + "ml-[", + "mr-[", + "w-[", + "h-[", + "min-w-[", + "min-h-[", + "max-w-[", + "max-h-[", + "gap-[", + "rounded-[", ]; for prefix in &arbitrary_prefixes { if html.contains(prefix) { @@ -2001,9 +2130,7 @@ fn canvas_path_arg(params: &Value) -> omegon_extension::Result<&str> { .get("path") .and_then(|v| v.as_str()) .or_else(|| params.get("canvas_path").and_then(|v| v.as_str())) - .ok_or_else(|| { - omegon_extension::Error::invalid_params("missing 'path' (or 'canvas_path')") - }) + .ok_or_else(|| omegon_extension::Error::invalid_params("missing 'path' (or 'canvas_path')")) } /// Accept either a structured JSON value or a stringified JSON value and @@ -2081,6 +2208,16 @@ mod tests { assert!(names.contains(&"find_document_by_slug".to_string())); assert!(names.contains(&"flynt_surface_guide".to_string())); assert!(names.contains(&"move_document".to_string())); + let guide = ext + .handle_rpc("execute_flynt_surface_guide", json!({})) + .await + .unwrap(); + let surfaces = guide["surfaces"].as_array().unwrap(); + assert!( + surfaces + .iter() + .any(|surface| surface["kind"] == "source_note") + ); assert!(names.contains(&"store_memory_fact".to_string())); assert!(names.contains(&"store_agent_communication".to_string())); assert!(names.contains(&"get_task".to_string())); @@ -2102,9 +2239,16 @@ mod tests { } // Phase 3 — scribe-absorbed forge / engagement tools. for n in [ - "engagement_create", "engagement_update", "engagement_list", "engagement_status", - "forge_status", "forge_list_issues", "forge_sync_issues", - "forge_create_issue", "log_work", "timeline", + "engagement_create", + "engagement_update", + "engagement_list", + "engagement_status", + "forge_status", + "forge_list_issues", + "forge_sync_issues", + "forge_create_issue", + "log_work", + "timeline", ] { assert!(names.contains(&n.to_string()), "expected {n} in tools/list"); } @@ -2150,7 +2294,12 @@ mod tests { ) .await .unwrap(); - assert!(memory["path"].as_str().unwrap().contains("ai/memory/storage")); + assert!( + memory["path"] + .as_str() + .unwrap() + .contains("ai/memory/storage") + ); let comm = ext .handle_rpc( @@ -2163,7 +2312,12 @@ mod tests { ) .await .unwrap(); - assert!(comm["path"].as_str().unwrap().contains("references/comms/scribe")); + assert!( + comm["path"] + .as_str() + .unwrap() + .contains("references/comms/scribe") + ); let board = ext .handle_rpc("execute_create_board", json!({ "name": "Sprint 1" })) @@ -2186,7 +2340,10 @@ mod tests { assert_eq!(task["title"], "Wire extension surface"); let tasks = ext - .handle_rpc("execute_list_tasks", json!({ "column": "Backlog", "board_id": board_id })) + .handle_rpc( + "execute_list_tasks", + json!({ "column": "Backlog", "board_id": board_id }), + ) .await .unwrap(); assert_eq!(tasks.as_array().unwrap().len(), 1); @@ -2287,7 +2444,11 @@ mod tests { "updated_at": "now" }), }; - std::fs::write(dir.join("ui-state.json"), serde_json::to_string(&body).unwrap()).unwrap(); + std::fs::write( + dir.join("ui-state.json"), + serde_json::to_string(&body).unwrap(), + ) + .unwrap(); } #[tokio::test] @@ -2303,7 +2464,10 @@ mod tests { assert!(tmp.path().join("drawings/Sketch.excalidraw").exists()); write_ui_state(&tmp, Some("drawings/Sketch.md")); - let active = ext.handle_rpc("execute_drawing_active", json!({})).await.unwrap(); + let active = ext + .handle_rpc("execute_drawing_active", json!({})) + .await + .unwrap(); assert_eq!(active["wrapper_path"], "drawings/Sketch.md"); assert_eq!(active["drawing_path"], "drawings/Sketch.excalidraw"); @@ -2321,7 +2485,10 @@ mod tests { .unwrap(); let got = ext - .handle_rpc("execute_drawing_get", json!({"path": "drawings/Sketch.excalidraw"})) + .handle_rpc( + "execute_drawing_get", + json!({"path": "drawings/Sketch.excalidraw"}), + ) .await .unwrap(); assert_eq!(got["scene"]["elements"][0]["id"], "box"); @@ -2348,7 +2515,11 @@ mod tests { .await .unwrap(); assert_eq!(created["drawing_path"], "drawings/Spec Sketch.excalidraw"); - assert!(tmp.path().join("drawings/Spec Sketch.drawing.json").exists()); + assert!( + tmp.path() + .join("drawings/Spec Sketch.drawing.json") + .exists() + ); let patched = ext .handle_rpc( @@ -2401,7 +2572,10 @@ mod tests { write_canvas(&tmp, "canvases/Hero.canvas", &body); let out = ext - .handle_rpc("execute_canvas_get", json!({"path": "canvases/Hero.canvas"})) + .handle_rpc( + "execute_canvas_get", + json!({"path": "canvases/Hero.canvas"}), + ) .await .unwrap(); assert_eq!(out["version"], 1); @@ -2428,21 +2602,33 @@ mod tests { let canvas = flynt_core::canvas::Canvas::default(); write_canvas(&tmp, "x.canvas", &serde_json::to_string(&canvas).unwrap()); - let by_path = ext.handle_rpc("execute_canvas_get", json!({"path": "x.canvas"})).await.unwrap(); - let by_canvas_path = ext.handle_rpc("execute_canvas_get", json!({"canvas_path": "x.canvas"})).await.unwrap(); + let by_path = ext + .handle_rpc("execute_canvas_get", json!({"path": "x.canvas"})) + .await + .unwrap(); + let by_canvas_path = ext + .handle_rpc("execute_canvas_get", json!({"canvas_path": "x.canvas"})) + .await + .unwrap(); assert_eq!(by_path, by_canvas_path); } #[tokio::test] async fn canvas_apply_theme_accepts_canvas_path_alias() { let (tmp, ext) = test_extension(); - write_canvas(&tmp, "x.canvas", - &serde_json::to_string(&flynt_core::canvas::Canvas::default()).unwrap()); + write_canvas( + &tmp, + "x.canvas", + &serde_json::to_string(&flynt_core::canvas::Canvas::default()).unwrap(), + ); - let out = ext.handle_rpc( - "execute_canvas_apply_theme", - json!({"canvas_path": "x.canvas", "theme": "amber"}), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_apply_theme", + json!({"canvas_path": "x.canvas", "theme": "amber"}), + ) + .await + .unwrap(); assert_eq!(out["theme"], "amber"); } @@ -2494,18 +2680,25 @@ mod tests { let (tmp, ext) = test_extension(); let cells_json = serde_json::to_string(&serde_json::json!([ {"id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "html": "x", "css": ""} - ])).unwrap(); + ])) + .unwrap(); - let out = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ "path": "x.canvas", "cells": cells_json }), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ "path": "x.canvas", "cells": cells_json }), + ) + .await + .unwrap(); assert_eq!(out["cell_count"], 1); assert!(tmp.path().join("x.canvas").exists()); // Round-trip via canvas_get to confirm the cell actually wrote. - let got = ext.handle_rpc("execute_canvas_get", json!({"path": "x.canvas"})).await.unwrap(); + let got = ext + .handle_rpc("execute_canvas_get", json!({"path": "x.canvas"})) + .await + .unwrap(); assert_eq!(got["cells"][0]["id"], "a"); assert_eq!(got["cells"][0]["html"], "x"); } @@ -2513,36 +2706,45 @@ mod tests { #[tokio::test] async fn canvas_set_cells_rejects_invalid_stringified_cells() { let (_tmp, ext) = test_extension(); - let err = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ "path": "x.canvas", "cells": "not-json" }), - ).await.unwrap_err(); + let err = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ "path": "x.canvas", "cells": "not-json" }), + ) + .await + .unwrap_err(); assert!(err.to_string().contains("cells")); } #[tokio::test] async fn canvas_set_cells_rejects_non_array_non_string_cells() { let (_tmp, ext) = test_extension(); - let err = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ "path": "x.canvas", "cells": 42 }), - ).await.unwrap_err(); + let err = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ "path": "x.canvas", "cells": 42 }), + ) + .await + .unwrap_err(); assert!(err.to_string().contains("cells")); } #[tokio::test] async fn canvas_set_cells_lint_flags_missing_h_full() { let (_tmp, ext) = test_extension(); - let out = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ - "path": "x.canvas", - "cells": [{ - "id": "needs-fill", "x": 0, "y": 0, "w": 4, "h": 3, - "html": "
stat
", "css": "" - }] - }), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ + "path": "x.canvas", + "cells": [{ + "id": "needs-fill", "x": 0, "y": 0, "w": 4, "h": 3, + "html": "
stat
", "css": "" + }] + }), + ) + .await + .unwrap(); let warnings = out["lint_warnings"].as_array().unwrap(); assert_eq!(warnings.len(), 1); let w = warnings[0].as_str().unwrap(); @@ -2553,35 +2755,45 @@ mod tests { #[tokio::test] async fn canvas_set_cells_lint_passes_h_full() { let (_tmp, ext) = test_extension(); - let out = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ - "path": "x.canvas", - "cells": [{ - "id": "fills", "x": 0, "y": 0, "w": 4, "h": 3, - "html": "
stat
", "css": "" - }] - }), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ + "path": "x.canvas", + "cells": [{ + "id": "fills", "x": 0, "y": 0, "w": 4, "h": 3, + "html": "
stat
", "css": "" + }] + }), + ) + .await + .unwrap(); assert!(out["lint_warnings"].as_array().unwrap().is_empty()); } #[tokio::test] async fn canvas_set_cells_lint_flags_arbitrary_tailwind() { let (_tmp, ext) = test_extension(); - let out = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ - "path": "x.canvas", - "cells": [{ - "id": "hot-pink", "x": 0, "y": 0, "w": 4, "h": 3, - "html": "
x
", "css": "" - }] - }), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ + "path": "x.canvas", + "cells": [{ + "id": "hot-pink", "x": 0, "y": 0, "w": 4, "h": 3, + "html": "
x
", "css": "" + }] + }), + ) + .await + .unwrap(); let warnings = out["lint_warnings"].as_array().unwrap(); assert!(!warnings.is_empty()); - let combined = warnings.iter().filter_map(|v| v.as_str()).collect::>().join(" "); + let combined = warnings + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(" "); assert!(combined.contains("arbitrary"), "{combined}"); assert!(combined.contains("hot-pink"), "{combined}"); } @@ -2590,17 +2802,20 @@ mod tests { async fn canvas_set_cells_lint_height_100_alternate_passes() { // Inline `style="height:100%"` should also satisfy the fill check. let (_tmp, ext) = test_extension(); - let out = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ - "path": "x.canvas", - "cells": [{ - "id": "inline", "x": 0, "y": 0, "w": 4, "h": 3, - "html": "
x
", - "css": "" - }] - }), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ + "path": "x.canvas", + "cells": [{ + "id": "inline", "x": 0, "y": 0, "w": 4, "h": 3, + "html": "
x
", + "css": "" + }] + }), + ) + .await + .unwrap(); assert!(out["lint_warnings"].as_array().unwrap().is_empty()); } @@ -2608,16 +2823,19 @@ mod tests { async fn canvas_set_cells_lint_empty_html_does_not_warn() { // Bare or empty cells aren't flagged — there's nothing to fill. let (_tmp, ext) = test_extension(); - let out = ext.handle_rpc( - "execute_canvas_set_cells", - json!({ - "path": "x.canvas", - "cells": [{ - "id": "blank", "x": 0, "y": 0, "w": 1, "h": 1, - "html": "", "css": "" - }] - }), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_set_cells", + json!({ + "path": "x.canvas", + "cells": [{ + "id": "blank", "x": 0, "y": 0, "w": 1, "h": 1, + "html": "", "css": "" + }] + }), + ) + .await + .unwrap(); assert!(out["lint_warnings"].as_array().unwrap().is_empty()); } @@ -2626,8 +2844,14 @@ mod tests { let (tmp, ext) = test_extension(); let mut canvas = flynt_core::canvas::Canvas::default(); canvas.upsert_cell(flynt_core::canvas::Cell { - id: "a".into(), x: 0, y: 0, w: 1, h: 1, - html: "old".into(), css: "".into(), js: None, + id: "a".into(), + x: 0, + y: 0, + w: 1, + h: 1, + html: "old".into(), + css: "".into(), + js: None, }); write_canvas(&tmp, "x.canvas", &serde_json::to_string(&canvas).unwrap()); @@ -2654,8 +2878,14 @@ mod tests { let mut canvas = flynt_core::canvas::Canvas::default(); for id in ["a", "b", "c"] { canvas.upsert_cell(flynt_core::canvas::Cell { - id: id.into(), x: 0, y: 0, w: 1, h: 1, - html: "".into(), css: "".into(), js: None, + id: id.into(), + x: 0, + y: 0, + w: 1, + h: 1, + html: "".into(), + css: "".into(), + js: None, }); } write_canvas(&tmp, "x.canvas", &serde_json::to_string(&canvas).unwrap()); @@ -2668,16 +2898,27 @@ mod tests { .await .unwrap(); assert_eq!(out["cell_count"], 2); - let deleted: Vec<&str> = out["deleted"].as_array().unwrap() - .iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(deleted, vec!["b"], "deleted only reports actually-removed ids"); + let deleted: Vec<&str> = out["deleted"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert_eq!( + deleted, + vec!["b"], + "deleted only reports actually-removed ids" + ); } #[tokio::test] async fn canvas_set_cells_updates_grid_and_theme() { let (tmp, ext) = test_extension(); - write_canvas(&tmp, "x.canvas", - &serde_json::to_string(&flynt_core::canvas::Canvas::default()).unwrap()); + write_canvas( + &tmp, + "x.canvas", + &serde_json::to_string(&flynt_core::canvas::Canvas::default()).unwrap(), + ); ext.handle_rpc( "execute_canvas_set_cells", @@ -2686,9 +2927,14 @@ mod tests { "grid": {"cols": 6, "rows": 4, "gap": 16}, "theme": "ocean" }), - ).await.unwrap(); + ) + .await + .unwrap(); - let got = ext.handle_rpc("execute_canvas_get", json!({"path": "x.canvas"})).await.unwrap(); + let got = ext + .handle_rpc("execute_canvas_get", json!({"path": "x.canvas"})) + .await + .unwrap(); assert_eq!(got["grid"]["cols"], 6); assert_eq!(got["grid"]["gap"], 16); assert_eq!(got["theme"], "ocean"); @@ -2697,13 +2943,19 @@ mod tests { #[tokio::test] async fn canvas_apply_theme_returns_previous() { let (tmp, ext) = test_extension(); - write_canvas(&tmp, "x.canvas", - &serde_json::to_string(&flynt_core::canvas::Canvas::default()).unwrap()); + write_canvas( + &tmp, + "x.canvas", + &serde_json::to_string(&flynt_core::canvas::Canvas::default()).unwrap(), + ); - let out = ext.handle_rpc( - "execute_canvas_apply_theme", - json!({"path": "x.canvas", "theme": "amber"}), - ).await.unwrap(); + let out = ext + .handle_rpc( + "execute_canvas_apply_theme", + json!({"path": "x.canvas", "theme": "amber"}), + ) + .await + .unwrap(); assert_eq!(out["theme"], "amber"); assert_eq!(out["previous_theme"], "default"); } @@ -2715,7 +2967,9 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); // Raw-string delimiters need to be longer than any `"#` substring // inside — colour values like "#000" force us to use r##"..."## here. - std::fs::write(dir.join("shadcn-primitives.json"), r##"{ + std::fs::write( + dir.join("shadcn-primitives.json"), + r##"{ "version": 1, "cell_authoring_guidance": ["wrap with h-full"], "primitives": [{ @@ -2723,15 +2977,24 @@ mod tests { "description":"","usage_notes":"inline", "html":"", ".btn { color: red; }", None); + let cell = cell_with( + "", + ".btn { color: red; }", + None, + ); let out = build_srcdoc(&cell, "default", "/* tw-marker */"); assert!(out.contains("/* tw-marker */"), "tailwind must be inlined"); assert!(out.contains("--background:"), "theme vars must be inlined"); - assert!(out.contains(".btn { color: red; }"), "cell css must be inlined"); - assert!(out.contains(""), "cell html must be in body"); + assert!( + out.contains(".btn { color: red; }"), + "cell css must be inlined" + ); + assert!( + out.contains(""), + "cell html must be in body" + ); } #[test] @@ -617,7 +690,10 @@ mod tests { let cell = cell_with("
x
", "", None); let out = build_srcdoc(&cell, "default", ""); assert!(out.contains("