Archived
0fddb7b5be
Bumps spec version to 1.2.0. Documents: - SSR engine in runtime/src/ssr.js (Node.js, fully implemented) - Hydration markers (data-el-component, data-el-id, data-el-state) - hydrate() vs mount() and when to use each - Full SSR + hydration lifecycle with code examples - elc-ui CLI --target=server and --target=web flags - Section 11: complete SSR + hydration walkthrough - Section 12: versioning roadmap updated to reflect v0.1 actual state - Section 9: clarifies that SSR is JS-implemented, Rust PlatformBackend is planned
892 lines
36 KiB
Markdown
892 lines
36 KiB
Markdown
# el-ui Framework Specification
|
||
|
||
Version 1.2.0 — May 4, 2026
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
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 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: 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 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:
|
||
|
||
| 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 directed **edges** with:
|
||
|
||
| 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 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:
|
||
|
||
```
|
||
strength = parent_strength × edge.weight × target.importance × cosine_sim(query, target)
|
||
```
|
||
|
||
### 1.3 Why Multiplication, Not Addition
|
||
|
||
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.
|
||
|
||
### 1.4 Pruning Threshold
|
||
|
||
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.
|
||
|
||
### 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 (shared extension with the El programming language). A component is a specialized el module with four optional sections.
|
||
|
||
### 2.1 Component Structure
|
||
|
||
```
|
||
component ComponentName {
|
||
props {
|
||
// prop declarations
|
||
}
|
||
|
||
state {
|
||
// state declarations
|
||
}
|
||
|
||
fn methodName(param: Type) -> ReturnType {
|
||
// method body
|
||
}
|
||
|
||
template {
|
||
// HTML template
|
||
}
|
||
}
|
||
```
|
||
|
||
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 read-only inputs from the parent component.
|
||
|
||
```
|
||
props {
|
||
label: String // required prop
|
||
variant: String = "primary" // optional with default
|
||
disabled: Bool = false // boolean with default
|
||
onClick: Fn() -> Void // function prop
|
||
}
|
||
```
|
||
|
||
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. Every state variable requires an initial value.
|
||
|
||
```
|
||
state {
|
||
count: Int = 0
|
||
label: String = "hello"
|
||
active: Bool = false
|
||
}
|
||
```
|
||
|
||
Each state variable becomes:
|
||
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. State assignments in method bodies compile to `setState()` calls.
|
||
|
||
```
|
||
fn increment() -> Void {
|
||
count = count + 1 // compiles to: __self.setState('count', count + 1)
|
||
}
|
||
```
|
||
|
||
The generated JavaScript method exposes all state variables as local `const` bindings before executing the body. The variable `__self` refers to the component instance.
|
||
|
||
---
|
||
|
||
## 3. Template Syntax
|
||
|
||
### 3.1 Interpolation
|
||
|
||
Any expression embedded in `{expr}`:
|
||
|
||
```
|
||
<h1>{count}</h1>
|
||
<p>{"Hello, " + name + "!"}</p>
|
||
<span>{active ? "on" : "off"}</span>
|
||
```
|
||
|
||
### 3.2 Event Binding
|
||
|
||
DOM events bound with `on:event={handler}`:
|
||
|
||
```
|
||
<button on:click={() => count = count + 1}>+</button>
|
||
<input on:input={(e) => value = e.target.value} />
|
||
<div on:mouseenter={() => hovered = true} on:mouseleave={() => hovered = false} />
|
||
```
|
||
|
||
Supported events: `click`, `input`, `change`, `mouseenter`, `mouseleave`, `keydown`, `keyup`, `keypress`, `focus`, `blur`, `submit`, `mousedown`, `mouseup`, `dblclick`, `contextmenu`.
|
||
|
||
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
|
||
|
||
```
|
||
<div class={active ? "btn-active" : "btn"}></div>
|
||
<a href={currentUrl}></a>
|
||
```
|
||
|
||
### 3.4 Static Attributes
|
||
|
||
```
|
||
<div class="container"></div>
|
||
<input type="text" />
|
||
```
|
||
|
||
### 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} />
|
||
```
|
||
|
||
A standalone attribute with no value (e.g., `<button disabled>`) is also parsed and treated as `BoolAttr { expr: "true" }`.
|
||
|
||
### 3.6 Component Invocation
|
||
|
||
Uppercase-initial tags are el-ui components; lowercase tags are HTML elements:
|
||
|
||
```
|
||
<Button label="Click me" variant="primary" onClick={() => handleClick()} />
|
||
<Counter initialValue={5} />
|
||
<UserCard name={currentUser.name} />
|
||
```
|
||
|
||
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 loggedIn}
|
||
<Dashboard />
|
||
{:else}
|
||
<Login />
|
||
{/if}
|
||
```
|
||
|
||
Compiles to a ternary template literal expression: `${(condition) ? `...` : `...`}`.
|
||
|
||
### 3.8 List Rendering — `{#each}`
|
||
|
||
```
|
||
{#each users as user}
|
||
<UserCard name={user.name} />
|
||
{/each}
|
||
```
|
||
|
||
The `items` expression must evaluate to an array. Compiles to `${(items).map((user) => `...`).join('')}`.
|
||
|
||
### 3.9 Semantic Activation — `{#activate}` (Novel Construct)
|
||
|
||
```
|
||
{#activate "recent premium subscribers" as results}
|
||
<UserCard name={results.name} />
|
||
{/activate}
|
||
```
|
||
|
||
`{#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. 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.
|
||
|
||
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. State Graph
|
||
|
||
### 4.1 Graph Structure
|
||
|
||
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 State Node Creation
|
||
|
||
```javascript
|
||
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.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. Default weight is `1.0`, default relation is `'related'`.
|
||
|
||
### 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) => {
|
||
// called whenever this node enters the activation surface
|
||
});
|
||
unsubscribe(); // stop listening
|
||
```
|
||
|
||
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: [{ 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. Spreading Activation Implementation (In-Browser)
|
||
|
||
The in-browser spreading activation algorithm in `graph.js` (and standalone utilities in `activation.js`) mirrors `engram-core/src/activation.rs`:
|
||
|
||
```javascript
|
||
// 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 */ }
|
||
}
|
||
}
|
||
```
|
||
|
||
**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)
|
||
|
||
**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
|
||
|
||
The in-browser graph omits the `cosine_sim` factor because embedding vectors are not yet available in the browser.
|
||
|
||
### 5.1 Standalone Activation Utilities (`activation.js`)
|
||
|
||
```javascript
|
||
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);
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Router — Graph-Based Routing
|
||
|
||
### 6.1 Overview
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 6.2 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.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. Compilation Pipeline
|
||
|
||
### 7.1 Overview
|
||
|
||
```
|
||
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` — Node.js SSR
|
||
|
||
Emits a Node.js CommonJS module (the "render script") that exports `renderToString(componentName, props)` and `renderToDocument(componentName, props, shellOpts)`. The render script uses `runtime/src/ssr.js` to parse the component source at module load time and render to HTML strings at call time.
|
||
|
||
The `build_node_tree()` / template-to-HTML pipeline is implemented in `runtime/src/ssr.js`. It covers all TemplateNode variants: `Element`, `Text`, `Interpolation`, `If`, `Each`, `Activate`, `Component`, and `RawHtml`. The `{#activate}` block renders empty on the server (no Engram server available at SSR time); the client runtime re-runs semantic queries after hydration.
|
||
|
||
The `ServerBackend` target is the only target where hydration markers are injected: `data-el-component`, `data-el-id`, and `data-el-state` on each component root element.
|
||
|
||
#### `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 1–6
|
||
- `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
|
||
|
||
The `elc-ui` Node.js script in `vessels/el-ui-compiler/elc-ui` exposes the compiler as a command-line tool:
|
||
|
||
```bash
|
||
elc-ui App.el # compile to App.js (web target, default)
|
||
elc-ui App.el -o app.js # explicit output path
|
||
elc-ui App.el --target=server # emit Node.js render script
|
||
elc-ui App.el --target=server -o render.js
|
||
elc-ui App.el --runtime=../el-ui.js # custom runtime path for web target
|
||
```
|
||
|
||
**Web target** emits an ES2022 JavaScript module — the same class-per-component format as the hand-compiled examples in `examples/counter/app.js`. Requires the el-ui runtime (`el-ui.js` or `dist/el-ui.js`) to be available at the path specified by `--runtime`.
|
||
|
||
**Server target** emits a Node.js CJS module that:
|
||
- Embeds the component source at compile time (base64-encoded)
|
||
- Exports `renderToString(componentName, props)` → HTML string
|
||
- Exports `renderToDocument(componentName, props, shellOpts)` → full HTML document
|
||
- Exports `componentNames` → array of component names found in the source
|
||
- Supports CLI invocation: `node render.js ComponentName '{"prop":"val"}'`
|
||
|
||
The server render script is self-contained: it does not require re-reading the `.el` source file at render time. The El web server calls it via `exec()` and captures stdout as the rendered HTML string.
|
||
|
||
---
|
||
|
||
## 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()`, `hydrate()`, re-exports |
|
||
| `ssr.js` | Server-side rendering engine (Node.js only, not a browser module) |
|
||
|
||
### 8.2 Component Lifecycle
|
||
|
||
**Client-side (mount path):**
|
||
|
||
| Lifecycle event | Description |
|
||
|-----------------|-------------|
|
||
| `constructor(props, initialState?)` | Seeds state graph, subscribes to activation. `initialState` is used by `hydrate()` to pre-load server state. |
|
||
| `render()` | Returns HTML string; called on activation |
|
||
| `onMount()` | Called after first render and DOM attachment |
|
||
|
||
**SSR + hydration path:**
|
||
|
||
| Lifecycle event | Description |
|
||
|-----------------|-------------|
|
||
| `constructor(props, initialState)` | Called by `hydrate()` with recovered server state |
|
||
| `_hydrate(root)` | Subscribes state nodes, creates renderer pointing at existing DOM, binds events. Does NOT call `render()`. |
|
||
| `onHydrate()` | Called after hydration completes |
|
||
|
||
**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', { optionalProp: 'value' });
|
||
// mount() returns the live Component instance
|
||
```
|
||
|
||
`mount` instantiates the root component, creates a `Renderer`, calls `renderer.mount()` which calls `render()`, sets `root.innerHTML`, binds events, then calls `onMount()`.
|
||
|
||
### 8.5 Hydration
|
||
|
||
```javascript
|
||
import { hydrate } from './el-ui.js';
|
||
import { Counter } from './app.js';
|
||
|
||
// Hydrate a server-rendered Counter without replacing the DOM
|
||
const component = hydrate('[data-el-component="Counter"]', Counter);
|
||
// or with a stable ID for multi-instance pages:
|
||
const component = hydrate('[data-el-id="el-counter-1"]', Counter);
|
||
```
|
||
|
||
`hydrate` finds the SSR root element, reads `data-el-state` to recover the server's initial state, constructs the component with that state pre-loaded, then calls `_hydrate(root)` which binds all event handlers without re-rendering. The existing server-rendered DOM is preserved; spreading activation begins on the first `setState()` call.
|
||
|
||
**SSR lifecycle:**
|
||
|
||
```
|
||
Server: renderToString(src, 'App', props) → HTML with markers
|
||
Network: HTML delivered, painted immediately (zero JS latency)
|
||
Client: import { hydrate } from './el-ui.js'
|
||
hydrate('[data-el-component="App"]', App)
|
||
→ events bound, spreading activation active
|
||
→ subsequent setState() calls patch the DOM normally
|
||
```
|
||
|
||
### 8.6 Server-Side Rendering
|
||
|
||
The SSR engine in `runtime/src/ssr.js` is a Node.js module that parses `.el` source files and renders components to HTML strings:
|
||
|
||
```javascript
|
||
const { renderToString, renderToDocument, parseComponents } = require('./runtime/src/ssr.js');
|
||
const fs = require('fs');
|
||
|
||
const src = fs.readFileSync('App.el', 'utf8');
|
||
|
||
// Render to HTML fragment
|
||
const html = renderToString(src, 'App', { title: 'Hello' });
|
||
|
||
// Render to full document
|
||
const doc = renderToDocument(src, 'App', { title: 'Hello' }, {
|
||
title: 'My App',
|
||
appScriptPath: './hydrate.js',
|
||
styles: 'body { background: #000; }',
|
||
});
|
||
```
|
||
|
||
**Hydration markers** emitted on every component root element:
|
||
|
||
| Attribute | Value | Purpose |
|
||
|-----------|-------|---------|
|
||
| `data-el-component` | component name | identifies component type for `hydrate()` |
|
||
| `data-el-id` | stable position-based ID | locates the exact instance when multiple components of the same type exist |
|
||
| `data-el-state` | JSON string | server's initial state snapshot; `hydrate()` reads this to initialize the Engram graph |
|
||
|
||
Event bindings are preserved as `data-el-*` attributes (e.g., `data-el-click="() => increment()"`). The client hydration pass binds these handlers via `new Function('__self', ...)` exactly as the standard mount path does.
|
||
|
||
**Server/client bridge:**
|
||
|
||
The El web server calls the render script as a subprocess:
|
||
|
||
```bash
|
||
node render.js ComponentName '{"prop":"value"}'
|
||
# stdout: the rendered HTML string
|
||
```
|
||
|
||
The render script is generated by `elc-ui --target=server`. It embeds the component source at compile time, so no `.el` file is needed at render time.
|
||
|
||
### 8.4 DOM Patching Strategy (v0.1)
|
||
|
||
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.
|
||
|
||
After patching, the renderer attempts to restore focus to the previously focused element by `id`.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 9. Platform Backend Architecture (`el-platform`)
|
||
|
||
The `el-platform` crate defines the `PlatformBackend` trait that all rendering backends implement:
|
||
|
||
```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`. In v0.1, the server rendering implementation is in `runtime/src/ssr.js` (Node.js), not in a Rust crate. The `PlatformBackend` interface above describes the planned Rust abstraction; the current implementation fulfills the same contract via the JavaScript SSR engine, which is the authoritative server rendering path. All other backends are platform-native.
|
||
|
||
Platform is selected via `manifest.el`:
|
||
|
||
```toml
|
||
[platform]
|
||
target = "web" # web | server | ios | android | macos | linux | windows
|
||
ssr = true
|
||
```
|
||
|
||
---
|
||
|
||
## 10. Comparison to Other Frameworks
|
||
|
||
### 10.1 vs. React
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 10.2 vs. Vue
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 10.3 vs. Svelte
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
---
|
||
|
||
## 11. SSR + Hydration Example
|
||
|
||
A complete SSR + hydration example lives in `examples/ssr-counter/`.
|
||
|
||
### 11.1 Counter.el
|
||
|
||
The component definition with state, methods, `{#if}` conditionals, and child composition:
|
||
|
||
```
|
||
component Counter {
|
||
props {
|
||
label: String = "Counter"
|
||
}
|
||
state {
|
||
count: Int = 0
|
||
history: String = ""
|
||
}
|
||
fn increment() -> Void {
|
||
count = count + 1
|
||
history = history + "+" + count + " "
|
||
}
|
||
template {
|
||
<div class="counter">
|
||
<h2>{label}</h2>
|
||
<span class={count > 0 ? "count positive" : "count zero"}>{count}</span>
|
||
<button on:click={() => increment()}>+</button>
|
||
{#if history != ""}
|
||
<code>{history}</code>
|
||
{/if}
|
||
</div>
|
||
}
|
||
}
|
||
```
|
||
|
||
### 11.2 Compile
|
||
|
||
```bash
|
||
# Server render script (embed source, export renderToString)
|
||
elc-ui Counter.el --target=server -o render.js
|
||
|
||
# Browser module (class-per-component, for hydration)
|
||
elc-ui Counter.el --target=web --runtime=../../dist/el-ui.js -o app.js
|
||
```
|
||
|
||
### 11.3 Server Render
|
||
|
||
```bash
|
||
# Render to HTML fragment
|
||
node render.js Counter
|
||
# => <div class="counter" data-el-component="Counter" data-el-id="el-counter-1"
|
||
# data-el-state='{"count":0,"history":""}'>
|
||
# <h2>Counter</h2>
|
||
# <span class="count zero">0</span>
|
||
# <button data-el-click="() => increment()">+</button>
|
||
# <p class="hint">Press + or - to begin.</p>
|
||
# </div>
|
||
```
|
||
|
||
Or from Node.js:
|
||
|
||
```javascript
|
||
const { renderToString } = require('./render.js');
|
||
const html = renderToString('Counter', { label: 'Score' });
|
||
```
|
||
|
||
### 11.4 Client Hydration
|
||
|
||
```javascript
|
||
// hydrate.js — runs in browser after SSR HTML is painted
|
||
import { hydrate } from '../../dist/el-ui.js';
|
||
import { Counter } from './app.js';
|
||
|
||
// Find each server-rendered Counter and attach the runtime
|
||
document.querySelectorAll('[data-el-component="Counter"]').forEach(el => {
|
||
if (el.dataset.elHydrated) return;
|
||
hydrate(`[data-el-id="${el.dataset.elId}"]`, Counter);
|
||
el.dataset.elHydrated = 'true';
|
||
});
|
||
```
|
||
|
||
### 11.5 Full Lifecycle
|
||
|
||
```
|
||
1. Server calls: node render.js App
|
||
2. Server injects: rendered HTML into <div id="app">
|
||
3. Browser: HTML painted immediately (no JS needed for first paint)
|
||
4. Browser: <script type="module" src="./hydrate.js"> executes
|
||
5. hydrate.js: reads data-el-state, constructs Counter with server state
|
||
6. hydrate.js: binds data-el-click handlers without re-rendering DOM
|
||
7. User clicks +: count = count + 1 → setState → spreading activation → patch
|
||
```
|
||
|
||
The DOM is never blank. The page is interactive as soon as hydrate.js finishes executing. State is continuous across the SSR/hydration boundary because `data-el-state` carries the exact initial values the server used.
|
||
|
||
---
|
||
|
||
## 12. Versioning Roadmap
|
||
|
||
| Version | Key changes |
|
||
|---------|-------------|
|
||
| v0.1.x | Current. Full re-render on state change. String-based `{#activate}` search. Web and Server targets via `elc-ui` CLI. SSR engine implemented (`runtime/src/ssr.js`). `hydrate()` function in client runtime. Native targets available via library API but `build_node_tree()` 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. |
|