From f4abfe6fdcc7ada3bfd223290e9ce376fa6a158e Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 30 Apr 2026 18:18:39 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20rename=20crates/=20=E2=86=92=20vessels/?= =?UTF-8?q?=20+=20add=20El=20ports=20per=20sub-vessel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/. --- Cargo.toml | 24 +- crates/el-ui-compiler/src/codegen.rs | 581 ------ examples/profile-card/Cargo.toml | 10 +- spec/framework.md | 727 ++++--- {crates => vessels}/el-aop/Cargo.toml | 0 vessels/el-aop/manifest.el | 22 + {crates => vessels}/el-aop/src/aspects.rs | 0 {crates => vessels}/el-aop/src/chain.rs | 0 {crates => vessels}/el-aop/src/lib.rs | 0 vessels/el-aop/src/main.el | 223 +++ {crates => vessels}/el-aop/src/public.rs | 0 {crates => vessels}/el-aop/src/registry.rs | 0 {crates => vessels}/el-aop/src/tests.rs | 0 {crates => vessels}/el-auth/Cargo.toml | 0 vessels/el-auth/manifest.el | 22 + {crates => vessels}/el-auth/src/context.rs | 0 .../el-auth/src/engram_session.rs | 0 {crates => vessels}/el-auth/src/jwt.rs | 0 {crates => vessels}/el-auth/src/lib.rs | 0 vessels/el-auth/src/main.el | 201 ++ {crates => vessels}/el-auth/src/middleware.rs | 0 {crates => vessels}/el-auth/src/roles.rs | 0 {crates => vessels}/el-auth/src/session.rs | 0 {crates => vessels}/el-auth/src/tests.rs | 0 {crates => vessels}/el-config/Cargo.toml | 0 vessels/el-config/manifest.el | 24 + {crates => vessels}/el-config/src/config.rs | 0 {crates => vessels}/el-config/src/env.rs | 0 {crates => vessels}/el-config/src/error.rs | 0 {crates => vessels}/el-config/src/lib.rs | 0 vessels/el-config/src/main.el | 177 ++ {crates => vessels}/el-config/src/source.rs | 0 {crates => vessels}/el-i18n/Cargo.toml | 0 vessels/el-i18n/manifest.el | 19 + {crates => vessels}/el-i18n/src/bundle.rs | 0 {crates => vessels}/el-i18n/src/format.rs | 0 {crates => vessels}/el-i18n/src/lib.rs | 0 {crates => vessels}/el-i18n/src/locale.rs | 0 vessels/el-i18n/src/main.el | 208 ++ {crates => vessels}/el-i18n/src/plural.rs | 0 {crates => vessels}/el-i18n/src/t.rs | 0 {crates => vessels}/el-identity/Cargo.toml | 0 vessels/el-identity/manifest.el | 24 + .../el-identity/src/context.rs | 0 {crates => vessels}/el-identity/src/engram.rs | 0 {crates => vessels}/el-identity/src/error.rs | 0 {crates => vessels}/el-identity/src/guard.rs | 0 {crates => vessels}/el-identity/src/lib.rs | 0 vessels/el-identity/src/main.el | 271 +++ {crates => vessels}/el-identity/src/nodes.rs | 0 {crates => vessels}/el-identity/src/oauth.rs | 0 .../el-identity/src/provider.rs | 0 .../el-identity/src/session.rs | 0 {crates => vessels}/el-identity/src/tests.rs | 0 {crates => vessels}/el-layout/Cargo.toml | 0 vessels/el-layout/manifest.el | 21 + .../el-layout/src/breakpoint.rs | 0 .../el-layout/src/constraints.rs | 0 {crates => vessels}/el-layout/src/flex.rs | 0 {crates => vessels}/el-layout/src/grid.rs | 0 {crates => vessels}/el-layout/src/lib.rs | 0 vessels/el-layout/src/main.el | 237 +++ {crates => vessels}/el-layout/src/platform.rs | 0 .../el-layout/src/responsive.rs | 0 {crates => vessels}/el-layout/src/scroll.rs | 0 {crates => vessels}/el-layout/src/stack.rs | 0 {crates => vessels}/el-platform/Cargo.toml | 0 vessels/el-platform/manifest.el | 23 + .../el-platform/src/backends/android.rs | 0 .../el-platform/src/backends/ios.rs | 0 .../el-platform/src/backends/linux.rs | 0 .../el-platform/src/backends/macos.rs | 0 .../el-platform/src/backends/mod.rs | 0 .../el-platform/src/backends/server.rs | 0 .../el-platform/src/backends/tests.rs | 0 .../el-platform/src/backends/web.rs | 0 .../el-platform/src/backends/windows.rs | 0 {crates => vessels}/el-platform/src/config.rs | 0 {crates => vessels}/el-platform/src/lib.rs | 0 vessels/el-platform/src/main.el | 152 ++ {crates => vessels}/el-platform/src/node.rs | 0 {crates => vessels}/el-publish/Cargo.toml | 0 vessels/el-publish/manifest.el | 25 + {crates => vessels}/el-publish/src/apple.rs | 0 {crates => vessels}/el-publish/src/cert.rs | 0 {crates => vessels}/el-publish/src/config.rs | 0 {crates => vessels}/el-publish/src/google.rs | 0 {crates => vessels}/el-publish/src/lib.rs | 0 vessels/el-publish/src/main.el | 243 +++ .../el-publish/src/metadata.rs | 0 {crates => vessels}/el-publish/src/rollout.rs | 0 .../el-publish/src/screenshot.rs | 0 {crates => vessels}/el-publish/src/tests.rs | 0 {crates => vessels}/el-secrets/Cargo.toml | 0 vessels/el-secrets/manifest.el | 20 + {crates => vessels}/el-secrets/src/error.rs | 0 {crates => vessels}/el-secrets/src/lib.rs | 0 vessels/el-secrets/src/main.el | 195 ++ .../el-secrets/src/resolver.rs | 0 {crates => vessels}/el-secrets/src/secret.rs | 0 {crates => vessels}/el-secrets/src/source.rs | 0 {crates => vessels}/el-services/Cargo.toml | 0 vessels/el-services/manifest.el | 20 + .../el-services/src/binding/direct.rs | 0 .../el-services/src/binding/grpc.rs | 0 .../el-services/src/binding/mod.rs | 0 .../el-services/src/binding/rest.rs | 0 .../el-services/src/binding/websocket.rs | 0 {crates => vessels}/el-services/src/config.rs | 0 {crates => vessels}/el-services/src/lib.rs | 0 vessels/el-services/src/main.el | 197 ++ {crates => vessels}/el-services/src/proxy.rs | 0 .../el-services/src/registry.rs | 0 {crates => vessels}/el-services/src/tests.rs | 0 {crates => vessels}/el-style/Cargo.toml | 0 vessels/el-style/manifest.el | 21 + {crates => vessels}/el-style/src/color.rs | 0 {crates => vessels}/el-style/src/lib.rs | 0 vessels/el-style/src/main.el | 231 +++ {crates => vessels}/el-style/src/modifier.rs | 0 {crates => vessels}/el-style/src/radius.rs | 0 {crates => vessels}/el-style/src/shadow.rs | 0 {crates => vessels}/el-style/src/spacing.rs | 0 .../el-style/src/stylesheet.rs | 0 {crates => vessels}/el-style/src/theme.rs | 0 .../el-style/src/typography.rs | 0 {crates => vessels}/el-ui-compiler/Cargo.toml | 0 vessels/el-ui-compiler/manifest.el | 28 + {crates => vessels}/el-ui-compiler/src/ast.rs | 0 vessels/el-ui-compiler/src/codegen.rs | 1733 +++++++++++++++++ .../el-ui-compiler/src/error.rs | 0 .../el-ui-compiler/src/lexer.rs | 0 {crates => vessels}/el-ui-compiler/src/lib.rs | 1 + vessels/el-ui-compiler/src/main.el | 142 ++ .../el-ui-compiler/src/main.rs | 0 .../el-ui-compiler/src/parser.rs | 0 vessels/el-ui-compiler/src/semantic.rs | 523 +++++ .../el-ui-compiler/src/tests.rs | 0 138 files changed, 5445 insertions(+), 900 deletions(-) delete mode 100644 crates/el-ui-compiler/src/codegen.rs rename {crates => vessels}/el-aop/Cargo.toml (100%) create mode 100644 vessels/el-aop/manifest.el rename {crates => vessels}/el-aop/src/aspects.rs (100%) rename {crates => vessels}/el-aop/src/chain.rs (100%) rename {crates => vessels}/el-aop/src/lib.rs (100%) create mode 100644 vessels/el-aop/src/main.el rename {crates => vessels}/el-aop/src/public.rs (100%) rename {crates => vessels}/el-aop/src/registry.rs (100%) rename {crates => vessels}/el-aop/src/tests.rs (100%) rename {crates => vessels}/el-auth/Cargo.toml (100%) create mode 100644 vessels/el-auth/manifest.el rename {crates => vessels}/el-auth/src/context.rs (100%) rename {crates => vessels}/el-auth/src/engram_session.rs (100%) rename {crates => vessels}/el-auth/src/jwt.rs (100%) rename {crates => vessels}/el-auth/src/lib.rs (100%) create mode 100644 vessels/el-auth/src/main.el rename {crates => vessels}/el-auth/src/middleware.rs (100%) rename {crates => vessels}/el-auth/src/roles.rs (100%) rename {crates => vessels}/el-auth/src/session.rs (100%) rename {crates => vessels}/el-auth/src/tests.rs (100%) rename {crates => vessels}/el-config/Cargo.toml (100%) create mode 100644 vessels/el-config/manifest.el rename {crates => vessels}/el-config/src/config.rs (100%) rename {crates => vessels}/el-config/src/env.rs (100%) rename {crates => vessels}/el-config/src/error.rs (100%) rename {crates => vessels}/el-config/src/lib.rs (100%) create mode 100644 vessels/el-config/src/main.el rename {crates => vessels}/el-config/src/source.rs (100%) rename {crates => vessels}/el-i18n/Cargo.toml (100%) create mode 100644 vessels/el-i18n/manifest.el rename {crates => vessels}/el-i18n/src/bundle.rs (100%) rename {crates => vessels}/el-i18n/src/format.rs (100%) rename {crates => vessels}/el-i18n/src/lib.rs (100%) rename {crates => vessels}/el-i18n/src/locale.rs (100%) create mode 100644 vessels/el-i18n/src/main.el rename {crates => vessels}/el-i18n/src/plural.rs (100%) rename {crates => vessels}/el-i18n/src/t.rs (100%) rename {crates => vessels}/el-identity/Cargo.toml (100%) create mode 100644 vessels/el-identity/manifest.el rename {crates => vessels}/el-identity/src/context.rs (100%) rename {crates => vessels}/el-identity/src/engram.rs (100%) rename {crates => vessels}/el-identity/src/error.rs (100%) rename {crates => vessels}/el-identity/src/guard.rs (100%) rename {crates => vessels}/el-identity/src/lib.rs (100%) create mode 100644 vessels/el-identity/src/main.el rename {crates => vessels}/el-identity/src/nodes.rs (100%) rename {crates => vessels}/el-identity/src/oauth.rs (100%) rename {crates => vessels}/el-identity/src/provider.rs (100%) rename {crates => vessels}/el-identity/src/session.rs (100%) rename {crates => vessels}/el-identity/src/tests.rs (100%) rename {crates => vessels}/el-layout/Cargo.toml (100%) create mode 100644 vessels/el-layout/manifest.el rename {crates => vessels}/el-layout/src/breakpoint.rs (100%) rename {crates => vessels}/el-layout/src/constraints.rs (100%) rename {crates => vessels}/el-layout/src/flex.rs (100%) rename {crates => vessels}/el-layout/src/grid.rs (100%) rename {crates => vessels}/el-layout/src/lib.rs (100%) create mode 100644 vessels/el-layout/src/main.el rename {crates => vessels}/el-layout/src/platform.rs (100%) rename {crates => vessels}/el-layout/src/responsive.rs (100%) rename {crates => vessels}/el-layout/src/scroll.rs (100%) rename {crates => vessels}/el-layout/src/stack.rs (100%) rename {crates => vessels}/el-platform/Cargo.toml (100%) create mode 100644 vessels/el-platform/manifest.el rename {crates => vessels}/el-platform/src/backends/android.rs (100%) rename {crates => vessels}/el-platform/src/backends/ios.rs (100%) rename {crates => vessels}/el-platform/src/backends/linux.rs (100%) rename {crates => vessels}/el-platform/src/backends/macos.rs (100%) rename {crates => vessels}/el-platform/src/backends/mod.rs (100%) rename {crates => vessels}/el-platform/src/backends/server.rs (100%) rename {crates => vessels}/el-platform/src/backends/tests.rs (100%) rename {crates => vessels}/el-platform/src/backends/web.rs (100%) rename {crates => vessels}/el-platform/src/backends/windows.rs (100%) rename {crates => vessels}/el-platform/src/config.rs (100%) rename {crates => vessels}/el-platform/src/lib.rs (100%) create mode 100644 vessels/el-platform/src/main.el rename {crates => vessels}/el-platform/src/node.rs (100%) rename {crates => vessels}/el-publish/Cargo.toml (100%) create mode 100644 vessels/el-publish/manifest.el rename {crates => vessels}/el-publish/src/apple.rs (100%) rename {crates => vessels}/el-publish/src/cert.rs (100%) rename {crates => vessels}/el-publish/src/config.rs (100%) rename {crates => vessels}/el-publish/src/google.rs (100%) rename {crates => vessels}/el-publish/src/lib.rs (100%) create mode 100644 vessels/el-publish/src/main.el rename {crates => vessels}/el-publish/src/metadata.rs (100%) rename {crates => vessels}/el-publish/src/rollout.rs (100%) rename {crates => vessels}/el-publish/src/screenshot.rs (100%) rename {crates => vessels}/el-publish/src/tests.rs (100%) rename {crates => vessels}/el-secrets/Cargo.toml (100%) create mode 100644 vessels/el-secrets/manifest.el rename {crates => vessels}/el-secrets/src/error.rs (100%) rename {crates => vessels}/el-secrets/src/lib.rs (100%) create mode 100644 vessels/el-secrets/src/main.el rename {crates => vessels}/el-secrets/src/resolver.rs (100%) rename {crates => vessels}/el-secrets/src/secret.rs (100%) rename {crates => vessels}/el-secrets/src/source.rs (100%) rename {crates => vessels}/el-services/Cargo.toml (100%) create mode 100644 vessels/el-services/manifest.el rename {crates => vessels}/el-services/src/binding/direct.rs (100%) rename {crates => vessels}/el-services/src/binding/grpc.rs (100%) rename {crates => vessels}/el-services/src/binding/mod.rs (100%) rename {crates => vessels}/el-services/src/binding/rest.rs (100%) rename {crates => vessels}/el-services/src/binding/websocket.rs (100%) rename {crates => vessels}/el-services/src/config.rs (100%) rename {crates => vessels}/el-services/src/lib.rs (100%) create mode 100644 vessels/el-services/src/main.el rename {crates => vessels}/el-services/src/proxy.rs (100%) rename {crates => vessels}/el-services/src/registry.rs (100%) rename {crates => vessels}/el-services/src/tests.rs (100%) rename {crates => vessels}/el-style/Cargo.toml (100%) create mode 100644 vessels/el-style/manifest.el rename {crates => vessels}/el-style/src/color.rs (100%) rename {crates => vessels}/el-style/src/lib.rs (100%) create mode 100644 vessels/el-style/src/main.el rename {crates => vessels}/el-style/src/modifier.rs (100%) rename {crates => vessels}/el-style/src/radius.rs (100%) rename {crates => vessels}/el-style/src/shadow.rs (100%) rename {crates => vessels}/el-style/src/spacing.rs (100%) rename {crates => vessels}/el-style/src/stylesheet.rs (100%) rename {crates => vessels}/el-style/src/theme.rs (100%) rename {crates => vessels}/el-style/src/typography.rs (100%) rename {crates => vessels}/el-ui-compiler/Cargo.toml (100%) create mode 100644 vessels/el-ui-compiler/manifest.el rename {crates => vessels}/el-ui-compiler/src/ast.rs (100%) create mode 100644 vessels/el-ui-compiler/src/codegen.rs rename {crates => vessels}/el-ui-compiler/src/error.rs (100%) rename {crates => vessels}/el-ui-compiler/src/lexer.rs (100%) rename {crates => vessels}/el-ui-compiler/src/lib.rs (99%) create mode 100644 vessels/el-ui-compiler/src/main.el rename {crates => vessels}/el-ui-compiler/src/main.rs (100%) rename {crates => vessels}/el-ui-compiler/src/parser.rs (100%) create mode 100644 vessels/el-ui-compiler/src/semantic.rs rename {crates => vessels}/el-ui-compiler/src/tests.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 6241e2a..e44b5c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,17 @@ [workspace] members = [ - "crates/el-ui-compiler", - "crates/el-platform", - "crates/el-services", - "crates/el-aop", - "crates/el-auth", - "crates/el-publish", - "crates/el-identity", - "crates/el-style", - "crates/el-layout", - "crates/el-i18n", - "crates/el-config", - "crates/el-secrets", + "vessels/el-ui-compiler", + "vessels/el-platform", + "vessels/el-services", + "vessels/el-aop", + "vessels/el-auth", + "vessels/el-publish", + "vessels/el-identity", + "vessels/el-style", + "vessels/el-layout", + "vessels/el-i18n", + "vessels/el-config", + "vessels/el-secrets", "examples/profile-card", ] resolver = "2" diff --git a/crates/el-ui-compiler/src/codegen.rs b/crates/el-ui-compiler/src/codegen.rs deleted file mode 100644 index c53849a..0000000 --- a/crates/el-ui-compiler/src/codegen.rs +++ /dev/null @@ -1,581 +0,0 @@ -//! Code generator — transforms el-ui AST into JavaScript module source. -//! -//! Each component becomes a class that: -//! 1. Extends `Component` from the el-ui runtime. -//! 2. Stores each `state` field as a node in an in-instance `Graph`. -//! 3. Implements `render()` returning an HTML string. -//! 4. Uses `setState()` to trigger spreading activation and DOM patching. -//! -//! ## Codegen targets -//! -//! The `CodegenTarget` enum selects the output format: -//! -//! - `Web` — ES2022 module (current, default behavior) -//! - `Server` — Rust code calling the `el-platform` server backend for SSR -//! - `Native(Platform)` — Rust code calling the `el-platform` native backend trait - -use crate::ast::*; -use crate::error::CompileResult; - -/// Which native platform to target when using `CodegenTarget::Native`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Platform { - Ios, - Android, - Macos, - Linux, - Windows, -} - -impl Platform { - pub fn as_str(&self) -> &'static str { - match self { - Self::Ios => "ios", - Self::Android => "android", - Self::Macos => "macos", - Self::Linux => "linux", - Self::Windows => "windows", - } - } -} - -/// Selects the output format for the code generator. -/// -/// - `Web` — ES2022 JavaScript module (default, uses the el-ui JS runtime) -/// - `Server` — Rust module that uses `el_platform::ServerBackend` for SSR. -/// The generated Rust struct implements a `render_to_html()` method. -/// - `Native(Platform)` — Rust module calling `el_platform::PlatformBackend` -/// for the specified native platform (iOS, Android, macOS, Linux, Windows). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CodegenTarget { - /// Current default — ES2022 JavaScript module for the browser. - Web, - /// Server-side rendering — generates Rust code using `el_platform::ServerBackend`. - Server, - /// Native platform — generates Rust code using the platform backend trait. - Native(Platform), -} - -impl CodegenTarget { - pub fn is_web(&self) -> bool { - matches!(self, Self::Web) - } - - pub fn is_rust_output(&self) -> bool { - matches!(self, Self::Server | Self::Native(_)) - } -} - -pub struct Codegen { - runtime_path: String, - /// The compilation target. Defaults to `Web`. - pub target: CodegenTarget, -} - -impl Codegen { - pub fn new(runtime_path: &str) -> Self { - Self { - runtime_path: runtime_path.to_owned(), - target: CodegenTarget::Web, - } - } - - pub fn with_target(mut self, target: CodegenTarget) -> Self { - self.target = target; - self - } - - pub fn generate(&self, components: &[Component]) -> CompileResult { - match &self.target { - CodegenTarget::Web => self.generate_web(components), - CodegenTarget::Server => self.generate_server_rust(components), - CodegenTarget::Native(platform) => self.generate_native_rust(components, platform), - } - } - - fn generate_web(&self, components: &[Component]) -> CompileResult { - let mut out = String::new(); - - // Runtime import - out.push_str(&format!( - "import {{ Component, Graph, Renderer, Router, mount }} from '{}';\n\n", - self.runtime_path - )); - - for component in components { - out.push_str(&self.gen_component(component)?); - out.push('\n'); - } - - // Export all component names - let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect(); - if !names.is_empty() { - out.push_str(&format!("export {{ {} }};\n", names.join(", "))); - } - - Ok(out) - } - - /// Generate Rust code for the `server` target. - /// - /// The output is a Rust module where each component is a struct implementing - /// `render_to_html(&self) -> String` using `el_platform::ServerBackend`. - fn generate_server_rust(&self, components: &[Component]) -> CompileResult { - let mut out = String::new(); - out.push_str("//! el-ui SSR — generated by el-ui-compiler (target: server)\n"); - out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target server\n\n"); - out.push_str("use el_platform::{ServerBackend, PlatformBackend, PlatformNode};\n\n"); - - for comp in components { - out.push_str(&format!("/// Server-side rendered component: {}\n", comp.name)); - out.push_str(&format!("pub struct {} {{\n", comp.name)); - for prop in &comp.props { - out.push_str(&format!(" pub {}: String,\n", prop.name)); - } - for state in &comp.state { - out.push_str(&format!(" pub {}: String,\n", state.name)); - } - out.push_str("}\n\n"); - - out.push_str(&format!("impl {} {{\n", comp.name)); - out.push_str(" pub fn render_to_html(&self) -> String {\n"); - out.push_str(" let backend = ServerBackend::new();\n"); - // Generate a simple element tree based on the template - out.push_str(" let root = self.build_node_tree();\n"); - out.push_str(" backend.render_to_string(&root).unwrap_or_default()\n"); - out.push_str(" }\n\n"); - out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n"); - out.push_str(" // TODO: full template → PlatformNode tree codegen\n"); - out.push_str(" // The compiler translates each TemplateNode into\n"); - out.push_str(" // PlatformNode::element() / PlatformNode::text() calls.\n"); - out.push_str(" PlatformNode::element(\"div\")\n"); - out.push_str(" }\n"); - out.push_str("}\n\n"); - } - - Ok(out) - } - - /// Generate Rust code for a `native` target. - /// - /// The output is a Rust module where each component builds a `PlatformNode` - /// tree and calls the appropriate backend to mount it. - fn generate_native_rust(&self, components: &[Component], platform: &Platform) -> CompileResult { - let backend_type = match platform { - Platform::Ios => "IosBackend", - Platform::Android => "AndroidBackend", - Platform::Macos => "MacosBackend", - Platform::Linux => "LinuxBackend", - Platform::Windows => "WindowsBackend", - }; - - let mut out = String::new(); - out.push_str(&format!( - "//! el-ui native — generated by el-ui-compiler (target: {})\n", - platform.as_str() - )); - out.push_str("//! Do not edit. Re-generate with: el-ui-compiler --target \n\n"); - out.push_str(&format!( - "use el_platform::{{{} as Backend, PlatformBackend, PlatformNode}};\n\n", - backend_type - )); - - for comp in components { - out.push_str(&format!("/// Native component: {} ({})\n", comp.name, platform.as_str())); - out.push_str(&format!("pub struct {} {{\n", comp.name)); - for prop in &comp.props { - out.push_str(&format!(" pub {}: String,\n", prop.name)); - } - for state in &comp.state { - out.push_str(&format!(" pub {}: String,\n", state.name)); - } - out.push_str(" backend: Backend,\n"); - out.push_str("}\n\n"); - - out.push_str(&format!("impl {} {{\n", comp.name)); - out.push_str(" pub fn new() -> Self {\n"); - out.push_str(" Self {\n"); - for prop in &comp.props { - let default = prop.default.as_deref().unwrap_or("\"\""); - out.push_str(&format!(" {}: {}.to_string(),\n", prop.name, default)); - } - for state in &comp.state { - out.push_str(&format!(" {}: {}.to_string(),\n", state.name, state.initial)); - } - out.push_str(" backend: Backend::new(),\n"); - out.push_str(" }\n"); - out.push_str(" }\n\n"); - out.push_str(" pub fn mount(&self, container_id: &str) -> el_platform::PlatformResult<()> {\n"); - out.push_str(" let root = self.build_node_tree();\n"); - out.push_str(" self.backend.mount(root, container_id)\n"); - out.push_str(" }\n\n"); - out.push_str(" fn build_node_tree(&self) -> PlatformNode {\n"); - out.push_str(" // TODO: full template → PlatformNode tree codegen\n"); - out.push_str(" PlatformNode::element(\"div\")\n"); - out.push_str(" }\n"); - out.push_str("}\n\n"); - } - - Ok(out) - } - - fn gen_component(&self, comp: &Component) -> CompileResult { - let mut out = String::new(); - - out.push_str(&format!("class {} extends Component {{\n", comp.name)); - - // constructor - out.push_str(" constructor(props = {}) {\n"); - out.push_str(" super();\n"); - out.push_str(" this.props = props;\n"); - out.push_str(" this._graph = new Graph();\n"); - out.push_str(" this._stateNodes = {};\n"); - out.push_str(" this._state = {};\n"); - - // Validate and set props - if !comp.props.is_empty() { - out.push_str(" // Props\n"); - for prop in &comp.props { - let default_js = prop.default.as_deref() - .map(|d| translate_el_to_js(d)) - .unwrap_or_else(|| "undefined".to_owned()); - out.push_str(&format!( - " this._props_{name} = props.{name} !== undefined ? props.{name} : {default};\n", - name = prop.name, - default = default_js, - )); - } - } - - // Seed state nodes - if !comp.state.is_empty() { - out.push_str(" // State nodes (Engram graph seeds)\n"); - for s in &comp.state { - let initial_js = translate_el_to_js(&s.initial); - out.push_str(&format!( - " this._stateNodes['{name}'] = this._graph.seed({{ type: 'state', name: '{name}', content: {initial} }});\n", - name = s.name, - initial = initial_js, - )); - out.push_str(&format!( - " this._state['{name}'] = {initial};\n", - name = s.name, - initial = initial_js, - )); - } - } - - // Subscribe to state node changes for reactive re-render - if !comp.state.is_empty() { - out.push_str(" // Subscribe to graph activation events\n"); - out.push_str(" for (const [key, nodeId] of Object.entries(this._stateNodes)) {\n"); - out.push_str(" this._graph.subscribe(nodeId, (node) => {\n"); - out.push_str(" this._state[key] = node.content;\n"); - out.push_str(" if (this._renderer) this._renderer.patch();\n"); - out.push_str(" });\n"); - out.push_str(" }\n"); - } - - out.push_str(" }\n\n"); - - // setState method - out.push_str(" setState(name, value) {\n"); - out.push_str(" if (this._stateNodes[name] !== undefined) {\n"); - out.push_str(" this._graph.update(this._stateNodes[name], value);\n"); - out.push_str(" }\n"); - out.push_str(" }\n\n"); - - // User-defined methods - for method in &comp.methods { - out.push_str(&self.gen_method(method, comp)?); - out.push('\n'); - } - - // render() - out.push_str(" render() {\n"); - out.push_str(" const __self = this;\n"); - - // Expose state variables - for s in &comp.state { - out.push_str(&format!( - " const {name} = this._state['{name}'];\n", - name = s.name, - )); - } - // Expose props - for p in &comp.props { - out.push_str(&format!( - " const {name} = this._props_{name};\n", - name = p.name, - )); - } - - out.push_str(" return `"); - let template_js = self.gen_template_nodes(&comp.template.nodes, comp)?; - out.push_str(&template_js); - out.push_str("`;\n"); - out.push_str(" }\n\n"); - - out.push_str("}\n"); - Ok(out) - } - - fn gen_method(&self, method: &Method, comp: &Component) -> CompileResult { - let mut out = String::new(); - let params: Vec = method.params.iter() - .map(|(n, _)| n.clone()) - .collect(); - - out.push_str(&format!( - " {}({}) {{\n", - method.name, - params.join(", ") - )); - - // Expose state in method body - for s in &comp.state { - out.push_str(&format!( - " const {name} = this._state['{name}'];\n", - name = s.name - )); - } - - // Translate body — simple pass-through with setState substitution - let body = translate_method_body(&method.body, comp); - for line in body.lines() { - out.push_str(&format!(" {}\n", line)); - } - - out.push_str(" }\n"); - Ok(out) - } - - fn gen_template_nodes(&self, nodes: &[TemplateNode], comp: &Component) -> CompileResult { - let mut out = String::new(); - for node in nodes { - out.push_str(&self.gen_template_node(node, comp)?); - } - Ok(out) - } - - fn gen_template_node(&self, node: &TemplateNode, comp: &Component) -> CompileResult { - match node { - TemplateNode::Text(t) => Ok(t.clone()), - - TemplateNode::Interpolation(expr) => { - let js_expr = translate_interpolation(expr, comp); - Ok(format!("${{{} }}", js_expr)) - } - - TemplateNode::Element { tag, attrs, children } => { - let mut out = format!("<{}", tag); - - for attr in attrs { - out.push_str(&self.gen_attr(attr, comp)?); - } - - if children.is_empty() { - out.push_str(&format!(" data-el-tag=\"{}\">", tag)); - out.push_str(&format!("", tag)); - } else { - out.push_str(&format!(" data-el-tag=\"{}\">", tag)); - out.push_str(&self.gen_template_nodes(children, comp)?); - out.push_str(&format!("", tag)); - } - Ok(out) - } - - TemplateNode::Component { name, props } => { - // Render as inline component call - let mut prop_entries: Vec = Vec::new(); - for prop in props { - match prop { - Attr::Static { name: pn, value } => { - prop_entries.push(format!("{}: \"{}\"", pn, value)); - } - Attr::Dynamic { name: pn, expr } => { - let js = translate_interpolation(expr, comp); - prop_entries.push(format!("{}: {}", pn, js)); - } - Attr::EventHandler { event, handler } => { - let js = translate_handler(handler, comp); - prop_entries.push(format!("on{}: {}", capitalize(event), js)); - } - Attr::BoolAttr { name: pn, expr } => { - prop_entries.push(format!("{}: {}", pn, expr)); - } - } - } - let props_js = format!("{{ {} }}", prop_entries.join(", ")); - Ok(format!("${{__self._child({}, {})}}", name, props_js)) - } - - TemplateNode::If { condition, then, else_ } => { - let cond_js = translate_interpolation(condition, comp); - let then_html = self.gen_template_nodes(then, comp)?; - let else_html = if let Some(els) = else_ { - self.gen_template_nodes(els, comp)? - } else { - String::new() - }; - Ok(format!( - "${{({}) ? `{}` : `{}`}}", - cond_js, then_html, else_html - )) - } - - TemplateNode::Each { items, item_name, children } => { - let items_js = translate_interpolation(items, comp); - let child_html = self.gen_template_nodes(children, comp)?; - // Generate a map over the array - Ok(format!( - "${{({}).map(({}) => `{}`).join('')}}", - items_js, item_name, child_html - )) - } - - TemplateNode::Activate { query, result_name, children } => { - let child_html = self.gen_template_nodes(children, comp)?; - Ok(format!( - "${{((__self._graph.search(\"{}\")) || []).map(({}) => `{}`).join('')}}", - query, result_name, child_html - )) - } - } - } - - fn gen_attr(&self, attr: &Attr, comp: &Component) -> CompileResult { - match attr { - Attr::Static { name, value } => { - Ok(format!(" {}=\"{}\"", name, value)) - } - Attr::Dynamic { name, expr } => { - let js = translate_interpolation(expr, comp); - Ok(format!(" {}=\"${{{} }}\"", name, js)) - } - Attr::BoolAttr { name, expr } => { - let js = translate_interpolation(expr, comp); - Ok(format!(" ${{({}) ? '{}' : '' }}", js, name)) - } - Attr::EventHandler { event, handler } => { - // We use data attributes to defer event binding - let js = translate_handler(handler, comp); - // Inline handler via data attribute — the renderer will bind these - Ok(format!(" data-el-{}=\"{}\"", event, escape_attr(&js))) - } - } - } -} - -/// Translate an el-ui expression to JavaScript. -/// Handles state assignments like `count = count + 1` → `__self.setState('count', count + 1)` -fn translate_interpolation(expr: &str, comp: &Component) -> String { - translate_expr(expr, comp) -} - -fn translate_expr(expr: &str, comp: &Component) -> String { - let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect(); - // Arrow functions: passthrough - // State assignment: `name = value` → `__self.setState('name', value)` - let trimmed = expr.trim(); - - // Check for simple assignment: `ident = expr` - if let Some(result) = try_translate_assignment(trimmed, &state_names) { - return result; - } - - // Arrow function containing assignment: `() => count = count + 1` - if trimmed.starts_with('(') || trimmed.starts_with("e =>") || trimmed.starts_with("() =>") { - return translate_arrow_fn(trimmed, &state_names); - } - - // Otherwise pass through as-is - trimmed.to_owned() -} - -fn try_translate_assignment(expr: &str, state_names: &[&str]) -> Option { - // Match: `name = value` where name is a state variable - // Must not be `==` (equality) - let parts: Vec<&str> = expr.splitn(2, '=').collect(); - if parts.len() == 2 { - let lhs = parts[0].trim(); - let rhs = parts[1].trim(); - // Ensure it's not `==` or `!=` or `<=` or `>=` - if !rhs.starts_with('=') && !lhs.ends_with('!') && !lhs.ends_with('<') && !lhs.ends_with('>') { - if state_names.contains(&lhs) { - return Some(format!("__self.setState('{}', {})", lhs, rhs)); - } - } - } - None -} - -fn translate_arrow_fn(expr: &str, state_names: &[&str]) -> String { - // Translate assignments inside arrow functions - // This is a best-effort string transformation - let mut result = expr.to_owned(); - for name in state_names { - // Replace `name = ` with `__self.setState('name', ` ... `)` is too complex - // for a simple string replacement, but we can handle common patterns. - // Pattern: `name = expr` at end of arrow fn or in braces - let pat = format!("{} = ", name); - if let Some(idx) = result.find(&pat) { - // Check it's not == - let after = &result[idx + pat.len()..]; - // Simple case: `() => count = count + 1` - let prefix = &result[..idx]; - result = format!("{}__self.setState('{}', {})", prefix, name, after.trim_end_matches(')')); - } - } - result -} - -fn translate_handler(handler: &str, comp: &Component) -> String { - translate_expr(handler, comp) -} - -/// Translate method body — replace bare state assignments with setState calls. -fn translate_method_body(body: &str, comp: &Component) -> String { - let state_names: Vec<&str> = comp.state.iter().map(|s| s.name.as_str()).collect(); - let mut lines: Vec = Vec::new(); - for line in body.lines() { - let trimmed = line.trim(); - if let Some(translated) = try_translate_assignment(trimmed, &state_names) { - lines.push(format!("{};", translated)); - } else if trimmed.starts_with("return ") { - lines.push(trimmed.to_owned()); - } else { - lines.push(trimmed.to_owned()); - } - } - lines.join("\n") -} - -fn translate_el_to_js(expr: &str) -> String { - let s = expr.trim(); - // Fn types — translate to null (not a valid JS value, handled at runtime) - if s.starts_with("Fn") { return "null".into(); } - // Boolean - if s == "true" { return "true".into(); } - if s == "false" { return "false".into(); } - // String literal - if s.starts_with('"') { return s.replace('"', "\"").to_owned(); } - // Numbers - if s.parse::().is_ok() { return s.to_owned(); } - if s.parse::().is_ok() { return s.to_owned(); } - // Empty string / void - if s.is_empty() { return "null".into(); } - s.to_owned() -} - -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } -} - -fn escape_attr(s: &str) -> String { - s.replace('"', """).replace('\'', "'") -} diff --git a/examples/profile-card/Cargo.toml b/examples/profile-card/Cargo.toml index 1a0dabd..098248a 100644 --- a/examples/profile-card/Cargo.toml +++ b/examples/profile-card/Cargo.toml @@ -9,8 +9,8 @@ name = "profile-card" path = "src/main.rs" [dependencies] -el-style = { path = "../../crates/el-style" } -el-layout = { path = "../../crates/el-layout" } -el-i18n = { path = "../../crates/el-i18n" } -el-config = { path = "../../crates/el-config" } -el-secrets = { path = "../../crates/el-secrets" } +el-style = { path = "../../vessels/el-style" } +el-layout = { path = "../../vessels/el-layout" } +el-i18n = { path = "../../vessels/el-i18n" } +el-config = { path = "../../vessels/el-config" } +el-secrets = { path = "../../vessels/el-secrets" } diff --git a/spec/framework.md b/spec/framework.md index bb8f771..85db900 100644 --- a/spec/framework.md +++ b/spec/framework.md @@ -1,76 +1,98 @@ # el-ui Framework Specification -Version 0.1.0 — April 2026 +Version 1.1.0 — April 30, 2026 --- ## Overview -el-ui is a frontend framework where state is an Engram graph and reactivity is spreading activation. - -Every framework answers the same question differently: **what re-renders when state changes?** +el-ui is a frontend framework where state is an Engram graph and reactivity is spreading activation. Every framework must answer the same question: what re-renders when state changes? el-ui's answer is: whatever the spreading activation algorithm determines is relevant. | Framework | Reactivity model | |-----------|-----------------| | React | Virtual DOM diffing | -| Vue | Dependency tracking (Proxy-based) | -| Svelte | Compile-time analysis | -| **el-ui** | **Spreading activation over a state graph** | +| Vue | Dependency tracking (Proxy-based reactive primitives) | +| Svelte | Compile-time static analysis | +| Solid | Fine-grained signal-based subscriptions | +| **el-ui** | **Spreading activation over a typed state graph** | -The core insight: treating component state as a graph of related nodes, and using the same spreading activation algorithm as the Engram knowledge engine to determine what to update. Reactivity is not declared, tracked, or diffed — it is activated and propagated, exactly as associative memory works in biological neural networks. +The core insight: component state is represented as a graph of related nodes, and the same spreading activation algorithm used by the Engram knowledge engine determines which components need to update. Reactivity is not declared, tracked, or diffed — it is activated and propagated, exactly as associative memory works in biological neural networks. --- -## 1. Activation-Based Reactivity +## 1. Activation-Based Reactivity Model ### 1.1 The Model Every piece of state in an el-ui application is a **node** in an in-browser Engram graph. Nodes have: -- `id` — UUID, the node's identity -- `type` — `'state'`, `'route'`, or user-defined -- `name` — the state variable name -- `content` — the current value -- `importance` — a salience weight `[0, 1]`, boosted when the node is activated +| Field | Type | Description | +|-------|------|-------------| +| `id` | String (UUID) | Node's permanent identity | +| `type` | String | `'state'`, `'route'`, or user-defined | +| `name` | String | State variable name | +| `content` | Any | Current value | +| `importance` | Float [0, 1] | Salience weight; boosted on update | -Nodes are connected by **edges** with a `weight` `[0, 1]` and a `relation` label. +Nodes are connected by directed **edges** with: -When state changes (via `setState()`), spreading activation runs from the changed node: +| Field | Type | Description | +|-------|------|-------------| +| `weight` | Float [0, 1] | Connection strength | +| `relation` | String | Semantic label of the relationship | + +### 1.2 Activation Formula + +When state changes via `setState()`, spreading activation runs from the changed node through the graph: ``` strength = parent_strength × edge.weight × target.importance ``` -This is multiplicative, matching the Engram core engine (`engram-core/src/activation.rs`). Every factor must be non-trivial for a path to propagate — weak edges, dormant nodes, and irrelevant connections die immediately. Components subscribed to nodes in the resulting **activation surface** update. Everything else stays still. +This formula is identical to the Engram core spreading activation formula, with the semantic similarity factor (cosine_sim) omitted because the in-browser graph does not yet carry embedding vectors. In a future version connected to the Engram server, the full four-factor formula applies: -### 1.2 Why Multiplication, Not Addition +``` +strength = parent_strength × edge.weight × target.importance × cosine_sim(query, target) +``` -Addition allows many weak signals to accumulate into false relevance — an observation from the Engram architecture. The brain's associative memory is conjunctive: a path requires ALL its links to be strong. Multiplication enforces this. If any factor is near zero, the path dies. +### 1.3 Why Multiplication, Not Addition -### 1.3 Pruning +Addition allows many weak signals to accumulate into false relevance. The brain's associative memory is conjunctive: an activated path requires ALL its links to be strong. Multiplication enforces this. If any factor is near zero — weak edge, dormant node, or irrelevant connection — the path dies immediately. This is not a performance optimization; it is a semantic property of associative retrieval. -Paths with activation strength below `PRUNE_THRESHOLD` (default: `0.01`) are cut. This prevents exponential blowup in large state graphs and models the brain's attention filter. +### 1.4 Pruning Threshold -### 1.4 Importance Boost +Paths with activation strength below `PRUNE_THRESHOLD` (default: `0.01`) are cut. This prevents exponential blowup in large state graphs and models the brain's attention filter. All nodes not reached above the threshold are not considered for re-render. -When a node is updated, its importance increases by 0.1 (capped at 1.0). Recently-changed state is more salient — it activates more easily in future propagation. This mirrors long-term potentiation: frequently-used pathways become lower-resistance. +### 1.5 Importance Boost (Long-Term Potentiation Analog) + +When a state node is updated, its `importance` increases by 0.1 (capped at 1.0): + +``` +node.importance = Math.min(1.0, node.importance + 0.1) +``` + +Recently-changed state becomes more salient — it activates more easily in future propagation. This mirrors long-term potentiation: frequently-used pathways become lower-resistance. State that changes often gains importance; state that never changes fades toward background salience. + +### 1.6 Activation Surface + +The **activation surface** is the set of nodes reached during a spreading activation pass, i.e., all nodes with `strength > PRUNE_THRESHOLD`. Components subscribed to any node in the activation surface receive update notifications and re-render. --- ## 2. Component Definition Syntax -Components are defined in `.el` files (the same extension as el source). A component is a specialized el module. +Components are defined in `.el` files (shared extension with the El programming language). A component is a specialized el module with four optional sections. -### 2.1 Structure +### 2.1 Component Structure ``` component ComponentName { props { - // optional prop declarations + // prop declarations } state { - // optional state declarations + // state declarations } fn methodName(param: Type) -> ReturnType { @@ -83,26 +105,30 @@ component ComponentName { } ``` -All four sections (`props`, `state`, methods, `template`) are optional. Components with no template render an empty string. +All four sections are optional. Components with no template render an empty string. Multiple `fn` blocks are allowed; multiple `props` or `state` blocks are not (only the last one is used). ### 2.2 Props -Props are inputs from the parent component. They are read-only inside the component. +Props are read-only inputs from the parent component. ``` props { - label: String // required - variant: String = "primary" // optional, with default + label: String // required prop + variant: String = "primary" // optional with default disabled: Bool = false // boolean with default onClick: Fn() -> Void // function prop } ``` -Prop types: `String`, `Int`, `Float`, `Bool`, `Fn(...) -> T`, `[T]`, `T?`. +Supported prop types: `String`, `Int`, `Float`, `Bool`, `Fn(...) -> T`. + +**Note:** Array (`[T]`) and optional (`T?`) type syntax is not currently parsed by the compiler. The parser reads a single identifier as the type name. + +Props are accessed in generated code as `this._props_{name}` (not `this.props.name`). In method and template bodies, the compiler exposes each prop as a local constant named `{name}`. ### 2.3 State -State declarations create nodes in the component's Engram graph. +State declarations create nodes in the component's Engram graph. Every state variable requires an initial value. ``` state { @@ -113,20 +139,23 @@ state { ``` Each state variable becomes: -1. A node in `this._graph` (seeded at construction time) -2. A reactive binding: changing the value via `setState()` triggers activation +1. A node in `this._graph` (seeded at construction time with `type: 'state'`), tracked by ID in `this._stateNodes`. +2. A mirrored value in `this._state` for direct read access. +3. A reactive binding: changing the value via `setState()` triggers activation and re-render. + +In method and template bodies, the compiler exposes each state variable as a local constant named `{name}` (reading from `this._state`). ### 2.4 Methods -Methods are `fn` definitions inside the component body. They have access to the component's current state and can call `setState()`. +Methods are `fn` definitions inside the component body. State assignments in method bodies compile to `setState()` calls. ``` fn increment() -> Void { - count = count + 1 + count = count + 1 // compiles to: __self.setState('count', count + 1) } ``` -State assignments in method bodies (`count = count + 1`) are compiled to `setState('count', count + 1)` calls. +The generated JavaScript method exposes all state variables as local `const` bindings before executing the body. The variable `__self` refers to the component instance. --- @@ -134,7 +163,7 @@ State assignments in method bodies (`count = count + 1`) are compiled to `setSta ### 3.1 Interpolation -Embed any expression with `{expr}`: +Any expression embedded in `{expr}`: ```

