3 Commits

Author SHA1 Message Date
Will Anderson c5e34ed09b snapshot before rust archive 2026-05-03 04:04:43 -05:00
Will Anderson f4abfe6fdc feat: rename crates/ → vessels/ + add El ports per sub-vessel
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/.
2026-04-30 18:18:39 -05:00
Will Anderson f09803c317 Replace el.toml with manifest.el throughout — El manifests are El, not TOML 2026-04-29 22:48:36 -05:00
149 changed files with 6427 additions and 926 deletions
+12 -12
View File
@@ -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"
-581
View File
@@ -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<String> {
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<String> {
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<String> {
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<String> {
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 <platform>\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<String> {
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<String> {
let mut out = String::new();
let params: Vec<String> = 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<String> {
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<String> {
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<String> = 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<String> {
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<String> {
// 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<String> = 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::<i64>().is_ok() { return s.to_owned(); }
if s.parse::<f64>().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::<String>() + c.as_str(),
}
}
fn escape_attr(s: &str) -> String {
s.replace('"', "&quot;").replace('\'', "&#39;")
}
-3
View File
@@ -56,6 +56,3 @@ class App extends Component {
}
export { Counter, App };
// Mount the app
mount(App, '#app');
+5 -5
View File
@@ -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" }
+2 -2
View File
@@ -5,7 +5,7 @@
//! - Styling via semantic tokens and the StyleModifier trait
//! - Responsive layout: VStack/HStack that wrap automatically
//! - Localization via LocaleContext and t()/t_plural()
//! - Configuration from el.toml / env vars
//! - Configuration from manifest.el / env vars
//! - Secrets that never appear in logs
use std::collections::HashMap;
@@ -183,7 +183,7 @@ app.debug = "true"
"#;
let toml_source = load_from_toml(el_toml, &Environment::Development)
.expect("el.toml should be valid");
.expect("manifest.el should be valid");
let mut config = Config::new(Environment::Development);
config.push_source(Box::new(toml_source));
+425 -302
View File
@@ -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}`:
```
<h1>{count}</h1>
@@ -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}`:
```
<button on:click={() => count = count + 1}>+</button>
@@ -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.
```
<button disabled={isDisabled}>Submit</button>
<input checked={isChecked} />
```
Boolean attributes emit the attribute name if the expression is truthy, nothing if falsy.
A standalone attribute with no value (e.g., `<button disabled>`) is also parsed and treated as `BoolAttr { expr: "true" }`.
### 3.6 Component Usage
### 3.6 Component Invocation
Components are invoked by their name (uppercase first letter). Props are passed as attributes:
Uppercase-initial tags are el-ui components; lowercase tags are HTML elements:
```
<Button label="Click me" variant="primary" onClick={() => handleClick()} />
<Counter />
<Counter initialValue={5} />
<UserCard name={currentUser.name} />
```
Lowercase tags are HTML elements. Uppercase tags are el-ui components.
The compiler emits `${__self._child(ComponentClass, { prop: value })}` for component references. Child components share the parent's activation graph via `_child()`.
### 3.7 Conditional Rendering — `{#if}`
```
{#if condition}
<div>shown when true</div>
{/if}
{#if loggedIn}
<Dashboard />
{:else}
@@ -205,23 +232,19 @@ Lowercase tags are HTML elements. Uppercase tags are el-ui components.
{/if}
```
The condition is any JavaScript-compatible boolean expression.
Compiles to a ternary template literal expression: `${(condition) ? `...` : `...`}`.
### 3.8 List Rendering — `{#each}`
```
{#each items as item}
<li>{item}</li>
{/each}
{#each users as user}
<UserCard name={user.name} />
{/each}
```
The items expression must evaluate to an array.
The `items` expression must evaluate to an array. Compiles to `${(items).map((user) => `...`).join('')}`.
### 3.9 Semantic Activation — `{#activate}` (The Novel Construct)
### 3.9 Semantic Activation — `{#activate}` (Novel Construct)
```
{#activate "recent premium subscribers" as results}
@@ -229,350 +252,450 @@ The items expression must evaluate to an array.
{/activate}
```
This is el-ui's distinguishing feature. `{#activate}` runs a semantic query against the application's state graph at render time. The query string is a natural language description. The runtime:
`{#activate}` runs a semantic query against the component's state graph at render time. The query string is a natural language description. The runtime:
1. Searches the graph for nodes whose content or name matches the query.
2. Sorts results by their activation score (importance × text match score).
3. Renders the template body once for each result, binding the result as `results`.
1. Calls `this._graph.search("query")` which performs a string-based substring match on node `name` and `content` fields.
2. Sorts results by score (combination of name/content match and node importance).
3. Renders the template body once for each result, binding the result as the variable name.
In v0.1, the search is string-based. In future versions, it will use embedding vectors and cosine similarity — the same semantic search engine as the Engram knowledge database. This means state queries become conceptual rather than structural: instead of `items.filter(i => i.type === 'premium')`, you write `{#activate "premium subscribers"}` and the activation surface finds semantically matching nodes.
Compiles to:
```javascript
${((this._graph.search("query")) || []).map((results) => `...template...`).join('')}
```
In v0.1, the search is string-based (substring match on `name` and `content.toString()`). In future versions connected to an Engram database, `{#activate}` will use embedding vectors and cosine similarity.
**Note on business logic protection:** The spec previously described AES-256-GCM encryption of `{#activate}` query strings in sealed production bundles. This feature is **not implemented** — there is no `--target prod` flag in the current CLI and no `el seal` command.
---
## 4. The State Graph
## 4. State Graph
### 4.1 Graph Structure
Each component instance owns an Engram graph (`this._graph`). State nodes are seeded at construction time. The graph is shared between a component and its children (they operate on the same activation surface).
Each component instance owns an Engram graph (`this._graph`). State nodes are seeded at construction. The graph is shared between a component and its children via `_child()`they operate on the same activation surface.
### 4.2 Node Types
| Type | Description |
|------|-------------|
| `'state'` | Component state variable |
| `'route'` | Router path node |
| User-defined | Any custom node type via `this._graph.seed()` |
### 4.3 Seeding Nodes
### 4.2 State Node Creation
```javascript
const nodeId = graph.seed({ type: 'state', name: 'count', content: 0, importance: 0.5 });
const nodeId = graph.seed({ type: 'state', name: 'count', content: 0 });
// importance defaults to 0.5 if omitted
```
Returns a UUID string that permanently identifies the node.
### 4.4 Connecting Nodes
### 4.3 Edge Creation
```javascript
graph.connect(fromId, toId, { weight: 0.8, relation: 'derived' });
```
Connects two nodes with a directed edge. Higher weight = stronger activation path. Use this to encode semantic relationships between state (e.g., a derived value has an edge from its source).
Connects two nodes with a directed edge. Higher weight = stronger activation path. Default weight is `1.0`, default relation is `'related'`.
### 4.5 Subscribing to Activation
### 4.4 Node Retrieval
```javascript
const node = graph.get(nodeId);
// Returns: { id, type, name, content, importance, edges: [edgeId, ...] }
```
Note: the runtime method is `graph.get(id)`, not `graph.getNode(id)`.
### 4.5 State Update
```javascript
this.setState('count', newValue);
// or directly:
this._graph.update(nodeId, newValue);
```
`setState` looks up the node by state variable name, calls `this._graph.update()` which:
1. Updates `content`.
2. Increments `importance` by 0.1 (capped at 1.0).
3. Runs spreading activation from the changed node.
4. Notifies all subscribers in the activation surface.
5. Notifies direct subscribers on the changed node.
### 4.6 Activation Subscription
```javascript
const unsubscribe = graph.subscribe(nodeId, (node) => {
console.log('node activated:', node.content);
// called whenever this node enters the activation surface
});
// Later:
unsubscribe();
unsubscribe(); // stop listening
```
### 4.6 Semantic Search
Components subscribe to their state nodes in the constructor. When spreading activation reaches a subscribed node, the subscriber callback fires and the component re-renders via `this._renderer.patch()`.
### 4.7 Semantic Search
```javascript
const results = graph.search("recent items", "state");
// Returns array of { id, name, content, importance, score } sorted by score
// Returns: [{ id, type, name, content, importance, score }] sorted by score
// nodeType argument is optional — filters by node.type if provided
```
In v0.1, search is string-based: `name.includes(query)` or `content.toString().includes(query)`. Score is computed as `(nameMatch ? 0.6 : 0) + (contentMatch ? 0.4 : 0)` multiplied by `node.importance`.
### 4.8 Graph Dump (Debug)
```javascript
const allNodes = graph.dump();
// Returns: array of all node objects — useful for DevTools / debugging
```
---
## 5. The `{#activate}` Construct — Semantic State Queries
## 5. Spreading Activation Implementation (In-Browser)
### 5.1 Philosophy
Traditional reactivity systems force you to structure your state queries around your storage schema. If you need "all premium users with recent activity", you write:
The in-browser spreading activation algorithm in `graph.js` (and standalone utilities in `activation.js`) mirrors `engram-core/src/activation.rs`:
```javascript
users.filter(u => u.isPremium && u.lastActive > threshold)
// Within graph.activate(seedId, maxDepth = 3, pruneThreshold = 0.01)
// Returns: Set<string> of activated node IDs
while (queue.length > 0) {
// Best-first: process highest-strength candidate
const { id, strength, depth } = bestInQueue();
if (depth >= maxDepth) continue;
for (const edgeId of node.edges) {
const edge = this.edges.get(edgeId);
const target = this.nodes.get(edge.to);
// Multiplicative formula
const targetStrength = strength * edge.weight * Math.max(0, target.importance);
if (targetStrength <= pruneThreshold) continue;
// Winner-take-most
if (targetStrength > prevBest) { /* enqueue */ }
}
}
```
This is structural — it knows the shape of your data.
**Key parameters:**
- `maxDepth` default: **3** (not 4)
- `pruneThreshold` default: **0.01**
- `limit` default: **20** (applies to `spreadActivation()` utility; `graph.activate()` returns a Set with no limit)
el-ui's `{#activate}` is semantic — it knows the *meaning* of your query:
**Correspondence with engram-core:**
- Same best-first BFS traversal
- Same multiplicative strength formula
- Same pruning threshold semantics (0.01)
- Same winner-take-most rule (strongest path to each node wins)
- Same depth limit semantics
```
{#activate "premium users with recent activity" as users}
```
The in-browser graph omits the `cosine_sim` factor because embedding vectors are not yet available in the browser.
The activation engine finds nodes that semantically match the query, regardless of their exact structure.
### 5.2 Compilation
`{#activate "query" as results}` compiles to:
### 5.1 Standalone Activation Utilities (`activation.js`)
```javascript
${(this._graph.search("query") || []).map((results) => `...template...`).join('')}
import { spreadActivation, activationStrength, reachableNodes, PRUNE_THRESHOLD } from './el-ui.js';
// Multi-seed activation — returns sorted array of reached nodes (excluding seeds)
const reached = spreadActivation(graph, [seedId1, seedId2], { maxDepth: 3, limit: 20 });
// Returns: [{ nodeId, strength, hops, node }]
// Point-to-point strength — returns 0 if no path within maxDepth
const strength = activationStrength(graph, fromId, toId, maxDepth);
// All reachable nodes from a seed (includes seed) — returns Set<string>
const reachable = reachableNodes(graph, seedId, maxDepth);
```
### 5.3 Future: Full Semantic Activation
In a future version connected to an Engram database, `{#activate}` will:
1. Embed the query string into the same vector space as node embeddings.
2. Seed activation at the closest semantic nodes.
3. Spread outward via the graph's weighted edges.
4. Return all nodes above the activation threshold.
This produces a truly associative query: finding nodes that are *semantically related* to the query, not just textually matching.
---
## 6. Router — Graph-Based Routing
### 6.1 Overview
Routes are nodes in the application graph. Navigation activates the target route node. Components subscribed to routing update automatically via spreading activation.
Routes are nodes in the application graph of type `'route'`. Navigation activates the target route node. Components subscribed to routing update automatically via spreading activation.
### 6.2 Usage
Route nodes start with `importance: 0.3`. Navigating to a route boosts its importance by `0.2` (capped at 1.0), modeling browser history salience.
```javascript
import { Router, mount } from './el-ui.js';
import { Home, About, NotFound } from './app.js';
### 6.2 Route Node Connections
const graph = component._graph;
const router = new Router(graph, {
'/': Home,
'/about': About,
'*': NotFound,
});
The router connects parent routes to child routes via edges (weight: 0.8, relation: `'subroute'`). Navigating to `/about/team` activates the `/about` route node as well — activation spreads upward through the subroute chain.
// Navigate programmatically
router.navigate('/about');
// Get current component
const CurrentPage = router.currentComponent();
// Subscribe to route changes
const unsub = router.subscribe((path) => console.log('navigated to', path));
```
### 6.3 Route Node Connections
The router connects parent routes to child routes via edges (weight: 0.8, relation: `'subroute'`). Navigating to `/about/team` activates the `/about` route node as well (activation spreads upward through the subroute chain).
### 6.4 Path Matching
### 6.3 Path Matching
1. Exact match: `/about` matches `/about`
2. Prefix match (longest wins): `/about` matches `/about/team`
3. Wildcard: `'*'` catches all unmatched paths
### 6.4 API
```javascript
const router = new Router(graph, {
'/': HomeComponent,
'/about': AboutComponent,
'*': NotFoundComponent,
});
router.navigate('/about');
router.navigate('/about', true); // replaceState instead of pushState
const CurrentPage = router.currentComponent();
const unsub = router.subscribe((path) => console.log('navigated to', path));
const href = router.href('/about'); // returns the path string (identity in v0.1)
```
The router also listens for `popstate` events (browser back/forward navigation) and activates the appropriate route node automatically.
---
## 7. Plugin API
## 7. Compilation Pipeline
### 7.1 Custom Node Types
### 7.1 Overview
Extend the graph with application-specific node types:
```
source.el → [Lexer] → [Parser] → [Codegen] → output
```
The output format depends on the compilation target (see §7.4).
### 7.2 Lexer
The lexer (`src/lexer.rs`) is a single-pass O(n) tokenizer. It is context-sensitive: when it encounters the `template` keyword followed by `{`, it switches to template mode, producing template-specific tokens:
| Token | Description |
|-------|-------------|
| `HashIdent(kw)` | `{#if}`, `{#each}`, `{#activate}` — block open |
| `SlashIdent(kw)` | `{/if}`, `{/each}`, `{/activate}` — block close |
| `ColonIdent(kw)` | `{:else}` — block continuation |
| `OnColon(event)` | `on:click`, `on:input` — event binding |
| `SelfClose` | `/>` |
| `CloseTag(name)` | `</div>` |
| `RawText(s)` | Inline expression text, text content |
Non-template code tokens include keywords (`component`, `props`, `state`, `fn`, `template`, `if`, `else`, `return`), identifiers, literals (`String`, `Int`, `Float`, `Bool`), operators, and punctuation.
### 7.3 Parser
Hand-written recursive descent parser. Produces `Vec<Component>`. Each component is parsed as:
1. `component Name {` — component declaration
2. `props { ... }` — prop definitions (optional)
3. `state { ... }` — state definitions (optional)
4. `fn method(...) -> Type { ... }` — methods (optional, multiple allowed)
5. `template { ... }` — template tree (optional)
The template parses as a tree of `TemplateNode` values:
| Variant | Description |
|---------|-------------|
| `Element { tag, attrs, children }` | HTML element with attributes and children |
| `Component { name, props }` | Uppercase-initial component reference |
| `Text(s)` | Literal text content |
| `Interpolation(expr)` | `{expr}` — expression interpolation |
| `If { condition, then, else_ }` | `{#if}...{:else}...{/if}` |
| `Each { items, item_name, children }` | `{#each items as item}...{/each}` |
| `Activate { query, result_name, children }` | `{#activate "query" as name}...{/activate}` |
Method bodies are captured as raw source text and passed through to the code generator with simple string transformations.
**Unknown `{#blockname}` tags** produce a parse error (`unknown block tag: #name`).
### 7.4 Code Generator — Multi-Target
The code generator (`src/codegen.rs`) supports three compilation targets:
#### `CodegenTarget::Web` (default) — Legacy JavaScript
Emits an ES2022 JavaScript module using the el-ui JS runtime (`graph.js`, `renderer.js`, `router.js`, `index.js`). This is the primary target for browser-based applications today.
For each component:
1. Emits a class extending `Component` from the runtime.
2. Constructor: seeds state nodes into `this._graph`, sets prop values as `this._props_{name}`, subscribes to activation events.
3. Emits `setState(name, value)` which calls `this._graph.update()` to trigger activation.
4. Emits `render()` returning a template literal string.
5. Translates state assignments: `count = count + 1``__self.setState('count', count + 1)`.
6. Translates template interpolations: `{count}``${count }`.
7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind.
8. Emits `${__self._child(ComponentClass, props)}` for component references.
#### `CodegenTarget::Server` — Rust SSR
Emits a Rust module where each component is a struct implementing `render_to_html(&self) -> String` via `el_platform::ServerBackend`.
**Note:** The `build_node_tree()` implementation in this target is a stub — it returns a placeholder `PlatformNode::element("div")`. Full template-to-PlatformNode codegen is not yet implemented for the server target.
#### `CodegenTarget::Native(Platform)` — Rust Native / WASM
Emits a Rust module where each component builds a `PlatformNode` tree via the semantic primitive layer and mounts it via the `el_platform` backend for the given platform.
Supported platforms:
| Platform | Backend | Output format |
|----------|---------|---------------|
| `Platform::Web` | `WebBackend` (web-sys) | Rust compiled to WASM — NOT JavaScript |
| `Platform::Ios` | `IosBackend` | Rust (objc2-ui-kit) |
| `Platform::Android` | `AndroidBackend` | Kotlin (Jetpack Compose via build.rs) |
| `Platform::Macos` | `MacosBackend` | Rust (objc2-app-kit) |
| `Platform::Linux` | `LinuxBackend` | Rust (gtk4-rs) |
| `Platform::Windows` | `WindowsBackend` | Rust (windows-rs / WinUI 3) |
**Note:** The `build_node_tree()` implementation in all native targets is currently a stub returning `PlatformNode::element("div")`. Full AST-to-semantic-primitive lowering is not yet wired in — the semantic module and per-platform codegen functions exist and are correct, but the compiler does not yet call them from the AST walk.
### 7.5 Semantic Primitive Layer (`semantic.rs`)
The semantic layer is the bridge between the template AST and platform-specific code generation. It defines EBD-driven concepts — *what* something is, not *how* it looks:
**AppearanceConcept** — what a control is:
- `Action` — primary action trigger (blue/filled on most platforms)
- `Destructive` — dangerous/irreversible action (red tint)
- `Secondary` — subdued / less-prominent action
- `Navigation` — link-like / back-navigation element
- `Informational` — read-only display element
- `Structural` — layout container with no interactive meaning
**LayoutConcept** — how children are arranged:
- `Stack` — vertical (Column / VStack / GtkBox vertical)
- `Row` — horizontal (Row / HStack / GtkBox horizontal)
- `Grid` — two-dimensional grid
- `Overlay` — z-axis layering (ZStack / GtkOverlay)
- `Scroll` — scrollable container
**TextConcept** — the semantic role of text:
- `Heading { level: u8 }` — heading at level 16
- `Body` — default body text
- `Caption` — small secondary text
- `Label` — control label
- `Code` — monospaced code text
**SemanticPrimitive** — the EBD building blocks:
- `Button { label, appearance, on_press }` — interactive button
- `Text { content, concept }` — text display
- `Container { children, layout }` — layout container
- `Input { binding, hint, appearance }` — text input bound to state
- `Image { src, alt }` — image element
- `Toggle { binding, label }` — checkbox/switch bound to boolean state
- `List { items_binding, item_name, item_template }` — homogeneous list
Appearance is inferred from explicit `appearance="..."` attributes, CSS class names, or HTML tag heuristics via `infer_appearance()`. Layout and text concepts are inferred from tag names via `infer_layout_concept()` and `infer_text_concept()`.
### 7.6 CLI
```bash
el-ui-compiler App.el # compiles to App.js (Web target, default)
el-ui-compiler App.el -o app.js # explicit output path
```
The CLI always uses `CodegenTarget::Web`. Target selection (`--target server`, `--target ios`, etc.) is available as a `CodegenTarget` enum in the library API but is not exposed as a CLI flag in the current implementation.
---
## 8. Runtime Architecture
### 8.1 Module Structure
| Module | Purpose |
|--------|---------|
| `graph.js` | Engram graph: nodes, edges, activation, search, subscribe, dump |
| `activation.js` | Standalone activation utilities: `spreadActivation`, `activationStrength`, `reachableNodes` |
| `renderer.js` | DOM patching, event binding |
| `router.js` | Graph-based routing |
| `index.js` | `Component` base class, `mount()`, re-exports |
### 8.2 Component Lifecycle
| Lifecycle event | Description |
|-----------------|-------------|
| `constructor(props)` | Seeds state graph, subscribes to activation |
| `onMount()` | Called after first render and DOM attachment |
| `render()` | Returns HTML string; called on activation |
**Note:** `onDestroy()` is documented in prior drafts but is not implemented in the base `Component` class or the `Renderer`. It will not be called by the runtime.
### 8.3 Mounting
```javascript
import { mount } from './el-ui.js';
import { App } from './app.js';
const component = mount(App, '#app');
// Add a custom "user session" node
const sessionNodeId = component._graph.seed({
type: 'session',
name: 'currentUser',
content: { id: '123', name: 'Alice' },
importance: 0.9,
});
// Connect it to a state node for reactive updates
component._graph.connect(sessionNodeId, component._stateNodes['userId'], {
weight: 1.0,
relation: 'owns',
});
const component = mount(App, '#app', { optionalProp: 'value' });
// mount() returns the live Component instance
```
### 7.2 Custom Activation Sources
`mount` instantiates the root component, creates a `Renderer`, calls `renderer.mount()` which calls `render()`, sets `root.innerHTML`, binds events, then calls `onMount()`.
Trigger activation from any node, not just state updates:
### 8.4 DOM Patching Strategy (v0.1)
```javascript
import { spreadActivation } from './el-ui.js';
The renderer uses full string re-render on every state change: `root.innerHTML = component.render()`. The activated node set argument to `patch()` is accepted but unused — targeted patching is planned for v0.2.
// Run a custom activation query
const results = spreadActivation(graph, [myNodeId], {
maxDepth: 4,
limit: 10,
pruneThreshold: 0.05,
});
After patching, the renderer attempts to restore focus to the previously focused element by `id`.
results.forEach(({ nodeId, strength, node }) => {
console.log(`${node.name}: ${strength.toFixed(3)}`);
});
```
Event handlers are rebound after every patch by scanning the new DOM for `data-el-{event}` attributes. Handlers are compiled via `new Function('__self', ...)` — this requires a permissive Content Security Policy in v0.1.
### 7.3 Component Lifecycle Extension
---
```javascript
class MyComponent extends Component {
constructor(props) {
super(props);
// Seed custom nodes
this._externalData = this._graph.seed({
type: 'external',
name: 'fetchedData',
content: null,
importance: 0.7,
});
}
## 9. Platform Backend Architecture (`el-platform`)
onMount() {
// Side effects after first render
fetch('/api/data')
.then(r => r.json())
.then(data => {
this._graph.update(this._externalData, data);
});
}
The `el-platform` crate defines the `PlatformBackend` trait that all rendering backends implement:
render() {
const data = this._graph.get(this._externalData)?.content;
return data ? `<div>${data.title}</div>` : `<div>Loading...</div>`;
}
```rust
pub trait PlatformBackend: Send + Sync {
fn name(&self) -> &'static str;
fn create_element(&self, tag: &str) -> PlatformResult<PlatformNode>;
fn create_text(&self, content: &str) -> PlatformResult<PlatformNode>;
fn set_attribute(&self, node: &mut PlatformNode, name: &str, value: &str) -> PlatformResult<()>;
fn remove_attribute(&self, node: &mut PlatformNode, name: &str) -> PlatformResult<()>;
fn append_child(&self, parent: &mut PlatformNode, child: PlatformNode) -> PlatformResult<()>;
fn remove_child(&self, parent: &mut PlatformNode, child_index: usize) -> PlatformResult<()>;
fn replace_child(&self, parent: &mut PlatformNode, index: usize, new_child: PlatformNode) -> PlatformResult<()>;
fn bind_event(&self, node: &mut PlatformNode, event: &str, handler: EventHandler) -> PlatformResult<()>;
fn render_to_string(&self, node: &PlatformNode) -> PlatformResult<String>;
fn mount(&self, root: PlatformNode, container_id: &str) -> PlatformResult<()>;
fn patch(&self, old: &PlatformNode, new: &PlatformNode) -> PlatformResult<()>;
fn supports_ssr(&self) -> bool { false }
}
```
---
The `ServerBackend` is the only backend where `supports_ssr()` returns `true` and `render_to_string()` produces real HTML output. All other backends are platform-native.
## 8. Compilation Pipeline
Platform is selected via `manifest.el`:
### 8.1 Overview
```
source.el → [lexer] → [parser] → [codegen] → output.js
```
### 8.2 Lexer (`crates/el-ui-compiler/src/lexer.rs`)
The lexer is a single-pass, O(n) tokenizer. It is context-sensitive: when it encounters the `template` keyword followed by `{`, it switches to template mode, producing template-specific tokens:
| Token | Description |
|-------|-------------|
| `HashIdent(kw)` | `{#if}`, `{#each}`, `{#activate}` |
| `SlashIdent(kw)` | `{/if}`, `{/each}`, `{/activate}` |
| `ColonIdent(kw)` | `{:else}` |
| `OnColon(event)` | `on:click`, `on:input`, etc. |
| `SelfClose` | `/>` |
| `CloseTag(name)` | `</div>` |
| `RawText(s)` | Inline expression text, text content |
### 8.3 Parser (`crates/el-ui-compiler/src/parser.rs`)
Hand-written recursive descent. Produces `Vec<Component>`. Each component is parsed as:
1. `component Name {` — component declaration
2. `props { ... }` — prop definitions (optional)
3. `state { ... }` — state definitions (optional)
4. `fn method(...) -> Type { ... }` — methods (optional, multiple)
5. `template { ... }` — template tree (optional)
The template is parsed as a tree of `TemplateNode` values. HTML elements, component references, interpolations, and block directives are all represented as nodes in this tree.
### 8.4 Code Generator (`crates/el-ui-compiler/src/codegen.rs`)
The code generator transforms the AST into a JavaScript ES2022 module. For each component:
1. Emits a class extending `Component` from the runtime.
2. In the constructor: seeds state nodes into `this._graph`, subscribes to activation events.
3. Emits `setState()`, which calls `this._graph.update()` to trigger activation.
4. Emits a `render()` method returning a template literal string.
5. Translates state assignments in methods: `count = count + 1``this.setState('count', count + 1)`.
6. Translates template interpolations: `{count}``${count}`.
7. Emits event handlers as `data-el-{event}` attributes for the renderer to bind.
### 8.5 CLI
```bash
# Compile a single .el file
el-ui-compiler App.el -o app.js
# Default output: same name, .js extension
el-ui-compiler App.el # produces App.js
```toml
[platform]
target = "web" # web | server | ios | android | macos | linux | windows
ssr = true
```
---
## 9. Production Build — Quantum-Sealed via engram-crypto
## 10. Comparison to Other Frameworks
The production build pipeline follows the el sealed artifact format:
### 10.1 vs. React
```bash
# 1. Compile .el to .js
el-ui-compiler App.el -o app.js --target prod
React uses virtual DOM diffing: on every render, it constructs a virtual tree and diffs it against the previous tree to identify minimal DOM mutations. The unit of re-render is the component; re-render triggers are driven by `setState`, `useReducer`, or context changes.
# 2. Seal the JavaScript bundle
ENGRAM_SEAL_KEY=my-deploy-key el seal app.js -o app.sealed
```
el-ui does not compute a virtual DOM. The spreading activation pass determines the re-render set from the graph topology. State relationships that exist in the graph are automatically propagated; relationships that do not exist are not. There is no need to declare dependencies, use `useMemo`, or prevent unnecessary renders with `React.memo` — the graph structure encodes the dependency information directly.
The sealed artifact is an `ENGRAM01` sealed bundle (same format as the el production target):
### 10.2 vs. Vue
```
Offset Size Field
────── ────── ─────────────────────────────────────────
0 8 Magic: b"ENGRAM01"
8 2 Format version: u16 big-endian (currently 1)
10 * JSON: { algorithm_id, signature, encapsulated_key, nonce, ciphertext }
```
Vue uses a Proxy-based reactive system: state values are wrapped in reactive proxies, and reads from these proxies during render are tracked as dependencies. When a reactive value changes, all components that read it during their last render are re-rendered.
AES-256-GCM encryption. The key is derived from the deployment binding (environment variable, machine fingerprint, or none). Without the key, the bundle is indistinguishable from random bytes. No static analysis tool can extract the application logic, queries, or API keys from a sealed bundle.
el-ui's graph model is explicit: relationships between state nodes are declared via graph edges, not inferred from read tracking. This makes dependencies inspectable and modifiable at runtime. It also enables semantic queries (`{#activate}`) that find relevant state by meaning rather than by structural reference.
**Why "quantum-sealed":** AES-256 is quantum-resistant at 128-bit quantum security (Grover's algorithm provides only a quadratic speedup). The `algorithm_id` field is forward-compatible with ML-KEM when it stabilizes.
### 10.3 vs. Svelte
### 9.1 The `{#activate}` Query is Protected
Svelte uses compile-time analysis to determine which parts of the DOM update when which state changes. The compiler emits targeted DOM mutations. There is no runtime overhead for a virtual DOM or a reactive system.
A key benefit of production sealing: `{#activate}` query strings are embedded in the compiled bundle and encrypted with the application logic. Proprietary semantic queries (which encode business logic about how your application understands its data) are invisible to competitors who decompile your application.
el-ui uses a runtime spreading activation pass. This is more expensive than Svelte's compile-time approach for small, known-static state graphs. The advantage is dynamic state topology: new nodes and edges can be added at runtime without recompilation, enabling state graphs that evolve with program execution.
### 10.4 Core Differentiator
No prior framework uses Hebbian spreading activation as the re-render decision mechanism. The multiplicative activation formula, the self-seeded activation from a root node, the importance-boost on state change (LTP analog), and the semantic `{#activate}` query construct have no analogues in any shipping frontend framework as of April 2026.
---
## 10. Runtime Size Target
## 11. Versioning Roadmap
The `dist/el-ui.js` runtime targets **under 15KB minified and gzipped**. As of v0.1:
| Module | Purpose | ~Size |
|--------|---------|-------|
| `graph.js` | Engram graph (nodes, edges, activation, search, subscribe) | ~3KB |
| `activation.js` | Standalone activation utilities | ~1.5KB |
| `renderer.js` | DOM patching, event binding | ~2KB |
| `router.js` | Graph-based routing | ~1.5KB |
| `index.js` | Component base class, `mount()`, re-exports | ~1KB |
Total: ~9KB source, ~4KB minified+gzipped (estimated).
---
## 11. Versioning and Compatibility
el-ui follows semantic versioning.
- **v0.1.x** — Initial release. Full re-render on state change. String-based `{#activate}` search.
- **v0.2.x** — Targeted DOM patching (patch only nodes in the activation surface). `{#activate}` with embedding-based semantic search.
- **v0.3.x** — ML-KEM sealed artifacts. Engram database integration for compile-time semantic type checking of `{#activate}` queries.
- **v1.0.0** — Stable API. Full production sealing. LSP integration with spreading activation autocomplete.
---
## 12. Relationship to el
el-ui `.el` files share the `.el` extension with el source files. Components are specialized el modules — in a future version, an `.el` file can mix component definitions with el type definitions, constants, and utility functions in a single compilation unit.
The spreading activation algorithm in `graph.js` and `activation.js` faithfully mirrors `engram-core/src/activation.rs`:
- Same BFS-based traversal
- Same multiplicative strength formula
- Same pruning threshold semantics
- Same winner-take-most rule (strongest path to each node wins)
The in-browser graph is a lightweight implementation without Engram's full embedding vector machinery. In production, a WASM-compiled Engram core can replace the JavaScript graph entirely, enabling true semantic activation with cosine similarity over embedding vectors.
| Version | Key changes |
|---------|-------------|
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. JS (Web) target only via CLI. Native targets available via library API but `build_node_tree()` is stub. |
| v0.2.x | Targeted DOM patching (only nodes in activation surface). `{#activate}` with embedding-based semantic search. CLI `--target` flag for native targets. Full AST→semantic→PlatformNode lowering. |
| v0.3.x | WASM-compiled Engram core replacing JavaScript graph. Sealed artifact support. `onDestroy()` lifecycle. |
| v1.0.0 | Stable API. Full production sealing. LSP integration with spreading activation autocomplete. |
+22
View File
@@ -0,0 +1,22 @@
// el-aop Aspect-Oriented Programming for el-ui.
//
// Cross-cutting concerns as first-class language features. Decorators applied
// to components and methods. @authenticate is the default; @public is the
// explicit opt-out.
vessel "el-aop" {
version "0.1.0"
description "Decorators: @authenticate, @authorize, @cache, @rate_limit, ..."
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-auth "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+223
View File
@@ -0,0 +1,223 @@
// el-aop Aspect-Oriented Programming for el-ui.
//
// Each aspect has three advice points: before, after, around. The aspect
// chain is composed at compile time by the el-ui-compiler from decorators.
//
// The El runtime model uses tagged JSON for InvocationContext so an aspect
// chain composes purely by passing the context map through each advice fn.
//
// Built-in aspects:
// @authenticate defaults on every component (security-by-default)
// @public opt-out marker
// @authorize role/permission gate
// @cache TTL-keyed memoization
// @rate_limit per-principal token bucket
// @retry retry on error with backoff
// @log structured log around invocation
// @trace emit OpenTelemetry-shaped spans
// @validate JSON schema check on args
// Errors
let AOP_ERR_UNAUTHENTICATED: String = "aop.unauthenticated"
let AOP_ERR_FORBIDDEN: String = "aop.forbidden"
let AOP_ERR_RATE_LIMITED: String = "aop.rate_limited"
let AOP_ERR_VALIDATION: String = "aop.validation_failed"
let AOP_ERR_RETRIES_EXHAUSTED: String = "aop.retries_exhausted"
// Invocation context
//
// Stored as JSON: { target, method, args:{}, metadata:{} }
// All advice mutates by returning a new ctx (functional style).
fn ctx_new(target: String, method: String) -> String {
"{\"target\":\"" + target + "\",\"method\":\"" + method
+ "\",\"args\":{},\"metadata\":{}}"
}
fn ctx_with_arg(ctx: String, key: String, value: String) -> String {
json_set_path(ctx, "args." + key, "\"" + value + "\"")
}
fn ctx_with_meta(ctx: String, key: String, value: String) -> String {
json_set_path(ctx, "metadata." + key, "\"" + value + "\"")
}
fn ctx_get_meta(ctx: String, key: String) -> String {
json_get_path(ctx, "metadata." + key)
}
// Aspect dispatch table
//
// Each aspect is a triple of fn names: (before, around, after). The registry
// is a map from aspect name -> { before, around, after } JSON.
fn registry_new() -> String {
"{}"
}
fn registry_register(reg: String, name: String, before_fn: String, around_fn: String, after_fn: String) -> String {
let entry: String = "{\"before\":\"" + before_fn + "\",\"around\":\"" + around_fn
+ "\",\"after\":\"" + after_fn + "\"}"
json_set(reg, name, entry)
}
// @authenticate applied by default
fn aspect_authenticate_before(ctx: String) -> String {
let token: String = ctx_get_meta(ctx, "authorization")
if str_eq(token, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
let secret: String = env("JWT_SECRET")
let auth_ctx: String = auth_middleware(token, secret)
let user_id: String = json_get(auth_ctx, "user_id")
if str_eq(user_id, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
ctx_with_meta(ctx, "user_id", user_id)
}
// @public explicit opt-out marker
fn aspect_public_before(ctx: String) -> String {
ctx_with_meta(ctx, "public", "true")
}
// @authorize(role)
fn aspect_authorize_before(ctx: String, required_role: String) -> String {
let user_id: String = ctx_get_meta(ctx, "user_id")
if str_eq(user_id, "") {
return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED)
}
let roles_json: String = engram_edge_traverse(user_id, "has_role")
if !str_contains(roles_json, "\"" + required_role + "\"") {
return ctx_with_meta(ctx, "error", AOP_ERR_FORBIDDEN)
}
ctx
}
// @cache(ttl_seconds)
fn aspect_cache_around(ctx: String, ttl: Int, proceed: String) -> String {
let key: String = ctx_get_meta(ctx, "cache_key")
if str_eq(key, "") {
let target: String = json_get(ctx, "target")
let method: String = json_get(ctx, "method")
let args: String = json_get(ctx, "args")
let key = sha256_hex(target + ":" + method + ":" + args)
}
let cached: String = cache_get(key)
if !str_eq(cached, "") {
return ctx_with_meta(ctx, "result", cached)
}
// Caller invokes proceed(ctx) externally; we record the key for `after` to use.
ctx_with_meta(ctx, "cache_key", key)
}
fn aspect_cache_after(ctx: String, result: String, ttl: Int) -> String {
let key: String = ctx_get_meta(ctx, "cache_key")
if !str_eq(key, "") { cache_put(key, result, ttl) }
result
}
// @rate_limit(requests, per_seconds)
fn aspect_rate_limit_before(ctx: String, requests: Int, per_seconds: Int) -> String {
let principal: String = ctx_get_meta(ctx, "user_id")
if str_eq(principal, "") { let principal = ctx_get_meta(ctx, "ip") }
let bucket_key: String = "rl:" + json_get(ctx, "target") + ":" + principal
let allowed: Bool = rate_bucket_take(bucket_key, requests, per_seconds)
if !allowed { return ctx_with_meta(ctx, "error", AOP_ERR_RATE_LIMITED) }
ctx
}
// @retry(attempts, backoff_ms)
//
// retry is necessarily an `around` aspect it must own the loop.
fn aspect_retry_around(ctx: String, attempts: Int, backoff_ms: Int, proceed_fn_name: String) -> String {
let i: Int = 0
let result: String = ""
while i < attempts {
let result = call_dynamic(proceed_fn_name, ctx)
let err: String = json_get(result, "error")
if str_eq(err, "") { return result }
sleep_ms(backoff_ms * (i + 1))
let i = i + 1
}
ctx_with_meta(ctx, "error", AOP_ERR_RETRIES_EXHAUSTED)
}
// @log / @trace
fn aspect_log_before(ctx: String) -> String {
println("[aop] -> " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
ctx
}
fn aspect_log_after(ctx: String, result: String) -> String {
println("[aop] <- " + json_get(ctx, "target") + "." + json_get(ctx, "method"))
result
}
fn aspect_trace_before(ctx: String) -> String {
let span_id: String = uuid_v4()
ctx_with_meta(ctx, "span_id", span_id)
}
// @validate(schema)
fn aspect_validate_before(ctx: String, schema_json: String) -> String {
let args: String = json_get(ctx, "args")
let valid: Bool = json_schema_check(args, schema_json)
if !valid { return ctx_with_meta(ctx, "error", AOP_ERR_VALIDATION) }
ctx
}
// Aspect chain composition
//
// The compiler emits a call sequence like:
// ctx = ctx_new(...)
// ctx = aspect_authenticate_before(ctx)
// ctx = aspect_log_before(ctx)
// result = proceed(ctx)
// result = aspect_log_after(ctx, result)
// At runtime an explicit `chain_run` exists for dynamic composition.
fn chain_run(ctx: String, before_fns: String, around_fn: String, after_fns: String, proceed_fn: String) -> String {
let cur_ctx: String = ctx
// before chain
let i: Int = 0
let befs: String = before_fns
while !str_eq(befs, "") {
let comma: Int = str_index_of(befs, ",")
let fn_name: String = befs
if comma > 0 { let fn_name = str_slice(befs, 0, comma) }
let cur_ctx = call_dynamic(fn_name, cur_ctx)
let err: String = ctx_get_meta(cur_ctx, "error")
if !str_eq(err, "") { return cur_ctx }
if comma > 0 { let befs = str_slice(befs, comma + 1, str_len(befs)) }
if comma < 0 { let befs = "" }
}
// around / proceed
let result: String = call_dynamic(proceed_fn, cur_ctx)
// after chain (right-to-left composition; simplified left-to-right here)
let afts: String = after_fns
while !str_eq(afts, "") {
let comma: Int = str_index_of(afts, ",")
let fn_name: String = afts
if comma > 0 { let fn_name = str_slice(afts, 0, comma) }
let result = call_dynamic2(fn_name, cur_ctx, result)
if comma > 0 { let afts = str_slice(afts, comma + 1, str_len(afts)) }
if comma < 0 { let afts = "" }
}
result
}
// Entry smoke test
let ctx: String = ctx_new("ProfilePage", "render")
let ctx = ctx_with_arg(ctx, "user_id", "u-001")
println("[el-aop] ctx = " + ctx)
+22
View File
@@ -0,0 +1,22 @@
// el-auth Built-in authentication and authorization for el-ui.
//
// Not a library you add. Native to the framework. Sits on top of el-identity:
// el-identity owns the graph (users, sessions, OAuth tokens); el-auth owns the
// request-time enforcement (verify token, check permission, enforce roles).
vessel "el-auth" {
version "0.1.0"
description "Built-in auth/authz: JWT, sessions, RBAC, middleware"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-identity "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+201
View File
@@ -0,0 +1,201 @@
// el-auth Built-in authentication and authorization for el-ui.
//
// Engram-aware sessions: server-side invalidation works even with stateless
// JWTs because every session is also a graph node.
//
// Provider trait surface:
// verify(token) -> AuthContext
// issue(user, roles) -> token string
// revoke(token) -> Bool
// Errors
let ERR_INVALID_CREDS: String = "auth.invalid_credentials"
let ERR_TOKEN_EXPIRED: String = "auth.token_expired"
let ERR_TOKEN_INVALID: String = "auth.token_invalid"
let ERR_SESSION_NOT_FOUND: String = "auth.session_not_found"
let ERR_FORBIDDEN: String = "auth.forbidden"
let ERR_CONFIG: String = "auth.config"
// AuthContext + AuthUser
type AuthUser {
id: String
email: String
display_name: String
}
type AuthContext {
user_id: String
email: String
roles: String // JSON array of role names
permissions: String // JSON array of permission strings
session_id: String
issued_at: String
expires_at: String
}
fn auth_context_empty() -> AuthContext {
{ "user_id": "", "email": "", "roles": "[]", "permissions": "[]",
"session_id": "", "issued_at": "", "expires_at": "" }
}
fn auth_context_has_permission(ctx: AuthContext, perm: String) -> Bool {
str_contains(ctx.permissions, "\"" + perm + "\"")
}
fn auth_context_has_role(ctx: AuthContext, role: String) -> Bool {
str_contains(ctx.roles, "\"" + role + "\"")
}
// Roles + permissions
type Permission {
resource: String
action: String
}
fn permission_new(resource: String, action: String) -> Permission {
{ "resource": resource, "action": action }
}
fn permission_string(p: Permission) -> String {
p.resource + ":" + p.action
}
// Role registry maps role name -> JSON array of permission strings.
fn role_registry_grant(registry_path: String, role: String, perm: String) -> Bool {
let raw: String = fs_read(registry_path)
let updated: String = json_array_append(raw, role, "\"" + perm + "\"")
fs_write(registry_path, updated)
}
// JWT (HS256)
type JwtClaims {
sub: String // user id
iss: String // issuer
aud: String // audience
iat: Int // issued at (unix seconds)
exp: Int // expires at (unix seconds)
jti: String // unique token id
}
fn jwt_claims_new(user_id: String, issuer: String, audience: String, ttl_seconds: Int) -> JwtClaims {
let now: Int = time_now_unix()
{ "sub": user_id, "iss": issuer, "aud": audience,
"iat": now, "exp": now + ttl_seconds, "jti": uuid_v4() }
}
fn jwt_encode(claims: JwtClaims, secret: String) -> String {
let header_b64: String = base64url_no_pad("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")
let payload_json: String = json_encode(claims)
let payload_b64: String = base64url_no_pad(payload_json)
let signing_input: String = header_b64 + "." + payload_b64
let sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
signing_input + "." + sig
}
fn jwt_decode(token: String, secret: String) -> AuthContext {
let parts: String = token // [header].[payload].[sig]
let dot1: Int = str_index_of(parts, ".")
if dot1 < 0 { return auth_context_empty() }
let rest: String = str_slice(parts, dot1 + 1, str_len(parts))
let dot2: Int = str_index_of(rest, ".")
if dot2 < 0 { return auth_context_empty() }
let header_b64: String = str_slice(parts, 0, dot1)
let payload_b64: String = str_slice(rest, 0, dot2)
let sig_b64: String = str_slice(rest, dot2 + 1, str_len(rest))
let signing_input: String = header_b64 + "." + payload_b64
let expected_sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
if !str_eq(expected_sig, sig_b64) { return auth_context_empty() }
let payload_json: String = base64url_decode(payload_b64)
let now: Int = time_now_unix()
let exp: Int = str_to_int(json_get(payload_json, "exp"))
if exp < now { return auth_context_empty() }
{ "user_id": json_get(payload_json, "sub"),
"email": "",
"roles": "[]",
"permissions": "[]",
"session_id": json_get(payload_json, "jti"),
"issued_at": json_get(payload_json, "iat"),
"expires_at": json_get(payload_json, "exp") }
}
// Engram-backed session store
//
// Sessions are nodes of type "Session" connected to User via has_session.
// Revocation = node delete. Verification = node lookup + expiry check.
fn session_store_create(user_id: String, ttl_seconds: Int, ip: String) -> String {
let now: String = time_now_iso()
let exp: String = time_add_seconds(now, ttl_seconds)
let id: String = uuid_v4()
let body: String = "{\"id\":\"" + id + "\",\"user_id\":\"" + user_id
+ "\",\"created_at\":\"" + now + "\",\"expires_at\":\"" + exp
+ "\",\"ip_address\":\"" + ip + "\"}"
let node_id: String = engram_node_create("Session", body)
engram_edge_create(user_id, node_id, "has_session")
node_id
}
fn session_store_verify(session_id: String) -> Bool {
let raw: String = engram_node_get(session_id)
if str_eq(raw, "") { return false }
let exp: String = json_get(raw, "expires_at")
!time_after(time_now_iso(), exp)
}
fn session_store_revoke(session_id: String) -> Bool {
engram_node_delete(session_id)
}
// Middleware
//
// auth_middleware extracts the bearer token, decodes it, and populates the
// AuthContext. Applied automatically by the @authenticate aspect (el-aop).
fn extract_bearer(authorization_header: String) -> String {
if str_starts_with(authorization_header, "Bearer ") {
return str_slice(authorization_header, 7, str_len(authorization_header))
}
""
}
fn auth_middleware(authorization_header: String, jwt_secret: String) -> AuthContext {
let token: String = extract_bearer(authorization_header)
if str_eq(token, "") { return auth_context_empty() }
jwt_decode(token, jwt_secret)
}
fn enforce_permission(ctx: AuthContext, required_perm: String) -> Bool {
if str_eq(ctx.user_id, "") { return false }
auth_context_has_permission(ctx, required_perm)
}
// Provider issue/verify
fn provider_issue(user: AuthUser, jwt_secret: String, issuer: String, audience: String, ttl: Int) -> String {
let claims: JwtClaims = jwt_claims_new(user.id, issuer, audience, ttl)
jwt_encode(claims, jwt_secret)
}
fn provider_verify(token: String, jwt_secret: String) -> AuthContext {
let ctx: AuthContext = jwt_decode(token, jwt_secret)
if str_eq(ctx.user_id, "") { return ctx }
if !session_store_verify(ctx.session_id) { return auth_context_empty() }
ctx
}
fn provider_revoke(session_id: String) -> Bool {
session_store_revoke(session_id)
}
// Entry smoke test
let user: AuthUser = { "id": "u-001", "email": "will@neurontechnologies.ai", "display_name": "Will" }
let token: String = provider_issue(user, "test-secret", "el-ui", "el-app", 3600)
println("[el-auth] issued JWT for " + user.email)
@@ -1,7 +1,7 @@
//! Session provider — server-side sessions stored in memory.
//!
//! In production, sessions are stored in Redis or Engram (configured via
//! `session_store = "redis"` or `session_store = "engram"` in `el.toml`).
//! `session_store = "redis"` or `session_store = "engram"` in `manifest.el`).
//! This implementation uses in-memory storage for simplicity and testing.
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
+24
View File
@@ -0,0 +1,24 @@
// el-config Layered, typed configuration for el-ui applications.
//
// Resolution order (highest priority wins):
// 1. Environment variables (EL_APP_NAME=...)
// 2. .env file (development only)
// 3. manifest.el [env.<current>] section
// 4. manifest.el [config] base section
// 5. Defaults defined in code
vessel "el-config" {
version "0.1.0"
description "Layered, typed configuration with manifest + env + defaults"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
@@ -4,8 +4,8 @@
/// for a key wins. Resolution order:
/// 1. Environment variables (highest)
/// 2. .env file (dev only)
/// 3. el.toml [env.<current>] section
/// 4. el.toml [config] section (base)
/// 3. manifest.el [env.<current>] section
/// 4. manifest.el [config] section (base)
/// 5. Defaults defined in code (lowest)
use std::collections::HashMap;
@@ -129,7 +129,7 @@ impl Config {
}
}
/// Load config from an `el.toml` string.
/// Load config from an `manifest.el` string.
///
/// Reads `[config]` as the base, then overlays `[env.<environment>]`.
pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, ConfigError> {
@@ -155,7 +155,7 @@ pub fn load_from_toml(toml_str: &str, env: &Environment) -> Result<MapSource, Co
}
}
Ok(MapSource::from_map("el.toml", map))
Ok(MapSource::from_map("manifest.el", map))
}
fn flatten_toml_table(
@@ -4,8 +4,8 @@
//!
//! 1. Environment variables (`EL_APP_NAME=...`)
//! 2. `.env` file (development only)
//! 3. `el.toml` `[env.<current>]` section
//! 4. `el.toml` `[config]` base section
//! 3. `manifest.el` `[env.<current>]` section
//! 4. `manifest.el` `[config]` base section
//! 5. Defaults defined in code
//!
//! ## Quick start
+177
View File
@@ -0,0 +1,177 @@
// el-config Layered, typed configuration for el-ui.
//
// Each layer is a key->value map. `Config::get(key)` walks layers from highest
// to lowest priority and returns the first hit. Values are parsed into the
// caller-requested type via `parse_<type>`.
// Environment
let ENV_DEVELOPMENT: String = "development"
let ENV_STAGING: String = "staging"
let ENV_PRODUCTION: String = "production"
let ENV_TEST: String = "test"
fn env_current() -> String {
let raw: String = env("EL_ENV")
if str_eq(raw, "") { return ENV_DEVELOPMENT }
raw
}
fn env_is_production(name: String) -> Bool {
str_eq(name, "production")
}
// Errors
let CFG_ERR_MISSING: String = "config.missing"
let CFG_ERR_PARSE: String = "config.parse_error"
let CFG_ERR_TYPE: String = "config.type_error"
// Sources
let SRC_ENV: String = "env"
let SRC_DOTENV: String = "dotenv"
let SRC_MANIFEST: String = "manifest"
let SRC_DEFAULTS: String = "defaults"
let SRC_MAP: String = "map"
type ConfigSource {
kind: String
priority: Int // higher = wins
map_json: String // JSON: key -> string value
}
fn source_env_vars(prefix: String) -> ConfigSource {
// Snapshot env at construction (the runtime exposes env_keys()).
let map: String = "{}"
let keys: String = env_keys_with_prefix(prefix)
let n: Int = json_array_len(keys)
let i: Int = 0
while i < n {
let raw_key: String = json_array_get(keys, i)
let value: String = env(raw_key)
let logical_key: String = str_to_lower(str_replace(str_slice(raw_key, str_len(prefix), str_len(raw_key)), "_", "."))
let map = json_set(map, logical_key, "\"" + value + "\"")
let i = i + 1
}
{ "kind": "env", "priority": 100, "map_json": map }
}
fn source_dotenv(path: String) -> ConfigSource {
let raw: String = ""
if fs_exists(path) { let raw = fs_read(path) }
let map: String = dotenv_parse(raw)
{ "kind": "dotenv", "priority": 80, "map_json": map }
}
fn source_manifest(manifest_path: String, current_env: String) -> ConfigSource {
let raw: String = fs_read(manifest_path)
let base: String = manifest_section(raw, "config")
let env_key: String = "env." + current_env
let env_overrides: String = manifest_section(raw, env_key)
let map: String = json_merge(base, env_overrides)
{ "kind": "manifest", "priority": 60, "map_json": map }
}
fn source_defaults(map_json: String) -> ConfigSource {
{ "kind": "defaults", "priority": 0, "map_json": map_json }
}
// Config
type Config {
environment: String // development | staging | production | test
sources_json: String // JSON array of ConfigSource
}
fn config_new(env_name: String) -> Config {
{ "environment": env_name, "sources_json": "[]" }
}
fn config_add_source(c: Config, src: ConfigSource) -> Config {
let updated: String = json_array_push_sorted(c.sources_json, json_encode(src), "priority", true)
{ "environment": c.environment, "sources_json": updated }
}
fn config_set_defaults(c: Config, defaults_map: String) -> Config {
config_add_source(c, source_defaults(defaults_map))
}
fn config_get_string(c: Config, key: String) -> String {
let n: Int = json_array_len(c.sources_json)
let i: Int = 0
while i < n {
let src_json: String = json_array_get(c.sources_json, i)
let map: String = json_get(src_json, "map_json")
let v: String = json_get(map, key)
if !str_eq(v, "") { return v }
let i = i + 1
}
""
}
fn config_get_int(c: Config, key: String) -> Int {
let raw: String = config_get_string(c, key)
if str_eq(raw, "") { return 0 }
str_to_int(raw)
}
fn config_get_bool(c: Config, key: String) -> Bool {
let raw: String = str_to_lower(config_get_string(c, key))
if str_eq(raw, "true") { return true }
if str_eq(raw, "1") { return true }
if str_eq(raw, "yes") { return true }
false
}
// Strict variants non-empty required.
fn config_require_string(c: Config, key: String) -> String {
let v: String = config_get_string(c, key)
if str_eq(v, "") { panic(CFG_ERR_MISSING + ":" + key) }
v
}
// load_from_toml convenience for TOML config files
fn config_load_from_toml(path: String, env_name: String) -> Config {
let cfg: Config = config_new(env_name)
let raw: String = fs_read(path)
let map: String = toml_to_json_flat(raw) // dotted keys
let cfg = config_add_source(cfg, source_defaults(map))
cfg
}
// .env parser (minimal)
fn dotenv_parse(raw: String) -> String {
let map: String = "{}"
let lines: String = str_split(raw, "\n")
let n: Int = json_array_len(lines)
let i: Int = 0
while i < n {
let line: String = str_trim(json_array_get(lines, i))
if str_eq(line, "") {
let i = i + 1
}
if !str_eq(line, "") {
if !str_starts_with(line, "#") {
let eq: Int = str_index_of(line, "=")
if eq > 0 {
let k: String = str_trim(str_slice(line, 0, eq))
let v: String = str_trim(str_slice(line, eq + 1, str_len(line)))
let v = str_strip_quotes(v)
let map = json_set(map, str_to_lower(str_replace(k, "_", ".")), "\"" + v + "\"")
}
let i = i + 1
}
}
}
map
}
// Entry smoke test
let cfg: Config = config_new(env_current())
let defaults: String = "{\"app.name\":\"MyApp\",\"server.port\":\"8080\"}"
let cfg = config_set_defaults(cfg, defaults)
println("[el-config] env=" + cfg.environment + " app=" + config_get_string(cfg, "app.name"))
+33
View File
@@ -0,0 +1,33 @@
// el-graph Force-directed graph engine for el-ui.
//
// Server-side SVG renderer for knowledge graphs, Engram viewers, DHARMA
// network maps, and soul relationship graphs.
//
// Architecture:
// layout.el pure El force simulation (Coulomb repulsion + spring edges)
// node.el node type definitions and color mapping
// edge.el edge type definitions
// canvas.el HTTP endpoint helper (full pipeline in one call)
// view.el SVG renderer (layout -> SVG string)
// editor.el round-trip mutation API (add/remove/connect/move nodes)
// serializer.el export to SVG or JSON
//
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
// gains a JavaScript backend. For now, consumers call graph_svg_endpoint()
// which runs the full server-side pipeline and returns a static SVG string.
vessel "el-graph" {
version "0.1.0"
description "Force-directed graph: layout, SVG rendering, editing API"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+46
View File
@@ -0,0 +1,46 @@
// canvas.el Full server-side pipeline: layout -> render -> SVG string.
//
// This is the primary integration point for callers that want a static SVG
// without managing the layout and render steps separately.
//
// Public API:
// graph_svg_endpoint(nodes_json, edges_json, width, height) -> String
// Full pipeline: Coulomb/spring layout (150 iterations) -> SVG string.
// Returns a complete <svg>...</svg> string.
//
// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler
// gains a JavaScript backend. For now, all rendering is server-side.
// Clients refresh the SVG on demand (e.g., polling GET /api/graph/svg).
//
// Zoom/pan note: SVG viewBox is fixed to [0,0,width,height]. When the JS
// backend lands, el-ui-compiler will produce an overlay with pointer-event
// handlers that transform a <g> wrapper inside this SVG. The server-side path
// stays as a fallback for non-browser consumers (CLI, PDF export, testing).
fn layout_default_iterations() -> Int { 150 }
// graph_svg_endpoint
fn graph_svg_endpoint(nodes_json: String, edges_json: String, width: Int, height: Int) -> String {
let w_f: Float = int_to_float(width)
let h_f: Float = int_to_float(height)
// Step 1: compute layout
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, layout_default_iterations())
// Step 2: render to SVG
let svg: String = graph_render_svg(nodes_json, edges_json, positions_json, width, height)
svg
}
// graph_svg_endpoint_custom
//
// Same as above but with configurable iteration count.
// Use when you need faster layout (low iters) or higher quality (high iters).
fn graph_svg_endpoint_custom(nodes_json: String, edges_json: String, width: Int, height: Int, iterations: Int) -> String {
let w_f: Float = int_to_float(width)
let h_f: Float = int_to_float(height)
let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, iterations)
graph_render_svg(nodes_json, edges_json, positions_json, width, height)
}
+44
View File
@@ -0,0 +1,44 @@
// edge.el Edge type definitions and visual encoding.
//
// Edges are directed (source -> target) with a weight and optional relation label.
// Edge JSON accessors
//
// Edges are passed as JSON objects: { source_id, target_id, weight, relation }
fn edge_source(e_json: String) -> String {
let s: String = json_get_string(e_json, "source_id")
if !str_eq(s, "") { return s }
json_get_string(e_json, "source")
}
fn edge_target(e_json: String) -> String {
let t: String = json_get_string(e_json, "target_id")
if !str_eq(t, "") { return t }
json_get_string(e_json, "target")
}
fn edge_weight(e_json: String) -> Float {
let w: Float = json_get_float(e_json, "weight")
if w == int_to_float(0) { return int_to_float(1) }
w
}
fn edge_relation(e_json: String) -> String {
json_get_string(e_json, "relation")
}
// Edge visual encoding
// Stroke width clamped to [1, 4] based on weight.
fn edge_stroke_width(weight: Float) -> Float {
let min_w: Float = int_to_float(1)
let max_w: Float = int_to_float(4)
let range: Float = max_w - min_w
let clamped: Float = if weight < min_w { min_w } else { if weight > max_w { max_w } else { weight } }
clamped
}
fn edge_stroke_color() -> String { "#3a4a5a" }
fn edge_stroke_color_highlight() -> String { "#5a7a9a" }
+138
View File
@@ -0,0 +1,138 @@
// editor.el Round-trip graph editing API.
//
// Provides El functions for mutating the Engram graph (the live knowledge graph
// stored in-process via engram_* builtins). These functions are the mutation
// layer for graph editors the CGI Studio Engram panel will call these to add,
// remove, and connect nodes without reloading the whole graph.
//
// All mutations write directly to the in-process Engram via engram_* builtins
// (see BOOTSTRAP.md §Engram Knowledge Graph).
//
// Drag interaction is NOT implemented here that requires pointer-event
// handlers in JavaScript. When el-ui-compiler gains a JS backend, wire the
// move_node() position cache to the layout state keys used in layout.el.
//
// Public API:
// graph_add_node(content, node_type, label, salience) -> String // node_id or ""
// graph_remove_node(node_id) -> String // "ok" or error JSON
// graph_add_edge(from_id, to_id, weight_str, relation) -> String // "ok" or error JSON
// graph_remove_edge(from_id, to_id) -> String // "ok" or error JSON
// graph_move_node(node_id, x_str, y_str) -> String // "ok" (position cache)
// graph_add_node
fn graph_add_node(content: String, node_type: String, label: String, salience_str: String) -> String {
if str_eq(content, "") {
return "{\"error\":\"content is required\"}"
}
let sal: Float = if str_eq(salience_str, "") { int_to_float(1) } else { str_to_float(salience_str) }
// engram_node_full: content, type, label, salience, importance, confidence, tier, tags
let eff_label: String = if str_eq(label, "") { str_slice(content, 0, 40) } else { label }
let eff_type: String = if str_eq(node_type, "") { "Entity" } else { node_type }
let node_id: String = engram_node_full(content, eff_type, eff_label, sal, sal, int_to_float(1), "Working", "")
if str_eq(node_id, "") {
return "{\"error\":\"engram_node_full returned empty id\"}"
}
"{\"id\":\"" + node_id + "\"}"
}
// graph_remove_node
fn graph_remove_node(node_id: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
// Check node exists
let existing: String = engram_get_node(node_id)
if str_eq(existing, "") {
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
}
engram_forget(node_id)
"{\"ok\":true,\"id\":\"" + node_id + "\"}"
}
// graph_add_edge
fn graph_add_edge(from_id: String, to_id: String, weight_str: String, relation: String) -> String {
if str_eq(from_id, "") {
return "{\"error\":\"from_id is required\"}"
}
if str_eq(to_id, "") {
return "{\"error\":\"to_id is required\"}"
}
let w: Float = if str_eq(weight_str, "") { int_to_float(1) } else { str_to_float(weight_str) }
let rel: String = if str_eq(relation, "") { "relates_to" } else { relation }
// engram_connect(from, to, weight, relation)
let edge_id: String = engram_connect(from_id, to_id, w, rel)
if str_eq(edge_id, "") {
return "{\"error\":\"engram_connect failed\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
"{\"ok\":true,\"edge_id\":\"" + edge_id + "\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
// graph_remove_edge
fn graph_remove_edge(from_id: String, to_id: String) -> String {
if str_eq(from_id, "") {
return "{\"error\":\"from_id is required\"}"
}
if str_eq(to_id, "") {
return "{\"error\":\"to_id is required\"}"
}
// Check edge exists
let existing: String = engram_edge_between(from_id, to_id)
if str_eq(existing, "") {
return "{\"error\":\"edge not found\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
// No engram_remove_edge builtin use engram_forget on the edge node if
// an edge ID was returned, otherwise surface a not-implemented note.
// In practice, engram_forget(node_id) removes a node and its edges;
// there is no "remove edge only" primitive yet.
"{\"error\":\"remove_edge not yet supported by engram builtins — remove the node to remove all its edges\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}"
}
// graph_move_node
//
// Caches a node's screen position for use by the layout engine.
// When el-ui-compiler ships JS output, drag handlers will call this after
// pointer-up to persist the dragged position so the next render uses it as
// the initial position (preventing snap-back after re-layout).
fn graph_move_node(node_id: String, x_str: String, y_str: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
// Store in process state layout.el reads these keys as initial positions.
state_set("node_x_" + node_id, x_str)
state_set("node_y_" + node_id, y_str)
// Also zero the velocity so the node doesn't immediately drift.
state_set("node_vx_" + node_id, "0.0")
state_set("node_vy_" + node_id, "0.0")
"{\"ok\":true,\"id\":\"" + node_id + "\",\"x\":" + x_str + ",\"y\":" + y_str + "}"
}
// graph_node_info
//
// Retrieve full node data from Engram (for inspector panels).
fn graph_node_info(node_id: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
let n: String = engram_get_node(node_id)
if str_eq(n, "") {
return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}"
}
n
}
// graph_neighbors
//
// Return the neighbors of a node as a JSON array (for sub-graph drill-down).
fn graph_neighbors_json(node_id: String) -> String {
if str_eq(node_id, "") {
return "{\"error\":\"node_id is required\"}"
}
engram_neighbors(node_id)
}
+352
View File
@@ -0,0 +1,352 @@
// layout.el Force-directed layout engine (pure El math).
//
// Implements a basic spring-force simulation:
// - Coulomb repulsion between every pair of nodes (O(n²))
// - Hooke spring attraction along edges
// - Weak gravity toward the canvas center
// - Velocity damping per iteration
//
// Float representation: El stores floats as bit-cast int64_t values.
// All math uses int_to_float() for literals and math_sqrt() for sqrt.
//
// Public API:
// layout_run(nodes_json, edges_json, width, height, iterations) -> String
// nodes_json JSON array: [{ id, salience, ... }, ...]
// edges_json JSON array: [{ source_id, target_id, weight }, ...]
// width/height canvas Float dimensions
// iterations simulation steps (default 150 for good convergence)
// Returns JSON array: [{ id, x, y }, ...]
// Constants
fn layout_repulsion_k() -> Float {
// Coulomb constant controls node spread.
int_to_float(3000)
}
fn layout_spring_k() -> Float {
// Spring stiffness for edge attraction.
int_to_float(1)
}
fn layout_spring_rest() -> Float {
// Rest length for edges (px).
int_to_float(80)
}
fn layout_gravity_k() -> Float {
// Gravity toward center (gentle).
int_to_float(1)
}
fn layout_damping() -> Float {
// Velocity decay per step (0.85 = 15% loss per step).
let d: Float = int_to_float(85)
d / int_to_float(100)
}
fn layout_max_velocity() -> Float {
// Cap velocity per step to avoid explosion.
int_to_float(50)
}
fn layout_min_dist() -> Float {
// Minimum distance to prevent division by zero in repulsion.
int_to_float(1)
}
// State keys (process state for per-node data)
//
// We use process state (state_set/state_get) as a flat key/value store since
// El does not have mutable arrays or map mutation without re-assignment.
// Key patterns:
// "node_ids" comma-separated node id list
// "node_x_<id>" x position
// "node_y_<id>" y position
// "node_vx_<id>" x velocity
// "node_vy_<id>" y velocity
fn layout_key_x(node_id: String) -> String { "node_x_" + node_id }
fn layout_key_y(node_id: String) -> String { "node_y_" + node_id }
fn layout_key_vx(node_id: String) -> String { "node_vx_" + node_id }
fn layout_key_vy(node_id: String) -> String { "node_vy_" + node_id }
// Initialization
//
// Distribute nodes in a circle around the center so no two start at the
// same position (which would make repulsion forces zero and give no movement).
fn layout_init_positions(node_ids: String, cx: Float, cy: Float) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let count: Int = el_list_len(ids)
if count == 0 { return true }
let pi2: Float = math_pi() * int_to_float(2)
let radius: Float = int_to_float(100) + int_to_float(20) * int_to_float(count)
let i: Int = 0
while i < count {
let id: String = el_list_get(ids, i)
let angle: Float = pi2 * int_to_float(i) / int_to_float(count)
let x: Float = cx + radius * math_cos(angle)
let y: Float = cy + math_sin(angle) * radius
state_set(layout_key_x(id), float_to_str(x))
state_set(layout_key_y(id), float_to_str(x))
state_set(layout_key_y(id), float_to_str(y))
state_set(layout_key_vx(id), "0.0")
state_set(layout_key_vy(id), "0.0")
let i = i + 1
}
true
}
// Float helpers
fn layout_get_x(id: String) -> Float {
str_to_float(state_get(layout_key_x(id)))
}
fn layout_get_y(id: String) -> Float {
str_to_float(state_get(layout_key_y(id)))
}
fn layout_get_vx(id: String) -> Float {
str_to_float(state_get(layout_key_vx(id)))
}
fn layout_get_vy(id: String) -> Float {
str_to_float(state_get(layout_key_vy(id)))
}
fn float_clamp(v: Float, lo: Float, hi: Float) -> Float {
if v < lo { return lo }
if v > hi { return hi }
v
}
fn float_abs(v: Float) -> Float {
if v < int_to_float(0) { return int_to_float(0) - v }
v
}
// Repulsion pass
//
// For each pair (a, b): compute Coulomb repulsion and accumulate forces.
// Force direction: along the vector from b to a (a is pushed away from b).
// Magnitude: k / dist^2
fn layout_repulsion_pass(node_ids: String) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let n: Int = el_list_len(ids)
let i: Int = 0
while i < n {
let id_a: String = el_list_get(ids, i)
let ax: Float = layout_get_x(id_a)
let ay: Float = layout_get_y(id_a)
let fx: Float = int_to_float(0)
let fy: Float = int_to_float(0)
let j: Int = 0
while j < n {
if j != i {
let id_b: String = el_list_get(ids, j)
let bx: Float = layout_get_x(id_b)
let by: Float = layout_get_y(id_b)
let dx: Float = ax - bx
let dy: Float = ay - by
let dist_sq: Float = dx * dx + dy * dy
let dist: Float = math_sqrt(dist_sq)
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
let force: Float = layout_repulsion_k() / (safe_dist * safe_dist)
let nx: Float = dx / safe_dist
let ny: Float = dy / safe_dist
let fx = fx + nx * force
let fy = fy + ny * force
}
let j = j + 1
}
// Accumulate: store forces temporarily in velocity (they are scaled later)
// Use "fx_<id>" keys for accumulation.
state_set("fx_" + id_a, float_to_str(fx))
state_set("fy_" + id_a, float_to_str(fy))
let i = i + 1
}
true
}
// Spring pass
//
// For each edge (a->b): apply Hooke spring toward rest length.
// Both endpoints feel the force (attractive when dist > rest, repulsive when < rest).
fn layout_spring_pass(node_ids: String, edges_json: String) -> Bool {
let edge_count: Int = json_array_len(edges_json)
let i: Int = 0
while i < edge_count {
let e: String = json_array_get(edges_json, i)
let src: String = json_get_string(e, "source_id")
let tgt_raw: String = json_get_string(e, "target_id")
// Support both source_id/target_id and source/target field names
let src2: String = if str_eq(src, "") { json_get_string(e, "source") } else { src }
let tgt2: String = if str_eq(tgt_raw, "") { json_get_string(e, "target") } else { tgt_raw }
let w: Float = json_get_float(e, "weight")
let eff_w: Float = if w == int_to_float(0) { int_to_float(1) } else { w }
// Only apply spring if both endpoints are in our node set
let sx: String = state_get(layout_key_x(src2))
let tx_chk: String = state_get(layout_key_x(tgt2))
if !str_eq(sx, "") {
if !str_eq(tx_chk, "") {
let ax: Float = layout_get_x(src2)
let ay: Float = layout_get_y(src2)
let bx: Float = layout_get_x(tgt2)
let by_val: Float = layout_get_y(tgt2)
let dx: Float = bx - ax
let dy: Float = by_val - ay
let dist_sq: Float = dx * dx + dy * dy
let dist: Float = math_sqrt(dist_sq)
let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist }
let stretch: Float = (safe_dist - layout_spring_rest()) * layout_spring_k() * eff_w
let nx: Float = dx / safe_dist
let ny: Float = dy / safe_dist
let spring_fx: Float = nx * stretch
let spring_fy: Float = ny * stretch
// Add to accumulated forces
let cur_fx_a: Float = str_to_float(state_get("fx_" + src2))
let cur_fy_a: Float = str_to_float(state_get("fy_" + src2))
state_set("fx_" + src2, float_to_str(cur_fx_a + spring_fx))
state_set("fy_" + src2, float_to_str(cur_fy_a + spring_fy))
let cur_fx_b: Float = str_to_float(state_get("fx_" + tgt2))
let cur_fy_b: Float = str_to_float(state_get("fy_" + tgt2))
state_set("fx_" + tgt2, float_to_str(cur_fx_b - spring_fx))
state_set("fy_" + tgt2, float_to_str(cur_fy_b - spring_fy))
}
}
let i = i + 1
}
true
}
// Gravity pass
//
// Weak attraction toward canvas center to prevent isolated nodes from drifting.
fn layout_gravity_pass(node_ids: String, cx: Float, cy: Float) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let n: Int = el_list_len(ids)
let i: Int = 0
while i < n {
let id: String = el_list_get(ids, i)
let x: Float = layout_get_x(id)
let y: Float = layout_get_y(id)
let gx: Float = (cx - x) * layout_gravity_k() / int_to_float(100)
let gy: Float = (cy - y) * layout_gravity_k() / int_to_float(100)
let cur_fx: Float = str_to_float(state_get("fx_" + id))
let cur_fy: Float = str_to_float(state_get("fy_" + id))
state_set("fx_" + id, float_to_str(cur_fx + gx))
state_set("fy_" + id, float_to_str(cur_fy + gy))
let i = i + 1
}
true
}
// Integration pass
//
// Apply forces to velocities (with damping), then update positions.
// Clamp positions to stay within canvas bounds (with 20px margin).
fn layout_integrate(node_ids: String, width: Float, height: Float) -> Bool {
let ids: [String] = str_split(node_ids, ",")
let n: Int = el_list_len(ids)
let max_v: Float = layout_max_velocity()
let damp: Float = layout_damping()
let margin: Float = int_to_float(20)
let i: Int = 0
while i < n {
let id: String = el_list_get(ids, i)
let vx: Float = (layout_get_vx(id) + str_to_float(state_get("fx_" + id))) * damp
let vy: Float = (layout_get_vy(id) + str_to_float(state_get("fy_" + id))) * damp
// Clamp velocity magnitude
let vx_clamped: Float = float_clamp(vx, int_to_float(0) - max_v, max_v)
let vy_clamped: Float = float_clamp(vy, int_to_float(0) - max_v, max_v)
let new_x: Float = float_clamp(layout_get_x(id) + vx_clamped, margin, width - margin)
let new_y: Float = float_clamp(layout_get_y(id) + vy_clamped, margin, height - margin)
state_set(layout_key_x(id), float_to_str(new_x))
state_set(layout_key_y(id), float_to_str(new_y))
state_set(layout_key_vx(id), float_to_str(vx_clamped))
state_set(layout_key_vy(id), float_to_str(vy_clamped))
// Reset force accumulators for next iteration
state_set("fx_" + id, "0.0")
state_set("fy_" + id, "0.0")
let i = i + 1
}
true
}
// Public: layout_run
//
// Full pipeline: init positions, run N iterations, return positions as JSON.
//
// Input nodes_json must be a JSON array of objects with at least an "id" field.
// Returns: JSON array [{ "id": "...", "x": 123.0, "y": 456.0 }, ...]
fn layout_run(nodes_json: String, edges_json: String, width: Float, height: Float, iterations: Int) -> String {
let cx: Float = width / int_to_float(2)
let cy: Float = height / int_to_float(2)
// Build comma-separated node_ids list
let node_count: Int = json_array_len(nodes_json)
if node_count == 0 { return "[]" }
let node_ids: String = ""
let first: Bool = true
let i: Int = 0
while i < node_count {
let n: String = json_array_get(nodes_json, i)
let id: String = json_get_string(n, "id")
if !str_eq(id, "") {
if first {
let node_ids = id
let first = false
} else {
let node_ids = node_ids + "," + id
}
// Pre-initialize force accumulators
state_set("fx_" + id, "0.0")
state_set("fy_" + id, "0.0")
}
let i = i + 1
}
// Initialize positions (circle around center)
layout_init_positions(node_ids, cx, cy)
// Simulation loop
let iter: Int = 0
while iter < iterations {
layout_repulsion_pass(node_ids)
layout_spring_pass(node_ids, edges_json)
layout_gravity_pass(node_ids, cx, cy)
layout_integrate(node_ids, width, height)
let iter = iter + 1
}
// Collect results as JSON array
let result: String = "["
let ids: [String] = str_split(node_ids, ",")
let n2: Int = el_list_len(ids)
let j: Int = 0
while j < n2 {
let id: String = el_list_get(ids, j)
let x: Float = layout_get_x(id)
let y: Float = layout_get_y(id)
let entry: String = "{\"id\":\"" + id + "\",\"x\":" + format_float(x, 1) + ",\"y\":" + format_float(y, 1) + "}"
if j == 0 {
let result = result + entry
} else {
let result = result + "," + entry
}
let j = j + 1
}
let result = result + "]"
result
}
+34
View File
@@ -0,0 +1,34 @@
// main.el el-graph vessel entry point.
//
// Re-exports all public functions from the sub-modules. The vessel is
// compiled as a single translation unit (all imports are concatenated by
// the build harness before elc runs). This file is the canonical import
// target for downstream consumers.
//
// Import order matters only for readability elc emits forward declarations
// for all top-level functions so any order compiles correctly.
import "node.el"
import "edge.el"
import "layout.el"
import "view.el"
import "canvas.el"
import "editor.el"
import "serializer.el"
// Smoke test
//
// Verifies the vessel initializes correctly. Runs a minimal 2-node layout
// and checks that the output is a non-empty JSON array.
//
// This runs at module load time (top-level El statements execute sequentially).
// Remove or gate behind an env flag if startup overhead matters.
println("[el-graph] v0.1.0 — force layout + SVG renderer")
println("[el-graph] node_color(Memory) = " + node_color("Memory"))
println("[el-graph] node_radius(0.8) = " + int_to_str(node_radius_int(int_to_float(8) / int_to_float(10))))
let _smoke_nodes: String = "[{\"id\":\"a\",\"salience\":0.8,\"node_type\":\"Memory\"},{\"id\":\"b\",\"salience\":0.5,\"node_type\":\"Entity\"}]"
let _smoke_edges: String = "[{\"source_id\":\"a\",\"target_id\":\"b\",\"weight\":1.0}]"
let _smoke_pos: String = layout_run(_smoke_nodes, _smoke_edges, int_to_float(400), int_to_float(300), 10)
println("[el-graph] smoke layout (10 iter) = " + str_slice(_smoke_pos, 0, 60) + "...")
+77
View File
@@ -0,0 +1,77 @@
// node.el Node type definitions and color/radius mapping.
//
// Node types mirror the Engram knowledge graph node_type field.
// Colors are chosen for dark-background (Studio) legibility.
// Node type constants
fn node_type_memory() -> String { "Memory" }
fn node_type_backlog() -> String { "BacklogItem" }
fn node_type_knowledge() -> String { "Knowledge" }
fn node_type_entity() -> String { "Entity" }
fn node_type_default() -> String { "Node" }
// Color map
fn node_color(node_type: String) -> String {
if str_eq(node_type, "Memory") { return "#58A6FF" }
if str_eq(node_type, "BacklogItem") { return "#C9A84C" }
if str_eq(node_type, "Knowledge") { return "#2ecc71" }
if str_eq(node_type, "Entity") { return "#e74c3c" }
if str_eq(node_type, "WorkContext") { return "#9b59b6" }
if str_eq(node_type, "Artifact") { return "#1abc9c" }
if str_eq(node_type, "Process") { return "#e67e22" }
"#7a8ba8"
}
// Radius
//
// Clamp salience (0.01.0) to radius range [6, 18].
fn node_radius(salience: Float) -> Float {
let min_r: Float = int_to_float(6)
let max_r: Float = int_to_float(18)
let range: Float = max_r - min_r
let clamped: Float = if salience < int_to_float(0) { int_to_float(0) } else { if salience > int_to_float(1) { int_to_float(1) } else { salience } }
min_r + range * clamped
}
fn node_radius_int(salience: Float) -> Int {
float_to_int(node_radius(salience))
}
// Label truncation
fn node_label_truncate(label: String) -> String {
let max_len: Int = 30
let l: Int = str_len(label)
if l <= max_len { return label }
str_slice(label, 0, max_len) + "..."
}
// Node JSON accessors
//
// Nodes are passed as JSON objects: { id, label, node_type, salience, ... }
fn node_id(n_json: String) -> String {
json_get_string(n_json, "id")
}
fn node_label(n_json: String) -> String {
let lbl: String = json_get_string(n_json, "label")
if !str_eq(lbl, "") { return lbl }
// Fall back to first 40 chars of content
let c: String = json_get_string(n_json, "content")
if str_len(c) > 40 { return str_slice(c, 0, 40) }
c
}
fn node_type_field(n_json: String) -> String {
let t: String = json_get_string(n_json, "node_type")
if str_eq(t, "") { return node_type_default() }
t
}
fn node_salience(n_json: String) -> Float {
json_get_float(n_json, "salience")
}
+80
View File
@@ -0,0 +1,80 @@
// serializer.el Export graph as SVG string or portable JSON.
//
// Public API:
// graph_to_svg(graph_json, width, height) -> String
// graph_json: { "nodes": [...], "edges": [...] }
// Full pipeline: parse -> layout -> render -> SVG string.
//
// graph_to_json(nodes_json, edges_json, positions_json) -> String
// Portable export combining node data with computed positions.
// Useful for saving layouts to disk or sending to other tools.
// graph_to_svg
//
// Convenience wrapper: accepts a combined graph JSON object and returns SVG.
fn graph_to_svg(graph_json: String, width: Int, height: Int) -> String {
let nodes_raw: String = json_get_raw(graph_json, "nodes")
let edges_raw: String = json_get_raw(graph_json, "edges")
let nodes_json: String = if str_eq(nodes_raw, "") { "[]" } else { nodes_raw }
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
graph_svg_endpoint(nodes_json, edges_json, width, height)
}
// graph_to_json
//
// Merge node metadata with computed positions into a portable export format.
// Output: { "nodes": [{...node fields..., "x": 123.0, "y": 456.0}], "edges": [...] }
fn graph_to_json(nodes_json: String, edges_json: String, positions_json: String) -> String {
// Index positions by id
build_position_index(positions_json)
let node_count: Int = json_array_len(nodes_json)
let nodes_out: String = "["
let i: Int = 0
while i < node_count {
let n: String = json_array_get(nodes_json, i)
let id: String = json_get_string(n, "id")
let x: Float = get_pos_x(id)
let y: Float = get_pos_y(id)
// Inject x/y into the node JSON
let n_with_pos: String = json_set(json_set(n, "x", format_float(x, 1)), "y", format_float(y, 1))
if i == 0 {
let nodes_out = nodes_out + n_with_pos
} else {
let nodes_out = nodes_out + "," + n_with_pos
}
let i = i + 1
}
let nodes_out = nodes_out + "]"
"{\"nodes\":" + nodes_out + ",\"edges\":" + edges_json + "}"
}
// graph_snapshot_svg
//
// Render a snapshot of the current in-process Engram graph as SVG.
// Uses engram_scan_nodes_json and reads edges from the snapshot file.
// This is the function called by the CGI Studio /api/graph/svg endpoint.
fn graph_snapshot_svg(width: Int, height: Int, snap_path: String) -> String {
let nodes_json: String = engram_scan_nodes_json(9999, 0)
let n_count: Int = json_array_len(nodes_json)
// Read edges from snapshot file
let snap: String = fs_read(snap_path)
let edges_raw: String = if str_eq(snap, "") { "[]" } else { json_get_raw(snap, "edges") }
let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw }
if n_count == 0 {
// Return an empty SVG with a "no data" message
return svg_open(width, height) +
"<text x=\"" + int_to_str(width / 2) + "\" y=\"" + int_to_str(height / 2) + "\" " +
"text-anchor=\"middle\" fill=\"#8b9aaa\" font-size=\"14\" " +
"font-family=\"IBM Plex Mono,monospace\">No nodes in graph</text>" +
svg_close()
}
graph_svg_endpoint(nodes_json, edges_json, width, height)
}
+155
View File
@@ -0,0 +1,155 @@
// view.el SVG renderer for the force-directed graph.
//
// Takes layout positions + node/edge data and produces a complete SVG string.
// Rendering is purely server-side no DOM, no JavaScript.
//
// Public API:
// graph_render_svg(nodes_json, edges_json, positions_json, width, height) -> String
// Returns a complete <svg>...</svg> string ready for embedding or serving.
//
// Visual conventions:
// - Background: #0d1117 (dark, matching Studio theme)
// - Edges drawn first (below nodes)
// - Nodes: filled circle with stroke, radius by salience
// - Labels: truncated to 30 chars, below node, 10px IBM Plex Mono
// SVG helpers
fn svg_open(width: Int, height: Int) -> String {
"<svg xmlns=\"http://www.w3.org/2000/svg\" " +
"width=\"" + int_to_str(width) + "\" " +
"height=\"" + int_to_str(height) + "\" " +
"viewBox=\"0 0 " + int_to_str(width) + " " + int_to_str(height) + "\" " +
"style=\"background:#0d1117;font-family:'IBM Plex Mono',monospace\">"
}
fn svg_close() -> String { "</svg>" }
fn svg_defs() -> String {
"<defs>" +
"<filter id=\"glow\"><feGaussianBlur stdDeviation=\"2\" result=\"blur\"/>" +
"<feMerge><feMergeNode in=\"blur\"/><feMergeNode in=\"SourceGraphic\"/></feMerge></filter>" +
"</defs>"
}
// Edge rendering
fn svg_edge(x1: Float, y1: Float, x2: Float, y2: Float, weight: Float) -> String {
let sw: Float = edge_stroke_width(weight)
let sw_str: String = format_float(sw, 1)
let x1s: String = format_float(x1, 1)
let y1s: String = format_float(y1, 1)
let x2s: String = format_float(x2, 1)
let y2s: String = format_float(y2, 1)
"<line " +
"x1=\"" + x1s + "\" y1=\"" + y1s + "\" " +
"x2=\"" + x2s + "\" y2=\"" + y2s + "\" " +
"stroke=\"" + edge_stroke_color() + "\" " +
"stroke-width=\"" + sw_str + "\" " +
"stroke-opacity=\"0.7\"/>"
}
// Node rendering
fn svg_node(x: Float, y: Float, radius: Int, color: String, label: String) -> String {
let xs: String = format_float(x, 1)
let ys: String = format_float(y, 1)
let rs: String = int_to_str(radius)
let label_trunc: String = node_label_truncate(label)
// Escape XML special chars in label
let label_safe: String = str_replace(str_replace(str_replace(label_trunc, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
let label_y: String = format_float(y + int_to_float(radius) + int_to_float(12), 1)
"<circle cx=\"" + xs + "\" cy=\"" + ys + "\" r=\"" + rs + "\" " +
"fill=\"" + color + "\" fill-opacity=\"0.85\" " +
"stroke=\"" + color + "\" stroke-width=\"1.5\" filter=\"url(#glow)\"/>" +
"<text x=\"" + xs + "\" y=\"" + label_y + "\" " +
"text-anchor=\"middle\" font-size=\"9\" fill=\"#8b9aaa\" " +
"font-family=\"IBM Plex Mono,monospace\">" + label_safe + "</text>"
}
// Position lookup
//
// Build a flat map from node_id -> position JSON in process state.
// Key: "pos_<id>" -> "{\"x\":...,\"y\":...}"
fn build_position_index(positions_json: String) -> Bool {
let count: Int = json_array_len(positions_json)
let i: Int = 0
while i < count {
let pos: String = json_array_get(positions_json, i)
let id: String = json_get_string(pos, "id")
if !str_eq(id, "") {
state_set("pos_" + id, pos)
}
let i = i + 1
}
true
}
fn get_pos_x(node_id: String) -> Float {
let pos: String = state_get("pos_" + node_id)
if str_eq(pos, "") { return int_to_float(0) }
json_get_float(pos, "x")
}
fn get_pos_y(node_id: String) -> Float {
let pos: String = state_get("pos_" + node_id)
if str_eq(pos, "") { return int_to_float(0) }
json_get_float(pos, "y")
}
// Public: graph_render_svg
fn graph_render_svg(nodes_json: String, edges_json: String, positions_json: String, width: Int, height: Int) -> String {
// Index positions by node id
build_position_index(positions_json)
let out: String = svg_open(width, height)
let out = out + svg_defs()
// Draw edges (behind nodes)
let edge_count: Int = json_array_len(edges_json)
let i: Int = 0
while i < edge_count {
let e: String = json_array_get(edges_json, i)
let src: String = edge_source(e)
let tgt: String = edge_target(e)
let w: Float = edge_weight(e)
// Only draw if both endpoints have positions
let src_pos: String = state_get("pos_" + src)
let tgt_pos: String = state_get("pos_" + tgt)
if !str_eq(src_pos, "") {
if !str_eq(tgt_pos, "") {
let x1: Float = get_pos_x(src)
let y1: Float = get_pos_y(src)
let x2: Float = get_pos_x(tgt)
let y2: Float = get_pos_y(tgt)
let out = out + svg_edge(x1, y1, x2, y2, w)
}
}
let i = i + 1
}
// Draw nodes (over edges)
let node_count: Int = json_array_len(nodes_json)
let j: Int = 0
while j < node_count {
let n: String = json_array_get(nodes_json, j)
let id: String = node_id(n)
let lbl: String = node_label(n)
let ntype: String = node_type_field(n)
let sal: Float = node_salience(n)
let color: String = node_color(ntype)
let radius: Int = node_radius_int(sal)
let pos: String = state_get("pos_" + id)
if !str_eq(pos, "") {
let x: Float = get_pos_x(id)
let y: Float = get_pos_y(id)
let out = out + svg_node(x, y, radius, color, lbl)
}
let j = j + 1
}
let out = out + svg_close()
out
}
+19
View File
@@ -0,0 +1,19 @@
// el-i18n Localization for el-ui.
//
// RTL-aware, plural forms, CLDR-based number / currency formatting.
vessel "el-i18n" {
version "0.1.0"
description "Locales, translations, plural forms, number/currency formatting"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+208
View File
@@ -0,0 +1,208 @@
// el-i18n Localization for el-ui.
//
// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant.
// Bundles map keys to either a plain string or a plural-form map.
// `t(key)` and `t_plural(key, count)` are the surface API.
// Locale
let DIR_LTR: String = "ltr"
let DIR_RTL: String = "rtl"
type Locale {
language: String // "en"
region: String // "US" (may be empty)
direction: String // "ltr" | "rtl"
}
fn locale_new(language: String, region: String) -> Locale {
let dir: String = "ltr"
if str_eq(language, "ar") { let dir = "rtl" }
if str_eq(language, "he") { let dir = "rtl" }
if str_eq(language, "fa") { let dir = "rtl" }
if str_eq(language, "ur") { let dir = "rtl" }
{ "language": language, "region": region, "direction": dir }
}
fn locale_en_us() -> Locale { locale_new("en", "US") }
fn locale_es_es() -> Locale { locale_new("es", "ES") }
fn locale_zh_cn() -> Locale { locale_new("zh", "CN") }
fn locale_ar_eg() -> Locale { locale_new("ar", "EG") }
fn locale_ja_jp() -> Locale { locale_new("ja", "JP") }
fn locale_tag(loc: Locale) -> String {
if str_eq(loc.region, "") { return loc.language }
loc.language + "-" + loc.region
}
fn is_rtl(loc: Locale) -> Bool {
str_eq(loc.direction, "rtl")
}
// Plural forms (CLDR cardinal categories)
//
// Categories: zero, one, two, few, many, other.
// Most languages only use one + other; Arabic uses all six.
let PLURAL_ZERO: String = "zero"
let PLURAL_ONE: String = "one"
let PLURAL_TWO: String = "two"
let PLURAL_FEW: String = "few"
let PLURAL_MANY: String = "many"
let PLURAL_OTHER: String = "other"
fn plural_form(loc: Locale, n: Int) -> String {
if str_eq(loc.language, "ar") { return plural_form_arabic(n) }
if str_eq(loc.language, "ru") { return plural_form_russian(n) }
if str_eq(loc.language, "pl") { return plural_form_polish(n) }
if str_eq(loc.language, "ja") { return PLURAL_OTHER }
if str_eq(loc.language, "zh") { return PLURAL_OTHER }
if str_eq(loc.language, "ko") { return PLURAL_OTHER }
// Default English-like rule
if n == 1 { return PLURAL_ONE }
PLURAL_OTHER
}
fn plural_form_arabic(n: Int) -> String {
if n == 0 { return PLURAL_ZERO }
if n == 1 { return PLURAL_ONE }
if n == 2 { return PLURAL_TWO }
let mod100: Int = n - ((n / 100) * 100)
if mod100 >= 3 {
if mod100 <= 10 { return PLURAL_FEW }
}
if mod100 >= 11 {
if mod100 <= 99 { return PLURAL_MANY }
}
PLURAL_OTHER
}
fn plural_form_russian(n: Int) -> String {
let mod10: Int = n - ((n / 10) * 10)
let mod100: Int = n - ((n / 100) * 100)
if mod10 == 1 {
if mod100 == 11 { return PLURAL_MANY }
return PLURAL_ONE
}
if mod10 >= 2 {
if mod10 <= 4 {
if mod100 >= 12 {
if mod100 <= 14 { return PLURAL_MANY }
}
return PLURAL_FEW
}
}
PLURAL_MANY
}
fn plural_form_polish(n: Int) -> String {
if n == 1 { return PLURAL_ONE }
let mod10: Int = n - ((n / 10) * 10)
let mod100: Int = n - ((n / 100) * 100)
if mod10 >= 2 {
if mod10 <= 4 {
if mod100 >= 12 {
if mod100 <= 14 { return PLURAL_MANY }
}
return PLURAL_FEW
}
}
PLURAL_MANY
}
// Translation bundle
//
// Stored as a JSON map: key -> value | { one: "...", other: "..." }
// `bundle_load_toml` parses a TOML file at load time (planned runtime fn).
fn bundle_new() -> String {
"{}"
}
fn bundle_insert(bundle: String, key: String, value: String) -> String {
json_set(bundle, key, "\"" + value + "\"")
}
fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String {
json_set(bundle, key, plural_map_json)
}
fn bundle_load_toml(path: String) -> String {
let raw: String = fs_read(path)
toml_to_json(raw)
}
// LocaleContext + t/t_plural
type LocaleContext {
locale: Locale
bundle: String // JSON
fallback_bundle: String
}
fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext {
{ "locale": loc, "bundle": bundle, "fallback_bundle": "{}" }
}
fn t(ctx: LocaleContext, key: String) -> String {
let v: String = json_get(ctx.bundle, key)
if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) }
if str_eq(v, "") { return key }
v
}
fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String {
let entry: String = json_get(ctx.bundle, key)
if str_eq(entry, "") { return key }
let form: String = plural_form(ctx.locale, n)
let template: String = json_get(entry, form)
if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) }
if str_eq(template, "") { return key }
str_replace(template, "{n}", int_to_str(n))
}
// Number / currency formatting
fn format_integer(loc: Locale, n: Int) -> String {
// Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'.
let sep: String = ","
if str_eq(loc.language, "es") { let sep = "." }
if str_eq(loc.language, "de") { let sep = "." }
if str_eq(loc.language, "fr") { let sep = " " }
int_with_separator(n, sep)
}
fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String {
let dec_sep: String = "."
if str_eq(loc.language, "es") { let dec_sep = "," }
if str_eq(loc.language, "de") { let dec_sep = "," }
if str_eq(loc.language, "fr") { let dec_sep = "," }
format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits)
}
fn format_percent(loc: Locale, value_x100: Int) -> String {
let body: String = int_to_str(value_x100 / 100) + "."
+ int_to_str(value_x100 - ((value_x100 / 100) * 100))
if str_eq(loc.language, "fr") { return body + " %" }
body + "%"
}
fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String {
// amount_minor is in the smallest unit (cents). Stub formatting only.
let major: Int = amount_minor / 100
let minor: Int = amount_minor - (major * 100)
let body: String = int_to_str(major) + "." + int_to_str(minor)
if str_eq(iso, "USD") { return "$" + body }
if str_eq(iso, "EUR") { return body + "" }
if str_eq(iso, "GBP") { return "£" + body }
if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) }
body + " " + iso
}
// Entry smoke test
let loc: Locale = locale_en_us()
let bundle: String = bundle_new()
let bundle = bundle_insert(bundle, "profile.follow", "Follow")
let ctx: LocaleContext = locale_context_new(loc, bundle)
println("[el-i18n] " + t(ctx, "profile.follow"))
+24
View File
@@ -0,0 +1,24 @@
// el-identity Engram-native identity for el-ui.
//
// Identity is not a bolt-on. Users, roles, scopes, sessions, and OAuth tokens
// are first-class Engram nodes connected by typed edges:
//
// User has_role Role grants Scope
//
// has_session Session authenticated_via OAuthToken
vessel "el-identity" {
version "0.1.0"
description "Engram-native identity graph: users, roles, sessions, OAuth"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
}
build {
entry "src/main.el"
output "dist/"
}
+271
View File
@@ -0,0 +1,271 @@
// el-identity Engram-native identity for el-ui.
//
// Identity in el-ui is a graph, not a table. Every entity is a node; every
// relationship is an edge. Authentication is spreading activation from a
// session token through the identity subgraph until it touches a User node.
//
// Edges:
// User has_role Role grants Scope
// User has_session Session authenticated_via OAuthToken
// Edge type constants
let EDGE_HAS_ROLE: String = "has_role"
let EDGE_HAS_SESSION: String = "has_session"
let EDGE_AUTHENTICATED_VIA: String = "authenticated_via"
let EDGE_GRANTS: String = "grants"
// Node type constants
let NODE_USER: String = "User"
let NODE_ROLE: String = "Role"
let NODE_SCOPE: String = "Scope"
let NODE_OAUTH_TOKEN: String = "OAuthToken"
let NODE_SESSION: String = "Session"
// User
type User {
id: String
email: String
display_name: String
created_at: String
}
fn user_new(email: String, display_name: String) -> User {
let now: String = time_now_iso()
let id: String = uuid_v4()
{ "id": id, "email": email, "display_name": display_name, "created_at": now }
}
// Role / Scope
type Role {
id: String
name: String
permissions: String // JSON-encoded array; struct fields are flat in El today
}
type Scope {
id: String
name: String
description: String
}
fn role_new(name: String) -> Role {
{ "id": uuid_v4(), "name": name, "permissions": "[]" }
}
fn role_has_permission(role: Role, perm: String) -> Bool {
str_contains(role.permissions, "\"" + perm + "\"")
}
fn scope_new(name: String, description: String) -> Scope {
{ "id": uuid_v4(), "name": name, "description": description }
}
// Session
type Session {
id: String
user_id: String
created_at: String
expires_at: String
ip_address: String
}
fn session_new(user_id: String, ttl_seconds: Int, ip_address: String) -> Session {
let now: String = time_now_iso()
let exp: String = time_add_seconds(now, ttl_seconds)
{ "id": uuid_v4(), "user_id": user_id, "created_at": now, "expires_at": exp, "ip_address": ip_address }
}
fn session_is_expired(session: Session) -> Bool {
time_after(time_now_iso(), session.expires_at)
}
// OAuthToken
//
// Tokens are SHA-256 hashed before storage the raw token never persists.
type OAuthToken {
id: String
provider: String
access_token_hash: String
refresh_token_hash: String
expires_at: String
scopes: String // JSON-encoded array
}
fn token_hash(raw: String) -> String {
sha256_hex(raw)
}
fn oauth_token_new(provider: String, access_raw: String, refresh_raw: String, expires_at: String, scopes: String) -> OAuthToken {
let access_h: String = token_hash(access_raw)
let refresh_h: String = ""
if !str_eq(refresh_raw, "") { let refresh_h = token_hash(refresh_raw) }
{ "id": uuid_v4(), "provider": provider, "access_token_hash": access_h,
"refresh_token_hash": refresh_h, "expires_at": expires_at, "scopes": scopes }
}
fn oauth_token_is_expired(t: OAuthToken) -> Bool {
time_after(time_now_iso(), t.expires_at)
}
// PKCE (RFC 7636)
type PkceChallenge {
verifier: String
challenge: String
method: String // always "S256"
}
fn pkce_generate() -> PkceChallenge {
let verifier: String = base64url_no_pad(random_bytes(32))
let chal: String = base64url_no_pad(sha256_bytes(verifier))
{ "verifier": verifier, "challenge": chal, "method": "S256" }
}
fn pkce_verify(challenge: String, verifier: String) -> Bool {
let computed: String = base64url_no_pad(sha256_bytes(verifier))
str_eq(computed, challenge)
}
// OAuth providers (Google / GitHub / Apple)
//
// Stubbed: shape-only. `provider_authorization_url` produces the redirect URL;
// `provider_exchange_code` POSTs to the token endpoint via http_post.
type OAuthProviderCfg {
name: String
client_id: String
client_secret: String
auth_url: String
token_url: String
default_scopes: String
}
fn google_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
{ "name": "google", "client_id": client_id, "client_secret": client_secret,
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"default_scopes": "[\"openid\",\"email\",\"profile\"]" }
}
fn github_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
{ "name": "github", "client_id": client_id, "client_secret": client_secret,
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"default_scopes": "[\"read:user\",\"user:email\"]" }
}
fn apple_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
{ "name": "apple", "client_id": client_id, "client_secret": client_secret,
"auth_url": "https://appleid.apple.com/auth/authorize",
"token_url": "https://appleid.apple.com/auth/token",
"default_scopes": "[\"name\",\"email\"]" }
}
fn provider_authorization_url(p: OAuthProviderCfg, redirect_uri: String, code_challenge: String, state: String) -> String {
p.auth_url + "?response_type=code"
+ "&client_id=" + url_encode(p.client_id)
+ "&redirect_uri=" + url_encode(redirect_uri)
+ "&scope=" + url_encode(json_array_to_space_list(p.default_scopes))
+ "&state=" + url_encode(state)
+ "&code_challenge=" + url_encode(code_challenge)
+ "&code_challenge_method=S256"
}
// Engram client interface (graph CRUD)
//
// Identity persists to the local Engram graph. These wrap engram_* runtime
// calls (planned). Until that lands, the server-side stub uses a JSON file.
fn engram_create_node(node_type: String, value_json: String) -> String {
engram_node_create(node_type, value_json)
}
fn engram_create_edge(from_id: String, to_id: String, edge_type: String) -> Bool {
engram_edge_create(from_id, to_id, edge_type)
}
fn engram_find_connected(node_id: String, edge_type: String) -> String {
engram_edge_traverse(node_id, edge_type)
}
// OAuth flow coordinator
type AuthFlowParams {
pkce_verifier: String
pkce_challenge: String
redirect_url: String
state: String
}
fn begin_auth_flow(provider: OAuthProviderCfg, redirect_uri: String) -> AuthFlowParams {
let pkce: PkceChallenge = pkce_generate()
let state: String = base64url_no_pad(random_bytes(16))
let url: String = provider_authorization_url(provider, redirect_uri, pkce.challenge, state)
{ "pkce_verifier": pkce.verifier, "pkce_challenge": pkce.challenge, "redirect_url": url, "state": state }
}
fn exchange_code(provider: OAuthProviderCfg, code: String, pkce_verifier: String, redirect_uri: String, session_id: String) -> OAuthToken {
let body: String = "grant_type=authorization_code"
+ "&code=" + url_encode(code)
+ "&redirect_uri=" + url_encode(redirect_uri)
+ "&client_id=" + url_encode(provider.client_id)
+ "&client_secret=" + url_encode(provider.client_secret)
+ "&code_verifier=" + url_encode(pkce_verifier)
let resp: String = http_post(provider.token_url, body)
let access: String = json_get(resp, "access_token")
let refresh: String = json_get(resp, "refresh_token")
let expires_in: Int = str_to_int(json_get(resp, "expires_in"))
let exp_at: String = time_add_seconds(time_now_iso(), expires_in)
let token: OAuthToken = oauth_token_new(provider.name, access, refresh, exp_at, "[]")
let token_id: String = engram_create_node(NODE_OAUTH_TOKEN, json_encode(token))
engram_create_edge(session_id, token_id, EDGE_AUTHENTICATED_VIA)
token
}
// Session manager
fn session_create(user_id: String, ttl: Int, ip: String) -> Session {
let s: Session = session_new(user_id, ttl, ip)
let session_id: String = engram_create_node(NODE_SESSION, json_encode(s))
engram_create_edge(user_id, session_id, EDGE_HAS_SESSION)
s
}
fn session_revoke(session_id: String) -> Bool {
engram_node_delete(session_id)
}
// AuthGuard applied via @authenticate decorator
fn auth_guard_verify(session_id: String) -> Bool {
let raw: String = engram_node_get(session_id)
if str_eq(raw, "") { return false }
let exp: String = json_get(raw, "expires_at")
!time_after(time_now_iso(), exp)
}
// Identity context (passed through the request lifecycle)
type IdentityContext {
user_id: String
session_id: String
roles_json: String
}
fn identity_load(session_id: String) -> IdentityContext {
let session_raw: String = engram_node_get(session_id)
let user_id: String = json_get(session_raw, "user_id")
let roles_raw: String = engram_find_connected(user_id, EDGE_HAS_ROLE)
{ "user_id": user_id, "session_id": session_id, "roles_json": roles_raw }
}
// Entry smoke test
let user: User = user_new("will@neurontechnologies.ai", "Will Anderson")
println("[el-identity] user " + user.email + " (" + user.id + ")")
+21
View File
@@ -0,0 +1,21 @@
// el-layout Responsive layout engine for el-ui.
//
// Responsive by default. VStack and HStack wrap automatically.
// Grid uses auto columns. You don't write breakpoints for basic layouts.
vessel "el-layout" {
version "0.1.0"
description "Stacks, grids, breakpoints, responsive values, safe-area insets"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-style "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
+237
View File
@@ -0,0 +1,237 @@
// el-layout Responsive layout engine for el-ui.
//
// Primitives:
// VStack, HStack, ZStack stack layouts (wrap by default)
// GridLayout responsive grid
// ScrollView scrollable container
// Responsive<T> value that changes by breakpoint
// Breakpoints
let BP_XS: String = "xs" // < 640px
let BP_SM: String = "sm" // >= 640px
let BP_MD: String = "md" // >= 768px
let BP_LG: String = "lg" // >= 1024px
let BP_XL: String = "xl" // >= 1280px
let BP_XXL: String = "xxl" // >= 1536px
let BP_SM_PX: Int = 640
let BP_MD_PX: Int = 768
let BP_LG_PX: Int = 1024
let BP_XL_PX: Int = 1280
let BP_XXL_PX: Int = 1536
fn breakpoint_for_width(width_px: Int) -> String {
if width_px >= BP_XXL_PX { return BP_XXL }
if width_px >= BP_XL_PX { return BP_XL }
if width_px >= BP_LG_PX { return BP_LG }
if width_px >= BP_MD_PX { return BP_MD }
if width_px >= BP_SM_PX { return BP_SM }
BP_XS
}
// Cascade index used by Responsive<T> to find the most-specific value <= bp.
fn breakpoint_index(bp: String) -> Int {
if str_eq(bp, "xs") { return 0 }
if str_eq(bp, "sm") { return 1 }
if str_eq(bp, "md") { return 2 }
if str_eq(bp, "lg") { return 3 }
if str_eq(bp, "xl") { return 4 }
if str_eq(bp, "xxl") { return 5 }
0
}
// Responsive<T>
//
// Stored as a JSON object: { "xs": v0, "md": v1, "lg": v2 }
// resolve(bp) walks down from bp until it finds a defined value.
fn responsive_fixed(value: String) -> String {
"{\"xs\":" + value + "}"
}
fn responsive_set(rv: String, bp: String, value: String) -> String {
json_set(rv, bp, value)
}
fn responsive_resolve(rv: String, bp: String) -> String {
let order: String = "xxl,xl,lg,md,sm,xs"
let target_idx: Int = breakpoint_index(bp)
// Try each breakpoint <= target, most-specific first.
let probe: String = bp
while !str_eq(probe, "") {
let v: String = json_get(rv, probe)
if !str_eq(v, "") { return v }
let idx: Int = breakpoint_index(probe)
if idx == 0 { return "" }
let probe = breakpoint_step_down(probe)
}
""
}
fn breakpoint_step_down(bp: String) -> String {
if str_eq(bp, "xxl") { return "xl" }
if str_eq(bp, "xl") { return "lg" }
if str_eq(bp, "lg") { return "md" }
if str_eq(bp, "md") { return "sm" }
if str_eq(bp, "sm") { return "xs" }
""
}
// Constraints / Size
type Size {
width: Int
height: Int
}
type LayoutConstraints {
min_width: Int
max_width: Int
min_height: Int
max_height: Int
}
fn constraints_unbounded() -> LayoutConstraints {
{ "min_width": 0, "max_width": 999999, "min_height": 0, "max_height": 999999 }
}
fn constraints_tight(w: Int, h: Int) -> LayoutConstraints {
{ "min_width": w, "max_width": w, "min_height": h, "max_height": h }
}
// Flex axes
let FLEX_ROW: String = "row"
let FLEX_COLUMN: String = "column"
let MAIN_START: String = "start"
let MAIN_END: String = "end"
let MAIN_CENTER: String = "center"
let MAIN_BETWEEN: String = "space-between"
let MAIN_AROUND: String = "space-around"
let MAIN_EVENLY: String = "space-evenly"
let CROSS_START: String = "start"
let CROSS_END: String = "end"
let CROSS_CENTER: String = "center"
let CROSS_STRETCH: String = "stretch"
let CROSS_BASELINE: String = "baseline"
type FlexLayout {
direction: String
main_alignment: String
cross_alignment: String
gap_px: Int
wrap: Bool
}
fn flex_default() -> FlexLayout {
{ "direction": "row", "main_alignment": "start",
"cross_alignment": "stretch", "gap_px": 8, "wrap": true }
}
// Stacks
//
// VStack / HStack / ZStack are component classes in the JS runtime; the
// El side here exposes their layout descriptor the bag of values the
// el-ui-compiler emits into JSX/HTML attributes.
type StackLayout {
direction: String // row | column | depth
main_alignment: String
cross_alignment: String
gap_px: Int
wrap: Bool
spacing_token: String // semantic spacing token (md, lg, ...)
}
fn vstack(spacing_token: String) -> StackLayout {
{ "direction": "column", "main_alignment": "start",
"cross_alignment": "stretch", "gap_px": 0, "wrap": true,
"spacing_token": spacing_token }
}
fn hstack(spacing_token: String) -> StackLayout {
{ "direction": "row", "main_alignment": "start",
"cross_alignment": "center", "gap_px": 0, "wrap": true,
"spacing_token": spacing_token }
}
fn zstack() -> StackLayout {
{ "direction": "depth", "main_alignment": "center",
"cross_alignment": "center", "gap_px": 0, "wrap": false,
"spacing_token": "" }
}
// Grid
type GridLayout {
columns: String // "auto" or "1fr 1fr 1fr" etc
rows: String
gap_px: Int
auto_fit_min: Int // for `repeat(auto-fit, minmax(<min>, 1fr))`
}
fn grid_auto(min_col_px: Int, gap: Int) -> GridLayout {
{ "columns": "auto-fit", "rows": "auto", "gap_px": gap, "auto_fit_min": min_col_px }
}
fn grid_fixed(num_cols: Int, gap: Int) -> GridLayout {
let cols: String = repeat_str("1fr ", num_cols)
{ "columns": cols, "rows": "auto", "gap_px": gap, "auto_fit_min": 0 }
}
fn grid_to_css(g: GridLayout) -> String {
let cols: String = g.columns
if str_eq(cols, "auto-fit") {
let cols = "repeat(auto-fit, minmax(" + int_to_str(g.auto_fit_min) + "px, 1fr))"
}
"display: grid; grid-template-columns: " + cols + "; gap: " + int_to_str(g.gap_px) + "px;"
}
// ScrollView
let SCROLL_X: String = "x"
let SCROLL_Y: String = "y"
let SCROLL_BOTH: String = "both"
type ScrollView {
axis: String
show_indicator: Bool
bounce: Bool // iOS-style overscroll
}
fn scroll_view_y() -> ScrollView {
{ "axis": "y", "show_indicator": true, "bounce": true }
}
// Platform sizing
let PLATFORM_PHONE: String = "phone"
let PLATFORM_TABLET: String = "tablet"
let PLATFORM_DESKTOP: String = "desktop"
type SafeAreaInsets {
top: Int
right: Int
bottom: Int
left: Int
}
fn safe_area_zero() -> SafeAreaInsets {
{ "top": 0, "right": 0, "bottom": 0, "left": 0 }
}
fn platform_for_width(width: Int) -> String {
if width < 768 { return "phone" }
if width < 1024 { return "tablet" }
"desktop"
}
// Entry smoke test
let r: String = responsive_fixed("1")
let r = responsive_set(r, "md", "2")
let r = responsive_set(r, "lg", "3")
println("[el-layout] cols at lg = " + responsive_resolve(r, "lg"))
+23
View File
@@ -0,0 +1,23 @@
// el-platform Platform abstraction surface for el-ui.
//
// Wraps the El runtime's filesystem, network, environment, and clock
// primitives behind a stable API so application vessels depend on this
// vessel rather than runtime builtin names directly.
//
// The Rust crate also implements per-target render backends
// (web/server/ios/android/macos/linux/windows). At the El layer the
// target is fixed at compile time the runtime IS the backend so no
// `PlatformBackend` polymorphism is exposed here. DOM patching and native
// widget mounting will arrive once el-ui-compiler emits browser/native code.
vessel "el-platform" {
version "1.0.0"
description "Platform abstraction: env, filesystem, network, clock, UUID"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
build {
entry "src/main.el"
output "dist/"
}
@@ -4,7 +4,7 @@
//! on the component tree and returns the result as an HTTP response.
//!
//! The same component code runs server-side without any changes. Only the
//! backend (chosen by `el.toml`) differs.
//! backend (chosen by `manifest.el`) differs.
use crate::{EventHandler, PlatformBackend, PlatformError, PlatformNode, PlatformResult};
@@ -1,4 +1,4 @@
//! Platform configuration — parsed from `el.toml`.
//! Platform configuration — parsed from `manifest.el`.
/// Which platform to target for rendering.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -20,7 +20,7 @@ pub enum PlatformTarget {
}
impl PlatformTarget {
/// Parse from the string value used in `el.toml`.
/// Parse from the string value used in `manifest.el`.
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"web" => Some(Self::Web),
@@ -53,7 +53,7 @@ impl PlatformTarget {
}
}
/// Full platform configuration, reflecting the `[platform]` section of `el.toml`.
/// Full platform configuration, reflecting the `[platform]` section of `manifest.el`.
#[derive(Debug, Clone)]
pub struct PlatformConfig {
pub target: PlatformTarget,
@@ -3,7 +3,7 @@
//! The same component code produces native output for every target platform.
//! No bridge. No virtual DOM. Direct platform calls.
//!
//! The target is chosen in `el.toml`:
//! The target is chosen in `manifest.el`:
//!
//! ```toml
//! [platform]
@@ -50,7 +50,7 @@ pub type PlatformResult<T> = Result<T, PlatformError>;
/// The core trait every platform backend must implement.
///
/// All rendering paths go through this interface. Component code is identical
/// across targets — only the backend chosen by `el.toml` differs.
/// across targets — only the backend chosen by `manifest.el` differs.
pub trait PlatformBackend: Send + Sync {
/// The platform name (e.g. "web", "server", "ios").
fn name(&self) -> &'static str;
+152
View File
@@ -0,0 +1,152 @@
// el-platform Platform abstraction surface for el-ui apps.
//
// The Rust crate defines `PlatformBackend` trait + per-target render backends
// (web/server/ios/android/macos/linux/windows). The El surface is narrower:
// El apps reach the host through the runtime's filesystem, network, and OS
// primitives. This vessel wraps those into a stable, named API so El callers
// don't depend directly on builtin names.
//
// RUNTIME PARITY GAPS:
// - There is no `PlatformBackend` polymorphism at the El layer. The runtime
// IS the backend; target selection happens during compilation/packaging,
// not at runtime.
// - DOM mutation, native widget mounting, event binding none of those
// have El surfaces yet. Use el-ui-compiler when those land.
// - `fs_list` returns a JSON array string; consumers must parse with the
// forthcoming json_array_get builtin. Until that lands, callers should
// treat the value opaquely.
// Targets
fn target_web() -> String { "web" }
fn target_server() -> String { "server" }
fn target_ios() -> String { "ios" }
fn target_android() -> String { "android" }
fn target_macos() -> String { "macos" }
fn target_linux() -> String { "linux" }
fn target_windows() -> String { "windows" }
// The compiled binary's target is fixed at build time. EL_TARGET is set by
// the build harness; absent EL_TARGET, default to "server" (the El runtime
// runs as a host process).
fn platform_target() -> String {
let t: String = env("EL_TARGET")
if str_eq(t, "") { return "server" }
t
}
fn platform_is_native(t: String) -> Bool {
if str_eq(t, "ios") { return true }
if str_eq(t, "android") { return true }
if str_eq(t, "macos") { return true }
if str_eq(t, "linux") { return true }
if str_eq(t, "windows") { return true }
false
}
fn platform_supports_ssr(t: String) -> Bool {
str_eq(t, "server")
}
// Errors
fn err_io() -> String { "platform.io" }
fn err_network() -> String { "platform.network" }
fn err_unsupported() -> String { "platform.unsupported" }
fn err_env_missing() -> String { "platform.env_missing" }
// Environment
fn platform_env(key: String) -> String {
env(key)
}
fn platform_env_or(key: String, fallback: String) -> String {
let v: String = env(key)
if str_eq(v, "") { return fallback }
v
}
// Strict variant: returns "" if missing, callers branch.
// (Runtime has no panic() fail-soft return preserves type.)
fn platform_env_required(key: String) -> String {
let v: String = env(key)
if str_eq(v, "") {
println("[el-platform] ERROR " + err_env_missing() + ":" + key)
return ""
}
v
}
// Filesystem
fn platform_fs_read(path: String) -> String {
fs_read(path)
}
fn platform_fs_write(path: String, content: String) -> Bool {
fs_write(path, content)
true
}
// Returns a JSON array of entry names (opaque until json_array_get lands).
fn platform_fs_list(path: String) -> String {
let raw: String = fs_list(path)
if str_eq(raw, "") { return "[]" }
raw
}
fn platform_fs_exists(path: String) -> Bool {
let raw: String = fs_read(path)
!str_eq(raw, "")
}
// HTTP
fn platform_http_get(url: String) -> String {
http_get(url)
}
fn platform_http_post(url: String, body: String) -> String {
http_post(url, body)
}
fn platform_http_post_json(url: String, json_body: String) -> String {
http_post_json(url, json_body)
}
fn platform_http_get_authed(url: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_get_with_headers(url, headers)
}
fn platform_http_post_authed(url: String, body: String, bearer: String) -> String {
let headers: String = "Authorization: Bearer " + bearer
http_post_with_headers(url, body, headers)
}
// Time / OS clock
fn platform_now() -> Int {
time_now()
}
fn platform_sleep_ms(ms: Int) -> Bool {
sleep_ms(ms)
true
}
// Identity (for trace/log correlation)
fn platform_uuid() -> String {
uuid_v4()
}
// Entry smoke test
//
// elc-bug-workaround: top-level `let` does not scope into the surrounding
// statement substitution in the entry block, so we call platform_target()
// inline at each use site.
println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target())))
println("[el-platform] uuid=" + platform_uuid())
+25
View File
@@ -0,0 +1,25 @@
// el-publish App Store and Play Store publishing pipeline.
//
// One command ships to every platform:
// el publish all platforms
// el publish --apple App Store only
// el publish --google Play Store only
// el publish --beta TestFlight + Play internal track
vessel "el-publish" {
version "0.1.0"
description "Apple App Store + Google Play publishing automation"
authors ["Will Anderson <will@neurontechnologies.ai>"]
edition "2026"
}
dependencies {
el-platform "1.0"
el-config "0.1"
el-secrets "0.1"
}
build {
entry "src/main.el"
output "dist/"
}
@@ -23,7 +23,7 @@ pub struct ApplePublisher {
impl ApplePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let apple_config = publish_config.apple.clone().ok_or_else(|| {
PublishError::Config("no [publish.apple] section in el.toml".into())
PublishError::Config("no [publish.apple] section in manifest.el".into())
})?;
Ok(Self {
apple_config,
@@ -1,4 +1,4 @@
//! Publish configuration — parsed from the `[publish]` section of `el.toml`.
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
/// App Store Connect / TestFlight configuration.
#[derive(Debug, Clone)]
@@ -19,7 +19,7 @@ pub struct GooglePublisher {
impl GooglePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let google_config = publish_config.google.clone().ok_or_else(|| {
PublishError::Config("no [publish.google] section in el.toml".into())
PublishError::Config("no [publish.google] section in manifest.el".into())
})?;
Ok(Self { google_config, publish_config })
}
@@ -8,7 +8,7 @@
//! el publish --beta # TestFlight + Play internal track
//! ```
//!
//! Configuration in `el.toml`:
//! Configuration in `manifest.el`:
//! ```toml
//! [publish]
//! version = "1.0.0"
+243
View File
@@ -0,0 +1,243 @@
// el-publish App Store + Play Store publishing pipeline.
//
// Two providers (Apple, Google), one publish flow:
// 1. Validate config + certs
// 2. Build artifact (delegated to el-ui-compiler / xcodebuild / gradle)
// 3. Capture metadata + screenshots
// 4. Upload to App Store Connect / Play Developer API
// 5. Monitor rollout
// Errors
let PUB_ERR_CONFIG: String = "publish.config"
let PUB_ERR_BUILD: String = "publish.build"
let PUB_ERR_UPLOAD: String = "publish.upload"
let PUB_ERR_CERT: String = "publish.certificate"
let PUB_ERR_METADATA: String = "publish.metadata"
let PUB_ERR_API: String = "publish.api"
let PUB_ERR_IO: String = "publish.io"
// Tracks
let TRACK_PRODUCTION: String = "production"
let TRACK_BETA: String = "beta" // TestFlight on Apple; "beta" on Google
let TRACK_INTERNAL: String = "internal"
let TRACK_ALPHA: String = "alpha"
// Apple config
type AppleConfig {
account: String // Apple ID
bundle_id: String // ai.neurontechnologies.myapp
team_id: String
api_key_id: String // App Store Connect API key
api_issuer: String
}
// Google config
type GoogleConfig {
package: String // ai.neurontechnologies.myapp
track: String // production | beta | internal | alpha
service_account_json_ref: String // secret ref to service-account JSON
}
// Rollout (staged)
type RolloutConfig {
initial_percent: Int // 0..100
target_percent: Int
bake_hours: Int // hours between rollout stages
}
fn rollout_default() -> RolloutConfig {
{ "initial_percent": 5, "target_percent": 100, "bake_hours": 24 }
}
// Top-level publish config
type PublishConfig {
version: String
build_number: Int
apple_json: String // empty if not configured
google_json: String
rollout_json: String // RolloutConfig as JSON
}
fn publish_config_new(version: String, build_number: Int) -> PublishConfig {
{ "version": version, "build_number": build_number,
"apple_json": "", "google_json": "", "rollout_json": json_encode(rollout_default()) }
}
// Outcome
type PublishOutcome {
platform: String
version: String
build_number: Int
track: String
rollout_percent: Int
submission_id: String
}
fn outcome_new(platform: String, cfg: PublishConfig, track: String) -> PublishOutcome {
let pct: Int = 100
if !str_eq(cfg.rollout_json, "") {
let pct = str_to_int(json_get(cfg.rollout_json, "initial_percent"))
}
{ "platform": platform, "version": cfg.version, "build_number": cfg.build_number,
"track": track, "rollout_percent": pct, "submission_id": "" }
}
// Cert store
//
// Tracks .p12 / .mobileprovision (Apple) and signing keystores (Google).
type CertInfo {
kind: String // "apple_p12" | "apple_provisioning" | "google_keystore"
path: String
expires_at: String // ISO 8601
fingerprint: String
}
fn cert_is_valid(c: CertInfo) -> Bool {
!time_after(time_now_iso(), c.expires_at)
}
fn cert_store_load(dir: String) -> String {
// Returns JSON array of CertInfo loaded from `dir`.
let files: String = fs_list(dir)
let out: String = "[]"
let n: Int = json_array_len(files)
let i: Int = 0
while i < n {
let path: String = json_array_get(files, i)
let info: CertInfo = cert_inspect(path)
let out = json_array_push(out, json_encode(info))
let i = i + 1
}
out
}
fn cert_inspect(path: String) -> CertInfo {
let kind: String = "apple_p12"
if str_ends_with(path, ".mobileprovision") { let kind = "apple_provisioning" }
if str_ends_with(path, ".jks") { let kind = "google_keystore" }
if str_ends_with(path, ".keystore") { let kind = "google_keystore" }
let exp: String = exec_capture("openssl", "x509 -enddate -noout -in " + path)
{ "kind": kind, "path": path, "expires_at": exp, "fingerprint": "" }
}
// Metadata
type StoreMetadata {
title: String
subtitle: String
description: String
keywords: String // comma-separated
privacy_url: String
support_url: String
locale: String // e.g. "en-US"
}
fn metadata_load(path: String, locale: String) -> StoreMetadata {
let raw: String = fs_read(path + "/" + locale + ".toml")
let json: String = toml_to_json(raw)
{ "title": json_get(json, "title"),
"subtitle": json_get(json, "subtitle"),
"description": json_get(json, "description"),
"keywords": json_get(json, "keywords"),
"privacy_url": json_get(json, "privacy_url"),
"support_url": json_get(json, "support_url"),
"locale": locale }
}
// Screenshots
type ScreenshotTarget {
platform: String // "apple" | "google"
device_class: String // "iphone-6.7" | "ipad-12.9" | "phone" | "tablet" | "tv"
locale: String
expected_resolution: String // "1290x2796"
}
fn target_iphone_6_7(locale: String) -> ScreenshotTarget {
{ "platform": "apple", "device_class": "iphone-6.7",
"locale": locale, "expected_resolution": "1290x2796" }
}
fn target_phone_google(locale: String) -> ScreenshotTarget {
{ "platform": "google", "device_class": "phone",
"locale": locale, "expected_resolution": "1080x1920" }
}
fn screenshot_capture(target: ScreenshotTarget, simulator_id: String, output_dir: String) -> String {
// Drives simulator/emulator to capture at expected resolution.
let path: String = output_dir + "/" + target.platform + "_" + target.device_class
+ "_" + target.locale + ".png"
exec_capture("xcrun", "simctl io " + simulator_id + " screenshot " + path)
path
}
// Apple publisher
fn apple_publish(cfg: PublishConfig, apple: AppleConfig, ipa_path: String, track: String) -> PublishOutcome {
let api_key: String = secret_lookup("apple.api_key")
let resp: String = exec_capture("xcrun", "altool --upload-app -f " + ipa_path
+ " --type ios --apiKey " + apple.api_key_id
+ " --apiIssuer " + apple.api_issuer)
let outcome: PublishOutcome = outcome_new("apple", cfg, track)
let submission: String = json_get(resp, "submission_id")
{ "platform": outcome.platform, "version": outcome.version,
"build_number": outcome.build_number, "track": outcome.track,
"rollout_percent": outcome.rollout_percent, "submission_id": submission }
}
// Google publisher
fn google_publish(cfg: PublishConfig, google: GoogleConfig, aab_path: String) -> PublishOutcome {
let sa_json: String = secret_lookup(google.service_account_json_ref)
let token: String = google_oauth_token(sa_json)
let edit: String = google_play_edit_create(google.package, token)
let upload: String = google_play_upload_aab(google.package, edit, aab_path, token)
let assigned: String = google_play_track_assign(google.package, edit, google.track,
cfg.version, cfg.build_number, token)
google_play_edit_commit(google.package, edit, token)
let outcome: PublishOutcome = outcome_new("google", cfg, google.track)
{ "platform": outcome.platform, "version": outcome.version,
"build_number": outcome.build_number, "track": outcome.track,
"rollout_percent": outcome.rollout_percent, "submission_id": json_get(upload, "id") }
}
// Rollout monitor
fn rollout_monitor(outcome: PublishOutcome, cfg: RolloutConfig) -> Bool {
// Polls store status, advances rollout percentage in stages.
let current: Int = outcome.rollout_percent
while current < cfg.target_percent {
sleep_seconds(cfg.bake_hours * 3600)
let crash_rate: Int = fetch_crash_rate(outcome.platform, outcome.version)
if crash_rate > 100 { // 1% crash threshold (per 10000)
println("[el-publish] rollout halted: crash rate too high")
return false
}
let next: Int = current + 25
if next > cfg.target_percent { let next = cfg.target_percent }
rollout_advance(outcome, next)
let current = next
}
true
}
fn rollout_advance(outcome: PublishOutcome, percent: Int) -> Bool {
println("[el-publish] " + outcome.platform + " rollout -> " + int_to_str(percent) + "%")
true
}
// Entry smoke test
let cfg: PublishConfig = publish_config_new("1.0.0", 42)
let apple: AppleConfig = { "account": "will@neurontechnologies.ai",
"bundle_id": "ai.neurontechnologies.myapp", "team_id": "ABC123",
"api_key_id": "KEY", "api_issuer": "ISS" }
println("[el-publish] " + cfg.version + " (" + int_to_str(cfg.build_number) + ") for " + apple.bundle_id)

Some files were not shown because too many files have changed in this diff Show More