From 94409f3fe2026a4e3b14444e92b7f7de169c6922 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 3 Jul 2026 19:06:49 +0800 Subject: [PATCH 01/10] feat(canvas): add product domain contracts --- Cargo.toml | 1 + .../product-domains/src/canvas/mod.rs | 25 ++ .../product-domains/src/canvas/policy.rs | 263 +++++++++++++++ .../product-domains/src/canvas/ports.rs | 90 +++++ .../product-domains/src/canvas/reference.rs | 97 ++++++ .../product-domains/src/canvas/runtime.rs | 34 ++ .../product-domains/src/canvas/types.rs | 307 ++++++++++++++++++ .../contracts/product-domains/src/lib.rs | 2 + .../product-domains/tests/canvas_contracts.rs | 193 +++++++++++ 9 files changed, 1012 insertions(+) create mode 100644 src/crates/contracts/product-domains/src/canvas/mod.rs create mode 100644 src/crates/contracts/product-domains/src/canvas/policy.rs create mode 100644 src/crates/contracts/product-domains/src/canvas/ports.rs create mode 100644 src/crates/contracts/product-domains/src/canvas/reference.rs create mode 100644 src/crates/contracts/product-domains/src/canvas/runtime.rs create mode 100644 src/crates/contracts/product-domains/src/canvas/types.rs create mode 100644 src/crates/contracts/product-domains/tests/canvas_contracts.rs diff --git a/Cargo.toml b/Cargo.toml index 26f7491e9..0244c3a95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ sse-stream = "0.2.3" which = "8.0" similar = "2.5" urlencoding = "2.1" +oxc = { version = "0.138.0", default-features = false, features = ["ast_visit", "codegen", "semantic", "transformer"] } # Tauri (desktop only) tauri = { version = "2.11", features = ["unstable", "macos-private-api", "tray-icon"] } diff --git a/src/crates/contracts/product-domains/src/canvas/mod.rs b/src/crates/contracts/product-domains/src/canvas/mod.rs new file mode 100644 index 000000000..36523a5ed --- /dev/null +++ b/src/crates/contracts/product-domains/src/canvas/mod.rs @@ -0,0 +1,25 @@ +//! Canvas domain contracts and pure policy helpers. +//! +//! Canvas is an Agent-created display artifact. This module owns only stable +//! DTOs, pure policy, runtime artifact shapes, and narrow ports. TSX +//! compilation, HTML assembly, filesystem persistence, and UI integration +//! belong outside `product-domains`. + +pub mod policy; +pub mod ports; +pub mod reference; +pub mod runtime; +pub mod types; + +pub use policy::{ + validate_canvas_imports, validate_canvas_source_policy, CanvasImportPolicyDiagnostic, + CanvasImportPolicyDiagnosticKind, BITFUN_CANVAS_IMPORT, +}; +pub use ports::{ + CanvasPortError, CanvasPortErrorKind, CanvasPortFuture, CanvasPortResult, CanvasStoragePort, +}; +pub use reference::{ + is_safe_canvas_ref_segment, parse_canvas_artifact_ref, CanvasArtifactRefParseError, +}; +pub use runtime::*; +pub use types::*; diff --git a/src/crates/contracts/product-domains/src/canvas/policy.rs b/src/crates/contracts/product-domains/src/canvas/policy.rs new file mode 100644 index 000000000..549289129 --- /dev/null +++ b/src/crates/contracts/product-domains/src/canvas/policy.rs @@ -0,0 +1,263 @@ +use crate::canvas::types::{ + CanvasDiagnostic, CanvasDiagnosticCategory, CanvasDiagnosticSeverity, CanvasSource, + CANVAS_SOURCE_LANGUAGE_TSX, +}; +use serde::{Deserialize, Serialize}; + +pub const BITFUN_CANVAS_IMPORT: &str = "bitfun/canvas"; +const CURSOR_CANVAS_IMPORT: &str = "cursor/canvas"; +const REACT_IMPORT: &str = "react"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasImportPolicyDiagnosticKind { + RelativeImport, + DynamicImport, + UnsupportedImport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasImportPolicyDiagnostic { + pub kind: CanvasImportPolicyDiagnosticKind, + pub specifier: String, + pub line: Option, + pub column: Option, +} + +pub fn validate_canvas_imports(source: &str) -> Vec { + let mut diagnostics = Vec::new(); + for specifier in module_import_export_specifiers(source) { + if is_allowed_canvas_import(&specifier.value) { + continue; + } + let kind = if specifier.value.starts_with('.') || specifier.value.starts_with('/') { + CanvasImportPolicyDiagnosticKind::RelativeImport + } else { + CanvasImportPolicyDiagnosticKind::UnsupportedImport + }; + diagnostics.push(CanvasImportPolicyDiagnostic { + kind, + specifier: specifier.value, + line: Some(specifier.line), + column: Some(specifier.column), + }); + } + + for specifier in dynamic_import_specifiers(source) { + diagnostics.push(CanvasImportPolicyDiagnostic { + kind: CanvasImportPolicyDiagnosticKind::DynamicImport, + specifier: specifier.value, + line: Some(specifier.line), + column: Some(specifier.column), + }); + } + + diagnostics +} + +fn is_allowed_canvas_import(specifier: &str) -> bool { + matches!( + specifier, + BITFUN_CANVAS_IMPORT | CURSOR_CANVAS_IMPORT | REACT_IMPORT + ) +} + +pub fn validate_canvas_source_policy(source: &CanvasSource) -> Vec { + let mut diagnostics = Vec::new(); + if source.language != CANVAS_SOURCE_LANGUAGE_TSX { + diagnostics.push(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::Unsupported, + message: format!( + "Canvas source language '{}' is not supported", + source.language + ), + code: Some("canvas.source.language_unsupported".to_string()), + line: None, + column: None, + suggested_fix: Some("Use a single TSX source file.".to_string()), + }); + } + + if !source.filename.ends_with(".tsx") { + diagnostics.push(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::Unsupported, + message: format!("Canvas filename '{}' must end with .tsx", source.filename), + code: Some("canvas.source.filename_unsupported".to_string()), + line: None, + column: None, + suggested_fix: Some("Use a .tsx filename.".to_string()), + }); + } + + if !has_default_export(&source.source) { + diagnostics.push(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::TypeScript, + message: "Canvas source must default-export a React component".to_string(), + code: Some("canvas.source.default_export_missing".to_string()), + line: None, + column: None, + suggested_fix: Some( + "Add `export default function ...` or `export default ...`.".to_string(), + ), + }); + } + + for import_diagnostic in validate_canvas_imports(&source.source) { + diagnostics.push(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::ImportPolicy, + message: import_policy_message(&import_diagnostic), + code: Some(import_policy_code(&import_diagnostic.kind).to_string()), + line: import_diagnostic.line, + column: import_diagnostic.column, + suggested_fix: Some("Import Canvas primitives from bitfun/canvas only.".to_string()), + }); + } + + diagnostics +} + +fn has_default_export(source: &str) -> bool { + source.contains("export default") +} + +fn import_policy_code(kind: &CanvasImportPolicyDiagnosticKind) -> &'static str { + match kind { + CanvasImportPolicyDiagnosticKind::RelativeImport => "canvas.import.relative", + CanvasImportPolicyDiagnosticKind::DynamicImport => "canvas.import.dynamic", + CanvasImportPolicyDiagnosticKind::UnsupportedImport => "canvas.import.unsupported", + } +} + +fn import_policy_message(diagnostic: &CanvasImportPolicyDiagnostic) -> String { + match diagnostic.kind { + CanvasImportPolicyDiagnosticKind::RelativeImport => { + format!( + "Relative import '{}' is not allowed in Canvas source", + diagnostic.specifier + ) + } + CanvasImportPolicyDiagnosticKind::DynamicImport => { + format!( + "Dynamic import '{}' is not allowed in Canvas source", + diagnostic.specifier + ) + } + CanvasImportPolicyDiagnosticKind::UnsupportedImport => { + format!( + "Import '{}' is not allowed; Canvas source may only import from {}", + diagnostic.specifier, BITFUN_CANVAS_IMPORT + ) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocatedSpecifier { + value: String, + line: u32, + column: u32, +} + +fn module_import_export_specifiers(source: &str) -> Vec { + module_import_export_specifiers_by_scan(source) +} + +fn module_import_export_specifiers_by_scan(source: &str) -> Vec { + source + .lines() + .enumerate() + .filter_map(|(line_index, line)| { + let trimmed = line.trim_start(); + if !(trimmed.starts_with("import ") + || (trimmed.starts_with("export ") && trimmed.contains(" from "))) + { + return None; + } + let leading_columns = line.len() - trimmed.len(); + let (quote_index, value) = quoted_specifier(trimmed)?; + Some(LocatedSpecifier { + value, + line: line_index as u32 + 1, + column: leading_columns as u32 + quote_index as u32 + 2, + }) + }) + .collect() +} + +fn dynamic_import_specifiers(source: &str) -> Vec { + dynamic_import_specifiers_by_scan(source) +} + +fn dynamic_import_specifiers_by_scan(source: &str) -> Vec { + let mut specifiers = Vec::new(); + let mut consumed = 0usize; + let mut rest = source; + while let Some(index) = rest.find("import(") { + let import_offset = consumed + index; + consumed += index + "import(".len(); + rest = &rest[index + "import(".len()..]; + let trimmed_len = rest.len() - rest.trim_start().len(); + let trimmed = rest.trim_start(); + if let Some((quote_index, value)) = quoted_specifier(trimmed) { + let (line, column) = line_column(source, consumed + trimmed_len + quote_index + 1); + specifiers.push(LocatedSpecifier { + value, + line, + column, + }); + } else { + let (line, column) = line_column(source, import_offset); + specifiers.push(LocatedSpecifier { + value: "".to_string(), + line, + column, + }); + } + } + specifiers +} + +fn quoted_specifier(text: &str) -> Option<(usize, String)> { + let single = quoted_specifier_with(text, '\''); + let double = quoted_specifier_with(text, '"'); + match (single, double) { + (Some((single_index, single_value)), Some((double_index, double_value))) => { + Some(if single_index < double_index { + (single_index, single_value) + } else { + (double_index, double_value) + }) + } + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + } +} + +fn quoted_specifier_with(text: &str, quote: char) -> Option<(usize, String)> { + let start = text.find(quote)?; + let after = &text[start + quote.len_utf8()..]; + let end = after.find(quote)?; + Some((start, after[..end].to_string())) +} + +fn line_column(source: &str, offset: usize) -> (u32, u32) { + let mut line = 1u32; + let mut column = 1u32; + for (index, ch) in source.char_indices() { + if index >= offset { + break; + } + if ch == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + (line, column) +} diff --git a/src/crates/contracts/product-domains/src/canvas/ports.rs b/src/crates/contracts/product-domains/src/canvas/ports.rs new file mode 100644 index 000000000..ccbca3975 --- /dev/null +++ b/src/crates/contracts/product-domains/src/canvas/ports.rs @@ -0,0 +1,90 @@ +use crate::canvas::types::{ + CanvasArtifact, CanvasCompiledPayload, CanvasDiagnostic, CanvasId, CanvasSessionId, + CanvasSnapshot, CanvasSource, CanvasState, +}; +use serde::{Deserialize, Serialize}; +use std::future::Future; +use std::pin::Pin; + +pub type CanvasPortFuture<'a, T> = Pin> + Send + 'a>>; +pub type CanvasPortResult = Result; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasPortErrorKind { + NotFound, + InvalidInput, + Unsupported, + Serialization, + Io, + Backend, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasPortError { + pub kind: CanvasPortErrorKind, + pub message: String, +} + +impl CanvasPortError { + pub fn new(kind: CanvasPortErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl std::fmt::Display for CanvasPortError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for CanvasPortError {} + +pub trait CanvasStoragePort: Send + Sync { + fn save_source( + &self, + artifact: CanvasArtifact, + source: CanvasSource, + diagnostics: Vec, + ) -> CanvasPortFuture<'_, CanvasSnapshot>; + + fn load_snapshot( + &self, + session_id: CanvasSessionId, + canvas_id: CanvasId, + ) -> CanvasPortFuture<'_, CanvasSnapshot>; + + fn list_session_artifacts( + &self, + session_id: CanvasSessionId, + ) -> CanvasPortFuture<'_, Vec>; + + fn save_compiled_payload( + &self, + session_id: CanvasSessionId, + payload: CanvasCompiledPayload, + ) -> CanvasPortFuture<'_, CanvasSnapshot>; + + fn report_runtime_diagnostic( + &self, + session_id: CanvasSessionId, + canvas_id: CanvasId, + diagnostic: CanvasDiagnostic, + ) -> CanvasPortFuture<'_, CanvasSnapshot>; + + fn load_state( + &self, + session_id: CanvasSessionId, + canvas_id: CanvasId, + ) -> CanvasPortFuture<'_, Option>; + + fn save_state( + &self, + session_id: CanvasSessionId, + state: CanvasState, + ) -> CanvasPortFuture<'_, CanvasState>; +} diff --git a/src/crates/contracts/product-domains/src/canvas/reference.rs b/src/crates/contracts/product-domains/src/canvas/reference.rs new file mode 100644 index 000000000..0facb629c --- /dev/null +++ b/src/crates/contracts/product-domains/src/canvas/reference.rs @@ -0,0 +1,97 @@ +use crate::canvas::types::{ + CanvasArtifactRef, CanvasId, CanvasSessionId, CANVAS_ARTIFACT_REF_SCHEME, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasArtifactRefParseError { + InvalidScheme, + InvalidShape, + EmptySessionId, + EmptyCanvasId, + UnsafeSessionId, + UnsafeCanvasId, + InvalidPercentEncoding, +} + +impl std::fmt::Display for CanvasArtifactRefParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for CanvasArtifactRefParseError {} + +pub fn parse_canvas_artifact_ref( + uri: &str, +) -> Result { + let prefix = format!("{CANVAS_ARTIFACT_REF_SCHEME}://"); + let rest = uri + .strip_prefix(&prefix) + .ok_or(CanvasArtifactRefParseError::InvalidScheme)?; + let parts: Vec<&str> = rest.split('/').collect(); + if parts.len() != 4 || parts[0] != "session" || parts[2] != "canvas" { + return Err(CanvasArtifactRefParseError::InvalidShape); + } + + let session_id = percent_decode_segment(parts[1])?; + if session_id.is_empty() { + return Err(CanvasArtifactRefParseError::EmptySessionId); + } + if !is_safe_canvas_ref_segment(&session_id) { + return Err(CanvasArtifactRefParseError::UnsafeSessionId); + } + + let canvas_id = percent_decode_segment(parts[3])?; + if canvas_id.is_empty() { + return Err(CanvasArtifactRefParseError::EmptyCanvasId); + } + if !is_safe_canvas_ref_segment(&canvas_id) { + return Err(CanvasArtifactRefParseError::UnsafeCanvasId); + } + + Ok(CanvasArtifactRef::new( + CanvasSessionId::new(session_id), + CanvasId::new(canvas_id), + )) +} + +pub fn is_safe_canvas_ref_segment(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && !value + .chars() + .any(|ch| ch == '/' || ch == '\\' || ch.is_control()) +} + +fn percent_decode_segment(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + output.push(bytes[index]); + index += 1; + continue; + } + if index + 2 >= bytes.len() { + return Err(CanvasArtifactRefParseError::InvalidPercentEncoding); + } + let high = from_hex(bytes[index + 1])?; + let low = from_hex(bytes[index + 2])?; + output.push((high << 4) | low); + index += 3; + } + String::from_utf8(output).map_err(|_| CanvasArtifactRefParseError::InvalidPercentEncoding) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(CanvasArtifactRefParseError::InvalidPercentEncoding), + } +} diff --git a/src/crates/contracts/product-domains/src/canvas/runtime.rs b/src/crates/contracts/product-domains/src/canvas/runtime.rs new file mode 100644 index 000000000..93c2b5a9c --- /dev/null +++ b/src/crates/contracts/product-domains/src/canvas/runtime.rs @@ -0,0 +1,34 @@ +use crate::canvas::types::{CanvasCompiledPayload, CanvasDiagnostic, CanvasId, CanvasRevision}; +use serde::{Deserialize, Serialize}; + +pub const BITFUN_CANVAS_SDK_VERSION: &str = "0.2.0"; +pub const BITFUN_CANVAS_RUNTIME_VERSION: &str = "0.1.0"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCompileRequest { + pub canvas_id: CanvasId, + pub source_revision: CanvasRevision, + pub source: String, + #[serde(default = "default_sdk_version")] + pub sdk_version: String, + #[serde(default = "default_runtime_version")] + pub runtime_version: String, + pub compiled_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCompileResult { + pub payload: Option, + pub diagnostics: Vec, + pub compiled: bool, +} + +fn default_sdk_version() -> String { + BITFUN_CANVAS_SDK_VERSION.to_string() +} + +fn default_runtime_version() -> String { + BITFUN_CANVAS_RUNTIME_VERSION.to_string() +} diff --git a/src/crates/contracts/product-domains/src/canvas/types.rs b/src/crates/contracts/product-domains/src/canvas/types.rs new file mode 100644 index 000000000..8f6f90592 --- /dev/null +++ b/src/crates/contracts/product-domains/src/canvas/types.rs @@ -0,0 +1,307 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +pub const CANVAS_SOURCE_LANGUAGE_TSX: &str = "tsx"; +pub const CANVAS_ARTIFACT_REF_SCHEME: &str = "bitfun-canvas"; +pub const CANVAS_CURRENT_SOURCE_SCHEMA_VERSION: u32 = 1; +pub const CANVAS_CURRENT_STATE_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CanvasId(pub String); + +impl CanvasId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CanvasRevision(pub String); + +impl CanvasRevision { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CanvasSessionId(pub String); + +impl CanvasSessionId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CanvasWorkspaceId(pub String); + +impl CanvasWorkspaceId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasScope { + Session, +} + +impl Default for CanvasScope { + fn default() -> Self { + Self::Session + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasStatus { + SourceSaved, + Compiled, + CompileFailed, + RuntimeFailed, + Unsupported, +} + +impl Default for CanvasStatus { + fn default() -> Self { + Self::SourceSaved + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasDiagnosticSeverity { + Error, + Warning, + Info, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanvasDiagnosticCategory { + TypeScript, + ImportPolicy, + Compile, + Runtime, + HostBridge, + State, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasDiagnostic { + pub severity: CanvasDiagnosticSeverity, + pub category: CanvasDiagnosticCategory, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggested_fix: Option, +} + +impl CanvasDiagnostic { + pub fn error( + category: CanvasDiagnosticCategory, + message: impl Into, + code: impl Into, + ) -> Self { + Self { + severity: CanvasDiagnosticSeverity::Error, + category, + message: message.into(), + code: Some(code.into()), + line: None, + column: None, + suggested_fix: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasArtifact { + pub id: CanvasId, + #[serde(default)] + pub scope: CanvasScope, + pub session_id: CanvasSessionId, + pub workspace_id: CanvasWorkspaceId, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source_revision: CanvasRevision, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_compiled_revision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_known_good_revision: Option, + #[serde(default)] + pub status: CanvasStatus, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasSource { + pub canvas_id: CanvasId, + pub revision: CanvasRevision, + pub filename: String, + pub language: String, + pub source: String, + pub sdk_version: String, + pub created_at: i64, +} + +impl CanvasSource { + pub fn new_tsx( + canvas_id: CanvasId, + revision: CanvasRevision, + filename: impl Into, + source: impl Into, + sdk_version: impl Into, + created_at: i64, + ) -> Self { + Self { + canvas_id, + revision, + filename: filename.into(), + language: CANVAS_SOURCE_LANGUAGE_TSX.to_string(), + source: source.into(), + sdk_version: sdk_version.into(), + created_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCompiledPayload { + pub canvas_id: CanvasId, + pub source_revision: CanvasRevision, + pub sdk_version: String, + pub runtime_version: String, + pub html: String, + pub content_hash: String, + pub diagnostics: Vec, + pub compiled_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasState { + pub canvas_id: CanvasId, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_revision_seen: Option, + #[serde(default)] + pub values: BTreeMap, + pub updated_at: i64, + #[serde(default = "default_state_schema_version")] + pub schema_version: u32, +} + +fn default_state_schema_version() -> u32 { + CANVAS_CURRENT_STATE_SCHEMA_VERSION +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasArtifactRef { + pub scheme: String, + pub session_id: CanvasSessionId, + pub canvas_id: CanvasId, +} + +impl CanvasArtifactRef { + pub fn new(session_id: CanvasSessionId, canvas_id: CanvasId) -> Self { + Self { + scheme: CANVAS_ARTIFACT_REF_SCHEME.to_string(), + session_id, + canvas_id, + } + } + + pub fn to_uri(&self) -> String { + format!( + "{}://session/{}/canvas/{}", + self.scheme, + percent_encode_segment(self.session_id.as_str()), + percent_encode_segment(self.canvas_id.as_str()) + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasCapabilityStatus { + pub supported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl CanvasCapabilityStatus { + pub fn supported() -> Self { + Self { + supported: true, + reason: None, + } + } + + pub fn unsupported(reason: impl Into) -> Self { + Self { + supported: false, + reason: Some(reason.into()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CanvasSnapshot { + pub artifact: CanvasArtifact, + pub source: CanvasSource, + #[serde(default)] + pub diagnostics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub compiled_payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, +} + +fn percent_encode_segment(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index 33a55128a..8d4d3c6ce 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -3,6 +3,8 @@ //! Product subdomains live here when they can be compiled without depending on //! the full BitFun core runtime assembly. +pub mod canvas; + #[cfg(feature = "miniapp")] pub mod miniapp; diff --git a/src/crates/contracts/product-domains/tests/canvas_contracts.rs b/src/crates/contracts/product-domains/tests/canvas_contracts.rs new file mode 100644 index 000000000..f48313bf1 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/canvas_contracts.rs @@ -0,0 +1,193 @@ +use bitfun_product_domains::canvas::{ + parse_canvas_artifact_ref, validate_canvas_imports, validate_canvas_source_policy, + CanvasArtifact, CanvasArtifactRef, CanvasDiagnostic, CanvasDiagnosticCategory, + CanvasDiagnosticSeverity, CanvasId, CanvasImportPolicyDiagnosticKind, CanvasRevision, + CanvasScope, CanvasSessionId, CanvasSource, CanvasStatus, CanvasWorkspaceId, + CANVAS_SOURCE_LANGUAGE_TSX, +}; + +#[test] +fn canvas_artifact_ref_uses_logical_uri_not_path() { + let reference = + CanvasArtifactRef::new(CanvasSessionId::new("session 1"), CanvasId::new("canvas 1")); + + let uri = reference.to_uri(); + + assert_eq!(uri, "bitfun-canvas://session/session%201/canvas/canvas%201"); + assert!(!uri.contains("/Users/")); + assert!(!uri.contains("\\")); + + let parsed = parse_canvas_artifact_ref(&uri).expect("reference should parse"); + assert_eq!(parsed, reference); +} + +#[test] +fn canvas_artifact_ref_rejects_unsafe_path_segments() { + for uri in [ + "bitfun-canvas://session/..%2Fother/canvas/canvas_1", + "bitfun-canvas://session/../canvas/canvas_1", + "bitfun-canvas://session/session_1/canvas/canvas%2Fwith%2Fslash", + "bitfun-canvas://session/session%5C1/canvas/canvas_1", + ] { + assert!( + parse_canvas_artifact_ref(uri).is_err(), + "unsafe Canvas artifact ref should fail: {uri}" + ); + } +} + +#[test] +fn canvas_artifact_ref_rejects_non_canvas_uri() { + let error = + parse_canvas_artifact_ref("file:///Users/user/project/canvas.tsx").expect_err("must fail"); + + assert_eq!( + serde_json::to_value(error).unwrap(), + serde_json::json!("invalid_scheme") + ); +} + +#[test] +fn canvas_artifact_serializes_with_camel_case_fields() { + let artifact = CanvasArtifact { + id: CanvasId::new("canvas_1"), + scope: CanvasScope::Session, + session_id: CanvasSessionId::new("session_1"), + workspace_id: CanvasWorkspaceId::new("workspace_1"), + title: "Review Matrix".to_string(), + description: Some("Generated from review notes".to_string()), + source_revision: CanvasRevision::new("rev_1"), + latest_compiled_revision: None, + last_known_good_revision: None, + status: CanvasStatus::SourceSaved, + created_at: 1_000, + updated_at: 1_001, + }; + + let value = serde_json::to_value(&artifact).unwrap(); + + assert_eq!(value["sourceRevision"], "rev_1"); + assert_eq!(value["sessionId"], "session_1"); + assert_eq!(value["workspaceId"], "workspace_1"); + assert!(value.get("latest_compiled_revision").is_none()); +} + +#[test] +fn canvas_source_defaults_to_tsx_contract() { + let source = CanvasSource::new_tsx( + CanvasId::new("canvas_1"), + CanvasRevision::new("rev_1"), + "review.tsx", + "export default function Review() { return null; }", + "0.1.0", + 42, + ); + + assert_eq!(source.language, CANVAS_SOURCE_LANGUAGE_TSX); + assert_eq!(source.filename, "review.tsx"); +} + +#[test] +fn canvas_diagnostic_shape_is_structured() { + let diagnostic = CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::ImportPolicy, + message: "Only bitfun/canvas imports are allowed".to_string(), + code: Some("canvas.import.unsupported".to_string()), + line: Some(2), + column: Some(8), + suggested_fix: Some("Import UI helpers from bitfun/canvas.".to_string()), + }; + + let value = serde_json::to_value(&diagnostic).unwrap(); + + assert_eq!(value["severity"], "error"); + assert_eq!(value["category"], "import_policy"); + assert_eq!( + value["suggestedFix"], + "Import UI helpers from bitfun/canvas." + ); +} + +#[test] +fn canvas_import_policy_rejects_relative_and_dynamic_imports() { + let source = r#" +import { Stack } from 'bitfun/canvas'; +import React from 'react'; +import helper from './helper'; +export * from './exports'; +const later = import('lodash'); +"#; + + let diagnostics = validate_canvas_imports(source); + + assert_eq!(diagnostics.len(), 3); + assert_eq!( + diagnostics[0].kind, + CanvasImportPolicyDiagnosticKind::RelativeImport + ); + assert_eq!(diagnostics[0].specifier, "./helper"); + assert_eq!(diagnostics[0].line, Some(4)); + assert_eq!( + diagnostics[1].kind, + CanvasImportPolicyDiagnosticKind::RelativeImport + ); + assert_eq!(diagnostics[1].specifier, "./exports"); + assert_eq!(diagnostics[1].line, Some(5)); + assert_eq!( + diagnostics[2].kind, + CanvasImportPolicyDiagnosticKind::DynamicImport + ); + assert_eq!(diagnostics[2].specifier, "lodash"); + assert_eq!(diagnostics[2].line, Some(6)); +} + +#[test] +fn canvas_import_policy_allows_canvas_compat_imports() { + let source = r#" +import { useState, useEffect } from 'react'; +import { Stack } from 'cursor/canvas'; +import { Text } from 'bitfun/canvas'; +"#; + + let diagnostics = validate_canvas_imports(source); + + assert!(diagnostics.is_empty()); +} + +#[test] +fn canvas_source_policy_returns_structured_diagnostics() { + let mut source = CanvasSource::new_tsx( + CanvasId::new("canvas_1"), + CanvasRevision::new("rev_1"), + "canvas.jsx", + "import React from 'react'; function C() { return null; }", + "0.1.0", + 1, + ); + source.language = "jsx".to_string(); + + let diagnostics = validate_canvas_source_policy(&source); + + assert_eq!(diagnostics.len(), 3); + assert_eq!( + diagnostics[0].category, + CanvasDiagnosticCategory::Unsupported + ); + assert_eq!( + diagnostics[0].code.as_deref(), + Some("canvas.source.language_unsupported") + ); + assert_eq!( + diagnostics[1].category, + CanvasDiagnosticCategory::Unsupported + ); + assert_eq!( + diagnostics[2].category, + CanvasDiagnosticCategory::TypeScript + ); + assert_eq!( + diagnostics[2].category, + CanvasDiagnosticCategory::TypeScript + ); +} From b8f6335de9ac6e86c56c7ad2f9a4907da932674a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 3 Jul 2026 19:07:18 +0800 Subject: [PATCH 02/10] feat(canvas): wire core tools and storage --- src/crates/assembly/core/Cargo.toml | 3 + .../agentic/agents/definitions/modes/claw.rs | 21 +- .../agents/definitions/modes/cowork.rs | 21 +- .../assembly/core/src/agentic/agents/mod.rs | 16 + .../tools/implementations/canvas_tools.rs | 1196 +++++++++++++++ .../src/agentic/tools/implementations/mod.rs | 4 + .../tools/product_runtime/materialization.rs | 8 + .../core/src/agentic/tools/registry.rs | 5 + .../src/service/canvas/compiler/analysis.rs | 756 ++++++++++ .../service/canvas/compiler/diagnostics.rs | 46 + .../core/src/service/canvas/compiler/html.rs | 55 + .../core/src/service/canvas/compiler/mod.rs | 113 ++ .../core/src/service/canvas/compiler/oxc.rs | 113 ++ .../canvas/compiler/runtime_bootstrap.js | 1282 +++++++++++++++++ .../service/canvas/compiler/runtime_style.css | 42 + .../service/canvas/compiler/sdk_contract.rs | 492 +++++++ .../core/src/service/canvas/compiler/tests.rs | 773 ++++++++++ .../assembly/core/src/service/canvas/mod.rs | 834 +++++++++++ src/crates/assembly/core/src/service/mod.rs | 4 + .../execution/tool-provider-groups/src/lib.rs | 8 + 20 files changed, 5786 insertions(+), 6 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/analysis.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/diagnostics.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/html.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/mod.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/oxc.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/runtime_bootstrap.js create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/runtime_style.css create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/sdk_contract.rs create mode 100644 src/crates/assembly/core/src/service/canvas/compiler/tests.rs create mode 100644 src/crates/assembly/core/src/service/canvas/mod.rs diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index a793dc5b1..49c05128e 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -49,6 +49,7 @@ tower-http = { workspace = true, optional = true } glob = { workspace = true, optional = true } notify = { workspace = true } +oxc = { workspace = true, optional = true } dirs = { workspace = true } dunce = { workspace = true } filetime = { workspace = true, optional = true } @@ -156,6 +157,7 @@ schannel = "0.1" default = ["product-full"] product-full = [ "ai-adapter-runtime", + "canvas-compiler", "dep:chrono-tz", "dep:cron", "dep:dashmap", @@ -208,6 +210,7 @@ service-integrations = [ ] tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full", "dep:image"] tauri-support = ["tauri"] # Optional tauri support +canvas-compiler = ["dep:oxc", "product-domains"] ssh-remote = [ "bitfun-services-integrations/remote-ssh-concrete", "russh", diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 4067056c1..fb5949fd8 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -41,6 +41,14 @@ impl ClawMode { // agent/tool instead of being surfaced as a ControlHub domain. "ControlHub".to_string(), "InitMiniApp".to_string(), + #[cfg(feature = "product-domains")] + "CreateCanvas".to_string(), + #[cfg(feature = "product-domains")] + "ReadCanvas".to_string(), + #[cfg(feature = "product-domains")] + "UpdateCanvas".to_string(), + #[cfg(feature = "product-domains")] + "PatchCanvas".to_string(), ], } } @@ -91,8 +99,15 @@ mod tests { #[test] fn claw_mode_includes_init_miniapp_in_default_tools() { - assert!(ClawMode::new() - .default_tools() - .contains(&"InitMiniApp".to_string())); + let tools = ClawMode::new().default_tools(); + assert!(tools.contains(&"InitMiniApp".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"CreateCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"ReadCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"UpdateCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"PatchCanvas".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index e107490f3..c20aada03 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -52,6 +52,14 @@ impl CoworkMode { "WebFetch".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + #[cfg(feature = "product-domains")] + "CreateCanvas".to_string(), + #[cfg(feature = "product-domains")] + "ReadCanvas".to_string(), + #[cfg(feature = "product-domains")] + "UpdateCanvas".to_string(), + #[cfg(feature = "product-domains")] + "PatchCanvas".to_string(), ], } } @@ -106,8 +114,15 @@ mod tests { #[test] fn cowork_mode_includes_init_miniapp_in_default_tools() { - assert!(CoworkMode::new() - .default_tools() - .contains(&"InitMiniApp".to_string())); + let tools = CoworkMode::new().default_tools(); + assert!(tools.contains(&"InitMiniApp".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"CreateCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"ReadCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"UpdateCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"PatchCanvas".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 4bd357006..c6a595124 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -91,6 +91,14 @@ pub fn shared_coding_mode_tools() -> Vec { "get_goal".to_string(), "create_goal".to_string(), "update_goal".to_string(), + #[cfg(feature = "product-domains")] + "CreateCanvas".to_string(), + #[cfg(feature = "product-domains")] + "ReadCanvas".to_string(), + #[cfg(feature = "product-domains")] + "UpdateCanvas".to_string(), + #[cfg(feature = "product-domains")] + "PatchCanvas".to_string(), "GenerativeUI".to_string(), "Skill".to_string(), "AskUserQuestion".to_string(), @@ -259,6 +267,14 @@ mod tests { assert!(tools.contains(&"Log".to_string())); assert!(tools.contains(&"get_goal".to_string())); assert!(tools.contains(&"update_goal".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"CreateCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"ReadCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"UpdateCanvas".to_string())); + #[cfg(feature = "product-domains")] + assert!(tools.contains(&"PatchCanvas".to_string())); } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs new file mode 100644 index 000000000..ecbd20471 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs @@ -0,0 +1,1196 @@ +//! Canvas artifact tools. + +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::service::canvas::{get_global_canvas_service_arc, CanvasService}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_product_domains::canvas::{ + parse_canvas_artifact_ref, CanvasArtifact, CanvasArtifactRef, CanvasId, CanvasRevision, + CanvasScope, CanvasSessionId, CanvasSnapshot, CanvasSource, CanvasStatus, CanvasStoragePort, + CanvasWorkspaceId, BITFUN_CANVAS_SDK_VERSION, +}; +use chrono::Utc; +use serde_json::{json, Value}; +use std::sync::Arc; + +pub struct CreateCanvasTool; +pub struct ReadCanvasTool; +pub struct UpdateCanvasTool; +pub struct PatchCanvasTool; + +struct CanvasReplacement { + old: String, + new: String, +} + +impl CreateCanvasTool { + pub fn new() -> Self { + Self + } +} + +impl Default for CreateCanvasTool { + fn default() -> Self { + Self::new() + } +} + +impl ReadCanvasTool { + pub fn new() -> Self { + Self + } +} + +impl Default for ReadCanvasTool { + fn default() -> Self { + Self::new() + } +} + +impl UpdateCanvasTool { + pub fn new() -> Self { + Self + } +} + +impl Default for UpdateCanvasTool { + fn default() -> Self { + Self::new() + } +} + +impl PatchCanvasTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PatchCanvasTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for CreateCanvasTool { + fn name(&self) -> &str { + "CreateCanvas" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Create a session-scoped BitFun Canvas artifact from a single TSX source file. + +Use this for rich visual artifacts, dashboards, explainers, interactive summaries, charts, diagrams, and compact apps that should render beside the conversation instead of being written into the user's repository. + +Rules: +- Provide one complete TSX source string. +- Import only from `bitfun/canvas`. +- Do not use relative imports, dynamic imports, npm packages, network fetches, or helper files. +- The source must include `export default`. + +Returns a stable `bitfun-canvas://...` artifact reference. Use ReadCanvas to inspect it, PatchCanvas for small targeted revisions, and UpdateCanvas for full-source rewrites."# + .to_string()) + } + + fn short_description(&self) -> String { + "Create a session-scoped BitFun Canvas artifact.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["title", "source"], + "properties": { + "title": { + "type": "string", + "description": "Short display title for the Canvas artifact." + }, + "description": { + "type": "string", + "description": "Optional one-sentence description." + }, + "source": { + "type": "string", + "description": "Complete single-file TSX source using imports from bitfun/canvas only." + }, + "filename": { + "type": "string", + "description": "Optional .tsx filename. Defaults to a sanitized title." + } + } + }) + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let title = required_non_empty_string(input, "title")?; + let source = normalize_canvas_source_input(required_non_empty_string(input, "source")?); + let description = optional_non_empty_string(input, "description"); + let filename = input + .get("filename") + .and_then(|value| value.as_str()) + .map(sanitize_canvas_filename) + .unwrap_or_else(|| sanitize_canvas_filename(title)); + let session_id = require_session_id(context)?; + let workspace_id = workspace_id_for_context(context); + let now = now_millis(); + let canvas_id = CanvasId::new(format!("canvas_{}", uuid_short())); + let revision = CanvasRevision::new(format!("rev_{}", uuid_short())); + let artifact = CanvasArtifact { + id: canvas_id.clone(), + scope: CanvasScope::Session, + session_id: session_id.clone(), + workspace_id, + title: title.to_string(), + description: description.map(str::to_string), + source_revision: revision.clone(), + latest_compiled_revision: None, + last_known_good_revision: None, + status: CanvasStatus::SourceSaved, + created_at: now, + updated_at: now, + }; + let source = CanvasSource::new_tsx( + canvas_id.clone(), + revision, + filename, + source, + BITFUN_CANVAS_SDK_VERSION, + now, + ); + + let service = canvas_service_for_context(context); + service + .save_source(artifact, source, Vec::new()) + .await + .map_err(canvas_port_error)?; + let compile_result = service + .compile_latest(session_id.clone(), canvas_id.clone(), now) + .await + .map_err(canvas_port_error)?; + let snapshot = service + .load_snapshot(session_id.clone(), canvas_id.clone()) + .await + .map_err(canvas_port_error)?; + + Ok(vec![canvas_tool_result( + "created", + &snapshot, + compile_result.compiled, + )]) + } +} + +#[async_trait] +impl Tool for ReadCanvasTool { + fn name(&self) -> &str { + "ReadCanvas" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Read a BitFun Canvas artifact from the current session. + +Provide either `artifact_reference` returned by CreateCanvas/PatchCanvas/UpdateCanvas or `canvas_id` for the current session. By default this returns metadata, status, diagnostics, and source. Set `include_source` to false when only status metadata is needed."# + .to_string()) + } + + fn short_description(&self) -> String { + "Read Canvas metadata, diagnostics, and source.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "artifact_reference": { + "type": "string", + "description": "Stable bitfun-canvas:// artifact reference." + }, + "canvas_id": { + "type": "string", + "description": "Canvas id in the current session." + }, + "include_source": { + "type": "boolean", + "description": "Whether to include the TSX source. Defaults to true." + }, + "include_compiled_payload": { + "type": "boolean", + "description": "Whether to include the compiled HTML payload. Defaults to false." + } + } + }) + } + + fn is_readonly(&self) -> bool { + true + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let (session_id, canvas_id) = resolve_canvas_target(input, context)?; + let snapshot = canvas_service_for_context(context) + .load_snapshot(session_id, canvas_id) + .await + .map_err(canvas_port_error)?; + let include_source = input + .get("include_source") + .and_then(|value| value.as_bool()) + .unwrap_or(true); + let include_compiled_payload = input + .get("include_compiled_payload") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let mut data = snapshot_data(&snapshot, include_source); + if include_compiled_payload { + data["compiledPayload"] = serde_json::to_value(&snapshot.compiled_payload) + .map_err(|error| BitFunError::tool(error.to_string()))?; + } + + let assistant_text = canvas_read_result_for_assistant(&snapshot, include_source); + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(assistant_text), + image_attachments: None, + }]) + } +} + +#[async_trait] +impl Tool for UpdateCanvasTool { + fn name(&self) -> &str { + "UpdateCanvas" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Replace the TSX source for an existing BitFun Canvas artifact. + +Provide either `artifact_reference` or `canvas_id`, plus one complete replacement `source` string. The Canvas remains session-scoped and keeps its stable artifact reference. The previous compiled payload is retained as last-known-good if the new source fails policy or compile validation."# + .to_string()) + } + + fn short_description(&self) -> String { + "Update an existing BitFun Canvas artifact.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["source"], + "properties": { + "artifact_reference": { + "type": "string", + "description": "Stable bitfun-canvas:// artifact reference." + }, + "canvas_id": { + "type": "string", + "description": "Canvas id in the current session." + }, + "source": { + "type": "string", + "description": "Complete replacement TSX source using imports from bitfun/canvas only." + }, + "title": { + "type": "string", + "description": "Optional replacement display title." + }, + "description": { + "type": "string", + "description": "Optional replacement description. Omit to preserve the current value." + }, + "filename": { + "type": "string", + "description": "Optional replacement .tsx filename. Omit to preserve the current value." + } + } + }) + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let source_text = + normalize_canvas_source_input(required_non_empty_string(input, "source")?); + let (session_id, canvas_id) = resolve_canvas_target(input, context)?; + let service = canvas_service_for_context(context); + let existing = service + .load_snapshot(session_id.clone(), canvas_id.clone()) + .await + .map_err(canvas_port_error)?; + let (snapshot, compiled) = save_canvas_source_revision( + &service, + session_id, + canvas_id, + existing, + source_text, + input, + ) + .await?; + + Ok(vec![canvas_tool_result("updated", &snapshot, compiled)]) + } +} + +#[async_trait] +impl Tool for PatchCanvasTool { + fn name(&self) -> &str { + "PatchCanvas" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Patch an existing BitFun Canvas artifact by applying exact text replacements to the latest TSX source. + +Use this for small, targeted edits such as changing a label, number, style prop, component prop, or a short JSX block. Provide either `artifact_reference` or `canvas_id`, plus one or more replacements. Each `old` text must match the current source exactly once; the tool fails without saving if a replacement is missing or ambiguous. For large rewrites, use UpdateCanvas with a complete replacement source."# + .to_string()) + } + + fn short_description(&self) -> String { + "Patch an existing BitFun Canvas artifact.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["replacements"], + "properties": { + "artifact_reference": { + "type": "string", + "description": "Stable bitfun-canvas:// artifact reference." + }, + "canvas_id": { + "type": "string", + "description": "Canvas id in the current session." + }, + "replacements": { + "type": "array", + "minItems": 1, + "description": "Exact source replacements applied in order. Each old text must match exactly once in the current TSX source.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["old", "new"], + "properties": { + "old": { + "type": "string", + "description": "Exact source text to replace. Must occur exactly once." + }, + "new": { + "type": "string", + "description": "Replacement source text." + } + } + } + }, + "title": { + "type": "string", + "description": "Optional replacement display title." + }, + "description": { + "type": "string", + "description": "Optional replacement description. Omit to preserve the current value." + }, + "filename": { + "type": "string", + "description": "Optional replacement .tsx filename. Omit to preserve the current value." + } + } + }) + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let replacements = parse_canvas_replacements(input)?; + let (session_id, canvas_id) = resolve_canvas_target(input, context)?; + let service = canvas_service_for_context(context); + let existing = service + .load_snapshot(session_id.clone(), canvas_id.clone()) + .await + .map_err(canvas_port_error)?; + let patched_source = apply_canvas_replacements(&existing.source.source, &replacements)?; + let (snapshot, compiled) = save_canvas_source_revision( + &service, + session_id, + canvas_id, + existing, + patched_source, + input, + ) + .await?; + + Ok(vec![canvas_tool_result("patched", &snapshot, compiled)]) + } +} + +async fn save_canvas_source_revision( + service: &CanvasService, + session_id: CanvasSessionId, + canvas_id: CanvasId, + existing: CanvasSnapshot, + source_text: String, + input: &Value, +) -> BitFunResult<(CanvasSnapshot, bool)> { + let now = now_millis(); + let revision = CanvasRevision::new(format!("rev_{}", uuid_short())); + let mut artifact = existing.artifact.clone(); + artifact.title = optional_non_empty_string(input, "title") + .map(str::to_string) + .unwrap_or(artifact.title); + if let Some(description) = optional_non_empty_string(input, "description") { + artifact.description = Some(description.to_string()); + } + artifact.source_revision = revision.clone(); + artifact.status = CanvasStatus::SourceSaved; + artifact.updated_at = now; + + let filename = input + .get("filename") + .and_then(|value| value.as_str()) + .map(sanitize_canvas_filename) + .unwrap_or(existing.source.filename); + let source = CanvasSource::new_tsx( + canvas_id.clone(), + revision, + filename, + source_text, + BITFUN_CANVAS_SDK_VERSION, + now, + ); + + service + .save_source(artifact, source, Vec::new()) + .await + .map_err(canvas_port_error)?; + let compile_result = service + .compile_latest(session_id.clone(), canvas_id.clone(), now) + .await + .map_err(canvas_port_error)?; + let snapshot = service + .load_snapshot(session_id, canvas_id) + .await + .map_err(canvas_port_error)?; + + Ok((snapshot, compile_result.compiled)) +} + +fn canvas_tool_result(action: &str, snapshot: &CanvasSnapshot, compiled: bool) -> ToolResult { + let reference = artifact_reference(snapshot).to_uri(); + let compiled_payload = snapshot.compiled_payload.as_ref().map(|payload| { + json!({ + "sourceRevision": payload.source_revision, + "sdkVersion": payload.sdk_version, + "runtimeVersion": payload.runtime_version, + "contentHash": payload.content_hash, + "compiledAt": payload.compiled_at, + }) + }); + let data = json!({ + "success": true, + "action": action, + "artifactReference": reference, + "compiled": compiled, + "diagnosticCount": snapshot.diagnostics.len(), + "compiledPayload": compiled_payload, + "canvas": snapshot_data(snapshot, true), + }); + let assistant_text = canvas_result_for_assistant(action, &reference, snapshot, compiled); + ToolResult::Result { + data, + result_for_assistant: Some(assistant_text), + image_attachments: None, + } +} + +fn canvas_result_for_assistant( + action: &str, + reference: &str, + snapshot: &CanvasSnapshot, + compiled: bool, +) -> String { + let mut message = format!( + "Canvas {}: {}. Status: {:?}. Source compiled: {}. Diagnostics: {}. Host render errors are reported later as runtime diagnostics on the same Canvas artifact.", + action, + reference, + snapshot.artifact.status, + compiled, + snapshot.diagnostics.len() + ); + + if !snapshot.diagnostics.is_empty() { + message.push_str("\nDiagnostics:"); + for (index, diagnostic) in snapshot.diagnostics.iter().take(5).enumerate() { + message.push_str(&format!("\n{}. {}", index + 1, diagnostic.message)); + if let Some(code) = diagnostic.code.as_deref() { + message.push_str(&format!(" [{}]", code)); + } + if let (Some(line), Some(column)) = (diagnostic.line, diagnostic.column) { + message.push_str(&format!(" at line {}, column {}", line, column)); + } + if let Some(fix) = diagnostic.suggested_fix.as_deref() { + message.push_str(&format!(". Suggested fix: {}", fix)); + } + } + } + + if !compiled { + let preview = canvas_source_preview(&snapshot.source.source); + if !preview.is_empty() { + message.push_str("\nSource starts with:\n"); + message.push_str(&preview); + } + } + + message +} + +fn canvas_read_result_for_assistant(snapshot: &CanvasSnapshot, include_source: bool) -> String { + let reference = artifact_reference(snapshot).to_uri(); + let mut message = format!( + "Canvas read: {}. Status: {:?}. Diagnostics: {}.", + reference, + snapshot.artifact.status, + snapshot.diagnostics.len() + ); + + if include_source { + message.push_str(&format!( + "\nSource revision: {}\nFilename: {}\nSource:\n```tsx\n{}\n```", + snapshot.source.revision.as_str(), + snapshot.source.filename, + snapshot.source.source + )); + } + + message +} + +fn normalize_canvas_source_input(source: &str) -> String { + let trimmed = source.trim(); + let Some(after_start) = trimmed.strip_prefix("") else { + return source.to_string(); + }; + inner.trim().to_string() +} + +fn parse_canvas_replacements(input: &Value) -> BitFunResult> { + let values = input + .get("replacements") + .and_then(|value| value.as_array()) + .filter(|values| !values.is_empty()) + .ok_or_else(|| BitFunError::validation("Missing required field: replacements"))?; + + values + .iter() + .enumerate() + .map(|(index, value)| { + let old = value + .get("old") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + BitFunError::validation(format!( + "Missing required field: replacements[{}].old", + index + )) + })?; + if old.is_empty() { + return Err(BitFunError::validation(format!( + "replacements[{}].old must not be empty", + index + ))); + } + let new = value + .get("new") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + BitFunError::validation(format!( + "Missing required field: replacements[{}].new", + index + )) + })?; + Ok(CanvasReplacement { + old: normalize_canvas_source_input(old), + new: normalize_canvas_source_input(new), + }) + }) + .collect() +} + +fn apply_canvas_replacements( + source: &str, + replacements: &[CanvasReplacement], +) -> BitFunResult { + let mut patched = source.to_string(); + for (index, replacement) in replacements.iter().enumerate() { + let matches = patched.match_indices(&replacement.old).count(); + match matches { + 0 => { + return Err(BitFunError::validation(format!( + "Canvas patch replacement {} did not match the current source", + index + 1 + ))); + } + 1 => { + patched = patched.replacen(&replacement.old, &replacement.new, 1); + } + count => { + return Err(BitFunError::validation(format!( + "Canvas patch replacement {} matched {} locations; provide a more specific old text", + index + 1, + count + ))); + } + } + } + Ok(patched) +} + +fn canvas_source_preview(source: &str) -> String { + const MAX_PREVIEW_CHARS: usize = 320; + source + .chars() + .take(MAX_PREVIEW_CHARS) + .collect::() + .trim() + .to_string() +} + +fn canvas_service_for_context(context: &ToolUseContext) -> Arc { + context + .workspace + .as_ref() + .map(|workspace| Arc::new(CanvasService::persistent(workspace.session_storage_dir()))) + .unwrap_or_else(get_global_canvas_service_arc) +} + +fn snapshot_data(snapshot: &CanvasSnapshot, include_source: bool) -> Value { + let reference = artifact_reference(snapshot).to_uri(); + let mut data = json!({ + "artifact": &snapshot.artifact, + "artifactReference": reference, + "status": snapshot.artifact.status, + "diagnostics": &snapshot.diagnostics, + "compiled": snapshot.compiled_payload.is_some(), + "latestCompiledRevision": snapshot.artifact.latest_compiled_revision, + "lastKnownGoodRevision": snapshot.artifact.last_known_good_revision, + "state": &snapshot.state, + }); + if include_source { + data["source"] = serde_json::to_value(&snapshot.source).unwrap_or(Value::Null); + } + data +} + +fn artifact_reference(snapshot: &CanvasSnapshot) -> CanvasArtifactRef { + CanvasArtifactRef::new( + snapshot.artifact.session_id.clone(), + snapshot.artifact.id.clone(), + ) +} + +fn resolve_canvas_target( + input: &Value, + context: &ToolUseContext, +) -> BitFunResult<(CanvasSessionId, CanvasId)> { + if let Some(reference) = optional_non_empty_string(input, "artifact_reference") { + let parsed = parse_canvas_artifact_ref(reference).map_err(|error| { + BitFunError::validation(format!("Invalid artifact_reference: {error}")) + })?; + return Ok((parsed.session_id, parsed.canvas_id)); + } + + let session_id = require_session_id(context)?; + let canvas_id = optional_non_empty_string(input, "canvas_id") + .ok_or_else(|| { + BitFunError::validation( + "Provide either artifact_reference or canvas_id for the Canvas artifact", + ) + }) + .map(CanvasId::new)?; + Ok((session_id, canvas_id)) +} + +fn require_session_id(context: &ToolUseContext) -> BitFunResult { + context + .session_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(CanvasSessionId::new) + .ok_or_else(|| BitFunError::tool("session_id is required to use Canvas tools")) +} + +fn workspace_id_for_context(context: &ToolUseContext) -> CanvasWorkspaceId { + CanvasWorkspaceId::new( + context + .current_workspace_scope() + .unwrap_or_else(|| "current".to_string()), + ) +} + +fn required_non_empty_string<'a>(input: &'a Value, key: &str) -> BitFunResult<&'a str> { + input + .get(key) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| BitFunError::validation(format!("Missing required field: {key}"))) +} + +fn optional_non_empty_string<'a>(input: &'a Value, key: &str) -> Option<&'a str> { + input + .get(key) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn sanitize_canvas_filename(value: &str) -> String { + let trimmed = value.trim(); + let without_extension = trimmed + .strip_suffix(".tsx") + .or_else(|| trimmed.strip_suffix(".TSX")) + .unwrap_or(trimmed); + let mut filename = without_extension + .trim() + .to_lowercase() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect::(); + while filename.contains("--") { + filename = filename.replace("--", "-"); + } + filename = filename.trim_matches('-').to_string(); + if filename.is_empty() { + filename = "canvas".to_string(); + } + format!("{filename}.tsx") +} + +fn uuid_short() -> String { + uuid::Uuid::new_v4() + .to_string() + .split('-') + .next() + .unwrap_or("00000000") + .to_string() +} + +fn now_millis() -> i64 { + Utc::now().timestamp_millis() +} + +fn canvas_port_error(error: bitfun_product_domains::canvas::CanvasPortError) -> BitFunError { + BitFunError::tool(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::Tool; + use crate::agentic::tools::ToolRuntimeRestrictions; + use bitfun_runtime_ports::ToolRuntimeHandles; + use std::collections::HashMap; + + fn test_context(session_id: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some("Agent".to_string()), + session_id: Some(session_id.to_string()), + dialog_turn_id: Some("turn_1".to_string()), + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: ToolRuntimeHandles::new(None, None), + } + } + + fn valid_source() -> &'static str { + "import { Stack } from 'bitfun/canvas'; export default function Canvas() { return ; }" + } + + #[tokio::test] + async fn create_read_and_update_canvas_round_trip() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Build Health", + "source": valid_source(), + }), + &context, + ) + .await + .expect("canvas should create"); + let ToolResult::Result { data, .. } = &created[0] else { + panic!("expected result"); + }; + let reference = data["artifactReference"] + .as_str() + .expect("reference should be returned"); + assert!(reference.starts_with("bitfun-canvas://session/")); + assert_eq!( + data["compiledPayload"]["html"], Value::Null, + "write tool results should not persist full compiled HTML" + ); + assert!(data["compiledPayload"]["contentHash"].is_string()); + + let read = ReadCanvasTool::new(); + let read_result = read + .call_impl(&json!({ "artifact_reference": reference }), &context) + .await + .expect("canvas should read"); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &read_result[0] + else { + panic!("expected result"); + }; + assert_eq!(data["artifact"]["title"], "Build Health"); + assert_eq!(data["source"]["filename"], "build-health.tsx"); + let assistant = result_for_assistant.as_deref().unwrap_or_default(); + assert!(assistant.contains("Source revision:"), "{assistant}"); + assert!(assistant.contains("Filename: build-health.tsx"), "{assistant}"); + assert!(assistant.contains("```tsx"), "{assistant}"); + assert!(assistant.contains(valid_source()), "{assistant}"); + + let update = UpdateCanvasTool::new(); + let updated = update + .call_impl( + &json!({ + "artifact_reference": reference, + "source": "import helper from './helper'; export default function Canvas() { return null; }", + }), + &context, + ) + .await + .expect("canvas should update even when compile policy fails"); + let ToolResult::Result { data, .. } = &updated[0] else { + panic!("expected result"); + }; + assert_eq!(data["compiled"], false); + assert_eq!(data["canvas"]["status"], "compile_failed"); + assert_eq!( + data["canvas"]["lastKnownGoodRevision"], + data["canvas"]["latestCompiledRevision"] + ); + } + + #[tokio::test] + async fn read_canvas_can_omit_source_from_assistant_result() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Summary Only", + "source": valid_source(), + }), + &context, + ) + .await + .expect("canvas should create"); + let ToolResult::Result { data, .. } = &created[0] else { + panic!("expected result"); + }; + let reference = data["artifactReference"] + .as_str() + .expect("reference should be returned"); + + let read = ReadCanvasTool::new(); + let read_result = read + .call_impl( + &json!({ + "artifact_reference": reference, + "include_source": false, + }), + &context, + ) + .await + .expect("canvas should read"); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &read_result[0] + else { + panic!("expected result"); + }; + let assistant = result_for_assistant.as_deref().unwrap_or_default(); + + assert!(data.get("source").is_none()); + assert!(assistant.contains("Canvas read:"), "{assistant}"); + assert!(!assistant.contains("```tsx"), "{assistant}"); + assert!(!assistant.contains(valid_source()), "{assistant}"); + } + + #[tokio::test] + async fn patch_canvas_applies_unique_text_replacements() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Stats", + "source": "import { Stat } from 'bitfun/canvas'; export default function Canvas() { return ; }", + }), + &context, + ) + .await + .expect("canvas should create"); + let ToolResult::Result { data, .. } = &created[0] else { + panic!("expected result"); + }; + let reference = data["artifactReference"] + .as_str() + .expect("reference should be returned"); + + let patch = PatchCanvasTool::new(); + let patched = patch + .call_impl( + &json!({ + "artifact_reference": reference, + "replacements": [ + { + "old": "value=\"+191\"", + "new": "value=\"-191\"" + } + ] + }), + &context, + ) + .await + .expect("canvas should patch"); + let ToolResult::Result { data, .. } = &patched[0] else { + panic!("expected result"); + }; + + assert_eq!(data["action"], "patched"); + assert_eq!(data["compiled"], true); + assert!(data["canvas"]["source"]["source"] + .as_str() + .unwrap_or_default() + .contains("value=\"-191\"")); + } + + #[tokio::test] + async fn patch_canvas_rejects_ambiguous_replacements_without_saving() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Duplicate", + "source": "import { Text } from 'bitfun/canvas'; export default function Canvas() { return <>samesame; }", + }), + &context, + ) + .await + .expect("canvas should create"); + let ToolResult::Result { data, .. } = &created[0] else { + panic!("expected result"); + }; + let reference = data["artifactReference"] + .as_str() + .expect("reference should be returned"); + + let patch = PatchCanvasTool::new(); + let error = patch + .call_impl( + &json!({ + "artifact_reference": reference, + "replacements": [ + { + "old": "same", + "new": "changed" + } + ] + }), + &context, + ) + .await + .expect_err("ambiguous patch should fail"); + assert!(error.to_string().contains("matched 2 locations"), "{error}"); + + let read = ReadCanvasTool::new(); + let read_result = read + .call_impl(&json!({ "artifact_reference": reference }), &context) + .await + .expect("canvas should read"); + let ToolResult::Result { data, .. } = &read_result[0] else { + panic!("expected result"); + }; + assert!(!data["source"]["source"] + .as_str() + .unwrap_or_default() + .contains("changed")); + } + + #[tokio::test] + async fn patch_canvas_rejects_missing_replacements_without_saving() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Missing", + "source": valid_source(), + }), + &context, + ) + .await + .expect("canvas should create"); + let ToolResult::Result { data, .. } = &created[0] else { + panic!("expected result"); + }; + let reference = data["artifactReference"] + .as_str() + .expect("reference should be returned"); + + let patch = PatchCanvasTool::new(); + let error = patch + .call_impl( + &json!({ + "artifact_reference": reference, + "replacements": [ + { + "old": "does not exist", + "new": "replacement" + } + ] + }), + &context, + ) + .await + .expect_err("missing patch should fail"); + assert!(error.to_string().contains("did not match"), "{error}"); + + let read = ReadCanvasTool::new(); + let read_result = read + .call_impl(&json!({ "artifact_reference": reference }), &context) + .await + .expect("canvas should read"); + let ToolResult::Result { data, .. } = &read_result[0] else { + panic!("expected result"); + }; + assert_eq!(data["source"]["source"], valid_source()); + } + + #[tokio::test] + async fn create_canvas_strips_cdata_source_wrapper() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Wrapped", + "source": format!("", valid_source()), + }), + &context, + ) + .await + .expect("canvas should create from wrapped source"); + let ToolResult::Result { + data, + result_for_assistant, + .. + } = &created[0] + else { + panic!("expected result"); + }; + + assert_eq!(data["compiled"], true); + assert_eq!(data["canvas"]["status"], "compiled"); + assert_eq!(data["canvas"]["source"]["source"], valid_source()); + assert!(!result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("CDATA")); + } + + #[tokio::test] + async fn canvas_compile_failure_result_includes_diagnostic_details() { + let context = test_context(&format!("session_{}", uuid_short())); + let create = CreateCanvasTool::new(); + let created = create + .call_impl( + &json!({ + "title": "Broken", + "source": "\nexport default function Canvas() { return null; }", + }), + &context, + ) + .await + .expect("canvas should return diagnostics"); + let ToolResult::Result { + result_for_assistant, + .. + } = &created[0] + else { + panic!("expected result"); + }; + let assistant = result_for_assistant.as_deref().unwrap_or_default(); + + assert!(assistant.contains("Unexpected token"), "{assistant}"); + assert!(assistant.contains(" at line "), "{assistant}"); + assert!(assistant.contains(", column "), "{assistant}"); + assert!(assistant.contains("Source starts with"), "{assistant}"); + } + + #[test] + fn sanitize_canvas_filename_keeps_tsx_extension() { + assert_eq!(sanitize_canvas_filename("Build Health"), "build-health.tsx"); + assert_eq!( + sanitize_canvas_filename("Chart.canvas.tsx"), + "chart-canvas.tsx" + ); + } + + #[test] + fn normalize_canvas_source_input_strips_only_complete_cdata_wrapper() { + assert_eq!( + normalize_canvas_source_input(""), + "import { Text } from 'bitfun/canvas';" + ); + assert_eq!( + normalize_canvas_source_input(" for ProductConcreteToolFactory { "get_goal" => Some(Arc::new(GetGoalTool::new())), "create_goal" => Some(Arc::new(CreateGoalTool::new())), "update_goal" => Some(Arc::new(UpdateGoalTool::new())), + #[cfg(feature = "product-domains")] + "CreateCanvas" => Some(Arc::new(CreateCanvasTool::new())), + #[cfg(feature = "product-domains")] + "ReadCanvas" => Some(Arc::new(ReadCanvasTool::new())), + #[cfg(feature = "product-domains")] + "UpdateCanvas" => Some(Arc::new(UpdateCanvasTool::new())), + #[cfg(feature = "product-domains")] + "PatchCanvas" => Some(Arc::new(PatchCanvasTool::new())), "CreatePlan" => Some(Arc::new(CreatePlanTool::new())), "submit_code_review" => Some(Arc::new(CodeReviewTool::new())), "GetToolSpec" => Some(Arc::new(GetToolSpecTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 880b203b9..46f564773 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -356,6 +356,10 @@ mod tests { "get_goal", "create_goal", "update_goal", + "CreateCanvas", + "ReadCanvas", + "UpdateCanvas", + "PatchCanvas", "CreatePlan", "submit_code_review", "GetToolSpec", @@ -581,6 +585,7 @@ mod tests { "AskUserQuestion", "TodoWrite", "get_goal", + "ReadCanvas", "CreatePlan", "submit_code_review", "GetToolSpec", diff --git a/src/crates/assembly/core/src/service/canvas/compiler/analysis.rs b/src/crates/assembly/core/src/service/canvas/compiler/analysis.rs new file mode 100644 index 000000000..668eefb3a --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/analysis.rs @@ -0,0 +1,756 @@ +use bitfun_product_domains::canvas::types::{ + CanvasDiagnostic, CanvasDiagnosticCategory, CanvasDiagnosticSeverity, +}; + +#[cfg(feature = "canvas-compiler")] +use oxc::allocator::Allocator; +#[cfg(feature = "canvas-compiler")] +use oxc::ast::ast::{ + BindingIdentifier, BindingPattern, Class, ExportDefaultDeclarationKind, Function, + ImportDeclaration, ImportDeclarationSpecifier, ImportOrExportKind, JSXMemberExpression, + ModuleExportName, Statement, +}; +#[cfg(feature = "canvas-compiler")] +use oxc::parser::Parser; +#[cfg(feature = "canvas-compiler")] +use oxc::span::{GetSpan, SourceType}; +#[cfg(feature = "canvas-compiler")] +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(feature = "canvas-compiler")] +use std::path::Path; + +#[cfg(feature = "canvas-compiler")] +use super::diagnostics::oxc_diagnostics_to_canvas; +use super::{compile_error, line_column}; + +#[cfg(feature = "canvas-compiler")] +pub(super) fn validate_canvas_import_shadowing( + source: &str, + analysis: &CanvasModuleAnalysis, +) -> Vec { + analysis + .import_bindings + .local_names() + .into_iter() + .filter_map(|name| { + let declaration_offset = analysis.local_binding_offsets.get(&name).copied()?; + let (line, column) = line_column(source, declaration_offset); + Some(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::TypeScript, + message: format!( + "`{}` is imported from bitfun/canvas and also declared locally", + name + ), + code: Some("canvas.compile.sdk_name_shadowed".to_string()), + line: Some(line), + column: Some(column), + suggested_fix: Some(format!( + "Remove the local `{}` declaration or rename it; use the imported Canvas SDK component directly.", + name + )), + }) + }) + .collect() +} + +#[cfg(feature = "canvas-compiler")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CanvasModuleAnalysis { + pub(super) import_bindings: CanvasSdkImportBindings, + import_removal_spans: Vec<(usize, usize)>, + default_export: Option, + pub(super) local_binding_offsets: BTreeMap, +} + +#[cfg(feature = "canvas-compiler")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum CanvasDefaultExport { + Declaration { + start: usize, + end: usize, + expression_start: usize, + }, + Identifier { + start: usize, + end: usize, + name: String, + }, +} + +#[cfg(feature = "canvas-compiler")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CanvasSdkImportSource { + Canvas, + React, +} + +#[cfg(feature = "canvas-compiler")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CanvasSdkNamedImport { + pub(super) local: String, + canonical: String, + target_expression: String, +} + +#[cfg(feature = "canvas-compiler")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CanvasSdkNamespaceImport { + pub(super) local: String, + pub(super) source: CanvasSdkImportSource, +} + +#[cfg(feature = "canvas-compiler")] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct CanvasSdkImportBindings { + named: Vec, + pub(super) namespaces: Vec, +} + +#[cfg(feature = "canvas-compiler")] +impl CanvasSdkImportBindings { + pub(super) fn local_names(&self) -> Vec { + let mut names = self + .named + .iter() + .map(|binding| binding.local.clone()) + .chain( + self.namespaces + .iter() + .map(|namespace| namespace.local.clone()), + ) + .collect::>(); + names.sort(); + names.dedup(); + names + } + + pub(super) fn canonical_component_for_local(&self, local: &str) -> Option<&str> { + self.named + .iter() + .find(|binding| binding.local == local) + .map(|binding| binding.canonical.as_str()) + } + + pub(super) fn canonical_for_local(&self, local: &str) -> Option<&str> { + self.named + .iter() + .find(|binding| binding.local == local) + .map(|binding| binding.canonical.as_str()) + } + + pub(super) fn canonical_component_for_member( + &self, + member: &JSXMemberExpression<'_>, + ) -> Option { + let root = member.get_identifier()?.name.as_str(); + let namespace = self + .namespaces + .iter() + .find(|namespace| namespace.local == root)?; + if namespace.source != CanvasSdkImportSource::Canvas { + return None; + } + Some(member.property.name.to_string()) + } + + fn insert_named(&mut self, local: String, canonical: String, target_expression: String) { + if let Some(existing) = self.named.iter_mut().find(|binding| binding.local == local) { + existing.canonical = canonical; + existing.target_expression = target_expression; + return; + } + self.named.push(CanvasSdkNamedImport { + local, + canonical, + target_expression, + }); + } + + fn insert_namespace(&mut self, local: String, source: CanvasSdkImportSource) { + if let Some(existing) = self + .namespaces + .iter_mut() + .find(|namespace| namespace.local == local) + { + existing.source = source; + return; + } + self.namespaces + .push(CanvasSdkNamespaceImport { local, source }); + } +} + +#[cfg(feature = "canvas-compiler")] +pub(super) fn analyze_canvas_module( + source: &str, +) -> Result> { + let path = Path::new("Canvas.tsx"); + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or(SourceType::tsx()); + let parse_return = Parser::new(&allocator, source, source_type).parse(); + if !parse_return.diagnostics.is_empty() { + return Err(oxc_diagnostics_to_canvas( + source, + parse_return.diagnostics.into_iter(), + "canvas.compile.oxc.parse", + )); + } + + let mut bindings = CanvasSdkImportBindings::default(); + let mut import_removal_spans = BTreeSet::new(); + let mut default_export = None; + let mut local_binding_offsets = BTreeMap::new(); + let mut diagnostics = Vec::new(); + for statement in &parse_return.program.body { + match statement { + Statement::ImportDeclaration(declaration) => { + collect_canvas_import_bindings( + source, + declaration, + &mut bindings, + &mut import_removal_spans, + &mut diagnostics, + ); + } + Statement::ExportDefaultDeclaration(declaration) => { + default_export = Some(match &declaration.declaration { + ExportDefaultDeclarationKind::FunctionDeclaration(function) => { + CanvasDefaultExport::Declaration { + start: declaration.span.start as usize, + end: declaration.span.end as usize, + expression_start: function.span.start as usize, + } + } + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + CanvasDefaultExport::Declaration { + start: declaration.span.start as usize, + end: declaration.span.end as usize, + expression_start: class.span.start as usize, + } + } + ExportDefaultDeclarationKind::Identifier(identifier) => { + CanvasDefaultExport::Identifier { + start: declaration.span.start as usize, + end: declaration.span.end as usize, + name: identifier.name.to_string(), + } + } + expression => CanvasDefaultExport::Declaration { + start: declaration.span.start as usize, + end: declaration.span.end as usize, + expression_start: expression.span().start as usize, + }, + }); + } + Statement::FunctionDeclaration(function) => { + collect_function_binding(function, &mut local_binding_offsets); + } + Statement::ClassDeclaration(class) => { + collect_class_binding(class, &mut local_binding_offsets); + } + Statement::VariableDeclaration(declaration) => { + for declarator in &declaration.declarations { + collect_binding_pattern_offsets(&declarator.id, &mut local_binding_offsets); + } + } + _ => { + collect_default_exportable_statement_binding(statement, &mut local_binding_offsets); + } + } + } + + if diagnostics.is_empty() { + Ok(CanvasModuleAnalysis { + import_bindings: bindings, + import_removal_spans: import_removal_spans.into_iter().collect(), + default_export, + local_binding_offsets, + }) + } else { + Err(diagnostics) + } +} + +#[cfg(feature = "canvas-compiler")] +fn collect_canvas_import_bindings( + source: &str, + declaration: &ImportDeclaration<'_>, + bindings: &mut CanvasSdkImportBindings, + import_removal_spans: &mut BTreeSet<(usize, usize)>, + diagnostics: &mut Vec, +) { + let Some(source_kind) = canvas_sdk_import_source(declaration.source.value.as_str()) else { + return; + }; + import_removal_spans.insert(( + declaration.span.start as usize, + declaration.span.end as usize, + )); + + let Some(specifiers) = declaration.specifiers.as_ref() else { + return; + }; + for specifier in specifiers { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if declaration.import_kind == ImportOrExportKind::Type + || specifier.import_kind == ImportOrExportKind::Type + { + continue; + } + let imported = module_export_name(&specifier.imported); + let local = specifier.local.name.to_string(); + match named_import_target(source_kind, imported.as_str()) { + Some(target) => { + bindings.insert_named(local, target.canonical, target.expression); + } + None => diagnostics.push(unsupported_sdk_import_diagnostic( + source, + specifier.span.start as usize, + imported.as_str(), + source_kind, + )), + } + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { + let local = specifier.local.name.to_string(); + if is_reserved_canvas_runtime_binding(local.as_str()) { + diagnostics.push(reserved_sdk_import_binding_diagnostic( + source, + specifier.span.start as usize, + local.as_str(), + )); + } else { + bindings.insert_namespace(local, source_kind); + } + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { + let local = specifier.local.name.to_string(); + if source_kind == CanvasSdkImportSource::React { + if is_reserved_canvas_runtime_binding(local.as_str()) { + diagnostics.push(reserved_sdk_import_binding_diagnostic( + source, + specifier.span.start as usize, + local.as_str(), + )); + } else { + bindings.insert_namespace(local, source_kind); + } + } else { + diagnostics.push(unsupported_sdk_import_diagnostic( + source, + specifier.span.start as usize, + "default", + source_kind, + )); + } + } + } + } +} + +#[cfg(feature = "canvas-compiler")] +fn collect_default_exportable_statement_binding( + statement: &Statement<'_>, + local_binding_offsets: &mut BTreeMap, +) { + match statement { + Statement::TSTypeAliasDeclaration(declaration) => { + local_binding_offsets + .entry(declaration.id.name.to_string()) + .or_insert(declaration.id.span.start as usize); + } + Statement::TSInterfaceDeclaration(declaration) => { + local_binding_offsets + .entry(declaration.id.name.to_string()) + .or_insert(declaration.id.span.start as usize); + } + Statement::TSEnumDeclaration(declaration) => { + local_binding_offsets + .entry(declaration.id.name.to_string()) + .or_insert(declaration.id.span.start as usize); + } + _ => {} + } +} + +#[cfg(feature = "canvas-compiler")] +fn collect_function_binding( + function: &Function<'_>, + local_binding_offsets: &mut BTreeMap, +) { + if let Some(id) = function.id.as_ref() { + collect_binding_identifier(id, local_binding_offsets); + } +} + +#[cfg(feature = "canvas-compiler")] +fn collect_class_binding(class: &Class<'_>, local_binding_offsets: &mut BTreeMap) { + if let Some(id) = class.id.as_ref() { + collect_binding_identifier(id, local_binding_offsets); + } +} + +#[cfg(feature = "canvas-compiler")] +fn collect_binding_pattern_offsets( + pattern: &BindingPattern<'_>, + local_binding_offsets: &mut BTreeMap, +) { + match pattern { + BindingPattern::BindingIdentifier(identifier) => { + collect_binding_identifier(identifier, local_binding_offsets); + } + BindingPattern::ObjectPattern(pattern) => { + for property in &pattern.properties { + collect_binding_pattern_offsets(&property.value, local_binding_offsets); + } + if let Some(rest) = pattern.rest.as_ref() { + collect_binding_pattern_offsets(&rest.argument, local_binding_offsets); + } + } + BindingPattern::ArrayPattern(pattern) => { + for element in pattern.elements.iter().flatten() { + collect_binding_pattern_offsets(element, local_binding_offsets); + } + if let Some(rest) = pattern.rest.as_ref() { + collect_binding_pattern_offsets(&rest.argument, local_binding_offsets); + } + } + BindingPattern::AssignmentPattern(pattern) => { + collect_binding_pattern_offsets(&pattern.left, local_binding_offsets); + } + } +} + +#[cfg(feature = "canvas-compiler")] +fn collect_binding_identifier( + identifier: &BindingIdentifier<'_>, + local_binding_offsets: &mut BTreeMap, +) { + local_binding_offsets + .entry(identifier.name.to_string()) + .or_insert(identifier.span.start as usize); +} + +#[cfg(feature = "canvas-compiler")] +pub(super) fn canvas_runtime_binding_prelude(import_bindings: &CanvasSdkImportBindings) -> String { + let mut local_bindings = sdk_runtime_exports() + .iter() + .map(|name| { + ( + (*name).to_string(), + format!("__BitfunCanvasSDK.{}", property_access(name)), + ) + }) + .collect::>(); + + for binding in &import_bindings.named { + if is_reserved_canvas_runtime_binding(binding.local.as_str()) { + continue; + } + local_bindings.insert(binding.local.clone(), binding.target_expression.clone()); + } + for namespace in &import_bindings.namespaces { + local_bindings.insert( + namespace.local.clone(), + match namespace.source { + CanvasSdkImportSource::Canvas => "__BitfunCanvasSDK".to_string(), + CanvasSdkImportSource::React => "__BitfunCanvasReactCompat".to_string(), + }, + ); + } + + let mut prelude = String::from( + "const __BitfunCanvasSDK = window.BitfunCanvasSDK;\n\ +const __BitfunCanvasRuntime = window.BitfunCanvasRuntime;\n\ +const __BitfunCanvasReactCompat = Object.freeze({ ...__BitfunCanvasSDK, createElement: __BitfunCanvasRuntime.h, Fragment: __BitfunCanvasRuntime.Fragment });\n\ +const h = __BitfunCanvasRuntime.h;\n\ +const Fragment = __BitfunCanvasRuntime.Fragment;\n", + ); + for (local, expression) in local_bindings { + if local == "h" || local == "Fragment" { + continue; + } + prelude.push_str("const "); + prelude.push_str(&local); + prelude.push_str(" = "); + prelude.push_str(&expression); + prelude.push_str(";\n"); + } + prelude +} + +#[cfg(feature = "canvas-compiler")] +fn canvas_sdk_import_source(source: &str) -> Option { + match source { + "bitfun/canvas" | "cursor/canvas" => Some(CanvasSdkImportSource::Canvas), + "react" => Some(CanvasSdkImportSource::React), + _ => None, + } +} + +#[cfg(feature = "canvas-compiler")] +struct NamedImportTarget { + canonical: String, + expression: String, +} + +#[cfg(feature = "canvas-compiler")] +fn named_import_target(source: CanvasSdkImportSource, imported: &str) -> Option { + match source { + CanvasSdkImportSource::Canvas => { + sdk_runtime_exports() + .contains(&imported) + .then(|| NamedImportTarget { + canonical: imported.to_string(), + expression: format!("__BitfunCanvasSDK.{}", property_access(imported)), + }) + } + CanvasSdkImportSource::React => match imported { + "useState" | "useRef" | "useEffect" | "useCallback" | "useMemo" => { + Some(NamedImportTarget { + canonical: imported.to_string(), + expression: format!("__BitfunCanvasSDK.{}", property_access(imported)), + }) + } + "Fragment" => Some(NamedImportTarget { + canonical: "Fragment".to_string(), + expression: "__BitfunCanvasRuntime.Fragment".to_string(), + }), + "createElement" => Some(NamedImportTarget { + canonical: "createElement".to_string(), + expression: "__BitfunCanvasRuntime.h".to_string(), + }), + _ => None, + }, + } +} + +#[cfg(feature = "canvas-compiler")] +fn module_export_name(name: &ModuleExportName<'_>) -> String { + match name { + ModuleExportName::IdentifierName(identifier) => identifier.name.to_string(), + ModuleExportName::IdentifierReference(identifier) => identifier.name.to_string(), + ModuleExportName::StringLiteral(literal) => literal.value.to_string(), + } +} + +#[cfg(feature = "canvas-compiler")] +fn unsupported_sdk_import_diagnostic( + source: &str, + offset: usize, + imported: &str, + import_source: CanvasSdkImportSource, +) -> CanvasDiagnostic { + let (line, column) = line_column(source, offset); + let module = match import_source { + CanvasSdkImportSource::Canvas => "bitfun/canvas", + CanvasSdkImportSource::React => "react", + }; + CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::TypeScript, + message: format!("`{}` is not exported by {}", imported, module), + code: Some("canvas.compile.sdk_import_unsupported".to_string()), + line: Some(line), + column: Some(column), + suggested_fix: Some( + "Import a supported Canvas SDK or React compatibility export.".to_string(), + ), + } +} + +#[cfg(feature = "canvas-compiler")] +fn reserved_sdk_import_binding_diagnostic( + source: &str, + offset: usize, + local: &str, +) -> CanvasDiagnostic { + let (line, column) = line_column(source, offset); + CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::TypeScript, + message: format!("`{}` is reserved by the Canvas runtime", local), + code: Some("canvas.compile.sdk_import_reserved".to_string()), + line: Some(line), + column: Some(column), + suggested_fix: Some("Use a different local import alias.".to_string()), + } +} + +#[cfg(feature = "canvas-compiler")] +fn is_reserved_canvas_runtime_binding(name: &str) -> bool { + matches!( + name, + "h" | "Fragment" + | "__BitfunCanvasSDK" + | "__BitfunCanvasRuntime" + | "__BitfunCanvasReactCompat" + ) +} + +#[cfg(feature = "canvas-compiler")] +pub(super) fn property_access(name: &str) -> String { + if is_identifier(name) { + name.to_string() + } else { + format!("{name:?}") + } +} + +#[cfg(feature = "canvas-compiler")] +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first == '$' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) +} + +#[cfg(feature = "canvas-compiler")] +pub(super) fn sdk_runtime_exports() -> &'static [&'static str] { + &[ + "Stack", + "Row", + "Grid", + "Box", + "Divider", + "Spacer", + "H1", + "H2", + "H3", + "Text", + "Code", + "Link", + "Card", + "CardHeader", + "CardBody", + "Alert", + "Callout", + "CollapsibleSection", + "Empty", + "Tabs", + "Pill", + "Stat", + "Table", + "KeyValueList", + "Timeline", + "FileTree", + "ProgressBar", + "Swatch", + "UsageBar", + "TodoList", + "TodoListCard", + "DependencyGraph", + "FlowDiagram", + "BarChart", + "LineChart", + "PieChart", + "Button", + "Toggle", + "Checkbox", + "Select", + "Input", + "TextInput", + "TextArea", + "IconButton", + "DiffStats", + "DiffView", + "computeDAGLayout", + "mergeStyle", + "colorPalette", + "usageColorSequence", + "categoryPaletteLight", + "categoryPaletteDark", + "canvasPaletteLight", + "canvasPaletteDark", + "canvasTokensLight", + "canvasTokens", + "useHostTheme", + "useCanvasState", + "useCanvasAction", + "useState", + "useRef", + "useEffect", + "useCallback", + "useMemo", + ] +} + +#[cfg(feature = "canvas-compiler")] +pub(super) fn rewrite_canvas_module_for_runtime( + source: &str, + analysis: &CanvasModuleAnalysis, +) -> Result> { + let Some(default_export) = analysis.default_export.as_ref() else { + return Err(vec![compile_error( + "Canvas source must default-export a component", + "canvas.compile.default_function_required", + )]); + }; + + if let CanvasDefaultExport::Identifier { start, name, .. } = default_export { + if !analysis.local_binding_offsets.contains_key(name) { + let (line, column) = line_column(source, *start); + let mut diagnostic = compile_error( + "Canvas default export must reference a component declared in the same source file", + "canvas.compile.default_function_required", + ); + diagnostic.line = Some(line); + diagnostic.column = Some(column); + return Err(vec![diagnostic]); + } + } + + let mut replacements = analysis + .import_removal_spans + .iter() + .map(|(start, end)| (*start, *end, String::new())) + .collect::>(); + replacements.push(default_export_replacement(source, default_export)); + replacements.sort_by_key(|(start, _, _)| *start); + + let mut rewritten = String::with_capacity(source.len() + 128); + let mut cursor = 0usize; + for (start, end, replacement) in replacements { + if start < cursor { + continue; + } + rewritten.push_str(&source[cursor..start]); + rewritten.push_str(&replacement); + cursor = end; + } + rewritten.push_str(&source[cursor..]); + rewritten.push_str("\nwindow.BitfunCanvasRuntime.mount(__BitfunCanvasComponent);\n"); + Ok(rewritten) +} + +#[cfg(feature = "canvas-compiler")] +fn default_export_replacement( + source: &str, + default_export: &CanvasDefaultExport, +) -> (usize, usize, String) { + match default_export { + CanvasDefaultExport::Declaration { + start, + end, + expression_start, + } => ( + *start, + *end, + format!( + "const __BitfunCanvasComponent = {};", + source[*expression_start..*end].trim() + ), + ), + CanvasDefaultExport::Identifier { start, end, name } => ( + *start, + *end, + format!("const __BitfunCanvasComponent = {name};"), + ), + } +} diff --git a/src/crates/assembly/core/src/service/canvas/compiler/diagnostics.rs b/src/crates/assembly/core/src/service/canvas/compiler/diagnostics.rs new file mode 100644 index 000000000..798ac4573 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/diagnostics.rs @@ -0,0 +1,46 @@ +use bitfun_product_domains::canvas::types::{ + CanvasDiagnostic, CanvasDiagnosticCategory, CanvasDiagnosticSeverity, +}; + +use oxc::diagnostics::{OxcDiagnostic, Severity}; + +use super::line_column; + +pub(super) fn oxc_diagnostics_to_canvas( + source: &str, + diagnostics: impl IntoIterator, + fallback_code: &str, +) -> Vec { + diagnostics + .into_iter() + .map(|diagnostic| { + let offset = diagnostic + .labels + .first() + .map(|label| label.inner().offset()); + let (line, column) = offset + .map(|offset| line_column(source, offset as usize)) + .map_or((None, None), |(line, column)| (Some(line), Some(column))); + CanvasDiagnostic { + severity: match diagnostic.severity { + Severity::Warning | Severity::Advice => CanvasDiagnosticSeverity::Warning, + _ => CanvasDiagnosticSeverity::Error, + }, + category: CanvasDiagnosticCategory::Compile, + message: diagnostic.to_string(), + code: diagnostic + .code + .is_some() + .then(|| format!("canvas.compile.oxc.{}", diagnostic.code)) + .or_else(|| Some(fallback_code.to_string())), + line, + column, + suggested_fix: diagnostic + .help + .as_ref() + .map(|help| help.to_string()) + .or_else(|| Some("Fix the Canvas TSX syntax and retry.".to_string())), + } + }) + .collect() +} diff --git a/src/crates/assembly/core/src/service/canvas/compiler/html.rs b/src/crates/assembly/core/src/service/canvas/compiler/html.rs new file mode 100644 index 000000000..0cedb0f65 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/html.rs @@ -0,0 +1,55 @@ +use bitfun_product_domains::canvas::types::CanvasSource; + +const CANVAS_RUNTIME_STYLE: &str = include_str!("runtime_style.css"); +const CANVAS_RUNTIME_BOOTSTRAP: &str = include_str!("runtime_bootstrap.js"); + +pub fn compile_canvas_html(source: &CanvasSource, component_js: &str) -> String { + format!( + r#" + + + + + + + + +
+ + + +"#, + style = CANVAS_RUNTIME_STYLE, + runtime = sanitize_script_body(CANVAS_RUNTIME_BOOTSTRAP), + revision = escape_html_attr(source.revision.as_str()), + component_js = sanitize_script_body(component_js), + ) +} + +pub(super) fn stable_content_hash(value: &str) -> String { + let mut hash: u64 = 0xcbf29ce484222325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn sanitize_script_body(value: &str) -> String { + value.replace(" String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn escape_html_attr(value: &str) -> String { + escape_html_text(value).replace('"', """) +} diff --git a/src/crates/assembly/core/src/service/canvas/compiler/mod.rs b/src/crates/assembly/core/src/service/canvas/compiler/mod.rs new file mode 100644 index 000000000..a7db6a204 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/mod.rs @@ -0,0 +1,113 @@ +//! Canvas compiler and runtime HTML assembly. +//! +//! Phase 1 uses a narrow Canvas module contract with OXC for TSX parsing and +//! JSX/TypeScript lowering. Unsupported Canvas policy still returns compile +//! diagnostics so callers can retain the last-known-good payload. + +#[cfg(feature = "canvas-compiler")] +mod analysis; +#[cfg(feature = "canvas-compiler")] +mod diagnostics; +mod html; +mod oxc; +#[cfg(feature = "canvas-compiler")] +mod sdk_contract; + +#[cfg(test)] +mod tests; + +use bitfun_product_domains::canvas::policy::validate_canvas_source_policy; +use bitfun_product_domains::canvas::runtime::{ + CanvasCompileResult, BITFUN_CANVAS_RUNTIME_VERSION, BITFUN_CANVAS_SDK_VERSION, +}; +use bitfun_product_domains::canvas::types::{ + CanvasCompiledPayload, CanvasDiagnostic, CanvasDiagnosticCategory, CanvasDiagnosticSeverity, + CanvasSource, +}; + +pub use html::compile_canvas_html; +use html::stable_content_hash; + +pub fn compile_canvas_source(source: &CanvasSource, compiled_at: i64) -> CanvasCompileResult { + let policy_diagnostics = validate_canvas_source_policy(source); + if has_error(&policy_diagnostics) { + return CanvasCompileResult { + payload: None, + diagnostics: policy_diagnostics, + compiled: false, + }; + } + + match compile_canvas_component_js(&source.source) { + Ok(component_js) => { + let html = compile_canvas_html(source, &component_js); + let diagnostics = policy_diagnostics; + let payload = CanvasCompiledPayload { + canvas_id: source.canvas_id.clone(), + source_revision: source.revision.clone(), + sdk_version: BITFUN_CANVAS_SDK_VERSION.to_string(), + runtime_version: BITFUN_CANVAS_RUNTIME_VERSION.to_string(), + content_hash: stable_content_hash(&html), + html, + diagnostics: diagnostics.clone(), + compiled_at, + }; + CanvasCompileResult { + payload: Some(payload), + diagnostics, + compiled: true, + } + } + Err(diagnostics) => { + let mut all_diagnostics = policy_diagnostics; + all_diagnostics.extend(diagnostics); + CanvasCompileResult { + payload: None, + diagnostics: all_diagnostics, + compiled: false, + } + } + } +} + +pub fn compile_canvas_component_js(source: &str) -> Result> { + oxc::compile_canvas_component_js_with_oxc(source) +} + +fn has_error(diagnostics: &[CanvasDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == CanvasDiagnosticSeverity::Error) +} + +fn compile_error(message: impl Into, code: impl Into) -> CanvasDiagnostic { + CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::Compile, + message: message.into(), + code: Some(code.into()), + line: None, + column: None, + suggested_fix: Some( + "Use a single default-exported function component with Canvas SDK JSX.".to_string(), + ), + } +} + +#[cfg(feature = "canvas-compiler")] +pub(super) fn line_column(source: &str, offset: usize) -> (u32, u32) { + let mut line = 1u32; + let mut column = 1u32; + for (index, ch) in source.char_indices() { + if index >= offset { + break; + } + if ch == '\n' { + line += 1; + column = 1; + } else { + column += 1; + } + } + (line, column) +} diff --git a/src/crates/assembly/core/src/service/canvas/compiler/oxc.rs b/src/crates/assembly/core/src/service/canvas/compiler/oxc.rs new file mode 100644 index 000000000..346114fd2 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/oxc.rs @@ -0,0 +1,113 @@ +use bitfun_product_domains::canvas::types::CanvasDiagnostic; + +#[cfg(feature = "canvas-compiler")] +use oxc::allocator::Allocator; +#[cfg(feature = "canvas-compiler")] +use oxc::codegen::{Codegen, CodegenOptions, CodegenReturn}; +#[cfg(feature = "canvas-compiler")] +use oxc::parser::Parser; +#[cfg(feature = "canvas-compiler")] +use oxc::semantic::SemanticBuilder; +#[cfg(feature = "canvas-compiler")] +use oxc::span::SourceType; +#[cfg(feature = "canvas-compiler")] +use oxc::transformer::{HelperLoaderMode, JsxOptions, JsxRuntime, TransformOptions, Transformer}; +#[cfg(feature = "canvas-compiler")] +use std::path::Path; + +#[cfg(feature = "canvas-compiler")] +use super::analysis::{ + analyze_canvas_module, canvas_runtime_binding_prelude, rewrite_canvas_module_for_runtime, + validate_canvas_import_shadowing, +}; +#[cfg(not(feature = "canvas-compiler"))] +use super::compile_error; +#[cfg(feature = "canvas-compiler")] +use super::diagnostics::oxc_diagnostics_to_canvas; +#[cfg(feature = "canvas-compiler")] +use super::sdk_contract::validate_canvas_sdk_contracts; + +#[cfg(feature = "canvas-compiler")] +pub(super) fn compile_canvas_component_js_with_oxc( + source: &str, +) -> Result> { + let analysis = analyze_canvas_module(source)?; + let shadow_diagnostics = validate_canvas_import_shadowing(source, &analysis); + if !shadow_diagnostics.is_empty() { + return Err(shadow_diagnostics); + } + let module = rewrite_canvas_module_for_runtime(source, &analysis)?; + let path = Path::new("Canvas.tsx"); + let allocator = Allocator::default(); + let source_type = SourceType::from_path(path).unwrap_or(SourceType::tsx()); + let parse_return = Parser::new(&allocator, &module, source_type).parse(); + if !parse_return.diagnostics.is_empty() { + return Err(oxc_diagnostics_to_canvas( + &module, + parse_return.diagnostics.into_iter(), + "canvas.compile.oxc.parse", + )); + } + + let mut program = parse_return.program; + let sdk_contract_diagnostics = + validate_canvas_sdk_contracts(&module, &program, &analysis.import_bindings); + if !sdk_contract_diagnostics.is_empty() { + return Err(sdk_contract_diagnostics); + } + + let semantic_return = SemanticBuilder::new() + .with_excess_capacity(2.0) + .with_enum_eval(true) + .build(&program); + if !semantic_return.diagnostics.is_empty() { + return Err(oxc_diagnostics_to_canvas( + &module, + semantic_return.diagnostics.into_iter(), + "canvas.compile.oxc.semantic", + )); + } + + let mut options = TransformOptions { + jsx: JsxOptions { + runtime: JsxRuntime::Classic, + pragma: Some("h".to_string()), + pragma_frag: Some("Fragment".to_string()), + development: false, + ..JsxOptions::enable() + }, + ..TransformOptions::default() + }; + options.typescript.jsx_pragma = "h".into(); + options.typescript.jsx_pragma_frag = "Fragment".into(); + options.helper_loader.mode = HelperLoaderMode::External; + + let transformer_return = Transformer::new(&allocator, path, &options) + .build_with_scoping(semantic_return.semantic.into_scoping(), &mut program); + if !transformer_return.diagnostics.is_empty() { + return Err(oxc_diagnostics_to_canvas( + &module, + transformer_return.diagnostics.into_iter(), + "canvas.compile.oxc.transform", + )); + } + + let CodegenReturn { code, .. } = Codegen::new() + .with_options(CodegenOptions::default()) + .build(&program); + + Ok(format!( + "{}\n{code}", + canvas_runtime_binding_prelude(&analysis.import_bindings) + )) +} + +#[cfg(not(feature = "canvas-compiler"))] +pub(super) fn compile_canvas_component_js_with_oxc( + _source: &str, +) -> Result> { + Err(vec![compile_error( + "Canvas TSX compilation requires the `canvas` feature", + "canvas.compile.feature_disabled", + )]) +} diff --git a/src/crates/assembly/core/src/service/canvas/compiler/runtime_bootstrap.js b/src/crates/assembly/core/src/service/canvas/compiler/runtime_bootstrap.js new file mode 100644 index 000000000..ab441321b --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/runtime_bootstrap.js @@ -0,0 +1,1282 @@ +(() => { + const root = document.getElementById('bitfun-canvas-root'); + let theme = makeTheme({ + type: 'auto', + bg: 'var(--bitfun-canvas-bg)', + panel: 'var(--bitfun-canvas-panel)', + fg: 'var(--bitfun-canvas-fg)', + muted: 'var(--bitfun-canvas-muted)', + border: 'var(--bitfun-canvas-border)', + accent: 'var(--bitfun-canvas-accent)', + success: 'var(--bitfun-canvas-success)', + warning: 'var(--bitfun-canvas-warning)', + danger: 'var(--bitfun-canvas-danger)', + info: 'var(--bitfun-canvas-info)', + }); + function makeTheme(tokens) { + const readToken = (value, fallback) => value === undefined || value === null || value === '' ? fallback : String(value); + const bg = readToken(tokens.bg, 'var(--bitfun-canvas-bg)'); + const panel = readToken(tokens.panel, 'var(--bitfun-canvas-panel)'); + const fg = readToken(tokens.fg, 'var(--bitfun-canvas-fg)'); + const muted = readToken(tokens.muted, 'var(--bitfun-canvas-muted)'); + const border = readToken(tokens.border, 'var(--bitfun-canvas-border)'); + const accent = readToken(tokens.accent, 'var(--bitfun-canvas-accent)'); + const success = readToken(tokens.success, 'var(--bitfun-canvas-success)'); + const warning = readToken(tokens.warning, 'var(--bitfun-canvas-warning)'); + const danger = readToken(tokens.danger, 'var(--bitfun-canvas-danger)'); + const info = readToken(tokens.info, 'var(--bitfun-canvas-info)'); + const token = (value, fields = {}) => Object.assign(new String(value), { + toString() { return value; }, + valueOf() { return value; }, + ...fields, + }); + return { + ...tokens, + bg: token(bg, { + canvas: bg, + elevated: panel, + editor: '#ffffff', + chrome: 'rgba(127,127,127,0.04)', + }), + panel, + fg, + muted, + border, + accent: token(accent, { + primary: accent, + success, + warning, + danger, + info, + }), + success, + warning, + danger, + info, + text: { + primary: fg, + secondary: muted, + tertiary: muted, + quaternary: 'rgba(102,112,133,0.72)', + onAccent: '#ffffff', + }, + fill: { + primary: panel, + secondary: 'rgba(127,127,127,0.10)', + tertiary: 'rgba(127,127,127,0.06)', + quaternary: 'rgba(127,127,127,0.04)', + }, + stroke: { + primary: border, + secondary: 'rgba(127,127,127,0.18)', + tertiary: 'rgba(127,127,127,0.10)', + }, + category: { + gray: '#7a8087', + purple: '#8b5cf6', + green: '#49a66a', + yellow: '#d88938', + cyan: '#49a8bd', + pink: '#c774a7', + blue: '#2f8ac4', + orange: '#d88938', + }, + status: { success, warning, danger, info }, + }; + } + function applyHostTheme(nextTheme) { + if (!nextTheme || typeof nextTheme !== 'object') return; + const allowed = ['bg', 'panel', 'fg', 'muted', 'border', 'accent', 'success', 'warning', 'danger', 'info']; + const rootStyle = document.documentElement.style; + for (const key of allowed) { + const value = nextTheme[key]; + if (typeof value === 'string' && value.trim()) { + rootStyle.setProperty(`--bitfun-canvas-${key}`, value.trim()); + } + } + const type = nextTheme.type === 'dark' || nextTheme.type === 'light' ? nextTheme.type : 'auto'; + document.documentElement.style.colorScheme = type === 'auto' ? 'light dark' : type; + theme = makeTheme({ + ...theme, + ...Object.fromEntries(allowed.map(key => [key, getComputedStyle(document.documentElement).getPropertyValue(`--bitfun-canvas-${key}`).trim() || theme[key]])), + type, + }); + } + const state = new Map(); + let hostStateReady = false; + let readySent = false; + let renderFn = null; + let nodeSeq = 0; + let designMode = false; + let inspectElement = null; + const inspectStyleSnapshot = new WeakMap(); + let hookIndex = 0; + const hookValues = []; + const hookEffects = []; + let renderQueued = false; + + function toArray(value) { + return Array.isArray(value) ? value.flat(Infinity) : [value]; + } + function applyStyle(node, style) { + if (style && typeof style === 'object') Object.assign(node.style, style); + } + function appendChildren(node, children) { + for (const child of toArray(children)) { + if (child === null || child === undefined || child === false) continue; + if (Array.isArray(child)) { + appendChildren(node, child); + } else if (child instanceof Node) { + node.appendChild(child); + } else { + node.appendChild(document.createTextNode(String(child))); + } + } + } + function el(tag, props = {}, children = []) { + const node = document.createElement(tag); + props = props || {}; + for (const [key, value] of Object.entries(props)) { + if (key === 'children' || key === 'key' || value === undefined || value === null || value === false) continue; + if (key === 'style') applyStyle(node, value); + else if (key === 'className') node.className = value; + else if (key === 'htmlFor') node.htmlFor = String(value); + else if (key === 'ref' && typeof value === 'function') value(node); + else if (key === 'ref' && value && typeof value === 'object') value.current = node; + else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value); + else if (key === 'checked' || key === 'selected' || key === 'disabled' || key === 'open') node[key] = Boolean(value); + else if (key === 'value') node.value = value; + else node.setAttribute(key, String(value)); + } + appendChildren(node, children); + return node; + } + const SVG_TAGS = new Set(['svg', 'g', 'defs', 'marker', 'polygon', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'text', 'tspan']); + function svgAttrName(key) { + return key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); + } + function markCanvasNode(value, component) { + for (const item of toArray(value)) { + if (item instanceof Element) { + if (!item.dataset.bitfunCanvasNode) item.dataset.bitfunCanvasNode = `node-${++nodeSeq}`; + if (!item.dataset.bitfunCanvasComponent) item.dataset.bitfunCanvasComponent = component; + } + } + return value; + } + function cssIdent(value) { + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value); + return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); + } + function elementSelector(element) { + if (element.id) return `#${cssIdent(element.id)}`; + const parts = []; + let current = element; + while (current && current instanceof Element && current !== root && current !== document.body && current !== document.documentElement) { + const tag = current.tagName.toLowerCase(); + const parent = current.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const siblings = Array.from(parent.children).filter(child => child.tagName === current.tagName); + const index = siblings.indexOf(current) + 1; + parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${index})` : tag); + current = parent; + } + return parts.join(' > ') || element.tagName.toLowerCase(); + } + function elementReference(element) { + const text = (element.innerText || element.textContent || '').replace(/\s+/g, ' ').trim(); + const rect = element.getBoundingClientRect(); + return { + nodeId: element.dataset.bitfunCanvasNode || null, + component: element.dataset.bitfunCanvasComponent || element.tagName.toLowerCase(), + tagName: element.tagName.toLowerCase(), + selector: elementSelector(element), + text: text.length > 180 ? `${text.slice(0, 180)}...` : text, + bounds: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }, + }; + } + function inspectableElement(target) { + if (!(target instanceof Element) || !root || !root.contains(target) || target === root) return null; + return target.closest('[data-bitfun-canvas-node]') || target; + } + function clearInspectHighlight() { + if (!inspectElement) return; + const snapshot = inspectStyleSnapshot.get(inspectElement); + if (snapshot) { + inspectElement.style.outline = snapshot.outline; + inspectElement.style.outlineOffset = snapshot.outlineOffset; + inspectElement.style.cursor = snapshot.cursor; + } + inspectElement = null; + } + function highlightInspectElement(element) { + if (inspectElement === element) return; + clearInspectHighlight(); + inspectElement = element; + inspectStyleSnapshot.set(element, { + outline: element.style.outline, + outlineOffset: element.style.outlineOffset, + cursor: element.style.cursor, + }); + element.style.outline = '2px solid var(--bitfun-canvas-accent)'; + element.style.outlineOffset = '2px'; + element.style.cursor = 'crosshair'; + } + function setDesignMode(enabled) { + designMode = Boolean(enabled); + document.body.dataset.bitfunCanvasDesignMode = designMode ? 'true' : 'false'; + document.body.style.cursor = designMode ? 'crosshair' : ''; + if (!designMode) clearInspectHighlight(); + } + document.addEventListener('mouseover', event => { + if (!designMode) return; + const element = inspectableElement(event.target); + if (element) highlightInspectElement(element); + }, true); + document.addEventListener('click', event => { + if (!designMode) return; + const element = inspectableElement(event.target); + if (!element) return; + event.preventDefault(); + event.stopPropagation(); + highlightInspectElement(element); + window.parent?.postMessage({ + type: 'bitfun-canvas-element-selected', + reference: elementReference(element), + }, '*'); + }, true); + function h(type, props, ...children) { + props = props || {}; + props.children = children; + const result = typeof type === 'function' ? type(props) : SVG_TAGS.has(String(type)) ? svg(String(type), props, children) : el(type, props, children); + return markCanvasNode(result, typeof type === 'function' ? type.name || 'Component' : String(type)); + } + function rerender() { + if (!renderFn || !root) return; + root.replaceChildren(); + try { + hookIndex = 0; + const result = renderFn(); + appendChildren(root, result); + flushEffects(); + if (!readySent) { + readySent = true; + window.parent?.postMessage({ type: 'bitfun-canvas-ready' }, '*'); + } + } catch (error) { + reportRuntimeError(error); + } + } + function reportRuntimeError(error) { + if (root) root.replaceChildren(errorView(error)); + window.parent?.postMessage({ + type: 'bitfun-canvas-runtime-error', + message: String(error?.message || error), + name: error?.name ? String(error.name) : undefined, + stack: error?.stack ? String(error.stack) : undefined, + }, '*'); + } + function errorView(error) { + return el('main', { style: { maxWidth: '860px', margin: '0 auto', padding: '12px', border: '1px solid var(--bitfun-canvas-border)', borderRadius: '8px' } }, [ + el('h1', { style: { margin: '0 0 8px', fontSize: '18px' } }, ['Canvas runtime error']), + el('pre', { style: { whiteSpace: 'pre-wrap', margin: 0, color: 'var(--bitfun-canvas-danger)' } }, [String(error?.stack || error?.message || error)]) + ]); + } + window.addEventListener('error', event => { + reportRuntimeError(event.error || event.message || 'Canvas runtime error'); + }); + window.addEventListener('unhandledrejection', event => { + reportRuntimeError(event.reason || 'Canvas runtime promise rejection'); + }); + function component(tag, baseStyle = {}) { + return ({ children, style, ...props } = {}) => el(tag, { ...props, style: { ...baseStyle, ...style } }, children); + } + function spacingValue(value) { + if (typeof value === 'number') return `${value}px`; + return value; + } + function sizeValue(size) { + if (typeof size === 'number') return `${size}px`; + return size === 'small' || size === 'sm' ? '12px' : size === 'lg' ? '16px' : size === 'body' || size === 'md' || !size ? '14px' : size; + } + function flexAlign(value) { + return value === 'start' ? 'flex-start' : value === 'end' ? 'flex-end' : value || 'center'; + } + function flexJustify(value) { + return value === 'start' ? 'flex-start' : value === 'end' ? 'flex-end' : value || 'flex-start'; + } + function spacingStyle(value, property) { + if (value === undefined || value === null) return {}; + if (typeof value === 'object') { + const result = {}; + if (value.x !== undefined) { + result[`${property}Left`] = spacingValue(value.x); + result[`${property}Right`] = spacingValue(value.x); + } + if (value.y !== undefined) { + result[`${property}Top`] = spacingValue(value.y); + result[`${property}Bottom`] = spacingValue(value.y); + } + for (const key of ['top', 'right', 'bottom', 'left']) { + if (value[key] !== undefined) result[`${property}${key[0].toUpperCase()}${key.slice(1)}`] = spacingValue(value[key]); + } + return result; + } + return { [property]: spacingValue(value) }; + } + function commonStyle(props = {}, style = {}) { + return { + ...spacingStyle(props.padding, 'padding'), + ...spacingStyle(props.margin, 'margin'), + ...(props.background !== undefined ? { background: props.background } : {}), + ...(props.border !== undefined ? { border: props.border } : {}), + ...(props.borderTop !== undefined ? { borderTop: props.borderTop } : {}), + ...(props.borderRight !== undefined ? { borderRight: props.borderRight } : {}), + ...(props.borderBottom !== undefined ? { borderBottom: props.borderBottom } : {}), + ...(props.borderLeft !== undefined ? { borderLeft: props.borderLeft } : {}), + ...(props.borderRadius !== undefined ? { borderRadius: spacingValue(props.borderRadius) } : {}), + ...(props.width !== undefined ? { width: spacingValue(props.width) } : {}), + ...(props.height !== undefined ? { height: spacingValue(props.height) } : {}), + ...(props.flex !== undefined ? { flex: props.flex } : {}), + ...(props.display !== undefined ? { display: props.display } : {}), + ...(props.color !== undefined ? { color: props.color } : {}), + ...(props.opacity !== undefined ? { opacity: props.opacity } : {}), + ...style, + }; + } + const colorPalette = ['gray', 'purple', 'green', 'yellow', 'cyan', 'pink', 'blue', 'orange']; + const usageColorSequence = ['gray', 'purple', 'green', 'yellow', 'pink', 'blue', 'orange']; + const categoryPaletteLight = { + gray: 'var(--bitfun-canvas-muted)', + purple: 'var(--bitfun-canvas-accent)', + green: 'var(--bitfun-canvas-success)', + yellow: 'var(--bitfun-canvas-warning)', + cyan: 'var(--bitfun-canvas-info)', + pink: 'var(--bitfun-canvas-danger)', + blue: 'var(--bitfun-canvas-accent)', + orange: 'var(--bitfun-canvas-warning)', + }; + const categoryPaletteDark = categoryPaletteLight; + const canvasTokensLight = { + bg: 'var(--bitfun-canvas-panel)', + panel: 'var(--bitfun-canvas-bg)', + elevated: 'var(--bitfun-canvas-bg)', + chrome: 'var(--bitfun-canvas-panel)', + text: 'var(--bitfun-canvas-fg)', + textSecondary: 'var(--bitfun-canvas-muted)', + textMuted: 'var(--bitfun-canvas-muted)', + border: 'var(--bitfun-canvas-border)', + accent: 'var(--bitfun-canvas-accent)', + success: 'var(--bitfun-canvas-success)', + warning: 'var(--bitfun-canvas-warning)', + danger: 'var(--bitfun-canvas-danger)', + info: 'var(--bitfun-canvas-info)', + }; + const canvasTokens = canvasTokensLight; + const canvasPaletteLight = categoryPaletteLight; + const canvasPaletteDark = categoryPaletteDark; + function mergeStyle(base = {}, override = {}) { + return { ...base, ...(override || {}) }; + } + function categoryColor(color, index = 0) { + const resolved = color || usageColorSequence[index % usageColorSequence.length] || 'gray'; + return categoryPaletteLight[resolved] || categoryPaletteLight.gray; + } + const Stack = ({ children, gap = 12, style, ...props } = {}) => el('div', { style: { display: 'flex', flexDirection: 'column', gap: `${gap}px`, ...commonStyle(props, style) } }, children); + const Row = ({ children, gap = 8, align = 'center', justify = 'start', wrap = false, style, ...props } = {}) => el('div', { style: { display: 'flex', flexDirection: 'row', gap: `${gap}px`, alignItems: flexAlign(align), justifyContent: flexJustify(justify), flexWrap: wrap ? 'wrap' : 'nowrap', ...commonStyle(props, style) } }, children); + const Grid = ({ children, columns = 2, gap = 12, align = 'stretch', style, ...props } = {}) => el('div', { style: { display: 'grid', gridTemplateColumns: typeof columns === 'number' ? `repeat(${columns}, minmax(0, 1fr))` : columns, gap: `${gap}px`, alignItems: flexAlign(align), ...commonStyle(props, style) } }, children); + const Spacer = () => el('div', { style: { flex: '1 1 auto', minWidth: 0, minHeight: 0 } }); + const Box = ({ + children, + style, + padding, + margin, + background, + border, + borderTop, + borderRight, + borderBottom, + borderLeft, + borderRadius, + width, + height, + flex, + display, + ...props + } = {}) => el('div', { + ...props, + style: commonStyle({ padding, margin, background, border, borderTop, borderRight, borderBottom, borderLeft, borderRadius, width, height, flex, display, ...props }, style), + }, children); + const Divider = ({ style } = {}) => el('hr', { style: { border: 0, borderTop: '1px solid rgba(127,127,127,0.18)', width: '100%', margin: '4px 0', ...style } }); + const H1 = ({ children, style } = {}) => el('h1', { style: { fontSize: '26px', lineHeight: '1.14', margin: 0, fontWeight: 720, letterSpacing: 0, ...style } }, children); + const H2 = ({ children, style } = {}) => el('h2', { style: { fontSize: '18px', lineHeight: '1.3', margin: 0, fontWeight: 650, letterSpacing: 0, ...style } }, children); + const H3 = ({ children, style } = {}) => el('h3', { style: { fontSize: '15px', lineHeight: '1.35', margin: 0, fontWeight: 650, letterSpacing: 0, ...style } }, children); + const Text = ({ children, tone = 'primary', size = 'body', weight = 'normal', italic = false, as = 'p', truncate = false, style, color, ...props } = {}) => { + const truncateStyle = truncate ? { + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + direction: truncate === 'start' ? 'rtl' : undefined, + textAlign: truncate === 'start' ? 'left' : undefined, + } : {}; + return el(as, { style: { margin: 0, color: color || toneColor(tone), fontSize: sizeValue(size), fontWeight: weightValue(weight), fontStyle: italic ? 'italic' : undefined, ...truncateStyle, ...commonStyle(props, style) } }, children); + }; + const Code = component('code', { fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace', fontSize: '12px', background: 'rgba(127,127,127,0.12)', borderRadius: '4px', padding: '1px 4px' }); + const Link = ({ children, href, style } = {}) => el('a', { href, target: '_blank', rel: 'noreferrer', style: { color: 'var(--bitfun-canvas-accent)', textDecoration: 'none', ...style } }, children); + const Card = ({ children, variant = 'default', size = 'base', style, ...props } = {}) => el('section', { + ...props, + style: { + border: variant === 'borderless' ? '0' : '1px solid rgba(127,127,127,0.20)', + borderRadius: variant === 'borderless' ? 0 : '8px', + background: variant === 'borderless' ? 'transparent' : 'var(--bitfun-canvas-bg)', + overflow: 'hidden', + ...style, + } + }, children); + const CardHeader = ({ children, trailing, style } = {}) => el('header', { style: { minHeight: '34px', display: 'flex', alignItems: 'center', gap: '10px', justifyContent: 'space-between', padding: '9px 12px', borderBottom: '1px solid rgba(127,127,127,0.16)', fontSize: '12px', fontWeight: 650, lineHeight: 1.25, ...style } }, [ + el('div', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, children), + trailing ? el('div', { style: { flexShrink: 0, color: 'var(--bitfun-canvas-muted)' } }, trailing) : null + ]); + const CardBody = ({ children, style } = {}) => el('div', { style: { padding: '12px', ...style } }, children); + const Empty = ({ description = 'No data', children, style } = {}) => el('div', { style: { display: 'grid', placeItems: 'center', gap: '6px', minHeight: '96px', padding: '18px', border: '1px dashed rgba(127,127,127,0.24)', borderRadius: '8px', color: 'var(--bitfun-canvas-muted)', textAlign: 'center', ...style } }, [ + el('div', { style: { fontSize: '13px' } }, [description]), + children || null, + ]); + const Tabs = ({ items = [], children, activeKey, defaultActiveKey, onChange, style } = {}) => { + const list = Array.isArray(items) ? items : []; + const selectedKey = activeKey ?? defaultActiveKey ?? list[0]?.key; + const selected = list.find(item => item.key === selectedKey) ?? list[0]; + return el('div', { style: { display: 'grid', gap: '10px', ...style } }, [ + list.length ? el('div', { role: 'tablist', style: { display: 'flex', gap: '6px', borderBottom: '1px solid rgba(127,127,127,0.16)' } }, list.map(item => + el('button', { role: 'tab', 'aria-selected': item.key === selected?.key, disabled: item.disabled, onClick: () => onChange?.(item.key), style: { border: 0, borderBottom: item.key === selected?.key ? '2px solid var(--bitfun-canvas-accent)' : '2px solid transparent', background: 'transparent', color: item.key === selected?.key ? 'var(--bitfun-canvas-fg)' : 'var(--bitfun-canvas-muted)', padding: '6px 8px', font: 'inherit', cursor: item.disabled ? 'default' : 'pointer' } }, [item.label]) + )) : null, + selected ? el('div', { role: 'tabpanel' }, selected.children) : children, + ]); + }; + function alertTone(type, tone) { + if (tone) return tone; + if (type === 'error') return 'danger'; + return type || 'info'; + } + function alertIcon(type) { + if (type === 'success') return '✓'; + if (type === 'warning' || type === 'error') return '!'; + return 'i'; + } + const Alert = ({ children, type = 'info', tone, title, message, description, showIcon = true, style } = {}) => { + const color = toneColor(alertTone(type, tone)); + return el('div', { role: 'alert', 'aria-live': type === 'error' ? 'assertive' : 'polite', style: { display: 'grid', gridTemplateColumns: showIcon ? '18px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: '9px', border: '1px solid rgba(127,127,127,0.20)', borderLeft: `3px solid ${color}`, borderRadius: '8px', padding: '10px 12px', background: 'rgba(127,127,127,0.04)', ...style } }, [ + showIcon ? el('span', { 'aria-hidden': true, style: { display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, color, fontSize: '11px', fontWeight: 700 } }, [alertIcon(type)]) : null, + el('span', { style: { minWidth: 0, display: 'grid', gap: '3px' } }, [ + title ? el('strong', { style: { color: 'var(--bitfun-canvas-fg)', fontSize: '13px', lineHeight: 1.35 } }, [title]) : null, + message || children ? el('span', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px', overflowWrap: 'anywhere' } }, [message ?? children]) : null, + description ? el('span', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px', overflowWrap: 'anywhere' } }, [description]) : null, + ]), + ]); + }; + const Callout = ({ children, tone = 'info', title, style } = {}) => el('section', { style: { border: '1px solid rgba(127,127,127,0.20)', borderLeft: `3px solid ${toneColor(tone)}`, borderRadius: '6px', padding: '10px', background: 'rgba(127,127,127,0.04)', ...style } }, [title ? el('div', { style: { fontWeight: 650, marginBottom: '4px', fontSize: '13px' } }, [title]) : null, children]); + const Pill = ({ children, active = false, size = 'md', leadingContent, keyboardHint, disabled, title, onClick, style } = {}) => { + const isButton = typeof onClick === 'function'; + const tag = isButton ? 'button' : 'span'; + const compact = size === 'sm'; + return el(tag, { title, disabled, onClick, style: { display: 'inline-flex', alignItems: 'center', gap: '5px', border: compact ? '0' : '1px solid rgba(127,127,127,0.22)', borderRadius: '999px', padding: compact ? '1px 6px' : '2px 8px', background: active ? 'rgba(52,120,246,0.16)' : 'rgba(127,127,127,0.05)', color: 'var(--bitfun-canvas-fg)', font: 'inherit', fontSize: compact ? '11px' : '12px', lineHeight: '18px', cursor: isButton && !disabled ? 'pointer' : 'default', opacity: disabled ? 0.55 : 1, ...style } }, [leadingContent, children, keyboardHint ? el('span', { style: { color: 'var(--bitfun-canvas-muted)', marginLeft: 2 } }, [keyboardHint]) : null]); + }; + function Chevron({ expanded } = {}) { + return svg('svg', { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none', style: { transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 120ms ease', flexShrink: 0 } }, [ + svg('path', { d: 'M4.5 2.5L8 6l-3.5 3.5', stroke: 'currentColor', 'stroke-width': 1.4, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }) + ]); + } + function CollapsibleSection({ title, leading, count, trailing, children, defaultOpen = false, style } = {}) { + const key = `collapsible:${title || ''}`; + const [open, setOpen] = useCanvasState(key, Boolean(defaultOpen)); + return el('section', { style: { ...style } }, [ + el('button', { onClick: () => setOpen(!open), style: { width: '100%', minHeight: '28px', display: 'flex', alignItems: 'center', gap: '7px', border: 0, padding: '4px 0', background: 'transparent', color: 'var(--bitfun-canvas-fg)', font: 'inherit', cursor: 'pointer', textAlign: 'left' } }, [ + Chevron({ expanded: open }), + leading || null, + el('span', { style: { fontSize: '13px', fontWeight: 650, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, [title]), + count !== undefined ? el('span', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [String(count)]) : null, + el('span', { style: { flex: 1 } }), + trailing ? el('span', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px', flexShrink: 0 } }, trailing) : null, + ]), + open ? el('div', { style: { marginLeft: '18px', paddingTop: '6px', paddingBottom: '4px' } }, children) : null, + ]); + } + function normalizeDAGEdges(edges) { + if (!Array.isArray(edges)) return []; + return edges + .map(edge => ({ ...edge, from: edge.from ?? edge.source, to: edge.to ?? edge.target })) + .filter(edge => edge.from !== undefined && edge.to !== undefined); + } + function layoutEdgePath(edge, direction) { + if (direction === 'horizontal') { + const midX = edge.sourceX + (edge.targetX - edge.sourceX) / 2; + return `M ${edge.sourceX} ${edge.sourceY} C ${midX} ${edge.sourceY}, ${midX} ${edge.targetY}, ${edge.targetX} ${edge.targetY}`; + } + const midY = edge.sourceY + (edge.targetY - edge.sourceY) / 2; + return `M ${edge.sourceX} ${edge.sourceY} C ${edge.sourceX} ${midY}, ${edge.targetX} ${midY}, ${edge.targetX} ${edge.targetY}`; + } + function computeDAGLayout(options = {}) { + const nodes = Array.isArray(options.nodes) ? options.nodes : []; + const edges = normalizeDAGEdges(options.edges); + const direction = options.direction === 'horizontal' ? 'horizontal' : 'vertical'; + const nodeWidth = Number(options.nodeWidth) || 160; + const nodeHeight = Number(options.nodeHeight) || 40; + const rankGap = Number(options.rankGap) || 64; + const nodeGap = Number(options.nodeGap) || 48; + const padding = Number(options.padding) || 24; + const nodeMetaById = new Map(nodes.map(node => [String(node.id), node])); + const ids = nodes.map(node => String(node.id)); + const idSet = new Set(ids); + const outgoing = new Map(ids.map(id => [id, []])); + const incoming = new Map(ids.map(id => [id, []])); + for (const edge of edges) { + const from = String(edge.from); + const to = String(edge.to); + if (!idSet.has(from) || !idSet.has(to)) continue; + outgoing.get(from).push(to); + incoming.get(to).push(from); + } + const backEdges = new Set(); + const visiting = new Set(); + const visited = new Set(); + function visit(id) { + if (visiting.has(id)) return; + if (visited.has(id)) return; + visiting.add(id); + for (const next of outgoing.get(id) || []) { + const key = `${id}\u0000${next}`; + if (visiting.has(next)) { + backEdges.add(key); + continue; + } + visit(next); + } + visiting.delete(id); + visited.add(id); + } + ids.forEach(visit); + const rank = new Map(ids.map(id => [id, 0])); + for (let pass = 0; pass < ids.length; pass++) { + let changed = false; + for (const edge of edges) { + const from = String(edge.from); + const to = String(edge.to); + if (!idSet.has(from) || !idSet.has(to) || backEdges.has(`${from}\u0000${to}`)) continue; + const nextRank = (rank.get(from) || 0) + 1; + if (nextRank > (rank.get(to) || 0)) { + rank.set(to, nextRank); + changed = true; + } + } + if (!changed) break; + } + const grouped = new Map(); + ids.forEach(id => { + const value = rank.get(id) || 0; + if (!grouped.has(value)) grouped.set(value, []); + grouped.get(value).push(id); + }); + const rankKeys = Array.from(grouped.keys()).sort((a, b) => a - b); + const maxRankWidth = Math.max(0, ...rankKeys.map(key => grouped.get(key).length * nodeWidth + Math.max(0, grouped.get(key).length - 1) * nodeGap)); + const maxRankHeight = Math.max(0, ...rankKeys.map(key => grouped.get(key).length * nodeHeight + Math.max(0, grouped.get(key).length - 1) * nodeGap)); + const positioned = []; + const ranks = []; + rankKeys.forEach((rankKey, rankIndex) => { + const rankIds = grouped.get(rankKey); + const rankWidth = direction === 'vertical' ? rankIds.length * nodeWidth + Math.max(0, rankIds.length - 1) * nodeGap : nodeWidth; + const rankHeight = direction === 'vertical' ? nodeHeight : rankIds.length * nodeHeight + Math.max(0, rankIds.length - 1) * nodeGap; + const rankX = direction === 'vertical' ? padding + Math.max(0, (maxRankWidth - rankWidth) / 2) : padding + rankIndex * (nodeWidth + rankGap); + const rankY = direction === 'vertical' ? padding + rankIndex * (nodeHeight + rankGap) : padding + Math.max(0, (maxRankHeight - rankHeight) / 2); + ranks.push({ rank: rankKey, x: rankX, y: rankY, width: rankWidth, height: rankHeight, nodeIds: rankIds.slice() }); + rankIds.forEach((id, order) => { + const meta = nodeMetaById.get(id) || {}; + const x = direction === 'vertical' ? rankX + order * (nodeWidth + nodeGap) : rankX; + const y = direction === 'vertical' ? rankY : rankY + order * (nodeHeight + nodeGap); + positioned.push({ + ...meta, + id, + meta, + source: meta, + x, + y, + centerX: x + nodeWidth / 2, + centerY: y + nodeHeight / 2, + width: nodeWidth, + height: nodeHeight, + rank: rankKey, + order, + }); + }); + }); + const posMap = new Map(positioned.map(node => [node.id, node])); + ranks.forEach(rankItem => { + const rankNodes = positioned.filter(node => node.rank === rankItem.rank); + rankItem.nodeIds = rankNodes.map(node => node.id); + rankItem.nodes = rankNodes; + }); + const layoutEdges = edges.flatMap(edge => { + const from = String(edge.from); + const to = String(edge.to); + const source = posMap.get(from); + const target = posMap.get(to); + if (!source || !target) return []; + const isBackEdge = backEdges.has(`${from}\u0000${to}`) || (rank.get(to) || 0) <= (rank.get(from) || 0); + if (direction === 'vertical') { + const layoutEdge = { ...edge, from, to, sourceX: source.x + nodeWidth / 2, sourceY: source.y + nodeHeight, targetX: target.x + nodeWidth / 2, targetY: target.y, isBackEdge }; + return [{ ...layoutEdge, path: layoutEdgePath(layoutEdge, direction) }]; + } + const layoutEdge = { ...edge, from, to, sourceX: source.x + nodeWidth, sourceY: source.y + nodeHeight / 2, targetX: target.x, targetY: target.y + nodeHeight / 2, isBackEdge }; + return [{ ...layoutEdge, path: layoutEdgePath(layoutEdge, direction) }]; + }); + const width = direction === 'vertical' ? padding * 2 + maxRankWidth : padding * 2 + rankKeys.length * nodeWidth + Math.max(0, rankKeys.length - 1) * rankGap; + const height = direction === 'vertical' ? padding * 2 + rankKeys.length * nodeHeight + Math.max(0, rankKeys.length - 1) * rankGap : padding * 2 + maxRankHeight; + return withLayoutNodeArrayCompat({ nodes: positioned, edges: layoutEdges, ranks, direction, width, height }); + } + function withLayoutNodeArrayCompat(layout) { + layout[Symbol.iterator] = () => layout.nodes[Symbol.iterator](); + layout.find = layout.nodes.find.bind(layout.nodes); + layout.filter = layout.nodes.filter.bind(layout.nodes); + layout.forEach = layout.nodes.forEach.bind(layout.nodes); + layout.map = layout.nodes.map.bind(layout.nodes); + return layout; + } + const Stat = ({ value, label, tone, style } = {}) => el('div', { style: { display: 'grid', gap: '2px', ...style } }, [el('strong', { style: { color: toneColor(tone), fontSize: '22px', lineHeight: 1.1, fontVariantNumeric: 'tabular-nums' } }, [value]), el('span', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [label])]); + const Table = ({ headers = [], rows = [], columnAlign = [], rowTone = [], framed = true, striped = false, stickyHeader = false, style, emptyMessage = 'No rows' } = {}) => { + const bodyRows = rows.length ? rows.map((row, rowIndex) => el('tr', { style: { background: striped && rowIndex % 2 === 1 ? 'rgba(127,127,127,0.04)' : 'transparent' } }, headers.map((_, index) => { + const content = row[index] ?? ''; + const tone = index === 0 ? rowTone[rowIndex] : undefined; + return el('td', { style: cellStyle(false, columnAlign[index]) }, [ + tone ? el('span', { style: { display: 'inline-block', width: 6, height: 6, borderRadius: 99, marginRight: 7, background: toneColor(tone), verticalAlign: 'middle' } }) : null, + content, + ]); + }))) : [el('tr', {}, [el('td', { colspan: headers.length || 1, style: { ...cellStyle(false), color: 'var(--bitfun-canvas-muted)' } }, [emptyMessage])])]; + return el('div', { style: { overflow: 'auto', border: framed ? '1px solid rgba(127,127,127,0.20)' : 0, borderRadius: framed ? '8px' : 0, background: 'var(--bitfun-canvas-bg)', ...style } }, [el('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '12px' } }, [ + el('thead', {}, [el('tr', {}, headers.map((h, index) => el('th', { style: { ...cellStyle(true, columnAlign[index]), position: stickyHeader ? 'sticky' : undefined, top: stickyHeader ? 0 : undefined, background: 'var(--bitfun-canvas-panel)' } }, [h])))]), + el('tbody', {}, bodyRows) + ])]); + }; + const KeyValueList = ({ items = [], columns = 1, compact = false, emptyMessage = 'No details', style } = {}) => { + const entries = Array.isArray(items) ? items : Object.entries(items || {}).map(([label, value]) => ({ label, value })); + const columnCount = Math.max(1, Math.min(4, Math.floor(Number(columns) || 1))); + return el('dl', { style: { display: 'grid', gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`, gap: compact ? '6px' : '10px', margin: 0, ...style } }, entries.length ? entries.map((item, index) => el('div', { key: item.key || index, style: { minWidth: 0, padding: compact ? '0 0 6px' : '8px 0', borderBottom: '1px solid rgba(127,127,127,0.16)' } }, [ + el('dt', { style: { margin: 0, color: 'var(--bitfun-canvas-muted)', fontSize: '11px', lineHeight: 1.35 } }, [item.label]), + el('dd', { style: { margin: '2px 0 0', color: toneColor(item.tone), fontSize: compact ? '12px' : '13px', fontWeight: 560, overflowWrap: 'anywhere' } }, [item.value]), + ])) : [el('div', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [emptyMessage])]); + }; + const Timeline = ({ items = [], emptyMessage = 'No events', style } = {}) => el('ol', { style: { display: 'grid', gap: '10px', margin: 0, padding: 0, listStyle: 'none', ...style } }, items.length ? items.map((item, index) => el('li', { key: item.key || index, style: { display: 'grid', gridTemplateColumns: '18px minmax(0, 1fr)', gap: '9px', minWidth: 0 } }, [ + el('span', { style: { display: 'grid', placeItems: 'center', width: 18, height: 18, marginTop: 1, borderRadius: 999, background: 'rgba(127,127,127,0.12)', color: toneColor(item.tone), fontSize: '10px', fontWeight: 700 } }, [item.icon || '']), + el('span', { style: { minWidth: 0, display: 'grid', gap: '2px' } }, [ + el('span', { style: { display: 'flex', gap: '8px', alignItems: 'baseline', justifyContent: 'space-between', minWidth: 0 } }, [ + el('strong', { style: { minWidth: 0, color: 'var(--bitfun-canvas-fg)', fontSize: '13px' } }, [item.title]), + item.time ? el('time', { style: { flex: '0 0 auto', color: 'var(--bitfun-canvas-muted)', fontSize: '11px' } }, [item.time]) : null, + ]), + item.description ? el('span', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px', overflowWrap: 'anywhere' } }, [item.description]) : null, + ]), + ])) : [el('li', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [emptyMessage])]); + function fileTreeKey(item, index, depth) { + return item.key || item.path || `${depth}-${index}-${String(item.name || '')}`; + } + function renderFileTreeItems(items, depth, defaultExpanded) { + return (items || []).map((item, index) => { + const children = Array.isArray(item.children) ? item.children : []; + const isFolder = item.type === 'folder' || children.length > 0; + const row = el('span', { style: { display: 'flex', alignItems: 'center', gap: '7px', minWidth: 0, padding: '3px 0', paddingLeft: `${depth * 16}px` } }, [ + el('span', { style: { flex: '0 0 auto', width: 14, color: isFolder ? 'var(--bitfun-canvas-accent)' : 'var(--bitfun-canvas-muted)' } }, [isFolder ? '▸' : '•']), + el('span', { style: { minWidth: 0, color: toneColor(item.tone), fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, [item.name || item.path]), + item.meta ? el('span', { style: { flex: '0 0 auto', marginLeft: 'auto', color: 'var(--bitfun-canvas-muted)', fontSize: '11px' } }, [item.meta]) : null, + ]); + if (!isFolder) return el('div', { key: fileTreeKey(item, index, depth) }, [row]); + return el('details', { key: fileTreeKey(item, index, depth), open: defaultExpanded }, [ + el('summary', { style: { display: 'block', cursor: 'default', listStyle: 'none' } }, [row]), + ...renderFileTreeItems(children, depth + 1, defaultExpanded), + ]); + }); + } + const FileTree = ({ items = [], defaultExpanded = true, emptyMessage = 'No files', style } = {}) => el('div', { style: { minWidth: 0, overflow: 'auto', border: '1px solid rgba(127,127,127,0.20)', borderRadius: '8px', padding: '8px 10px', background: 'rgba(127,127,127,0.04)', ...style } }, items.length ? renderFileTreeItems(items, 0, defaultExpanded) : [el('div', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [emptyMessage])]); + const ProgressBar = ({ value = 0, max = 100, label, tone = 'primary', showValue = true, style } = {}) => { + const safeMax = Math.max(1, Number(max) || 100); + const safeValue = Math.max(0, Math.min(safeMax, Number(value) || 0)); + const percent = Math.round(safeValue / safeMax * 100); + return el('div', { style }, [ + label || showValue ? el('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '10px', marginBottom: '5px', color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [ + el('span', {}, [label]), + showValue ? el('span', { style: { fontVariantNumeric: 'tabular-nums' } }, [`${percent}%`]) : null, + ]) : null, + el('div', { role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': safeMax, 'aria-valuenow': safeValue, style: { height: 8, overflow: 'hidden', borderRadius: 999, background: 'rgba(127,127,127,0.20)' } }, [ + el('div', { style: { width: `${percent}%`, height: '100%', borderRadius: 999, background: toneColor(tone) } }), + ]), + ]); + }; + const Swatch = ({ color = 'gray', style, title } = {}) => el('span', { + title, + 'aria-hidden': title ? undefined : true, + style: { + display: 'inline-block', + width: 12, + height: 12, + borderRadius: 3, + background: categoryColor(color), + border: '1px solid var(--bitfun-canvas-border)', + flex: '0 0 auto', + ...style, + }, + }); + function positiveSegmentValue(value) { + const next = typeof value === 'number' ? value : Number(value); + return Number.isFinite(next) && next > 0 ? next : 0; + } + const UsageBar = ({ segments = [], total = 0, topLeftLabel, topRightLabel, style } = {}) => { + const normalized = (Array.isArray(segments) ? segments : []).map((segment, index) => ({ + ...segment, + value: positiveSegmentValue(segment.value), + color: segment.color || usageColorSequence[index % usageColorSequence.length], + })); + const segmentTotal = normalized.reduce((sum, segment) => sum + segment.value, 0); + const safeTotal = Math.max(positiveSegmentValue(total), segmentTotal, 1); + const remainder = Math.max(0, safeTotal - segmentTotal); + return el('div', { style }, [ + topLeftLabel || topRightLabel ? el('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 6, color: 'var(--bitfun-canvas-muted)', fontSize: '12px', lineHeight: 1.35 } }, [ + el('span', {}, [topLeftLabel]), + el('span', { style: { marginLeft: 'auto', fontVariantNumeric: 'tabular-nums' } }, [topRightLabel]), + ]) : null, + el('div', { role: 'progressbar', 'aria-valuemin': 0, 'aria-valuemax': safeTotal, 'aria-valuenow': Math.min(segmentTotal, safeTotal), style: { display: 'flex', gap: 2, height: 10, overflow: 'hidden', borderRadius: 999, background: 'rgba(127,127,127,0.20)', padding: 1 } }, [ + ...normalized.map((segment, index) => segment.value > 0 ? el('span', { key: segment.id || index, title: `${segment.id}: ${segment.value}`, style: { flex: `${segment.value} 1 0`, minWidth: 2, borderRadius: 999, background: categoryColor(segment.color, index) } }) : null), + remainder > 0 ? el('span', { 'aria-hidden': true, style: { flex: `${remainder} 1 0`, minWidth: 2, borderRadius: 999, background: 'rgba(127,127,127,0.10)' } }) : null, + ]), + ]); + }; + function todoStatusColor(status) { + if (status === 'completed') return 'var(--bitfun-canvas-success)'; + if (status === 'in_progress') return 'var(--bitfun-canvas-warning)'; + return 'var(--bitfun-canvas-muted)'; + } + function todoStatusLabel(status) { + if (status === 'completed') return 'completed'; + if (status === 'in_progress') return 'in progress'; + if (status === 'cancelled') return 'cancelled'; + return 'pending'; + } + function dimmedTodoSet(value) { + if (!value) return new Set(); + if (value instanceof Set) return value; + return new Set(Array.isArray(value) ? value : []); + } + function TodoMarker({ status } = {}) { + const color = todoStatusColor(status); + const completed = status === 'completed'; + return el('span', { 'aria-hidden': true, style: { width: 14, height: 14, marginTop: 2, flex: '0 0 auto', display: 'inline-grid', placeItems: 'center', borderRadius: status === 'in_progress' ? 999 : 3, border: `1.5px solid ${color}`, background: completed ? color : 'transparent', color: 'var(--bitfun-canvas-panel)', fontSize: '10px', lineHeight: 1, fontWeight: 800 } }, [completed ? '✓' : '']); + } + const TodoList = ({ todos = [], dimmedTodoIds, onTodoClick, style } = {}) => { + const list = Array.isArray(todos) ? todos : []; + if (!list.length) return null; + const dimmed = dimmedTodoSet(dimmedTodoIds); + return el('div', { style: { display: 'grid', gap: 4, ...style } }, list.map(todo => { + const content = todo.content || todo.id; + const isDimmed = dimmed.has(todo.id); + const rowStyle = { + width: '100%', + display: 'grid', + gridTemplateColumns: '18px minmax(0, 1fr)', + gap: 8, + alignItems: 'start', + border: 0, + borderRadius: 6, + padding: '6px 7px', + background: 'transparent', + color: 'var(--bitfun-canvas-fg)', + font: 'inherit', + textAlign: 'left', + opacity: isDimmed ? 0.5 : 1, + cursor: onTodoClick ? 'pointer' : 'default', + }; + const body = [ + TodoMarker({ status: todo.status }), + el('span', { style: { minWidth: 0, display: 'grid', gap: 2 } }, [ + el('span', { style: { color: todo.status === 'completed' ? 'var(--bitfun-canvas-muted)' : 'var(--bitfun-canvas-fg)', fontSize: '12px', lineHeight: 1.45, textDecoration: todo.status === 'completed' ? 'line-through' : undefined, overflowWrap: 'anywhere' } }, [content]), + el('span', { style: { color: todoStatusColor(todo.status), fontSize: '10px', lineHeight: 1.2 } }, [todoStatusLabel(todo.status)]), + ]), + ]; + return onTodoClick + ? el('button', { key: todo.id, type: 'button', onClick: () => onTodoClick(todo), style: rowStyle }, body) + : el('div', { key: todo.id, style: rowStyle }, body); + })); + }; + const TodoListCard = ({ todos = [], dimmedTodoIds, defaultExpanded = false, onTodoClick, style } = {}) => { + const list = Array.isArray(todos) ? todos : []; + if (!list.length) return null; + const completed = list.filter(todo => todo.status === 'completed').length; + const key = `todo-list-card:${list.map(todo => todo.id).join('|')}`; + const [open, setOpen] = useCanvasState(key, Boolean(defaultExpanded)); + return el('section', { style: { border: '1px solid var(--bitfun-canvas-border)', borderRadius: '8px', background: 'var(--bitfun-canvas-bg)', overflow: 'hidden', ...style } }, [ + el('button', { type: 'button', 'aria-expanded': open, onClick: () => setOpen(!open), style: { width: '100%', minHeight: 34, display: 'flex', alignItems: 'center', gap: 8, border: 0, borderBottom: open ? '1px solid var(--bitfun-canvas-border)' : 0, background: 'transparent', color: 'var(--bitfun-canvas-fg)', padding: '8px 10px', font: 'inherit', cursor: 'pointer', textAlign: 'left' } }, [ + el('span', { 'aria-hidden': true, style: { color: 'var(--bitfun-canvas-muted)', transform: open ? 'rotate(90deg)' : 'rotate(0deg)' } }, ['›']), + el('span', { style: { fontWeight: 650, fontSize: '12px' } }, ['Tasks']), + el('span', { style: { marginLeft: 'auto', color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, [`${completed}/${list.length} done`]), + ]), + open ? el('div', { style: { padding: 8 } }, [TodoList({ todos: list, dimmedTodoIds, onTodoClick })]) : null, + ]); + }; + const Button = ({ children, variant = 'secondary', onClick, disabled, type = 'button', style } = {}) => { + const primary = variant === 'primary'; + const ghost = variant === 'ghost'; + return el('button', { type, onClick, disabled, style: { border: ghost ? '1px solid transparent' : '1px solid rgba(127,127,127,0.22)', borderRadius: '6px', background: primary ? 'var(--bitfun-canvas-accent)' : ghost ? 'transparent' : 'rgba(127,127,127,0.06)', color: primary ? '#fff' : 'var(--bitfun-canvas-fg)', padding: '4px 10px', minHeight: '24px', font: 'inherit', fontSize: '12px', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.55 : 1, ...style } }, children); + }; + const Toggle = ({ checked, onChange, label, disabled, size = 'sm', style } = {}) => { + const width = size === 'md' ? 34 : 28; + const height = size === 'md' ? 20 : 16; + const knob = height - 4; + return el('button', { disabled, onClick: () => onChange?.(!checked), style: { width, height, border: 0, borderRadius: 999, background: checked ? 'var(--bitfun-canvas-accent)' : 'rgba(127,127,127,0.20)', padding: 2, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.55 : 1, ...style } }, [ + el('span', { style: { display: 'block', width: knob, height: knob, borderRadius: 999, background: '#fff', transform: `translateX(${checked ? width - height : 0}px)` } }), + label ? el('span', {}, [label]) : null, + ]); + }; + const Checkbox = ({ checked, onChange, disabled, label, style } = {}) => el('label', { style: { display: 'inline-flex', gap: '6px', alignItems: 'center', fontSize: '12px', opacity: disabled ? 0.55 : 1, ...style } }, [el('input', { type: 'checkbox', checked, disabled, onChange: event => onChange?.(event.target.checked), style: { accentColor: 'var(--bitfun-canvas-accent)' } }), label]); + const Select = ({ value, options = [], placeholder, disabled, onChange, style } = {}) => el('select', { value, disabled, onChange: event => onChange?.(event.target.value), style: { border: '1px solid rgba(127,127,127,0.22)', borderRadius: '6px', minHeight: '28px', padding: '4px 8px', background: 'var(--bitfun-canvas-panel)', color: 'var(--bitfun-canvas-fg)', ...style } }, [placeholder ? el('option', { value: '' }, [placeholder]) : null, ...options.map(option => typeof option === 'string' ? el('option', { value: option }, [option]) : el('option', { value: option.value, disabled: option.disabled }, [option.label]))]); + const TextInput = ({ value, placeholder, disabled, type = 'text', onChange, style } = {}) => el('input', { value, placeholder, disabled, type, onInput: event => onChange?.(event.target.value), style: { border: '1px solid rgba(127,127,127,0.22)', borderRadius: '6px', minHeight: '28px', padding: '4px 8px', background: 'var(--bitfun-canvas-panel)', color: 'var(--bitfun-canvas-fg)', ...style } }); + const Input = ({ value, placeholder, disabled, type = 'text', onChange, label, hint, prefix, suffix, error, errorMessage, style } = {}) => el('label', { style: { display: 'grid', gap: '5px', color: 'var(--bitfun-canvas-fg)', fontSize: '12px', ...style } }, [ + label ? el('span', { style: { fontWeight: 600 } }, [label]) : null, + el('span', { style: { display: 'flex', alignItems: 'center', gap: '6px', border: `1px solid ${error ? 'var(--bitfun-canvas-danger)' : 'rgba(127,127,127,0.22)'}`, borderRadius: '6px', minHeight: '30px', padding: '0 8px', background: 'var(--bitfun-canvas-panel)' } }, [ + prefix || null, + el('input', { value, placeholder, disabled, type, onInput: event => onChange?.(event.target.value), style: { flex: 1, minWidth: 0, border: 0, outline: 0, background: 'transparent', color: 'var(--bitfun-canvas-fg)', font: 'inherit' } }), + suffix || null, + ]), + error && errorMessage ? el('span', { style: { color: 'var(--bitfun-canvas-danger)' } }, [errorMessage]) : hint ? el('span', { style: { color: 'var(--bitfun-canvas-muted)' } }, [hint]) : null, + ]); + const TextArea = ({ value, placeholder, disabled, rows = 3, onChange, style } = {}) => el('textarea', { value, placeholder, disabled, rows, onInput: event => onChange?.(event.target.value), style: { border: '1px solid rgba(127,127,127,0.22)', borderRadius: '6px', padding: '7px 8px', background: 'var(--bitfun-canvas-panel)', color: 'var(--bitfun-canvas-fg)', font: 'inherit', fontSize: '13px', resize: 'vertical', width: '100%', boxSizing: 'border-box', ...style } }); + const IconButton = ({ children, onClick, disabled, title, variant = 'default', size = 'md', style } = {}) => { + const px = size === 'sm' ? 18 : 24; + return el('button', { title, onClick, disabled, style: { width: px, height: px, display: 'inline-grid', placeItems: 'center', border: 0, borderRadius: variant === 'circle' ? 999 : 5, background: variant === 'circle' ? 'rgba(127,127,127,0.12)' : 'transparent', color: 'var(--bitfun-canvas-muted)', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.55 : 1, ...style } }, children); + }; + const DiffStats = ({ additions = 0, deletions = 0, style } = {}) => { + if (!additions && !deletions) return null; + return el('span', { style: { display: 'inline-flex', gap: '6px', alignItems: 'center', fontSize: '12px', fontVariantNumeric: 'tabular-nums', ...style } }, [ + additions ? el('span', { style: { color: 'var(--bitfun-canvas-success)' } }, [`+${additions}`]) : null, + deletions ? el('span', { style: { color: 'var(--bitfun-canvas-danger)' } }, [`-${deletions}`]) : null, + ]); + }; + function diffLineStyle(type) { + if (type === 'added') return { background: 'rgba(36,138,61,0.12)', color: 'var(--bitfun-canvas-fg)', accent: 'var(--bitfun-canvas-success)', sign: '+' }; + if (type === 'removed') return { background: 'rgba(209,36,47,0.12)', color: 'var(--bitfun-canvas-fg)', accent: 'var(--bitfun-canvas-danger)', sign: '-' }; + return { background: 'transparent', color: 'var(--bitfun-canvas-fg)', accent: 'transparent', sign: ' ' }; + } + const DiffView = ({ lines = [], showLineNumbers = true, coloredLineNumbers = true, showAccentStrip = true, style } = {}) => el('div', { style: { overflow: 'auto', fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace', fontSize: '12px', lineHeight: 1.55, background: 'rgba(127,127,127,0.035)', ...style } }, lines.map((line, index) => { + const meta = diffLineStyle(line.type); + return el('div', { style: { display: 'grid', gridTemplateColumns: `${showAccentStrip ? '3px ' : ''}${showLineNumbers ? '52px ' : ''}18px minmax(0,1fr)`, minWidth: '100%', background: meta.background, color: meta.color, whiteSpace: 'pre' } }, [ + showAccentStrip ? el('span', { style: { background: meta.accent } }) : null, + showLineNumbers ? el('span', { style: { color: coloredLineNumbers && line.type !== 'unchanged' ? meta.accent : 'var(--bitfun-canvas-muted)', textAlign: 'right', padding: '0 8px', userSelect: 'none' } }, [line.lineNumber ?? index + 1]) : null, + el('span', { style: { color: meta.accent === 'transparent' ? 'var(--bitfun-canvas-muted)' : meta.accent, userSelect: 'none' } }, [meta.sign]), + el('span', { style: { paddingRight: '10px' } }, [line.content || '']), + ]); + })); + const chartPalette = ['#3478f6', '#248a3d', '#b54708', '#d1242f', '#8250df', '#0a7ea4', '#bf3989']; + function svg(tag, props = {}, children = []) { + const node = document.createElementNS('http://www.w3.org/2000/svg', tag); + props = props || {}; + for (const [key, value] of Object.entries(props)) { + if (key === 'children' || key === 'key' || value === undefined || value === null || value === false) continue; + if (key === 'style') applyStyle(node, value); + else if (key === 'className') node.setAttribute('class', String(value)); + else if (key === 'ref' && typeof value === 'function') value(node); + else if (key === 'ref' && value && typeof value === 'object') value.current = node; + else if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2).toLowerCase(), value); + else node.setAttribute(svgAttrName(key), String(value)); + } + appendChildren(node, children); + return node; + } + function finiteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + function chartLabel(item, index, categories, labelKey) { + if (categories[index] !== undefined) return String(categories[index]); + if (item && typeof item === 'object') return String(item[labelKey] ?? item.label ?? item.name ?? item.category ?? index + 1); + return String(index + 1); + } + function numericKeys(rows, labelKey, valueKey) { + const blocked = new Set([labelKey, valueKey, 'label', 'name', 'category', 'color']); + const keys = []; + for (const row of rows) { + if (!row || typeof row !== 'object' || Array.isArray(row)) continue; + for (const [key, value] of Object.entries(row)) { + if (blocked.has(key) || keys.includes(key)) continue; + if (Number.isFinite(Number(value))) keys.push(key); + } + } + return keys; + } + function normalizeChart(props = {}) { + const categories = Array.isArray(props.categories) ? props.categories : []; + const data = Array.isArray(props.data) ? props.data : []; + const rawSeries = Array.isArray(props.series) ? props.series : []; + const labelKey = props.labelKey || props.xKey || 'label'; + const valueKey = props.valueKey || props.yKey || 'value'; + let labels = categories.map(String); + let series = []; + if (rawSeries.length && rawSeries.every(item => item && typeof item === 'object' && Array.isArray(item.data))) { + series = rawSeries.map((entry, index) => ({ + name: String(entry.name ?? entry.label ?? `Series ${index + 1}`), + color: entry.color || chartPalette[index % chartPalette.length], + values: entry.data.map((item, itemIndex) => finiteNumber(item && typeof item === 'object' ? item[valueKey] ?? item.value : item)), + })); + const maxLength = Math.max(labels.length, ...series.map(entry => entry.values.length)); + labels = Array.from({ length: maxLength }, (_, index) => labels[index] ?? String(index + 1)); + } else if (rawSeries.length && rawSeries.every(item => Number.isFinite(Number(item)))) { + labels = labels.length ? labels : rawSeries.map((_, index) => String(index + 1)); + series = [{ name: props.name || 'Value', color: props.color || chartPalette[0], values: rawSeries.map(finiteNumber) }]; + } else if (data.length && data.every(item => Number.isFinite(Number(item)))) { + labels = labels.length ? labels : data.map((_, index) => String(index + 1)); + series = [{ name: props.name || 'Value', color: props.color || chartPalette[0], values: data.map(finiteNumber) }]; + } else if (data.length) { + const keys = numericKeys(data, labelKey, valueKey); + labels = data.map((item, index) => chartLabel(item, index, categories, labelKey)); + if (keys.length) { + series = keys.map((key, index) => ({ + name: key, + color: chartPalette[index % chartPalette.length], + values: data.map(item => finiteNumber(item?.[key])), + })); + } else { + series = [{ + name: props.name || 'Value', + color: props.color || chartPalette[0], + values: data.map(item => finiteNumber(item?.[valueKey] ?? item?.value)), + }]; + } + } + const maxLength = Math.max(labels.length, ...series.map(entry => entry.values.length), 0); + labels = Array.from({ length: maxLength }, (_, index) => labels[index] ?? String(index + 1)); + series = series.map((entry, index) => ({ + ...entry, + color: entry.color || chartPalette[index % chartPalette.length], + values: Array.from({ length: maxLength }, (_, itemIndex) => finiteNumber(entry.values[itemIndex])), + })); + return { labels, series }; + } + function chartShell(title, height, style, child) { + return el('div', { style: { border: '1px solid var(--bitfun-canvas-border)', borderRadius: '8px', padding: '10px', background: 'rgba(127,127,127,0.04)', ...style } }, [ + title ? el('div', { style: { fontWeight: 650, marginBottom: '8px' } }, [title]) : null, + child || el('div', { style: { minHeight: `${height}px`, display: 'grid', placeItems: 'center', color: 'var(--bitfun-canvas-muted)' } }, ['No chart data']) + ]); + } + function chartLegend(series) { + return el('div', { style: { display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '8px', color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, series.map(entry => el('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '5px' } }, [ + el('span', { style: { width: '9px', height: '9px', borderRadius: '999px', background: entry.color } }), + entry.name + ]))); + } + function chartMax(series) { + return Math.max(1, ...series.flatMap(entry => entry.values).map(value => Math.abs(value))); + } + function BarChart(props = {}) { + const { labels, series } = normalizeChart(props); + const height = Number(props.height) || 220; + if (!labels.length || !series.length) return chartShell(props.title || 'Bar chart', height, props.style); + const width = 720; + const padding = { top: 12, right: 18, bottom: 42, left: 44 }; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const max = chartMax(series); + const groupWidth = innerWidth / labels.length; + const barWidth = Math.max(3, (groupWidth - 8) / series.length); + const bars = []; + labels.forEach((label, labelIndex) => { + series.forEach((entry, seriesIndex) => { + const value = entry.values[labelIndex] || 0; + const barHeight = Math.abs(value) / max * innerHeight; + const x = padding.left + labelIndex * groupWidth + 4 + seriesIndex * barWidth; + const y = padding.top + innerHeight - barHeight; + bars.push(svg('rect', { x, y, width: Math.max(1, barWidth - 2), height: barHeight, rx: 3, fill: entry.color })); + }); + }); + const axis = [ + svg('line', { x1: padding.left, y1: padding.top + innerHeight, x2: width - padding.right, y2: padding.top + innerHeight, stroke: 'var(--bitfun-canvas-border)' }), + svg('line', { x1: padding.left, y1: padding.top, x2: padding.left, y2: padding.top + innerHeight, stroke: 'var(--bitfun-canvas-border)' }), + svg('text', { x: padding.left - 8, y: padding.top + 10, 'text-anchor': 'end', fill: 'var(--bitfun-canvas-muted)', 'font-size': 11 }, [String(max)]), + ...labels.map((label, index) => svg('text', { x: padding.left + index * groupWidth + groupWidth / 2, y: height - 12, 'text-anchor': 'middle', fill: 'var(--bitfun-canvas-muted)', 'font-size': 11 }, [label])), + ]; + return chartShell(props.title || 'Bar chart', height, props.style, el('div', {}, [ + svg('svg', { viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': props.title || 'Bar chart', style: { width: '100%', height: `${height}px`, display: 'block' } }, [...axis, ...bars]), + chartLegend(series) + ])); + } + function LineChart(props = {}) { + const { labels, series } = normalizeChart(props); + const height = Number(props.height) || 220; + if (!labels.length || !series.length) return chartShell(props.title || 'Line chart', height, props.style); + const width = 720; + const padding = { top: 12, right: 18, bottom: 42, left: 44 }; + const innerWidth = width - padding.left - padding.right; + const innerHeight = height - padding.top - padding.bottom; + const max = chartMax(series); + const step = labels.length > 1 ? innerWidth / (labels.length - 1) : innerWidth; + const lines = series.flatMap(entry => { + const points = entry.values.map((value, index) => { + const x = padding.left + (labels.length > 1 ? index * step : innerWidth / 2); + const y = padding.top + innerHeight - (Math.abs(value) / max * innerHeight); + return [x, y]; + }); + return [ + svg('polyline', { points: points.map(point => point.join(',')).join(' '), fill: 'none', stroke: entry.color, 'stroke-width': 2.4, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }), + ...points.map(point => svg('circle', { cx: point[0], cy: point[1], r: 3.4, fill: entry.color })), + ]; + }); + const axis = [ + svg('line', { x1: padding.left, y1: padding.top + innerHeight, x2: width - padding.right, y2: padding.top + innerHeight, stroke: 'var(--bitfun-canvas-border)' }), + svg('line', { x1: padding.left, y1: padding.top, x2: padding.left, y2: padding.top + innerHeight, stroke: 'var(--bitfun-canvas-border)' }), + svg('text', { x: padding.left - 8, y: padding.top + 10, 'text-anchor': 'end', fill: 'var(--bitfun-canvas-muted)', 'font-size': 11 }, [String(max)]), + ...labels.map((label, index) => svg('text', { x: padding.left + (labels.length > 1 ? index * step : innerWidth / 2), y: height - 12, 'text-anchor': 'middle', fill: 'var(--bitfun-canvas-muted)', 'font-size': 11 }, [label])), + ]; + return chartShell(props.title || 'Line chart', height, props.style, el('div', {}, [ + svg('svg', { viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': props.title || 'Line chart', style: { width: '100%', height: `${height}px`, display: 'block' } }, [...axis, ...lines]), + chartLegend(series) + ])); + } + function polarPoint(cx, cy, radius, angle) { + return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + } + function piePath(cx, cy, radius, startAngle, endAngle) { + const start = polarPoint(cx, cy, radius, startAngle); + const end = polarPoint(cx, cy, radius, endAngle); + const largeArc = endAngle - startAngle > Math.PI ? 1 : 0; + return `M ${cx} ${cy} L ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${largeArc} 1 ${end[0]} ${end[1]} Z`; + } + function PieChart(props = {}) { + const normalized = normalizeChart(props); + const values = normalized.series[0]?.values || []; + const labels = normalized.labels; + const height = Number(props.height) || 240; + const entries = values.map((value, index) => ({ label: labels[index] || String(index + 1), value: Math.max(0, finiteNumber(value)), color: chartPalette[index % chartPalette.length] })).filter(entry => entry.value > 0); + if (!entries.length) return chartShell(props.title || 'Pie chart', height, props.style); + const width = 720; + const cx = 160; + const cy = height / 2; + const radius = Math.max(48, Math.min(90, height / 2 - 18)); + const total = entries.reduce((sum, entry) => sum + entry.value, 0); + let angle = -Math.PI / 2; + const slices = entries.map(entry => { + const nextAngle = angle + (entry.value / total) * Math.PI * 2; + const path = svg('path', { d: piePath(cx, cy, radius, angle, nextAngle), fill: entry.color, stroke: 'var(--bitfun-canvas-bg)', 'stroke-width': 2 }); + angle = nextAngle; + return path; + }); + const legend = entries.map((entry, index) => { + const y = 40 + index * 24; + const percent = Math.round(entry.value / total * 100); + return [ + svg('rect', { x: 330, y: y - 10, width: 10, height: 10, rx: 2, fill: entry.color }), + svg('text', { x: 348, y, fill: 'var(--bitfun-canvas-fg)', 'font-size': 12 }, [`${entry.label} (${percent}%)`]), + ]; + }).flat(); + return chartShell(props.title || 'Pie chart', height, props.style, svg('svg', { viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': props.title || 'Pie chart', style: { width: '100%', height: `${height}px`, display: 'block' } }, [...slices, ...legend])); + } + function diagramNodeLabel(node, fallback) { + return node?.label ?? node?.title ?? fallback; + } + function diagramNodeDescription(node) { + const meta = node?.meta; + return node?.description ?? node?.subtitle ?? node?.sub ?? (typeof meta === 'string' || typeof meta === 'number' ? meta : undefined); + } + function diagramEdgePath(edge, direction) { + if (direction === 'horizontal') { + const midX = edge.sourceX + (edge.targetX - edge.sourceX) / 2; + return `M ${edge.sourceX} ${edge.sourceY} C ${midX} ${edge.sourceY}, ${midX} ${edge.targetY}, ${edge.targetX} ${edge.targetY}`; + } + const midY = edge.sourceY + (edge.targetY - edge.sourceY) / 2; + return `M ${edge.sourceX} ${edge.sourceY} C ${edge.sourceX} ${midY}, ${edge.targetX} ${midY}, ${edge.targetX} ${edge.targetY}`; + } + function renderDiagramSvg(layout, nodes, edges, label) { + const nodeById = new Map((nodes || []).map(node => [String(node.id), node])); + const edgeByKey = new Map(normalizeDAGEdges(edges || []).map(edge => [`${String(edge.from)}\u0000${String(edge.to)}`, edge])); + const edgeEls = layout.edges.map((edge, index) => { + const meta = edgeByKey.get(`${edge.from}\u0000${edge.to}`) || {}; + const color = toneColor(meta.tone); + return svg('g', {}, [ + svg('path', { d: diagramEdgePath(edge, layout.direction), stroke: color, 'stroke-width': 1.5, opacity: edge.isBackEdge ? 0.5 : 0.75, fill: 'none' }), + svg('circle', { cx: edge.targetX, cy: edge.targetY, r: 3, fill: color, opacity: 0.8 }), + meta.label ? svg('text', { x: (edge.sourceX + edge.targetX) / 2, y: (edge.sourceY + edge.targetY) / 2 - 4, 'text-anchor': 'middle', fill: 'var(--bitfun-canvas-muted)', 'font-size': 10 }, [String(meta.label).slice(0, 18)]) : null, + ]); + }); + const nodeEls = layout.nodes.map(layoutNode => { + const node = nodeById.get(layoutNode.id) || {}; + const title = diagramNodeLabel(node, layoutNode.id); + const description = diagramNodeDescription(node); + const color = toneColor(node.tone); + return svg('g', { transform: `translate(${layoutNode.x} ${layoutNode.y})` }, [ + svg('rect', { width: layoutNode.width, height: layoutNode.height, rx: 6, fill: 'var(--bitfun-canvas-bg)', stroke: color, 'stroke-width': 1.25 }), + svg('text', { x: 12, y: description ? 18 : layoutNode.height / 2 + 4, fill: 'var(--bitfun-canvas-fg)', 'font-size': 12, 'font-weight': 650 }, [String(title).slice(0, 22)]), + description ? svg('text', { x: 12, y: 34, fill: 'var(--bitfun-canvas-muted)', 'font-size': 10 }, [String(description).slice(0, 26)]) : null, + ]); + }); + return svg('svg', { viewBox: `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`, role: 'img', 'aria-label': label, style: { width: '100%', minWidth: `${layout.width}px`, height: `${layout.height}px`, display: 'block' } }, [...edgeEls, ...nodeEls]); + } + function diagramShell(title, height, style, child) { + return el('div', { style: { minWidth: 0, overflow: 'auto', border: '1px solid var(--bitfun-canvas-border)', borderRadius: '8px', padding: '10px', background: 'rgba(127,127,127,0.04)', ...style } }, [ + title ? el('div', { style: { marginBottom: '10px', color: 'var(--bitfun-canvas-fg)', fontSize: '12px', fontWeight: 650 } }, [title]) : null, + el('div', { style: { minHeight: `${height}px` } }, [child]) + ]); + } + function DependencyGraph(props = {}) { + const nodes = Array.isArray(props.nodes) ? props.nodes : []; + const edges = normalizeDAGEdges(props.edges); + const layout = computeDAGLayout({ nodes, edges, direction: props.direction, nodeWidth: props.nodeWidth || 160, nodeHeight: props.nodeHeight || 46, rankGap: props.rankGap || 64, nodeGap: props.nodeGap || 48, padding: props.padding || 24 }); + return diagramShell(props.title, props.height || layout.height, props.style, nodes.length ? renderDiagramSvg(layout, nodes, edges, props.title || 'Dependency graph') : el('div', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, ['No graph nodes'])); + } + function flowNodes(steps) { + if (!Array.isArray(steps)) return []; + return steps.map((step, index) => typeof step === 'string' ? { id: `step-${index + 1}`, label: step } : { id: step.id || `step-${index + 1}`, label: diagramNodeLabel(step, `Step ${index + 1}`), description: step.description ?? step.subtitle ?? step.sub, tone: step.tone, meta: step.meta }); + } + function flowEdges(nodes) { + return nodes.slice(0, -1).map((node, index) => ({ from: node.id, to: nodes[index + 1].id })); + } + function FlowDiagram(props = {}) { + const stepNodes = flowNodes(props.steps); + const nodes = Array.isArray(props.nodes) && props.nodes.length ? props.nodes : stepNodes; + const edges = Array.isArray(props.edges) && props.edges.length ? normalizeDAGEdges(props.edges) : flowEdges(nodes); + const layout = computeDAGLayout({ nodes, edges, direction: props.direction || 'horizontal', nodeWidth: props.nodeWidth || 150, nodeHeight: props.nodeHeight || 46, rankGap: props.rankGap || 54, nodeGap: props.nodeGap || 36, padding: props.padding || 20 }); + return diagramShell(props.title, props.height || layout.height, props.style, nodes.length ? renderDiagramSvg(layout, nodes, edges, props.title || 'Flow diagram') : el('div', { style: { color: 'var(--bitfun-canvas-muted)', fontSize: '12px' } }, ['No flow steps'])); + } + function toneColor(tone) { + return tone === 'muted' || tone === 'secondary' || tone === 'tertiary' || tone === 'quaternary' ? 'var(--bitfun-canvas-muted)' : tone === 'success' ? 'var(--bitfun-canvas-success)' : tone === 'warning' ? 'var(--bitfun-canvas-warning)' : tone === 'danger' ? 'var(--bitfun-canvas-danger)' : tone === 'info' ? 'var(--bitfun-canvas-info)' : tone === 'neutral' ? 'var(--bitfun-canvas-muted)' : 'var(--bitfun-canvas-fg)'; + } + function weightValue(weight) { return weight === 'medium' ? 500 : weight === 'semibold' ? 650 : weight === 'bold' ? 700 : 400; } + function cellStyle(head, align = 'left') { return { textAlign: align, padding: '7px 9px', borderBottom: '1px solid rgba(127,127,127,0.16)', fontWeight: head ? 650 : 400, color: head ? 'var(--bitfun-canvas-fg)' : undefined }; } + function useHostTheme() { return { ...theme, tokens: theme }; } + function depsChanged(previous, next) { + if (!Array.isArray(next)) return true; + if (!Array.isArray(previous)) return true; + if (previous.length !== next.length) return true; + return next.some((value, index) => !Object.is(value, previous[index])); + } + function queueRender() { + if (renderQueued) return; + renderQueued = true; + setTimeout(() => { + renderQueued = false; + rerender(); + }, 0); + } + function useState(defaultValue) { + const index = hookIndex++; + if (index >= hookValues.length) { + hookValues[index] = typeof defaultValue === 'function' ? defaultValue() : defaultValue; + } + return [hookValues[index], value => { + const previous = hookValues[index]; + const next = typeof value === 'function' ? value(previous) : value; + if (Object.is(previous, next)) return; + hookValues[index] = next; + queueRender(); + }]; + } + function useRef(defaultValue) { + const [ref] = useState(() => ({ current: defaultValue })); + return ref; + } + function useMemo(factory, deps) { + const index = hookIndex++; + const previous = hookValues[index]; + if (!previous || depsChanged(previous.deps, deps)) { + const value = factory(); + hookValues[index] = { deps: Array.isArray(deps) ? deps.slice() : deps, value }; + return value; + } + return previous.value; + } + function useCallback(callback, deps) { + return useMemo(() => callback, deps); + } + function useEffect(effect, deps) { + const index = hookIndex++; + const previous = hookEffects[index]; + if (!previous || depsChanged(previous.deps, deps)) { + hookEffects[index] = { + deps: Array.isArray(deps) ? deps.slice() : deps, + effect, + cleanup: previous?.cleanup, + pending: true, + }; + } + } + function flushEffects() { + hookEffects.forEach(entry => { + if (!entry || !entry.pending) return; + entry.pending = false; + setTimeout(() => { + if (typeof entry.cleanup === 'function') { + try { entry.cleanup(); } catch (error) { reportRuntimeError(error); } + } + try { + const cleanup = entry.effect(); + entry.cleanup = typeof cleanup === 'function' ? cleanup : undefined; + } catch (error) { + reportRuntimeError(error); + } + }, 0); + }); + } + function useCanvasState(key, defaultValue) { + if (!state.has(key)) state.set(key, defaultValue); + return [state.get(key), value => { + state.set(key, typeof value === 'function' ? value(state.get(key)) : value); + persistState(); + rerender(); + }]; + } + let actionSeq = 0; + const pendingActions = new Map(); + function useCanvasAction() { + return action => new Promise((resolve, reject) => { + const requestId = `action-${++actionSeq}`; + pendingActions.set(requestId, { resolve, reject }); + window.parent?.postMessage({ type: 'bitfun-canvas-action', requestId, action }, '*'); + }); + } + function persistState() { + if (!hostStateReady) return; + window.parent?.postMessage({ + type: 'bitfun-canvas-save-state', + values: Object.fromEntries(state.entries()) + }, '*'); + } + window.addEventListener('message', event => { + const data = event.data || {}; + if (data.type === 'bitfun-canvas-action-result') { + const pending = pendingActions.get(data.requestId); + if (!pending) return; + pendingActions.delete(data.requestId); + if (data.error) pending.reject(new Error(String(data.error))); + else pending.resolve(data.result ?? null); + return; + } + if (data.type === 'bitfun-canvas-theme') { + applyHostTheme(data.theme); + rerender(); + return; + } + if (data.type === 'bitfun-canvas-design-mode') { + setDesignMode(data.enabled); + return; + } + if (data.type !== 'bitfun-canvas-state' && data.type !== 'bitfun-canvas-load-state-result' && data.type !== 'bitfun-canvas-save-state-result') return; + const values = data.state && typeof data.state === 'object' && data.state.values && typeof data.state.values === 'object' + ? data.state.values + : {}; + for (const [key, value] of Object.entries(values)) { + state.set(key, value); + } + hostStateReady = true; + rerender(); + }); + const Fragment = ({ children } = {}) => toArray(children); + window.BitfunCanvasSDK = { Stack, Row, Grid, Box, Divider, Spacer, H1, H2, H3, Text, Code, Link, Card, CardHeader, CardBody, Alert, Callout, CollapsibleSection, Empty, Tabs, Pill, Stat, Table, KeyValueList, Timeline, FileTree, ProgressBar, Swatch, UsageBar, TodoList, TodoListCard, DependencyGraph, FlowDiagram, BarChart, LineChart, PieChart, Button, Toggle, Checkbox, Select, Input, TextInput, TextArea, IconButton, DiffStats, DiffView, computeDAGLayout, mergeStyle, colorPalette, usageColorSequence, categoryPaletteLight, categoryPaletteDark, canvasPaletteLight, canvasPaletteDark, canvasTokensLight, canvasTokens, useHostTheme, useCanvasState, useCanvasAction, useState, useRef, useEffect, useCallback, useMemo }; + window.BitfunCanvasRuntime = { h, Fragment, mount(component) { renderFn = component; rerender(); } }; +})(); diff --git a/src/crates/assembly/core/src/service/canvas/compiler/runtime_style.css b/src/crates/assembly/core/src/service/canvas/compiler/runtime_style.css new file mode 100644 index 000000000..1e31d6e47 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/runtime_style.css @@ -0,0 +1,42 @@ +:root { + color-scheme: light dark; + --bitfun-canvas-bg: #ffffff; + --bitfun-canvas-panel: #f4f6f8; + --bitfun-canvas-fg: #20242a; + --bitfun-canvas-muted: #697386; + --bitfun-canvas-border: rgba(32, 36, 42, 0.15); + --bitfun-canvas-accent: #2f6fed; + --bitfun-canvas-success: #248a3d; + --bitfun-canvas-warning: #b54708; + --bitfun-canvas-danger: #d1242f; + --bitfun-canvas-info: #0969da; +} +@media (prefers-color-scheme: dark) { + :root { + --bitfun-canvas-bg: #111318; + --bitfun-canvas-panel: #181b22; + --bitfun-canvas-fg: #eef0f4; + --bitfun-canvas-muted: #a4acb9; + } +} +body { + margin: 0; + background: #eef2f6; + color: var(--bitfun-canvas-fg); + font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-rendering: geometricPrecision; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} +html { + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; +} +#bitfun-canvas-root { + min-height: 100vh; + padding: 24px 20px 56px; + box-sizing: border-box; +} +* { box-sizing: border-box; } +button, input, select { font: inherit; } diff --git a/src/crates/assembly/core/src/service/canvas/compiler/sdk_contract.rs b/src/crates/assembly/core/src/service/canvas/compiler/sdk_contract.rs new file mode 100644 index 000000000..78a16ea12 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/sdk_contract.rs @@ -0,0 +1,492 @@ +use bitfun_product_domains::canvas::types::{ + CanvasDiagnostic, CanvasDiagnosticCategory, CanvasDiagnosticSeverity, +}; + +#[cfg(feature = "canvas-compiler")] +use oxc::ast::ast::{ + BindingPattern, CallExpression, Expression, JSXAttributeItem, JSXAttributeName, JSXElementName, + JSXOpeningElement, Program, StaticMemberExpression, VariableDeclarator, +}; +#[cfg(feature = "canvas-compiler")] +use oxc::ast_visit::{ + walk::{walk_jsx_opening_element, walk_static_member_expression, walk_variable_declarator}, + Visit, +}; +#[cfg(feature = "canvas-compiler")] +use std::collections::BTreeSet; + +#[cfg(feature = "canvas-compiler")] +use super::analysis::{CanvasSdkImportBindings, CanvasSdkImportSource}; +use super::line_column; + +#[cfg(feature = "canvas-compiler")] +pub(super) fn validate_canvas_sdk_contracts( + source: &str, + program: &Program<'_>, + import_bindings: &CanvasSdkImportBindings, +) -> Vec { + let mut visitor = CanvasSdkContractVisitor { + source, + import_bindings, + diagnostics: Vec::new(), + host_theme_locals: BTreeSet::new(), + }; + visitor.visit_program(program); + visitor.diagnostics +} + +#[cfg(feature = "canvas-compiler")] +struct CanvasSdkContractVisitor<'a> { + source: &'a str, + import_bindings: &'a CanvasSdkImportBindings, + diagnostics: Vec, + host_theme_locals: BTreeSet, +} + +#[cfg(feature = "canvas-compiler")] +impl<'a> Visit<'a> for CanvasSdkContractVisitor<'_> { + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + self.validate_opening_element(element); + walk_jsx_opening_element(self, element); + } + + fn visit_variable_declarator(&mut self, declarator: &VariableDeclarator<'a>) { + self.collect_host_theme_local(declarator); + walk_variable_declarator(self, declarator); + } + + fn visit_static_member_expression(&mut self, expression: &StaticMemberExpression<'a>) { + self.validate_host_theme_member(expression); + walk_static_member_expression(self, expression); + } +} + +#[cfg(feature = "canvas-compiler")] +impl CanvasSdkContractVisitor<'_> { + fn validate_opening_element(&mut self, element: &JSXOpeningElement<'_>) { + let Some(component) = self.jsx_component_name(&element.name) else { + return; + }; + let Some(allowed_props) = sdk_component_allowed_props(component.as_str()) else { + return; + }; + + for item in &element.attributes { + let JSXAttributeItem::Attribute(attribute) = item else { + continue; + }; + let Some(prop) = jsx_attribute_name(&attribute.name) else { + continue; + }; + if prop == "key" + || common_canvas_style_prop(prop.as_str()) + || allowed_props.iter().any(|allowed| *allowed == prop) + { + continue; + } + + let (line, column) = line_column(self.source, attribute.span.start as usize); + self.diagnostics.push(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::TypeScript, + message: format!( + "`{}` is not a valid prop for `{}` in bitfun/canvas", + prop, component + ), + code: Some("canvas.sdk.invalid_prop".to_string()), + line: Some(line), + column: Some(column), + suggested_fix: Some( + sdk_invalid_prop_fix(component.as_str(), prop.as_str()).to_string(), + ), + }); + } + } + + fn jsx_component_name(&self, name: &JSXElementName<'_>) -> Option { + match name { + JSXElementName::IdentifierReference(identifier) => self + .import_bindings + .canonical_component_for_local(identifier.name.as_str()) + .map(str::to_string) + .or_else(|| Some(identifier.name.to_string())), + JSXElementName::MemberExpression(member) => { + self.import_bindings.canonical_component_for_member(member) + } + _ => None, + } + } + + fn collect_host_theme_local(&mut self, declarator: &VariableDeclarator<'_>) { + let BindingPattern::BindingIdentifier(identifier) = &declarator.id else { + return; + }; + let Some(Expression::CallExpression(call)) = declarator.init.as_ref() else { + return; + }; + if self.is_use_host_theme_call(call) { + self.host_theme_locals.insert(identifier.name.to_string()); + } + } + + fn is_use_host_theme_call(&self, call: &CallExpression<'_>) -> bool { + match &call.callee { + Expression::Identifier(identifier) => { + self.import_bindings + .canonical_for_local(identifier.name.as_str()) + .unwrap_or(identifier.name.as_str()) + == "useHostTheme" + } + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(namespace) = &member.object else { + return false; + }; + member.property.name.as_str() == "useHostTheme" + && self.import_bindings.namespaces.iter().any(|binding| { + binding.source == CanvasSdkImportSource::Canvas + && binding.local == namespace.name.as_str() + }) + } + _ => false, + } + } + + fn validate_host_theme_member(&mut self, expression: &StaticMemberExpression<'_>) { + let token = expression.property.name.as_str(); + let Expression::StaticMemberExpression(group_expression) = &expression.object else { + return; + }; + let group = group_expression.property.name.as_str(); + let Expression::Identifier(root) = &group_expression.object else { + return; + }; + if !self.host_theme_locals.contains(root.name.as_str()) { + return; + } + if canvas_theme_token_group_allows(group, token) { + return; + } + + let (line, column) = line_column(self.source, expression.property.span.start as usize); + self.diagnostics.push(CanvasDiagnostic { + severity: CanvasDiagnosticSeverity::Error, + category: CanvasDiagnosticCategory::TypeScript, + message: format!( + "`{}.{}.{}` is not a valid Canvas host theme token", + root.name, group, token + ), + code: Some("canvas.sdk.invalid_theme_token".to_string()), + line: Some(line), + column: Some(column), + suggested_fix: Some(canvas_theme_token_fix(group, token).to_string()), + }); + } +} + +#[cfg(feature = "canvas-compiler")] +fn jsx_attribute_name(name: &JSXAttributeName<'_>) -> Option { + match name { + JSXAttributeName::Identifier(identifier) => Some(identifier.name.to_string()), + _ => None, + } +} + +#[cfg(feature = "canvas-compiler")] +fn sdk_component_allowed_props(component: &str) -> Option<&'static [&'static str]> { + match component { + "Stack" => Some(&["children", "gap", "style"]), + "Row" => Some(&["children", "gap", "align", "justify", "wrap", "style"]), + "Grid" => Some(&["children", "columns", "gap", "align", "style"]), + "Box" => Some(&[ + "children", + "padding", + "background", + "border", + "radius", + "style", + ]), + "Divider" => Some(&["style"]), + "H1" | "H2" | "H3" | "Code" | "Link" => Some(&["children", "href", "style"]), + "Text" => Some(&[ + "children", "tone", "size", "as", "weight", "italic", "truncate", "style", "color", + ]), + "Card" => Some(&[ + "children", + "variant", + "size", + "stickyHeader", + "collapsible", + "defaultOpen", + "open", + "onOpenChange", + "style", + ]), + "CardHeader" => Some(&["children", "trailing", "style"]), + "CardBody" => Some(&["children", "style"]), + "Alert" => Some(&[ + "children", + "type", + "tone", + "title", + "message", + "description", + "showIcon", + "style", + ]), + "Callout" => Some(&["children", "tone", "title", "icon", "style"]), + "CollapsibleSection" => Some(&[ + "children", + "title", + "leading", + "count", + "trailing", + "defaultOpen", + "style", + ]), + "Empty" => Some(&["description", "image", "imageSize", "children", "style"]), + "Tabs" => Some(&[ + "items", + "activeKey", + "defaultActiveKey", + "onChange", + "children", + "type", + "size", + "stretch", + "style", + ]), + "Pill" => Some(&[ + "children", + "active", + "tone", + "size", + "leadingContent", + "keyboardHint", + "disabled", + "title", + "style", + "onClick", + ]), + "Stat" => Some(&["value", "label", "tone", "style"]), + "Table" => Some(&[ + "headers", + "rows", + "columnAlign", + "rowTone", + "framed", + "striped", + "stickyHeader", + "style", + "emptyMessage", + ]), + "KeyValueList" => Some(&["items", "columns", "compact", "emptyMessage", "style"]), + "Timeline" => Some(&["items", "emptyMessage", "style"]), + "FileTree" => Some(&["items", "defaultExpanded", "emptyMessage", "style"]), + "ProgressBar" => Some(&["value", "max", "label", "tone", "showValue", "style"]), + "Swatch" => Some(&["color", "style", "title", "className"]), + "UsageBar" => Some(&[ + "segments", + "total", + "topLeftLabel", + "topRightLabel", + "style", + ]), + "TodoList" => Some(&["todos", "dimmedTodoIds", "onTodoClick", "style"]), + "TodoListCard" => Some(&[ + "todos", + "dimmedTodoIds", + "defaultExpanded", + "onTodoClick", + "style", + ]), + "DependencyGraph" => Some(&[ + "nodes", + "edges", + "direction", + "nodeWidth", + "nodeHeight", + "rankGap", + "nodeGap", + "padding", + "title", + "height", + "style", + ]), + "FlowDiagram" => Some(&[ + "steps", + "nodes", + "edges", + "direction", + "nodeWidth", + "nodeHeight", + "rankGap", + "nodeGap", + "padding", + "title", + "height", + "style", + ]), + "Button" => Some(&[ + "children", "variant", "disabled", "type", "style", "onClick", + ]), + "Toggle" => Some(&["checked", "onChange", "disabled", "size", "style"]), + "Checkbox" => Some(&["checked", "onChange", "disabled", "label", "style"]), + "Select" => Some(&[ + "value", + "onChange", + "options", + "placeholder", + "disabled", + "style", + ]), + "TextInput" => Some(&[ + "value", + "onChange", + "placeholder", + "disabled", + "type", + "style", + ]), + "Input" => Some(&[ + "value", + "onChange", + "placeholder", + "disabled", + "type", + "label", + "hint", + "prefix", + "suffix", + "error", + "errorMessage", + "size", + "style", + ]), + "TextArea" => Some(&[ + "value", + "onChange", + "placeholder", + "disabled", + "rows", + "style", + ]), + "IconButton" => Some(&[ + "children", "onClick", "disabled", "title", "variant", "size", "style", + ]), + "DiffStats" => Some(&["additions", "deletions", "style"]), + "DiffView" => Some(&[ + "lines", + "path", + "language", + "showLineNumbers", + "coloredLineNumbers", + "showAccentStrip", + "style", + ]), + "BarChart" | "LineChart" | "PieChart" => Some(&[ + "data", + "categories", + "series", + "height", + "style", + "stacked", + "horizontal", + "normalized", + "valueSuffix", + "valuePrefix", + "showValues", + "beginAtZero", + "yMin", + "yMax", + "referenceLines", + "fill", + "showHoverGuide", + "size", + "donut", + ]), + "Spacer" => Some(&[]), + _ => None, + } +} + +#[cfg(feature = "canvas-compiler")] +fn common_canvas_style_prop(prop: &str) -> bool { + matches!( + prop, + "padding" + | "margin" + | "background" + | "border" + | "borderTop" + | "borderRight" + | "borderBottom" + | "borderLeft" + | "borderRadius" + | "width" + | "height" + | "flex" + | "display" + | "opacity" + | "minWidth" + | "maxWidth" + | "minHeight" + | "maxHeight" + ) +} + +#[cfg(feature = "canvas-compiler")] +fn canvas_theme_token_group_allows(group: &str, token: &str) -> bool { + match group { + "bg" => matches!(token, "editor" | "chrome" | "elevated" | "canvas"), + "text" => matches!( + token, + "primary" | "secondary" | "tertiary" | "quaternary" | "link" | "onAccent" + ), + "fill" => matches!(token, "primary" | "secondary" | "tertiary" | "quaternary"), + "stroke" => matches!(token, "primary" | "secondary" | "tertiary" | "focused"), + "accent" => matches!( + token, + "primary" | "control" | "controlHover" | "success" | "warning" | "danger" | "info" + ), + "diff" => matches!( + token, + "insertedLine" | "removedLine" | "stripAdded" | "stripRemoved" + ), + "category" => matches!( + token, + "blue" | "cyan" | "gray" | "green" | "orange" | "pink" | "purple" | "yellow" + ), + "status" => matches!(token, "success" | "warning" | "danger" | "info"), + "tokens" | "palette" => true, + _ => false, + } +} + +#[cfg(feature = "canvas-compiler")] +fn canvas_theme_token_fix(group: &str, token: &str) -> &'static str { + match (group, token) { + ("surface", "primary") => { + "Use theme.bg.editor for the main background or theme.fill.primary for a filled surface." + } + ("surface", "secondary") => { + "Use theme.bg.elevated for raised panels or theme.fill.secondary for tinted fills." + } + ("surface", _) => { + "Canvas theme has no `surface` group. Use theme.bg.* for backgrounds or theme.fill.* for tinted fills." + } + ("interactive", "accent") => "Use theme.accent.primary.", + ("interactive", _) => "Canvas theme has no `interactive` group. Use theme.accent.* tokens.", + (_, _) => { + "Use one of the declared useHostTheme() token paths: bg, text, fill, stroke, accent, diff, category, or status." + } + } +} + +#[cfg(feature = "canvas-compiler")] +fn sdk_invalid_prop_fix(component: &str, prop: &str) -> &'static str { + match (component, prop) { + ("Pill", "label") => "Put the label inside the Pill children, e.g. Label.", + ("Table", "columns") => "Use ; the Canvas SDK does not support a columns prop.", + _ => "Use props declared by the bitfun/canvas SDK for this component.", + } +} diff --git a/src/crates/assembly/core/src/service/canvas/compiler/tests.rs b/src/crates/assembly/core/src/service/canvas/compiler/tests.rs new file mode 100644 index 000000000..742b5cf82 --- /dev/null +++ b/src/crates/assembly/core/src/service/canvas/compiler/tests.rs @@ -0,0 +1,773 @@ +#[cfg(feature = "canvas-compiler")] +use super::*; +#[cfg(feature = "canvas-compiler")] +use bitfun_product_domains::canvas::types::{CanvasId, CanvasRevision}; + +#[cfg(feature = "canvas-compiler")] +mod enabled { + use super::*; + + #[cfg(feature = "canvas-compiler")] + fn source(source: &str) -> CanvasSource { + CanvasSource::new_tsx( + CanvasId::new("canvas_1"), + CanvasRevision::new("rev_1"), + "canvas.tsx", + source, + BITFUN_CANVAS_SDK_VERSION, + 1, + ) + } + + #[test] + fn canvas_compiler_transforms_default_component_jsx() { + let result = compile_canvas_source( + &source( + r#" +import { Stack, Text } from 'bitfun/canvas'; +const rows = ['a', 'b']; +export default function Canvas() { + return Ready{rows.map(row => {row})}; +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("window.BitfunCanvasRuntime.mount")); + assert!(html.contains("const h = __BitfunCanvasRuntime.h")); + assert!(html.contains("const Fragment = __BitfunCanvasRuntime.Fragment")); + assert!(html.contains("h(Stack")); + assert!(html.contains("rows.map")); + assert!(html.contains("h(Text")); + assert!(html.contains("bitfun-canvas-save-state")); + assert!(html.contains("bitfun-canvas-state")); + assert!(html.contains("bitfun-canvas-theme")); + assert!(html.contains("applyHostTheme")); + assert!(html.contains("bitfun-canvas-design-mode")); + assert!(html.contains("bitfun-canvas-element-selected")); + assert!(html.contains("data-bitfun-canvas-node")); + assert!(html.contains("bitfun-canvas-action-result")); + assert!(html.contains("pendingActions")); + assert!(html.contains("stack: error?.stack ? String(error.stack) : undefined")); + assert!(html.contains("connect-src 'none'")); + } + + #[test] + fn canvas_compiler_rejects_local_sdk_component_shadowing() { + let result = compile_canvas_source( + &source( + r#" +import { Grid, Stack, Text } from 'bitfun/canvas'; + +export default function Canvas() { + return Ready; +} + +function Grid() { + return
; +} +"#, + ), + 2, + ); + + assert!(!result.compiled); + let diagnostic = result + .diagnostics + .iter() + .find(|diagnostic| { + diagnostic.code.as_deref() == Some("canvas.compile.sdk_name_shadowed") + }) + .expect("shadow diagnostic should be present"); + assert!(diagnostic.message.contains("Grid"), "{diagnostic:?}"); + } + + #[test] + fn canvas_compiler_preserves_named_import_alias_bindings() { + let result = compile_canvas_source( + &source( + r#" +import { Text as T, Stack } from 'bitfun/canvas'; + +export default function Canvas() { + return Ready; +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("const T = __BitfunCanvasSDK.Text;")); + assert!(html.contains("h(T")); + assert!(!html.contains("from 'bitfun/canvas'")); + } + + #[test] + fn canvas_compiler_validates_props_through_named_import_alias() { + let result = compile_canvas_source( + &source( + r#" +import { Pill as P } from 'bitfun/canvas'; + +export default function Canvas() { + return

; +} +"#, + ), + 2, + ); + + assert!(!result.compiled); + let diagnostic = result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code.as_deref() == Some("canvas.sdk.invalid_prop")) + .expect("alias prop diagnostic should be present"); + assert!(diagnostic.message.contains("Pill"), "{diagnostic:?}"); + } + + #[test] + fn canvas_compiler_preserves_namespace_import_bindings() { + let result = compile_canvas_source( + &source( + r#" +import * as Canvas from 'bitfun/canvas'; + +export default function CanvasView() { + return Ready; +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("const Canvas = __BitfunCanvasSDK;")); + assert!(html.contains("h(Canvas.Stack")); + assert!(html.contains("h(Canvas.Text")); + } + + #[test] + fn canvas_compiler_validates_props_through_namespace_import() { + let result = compile_canvas_source( + &source( + r#" +import * as C from 'bitfun/canvas'; + +export default function Canvas() { + return ; +} +"#, + ), + 2, + ); + + assert!(!result.compiled); + let diagnostic = result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code.as_deref() == Some("canvas.sdk.invalid_prop")) + .expect("namespace prop diagnostic should be present"); + assert!(diagnostic.message.contains("Table"), "{diagnostic:?}"); + } + + #[test] + fn canvas_compiler_strips_imports_by_ast_span_not_semicolon_scan() { + let result = compile_canvas_source( + &source( + r#" +import { Text as T } from 'bitfun/canvas' with { note: "semi;colon" }; + +export default function Canvas() { + return Ready; +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("const T = __BitfunCanvasSDK.Text;")); + assert!(!html.contains("semi;colon")); + } + + #[test] + fn canvas_compiler_preserves_react_namespace_and_default_compat_bindings() { + let result = compile_canvas_source( + &source( + r#" +import React, * as R from 'react'; +import { Text } from 'bitfun/canvas'; + +export default function Canvas() { + const [count] = React.useState(1); + return R.createElement(Text, null, String(count)); +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("const React = __BitfunCanvasReactCompat;")); + assert!(html.contains("const R = __BitfunCanvasReactCompat;")); + assert!(!html.contains("from 'react'")); + } + + #[test] + fn canvas_compiler_supports_named_arrow_default_export() { + let result = compile_canvas_source( + &source( + r#" +import { Stack } from 'bitfun/canvas'; +const Canvas = () => ; +export default Canvas; +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("const Canvas")); + assert!(html.contains("h(Stack")); + assert!(html.contains("const __BitfunCanvasComponent = Canvas;")); + assert!(html.contains("window.BitfunCanvasRuntime.mount(__BitfunCanvasComponent)")); + } + + #[test] + fn canvas_compiler_reports_missing_default_component_declaration() { + let result = compile_canvas_source( + &source( + r#" +import { Stack } from 'bitfun/canvas'; +export default Canvas; +"#, + ), + 2, + ); + + assert!(!result.compiled); + assert_eq!( + result.diagnostics[0].code.as_deref(), + Some("canvas.compile.default_function_required") + ); + } + + #[test] + fn canvas_compiler_reports_invalid_host_theme_tokens() { + let result = compile_canvas_source( + &source( + r#" +import { Stack, useHostTheme } from 'bitfun/canvas'; +export default function Canvas() { + const theme = useHostTheme(); + return Body; +} +"#, + ), + 2, + ); + + assert!(!result.compiled); + let diagnostics = result + .diagnostics + .iter() + .filter(|diagnostic| { + diagnostic.code.as_deref() == Some("canvas.sdk.invalid_theme_token") + }) + .collect::>(); + assert_eq!(diagnostics.len(), 2, "{:?}", result.diagnostics); + assert!(diagnostics[0].message.contains("theme.surface.primary")); + assert!(diagnostics[0] + .suggested_fix + .as_deref() + .is_some_and(|fix| fix.contains("theme.bg.editor"))); + assert!(diagnostics[1].message.contains("theme.interactive.accent")); + assert!(diagnostics[1] + .suggested_fix + .as_deref() + .is_some_and(|fix| fix.contains("theme.accent.primary"))); + } + + #[test] + fn canvas_compiler_reports_located_jsx_diagnostics() { + let result = compile_canvas_source( + &source( + r#" +import { Stack } from 'bitfun/canvas'; +export default function Canvas() { + return ; +} +"#, + ), + 2, + ); + + assert!(!result.compiled); + let diagnostic = &result.diagnostics[0]; + assert!(diagnostic + .code + .as_deref() + .is_some_and(|code| code.starts_with("canvas.compile.oxc."))); + assert!(diagnostic.line.is_some(), "{diagnostic:?}"); + assert!(diagnostic.column.is_some(), "{diagnostic:?}"); + } + + #[test] + fn canvas_compiler_sanitizes_script_close_tags() { + let result = compile_canvas_source( + &source( + r#" +import { Text } from 'bitfun/canvas'; +export default function Canvas() { + return {"

"}; +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + assert!(!result.payload.unwrap().html.contains("
")); + } + + #[test] + fn canvas_compiler_supports_fragments() { + let result = compile_canvas_source( + &source( + r#" +import { H1, Text } from 'bitfun/canvas'; +export default function Canvas() { + return <>

Title

Body; +} +"#, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("h(Fragment")); + assert!(html.contains("h(H1")); + assert!(html.contains("h(Text")); + } + + #[test] + fn canvas_runtime_exports_box_component() { + let result = compile_canvas_source( + &source( + r##" +import { Box, Text } from 'bitfun/canvas'; +export default function Canvas() { + return Body; +} +"##, + ), + 2, + ); + + assert!(result.compiled, "{:?}", result.diagnostics); + let html = result.payload.unwrap().html; + assert!(html.contains("Stack, Row, Grid, Box, Divider")); + assert!(html.contains("const Box =")); + assert!(html.contains("window.BitfunCanvasSDK = { Stack, Row, Grid, Box")); + assert!(html.contains("h(Box")); + } + + #[test] + fn canvas_runtime_exports_cursor_style_canvas_components() { + let result = compile_canvas_source( + &source( + r#" +import { + Alert, + Card, + CardBody, + CardHeader, + CollapsibleSection, + DiffStats, + DiffView, + DependencyGraph, + Empty, + FileTree, + FlowDiagram, + Grid, + H1, + Input, + KeyValueList, + Pill, + ProgressBar, + Row, + Spacer, + Stack, + Swatch, + Table, + Text, + TextArea, + Tabs, + Timeline, + TodoListCard, + UsageBar, + canvasTokens, + colorPalette, + computeDAGLayout, + mergeStyle, + useCanvasState, + useHostTheme, +} from 'bitfun/canvas'; + +const lines = [ + { type: 'unchanged', content: 'fn main() {}', lineNumber: 1 }, + { type: 'added', content: 'println!("ready");', lineNumber: 2 }, +]; + +export default function Canvas() { + const theme = useHostTheme(); + const [note, setNote] = useCanvasState('note', ''); + const merged = mergeStyle({ maxWidth: 900 }, { padding: 4 }); + const layout = computeDAGLayout({ +nodes: [{ id: 'web-ui' }, { id: 'core' }], +edges: [{ from: 'web-ui', to: 'core' }], +nodeWidth: 120, +nodeHeight: 40, + }); + return ( + +

Cursor-style canvas

+ + {layout.width} + OPEN + + + + + + + + + {colorPalette.length} palette colors + + + + + + Graph tab }]} defaultActiveKey="graph" /> + + + }> + src/main.rs + + + + + + + + +