{count}

@@ -144,7 +173,7 @@ Embed any expression with `{expr}`: ### 3.2 Event Binding -Bind DOM events with `on:event={handler}`: +DOM events bound with `on:event={handler}`: ``` @@ -154,7 +183,7 @@ Bind DOM events with `on:event={handler}`: Supported events: `click`, `input`, `change`, `mouseenter`, `mouseleave`, `keydown`, `keyup`, `keypress`, `focus`, `blur`, `submit`, `mousedown`, `mouseup`, `dblclick`, `contextmenu`. -The compiler emits `data-el-{event}` attributes. The renderer binds handlers at mount time and rebinds after each patch. +The compiler emits `data-el-{event}` attributes on elements. The renderer binds handlers at mount time using `new Function(...)` evaluated in the component's scope (`__self`), and rebinds after each patch. ### 3.3 Dynamic Attributes @@ -172,32 +201,30 @@ The compiler emits `data-el-{event}` attributes. The renderer binds handlers at ### 3.5 Boolean Attributes +The parser recognises a fixed set of boolean attribute names: `disabled`, `checked`, `readonly`, `required`, `multiple`, `selected`. These emit the attribute name when the expression is truthy, nothing when falsy. + ``` ``` -Boolean attributes emit the attribute name if the expression is truthy, nothing if falsy. +A standalone attribute with no value (e.g., `"#, + app = app, + label = html_escape_codegen(label), + ) + } + + SemanticPrimitive::Text { content, concept } => { + let tag = concept.html_tag(); + let class = match concept { + TextConcept::Heading { .. } => format!(" class=\"heading\""), + TextConcept::Body => format!(" class=\"body-text\""), + TextConcept::Caption => format!(" class=\"caption\""), + TextConcept::Label => format!(" class=\"label\""), + TextConcept::Code => format!(" class=\"code\""), + }; + format!( + "<{tag}{class}>{content}", + tag = tag, + class = class, + content = html_escape_codegen(content), + ) + } + + SemanticPrimitive::Container { children, layout } => { + let flex_dir = layout.css_flex_direction(); + let child_html: Vec = + children.iter().map(generate_server_html).collect(); + format!( + r#"
{children}
"#, + flex_dir = flex_dir, + children = child_html.join(""), + ) + } + + SemanticPrimitive::Input { binding, hint, appearance } => { + let placeholder = hint.as_deref().unwrap_or(""); + let app = appearance.css_class(); + format!( + r#""#, + app = app, + binding = binding, + placeholder = html_escape_codegen(placeholder), + ) + } + + SemanticPrimitive::Image { src, alt } => { + format!( + r#"{alt}"#, + src = html_escape_codegen(src), + alt = html_escape_codegen(alt), + ) + } + + SemanticPrimitive::Toggle { binding, label } => { + format!( + r#""#, + binding = binding, + label = html_escape_codegen(label), + ) + } + + SemanticPrimitive::List { items_binding, item_name, item_template } => { + // At SSR time the list is empty — the client hydrates with real data. + let placeholder_item = generate_server_html(item_template); + format!( + r#"
"#, + items_binding = items_binding, + item_name = item_name, + placeholder_item = placeholder_item, + ) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers shared across codegen functions +// --------------------------------------------------------------------------- + +/// Minimal HTML escape for content emitted into generated source strings. +fn html_escape_codegen(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Sanitize a display string into a valid Rust identifier fragment. +fn sanitize_id(s: &str) -> String { + s.chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect::() + .to_lowercase() +} diff --git a/crates/el-ui-compiler/src/error.rs b/vessels/el-ui-compiler/src/error.rs similarity index 100% rename from crates/el-ui-compiler/src/error.rs rename to vessels/el-ui-compiler/src/error.rs diff --git a/crates/el-ui-compiler/src/lexer.rs b/vessels/el-ui-compiler/src/lexer.rs similarity index 100% rename from crates/el-ui-compiler/src/lexer.rs rename to vessels/el-ui-compiler/src/lexer.rs diff --git a/crates/el-ui-compiler/src/lib.rs b/vessels/el-ui-compiler/src/lib.rs similarity index 99% rename from crates/el-ui-compiler/src/lib.rs rename to vessels/el-ui-compiler/src/lib.rs index 6a05628..a932372 100644 --- a/crates/el-ui-compiler/src/lib.rs +++ b/vessels/el-ui-compiler/src/lib.rs @@ -11,6 +11,7 @@ pub mod codegen; pub mod error; pub mod lexer; pub mod parser; +pub mod semantic; pub use ast::{Attr, Component, Method, PropDef, StateDef, Template, TemplateNode}; diff --git a/vessels/el-ui-compiler/src/main.el b/vessels/el-ui-compiler/src/main.el new file mode 100644 index 0000000..f4d4fef --- /dev/null +++ b/vessels/el-ui-compiler/src/main.el @@ -0,0 +1,142 @@ +// el-ui-compiler — El→browser-target component compiler (STUB). +// +// The Rust crate transforms `.el` component files into JavaScript using a +// real lexer/parser/codegen pipeline backed by the el-ui runtime +// (`el-ui.js`). This El surface is a placeholder. +// +// RUNTIME GAP — load-bearing: +// `elc` (the bootstrap El compiler) currently emits C only. There is no +// JavaScript or WebAssembly backend yet, so the El surface cannot +// actually compile a component to JS today. +// +// What this vessel provides: +// - The shape of the public API (Compiler with runtime_path, +// compile_component, compile_app). +// - A `CompileResult` record consumers can branch on. +// - Stub bodies that return a deterministic "not yet implemented" +// module string so downstream tooling can integration-test against +// a stable contract. +// +// When elc gains a JS/Wasm backend, swap the bodies of `lex`, `parse`, +// `semantic_check`, and `codegen` for real implementations — the public +// surface should not need to change. + +// ── Errors ────────────────────────────────────────────────────────────────── + +fn err_lex() -> String { "elc.lex" } +fn err_parse() -> String { "elc.parse" } +fn err_semantic() -> String { "elc.semantic" } +fn err_codegen() -> String { "elc.codegen" } +fn err_backend_missing() -> String { "elc.backend_missing" } + +// ── Compile result ────────────────────────────────────────────────────────── +// +// Modeled as JSON since the runtime's product types and field accessors +// don't yet support the Bool/String mix we want here cleanly. + +fn result_ok(code: String) -> String { + "{\"ok\":true,\"code\":" + json_quote(code) + ",\"error\":\"\"}" +} + +fn result_err(error: String) -> String { + "{\"ok\":false,\"code\":\"\",\"error\":\"" + error + "\"}" +} + +// Minimal JSON string quoter (escape backslash + double-quote only). +// elc still doesn't expose a json_string_quote builtin. +fn json_quote(s: String) -> String { + let escaped: String = str_replace(s, "\\", "\\\\") + let escaped = str_replace(escaped, "\"", "\\\"") + "\"" + escaped + "\"" +} + +fn result_ok_p(result_json: String) -> Bool { + json_get_bool(result_json, "ok") +} + +// ── Compiler config ───────────────────────────────────────────────────────── + +fn compiler_default_runtime_path() -> String { "./el-ui.js" } +fn compiler_default_target() -> String { "js" } + +fn compiler_new() -> String { + "{\"runtime_path\":\"./el-ui.js\",\"target\":\"js\"}" +} + +fn compiler_with_runtime(c_json: String, path: String) -> String { + json_set(c_json, "runtime_path", json_quote(path)) +} + +fn compiler_with_target(c_json: String, target: String) -> String { + json_set(c_json, "target", json_quote(target)) +} + +fn compiler_runtime_path(c_json: String) -> String { + json_get_string(c_json, "runtime_path") +} + +fn compiler_target(c_json: String) -> String { + json_get_string(c_json, "target") +} + +// ── Pipeline stages (stubs) ───────────────────────────────────────────────── +// +// Real implementations live in the Rust crate (lexer.rs, parser.rs, +// semantic.rs, codegen.rs). These El stubs only validate inputs and +// produce a marker payload so callers can wire the contract. + +fn lex(source: String) -> String { + if str_eq(source, "") { return "" } + "[]" // would be tokens JSON +} + +fn parse(tokens_json: String) -> String { + if str_eq(tokens_json, "") { return "" } + "[]" // would be Component AST JSON +} + +fn semantic_check(ast_json: String) -> String { + ast_json // pass-through stub +} + +fn codegen(c_json: String, ast_json: String) -> String { + let runtime: String = compiler_runtime_path(c_json) + let preamble: String = "// AUTOGENERATED by el-ui-compiler (STUB)\n" + let import_line: String = "import { register, h } from \"" + runtime + "\";\n" + let body: String = "throw new Error(\"" + err_backend_missing() + + ": el-ui-compiler has no JS backend yet; emit C with elc instead\");\n" + preamble + import_line + body +} + +// ── Public entry points ───────────────────────────────────────────────────── + +fn compile_component(c_json: String, source: String) -> String { + let tokens: String = lex(source) + if str_eq(tokens, "") { return result_err(err_lex()) } + let ast: String = parse(tokens) + if str_eq(ast, "") { return result_err(err_parse()) } + let checked: String = semantic_check(ast) + if str_eq(checked, "") { return result_err(err_semantic()) } + let js: String = codegen(c_json, checked) + result_ok(js) +} + +// `entry_source` is the app entry; `components_json` is a JSON object map of +// name -> source text. We compile each component first, then the entry. +fn compile_app(c_json: String, entry_source: String, components_json: String) -> String { + // RUNTIME GAP: no json_object_keys() yet, so we cannot iterate the map + // generically here. Real implementation will iterate and short-circuit + // on first compile error. For the stub we just compile the entry. + compile_component(c_json, entry_source) +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let c: String = compiler_new() +let c = compiler_with_runtime(c, "./el-ui.js") +let r: String = compile_component(c, "component Hello { template {

