From 619d3ec53863e53a5c7b672189daf082df8c5615 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 08:28:44 +0200 Subject: [PATCH] feat: dynamic mode --- backend/converter.py | 4 + backend/pages.py | 11 +- backend/uframe/__init__.py | 70 ++++ backend/uframe/codegen.py | 322 ++++++++++++++++++ backend/uframe/ir.py | 116 ++++++- backend/uframe/layout.py | 25 +- backend/uframe/measure.py | 65 +++- backend/uframe/paint.py | 55 +++ backend/uframe/parser.py | 166 ++++++++- backend/uframe/tests/test_compile.py | 55 +++ backend/uframe/tests/test_dynamic.py | 124 +++++++ .../src/components/editor/PreviewPane.tsx | 53 +-- frontend/src/components/editor/examples.ts | 68 ++++ .../src/components/editor/micronRenderer.ts | 68 +++- frontend/src/hooks/useCompile.ts | 4 +- frontend/src/stores/editorStore.ts | 16 +- 16 files changed, 1184 insertions(+), 38 deletions(-) create mode 100644 backend/uframe/codegen.py create mode 100644 backend/uframe/tests/test_dynamic.py diff --git a/backend/converter.py b/backend/converter.py index d0b2965..90d9c7b 100644 --- a/backend/converter.py +++ b/backend/converter.py @@ -17,6 +17,8 @@ class CompileRequest(BaseModel): class CompileResponse(BaseModel): ascii: str micron: str + script: str + is_dynamic: bool warnings: list[str] @@ -28,6 +30,8 @@ async def compile_source(req: CompileRequest): return CompileResponse( ascii=result.ascii, micron=result.micron, + script=result.script, + is_dynamic=result.is_dynamic, warnings=[w.message for w in result.warnings], ) except UFrameError as e: diff --git a/backend/pages.py b/backend/pages.py index 7201392..a73f8f3 100644 --- a/backend/pages.py +++ b/backend/pages.py @@ -150,7 +150,16 @@ async def save_page(name: str, req: SaveRequest): try: result = uframe.compile(req.source) mu_path = PAGES_DIR / f"{name}.mu" - mu_path.write_text(result.micron, encoding="utf-8") + + if result.is_dynamic and result.script: + # Dynamic page: write executable Python script + mu_path.write_text(result.script, encoding="utf-8") + mu_path.chmod(0o755) # Set execute bit for NomadNet + else: + # Static page: write compiled Micron + mu_path.write_text(result.micron, encoding="utf-8") + # Remove execute bit if it was previously dynamic + mu_path.chmod(0o644) except Exception as e: raise HTTPException( status_code=422, diff --git a/backend/uframe/__init__.py b/backend/uframe/__init__.py index 768c8e3..4f7ec33 100644 --- a/backend/uframe/__init__.py +++ b/backend/uframe/__init__.py @@ -17,6 +17,10 @@ from uframe.borders import merge_borders from uframe.grid import CharGrid from uframe.emit_ascii import emit_ascii from uframe.emit_micron import emit_micron +from uframe.ir import ( + IRNode, Field, Password, Radio, Checkbox, FormButton, + Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl, +) @dataclass @@ -24,9 +28,57 @@ class CompileResult: """Result of compiling a .uf source.""" ascii: str = "" micron: str = "" + script: str = "" # generated Python script (dynamic mode only) + is_dynamic: bool = False warnings: list[CompileWarning] = field(default_factory=list) +def _has_dynamic_nodes(node: IRNode) -> bool: + """Check if the IR tree contains any dynamic nodes.""" + if isinstance(node, (Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl)): + return True + return any(_has_dynamic_nodes(child) for child in node.children) + + +def _collect_form_nodes(node: IRNode) -> dict[int, IRNode]: + """Walk the IR tree and collect form nodes keyed by their y position.""" + result: dict[int, IRNode] = {} + if isinstance(node, (Field, Password, Radio, Checkbox, FormButton)): + result[node.rect.y] = node + for child in node.children: + result.update(_collect_form_nodes(child)) + return result + + +def _micron_form_line(node: IRNode) -> str: + """Generate the Micron form tag for a form node.""" + if isinstance(node, Field): + w = node.field_width + name = node.field_name + ph = node.placeholder or name + return f"{name}: `<{w}|{name}`{ph}>" + elif isinstance(node, Password): + w = node.field_width + name = node.field_name + ph = node.placeholder or name + return f"{name}: `" + elif isinstance(node, Radio): + parts = [] + for i, opt in enumerate(node.options): + val = opt.lower().replace(" ", "_") + checked = "|*" if i == 0 else "" + parts.append(f"`<^|{node.group}|{val}{checked}`{opt}>") + return f"{node.group}: {' '.join(parts)}" + elif isinstance(node, Checkbox): + name = node.field_name + label = node.checkbox_label + checked = "|*" if node.checked else "" + return f"`" + elif isinstance(node, FormButton): + return f"`[`!{node.button_label}`!`:{node.dest}]" + return "" + + def compile(source: str, width: int = 64) -> CompileResult: """Compile a µFrame .uf source string into ASCII and Micron output. @@ -67,8 +119,26 @@ def compile(source: str, width: int = 64) -> CompileResult: ascii_out = emit_ascii(grid) micron_out = emit_micron(grid, page_title=page.title) + # 7. Post-pass: replace form element lines in Micron output + form_nodes = _collect_form_nodes(page) + if form_nodes: + micron_lines = micron_out.split("\n") + for row_y, form_node in form_nodes.items(): + if 0 <= row_y < len(micron_lines): + micron_lines[row_y] = _micron_form_line(form_node) + micron_out = "\n".join(micron_lines) + + # 8. Check if this page has dynamic features + is_dynamic = _has_dynamic_nodes(page) + script = "" + if is_dynamic: + from uframe.codegen import compile_dynamic + script = compile_dynamic(page) + return CompileResult( ascii=ascii_out, micron=micron_out, + script=script, + is_dynamic=is_dynamic, warnings=warnings, ) diff --git a/backend/uframe/codegen.py b/backend/uframe/codegen.py new file mode 100644 index 0000000..5db6ccd --- /dev/null +++ b/backend/uframe/codegen.py @@ -0,0 +1,322 @@ +"""Dynamic page compiler — generate executable Python scripts from µFrame IR. + +Takes a parsed IR tree containing dynamic nodes (source, if, for, on_submit, +state) and generates a self-contained Python script that: +1. Sets shebang + cache header +2. Reads form data from environment variables +3. Executes source commands +4. Evaluates conditionals and loops +5. Renders the layout into a CharGrid +6. Emits Micron to stdout +""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +from uframe.ir import ( + IRNode, Page, Box, Row, Col, Spacer, Pad, + Heading, Text, Label, Divider, Link, ListNode, ListItem, + Gauge, Sparkline, Status, Table, + Form, Field, Password, Radio, Checkbox, FormButton, + Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, + SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle, +) + + +def _indent(code: str, level: int = 1) -> str: + """Indent a block of code.""" + prefix = " " * level + return "\n".join(prefix + line if line.strip() else "" for line in code.split("\n")) + + +def _resolve_vars(text: str) -> str: + """Convert $var references to Python f-string expressions.""" + import re + # Replace $var.attr.attr with {var_attr_attr} and simple $var with {var} + def replace_var(m: re.Match) -> str: + var = m.group(1) + # Replace dots with underscores for Python variable names + py_var = var.replace(".", "_") + return "{" + py_var + "}" + return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text) + + +def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]: + """Generate Python code lines for a single IR node.""" + lines: list[str] = [] + ind = " " * indent_level + + if isinstance(node, Let): + val = node.var_value + # Try to detect numeric values + try: + float(val) + lines.append(f"{ind}{node.var_name} = {val}") + except ValueError: + if "," in val: + # Comma-separated list + lines.append(f"{ind}{node.var_name} = [{val}]") + else: + lines.append(f"{ind}{node.var_name} = {val!r}") + + elif isinstance(node, Source): + var = node.var_name + if node.source_type == SourceType.SHELL: + lines.append(f"{ind}{var} = _shell({node.command!r}, timeout={node.timeout})") + elif node.source_type == SourceType.FILE: + lines.append(f"{ind}{var} = _read_file({node.command!r})") + elif node.source_type == SourceType.JSON: + lines.append(f"{ind}{var} = _read_json({node.command!r})") + elif node.source_type == SourceType.PYTHON: + lines.append(f"{ind}{var} = eval({node.command!r})") + elif node.source_type == SourceType.PARAM: + lines.append(f"{ind}{var} = _get_param({node.command!r})") + elif node.source_type == SourceType.RNS: + lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})") + + elif isinstance(node, CacheControl): + lines.append(f"{ind}_cache_seconds = {node.seconds}") + + elif isinstance(node, StateDecl): + lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})") + + elif isinstance(node, IfBlock): + cond = _resolve_vars(node.condition) + # Convert simple comparisons + cond = cond.replace("&&", " and ").replace("||", " or ") + lines.append(f"{ind}if {cond}:") + if node.children: + for child in node.children: + lines.extend(_emit_node(child, indent_level + 1)) + else: + lines.append(f"{ind} pass") + + for elif_cond, elif_children in node.elif_branches: + ec = _resolve_vars(elif_cond).replace("&&", " and ").replace("||", " or ") + lines.append(f"{ind}elif {ec}:") + if elif_children: + for child in elif_children: + lines.extend(_emit_node(child, indent_level + 1)) + else: + lines.append(f"{ind} pass") + + if node.else_children: + lines.append(f"{ind}else:") + for child in node.else_children: + lines.extend(_emit_node(child, indent_level + 1)) + + elif isinstance(node, ForLoop): + iterable = _resolve_vars(node.iterable) + lines.append(f"{ind}for {node.var_name} in _iter({iterable}):") + if node.children: + for child in node.children: + lines.extend(_emit_node(child, indent_level + 1)) + else: + lines.append(f"{ind} pass") + + elif isinstance(node, OnSubmit): + lines.append(f"{ind}if _get_field({node.form_name!r}, ''):") + lines.append(f"{ind} # Form '{node.form_name}' was submitted") + for child in node.children: + lines.extend(_emit_node(child, indent_level + 1)) + + elif isinstance(node, Page): + lines.append(f"{ind}_page_title = {node.title!r}") + lines.append(f"{ind}_page_width = {node.width}") + lines.append(f"{ind}_uf_source_parts = []") + for child in node.children: + lines.extend(_emit_node(child, indent_level)) + + # Content nodes — emit as µFrame source that gets compiled + elif isinstance(node, Heading): + level = node.level.value + text = _resolve_vars(node.text) + lines.append(f"{ind}_uf_source_parts.append(f'heading {level} \"{text}\"')") + + elif isinstance(node, Text): + text = _resolve_vars(node.content) + lines.append(f"{ind}_uf_source_parts.append(f'text \"{text}\"')") + + elif isinstance(node, Label): + key = _resolve_vars(node.key) + val = _resolve_vars(node.value) + lines.append(f"{ind}_uf_source_parts.append(f'label \"{key}\" \"{val}\"')") + + elif isinstance(node, Gauge): + label = _resolve_vars(node.label) + value = _resolve_vars(str(node.value)) if "$" in str(node.value) else str(node.value) + extra = "" + if node.warn is not None: + extra += f" warn={node.warn}" + if node.crit is not None: + extra += f" crit={node.crit}" + lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {node.max_val} {node.bar_width}{extra}')") + + elif isinstance(node, Status): + label = _resolve_vars(node.label) + state = _resolve_vars(node.state) + lines.append(f"{ind}_uf_source_parts.append(f'status \"{label}\" {state}')") + + elif isinstance(node, Box): + weight = node.weight.name.lower() + title = _resolve_vars(node.title) + lines.append(f"{ind}_uf_source_parts.append(f'box {weight} \"{title}\"')") + for child in node.children: + # Indent children for the box + child_lines = _emit_node(child, indent_level) + for cl in child_lines: + if "_uf_source_parts.append" in cl: + # Add 2-space indent to the µFrame source + cl = cl.replace(".append(f'", ".append(f' ") + cl = cl.replace(".append('", ".append(' ") + lines.append(cl) + + elif isinstance(node, Divider): + style = node.divider_style.name.lower() + lines.append(f"{ind}_uf_source_parts.append('divider {style}')") + + elif isinstance(node, Spacer): + lines.append(f"{ind}_uf_source_parts.append('spacer {node.lines}')") + + elif isinstance(node, Link): + display = _resolve_vars(node.display) + dest = _resolve_vars(node.dest) + lines.append(f"{ind}_uf_source_parts.append(f'link \"{display}\" \"{dest}\"')") + + elif isinstance(node, Field): + lines.append(f"{ind}_uf_source_parts.append('field \"{node.field_name}\" {node.field_width} \"{node.placeholder}\"')") + + elif isinstance(node, FormButton): + lines.append(f"{ind}_uf_source_parts.append('button \"{node.button_label}\" \"{node.dest}\"')") + + elif isinstance(node, Form): + lines.append(f"{ind}_uf_source_parts.append('form \"{node.form_name}\"')") + for child in node.children: + child_lines = _emit_node(child, indent_level) + for cl in child_lines: + if "_uf_source_parts.append" in cl: + cl = cl.replace(".append(f'", ".append(f' ") + cl = cl.replace(".append('", ".append(' ") + lines.append(cl) + + else: + # Generic: emit children + for child in node.children: + lines.extend(_emit_node(child, indent_level)) + + return lines + + +# --------------------------------------------------------------------------- +# Runtime template embedded in generated scripts +# --------------------------------------------------------------------------- + +def _build_script(uframe_import: str, page_logic: str, page_title: str, page_width: int) -> str: + """Build the dynamic script from parts (avoids str.format brace issues).""" + lines = [ + "#!/usr/bin/env python3", + "# Auto-generated by uFrame", + "# Do not edit — regenerate with: uframe compile .uf", + "", + "import os, sys, json, subprocess, datetime, secrets, shlex", + "", + "# ─── Runtime Helpers ─────────────────────────────────────────", + "", + 'def _shell(cmd, timeout=5):', + ' """Execute shell command, return stdout."""', + ' try:', + ' return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()', + ' except Exception:', + ' return ""', + "", + 'def _read_file(path):', + ' """Read file contents."""', + ' try:', + ' return open(path).read().strip()', + ' except Exception:', + ' return ""', + "", + 'def _read_json(path):', + ' """Read and parse JSON file."""', + ' try:', + ' with open(path) as f:', + ' return json.load(f)', + ' except Exception:', + ' return {}', + "", + 'def _get_field(name, default=""):', + ' """Read submitted form field from environment."""', + ' return os.environ.get(f"FIELD_{name}", default)', + "", + 'def _get_param(name, default=""):', + ' """Read URL parameter."""', + ' return os.environ.get(f"PARAM_{name}",', + ' os.environ.get(f"var_{name}", default))', + "", + 'def _load_state(path):', + ' """Load state from JSON file."""', + ' try:', + ' with open(path) as f:', + ' return json.load(f)', + ' except Exception:', + ' return {}', + "", + 'def _save_state(path, data):', + ' """Save state to JSON file."""', + ' os.makedirs(os.path.dirname(path), exist_ok=True)', + ' with open(path, "w") as f:', + ' json.dump(data, f, indent=2)', + "", + 'def _iter(val):', + ' """Make a value iterable for for-loops."""', + ' if isinstance(val, (list, tuple)):', + ' return val', + ' if isinstance(val, dict):', + ' return [val]', + ' if isinstance(val, str):', + ' return val.strip().splitlines()', + ' return []', + "", + f"# ─── µFrame Compile ──────────────────────────────────────────", + "", + uframe_import, + "", + "# ─── Page Logic ──────────────────────────────────────────────", + "", + "_cache_seconds = 0", + "", + page_logic, + "", + "# ─── Render & Output ─────────────────────────────────────────", + "", + f'_uf_source = f\'\'\'page "{page_title}" {page_width}', + "''' + \"\\n\".join(_uf_source_parts)", + "", + f"result = uframe.compile(_uf_source, width={page_width})", + "", + "if _cache_seconds >= 0:", + ' print(f"#!c={_cache_seconds}")', + "print(result.micron)", + ] + return "\n".join(lines) + + +def compile_dynamic(page: Page) -> str: + """Generate a self-contained executable Python script from an IR tree. + + The generated script imports uframe at runtime and compiles the + dynamically-built .uf source into Micron output. + """ + logic_lines = _emit_node(page, indent_level=0) + page_logic = "\n".join(logic_lines) + + uframe_import = "import uframe" + + return _build_script( + uframe_import=uframe_import, + page_logic=page_logic, + page_title=page.title, + page_width=page.width, + ) diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index a448e3e..fcbb6a3 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -237,7 +237,121 @@ class Status(IRNode): @dataclass class Table(IRNode): - """Box-drawn table (Phase 4).""" + """Box-drawn table.""" title: str = "" columns: list[tuple[str, int]] = field(default_factory=list) # (name, width) rows: list[list[str]] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Form nodes +# --------------------------------------------------------------------------- + +@dataclass +class Form(IRNode): + """Form container grouping interactive fields.""" + form_name: str = "" + + +@dataclass +class Field(IRNode): + """Text input field.""" + field_name: str = "" + field_width: int = 24 + placeholder: str = "" + + +@dataclass +class Password(IRNode): + """Masked password field.""" + field_name: str = "" + field_width: int = 24 + placeholder: str = "" + + +@dataclass +class Radio(IRNode): + """Radio button group — options separated by |.""" + group: str = "" + options: list[str] = field(default_factory=list) + + +@dataclass +class Checkbox(IRNode): + """Checkbox field.""" + field_name: str = "" + checkbox_label: str = "" + checked: bool = False + + +@dataclass +class FormButton(IRNode): + """Submit button — clickable link in Micron.""" + button_label: str = "" + dest: str = "" + + +# --------------------------------------------------------------------------- +# Dynamic nodes (Phase 7) +# --------------------------------------------------------------------------- + +class SourceType(Enum): + SHELL = auto() + FILE = auto() + JSON = auto() + PYTHON = auto() + RNS = auto() + PARAM = auto() + + +@dataclass +class Let(IRNode): + """Variable assignment: let name = "value" or let name = 1,2,3.""" + var_name: str = "" + var_value: str = "" + + +@dataclass +class Source(IRNode): + """Data source resolved at render time (dynamic pages only).""" + var_name: str = "" + source_type: SourceType = SourceType.SHELL + command: str = "" + timeout: int = 5 + + +@dataclass +class IfBlock(IRNode): + """Conditional block: if $var > threshold.""" + condition: str = "" + # children = the "then" branch + elif_branches: list[tuple[str, list[IRNode]]] = field(default_factory=list) + else_children: list[IRNode] = field(default_factory=list) + + +@dataclass +class ForLoop(IRNode): + """Iteration: for item in $collection.""" + var_name: str = "" + iterable: str = "" + # children = loop body + + +@dataclass +class CacheControl(IRNode): + """Cache header: cache 0 (never cache) or cache 300 (5 min).""" + seconds: int = 0 + + +@dataclass +class OnSubmit(IRNode): + """Form submission handler: on_submit "form_name".""" + form_name: str = "" + # children = handler body + + +@dataclass +class StateDecl(IRNode): + """State persistence: state "name" "/path.json".""" + state_name: str = "" + path: str = "" diff --git a/backend/uframe/layout.py b/backend/uframe/layout.py index ed27738..f575246 100644 --- a/backend/uframe/layout.py +++ b/backend/uframe/layout.py @@ -6,6 +6,8 @@ from uframe.ir import ( IRNode, Page, Box, Row, Col, Spacer, Pad, Rect, Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, + Form, Field, Password, Radio, Checkbox, FormButton, + Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, ) @@ -111,8 +113,29 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int: node.rect.h = (cursor_y - y) + node.bottom return node.rect.h + elif isinstance(node, Form): + cursor_y = y + for child in node.children: + child_h = layout(child, x, cursor_y, w, h - (cursor_y - y)) + cursor_y += child_h + node.rect.h = cursor_y - y + return node.rect.h + + elif isinstance(node, (Let, Source, CacheControl, StateDecl)): + node.rect.h = 0 + return 0 + + elif isinstance(node, (IfBlock, ForLoop, OnSubmit)): + cursor_y = y + for child in node.children: + child_h = layout(child, x, cursor_y, w, h - (cursor_y - y)) + cursor_y += child_h + node.rect.h = cursor_y - y + return node.rect.h + elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem, - Gauge, Sparkline, Status, Table)): + Gauge, Sparkline, Status, Table, + Field, Password, Radio, Checkbox, FormButton)): node.rect.h = node.pref_height return node.pref_height diff --git a/backend/uframe/measure.py b/backend/uframe/measure.py index 26eefb1..1701d1c 100644 --- a/backend/uframe/measure.py +++ b/backend/uframe/measure.py @@ -6,6 +6,8 @@ from uframe.ir import ( IRNode, Page, Box, Row, Col, Spacer, Pad, Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, + Form, Field, Password, Radio, Checkbox, FormButton, + Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, ) @@ -195,13 +197,70 @@ def measure(node: IRNode, available_width: int) -> None: node.min_height = 1 elif isinstance(node, Table): - # Height = header border + header row + separator + data rows + bottom border num_rows = len(node.rows) node.pref_width = available_width - node.min_width = len(node.columns) * 3 + 1 # minimum 3 chars per col + borders - node.pref_height = num_rows + 4 # top border + header + separator + rows + bottom border + node.min_width = len(node.columns) * 3 + 1 + node.pref_height = num_rows + 4 node.min_height = 4 + elif isinstance(node, Form): + total_h = 0 + for child in node.children: + measure(child, available_width) + total_h += child.pref_height + node.pref_width = available_width + node.min_width = 10 + node.pref_height = total_h + node.min_height = total_h + + elif isinstance(node, Field): + node.pref_width = available_width + node.min_width = len(node.field_name) + node.field_width + 6 + node.pref_height = 1 + node.min_height = 1 + + elif isinstance(node, Password): + node.pref_width = available_width + node.min_width = len(node.field_name) + node.field_width + 6 + node.pref_height = 1 + node.min_height = 1 + + elif isinstance(node, Radio): + node.pref_width = available_width + node.min_width = len(node.group) + sum(len(o) + 6 for o in node.options) + node.pref_height = 1 + node.min_height = 1 + + elif isinstance(node, Checkbox): + node.pref_width = available_width + node.min_width = len(node.checkbox_label) + 6 + node.pref_height = 1 + node.min_height = 1 + + elif isinstance(node, FormButton): + node.pref_width = available_width + node.min_width = len(node.button_label) + 6 + node.pref_height = 1 + node.min_height = 1 + + elif isinstance(node, (Let, Source, CacheControl, StateDecl)): + # Zero-height metadata nodes — no visual output + node.pref_width = 0 + node.min_width = 0 + node.pref_height = 0 + node.min_height = 0 + + elif isinstance(node, (IfBlock, ForLoop, OnSubmit)): + # Container nodes — height = sum of children + total_h = 0 + for child in node.children: + measure(child, available_width) + total_h += child.pref_height + node.pref_width = available_width + node.min_width = 1 + node.pref_height = total_h + node.min_height = 0 + else: # Generic: just measure children total_h = 0 diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index 407ab45..cb4727d 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -18,6 +18,7 @@ from uframe.ir import ( IRNode, Page, Box, Row, Col, Spacer, Pad, Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, + Form, Field, Password, Radio, Checkbox, FormButton, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, ) @@ -213,6 +214,60 @@ def paint(node: IRNode, grid: CharGrid) -> None: elif isinstance(node, Table): _paint_table(node, grid, x, y, w) + elif isinstance(node, Form): + for child in node.children: + paint(child, grid) + + elif isinstance(node, Field): + label_style = CellStyle(fg="888") + field_style = CellStyle(fg="0cf") + label_text = f"{node.field_name}: " + grid.put_text(x, y, label_text, style=label_style) + # Draw [ placeholder_______ ] + fx = x + len(label_text) + fw = min(node.field_width, w - len(label_text) - 2) + grid.put(fx, y, "[", style=field_style) + placeholder = node.placeholder or node.field_name + inner = f" {placeholder}".ljust(fw - 1)[:fw - 1] + grid.put_text(fx + 1, y, inner, style=CellStyle(fg="555")) + grid.put(fx + fw, y, "]", style=field_style) + + elif isinstance(node, Password): + label_style = CellStyle(fg="888") + field_style = CellStyle(fg="0cf") + label_text = f"{node.field_name}: " + grid.put_text(x, y, label_text, style=label_style) + fx = x + len(label_text) + fw = min(node.field_width, w - len(label_text) - 2) + grid.put(fx, y, "[", style=field_style) + inner = " " + "•" * (fw - 2) + grid.put_text(fx + 1, y, inner[:fw - 1], style=CellStyle(fg="555")) + grid.put(fx + fw, y, "]", style=field_style) + + elif isinstance(node, Radio): + label_style = CellStyle(fg="888") + label_text = f"{node.group}: " + grid.put_text(x, y, label_text, style=label_style) + rx = x + len(label_text) + for i, opt in enumerate(node.options): + dot = "(•)" if i == 0 else "( )" + opt_style = CellStyle(fg="0cf" if i == 0 else "888") + grid.put_text(rx, y, dot, style=opt_style) + rx += 4 + grid.put_text(rx, y, opt, style=CellStyle()) + rx += len(opt) + 2 + + elif isinstance(node, Checkbox): + check_style = CellStyle(fg="0cf") + box_char = "[✓]" if node.checked else "[ ]" + grid.put_text(x, y, box_char, style=check_style) + grid.put_text(x + 4, y, node.checkbox_label) + + elif isinstance(node, FormButton): + btn_style = CellStyle(bold=True, fg="0f0") + btn_text = f"[ {node.button_label} ]" + grid.put_text(x, y, btn_text, style=btn_style, link=node.dest) + else: # Generic: paint children for child in node.children: diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index d8652b7..648a42c 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -18,6 +18,9 @@ from uframe.ir import ( IRNode, Page, Box, Row, Col, Spacer, Pad, Heading, Text, Label, Divider, Link, ListNode, ListItem, Gauge, Sparkline, Status, Table, TextSpan, + Form, Field, Password, Radio, Checkbox, FormButton, + Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, + SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, ) @@ -245,9 +248,19 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: # Phase 4 placeholders elif keyword == "gauge": label = args[0] if args else "" - value = float(args[1]) if len(args) > 1 else 0 - max_val = float(args[2]) if len(args) > 2 else 100 - bar_width = int(args[3]) if len(args) > 3 else 28 + val_str = args[1] if len(args) > 1 else "0" + try: + value = float(val_str) + except ValueError: + value = 0 # $variable — resolved at runtime + try: + max_val = float(args[2]) if len(args) > 2 else 100 + except ValueError: + max_val = 100 + try: + bar_width = int(args[3]) if len(args) > 3 else 28 + except ValueError: + bar_width = 28 # Parse warn=N crit=N from remaining args warn = crit = None for a in args[4:]: @@ -289,10 +302,139 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: cols.append((col_name, col_w)) return _TableColumns(cols, line_num) + # Forms + elif keyword == "form": + form_name = args[0] if args else "" + return Form(form_name=form_name, source_line=line_num) + + elif keyword == "field": + name = args[0] if args else "" + width = int(args[1]) if len(args) > 1 else 24 + placeholder = args[2] if len(args) > 2 else "" + return Field(field_name=name, field_width=width, placeholder=placeholder, + source_line=line_num) + + elif keyword == "password": + name = args[0] if args else "" + width = int(args[1]) if len(args) > 1 else 24 + placeholder = args[2] if len(args) > 2 else "" + return Password(field_name=name, field_width=width, placeholder=placeholder, + source_line=line_num) + + elif keyword == "radio": + group = args[0] if args else "" + raw = " ".join(args[1:]) if len(args) > 1 else "" + options = [o.strip().strip('"') for o in raw.split("|")] if raw else [] + return Radio(group=group, options=options, source_line=line_num) + + elif keyword == "checkbox": + name = args[0] if args else "" + label_text = args[1] if len(args) > 1 else "" + return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num) + + elif keyword == "button": + label_text = args[0] if args else "" + dest = args[1] if len(args) > 1 else "" + return FormButton(button_label=label_text, dest=dest, source_line=line_num) + + # Dynamic features + elif keyword == "let": + # let name = "value" or let name = 1,2,3 + raw = " ".join(args) + eq = raw.find("=") + if eq != -1: + var_name = raw[:eq].strip() + var_value = raw[eq + 1:].strip().strip('"') + else: + var_name = args[0] if args else "" + var_value = args[1] if len(args) > 1 else "" + return Let(var_name=var_name, var_value=var_value, source_line=line_num) + + elif keyword == "source": + # source cpu : shell "grep 'cpu' /proc/stat" + # source name : type "command" + raw = " ".join(args) + colon = raw.find(":") + if colon != -1: + var_name = raw[:colon].strip() + rest = raw[colon + 1:].strip() + parts = _split_args(rest) + src_type_str = parts[0] if parts else "shell" + command = parts[1] if len(parts) > 1 else "" + src_type = { + "shell": SourceType.SHELL, + "file": SourceType.FILE, + "json": SourceType.JSON, + "python": SourceType.PYTHON, + "rns": SourceType.RNS, + "param": SourceType.PARAM, + }.get(src_type_str.lower(), SourceType.SHELL) + # Parse optional timeout + timeout = 5 + for p in parts[2:]: + if p.startswith("timeout"): + try: + timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1]) + except (ValueError, IndexError): + pass + return Source(var_name=var_name, source_type=src_type, + command=command, timeout=timeout, source_line=line_num) + else: + return Source(var_name=args[0] if args else "", source_line=line_num) + + elif keyword == "if": + condition = " ".join(args) + return IfBlock(condition=condition, source_line=line_num) + + elif keyword == "elif": + condition = " ".join(args) + return _ElifBranch(condition, line_num) + + elif keyword == "else": + return _ElseBranch(line_num) + + elif keyword == "for": + # for item in $collection + var_name = args[0] if args else "item" + # Skip "in" keyword + iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "") + return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num) + + elif keyword == "cache": + seconds = int(args[0]) if args else 0 + return CacheControl(seconds=seconds, source_line=line_num) + + elif keyword == "on_submit": + form_name = args[0] if args else "" + return OnSubmit(form_name=form_name, source_line=line_num) + + elif keyword == "state": + state_name = args[0] if args else "" + path = args[1] if len(args) > 1 else "" + return StateDecl(state_name=state_name, path=path, source_line=line_num) + + elif keyword in ("set", "append", "prepend"): + # State operations — store as text nodes with metadata for the compiler + content = " ".join([keyword] + args) + return Text(content=content, source_line=line_num) + else: raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num) +class _ElifBranch(IRNode): + """Temporary node — absorbed by parent IfBlock during tree building.""" + def __init__(self, condition: str, line_num: int): + super().__init__(source_line=line_num) + self.condition = condition + + +class _ElseBranch(IRNode): + """Temporary node — absorbed by parent IfBlock during tree building.""" + def __init__(self, line_num: int): + super().__init__(source_line=line_num) + + class _TableColumns(IRNode): """Temporary node — absorbed by parent Table during tree building.""" def __init__(self, columns: list[tuple[str, int]], line_num: int): @@ -377,6 +519,24 @@ def parse(source: str) -> Page: stack[-1][1].rows.append(node.cells) continue + # elif/else branches are absorbed by the nearest IfBlock ancestor + if isinstance(node, _ElifBranch): + # Find the IfBlock in the stack + for si in range(len(stack) - 1, -1, -1): + if isinstance(stack[si][1], IfBlock): + # Collect subsequent children under this elif + stack[si][1].elif_branches.append((node.condition, [])) + break + continue + + if isinstance(node, _ElseBranch): + # Find the IfBlock in the stack — mark it for else collection + for si in range(len(stack) - 1, -1, -1): + if isinstance(stack[si][1], IfBlock): + stack[si][1].else_children = [] # will be filled by subsequent children + break + continue + # Attach to parent if stack: parent = stack[-1][1] diff --git a/backend/uframe/tests/test_compile.py b/backend/uframe/tests/test_compile.py index 159c924..1aa8b68 100644 --- a/backend/uframe/tests/test_compile.py +++ b/backend/uframe/tests/test_compile.py @@ -199,6 +199,61 @@ def test_table_with_color(): assert "`F0f0" in result.micron # green color tag +def test_form_field(): + source = '''page "Demo" 50 + form "test" + field "name" 20 "Enter name..."''' + result = uframe.compile(source) + assert "name:" in result.ascii + assert "[" in result.ascii + assert "]" in result.ascii + # Micron should have form tag + assert "`<" in result.micron or "<" in result.micron + + +def test_form_radio(): + source = '''page "Demo" 50 + form "test" + radio "mode" "Ping" | "Trace" | "Page"''' + result = uframe.compile(source) + assert "(•)" in result.ascii # first option selected + assert "( )" in result.ascii # other options unselected + assert "Ping" in result.ascii + assert "Trace" in result.ascii + + +def test_form_checkbox(): + source = '''page "Demo" 50 + form "test" + checkbox "agree" "I agree to terms"''' + result = uframe.compile(source) + assert "[ ]" in result.ascii + assert "I agree to terms" in result.ascii + + +def test_form_button(): + source = '''page "Demo" 50 + form "test" + button "Submit" "/page/submit.mu"''' + result = uframe.compile(source) + assert "Submit" in result.ascii + assert "submit.mu" in result.micron + + +def test_form_complete(): + source = '''page "Search" 50 + form "search" + field "query" 24 "Search term..." + radio "scope" "Local" | "Network" + checkbox "cache" "Include cached" + button "Go" "/page/search.mu"''' + result = uframe.compile(source) + assert "query:" in result.ascii + assert "(•)" in result.ascii + assert "[ ]" in result.ascii + assert "Go" in result.ascii + + def test_full_dashboard(): """Integration test: a realistic dashboard layout.""" source = '''page "Node Status" 60 diff --git a/backend/uframe/tests/test_dynamic.py b/backend/uframe/tests/test_dynamic.py new file mode 100644 index 0000000..3f66991 --- /dev/null +++ b/backend/uframe/tests/test_dynamic.py @@ -0,0 +1,124 @@ +"""Tests for dynamic page features — source, if/for, codegen.""" + +import uframe + + +def test_static_page_not_dynamic(): + result = uframe.compile('page "Test" 40\n heading 1 "Hello"') + assert not result.is_dynamic + assert result.script == "" + + +def test_source_makes_dynamic(): + source = '''page "Test" 40 + source cpu : shell "echo 42" + heading 1 "CPU: $cpu"''' + result = uframe.compile(source) + assert result.is_dynamic + assert result.script != "" + assert "#!/usr/bin/env python3" in result.script + assert "_shell" in result.script + + +def test_cache_control(): + source = '''page "Test" 40 + cache 0 + heading 1 "Live"''' + result = uframe.compile(source) + assert result.is_dynamic + assert "_cache_seconds = 0" in result.script + + +def test_if_block(): + source = '''page "Test" 40 + source val : shell "echo 50" + if $val > 90 + text "Critical"''' + result = uframe.compile(source) + assert result.is_dynamic + assert "if {val} > 90:" in result.script + + +def test_for_loop(): + source = '''page "Test" 40 + source items : shell "echo hello" + for item in $items + text "$item"''' + result = uframe.compile(source) + assert result.is_dynamic + assert "for item in _iter({items}):" in result.script + + +def test_let_variable(): + """let + source makes it dynamic; let alone is static.""" + source = '''page "Test" 40 + let name = "Relay Alpha" + source ts : python "datetime.now().isoformat()" + heading 1 "$name"''' + result = uframe.compile(source) + assert result.is_dynamic + assert "name = 'Relay Alpha'" in result.script + + +def test_state_declaration(): + source = '''page "Test" 40 + state "counter" "/tmp/counter.json" + heading 1 "Visits"''' + result = uframe.compile(source) + assert result.is_dynamic + assert "_load_state" in result.script + assert "/tmp/counter.json" in result.script + + +def test_on_submit(): + source = '''page "Test" 40 + form "search" + field "query" 20 "Search..." + button "Go" "/page/test.mu" + on_submit "search" + text "Results for $query"''' + result = uframe.compile(source) + assert result.is_dynamic + assert "_get_field" in result.script + + +def test_codegen_has_runtime(): + source = '''page "Test" 40 + source data : shell "echo ok" + text "$data"''' + result = uframe.compile(source) + script = result.script + # Verify the runtime helpers are included + assert "def _shell" in script + assert "def _get_field" in script + assert "def _load_state" in script + assert "def _iter" in script + assert "import uframe" in script + assert "result.micron" in script + + +def test_codegen_complete_dashboard(): + source = '''page "Dashboard" 60 + cache 0 + source cpu : shell "echo 42" + source mem : shell "echo 67" + + box double "Node Status" + text "System Monitor" + + gauge "CPU" $cpu 100 28 warn=75 crit=90 + gauge "MEM" $mem 100 28 warn=80 crit=95 + + if $cpu > 90 + text "ALERT: CPU critical" + + divider heavy + link "Home" "/page/index.mu"''' + result = uframe.compile(source) + assert result.is_dynamic + script = result.script + assert "#!/usr/bin/env python3" in script + assert "_cache_seconds = 0" in script + assert "_shell" in script + assert "if {cpu} > 90:" in script + assert "uframe.compile" in script diff --git a/frontend/src/components/editor/PreviewPane.tsx b/frontend/src/components/editor/PreviewPane.tsx index de6feff..538b9af 100644 --- a/frontend/src/components/editor/PreviewPane.tsx +++ b/frontend/src/components/editor/PreviewPane.tsx @@ -1,23 +1,25 @@ import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "@/components/ui/button"; import { useEditorStore } from "@/stores/editorStore"; import { renderMicron } from "./micronRenderer"; import { cn } from "@/lib/utils"; -type PreviewMode = "ascii" | "micron" | "raw"; +type PreviewMode = "ascii" | "micron" | "raw" | "script"; export default function PreviewPane() { const previewMode = useEditorStore((s) => s.previewMode); const setPreviewMode = useEditorStore((s) => s.setPreviewMode); const compiledAscii = useEditorStore((s) => s.compiledAscii); const compiledMicron = useEditorStore((s) => s.compiledMicron); + const compiledScript = useEditorStore((s) => s.compiledScript); + const isDynamic = useEditorStore((s) => s.isDynamic); const isCompiling = useEditorStore((s) => s.isCompiling); const compileError = useEditorStore((s) => s.compileError); - const tabs: { value: PreviewMode; label: string }[] = [ - { value: "ascii", label: "ASCII" }, - { value: "micron", label: "Micron" }, - { value: "raw", label: "Raw" }, + const tabs: { value: PreviewMode; label: string; show: boolean }[] = [ + { value: "ascii", label: "ASCII", show: true }, + { value: "micron", label: "Micron", show: true }, + { value: "raw", label: "Raw", show: true }, + { value: "script", label: "Script", show: isDynamic }, ]; return ( @@ -25,6 +27,11 @@ export default function PreviewPane() {
Preview + {isDynamic && ( + + ⚡ dynamic + + )} {isCompiling && ( compiling… @@ -37,20 +44,22 @@ export default function PreviewPane() { )}
- {tabs.map((tab) => ( - - ))} + {tabs + .filter((t) => t.show) + .map((tab) => ( + + ))}
@@ -77,6 +86,10 @@ export default function PreviewPane() { ) + ) : previewMode === "script" ? ( +
+            {compiledScript || "No dynamic script generated."}
+          
) : (
             {compiledMicron || "Raw Micron output will appear here…"}
diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts
index b2528b2..696ab89 100644
--- a/frontend/src/components/editor/examples.ts
+++ b/frontend/src/components/editor/examples.ts
@@ -227,4 +227,72 @@ export const EXAMPLES: Example[] = [
     col 30
       link "Settings" "/page/settings.mu"`,
   },
+  {
+    name: "Interactive Form",
+    description: "Text fields, radio buttons, checkboxes, and submit",
+    source: `page "Search" 56
+
+  box rounded "Node Search"
+    align center
+    text "Find peers and pages on the mesh"
+
+  spacer
+
+  form "search"
+    field "query" 30 "Enter search term..."
+    radio "scope" "Local" | "Network" | "All"
+    checkbox "cache" "Include cached results"
+    spacer
+    button "Search" "/page/search.mu"
+
+  divider light
+
+  heading 2 "Quick Actions"
+
+  form "ping"
+    field "target" 30 "Destination hash..."
+    radio "mode" "Ping" | "Trace" | "Page"
+    checkbox "verbose" "Verbose output"
+    spacer
+    button "Execute" "/page/action.mu"`,
+  },
+  {
+    name: "Dynamic Dashboard",
+    description: "Live data sources, conditionals, and cache control",
+    source: `page "Live Status" 60
+  cache 0
+
+  source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
+  source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
+  source uptime : shell "uptime -p"
+  source timestamp : python "datetime.now().strftime('%H:%M:%S')"
+
+  box double "Node Monitor"
+    align center
+    text "Live System Dashboard"
+    text "Updated: $timestamp"
+
+  spacer
+
+  heading 1 "Resources"
+
+  gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
+  gauge "MEM" $mem_pct 100 28 warn=80 crit=95
+
+  spacer
+
+  if $cpu_pct > 90
+    box heavy "ALERT"
+      color f00
+      text "CPU critical! Immediate action required."
+  elif $cpu_pct > 75
+    text "@color{ff0}{Warning: CPU usage elevated}"
+
+  spacer
+
+  label "Uptime" "$uptime"
+
+  divider heavy
+  text "Press Ctrl+R to refresh"`,
+  },
 ];
diff --git a/frontend/src/components/editor/micronRenderer.ts b/frontend/src/components/editor/micronRenderer.ts
index 9f8eec0..8c238c4 100644
--- a/frontend/src/components/editor/micronRenderer.ts
+++ b/frontend/src/components/editor/micronRenderer.ts
@@ -68,6 +68,31 @@ function renderInline(raw: string): string {
       } else if (code === "l") {
         out += ``;
         openTags.push(""); i += 2; continue;
+      } else if (code === "<") {
+        // Form element: `<...> or ` or ` or `<^...>
+        const closeAngle = raw.indexOf(">", i + 2);
+        if (closeAngle !== -1) {
+          const inner = raw.slice(i + 2, closeAngle);
+          out += renderFormTag(inner);
+          i = closeAngle + 1;
+          continue;
+        }
+      } else if (code === "[") {
+        // Link with inline formatting: `[`!Label`!`:/dest]
+        const closeBracket = raw.indexOf("]", i + 2);
+        if (closeBracket !== -1) {
+          const inner = raw.slice(i + 2, closeBracket);
+          const colonIdx = inner.indexOf("`:");
+          if (colonIdx !== -1) {
+            const labelRaw = inner.slice(0, colonIdx);
+            const dest = inner.slice(colonIdx + 2);
+            // Strip formatting tags from label for display
+            const label = labelRaw.replace(/`[!*_]/g, "");
+            out += `${escapeHtml(label)}`;
+            i = closeBracket + 1;
+            continue;
+          }
+        }
       }
     }
 
@@ -95,6 +120,46 @@ function renderInline(raw: string): string {
   return out;
 }
 
+/** Render a Micron form tag `<...> as styled HTML. */
+function renderFormTag(inner: string): string {
+  const esc = escapeHtml;
+
+  // Text field: width|name`placeholder or name`placeholder
+  // Password: !width|name`placeholder
+  // Checkbox: ?|name|value`label or ?|name|value|*`label
+  // Radio: ^|group|value`label or ^|group|value|*`label
+
+  if (inner.startsWith("?")) {
+    // Checkbox
+    const backtick = inner.indexOf("`");
+    const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
+    const checked = inner.includes("|*");
+    const box = checked ? "☑" : "☐";
+    return `${box} ${esc(label)}`;
+  }
+
+  if (inner.startsWith("^")) {
+    // Radio button
+    const backtick = inner.indexOf("`");
+    const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
+    const selected = inner.includes("|*");
+    const dot = selected ? "◉" : "○";
+    return `${dot} ${esc(label)}`;
+  }
+
+  if (inner.startsWith("!")) {
+    // Password field
+    const backtick = inner.indexOf("`");
+    const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
+    return `🔒 ${esc(placeholder || "••••••")}`;
+  }
+
+  // Regular text field: width|name`placeholder or name`placeholder
+  const backtick = inner.indexOf("`");
+  const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
+  return `${esc(placeholder || "...")}`;
+}
+
 /** Render a form element line as a styled badge. */
 function renderForm(line: string): string {
   const inner = escapeHtml(line);
@@ -145,9 +210,6 @@ export function renderMicron(source: string): string {
     // Empty line
     } else if (line.trim() === "") {
       htmlLines.push("");
-    // Form elements on their own line
-    } else if (/^`?<[^>]+>$/.test(line)) {
-      htmlLines.push(renderForm(line));
     } else {
       htmlLines.push(renderInline(line));
     }
diff --git a/frontend/src/hooks/useCompile.ts b/frontend/src/hooks/useCompile.ts
index 8b068c6..6860c69 100644
--- a/frontend/src/hooks/useCompile.ts
+++ b/frontend/src/hooks/useCompile.ts
@@ -19,7 +19,7 @@ export function useCompile() {
   const compile = useCallback(
     async (source: string) => {
       if (!source.trim()) {
-        setCompileResult("", "", []);
+        setCompileResult("", "", "", false, []);
         return;
       }
 
@@ -45,7 +45,7 @@ export function useCompile() {
         }
 
         const data = await res.json();
-        setCompileResult(data.ascii, data.micron, data.warnings || []);
+        setCompileResult(data.ascii, data.micron, data.script || "", data.is_dynamic || false, data.warnings || []);
       } catch (e: unknown) {
         if (e instanceof DOMException && e.name === "AbortError") return;
         setCompileError(e instanceof Error ? e.message : "Compile failed");
diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts
index 9324b2f..e287e25 100644
--- a/frontend/src/stores/editorStore.ts
+++ b/frontend/src/stores/editorStore.ts
@@ -18,21 +18,23 @@ interface EditorStore {
   // Compiled output
   compiledAscii: string;
   compiledMicron: string;
+  compiledScript: string;
+  isDynamic: boolean;
   compileWarnings: string[];
   isCompiling: boolean;
   compileError: string | null;
 
   // Preview
-  previewMode: "ascii" | "micron" | "raw";
+  previewMode: "ascii" | "micron" | "raw" | "script";
 
   // Actions
   setSource: (s: string) => void;
   setCurrentPage: (p: PageMeta | null) => void;
   setDirty: (v: boolean) => void;
-  setCompileResult: (ascii: string, micron: string, warnings: string[]) => void;
+  setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => void;
   setCompiling: (v: boolean) => void;
   setCompileError: (e: string | null) => void;
-  setPreviewMode: (mode: "ascii" | "micron" | "raw") => void;
+  setPreviewMode: (mode: "ascii" | "micron" | "raw" | "script") => void;
   reset: () => void;
 }
 
@@ -43,6 +45,8 @@ export const useEditorStore = create((set) => ({
 
   compiledAscii: "",
   compiledMicron: "",
+  compiledScript: "",
+  isDynamic: false,
   compileWarnings: [],
   isCompiling: false,
   compileError: null,
@@ -52,10 +56,12 @@ export const useEditorStore = create((set) => ({
   setSource: (s) => set({ ufSource: s, isDirty: true }),
   setCurrentPage: (p) => set({ currentPage: p }),
   setDirty: (v) => set({ isDirty: v }),
-  setCompileResult: (ascii, micron, warnings) =>
+  setCompileResult: (ascii, micron, script, isDynamic, warnings) =>
     set({
       compiledAscii: ascii,
       compiledMicron: micron,
+      compiledScript: script,
+      isDynamic,
       compileWarnings: warnings,
       isCompiling: false,
       compileError: null,
@@ -70,6 +76,8 @@ export const useEditorStore = create((set) => ({
       currentPage: null,
       compiledAscii: "",
       compiledMicron: "",
+      compiledScript: "",
+      isDynamic: false,
       compileWarnings: [],
       isCompiling: false,
       compileError: null,