From c54c8e0fb556dca6ec66f9aa7d3f9661d48707b1 Mon Sep 17 00:00:00 2001
From: "ilblackdragon@gmail.com"
Date: Sat, 28 Mar 2026 01:13:18 -0700
Subject: [PATCH] feat(frontend): extract frontend into ironclaw_frontend crate
with widget extension system
Moves all frontend static assets (app.js, style.css, index.html, i18n/*,
theme-init.js, favicon.ico) from src/channels/web/static/ into a dedicated
ironclaw_frontend crate. The crate also adds:
- Layout configuration types (branding, tab order, chat features, per-widget config)
- Widget manifest types with named slot system (tab, chat_header, sidebar, etc.)
- CSS scoping utility (auto-prefixes selectors with [data-widget="id"])
- Bundle assembly (injects layout config, widgets, and custom CSS into HTML)
- Frontend API endpoints (GET/PUT layout, list widgets, serve widget files)
- Browser-side IronClaw.registerWidget() API with authenticated fetch,
event subscription, theme access, and i18n
Widgets are stored in workspace at frontend/widgets/{id}/ and served via
the API. Layout config is stored at frontend/layout.json. The agent can
create/edit both using existing memory_write/memory_read tools.
Gateway handlers now reference ironclaw_frontend::assets constants instead
of include_str!() with local paths, completing the separation.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
Cargo.lock | 10 +
Cargo.toml | 3 +-
crates/ironclaw_frontend/Cargo.toml | 14 +
crates/ironclaw_frontend/src/assets.rs | 37 +++
crates/ironclaw_frontend/src/bundle.rs | 240 ++++++++++++++++++
crates/ironclaw_frontend/src/layout.rs | 179 +++++++++++++
crates/ironclaw_frontend/src/lib.rs | 36 +++
crates/ironclaw_frontend/src/widget.rs | 175 +++++++++++++
.../ironclaw_frontend}/static/app.js | 149 +++++++++++
.../ironclaw_frontend}/static/favicon.ico | Bin
.../ironclaw_frontend}/static/i18n-app.js | 0
.../ironclaw_frontend}/static/i18n/en.js | 0
.../ironclaw_frontend}/static/i18n/index.js | 0
.../ironclaw_frontend}/static/i18n/zh-CN.js | 0
.../ironclaw_frontend}/static/index.html | 0
.../ironclaw_frontend}/static/style.css | 0
.../ironclaw_frontend}/static/theme-init.js | 0
src/channels/web/handlers/frontend.rs | 140 ++++++++++
src/channels/web/handlers/mod.rs | 1 +
src/channels/web/handlers/static_files.rs | 6 +-
src/channels/web/server.rs | 37 ++-
21 files changed, 1014 insertions(+), 13 deletions(-)
create mode 100644 crates/ironclaw_frontend/Cargo.toml
create mode 100644 crates/ironclaw_frontend/src/assets.rs
create mode 100644 crates/ironclaw_frontend/src/bundle.rs
create mode 100644 crates/ironclaw_frontend/src/layout.rs
create mode 100644 crates/ironclaw_frontend/src/lib.rs
create mode 100644 crates/ironclaw_frontend/src/widget.rs
rename {src/channels/web => crates/ironclaw_frontend}/static/app.js (97%)
rename {src/channels/web => crates/ironclaw_frontend}/static/favicon.ico (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/i18n-app.js (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/i18n/en.js (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/i18n/index.js (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/i18n/zh-CN.js (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/index.html (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/style.css (100%)
rename {src/channels/web => crates/ironclaw_frontend}/static/theme-init.js (100%)
create mode 100644 src/channels/web/handlers/frontend.rs
diff --git a/Cargo.lock b/Cargo.lock
index 0c524704..44aca8d2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3429,6 +3429,7 @@ dependencies = [
"iana-time-zone",
"insta",
"ironclaw_common",
+ "ironclaw_frontend",
"ironclaw_safety",
"json5",
"libsql",
@@ -3496,6 +3497,15 @@ dependencies = [
"serde_json",
]
+[[package]]
+name = "ironclaw_frontend"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "ironclaw_safety"
version = "0.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 0382a2a7..adf74389 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
+members = [".", "crates/ironclaw_common", "crates/ironclaw_safety", "crates/ironclaw_frontend"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
@@ -105,6 +105,7 @@ cron = "0.13"
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
# Safety/sanitization
+ironclaw_frontend = { path = "crates/ironclaw_frontend", version = "0.1.0" }
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
regex = "1"
aho-corasick = "1"
diff --git a/crates/ironclaw_frontend/Cargo.toml b/crates/ironclaw_frontend/Cargo.toml
new file mode 100644
index 00000000..b8e8aa15
--- /dev/null
+++ b/crates/ironclaw_frontend/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "ironclaw_frontend"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.85"
+description = "Frontend assets, layout configuration, and widget extension system for IronClaw"
+
+[package.metadata.dist]
+dist = false
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "2"
diff --git a/crates/ironclaw_frontend/src/assets.rs b/crates/ironclaw_frontend/src/assets.rs
new file mode 100644
index 00000000..78fea26d
--- /dev/null
+++ b/crates/ironclaw_frontend/src/assets.rs
@@ -0,0 +1,37 @@
+//! Embedded static assets for the IronClaw web gateway.
+//!
+//! All frontend files are compiled into the binary via `include_str!()` /
+//! `include_bytes!()`. The web gateway serves these as the default baseline;
+//! workspace-stored customizations (layout config, widgets, CSS overrides)
+//! are layered on top at runtime.
+
+// ==================== Core Files ====================
+
+/// Main HTML page (SPA shell).
+pub const INDEX_HTML: &str = include_str!("../static/index.html");
+
+/// Main application JavaScript.
+pub const APP_JS: &str = include_str!("../static/app.js");
+
+/// Base stylesheet.
+pub const STYLE_CSS: &str = include_str!("../static/style.css");
+
+/// Theme initialization script (runs synchronously in `` to prevent FOUC).
+pub const THEME_INIT_JS: &str = include_str!("../static/theme-init.js");
+
+/// Favicon.
+pub const FAVICON_ICO: &[u8] = include_bytes!("../static/favicon.ico");
+
+// ==================== Internationalization ====================
+
+/// i18n core library.
+pub const I18N_INDEX_JS: &str = include_str!("../static/i18n/index.js");
+
+/// English translations.
+pub const I18N_EN_JS: &str = include_str!("../static/i18n/en.js");
+
+/// Chinese (Simplified) translations.
+pub const I18N_ZH_CN_JS: &str = include_str!("../static/i18n/zh-CN.js");
+
+/// i18n integration with the app.
+pub const I18N_APP_JS: &str = include_str!("../static/i18n-app.js");
diff --git a/crates/ironclaw_frontend/src/bundle.rs b/crates/ironclaw_frontend/src/bundle.rs
new file mode 100644
index 00000000..5e25e1a2
--- /dev/null
+++ b/crates/ironclaw_frontend/src/bundle.rs
@@ -0,0 +1,240 @@
+//! Frontend bundle assembly.
+//!
+//! Combines the embedded base HTML with workspace customizations (layout
+//! config, widgets, CSS overrides) into the final served page.
+
+use crate::layout::LayoutConfig;
+use crate::widget::{WidgetManifest, scope_css};
+
+/// A resolved frontend bundle ready for serving.
+///
+/// Contains the layout configuration, resolved widgets (with their JS/CSS
+/// content loaded), and any custom CSS overrides.
+#[derive(Debug, Clone, Default)]
+pub struct FrontendBundle {
+ /// Layout configuration (branding, tabs, chat settings).
+ pub layout: LayoutConfig,
+
+ /// Resolved widgets with their source code loaded.
+ pub widgets: Vec,
+
+ /// Custom CSS to append after the base stylesheet.
+ pub custom_css: Option,
+}
+
+/// A widget with its manifest and source files loaded.
+#[derive(Debug, Clone)]
+pub struct ResolvedWidget {
+ /// Widget metadata.
+ pub manifest: WidgetManifest,
+
+ /// JavaScript source code (`index.js`).
+ pub js: String,
+
+ /// Optional CSS source code (`style.css`), auto-scoped.
+ pub css: Option,
+}
+
+/// Inject frontend customizations into the base HTML template.
+///
+/// Modifications:
+///
+/// **Before ``:**
+/// - Branding CSS custom property overrides
+/// - Title override (replaces `` content)
+///
+/// **Before `
`:**
+/// - Layout config as `window.__IRONCLAW_LAYOUT__`
+/// - Scoped widget `", css_vars));
+ }
+
+ // --- Body injections ---
+
+ // Layout config as global variable
+ if let Ok(layout_json) = serde_json::to_string(&bundle.layout) {
+ body_injections.push(format!(
+ "",
+ layout_json
+ ));
+ }
+
+ // Widget CSS (scoped) and JS
+ for widget in &bundle.widgets {
+ if let Some(ref css) = widget.css {
+ let scoped = scope_css(css, &widget.manifest.id);
+ if !scoped.trim().is_empty() {
+ body_injections.push(format!(
+ "",
+ widget.manifest.id, scoped
+ ));
+ }
+ }
+
+ // Widget JS as module script served from API
+ body_injections.push(format!(
+ "",
+ widget.manifest.id
+ ));
+ }
+
+ // Custom CSS
+ if let Some(ref custom_css) = bundle.custom_css {
+ if !custom_css.trim().is_empty() {
+ body_injections.push(format!(
+ "",
+ custom_css
+ ));
+ }
+ }
+
+ // --- Assemble ---
+
+ let mut result = base_html.to_string();
+
+ // Inject before
+ if !head_injections.is_empty() {
+ let head_block = head_injections.join("\n");
+ if let Some(pos) = result.rfind("") {
+ result.insert_str(pos, &format!("\n{}\n", head_block));
+ }
+ }
+
+ // Override
", title));
+ }
+ }
+ }
+
+ // Inject before
+ if !body_injections.is_empty() {
+ let body_block = body_injections.join("\n");
+ if let Some(pos) = result.rfind("") {
+ result.insert_str(pos, &format!("\n{}\n", body_block));
+ }
+ }
+
+ result
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::layout::*;
+ use crate::widget::*;
+
+ const MINIMAL_HTML: &str =
+ "