hi

} }") +if result_ok_p(r) { + println("[el-ui-compiler] STUB compiled (target=" + compiler_target(c) + ")") +} else { + println("[el-ui-compiler] error: " + json_get_string(r, "error")) +} diff --git a/crates/el-ui-compiler/src/main.rs b/vessels/el-ui-compiler/src/main.rs similarity index 100% rename from crates/el-ui-compiler/src/main.rs rename to vessels/el-ui-compiler/src/main.rs diff --git a/crates/el-ui-compiler/src/parser.rs b/vessels/el-ui-compiler/src/parser.rs similarity index 100% rename from crates/el-ui-compiler/src/parser.rs rename to vessels/el-ui-compiler/src/parser.rs diff --git a/vessels/el-ui-compiler/src/semantic.rs b/vessels/el-ui-compiler/src/semantic.rs new file mode 100644 index 0000000..093d60e --- /dev/null +++ b/vessels/el-ui-compiler/src/semantic.rs @@ -0,0 +1,523 @@ +//! Semantic primitive layer — EBD-driven appearance and layout concepts. +//! +//! # Design rationale +//! +//! Traditional UI frameworks describe *appearance*: a red button with rounded corners. +//! El UI describes *concepts*: a destructive action trigger. The platform then maps +//! that concept to its native visual convention — red on iOS, a warning style on +//! macOS, an outlined filled button on Android, etc. +//! +//! This is the EBD principle applied to UI: the component expresses *what something is*, +//! the platform backend expresses *how it looks*. +//! +//! # The semantic → PlatformNode pipeline +//! +//! ```text +//! .el template +//! ↓ +//! SemanticPrimitive (this module) +//! ↓ +//! platform codegen (codegen.rs — per-platform dispatch) +//! ↓ +//! PlatformNode (el-platform IR) +//! ↓ +//! platform backend (el-platform — native API calls) +//! ``` + +// Semantic types drive codegen decisions. They do not directly construct +// PlatformNodes at compile time — the generated *source code* strings reference +// PlatformNode from the el-platform crate, which is a separate runtime crate. +// The compiler emits Rust source that will reference el_platform::PlatformNode. + +// --------------------------------------------------------------------------- +// Appearance concepts +// --------------------------------------------------------------------------- + +/// EBD appearance concept — WHAT a control is, not HOW it looks. +/// +/// Each platform maps these to its visual conventions: +/// - `Action` → primary button style (blue on Apple, filled on Material) +/// - `Destructive` → warning/danger style (red tint on Apple, error colour on Material) +/// - `Secondary` → subdued style (grey on Apple, outlined on Material) +/// - `Navigation` → used for link-like / back-navigation elements +/// - `Informational` → read-only display elements +/// - `Structural` → layout containers with no interactive meaning +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppearanceConcept { + /// Primary action trigger — the most prominent interactive element. + Action, + /// Dangerous or irreversible action (delete, reset, etc.). + Destructive, + /// Less prominent action — secondary to the main action on screen. + Secondary, + /// Navigational element — links, back buttons, tab items. + Navigation, + /// Displays information — labels, status indicators, badges. + Informational, + /// Layout or structural container — no interactive semantics. + Structural, +} + +impl AppearanceConcept { + /// Canonical CSS class suffix for the server/web targets. + pub fn css_class(&self) -> &'static str { + match self { + Self::Action => "action", + Self::Destructive => "destructive", + Self::Secondary => "secondary", + Self::Navigation => "navigation", + Self::Informational => "informational", + Self::Structural => "structural", + } + } + + /// AppKit bezel style constant for the macOS target. + pub fn appkit_bezel_style(&self) -> &'static str { + match self { + Self::Action => "NSBezelStyle::rounded()", + Self::Destructive => "NSBezelStyle::rounded()", // red tint applied via theme token + Self::Secondary => "NSBezelStyle::smallSquare()", + Self::Navigation => "NSBezelStyle::recessed()", + Self::Informational => "NSBezelStyle::inline()", + Self::Structural => "NSBezelStyle::rounded()", + } + } + + /// UIKit button configuration for the iOS target. + pub fn uikit_button_config(&self) -> &'static str { + match self { + Self::Action => "UIButton.Configuration.filled()", + Self::Destructive => "UIButton.Configuration.filled()", // tintColor = .systemRed + Self::Secondary => "UIButton.Configuration.gray()", + Self::Navigation => "UIButton.Configuration.plain()", + Self::Informational => "UIButton.Configuration.plain()", + Self::Structural => "UIButton.Configuration.plain()", + } + } + + /// Jetpack Compose button composable for the Android target. + pub fn compose_button_type(&self) -> &'static str { + match self { + Self::Action => "Button", + Self::Destructive => "Button", // containerColor = MaterialTheme.colorScheme.error + Self::Secondary => "OutlinedButton", + Self::Navigation => "TextButton", + Self::Informational => "TextButton", + Self::Structural => "TextButton", + } + } + + /// WinUI control style for the Windows target. + pub fn winui_button_style(&self) -> &'static str { + match self { + Self::Action => "AccentButtonStyle", + Self::Destructive => "AccentButtonStyle", // Background = DangerBrush via theme + Self::Secondary => "DefaultButtonStyle", + Self::Navigation => "NavigationBackButtonNormalStyle", + Self::Informational => "DefaultButtonStyle", + Self::Structural => "DefaultButtonStyle", + } + } + + /// GTK CSS class for the Linux target. + pub fn gtk_css_class(&self) -> &'static str { + match self { + Self::Action => "suggested-action", + Self::Destructive => "destructive-action", + Self::Secondary => "flat", + Self::Navigation => "flat", + Self::Informational => "flat", + Self::Structural => "flat", + } + } +} + +// --------------------------------------------------------------------------- +// Layout concepts +// --------------------------------------------------------------------------- + +/// Layout concept — how children are arranged. +/// +/// Platform mapping: +/// - `Stack` → VStack / UIStackView(vertical) / Column / StackPanel / GtkBox(vertical) +/// - `Row` → HStack / UIStackView(horizontal) / Row / StackPanel(horizontal) / GtkBox(horizontal) +/// - `Grid` → LazyVGrid / UICollectionView / LazyVerticalGrid / GridView +/// - `Overlay` → ZStack / UIView layering / Box / Canvas / GtkOverlay +/// - `Scroll` → ScrollView / UIScrollView / LazyColumn / ScrollViewer / GtkScrolledWindow +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LayoutConcept { + /// Vertical stack — children arranged top-to-bottom. + Stack, + /// Horizontal row — children arranged left-to-right. + Row, + /// Grid — children in a two-dimensional grid. + Grid, + /// Overlay — children layered on the z-axis. + Overlay, + /// Scrollable container — overflowing children are scrollable. + Scroll, +} + +impl LayoutConcept { + /// HTML/CSS flex direction for the web/server targets. + pub fn css_flex_direction(&self) -> &'static str { + match self { + Self::Stack => "column", + Self::Row => "row", + Self::Grid => "row wrap", // use CSS grid in practice + Self::Overlay => "row", // position: relative + absolute children + Self::Scroll => "column", + } + } + + /// AppKit stack view orientation. + pub fn appkit_orientation(&self) -> &'static str { + match self { + Self::Stack | Self::Grid | Self::Overlay | Self::Scroll => "NSUserInterfaceLayoutOrientation::vertical", + Self::Row => "NSUserInterfaceLayoutOrientation::horizontal", + } + } + + /// UIKit stack view axis. + pub fn uikit_axis(&self) -> &'static str { + match self { + Self::Stack | Self::Grid | Self::Overlay | Self::Scroll => "NSLayoutConstraint.Axis.vertical", + Self::Row => "NSLayoutConstraint.Axis.horizontal", + } + } + + /// Jetpack Compose layout composable. + pub fn compose_layout(&self) -> &'static str { + match self { + Self::Stack => "Column", + Self::Row => "Row", + Self::Grid => "LazyVerticalGrid", + Self::Overlay => "Box", + Self::Scroll => "LazyColumn", + } + } + + /// WinUI panel type. + pub fn winui_panel(&self) -> &'static str { + match self { + Self::Stack => "Microsoft.UI.Xaml.Controls.StackPanel", + Self::Row => "Microsoft.UI.Xaml.Controls.StackPanel", // Orientation=Horizontal + Self::Grid => "Microsoft.UI.Xaml.Controls.Grid", + Self::Overlay => "Microsoft.UI.Xaml.Controls.Canvas", + Self::Scroll => "Microsoft.UI.Xaml.Controls.ScrollViewer", + } + } + + /// GTK widget type. + pub fn gtk_widget(&self) -> &'static str { + match self { + Self::Stack => "GtkBox", // orientation=vertical + Self::Row => "GtkBox", // orientation=horizontal + Self::Grid => "GtkGrid", + Self::Overlay => "GtkOverlay", + Self::Scroll => "GtkScrolledWindow", + } + } +} + +// --------------------------------------------------------------------------- +// Text role concepts +// --------------------------------------------------------------------------- + +/// Text role concept — the semantic purpose of a piece of text. +/// +/// Platform mapping: +/// - `Heading` → large/bold system font at the appropriate level +/// - `Body` → default body text style +/// - `Caption` → small secondary text (subtitles, footnotes) +/// - `Label` → paired with a control — describes it +/// - `Code` → monospaced font, code block style +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextConcept { + /// Document heading at the given level (1 = most prominent, 6 = least). + Heading { level: u8 }, + /// Default body text. + Body, + /// Small secondary text — captions, footnotes, helper text. + Caption, + /// A control label — paired with an input or button. + Label, + /// Monospaced code text. + Code, +} + +impl TextConcept { + /// HTML tag for the server/web targets. + pub fn html_tag(&self) -> String { + match self { + Self::Heading { level } => format!("h{}", level.clamp(&1, &6)), + Self::Body => "p".to_string(), + Self::Caption => "small".to_string(), + Self::Label => "label".to_string(), + Self::Code => "code".to_string(), + } + } + + /// AppKit font preset. + pub fn appkit_font(&self) -> &'static str { + match self { + Self::Heading { level: 1 } => "NSFont.systemFont(ofSize: 28, weight: .bold)", + Self::Heading { level: 2 } => "NSFont.systemFont(ofSize: 22, weight: .semibold)", + Self::Heading { .. } => "NSFont.systemFont(ofSize: 17, weight: .medium)", + Self::Body => "NSFont.systemFont(ofSize: 13)", + Self::Caption => "NSFont.systemFont(ofSize: 11, weight: .light)", + Self::Label => "NSFont.systemFont(ofSize: 13)", + Self::Code => "NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)", + } + } + + /// UIKit font preset. + pub fn uikit_font(&self) -> &'static str { + match self { + Self::Heading { level: 1 } => "UIFont.preferredFont(forTextStyle: .largeTitle)", + Self::Heading { level: 2 } => "UIFont.preferredFont(forTextStyle: .title1)", + Self::Heading { level: 3 } => "UIFont.preferredFont(forTextStyle: .title2)", + Self::Heading { .. } => "UIFont.preferredFont(forTextStyle: .headline)", + Self::Body => "UIFont.preferredFont(forTextStyle: .body)", + Self::Caption => "UIFont.preferredFont(forTextStyle: .caption1)", + Self::Label => "UIFont.preferredFont(forTextStyle: .callout)", + Self::Code => "UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)", + } + } + + /// Material 3 text style for Jetpack Compose. + pub fn compose_text_style(&self) -> &'static str { + match self { + Self::Heading { level: 1 } => "MaterialTheme.typography.headlineLarge", + Self::Heading { level: 2 } => "MaterialTheme.typography.headlineMedium", + Self::Heading { level: 3 } => "MaterialTheme.typography.headlineSmall", + Self::Heading { .. } => "MaterialTheme.typography.titleLarge", + Self::Body => "MaterialTheme.typography.bodyLarge", + Self::Caption => "MaterialTheme.typography.labelSmall", + Self::Label => "MaterialTheme.typography.labelMedium", + Self::Code => "MaterialTheme.typography.bodyMedium", // monospace font family + } + } +} + +// --------------------------------------------------------------------------- +// Semantic primitives +// --------------------------------------------------------------------------- + +/// A semantic UI primitive — CONCEPT, not visual element. +/// +/// Each variant describes *what* the element is, not *how* it looks. +/// The platform codegen translates these into native API calls. +/// +/// These are the building blocks; the platform backends decide the visual +/// expression. A `Button { appearance: Destructive }` looks different on +/// macOS (red-tinted rounded button) vs Android (error-coloured filled button) +/// but expresses the same concept. +#[derive(Debug, Clone, PartialEq)] +pub enum SemanticPrimitive { + /// An interactive button — triggers an action on press. + Button { + /// Visible label text. + label: String, + /// What kind of action this button represents. + appearance: AppearanceConcept, + /// Handler name from the component's method table, if any. + on_press: Option, + }, + + /// A text display element. + Text { + /// The content to display. + content: String, + /// The semantic role of this text. + concept: TextConcept, + }, + + /// A layout container with child primitives. + Container { + /// Child primitives, in render order. + children: Vec, + /// How children are arranged. + layout: LayoutConcept, + }, + + /// A text input field bound to a state variable. + Input { + /// State variable name this field is bound to (two-way). + binding: String, + /// Placeholder / hint text shown when empty. + hint: Option, + /// Visual role of this input. + appearance: AppearanceConcept, + }, + + /// An image element. + Image { + /// Source URI or asset name. + src: String, + /// Accessibility description. + alt: String, + }, + + /// A toggle/checkbox control bound to a boolean state variable. + Toggle { + /// State variable name (must be Bool). + binding: String, + /// Label displayed alongside the toggle. + label: String, + }, + + /// A list of homogeneous items, each rendered by the same child primitive. + List { + /// Items to iterate over — a state variable or expression name. + items_binding: String, + /// The name given to each item within the child template. + item_name: String, + /// The primitive template applied to each item. + item_template: Box, + }, +} + +impl SemanticPrimitive { + /// Convenience constructor for a primary action button. + pub fn action_button(label: impl Into, on_press: impl Into) -> Self { + Self::Button { + label: label.into(), + appearance: AppearanceConcept::Action, + on_press: Some(on_press.into()), + } + } + + /// Convenience constructor for a destructive action button. + pub fn destructive_button(label: impl Into, on_press: impl Into) -> Self { + Self::Button { + label: label.into(), + appearance: AppearanceConcept::Destructive, + on_press: Some(on_press.into()), + } + } + + /// Convenience constructor for a heading. + pub fn heading(content: impl Into, level: u8) -> Self { + Self::Text { + content: content.into(), + concept: TextConcept::Heading { level }, + } + } + + /// Convenience constructor for body text. + pub fn body_text(content: impl Into) -> Self { + Self::Text { + content: content.into(), + concept: TextConcept::Body, + } + } + + /// Convenience constructor for a vertical stack container. + pub fn stack(children: Vec) -> Self { + Self::Container { + children, + layout: LayoutConcept::Stack, + } + } + + /// Convenience constructor for a horizontal row container. + pub fn row(children: Vec) -> Self { + Self::Container { + children, + layout: LayoutConcept::Row, + } + } +} + +// --------------------------------------------------------------------------- +// Semantic extraction from AST +// --------------------------------------------------------------------------- + +/// Map an HTML tag + appearance attribute from the AST into a `SemanticPrimitive`. +/// +/// This is the bridge from the parser's `TemplateNode` representation to the +/// semantic layer. The compiler calls this during its semantic analysis pass. +/// +/// Appearance is inferred from: +/// 1. An explicit `appearance="..."` attribute on the element. +/// 2. The element's tag + class names (heuristic fallback). +pub fn infer_appearance(tag: &str, class: Option<&str>, explicit: Option<&str>) -> AppearanceConcept { + // Explicit attribute wins. + if let Some(a) = explicit { + return match a { + "action" | "primary" => AppearanceConcept::Action, + "destructive" | "danger" | "delete" => AppearanceConcept::Destructive, + "secondary" | "subdued" => AppearanceConcept::Secondary, + "navigation" | "nav" | "link" => AppearanceConcept::Navigation, + "informational" | "info" | "display" => AppearanceConcept::Informational, + "structural" | "layout" => AppearanceConcept::Structural, + _ => AppearanceConcept::Action, + }; + } + + // Infer from class names. + if let Some(cls) = class { + if cls.contains("danger") || cls.contains("destructive") || cls.contains("delete") { + return AppearanceConcept::Destructive; + } + if cls.contains("secondary") || cls.contains("outline") || cls.contains("ghost") { + return AppearanceConcept::Secondary; + } + if cls.contains("nav") || cls.contains("link") { + return AppearanceConcept::Navigation; + } + } + + // Tag-level heuristic fallback. + match tag { + "button" => AppearanceConcept::Action, + "a" => AppearanceConcept::Navigation, + "input" | "textarea" | "select" => AppearanceConcept::Action, + "div" | "section" | "main" | "article" | "aside" | "header" | "footer" => { + AppearanceConcept::Structural + } + "span" | "p" | "label" | "small" => AppearanceConcept::Informational, + _ => AppearanceConcept::Structural, + } +} + +/// Infer a `TextConcept` from an HTML tag name. +pub fn infer_text_concept(tag: &str) -> TextConcept { + match tag { + "h1" => TextConcept::Heading { level: 1 }, + "h2" => TextConcept::Heading { level: 2 }, + "h3" => TextConcept::Heading { level: 3 }, + "h4" => TextConcept::Heading { level: 4 }, + "h5" => TextConcept::Heading { level: 5 }, + "h6" => TextConcept::Heading { level: 6 }, + "label" => TextConcept::Label, + "small" | "caption" => TextConcept::Caption, + "code" | "pre" | "kbd" | "samp" => TextConcept::Code, + _ => TextConcept::Body, + } +} + +/// Infer a `LayoutConcept` from an HTML tag name and optional class names. +pub fn infer_layout_concept(tag: &str, class: Option<&str>) -> LayoutConcept { + if let Some(cls) = class { + if cls.contains("row") || cls.contains("horizontal") || cls.contains("flex-row") { + return LayoutConcept::Row; + } + if cls.contains("grid") { + return LayoutConcept::Grid; + } + if cls.contains("overlay") || cls.contains("stack-z") { + return LayoutConcept::Overlay; + } + if cls.contains("scroll") { + return LayoutConcept::Scroll; + } + } + + match tag { + "ul" | "ol" => LayoutConcept::Scroll, // scrollable list + "nav" => LayoutConcept::Row, + _ => LayoutConcept::Stack, // default: vertical stack + } +} diff --git a/crates/el-ui-compiler/src/tests.rs b/vessels/el-ui-compiler/src/tests.rs similarity index 100% rename from crates/el-ui-compiler/src/tests.rs rename to vessels/el-ui-compiler/src/tests.rs