Compare commits

..

43 Commits

Author SHA1 Message Date
46185705d7 feat: new day new graph 2026-04-06 22:27:02 +02:00
d8abe311cd feat: configs 2026-04-06 20:28:03 +02:00
2994735f3b feat: performance improvements 2026-04-06 20:16:46 +02:00
d7e9788f99 feat: nomad 2026-04-06 20:16:37 +02:00
6e8a4af40d feat: add settings 2026-04-06 01:09:02 +02:00
20e41f7680 feat: labels 2026-04-06 00:23:10 +02:00
c9eead1965 feat: inertia 2026-04-05 23:49:10 +02:00
a1ad332e01 feat: improvements 2026-04-05 22:39:31 +02:00
3c5856aecb feat: link resolver and spinner 2026-04-05 11:01:48 +02:00
ad89f409bf feat: refactoring 2026-04-05 10:24:01 +02:00
e1db06104e feat: composer improvements 2026-04-05 09:53:01 +02:00
7838760ca4 feat: add self to graph 2026-04-05 00:39:32 +02:00
914945279f feat: editor in popover 2026-04-05 00:31:47 +02:00
3eec7f316e feat: final graph 2026-04-04 23:24:44 +02:00
3132d40391 feat: graph browser 2026-04-04 16:18:21 +02:00
72ff02dbdf feat: improvements 2026-04-04 00:43:00 +02:00
e5c9c00d2f feat: browser graph 2026-04-04 00:13:38 +02:00
285825fff7 feat: use micron renderer 2026-04-03 22:59:39 +02:00
b878068ab3 feat: style 2026-04-03 22:10:26 +02:00
234081db09 feat: remove asccii preview 2026-04-03 16:55:11 +02:00
911f3fb57d feat: styles 2026-04-03 16:53:27 +02:00
0e7e435edd feat: menu and ponters 2026-04-03 16:44:52 +02:00
e1c7ea7635 feat: new routes 2026-04-03 15:57:15 +02:00
c820f06d1c feat: clean up 2026-04-03 15:24:06 +02:00
0d469f70bf feat: improve styles 2026-04-03 15:04:10 +02:00
f082a30b7f feat: docker and nomad config 2026-04-03 14:46:27 +02:00
576af715bd feat: fix buiild 2026-04-03 10:40:14 +02:00
82cb4a9786 feat: add tests and compose 2026-04-03 10:15:30 +02:00
f0b0a5dd24 featL: theme 2026-04-03 10:02:54 +02:00
fcf3dc74a8 feat: styles 2026-04-02 12:42:21 +02:00
462d6bf289 feat: styles 2026-04-01 23:07:00 +02:00
a95594f446 feat: navigation 2026-04-01 16:04:06 +02:00
a0a5f18128 feat: styles 2026-04-01 16:03:55 +02:00
0642d0f894 feat: sync registry with suggestions 2026-04-01 12:48:46 +02:00
528d2d8519 feat: imgage feature 2026-04-01 12:31:23 +02:00
175de893aa feat: big title 2026-04-01 12:16:16 +02:00
8d3245b7b1 feat: registry 2026-04-01 12:16:05 +02:00
01a3e0095c feat: themes 2026-04-01 10:45:02 +02:00
0316e50233 feat: preview 2026-04-01 10:13:14 +02:00
8776459ffb feat: component 2026-04-01 08:40:17 +02:00
619d3ec538 feat: dynamic mode 2026-04-01 08:28:44 +02:00
df11705875 feat: templates 2026-04-01 07:50:43 +02:00
b40c6436cd feat: added a twist 2026-04-01 00:53:55 +02:00
122 changed files with 24433 additions and 259 deletions

17
.claude/launch.json Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Backend (uvicorn)",
"runtimeExecutable": "/bin/sh",
"runtimeArgs": ["-c", "PAGES_DIR=$HOME/.nomadnetwork/storage/pages SOURCES_DIR=$HOME/.micron-editor/sources /Users/dtoro/Projects/micronomicon/backend/.venv/bin/uvicorn main:app --reload --port 8080 --app-dir /Users/dtoro/Projects/micronomicon/backend"],
"port": 8080
},
{
"name": "Frontend (vite)",
"runtimeExecutable": "/opt/homebrew/bin/node",
"runtimeArgs": ["/Users/dtoro/Projects/micronomicon/frontend/node_modules/.bin/vite", "/Users/dtoro/Projects/micronomicon/frontend"],
"port": 5173
}
]
}

View File

@@ -0,0 +1,353 @@
# uFrame Integration Plan for Micronomicon
## Executive Summary
Replace the existing raw-Micron editor with a uFrame IDE. uFrame is a declarative DSL that compiles `.uf` files into ASCII art and Micron `.mu` files. The user edits `.uf` source; the backend compiles it; the frontend shows dual ASCII + Micron previews. On publish, the backend compiles `.uf` to `.mu` and writes the result to PAGES_DIR.
---
## Current Architecture (as-is)
**Backend** (`backend/`): 5 Python files, no package structure.
- `main.py` — FastAPI app, mounts routers at `/api`, serves static frontend
- `pages.py` — CRUD for `.mu` files across SOURCES_DIR and PAGES_DIR
- `converter.py` — POST `/api/convert` wrapping md2txt (markdown -> micron)
- `graph.py` — GET `/api/graph` parsing Micron `[label` + `slug]` links
- `docker_utils.py` — POST `/api/restart` for NomadNet container
**Frontend** (`frontend/src/`):
- `EditorView.tsx` — main route, split pane: `EditorPane` (CodeMirror) + `PreviewPane` (Micron HTML)
- `editorStore.ts` — state: `micronSource`, `isDirty`, `currentPage`, `previewMode`
- `pagesStore.ts` — page list, fetch/delete/unpublish
- `micronHighlight.ts` — CodeMirror StreamLanguage for Micron syntax
- `micronRenderer.ts` — Micron markup to HTML for preview
- `slashCommands.ts``/H1`, `/Bold`, etc. autocomplete for Micron codes
- `wikiLinkCompletion.ts``[[` autocomplete inserting Micron links
**Storage model**: Sources stored as `.mu` in SOURCES_DIR, published copies in PAGES_DIR. Both are Micron markup. No intermediate format.
**Key invariant**: NomadNet reads `.mu` files from PAGES_DIR. This must be preserved — uFrame compiles to `.mu` for publishing.
---
## Phase 1 — uFrame Core Engine (Backend Python Package)
### 1.1 Package Structure
Create `backend/uframe/` as a Python package:
```
backend/uframe/
__init__.py # Public API: compile(source, width) -> CompileResult
parser.py # Line-by-line indentation parser -> IR tree
ir.py # IR node dataclasses (Page, Box, Row, Col, Text, etc.)
grid.py # CharGrid class — 2D character + style buffer
measure.py # Bottom-up size computation pass
layout.py # Top-down position assignment pass
paint.py # Write chars into CharGrid
borders.py # Border merge pass — fix junction characters
emit_ascii.py # CharGrid -> plain text string
emit_micron.py # CharGrid -> Micron markup with style tags
errors.py # ParseError, LayoutError exception types
```
### 1.2 New Files to Create
**`backend/uframe/__init__.py`**
Public API surface. Exports `compile(source: str, width: int = 72) -> CompileResult`.
CompileResult is a dataclass with fields: `ascii: str`, `micron: str`, `warnings: list[str]`.
The function orchestrates: parse -> measure -> layout -> paint -> merge_borders -> emit.
**`backend/uframe/ir.py`**
IR node types as Python dataclasses. Every node has:
- `children: list[Node]`
- `width`, `height` (computed by measure)
- `x`, `y` (computed by layout)
- `style: Style` (fg, bg, bold, italic, underline)
Node types for Phase 1:
- `Page` — root, has `width` property, optional `title`
- `Box` — border weight (none/light/heavy/double/rounded), padding
- `Row` — horizontal container, children laid out left-to-right
- `Col` — vertical container, children laid out top-to-bottom
- `Spacer` — flexible space filler
- `Pad` — explicit padding wrapper
- `Heading` — level 1-3, renders with Micron `>`, `>>`, `>>>`
- `Text` — body text with inline @modifiers (@bold, @italic, @color)
- `Label` — fixed-width key:value pair
- `Divider` — horizontal rule with character choice
- `Link` — generates `[label` + `target]` in Micron output
- `List` — bulleted or numbered list items
**`backend/uframe/parser.py`**
Line-by-line, indentation-based parser. Design:
- Each line is `(indent_level, keyword, props_string)`
- Indent = 2 spaces per level (configurable)
- Keywords: page, box, row, col, spacer, pad, heading, text, label, divider, link, list
- Properties parsed from the rest of the line after the keyword
- Multiline text: subsequent indented lines without a keyword are text content
- Returns ParseTree with root Page node and warnings list
- Raises ParseError with line number + message for unrecoverable errors
**`backend/uframe/grid.py`**
CharGrid — the core rendering buffer:
- 2D array of Cell objects: (char, fg, bg, bold, italic, underline, border_flag)
- Methods: put(x, y, char, style), put_str(x, y, text, style), hline(x, y, length, char), vline(x, y, length, char), rect(x, y, w, h, weight)
- Border characters stored with border=True for the merge pass
- width and height properties
**`backend/uframe/measure.py`**
Bottom-up pass computing sizes:
- Leaf nodes (Text, Heading, Label, Divider) compute natural size
- Text wraps to available width, computes resulting height
- Row: width = sum(child widths), height = max(child heights)
- Col: width = max(child widths), height = sum(child heights)
- Box: adds 2 for borders (if weight != none), plus padding
- Percentage-based widths resolved relative to parent
**`backend/uframe/layout.py`**
Top-down pass assigning positions:
- Page starts at (0, 0)
- Row distributes x positions left-to-right
- Col distributes y positions top-to-bottom
- Spacer expands to fill remaining space
**`backend/uframe/paint.py`**
Renders each node into the CharGrid:
- Box: draws border rectangle, then paints children inside
- Text: writes word-wrapped text with style
- Heading: writes text, marks heading level for Micron emitter
- Divider: draws horizontal line
- Label: writes "key: value" with key highlighted
- Link: writes label text, stores target in metadata for Micron emitter
**`backend/uframe/borders.py`**
Post-paint pass over CharGrid:
- Scans for adjacent border cells
- Replaces with correct junction characters (T, cross, corner)
- Unicode box-drawing character lookup tables
- Handles mixed weights (light meets heavy, etc.)
**`backend/uframe/emit_ascii.py`**
Reads CharGrid row by row, outputs plain characters only. Strips trailing whitespace per line.
**`backend/uframe/emit_micron.py`**
Reads CharGrid row by row, inserts Micron formatting codes:
- Track current style state, emit format codes on change
- Headings get `>`, `>>`, `>>>` prefixes
- Links emit Micron link syntax
- Reset formatting at end of styled runs
**`backend/uframe/errors.py`**
Exception types: UFrameError (base), ParseError, LayoutError. Each carries line/col/message.
### 1.3 Existing Files to Modify
**`backend/converter.py`** — REPLACE contents.
Remove md2txt wrapper. Add uFrame compile endpoint:
- POST `/api/compile` accepts `{source: str, width: int}`, returns `{ascii: str, micron: str, warnings: list[str]}`
- Old POST `/api/convert` can remain as deprecated during transition
**`backend/pages.py`** — Modify storage model.
- SOURCES_DIR stores `.uf` files instead of `.mu` files
- `_list_all_page_names()`: scan for `.uf` in SOURCES_DIR, `.mu` in PAGES_DIR
- `get_page()`: returns `.uf` source from SOURCES_DIR
- `save_page()`: accepts `.uf` source, saves as `.uf`
- On publish: compile .uf -> .mu via uframe.compile(), write .mu to PAGES_DIR
- `_extract_title()`: parse .uf source for heading or page title= property
- PageDetail model: add `source` field (the .uf content)
- SaveRequest model: `source` field replaces `micron`
**`backend/graph.py`** — Modify link extraction.
- Parse .uf source files for link nodes instead of Micron link pattern
- Use uframe.parser.parse() to get IR tree, walk for Link nodes
- Or: simpler regex for link keyword in .uf syntax
- `_extract_title()`: parse .uf for page title= or first heading
**`backend/requirements.txt`** — Remove md2txt dependency (eventually).
### 1.4 API Changes
| Method | Path | Before | After |
|--------|------|--------|-------|
| POST | `/api/convert` | markdown -> micron | DEPRECATED |
| POST | `/api/compile` | NEW | .uf source -> {ascii, micron, warnings} |
| GET | `/api/pages/{name}` | returns {name, micron} | returns {name, source, micron} |
| POST | `/api/pages/{name}` | body: {micron, publish} | body: {source, publish} |
### 1.5 Migration Strategy for Existing Pages
Existing .mu source files in SOURCES_DIR need a migration path:
- Option A (recommended): One-time migration script `backend/migrate_mu_to_uf.py` wrapping existing Micron in minimal .uf template
- Option B: Keep .mu fallback — if .uf not found but .mu exists in SOURCES_DIR, return raw Micron as read-only legacy
- pages.py get_page should check for .uf first, fall back to .mu
### 1.6 Verification Steps
1. **Unit tests**`backend/tests/test_uframe/`:
- test_parser.py, test_grid.py, test_measure.py, test_layout.py
- test_borders.py, test_emit_ascii.py, test_emit_micron.py, test_compile.py
2. **Golden file tests**`backend/tests/test_uframe/golden/`:
- .uf input files paired with expected .ascii and .mu outputs
- Cases: single box, nested rows/cols, heading+text page, mixed content
3. **API test** — verify POST /api/compile returns valid response
4. **Edge cases**: empty page, deeply nested boxes, text overflow, zero-width
---
## Phase 2 — Data Visualization Nodes
### 2.1 New Files
**`backend/uframe/viz.py`** — Viz node implementations:
- Gauge, Meter, BarH, BarV, Sparkline (braille), Heatmap, Status, Table
### 2.2 Modified Files
- `backend/uframe/ir.py` — add viz node dataclasses
- `backend/uframe/parser.py` — add viz keyword handlers
- `backend/uframe/measure.py` — add viz size computation
- `backend/uframe/paint.py` — add viz rendering
### 2.3 Verification
- Golden file tests for each viz type
- Edge cases: empty data, single value, overflow
---
## Phase 3 — Web IDE (Frontend Replacement)
### 3.1 New Files
**`frontend/src/components/editor/uframeHighlight.ts`**
CodeMirror language support for .uf syntax:
- Keywords: page, box, row, col, spacer, pad, heading, text, label, divider, link, list, gauge, sparkline, etc.
- Properties: width=, height=, weight=, title=, char=, @modifiers
- String literals, comments (#), indentation awareness
**`frontend/src/components/editor/uframeCommands.ts`**
Slash commands for .uf syntax:
- /box, /row, /col, /heading, /text, /divider, etc.
- Each inserts a snippet with correct indentation
- Property suggestions after =
**`frontend/src/components/editor/AsciiPreviewPane.tsx`**
ASCII output preview:
- Monospace rendering of plain-text ASCII art
- Terminal aesthetic (green-on-black optional)
**`frontend/src/hooks/useCompile.ts`**
Hook calling POST /api/compile:
- Debounced (300ms) on source change
- Returns { ascii, micron, warnings, isCompiling, error }
### 3.2 Modified Files
**`frontend/src/stores/editorStore.ts`** — Major rewrite:
- `ufSource` replaces `micronSource`
- Add `compiledAscii`, `compiledMicron`, `warnings`, `isCompiling`
- `previewMode`: "ascii" | "micron" | "raw" (was "preview" | "raw")
**`frontend/src/routes/EditorView.tsx`** — Significant changes:
- Use ufSource instead of micronSource
- Add compile-on-change hook (debounced /api/compile)
- Save sends source field not micron
- Replace micronHighlight with uframeHighlight
- Replace slashCommands with uframeCommands
- Replace single PreviewPane with tabbed ASCII + Micron preview
**`frontend/src/components/editor/PreviewPane.tsx`** — Major rewrite:
- Tab bar: "ASCII" | "Micron" | "Raw Micron"
- ASCII tab: compiledAscii in monospace pre block
- Micron tab: compiledMicron through existing renderMicron()
- Raw tab: raw Micron source
**`frontend/src/components/editor/ToolBar.tsx`** — Minor:
- Compile warnings count in status area
**`frontend/src/stores/pagesStore.ts`** — Minor:
- unpublishPage sends source not micron
**`frontend/src/components/editor/wikiLinkCompletion.ts`** — Adapt to .uf link syntax
### 3.3 Removed Files
- `frontend/src/components/editor/slashCommands.ts` (replaced by uframeCommands.ts)
- `frontend/src/components/editor/micronHighlight.ts` (replaced by uframeHighlight.ts)
### 3.4 Files Kept As-Is
- `micronRenderer.ts` — still needed for Micron preview tab
- `oneDarkTheme.ts` — theme remains
- `DashboardView.tsx`, `GraphView.tsx` — no changes
- All shadcn/ui components — unchanged
### 3.5 Verification
- Create page with boxes, headings, text — verify ASCII and Micron previews
- Publish writes correct .mu to PAGES_DIR
- Page load round-trip: save .uf, reload, content preserved
- Dashboard still shows pages correctly
- Graph still shows links
- Keyboard shortcuts (Ctrl+S, Ctrl+P) still work
- Unsaved changes guard still fires
---
## Phase 4+ — Forms, Dynamic, Components (Future)
### Forms (Phase 4)
- IR nodes: Field, Password, Radio, Checkbox, Button
- Parser + measure + paint for each
- Micron emit generates form codes
### Dynamic (Phase 5)
- source blocks, on_submit, if/else, for loops, state
- Requires runtime component beyond static compilation
### Components (Phase 6)
- Reusable definitions, import, parameterized props
---
## Implementation Order (Phase 1 detail)
Build sequence within Phase 1, each step testable in isolation:
1. `errors.py` — Exception types (no dependencies)
2. `ir.py` — Node dataclasses (depends on nothing)
3. `parser.py` — Parse .uf -> IR tree (depends on ir, errors)
4. `grid.py` — CharGrid buffer (depends on nothing)
5. `measure.py` — Size computation (depends on ir)
6. `layout.py` — Position assignment (depends on ir)
7. `paint.py` — Render to grid (depends on ir, grid)
8. `borders.py` — Junction resolution (depends on grid)
9. `emit_ascii.py` — ASCII output (depends on grid)
10. `emit_micron.py` — Micron output (depends on grid)
11. `__init__.py` — Public API tying it together
12. `converter.py` update — Wire /api/compile endpoint
13. `pages.py` update — .uf storage model
14. `graph.py` update — .uf link extraction
---
## Key Design Decisions
1. **Separate measure/layout/paint passes** — enables caching (re-layout without re-parse) and independent testability.
2. **CharGrid as intermediate** — decouples layout from emission. Same grid produces ASCII and Micron. Adding new emitters (HTML, ANSI) later is trivial.
3. **Border flag on cells** — merge pass identifies borders without confusing them with box-drawing text content.
4. **Style state in cells** — each cell carries its own style so Micron emitter can diff adjacent cells and emit minimal formatting codes.
5. **Line-based parser** — matches indentation-sensitive DSL. Simpler than full tokenizer. Upgradeable to PEG grammar later.
6. **No new Python dependencies** — pure stdlib. Box-drawing and braille characters are Unicode literals.
7. **SOURCES_DIR uses .uf extension** — clean break from .mu sources. Published files remain .mu (NomadNet requirement).
8. **Compile on backend** — single source of truth. Frontend is thin editor + preview client. Enables future CLI compilation and batch processing.

448
CLAUDE.md Normal file
View File

@@ -0,0 +1,448 @@
# µFrame (Micronomicon)
A self-hosted web IDE and CLI for building rich terminal UIs using a declarative DSL. Compiles `.uf` source files into both plain ASCII art and styled Micron `.mu` pages for NomadNet — a decentralized communication platform running on the Reticulum mesh network.
## What It Does
Write this:
```
page "Node Status" 60
box double "Relay Alpha-7"
align center
text "Reticulum Network Node"
gauge "CPU" 62 100 28 warn=75 crit=90
status "East Relay" online
table "Routes"
columns "Dest" 20 | "Hops" 6 | "Status" 10
row "relay-east" | "2" | "@color{0f0}{alive}"
```
Get this (ASCII):
```
╔═ Relay Alpha-7 ════════════════════════════════════════════╗
║ Reticulum Network Node ║
╚════════════════════════════════════════════════════════════╝
CPU ████████████████████░░░░░░░░ 62%
● East Relay
┌────────────────────┬──────┬──────────┐
│Dest │Hops │Status │
├────────────────────┼──────┼──────────┤
│relay-east │2 │● alive │
└────────────────────┴──────┴──────────┘
```
And the same content as Micron `.mu` with color tags, bold, links, and interactive form fields — ready to serve on NomadNet.
## Quick Start
### Web IDE
```bash
# Backend
cd backend
source .venv/bin/activate
PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080
# Frontend (separate terminal)
cd frontend
npm run dev
```
Open http://localhost:5173 → click **New Page** → click **Examples** → pick a template.
### CLI
```bash
cd backend
source .venv/bin/activate
# Render to terminal
python -m uframe render page.uf
# Render Micron only
python -m uframe render page.uf --micron
# Compile to .mu file
python -m uframe compile page.uf --out page.mu
# Validate without output
python -m uframe check page.uf
# Compile and deploy to NomadNet
python -m uframe deploy page.uf
```
## Tech Stack
| Layer | Technology |
|------------|-------------------------------------------------|
| Backend | Python 3.13 + FastAPI + uvicorn |
| µFrame | Pure Python (zero deps): parser → IR → CharGrid → emitters |
| Frontend | React 19 + Vite + TypeScript |
| UI | shadcn/ui + Tailwind CSS v4 |
| Editor | CodeMirror 6 (custom µFrame syntax mode) |
| Graph | React Flow (@xyflow/react) + dagre |
| State | Zustand |
| Container | Docker + Compose |
## Project Structure
```
micronomicon/
├── backend/
│ ├── main.py # FastAPI app + static file serving
│ ├── converter.py # POST /api/compile endpoint
│ ├── pages.py # CRUD /api/pages (.uf sources + .mu publish)
│ ├── graph.py # GET /api/graph (link parser)
│ ├── docker_utils.py # POST /api/restart (NomadNet container)
│ └── uframe/ # µFrame engine (16 modules, ~3800 LOC)
│ ├── __init__.py # compile(source, width) → CompileResult
│ ├── parser.py # .uf DSL → IR tree (indentation-based)
│ ├── ir.py # 30+ IR node dataclasses
│ ├── grid.py # CharGrid — 2D char + style buffer
│ ├── chars.py # Unicode tables (box-drawing, braille)
│ ├── measure.py # Bottom-up size computation
│ ├── layout.py # Top-down position assignment
│ ├── paint.py # IR nodes → CharGrid rendering
│ ├── borders.py # Junction merging post-pass
│ ├── emit_ascii.py # CharGrid → plain text
│ ├── emit_micron.py # CharGrid → Micron with style tags
│ ├── codegen.py # Dynamic page → executable Python script
│ ├── cli.py # CLI: render / compile / check / deploy
│ ├── errors.py # ParseError, LayoutError, CompileWarning
│ └── tests/ # 42 tests (compile, dynamic, components)
├── frontend/src/
│ ├── routes/ # DashboardView, EditorView, GraphView
│ ├── components/editor/
│ │ ├── EditorPane.tsx # CodeMirror 6 host
│ │ ├── PreviewPane.tsx # ASCII / Micron / Raw / Script tabs
│ │ ├── ToolBar.tsx # Save, Publish, Examples, Backlinks
│ │ ├── uframeHighlight.ts # µFrame syntax highlighting
│ │ ├── uframeCommands.ts # "/" slash command palette
│ │ ├── micronRenderer.ts # Micron → HTML preview renderer
│ │ └── examples.ts # 9 built-in example templates
│ ├── hooks/
│ │ ├── useCompile.ts # Debounced POST /api/compile
│ │ └── useUnsavedGuard.ts # Prevent accidental navigation
│ └── stores/
│ ├── editorStore.ts # Zustand: source, compiled output, preview mode
│ └── pagesStore.ts # Zustand: page list, delete, unpublish
└── docs/
├── framework-design-v3.md # Full DSL spec + rendering model
└── dynamic-templates.md # Dynamic page addendum
```
## API Endpoints
| Method | Path | Description |
|--------|---------------------|------------------------------------------------------|
| GET | /api/health | Health check |
| POST | /api/compile | Compile `.uf``{ascii, micron, script, is_dynamic}` |
| GET | /api/pages | List all pages with metadata |
| GET | /api/pages/{name} | Read page source |
| POST | /api/pages/{name} | Save page — `{ source, publish }` |
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
| GET | /api/graph | Page link graph (nodes + edges) |
| POST | /api/restart | Restart NomadNet Docker container |
## Storage
```
~/.micron-editor/sources/ ← .uf source files (drafts + published)
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
```
- **Save Draft**: writes `.uf` to sources dir only
- **Publish (static)**: compiles `.uf``.mu`, writes to pages dir (chmod 644)
- **Publish (dynamic)**: compiles `.uf` → executable Python script, writes to pages dir (chmod 755)
NomadNet auto-detects the execute bit: static pages are served as-is, dynamic pages are executed and their stdout is served.
## µFrame DSL Reference
### Layout
```
page "Title" [width] # root (default width 64)
box [light|heavy|double|rounded] "Title" # bordered panel
row [gap] # horizontal layout
col [width] # column in a row
spacer [lines] # vertical whitespace
pad [t] [r] [b] [l] # inner margin
```
### Content
```
heading [1|2|3] "Text" # styled heading
text "Content with @bold{inline} @color{hex}{modifiers}"
label "Key" "Value" # aligned key-value pair
list [bullet|dash]
item "Entry"
link "Display text" "/dest.mu" # clickable in Micron
divider [light|heavy|double|dash|dot] # horizontal rule
# comment # ignored in output
```
### Data Visualization
```
gauge "Label" value max width [warn=N crit=N] # ████░░░░ bar with thresholds
sparkline "Label" "1,3,5,8,7,5" width # ⣀⣤⣶⣿⣷⣤ braille chart
status "Label" [online|offline|degraded] # ●○◐ colored indicators
table "Title"
columns "Name" 20 | "Hops" 6 | "Status" 10
row "relay" | "2" | "@color{0f0}{● alive}"
```
### Forms
```
form "name"
field "name" [width] "placeholder" # text input
password "name" [width] "placeholder" # masked input
radio "group" "Opt A" | "Opt B" | "Opt C" # radio buttons
checkbox "name" "Label" # checkbox
button "Label" "/action/path" # submit link
```
### Dynamic Features
Any page using `source`, `if`, `for`, `on_submit`, or `state` becomes **dynamic**: it compiles to an executable Python script instead of static Micron. NomadNet runs the script on each request and serves its stdout.
#### Variables
```
let name = "Relay Alpha" # string assignment
let threshold = 75 # numeric
let tags = "alpha","beta","gamma" # comma-separated → list
```
Variables are substituted with `$name` in text, labels, and other content. They work in both static and dynamic pages.
#### Data Sources
```
source var_name : type "command" [timeout=N]
```
Sources fetch data **at render time** and bind results to variables:
| Type | Description | Example |
|----------|--------------------------------------|----------------------------------------------------------|
| `shell` | Run shell command, capture stdout | `source cpu : shell "cat /proc/loadavg"` |
| `file` | Read file contents as string | `source motd : file "/etc/motd"` |
| `json` | Read + parse JSON file → dict/list | `source config : json "/etc/config.json"` |
| `python` | Evaluate Python expression | `source ts : python "datetime.now().strftime('%H:%M')"` |
| `http` | HTTP request, auto-parses JSON | `source data : http "https://api.example.com/data"` |
| `sqlite` | SQLite query → list of dicts | `source users : sqlite "/path/db" "SELECT * FROM users"` |
| `env` | Read environment variable | `source key : env "API_KEY"` |
| `param` | Read URL parameter from link | `source hash : param "hash"` |
| `rns` | Query Reticulum via `rnstatus` | `source peers : rns "peers"` |
**Shell** commands have a default 5-second timeout (override with `timeout=N`).
**Python** expressions have access to: `datetime` (the class, so `datetime.now()` works), `timedelta`, `secrets`, `os`, `json`. Expressions are evaluated via `eval()` — single expressions only, not statements.
```
# Python source examples
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source rand_id : python "secrets.token_hex(4)"
source cpu_sim : python "secrets.randbelow(60) + 20"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
```
**HTTP** requests return parsed JSON (dict/list) or raw string. Default timeout 10s.
```
# GET request — JSON auto-parsed into dict
source todo : http "https://api.example.com/todos/1"
text "Title: $todo.title"
# POST with JSON body
source result : http "https://api.example.com/search" method=POST body='{"q":"relay"}'
# Custom headers (semicolon-separated)
source data : http "https://api.example.com/data" headers='Authorization: Bearer tok123'
# Use $var references in URL, headers, and body — resolved at runtime
source token : env "API_TOKEN"
source data : http "https://api.example.com/data" headers='Authorization: Bearer $token'
```
**Env** reads server-side environment variables. Use this for secrets — tokens never appear in `.uf` source or compiled scripts.
```
source api_key : env "API_KEY"
source db_pass : env "DB_PASSWORD"
```
**SQLite** queries return a list of dicts (or a single dict for one row). Uses Python stdlib `sqlite3`.
```
# Query returns list of dicts with column names as keys
source nodes : sqlite "/data/network.db" "SELECT name, status, hops FROM nodes"
# Iterate results
for node in $nodes
label "$node.name" "$node.status ($node.hops hops)"
# Single row queries return a dict directly
source config : sqlite "/data/app.db" "SELECT value FROM config WHERE key='theme'"
text "Theme: $config.value"
```
#### Conditionals
```
if $cpu > 90
text "ALERT: CPU critical"
elif $cpu > 75
text "Warning: elevated"
else
text "All clear"
```
Conditions are Python expressions. `$var` references resolve to the variable's value. Supports `>`, `<`, `>=`, `<=`, `==`, `!=`, `&&` (and), `||` (or).
#### Loops
```
for peer in $peers
status "$peer.name" $peer.state
```
Iterates over lists (from JSON sources), dicts (wrapped as single-item list), or newline-delimited strings (from shell output). Access nested fields with `$item.field`.
#### Cache Control
```
cache 0 # never cache (re-execute every request)
cache 300 # cache for 5 minutes
```
Emits the `#!c=N` header that NomadNet uses to control page caching.
#### Form Submission Handling
```
on_submit "form_name"
# Runs when the named form is submitted
# Form field values are available as $field_name
source results : shell "search.py '$query'"
text "Found: $results"
```
Field values are read from `FIELD_*` environment variables set by NomadNet.
#### Persistent State
```
state "counter" "/tmp/counter.json" # load JSON into $counter
```
Loads a JSON file into a variable. Use `_save_state(path, data)` in the generated script to persist changes.
#### Using Variables in Content
```
text "Hello, $name" # inline substitution
label "CPU" "$cpu_pct%" # in labels
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90 # as gauge values
status "$peer" $state # in status indicators
link "View $name" "/page/detail.mu" # in links
```
#### Generated Script Runtime
The compiled script includes these helpers, available in `on_submit` and source blocks:
| Helper | Description |
|-------------------------------------|-----------------------------------------------|
| `_shell(cmd, timeout=5)` | Execute shell command, return stdout |
| `_read_file(path)` | Read file contents |
| `_read_json(path)` | Read + parse JSON file |
| `_http(url, method, body, headers)` | HTTP request, auto-parse JSON response |
| `_sqlite(db_path, query)` | SQLite query → list of dicts (or single dict) |
| `_get_field(name, default)` | Read submitted form field |
| `_get_param(name, default)` | Read URL parameter |
| `_load_state(path)` | Load state from JSON file |
| `_save_state(path, data)` | Save state to JSON file |
| `_iter(val)` | Make a value iterable (list/dict/string) |
### Components
```
# Define a reusable component
component stat(label, value, max)
gauge "$label" $value $max 20
# Use it
stat "CPU" 62 100
stat "MEM" 84 100
# Import standard library
use std/dashboard
banner "My Node" "Mesh Network"
resources 62 84
```
### Standard Libraries
| Library | Components |
|---------|-----------|
| `std/dashboard` | `banner(title, subtitle)`, `resources(cpu, mem)`, `peer_status(name, state)` |
| `std/status-bar` | `status_bar(label, value, max)`, `status_item(name, state)` |
| `std/nav` | `nav_link(label, dest)`, `nav_divider()` |
## Rendering Pipeline
```
.uf source
Parse ──→ IR Tree (30+ node types)
Measure (bottom-up: compute sizes)
Layout (top-down: assign positions)
Paint (depth-first: write chars into CharGrid)
Merge Borders (fix junction characters)
├──→ ASCII emitter → plain text
├──→ Micron emitter → styled .mu (with colors, links, form tags)
└──→ Codegen (if dynamic) → executable Python script
```
## Web IDE Features
- **Split-pane editor**: µFrame DSL source (left) / live preview (right)
- **Syntax highlighting**: keywords, strings, variables, comments in distinct colors
- **`/` command palette**: type `/` to insert layout, content, data viz, form, and style primitives
- **4 preview tabs**: ASCII | Micron (rendered) | Raw (Micron source) | Script (dynamic pages only)
- **`⚡ dynamic` badge**: auto-detected when source contains `source`, `if`, `for`, etc.
- **Examples dropdown**: 9 built-in templates (Hello World → Full Node Page → Dynamic Dashboard)
- **Pages dashboard**: table view with Published/Draft/Orphan status badges
- **Page graph**: React Flow visualization of inter-page links
- **Keyboard shortcuts**: `Ctrl+S` save draft, `Ctrl+P` publish
- **Unsaved changes guard**: warns before navigating away
- **Backlink indicator**: shows which pages link to the current page
## Running Tests
```bash
cd backend
source .venv/bin/activate
python -m pytest uframe/tests/ -v
```
42 tests covering:
- Static compilation (boxes, headings, text, gauges, tables, links, lists, spacers, dividers)
- Form elements (field, radio, checkbox, button)
- Dynamic pages (source, if/for, let, state, on_submit, codegen)
- Components (inline definitions, standard library, parameter substitution)
## Conventions
- µFrame engine is **pure Python stdlib** — zero external dependencies
- Backend is FastAPI; no ORM, flat file storage
- Frontend uses shadcn/ui components in `frontend/src/components/ui/`
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
- State management via Zustand stores
- All `.uf` sources stored in `SOURCES_DIR`, compiled `.mu` in `PAGES_DIR`
## References
- µFrame design spec: `docs/framework-design-v3.md`
- Dynamic templates spec: `docs/dynamic-templates.md`
- NomadNet: https://github.com/markqvist/NomadNet
- Micron syntax: https://github.com/fr33n0w/micron-composer
- Reticulum: https://github.com/markqvist/Reticulum

View File

@@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./ COPY backend/ ./
COPY frontend/dist/ ./static/ COPY frontend/dist/ ./static/
EXPOSE 8080 EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

289
README.md
View File

@@ -1,21 +1,64 @@
# Micronomicon # µFrame (Micronomicon)
A self-hosted web editor for writing Markdown and publishing `.mu` pages to a NomadNet node. A declarative DSL and web IDE for building rich terminal UIs that publish as `.mu` pages to [NomadNet](https://github.com/markqvist/NomadNet) — a decentralized communication platform on the [Reticulum](https://github.com/markqvist/Reticulum) mesh network.
Write in Markdown → preview as Micron → publish directly to `~/.nomadnetwork/storage/pages/`. Write structured layouts with box-drawing, gauges, tables, and forms in a simple DSL. Get both plain ASCII art (viewable in any terminal) and styled Micron markup (with colors, links, and interactive form fields) from the same source.
```
page "Dashboard" 60 ╔═ Relay Alpha ══════════════════╗
box double "Relay Alpha" ║ Reticulum Network Node ║
align center ╚════════════════════════════════╝
text "Reticulum Network Node"
CPU ████████████████░░░░ 62%
gauge "CPU" 62 100 28 warn=75 crit=90 MEM ██████████████████░░ 84% ⚠
gauge "MEM" 84 100 28 warn=80 crit=95
┌──────────┬──────┬──────────┐
table "Routes" │Dest │Hops │Status │
columns "Dest" 10 | "Hops" 6 | ... ├──────────┼──────┼──────────┤
row "east" | "2" | "alive" │east │2 │● alive │
└──────────┴──────┴──────────┘
status "East Relay" online ● East Relay
```
--- ---
## Requirements ## Quick Start
- Docker + Docker Compose ### Web IDE
- Python 3.13+ (for local backend development only)
- Node 20+ (for local frontend development only)
- A running NomadNet container named `nomadnet` (for the restart button)
--- ```bash
# 1. Create directories
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
## Quick Start (Docker) # 2. Backend
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080
# 3. Frontend (separate terminal)
cd frontend
npm install
npm run dev
```
Open http://localhost:5173 → **New Page****Examples** → pick a template.
### CLI
```bash
cd backend && source .venv/bin/activate
python -m uframe render page.uf # ASCII to stdout
python -m uframe render page.uf --micron # Micron to stdout
python -m uframe compile page.uf # → page.mu
python -m uframe check page.uf # validate
python -m uframe deploy page.uf # compile + copy to NomadNet pages
```
### Docker
```bash ```bash
# 1. Build the frontend # 1. Build the frontend
@@ -24,48 +67,135 @@ npm install
npm run build npm run build
cd .. cd ..
# 2. Create source directories # 2. Build and start all services
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources docker compose up --build -d
# 3. Start the stack
docker compose up --build
``` ```
App is available at `http://localhost:8080`. This starts two containers:
### Tailscale HTTPS - **micronomicon** — the web IDE + API on <http://localhost:8080>
- **nomadnet** — NomadNet node serving your published `.mu` pages
Pages published through the IDE are written to a shared volume that NomadNet reads from. The Docker socket is mounted read-only so the IDE can restart NomadNet when needed.
To follow logs: `docker compose logs -f`
To stop: `docker compose down`
To expose over Tailscale: `tailscale serve --bg https+insecure://localhost:8080`
#### Deploy a page via CLI (into the running stack)
```bash ```bash
tailscale serve --bg https+insecure://localhost:8080 cd backend && source .venv/bin/activate
python -m uframe deploy page.uf --dest "$(docker volume inspect micronomicon_pages -f '{{.Mountpoint}}')"
```
Or use the web IDE at <http://localhost:8080> and click **Publish**.
---
## DSL Overview
### Layout
```
page "Title" [width] # root container
box [light|heavy|double|rounded] "Title" # bordered panel
row [gap] # horizontal split
col [width] # column
spacer [lines] # vertical space
```
### Content
```
heading [1|2|3] "Text" # heading
text "Hello @bold{world} @color{0f0}{green}" # text with inline modifiers
label "Key" "Value" # key-value pair
link "Click me" "/page/dest.mu" # clickable link
divider [light|heavy|double] # horizontal rule
```
### Data Visualization
```
gauge "CPU" 62 100 28 warn=75 crit=90 # ████████░░░░ 62%
sparkline "Net" "1,3,5,8,7,5" 20 # ⣀⣤⣶⣿⣷⣤ braille chart
status "Server" [online|offline|degraded] # ●○◐ indicator
table "Routes"
columns "Dest" 20 | "Hops" 6
row "east" | "2"
```
### Forms
```
form "search"
field "query" 30 "Search..." # text input
radio "scope" "Local" | "Network" # radio buttons
checkbox "cache" "Include cached" # checkbox
button "Go" "/page/search.mu" # submit
```
### Dynamic Pages
```
cache 0 # re-execute on every request
source cpu : shell "cat /proc/loadavg" # live data
if $cpu > 90
text "ALERT"
for peer in $peers
status "$peer.name" $peer.state
state "visits" "/tmp/visits.json" # persistent store
```
### Components
```
component stat(label, value, max)
gauge "$label" $value $max 20
stat "CPU" 62 100 # reuse
use std/dashboard # import standard library
banner "My Node" "Mesh Network" # use library component
``` ```
--- ---
## Local Development ## Architecture
Run backend and frontend separately with hot reload. ```
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
**Backend** ├→ ASCII (plain text)
├→ Micron (.mu with styles)
```bash └→ Script (dynamic: executable Python)
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080
``` ```
**Frontend** **Static pages**: `.uf` compiles to `.mu` (Micron markup). NomadNet serves the file directly.
```bash **Dynamic pages**: `.uf` with `source`/`if`/`for` compiles to an executable Python script. NomadNet detects the `+x` bit, runs the script on each request, and serves the stdout as Micron. Live system data, form handling, and state persistence all work through this model.
cd frontend
npm install
npm run dev # proxies /api → localhost:8080
```
Open `http://localhost:5173`. ---
## API
| Method | Path | Description |
|--------|---------------------|-------------------------------------------------------|
| POST | /api/compile | Compile `.uf``{ascii, micron, script, is_dynamic}` |
| GET | /api/pages | List all pages with metadata |
| GET | /api/pages/{name} | Read page source |
| POST | /api/pages/{name} | Save `{source, publish}` — draft or publish |
| DELETE | /api/pages/{name} | Delete page |
| GET | /api/graph | Page link graph |
| POST | /api/restart | Restart NomadNet container |
---
## Web IDE Features
- **Split-pane editor** with µFrame syntax highlighting and live preview
- **`/` command palette** — type `/` to insert any DSL primitive
- **4 preview tabs** — ASCII | Micron (rendered) | Raw | Script (dynamic only)
- **Examples dropdown** — 9 templates from Hello World to Dynamic Dashboard
- **Pages dashboard** with Published / Draft / Orphan status badges
- **Page graph** — React Flow visualization of inter-page links
- **Keyboard shortcuts** — `Ctrl+S` save, `Ctrl+P` publish
- **Backlink indicator** — shows which pages link to the current page
--- ---
@@ -74,85 +204,24 @@ Open `http://localhost:5173`.
| Variable | Default | Description | | Variable | Default | Description |
|----------------------|------------------|--------------------------------------| |----------------------|------------------|--------------------------------------|
| `PAGES_DIR` | `/data/pages` | NomadNet pages directory | | `PAGES_DIR` | `/data/pages` | NomadNet pages directory |
| `SOURCES_DIR` | `/data/sources` | Markdown source files directory | | `SOURCES_DIR` | `/data/sources` | µFrame source files directory |
| `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart | | `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart |
--- ---
## API Reference ## Tests
| Method | Path | Description | ```bash
|----------|---------------------|-----------------------------------------------| cd backend && source .venv/bin/activate
| `GET` | `/api/health` | Health check | python -m pytest uframe/tests/ -v # 91 tests
| `POST` | `/api/convert` | Convert `{ markdown }``{ micron }` |
| `GET` | `/api/pages` | List all pages with metadata |
| `GET` | `/api/pages/{name}` | Read page (markdown source + micron output) |
| `POST` | `/api/pages/{name}` | Save `{ markdown, publish }` — draft or live |
| `DELETE` | `/api/pages/{name}` | Delete source and/or `.mu` file |
| `GET` | `/api/graph` | Graph nodes + edges from parsed link sources |
| `POST` | `/api/restart` | Restart NomadNet Docker container |
---
## Directory Layout
```
micronomicon/
Dockerfile
compose.yml
backend/
main.py ← FastAPI app + static file serving
converter.py ← md2txt wrapper (POST /api/convert)
pages.py ← file management (CRUD /api/pages)
graph.py ← link parser (GET /api/graph)
docker_utils.py ← container restart (POST /api/restart)
requirements.txt
frontend/
src/
App.tsx
routes/ ← DashboardView, EditorView, GraphView
components/ ← dashboard/, editor/, shared/, ui/ (shadcn)
stores/ ← editorStore, pagesStore (Zustand)
hooks/ ← useConversion, useGraph, useUnsavedGuard
lib/ ← utils (cn)
~/.nomadnetwork/storage/pages/ ← published .mu files (NomadNet serves these)
~/.micron-editor/sources/ ← markdown sources (managed by this app)
``` ```
--- ---
## Page Lifecycle ## References
``` - [NomadNet](https://github.com/markqvist/NomadNet) — decentralized communication
New Page → /editor/new → Save Draft → .md saved to sources/ - [Reticulum](https://github.com/markqvist/Reticulum) — mesh networking stack
→ Publish → .md saved + .mu written to pages/ - [Micron syntax](https://github.com/fr33n0w/micron-composer) — markup reference
``` - [Design spec](docs/framework-design-v3.md) — full DSL design document
- [Dynamic templates](docs/dynamic-templates.md) — dynamic page system spec
- **Draft** — `.md` exists, no `.mu`. Not visible on NomadNet.
- **Published** — both `.md` and `.mu` exist.
- **Orphan** — `.mu` exists but no `.md` source (e.g. pages created outside this tool).
---
## Tech Stack
| Layer | Technology |
|-----------|-----------------------------------------|
| Backend | Python 3.13 + FastAPI + uvicorn |
| Converter | md2txt (micron renderer) |
| Frontend | React 19 + Vite + TypeScript |
| UI | shadcn/ui + Tailwind CSS v4 |
| Editor | CodeMirror 6 |
| Graph | React Flow + dagre |
| State | Zustand |
| Container | Docker + Compose |
---
## Known Limitations (Phase 1)
- Micron preview is plain text — full terminal rendering comes in a later phase (micron-parser-js iframe)
- `[[` link autocomplete not yet implemented (Phase 2)
- Graph view is read-only; click a node to open it in the editor
- No metrics (Phase 4)

463
backend/browse.py Normal file
View File

@@ -0,0 +1,463 @@
"""Network browser — discovers NomadNet nodes and fetches pages via Reticulum.
Starts an RNS transport on FastAPI startup, listens for NomadNet node
announces, and exposes discovered nodes + remote page fetching via API.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import threading
import time
from pathlib import Path
from fastapi import APIRouter, Query
from starlette.responses import StreamingResponse
router = APIRouter()
log = logging.getLogger("browse")
# ---------------------------------------------------------------------------
# Module-level state
# ---------------------------------------------------------------------------
_nodes: dict[str, dict] = {} # hash_hex -> node info
_own_hash: str | None = None
def _own_name() -> str:
"""Read node name from NomadNet config, falling back to env/default."""
try:
path = _CONFIG_PATHS["nomadnet"]()
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("node_name"):
_, _, val = stripped.partition("=")
val = val.strip()
if val:
return val
except Exception:
pass
return os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
_lock = threading.Lock()
_started = False
_subscribers: list[asyncio.Queue] = []
_sub_lock = threading.Lock()
_loop: asyncio.AbstractEventLoop | None = None
# ---------------------------------------------------------------------------
# RNS announce handler (must be an object with aspect_filter + method)
# ---------------------------------------------------------------------------
def _push_node(node_data: dict) -> None:
"""Push a node update to all SSE subscribers (thread-safe)."""
with _sub_lock:
for q in list(_subscribers):
if _loop and _loop.is_running():
_loop.call_soon_threadsafe(q.put_nowait, node_data)
else:
try:
q.put_nowait(node_data)
except asyncio.QueueFull:
pass
class _AnnounceHandler:
"""RNS-compatible announce handler.
RNS.Transport.register_announce_handler() requires an object with:
- aspect_filter: str attribute
- received_announce(dest_hash, identity, app_data, ...): callable
"""
aspect_filter = "nomadnetwork.node"
def received_announce(
self,
destination_hash: bytes,
announced_identity,
app_data: bytes | None,
**kwargs,
) -> None:
import RNS
hash_hex = RNS.hexrep(destination_hash, delimit=False)
name = hash_hex[:12]
if app_data:
try:
name = app_data.decode("utf-8")
except Exception:
pass
is_self = name == _own_name()
# Determine which interface this announce arrived on
iface_name = None
try:
path_entry = RNS.Transport.path_table.get(destination_hash)
if path_entry and path_entry[5]: # IDX_PT_RVCD_IF = 5
iface_name = getattr(path_entry[5], "name", None)
except Exception:
pass
with _lock:
_nodes[hash_hex] = {
"hash": hash_hex,
"name": name,
"last_seen": time.time(),
"is_self": is_self,
"type": "node",
"interface": iface_name,
}
if is_self:
global _own_hash
_own_hash = hash_hex
log.info(
"Node announce: %s (%s) via %s%s",
name, hash_hex[:8], iface_name or "?",
" [self]" if is_self else "",
)
_push_node(_nodes[hash_hex])
# ---------------------------------------------------------------------------
# RNS lifecycle
# ---------------------------------------------------------------------------
_reticulum = None
def _collect_interfaces() -> list[dict]:
"""Read active RNS interfaces and return them as node-like dicts."""
try:
import RNS
except ImportError:
return []
ifaces = []
for iface in RNS.Transport.interfaces:
name = getattr(iface, "name", str(iface))
iface_id = f"iface_{name}"
target = getattr(iface, "target_ip", None) or getattr(iface, "target_host", None)
port = getattr(iface, "target_port", None) or getattr(iface, "bind_port", None)
ifaces.append({
"hash": iface_id,
"name": name,
"last_seen": time.time(),
"is_self": False,
"type": "interface",
"online": getattr(iface, "online", False),
"target": f"{target}:{port}" if target and port else None,
"txb": getattr(iface, "txb", 0),
"rxb": getattr(iface, "rxb", 0),
"bitrate": getattr(iface, "bitrate", 0),
"clients": len(getattr(iface, "clients", None) or []) if hasattr(iface, "clients") else None,
})
return ifaces
def start_browser() -> None:
"""Initialize RNS and begin listening for NomadNet node announces."""
global _started, _loop, _reticulum
if _started:
return
try:
_loop = asyncio.get_event_loop()
except RuntimeError:
_loop = None
try:
import RNS
configdir = os.environ.get("RNS_CONFIG_DIR", None)
if configdir:
Path(configdir).mkdir(parents=True, exist_ok=True)
_reticulum = RNS.Reticulum(configdir=configdir)
RNS.Transport.register_announce_handler(_AnnounceHandler())
_started = True
log.info("RNS browser started (v%s)", RNS.__version__)
except Exception as exc:
log.warning("Failed to start RNS browser: %s", exc)
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
_CONFIG_PATHS = {
"reticulum": lambda: Path(os.environ.get("RNS_SERVER_CONFIG_DIR", os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum")))) / "config",
"reticulum-client": lambda: Path(os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum"))) / "config",
"nomadnet": lambda: Path(os.environ.get("NOMADNET_CONFIG_DIR", str(Path.home() / ".nomadnetwork"))) / "config",
}
def _config_path(kind: str) -> Path:
resolver = _CONFIG_PATHS.get(kind)
if not resolver:
raise ValueError(f"Unknown config kind: {kind}")
return resolver()
def _restart_nomadnet() -> bool:
"""Restart the NomadNet container. Returns True on success."""
try:
from docker_utils import NOMADNET_CONTAINER
import docker
client = docker.from_env()
container = client.containers.get(NOMADNET_CONTAINER)
container.restart()
log.info("NomadNet container restarted")
return True
except Exception as exc:
log.info("NomadNet container not available: %s", exc)
return False
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
def _build_snapshot() -> list[dict]:
"""Build a full snapshot: self node + interfaces + discovered nodes."""
with _lock:
nodes = list(_nodes.values())
# Ensure type field on all nodes
for n in nodes:
n.setdefault("type", "node")
if not any(n["is_self"] for n in nodes):
nodes.insert(0, {
"hash": _own_hash or "self",
"name": _own_name(),
"last_seen": time.time(),
"is_self": True,
"type": "node",
})
# Add interfaces
nodes.extend(_collect_interfaces())
return nodes
@router.get("/browse/nodes")
async def list_nodes():
"""Return all discovered NomadNet nodes and interfaces."""
return _build_snapshot()
@router.get("/browse/nodes/stream")
async def stream_nodes():
"""SSE stream — pushes full snapshot then live node announces."""
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
with _sub_lock:
_subscribers.append(queue)
async def event_generator():
try:
# Send full snapshot (self + interfaces + known nodes)
for entry in _build_snapshot():
yield f"data: {json.dumps(entry)}\n\n"
# Then stream new announces as they arrive
while True:
node = await queue.get()
yield f"data: {json.dumps(node)}\n\n"
except asyncio.CancelledError:
pass
finally:
with _sub_lock:
_subscribers.remove(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/browse/page/{hash_hex}")
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
"""Fetch a Micron page from a node.
For the user's own node reads from the local pages directory.
For remote nodes establishes an RNS link and requests the page.
"""
with _lock:
node = _nodes.get(hash_hex)
if (node and node.get("is_self")) or hash_hex == "self":
return _read_local_page(path)
content = await _request_remote_page(hash_hex, path)
if content is None:
return {"content": None, "error": "Could not reach node"}
return {"content": content}
# ---------------------------------------------------------------------------
# Local page reader
# ---------------------------------------------------------------------------
def _read_local_page(path: str) -> dict:
from converter import execute_dynamic_script
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
filepath = Path(pages_dir) / path
if not filepath.exists():
return {"content": None, "error": "Page not found"}
try:
if os.access(filepath, os.X_OK):
script = filepath.read_text(encoding="utf-8")
return {"content": execute_dynamic_script(script)}
return {"content": filepath.read_text()}
except Exception as exc:
return {"content": None, "error": str(exc)}
# ---------------------------------------------------------------------------
# Remote page fetcher (RNS link + request/response)
# ---------------------------------------------------------------------------
async def _request_remote_page(hash_hex: str, path: str) -> str | None:
"""Establish an RNS link to a remote NomadNet node and request a page."""
try:
import RNS
except ImportError:
return None
loop = asyncio.get_event_loop()
future: asyncio.Future[str | None] = loop.create_future()
def _do_request():
try:
dest_hash = bytes.fromhex(hash_hex)
if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash)
deadline = time.time() + 10
while time.time() < deadline:
if RNS.Transport.has_path(dest_hash):
break
time.sleep(0.1)
else:
loop.call_soon_threadsafe(future.set_result, None)
return
identity = RNS.Identity.recall(dest_hash)
if not identity:
loop.call_soon_threadsafe(future.set_result, None)
return
dest = RNS.Destination(
identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"nomadnetwork",
"node",
)
link = RNS.Link(dest)
deadline = time.time() + 15
while time.time() < deadline:
if link.status == RNS.Link.ACTIVE:
break
time.sleep(0.1)
else:
link.teardown()
loop.call_soon_threadsafe(future.set_result, None)
return
def on_response(request_receipt):
try:
resp = request_receipt.response
if resp is not None:
content = resp.decode("utf-8") if isinstance(resp, bytes) else str(resp)
loop.call_soon_threadsafe(future.set_result, content)
else:
loop.call_soon_threadsafe(future.set_result, None)
except Exception:
loop.call_soon_threadsafe(future.set_result, None)
finally:
link.teardown()
def on_failed(request_receipt):
if not future.done():
loop.call_soon_threadsafe(future.set_result, None)
link.teardown()
link.request(
"/page/" + path,
response_callback=on_response,
failed_callback=on_failed,
)
except Exception as exc:
log.warning("Remote page request failed: %s", exc)
if not future.done():
loop.call_soon_threadsafe(future.set_result, None)
threading.Thread(target=_do_request, daemon=True).start()
try:
return await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError:
return None
# ---------------------------------------------------------------------------
# Reticulum config endpoints
# ---------------------------------------------------------------------------
@router.get("/browse/identity")
async def get_identity():
"""Return the node's RNS identity hash and configured name."""
identity_hash = None
try:
import RNS
if _reticulum and RNS.Transport.identity:
identity_hash = RNS.hexrep(RNS.Transport.identity.hash, delimit=False)
except Exception:
pass
return {
"name": _own_name(),
"hash": _own_hash or identity_hash,
}
@router.post("/browse/restart")
async def restart_services():
"""Restart NomadNet to apply config changes."""
restarted = _restart_nomadnet()
return {"ok": True, "nomadnet_restarted": restarted}
@router.get("/browse/config/{kind}")
async def get_config(kind: str):
"""Return config file contents for reticulum or nomadnet."""
path = _config_path(kind)
if not path.exists():
return {"content": ""}
return {"content": path.read_text(encoding="utf-8")}
@router.post("/browse/config/{kind}")
async def save_config(kind: str, body: dict):
"""Write config file for reticulum or nomadnet."""
content = body.get("content", "")
path = _config_path(kind)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return {"ok": True}

View File

@@ -1,28 +1,126 @@
from fastapi import APIRouter, HTTPException """µFrame compile, DSL metadata, and image upload endpoints."""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
import uframe
import uframe.keywords # noqa: F401 — triggers keyword registration
from uframe.errors import UFrameError
from uframe.registry import get_dsl_meta
router = APIRouter() router = APIRouter()
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
class ConvertRequest(BaseModel): BACKEND_DIR = str(Path(__file__).resolve().parent)
markdown: str
width: int = 80
class ConvertResponse(BaseModel): def execute_dynamic_script(script: str, timeout: int = 10) -> str:
"""Execute a dynamic page script and return its stdout (micron output).
Used by both the compile preview and the browse page reader.
"""
env = {**os.environ, "PYTHONPATH": BACKEND_DIR}
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(script)
f.flush()
try:
result = subprocess.run(
[sys.executable, f.name],
capture_output=True, text=True, timeout=timeout,
cwd=BACKEND_DIR, env=env,
)
if result.returncode != 0 and result.stderr:
return result.stderr
return result.stdout
except subprocess.TimeoutExpired:
return "Error: script timed out"
finally:
os.unlink(f.name)
class CompileRequest(BaseModel):
source: str
width: int = 64
class CompileResponse(BaseModel):
ascii: str
micron: str micron: str
script: str
is_dynamic: bool
warnings: list[str]
@router.post("/convert", response_model=ConvertResponse) @router.post("/compile", response_model=CompileResponse)
async def convert(req: ConvertRequest): async def compile_source(req: CompileRequest):
"""Compile µFrame .uf source into ASCII and Micron output.
For dynamic pages, the generated script is executed and the
resolved micron output replaces the static micron in the response.
"""
try: try:
from md2txt import convert_markdown result = uframe.compile(req.source, width=req.width)
result = convert_markdown( micron = result.micron
req.markdown, if result.is_dynamic and result.script:
width=req.width, executed = execute_dynamic_script(result.script)
renderer_name="micron", # Strip cache header line if present
lines = executed.split("\n")
if lines and lines[0].startswith("#!c="):
lines = lines[1:]
micron = "\n".join(lines)
return CompileResponse(
ascii=result.ascii,
micron=micron,
script=result.script,
is_dynamic=result.is_dynamic,
warnings=[w.message for w in result.warnings],
) )
return ConvertResponse(micron=result) except UFrameError as e:
except Exception as e: raise HTTPException(status_code=422, detail=str(e))
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
@router.get("/dsl-meta")
async def dsl_meta():
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
return get_dsl_meta()
@router.post("/upload-image")
async def upload_image(file: UploadFile = File(...)):
"""Upload an image for use in .uf pages."""
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Sanitize filename
name = file.filename or "upload.png"
safe_name = "".join(c for c in name if c.isalnum() or c in "._-").rstrip(".")
if not safe_name:
safe_name = "upload.png"
dest = UPLOAD_DIR / safe_name
content = await file.read()
dest.write_bytes(content)
# Return the path relative to backend working directory
rel_path = str(dest)
return {"path": rel_path, "filename": safe_name, "size": len(content)}
@router.get("/images")
async def list_images():
"""List uploaded images available for embedding."""
if not UPLOAD_DIR.is_dir():
return []
images = []
for f in sorted(UPLOAD_DIR.iterdir()):
if f.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".gif"):
images.append({
"filename": f.name,
"path": str(f),
"size": f.stat().st_size,
})
return images

View File

@@ -1,81 +0,0 @@
import os
import re
from pathlib import Path
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
# Matches markdown links: [text](slug) where slug has no protocol or path separators
_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)")
class GraphNode(BaseModel):
id: str
published: bool
title: str | None = None
class GraphEdge(BaseModel):
source: str
target: str
class GraphData(BaseModel):
nodes: list[GraphNode]
edges: list[GraphEdge]
def _all_page_names() -> set[str]:
names: set[str] = set()
if PAGES_DIR.is_dir():
for f in PAGES_DIR.iterdir():
if f.suffix == ".mu" and f.is_file():
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix == ".md" and f.is_file():
names.add(f.stem)
return names
def _extract_title(markdown: str) -> str | None:
for line in markdown.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
return None
@router.get("/graph", response_model=GraphData)
async def get_graph():
all_names = _all_page_names()
nodes: list[GraphNode] = []
edges: list[GraphEdge] = []
for name in sorted(all_names):
md_path = SOURCES_DIR / f"{name}.md"
mu_path = PAGES_DIR / f"{name}.mu"
title = None
if md_path.is_file():
content = md_path.read_text(encoding="utf-8")
title = _extract_title(content)
# Parse internal links
for match in _INTERNAL_LINK.finditer(content):
target = match.group(2)
if target in all_names:
edges.append(GraphEdge(source=name, target=target))
nodes.append(GraphNode(
id=name,
published=mu_path.is_file(),
title=title,
))
return GraphData(nodes=nodes, edges=edges)

View File

@@ -4,17 +4,24 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from converter import router as converter_router from pages import router as pages_router, files_router, ensure_default_pages
from pages import router as pages_router
from graph import router as graph_router
from docker_utils import router as docker_router from docker_utils import router as docker_router
from converter import router as converter_router
from browse import router as browse_router, start_browser
app = FastAPI(title="Micron Page Editor") app = FastAPI(title="µFrame Editor")
app.include_router(converter_router, prefix="/api") app.include_router(converter_router, prefix="/api")
app.include_router(pages_router, prefix="/api") app.include_router(pages_router, prefix="/api")
app.include_router(graph_router, prefix="/api") app.include_router(files_router, prefix="/api")
app.include_router(docker_router, prefix="/api") app.include_router(docker_router, prefix="/api")
app.include_router(browse_router, prefix="/api")
@app.on_event("startup")
async def startup():
ensure_default_pages()
start_browser()
@app.get("/api/health") @app.get("/api/health")
@@ -22,7 +29,16 @@ async def health():
return {"status": "ok"} return {"status": "ok"}
# Serve built frontend as static files (SPA fallback) # Serve built frontend as static files with SPA fallback
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
if static_dir.is_dir(): if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") from fastapi.responses import FileResponse
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
@app.get("/{path:path}")
async def spa_fallback(path: str):
file = static_dir / path
if file.is_file():
return FileResponse(file)
return FileResponse(static_dir / "index.html")

6
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,14 +1,72 @@
import os import os
import shlex
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
import uframe
router = APIRouter() router = APIRouter()
files_router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
DEFAULT_INDEX_SOURCE = '''\
page "Welcome" 60
bigtitle "uFrame" thin
box rounded "Micronomicon"
align center
text "Decentralized Page Server"
text "Powered by @bold{Reticulum} and @bold{NomadNet}"
spacer
heading 2 "Pages"
text "This node is serving pages built with the uFrame DSL."
text "Use the web IDE to create and publish new pages."
spacer
divider light
label "Node" "Micronomicon"
label "Engine" "uFrame v1"
status "Node" online
'''
def ensure_default_pages():
"""Create default index page and .env file if they don't exist."""
PAGES_DIR.mkdir(parents=True, exist_ok=True)
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
index_mu = PAGES_DIR / "index.mu"
index_src = SOURCES_DIR / "index.uf"
if not index_mu.is_file():
# Compile and publish the default index
result = uframe.compile(DEFAULT_INDEX_SOURCE)
index_mu.write_text(result.micron, encoding="utf-8")
index_mu.chmod(0o644)
if not index_src.is_file():
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
env_path = SOURCES_DIR / ".env"
if not env_path.is_file():
env_path.write_text(
"# Environment variables for dynamic pages\n"
"# Access with: source name : env \"KEY\"\n"
"#\n"
"# Example:\n"
"# API_KEY=your-key-here\n",
encoding="utf-8",
)
class PageMeta(BaseModel): class PageMeta(BaseModel):
name: str name: str
@@ -21,20 +79,42 @@ class PageMeta(BaseModel):
class PageDetail(BaseModel): class PageDetail(BaseModel):
name: str name: str
markdown: str | None = None source: str | None = None
micron: str | None = None
class SaveRequest(BaseModel): class SaveRequest(BaseModel):
markdown: str source: str
publish: bool = False publish: bool = False
def _extract_title(markdown: str) -> str | None: def _extract_title(source: str) -> str | None:
for line in markdown.splitlines(): """Extract title from µFrame source or legacy Micron."""
for line in source.splitlines():
stripped = line.strip() stripped = line.strip()
if stripped.startswith("# "): if not stripped or stripped.startswith("#"):
return stripped[2:].strip() continue
# µFrame: page "Title" [width]
if stripped.lower().startswith("page "):
try:
parts = shlex.split(stripped)
if len(parts) >= 2:
return parts[1]
except ValueError:
pass
break
# µFrame: heading 1 "Title"
if stripped.lower().startswith("heading "):
try:
parts = shlex.split(stripped)
if len(parts) >= 3:
return parts[2]
except ValueError:
pass
break
# Legacy Micron: >Title
if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip()
break
return None return None
@@ -46,18 +126,29 @@ def _list_all_page_names() -> set[str]:
names.add(f.stem) names.add(f.stem)
if SOURCES_DIR.is_dir(): if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir(): for f in SOURCES_DIR.iterdir():
if f.suffix == ".md" and f.is_file(): if f.suffix in (".uf", ".mu") and f.is_file():
names.add(f.stem) names.add(f.stem)
return names return names
def _source_path(name: str) -> Path:
"""Get source file path, preferring .uf over legacy .mu."""
uf = SOURCES_DIR / f"{name}.uf"
if uf.is_file():
return uf
mu = SOURCES_DIR / f"{name}.mu"
return mu if mu.is_file() else uf # default to .uf for new files
def _page_meta(name: str) -> PageMeta: def _page_meta(name: str) -> PageMeta:
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu" mu_path = PAGES_DIR / f"{name}.mu"
md_path = SOURCES_DIR / f"{name}.md"
title = None title = None
if md_path.is_file(): if src_path.is_file():
title = _extract_title(md_path.read_text(encoding="utf-8")) title = _extract_title(src_path.read_text(encoding="utf-8"))
elif mu_path.is_file():
title = _extract_title(mu_path.read_text(encoding="utf-8"))
published = mu_path.is_file() published = mu_path.is_file()
last_modified = mu_path.stat().st_mtime if published else None last_modified = mu_path.stat().st_mtime if published else None
@@ -67,7 +158,7 @@ def _page_meta(name: str) -> PageMeta:
name=name, name=name,
title=title, title=title,
published=published, published=published,
has_source=md_path.is_file(), has_source=src_path.is_file(),
last_modified=last_modified, last_modified=last_modified,
size=size, size=size,
) )
@@ -80,16 +171,19 @@ async def list_pages():
@router.get("/pages/{name}", response_model=PageDetail) @router.get("/pages/{name}", response_model=PageDetail)
async def get_page(name: str): async def get_page(name: str):
md_path = SOURCES_DIR / f"{name}.md" src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu" mu_path = PAGES_DIR / f"{name}.mu"
if not md_path.is_file() and not mu_path.is_file(): if not src_path.is_file() and not mu_path.is_file():
raise HTTPException(status_code=404, detail="Page not found") raise HTTPException(status_code=404, detail="Page not found")
markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None source = (
micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None src_path.read_text(encoding="utf-8")
if src_path.is_file()
else mu_path.read_text(encoding="utf-8")
)
return PageDetail(name=name, markdown=markdown, micron=micron) return PageDetail(name=name, source=source)
@router.post("/pages/{name}", response_model=PageMeta) @router.post("/pages/{name}", response_model=PageMeta)
@@ -97,40 +191,223 @@ async def save_page(name: str, req: SaveRequest):
SOURCES_DIR.mkdir(parents=True, exist_ok=True) SOURCES_DIR.mkdir(parents=True, exist_ok=True)
PAGES_DIR.mkdir(parents=True, exist_ok=True) PAGES_DIR.mkdir(parents=True, exist_ok=True)
# Always save markdown source # Save source as .uf
md_path = SOURCES_DIR / f"{name}.md" src_path = SOURCES_DIR / f"{name}.uf"
md_path.write_text(req.markdown, encoding="utf-8") src_path.write_text(req.source, encoding="utf-8")
# Optionally publish # Remove legacy .mu source if it exists
legacy_mu = SOURCES_DIR / f"{name}.mu"
if legacy_mu.is_file():
legacy_mu.unlink()
# Publish: compile .uf → .mu and write to pages dir
if req.publish: if req.publish:
try: try:
from md2txt import convert_markdown result = uframe.compile(req.source)
mu_path = PAGES_DIR / f"{name}.mu"
micron = convert_markdown( if result.is_dynamic and result.script:
req.markdown, # Dynamic page: write executable Python script
width=80, mu_path.write_text(result.script, encoding="utf-8")
renderer_name="micron", 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: except Exception as e:
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}") raise HTTPException(
status_code=422,
mu_path = PAGES_DIR / f"{name}.mu" detail=f"Compile failed during publish: {e}",
mu_path.write_text(micron, encoding="utf-8") )
return _page_meta(name) return _page_meta(name)
@router.delete("/pages/{name}") @router.delete("/pages/{name}")
async def delete_page(name: str): async def delete_page(name: str):
md_path = SOURCES_DIR / f"{name}.md" src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu" mu_path = PAGES_DIR / f"{name}.mu"
if not md_path.is_file() and not mu_path.is_file(): if not src_path.is_file() and not mu_path.is_file():
raise HTTPException(status_code=404, detail="Page not found") raise HTTPException(status_code=404, detail="Page not found")
if md_path.is_file(): if src_path.is_file():
md_path.unlink() src_path.unlink()
if mu_path.is_file(): if mu_path.is_file():
mu_path.unlink() mu_path.unlink()
return {"deleted": name} return {"deleted": name}
# ---------------------------------------------------------------------------
# File browser endpoints
# ---------------------------------------------------------------------------
class FileEntry(BaseModel):
name: str
path: str
type: str # "file" | "folder" | "env"
size: int | None = None
last_modified: float | None = None
title: str | None = None
published: bool = False
class MkdirRequest(BaseModel):
path: str
class MoveRequest(BaseModel):
model_config = {"populate_by_name": True}
from_path: str = Field(alias="from")
to: str
class EnvRequest(BaseModel):
content: str
def _validate_relative_path(rel: str) -> Path:
"""Validate that a relative path has no traversal components and resolves
inside the expected base directories. Returns the cleaned relative Path."""
p = Path(rel)
# Reject absolute paths and any ".." components
if p.is_absolute():
raise HTTPException(status_code=400, detail="Absolute paths not allowed")
for part in p.parts:
if part == "..":
raise HTTPException(status_code=400, detail="Directory traversal not allowed")
# Extra safety: resolve against SOURCES_DIR and verify containment
resolved = (SOURCES_DIR / p).resolve()
if not str(resolved).startswith(str(SOURCES_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path escapes base directory")
return p
def _file_entry(base: Path, rel_path: Path) -> FileEntry:
"""Build a FileEntry for a file or directory at base/rel_path."""
full = base / rel_path
name = rel_path.name
if full.is_dir():
return FileEntry(
name=name,
path=str(rel_path),
type="folder",
)
# .env file
if name == ".env":
stat = full.stat()
return FileEntry(
name=name,
path=str(rel_path),
type="env",
size=stat.st_size,
last_modified=stat.st_mtime,
)
# Regular file
stat = full.stat()
title = None
published = False
if full.suffix == ".uf":
try:
title = _extract_title(full.read_text(encoding="utf-8"))
except Exception:
pass
# Check published status: corresponding .mu in PAGES_DIR at same relative path
mu_rel = rel_path.with_suffix(".mu")
published = (PAGES_DIR / mu_rel).is_file()
return FileEntry(
name=name,
path=str(rel_path),
type="file",
size=stat.st_size,
last_modified=stat.st_mtime,
title=title,
published=published,
)
@files_router.get("/files", response_model=list[FileEntry])
async def list_files(path: str = Query(default="")):
"""List files and folders in SOURCES_DIR, optionally scoped to a subfolder."""
if path:
rel = _validate_relative_path(path)
else:
rel = Path(".")
target = (SOURCES_DIR / rel).resolve()
if not str(target).startswith(str(SOURCES_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path escapes base directory")
if not target.is_dir():
raise HTTPException(status_code=404, detail="Directory not found")
entries: list[FileEntry] = []
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
item_rel = item.relative_to(SOURCES_DIR)
entries.append(_file_entry(SOURCES_DIR, item_rel))
return entries
@files_router.post("/files/mkdir")
async def mkdir(req: MkdirRequest):
"""Create a folder in both SOURCES_DIR and PAGES_DIR."""
rel = _validate_relative_path(req.path)
(SOURCES_DIR / rel).mkdir(parents=True, exist_ok=True)
(PAGES_DIR / rel).mkdir(parents=True, exist_ok=True)
return {"created": str(rel)}
@files_router.post("/files/move")
async def move_file(req: MoveRequest):
"""Move/rename a file or folder in both SOURCES_DIR and PAGES_DIR."""
from_rel = _validate_relative_path(req.from_path)
to_rel = _validate_relative_path(req.to)
# Move in SOURCES_DIR
src_from = SOURCES_DIR / from_rel
src_to = SOURCES_DIR / to_rel
if src_from.exists():
src_to.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src_from), str(src_to))
# Move in PAGES_DIR (for .uf files, look for .mu counterpart)
if src_from.suffix == ".uf" or (not src_from.exists() and from_rel.suffix == ".uf"):
pages_from = PAGES_DIR / from_rel.with_suffix(".mu")
pages_to = PAGES_DIR / to_rel.with_suffix(".mu")
else:
pages_from = PAGES_DIR / from_rel
pages_to = PAGES_DIR / to_rel
if pages_from.exists():
pages_to.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(pages_from), str(pages_to))
return {"moved": {"from": str(from_rel), "to": str(to_rel)}}
@files_router.get("/files/env")
async def read_env():
"""Read the .env file from SOURCES_DIR root."""
env_path = SOURCES_DIR / ".env"
if env_path.is_file():
return {"content": env_path.read_text(encoding="utf-8")}
return {"content": ""}
@files_router.post("/files/env")
async def save_env(req: EnvRequest):
"""Save the .env file to SOURCES_DIR root."""
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
env_path = SOURCES_DIR / ".env"
env_path.write_text(req.content, encoding="utf-8")
return {"saved": True}

View File

@@ -1,4 +1,7 @@
fastapi>=0.115 fastapi>=0.115
uvicorn[standard]>=0.34 uvicorn[standard]>=0.34
docker>=7.0 docker>=7.0
Pillow>=10.0
python-multipart>=0.0.6
md2txt @ git+https://codeberg.org/randogoth/md2txt md2txt @ git+https://codeberg.org/randogoth/md2txt
rns>=0.9.3

BIN
backend/test_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

149
backend/uframe/__init__.py Normal file
View File

@@ -0,0 +1,149 @@
"""µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron.
Public API:
compile(source, width=64) → CompileResult
"""
from __future__ import annotations
from dataclasses import dataclass, field
from uframe.errors import CompileWarning, UFrameError
from uframe.parser import parse
from uframe.measure import measure
from uframe.layout import layout
from uframe.paint import paint
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,
)
from uframe.themes import get_theme, ThemeDef
@dataclass
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}: `<!{w}|{name}`{ph}>"
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"`<?|{name}|yes{checked}`{label}>"
elif isinstance(node, FormButton):
return f"`[`!{node.button_label}`!`:{node.dest}]"
return ""
def compile(source: str, width: int = 64, theme: str = "") -> CompileResult:
"""Compile a µFrame .uf source string into ASCII and Micron output.
Args:
source: the .uf DSL source text
width: page width in characters (default 64)
Returns:
CompileResult with ascii, micron, and any warnings
Raises:
ParseError: if the source cannot be parsed
LayoutError: if layout constraints fail
"""
warnings: list[CompileWarning] = []
# 1. Parse
page = parse(source)
if page.width == 64 and width != 64:
page.width = width
w = page.width
# 1b. Resolve theme (CLI flag overrides source directive)
theme_name = theme or page.theme_name or "default"
theme_def = get_theme(theme_name)
# 2. Measure
measure(page, w)
# 3. Layout
total_h = layout(page, 0, 0, w, page.pref_height + 100)
# 4. Create grid and paint
grid = CharGrid(w, max(total_h, 1))
paint(page, grid, theme_def)
# 5. Merge borders
merge_borders(grid)
# 6. Emit
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,
)

View File

@@ -0,0 +1,4 @@
"""Allow running µFrame as: python -m uframe render file.uf"""
from uframe.cli import main
import sys
sys.exit(main())

19
backend/uframe/borders.py Normal file
View File

@@ -0,0 +1,19 @@
"""Border merging post-pass (currently no-op).
The draw_border and _paint_table functions produce correct border characters
directly. The original merge pass caused garbled junctions when borders from
different boxes were adjacent, so it was disabled.
If future features need cross-box junction merging (e.g. tables sharing
edges with parent boxes), add targeted logic here using border_id from
the grid cells to only merge within the same border group.
"""
from __future__ import annotations
from uframe.grid import CharGrid
def merge_borders(grid: CharGrid) -> None:
"""No-op — borders are correctly painted by draw_border and _paint_table."""
pass

158
backend/uframe/chars.py Normal file
View File

@@ -0,0 +1,158 @@
"""Unicode character lookup tables for box-drawing, block elements, braille, and indicators."""
from __future__ import annotations
from uframe.ir import BorderWeight
# ---------------------------------------------------------------------------
# Box-drawing characters by weight
# ---------------------------------------------------------------------------
# Keys: (weight) → dict of part names → char
BOX_CHARS: dict[BorderWeight, dict[str, str]] = {
BorderWeight.LIGHT: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
BorderWeight.HEAVY: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
BorderWeight.DOUBLE: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
BorderWeight.ROUNDED: {
"tl": "", "tr": "", "bl": "", "br": "",
"h": "", "v": "",
"t_down": "", "t_up": "", "t_right": "", "t_left": "",
"cross": "",
},
}
# ---------------------------------------------------------------------------
# Block elements for gauges and bars
# ---------------------------------------------------------------------------
# Horizontal fill blocks: full → 1/8
HFILL = "█▉▊▋▌▍▎▏"
# Vertical fill blocks: 1/8 → full (bottom-up)
VFILL = "▁▂▃▄▅▆▇█"
# Shade blocks: 25% → 100%
SHADE = "░▒▓█"
# Gauge characters
GAUGE_FILLED = ""
GAUGE_EMPTY = ""
# ---------------------------------------------------------------------------
# Braille patterns for sparklines
# ---------------------------------------------------------------------------
# Braille base: U+2800. Each character is a 2×4 dot matrix.
# Dot positions (bit index):
# 0 3
# 1 4
# 2 5
# 6 7
BRAILLE_BASE = 0x2800
# Row dot bits for left column (bits 0,1,2,6) and right column (bits 3,4,5,7)
BRAILLE_LEFT = [0x01, 0x02, 0x04, 0x40] # rows 0-3
BRAILLE_RIGHT = [0x08, 0x10, 0x20, 0x80] # rows 0-3
def braille_char(dots: list[tuple[int, int]]) -> str:
"""Build a braille character from a list of (col, row) positions.
col: 0 (left) or 1 (right)
row: 0 (top) to 3 (bottom)
"""
code = BRAILLE_BASE
for col, row in dots:
if 0 <= row <= 3:
if col == 0:
code |= BRAILLE_LEFT[row]
else:
code |= BRAILLE_RIGHT[row]
return chr(code)
def sparkline_chars(values: list[float], width: int) -> list[str]:
"""Convert a list of values into braille sparkline characters.
Each output character represents two consecutive values (left + right columns).
Values are normalized to 07 (mapping to 4 braille rows × 2 resolution).
"""
if not values:
return []
lo = min(values)
hi = max(values)
span = hi - lo if hi != lo else 1.0
# Normalize to 07 range (8 vertical positions: 4 rows × 2 resolution)
norm = [int((v - lo) / span * 7) for v in values]
# Pad to even length
if len(norm) % 2:
norm.append(norm[-1])
chars = []
for i in range(0, min(len(norm), width * 2), 2):
left_val = norm[i]
right_val = norm[i + 1] if i + 1 < len(norm) else norm[i]
dots = []
# Fill dots from bottom up for each column
for row in range(3, -1, -1):
threshold = (3 - row) * 2 # row 3=0, row 2=2, row 1=4, row 0=6
if left_val >= threshold:
dots.append((0, row))
if right_val >= threshold:
dots.append((1, row))
chars.append(braille_char(dots))
return chars[:width]
# ---------------------------------------------------------------------------
# Status indicators
# ---------------------------------------------------------------------------
STATUS_CHARS: dict[str, str] = {
"online": "",
"offline": "",
"degraded": "",
"unknown": "",
"alert": "",
}
STATUS_COLORS: dict[str, str] = {
"online": "0f0",
"offline": "f00",
"degraded": "ff0",
"unknown": "888",
"alert": "f00",
}
# ---------------------------------------------------------------------------
# Divider characters
# ---------------------------------------------------------------------------
DIVIDER_CHARS: dict[str, str] = {
"light": "",
"heavy": "",
"double": "",
"dash": "",
"dot": "",
}

193
backend/uframe/cli.py Normal file
View File

@@ -0,0 +1,193 @@
"""µFrame CLI — render, compile, check, and deploy .uf files.
Usage:
python -m uframe.cli render <file.uf> [--ascii | --micron] [--width N]
python -m uframe.cli compile <file.uf> [--out <file.mu>] [--embed]
python -m uframe.cli check <file.uf>
python -m uframe.cli deploy <file.uf> [--dest <dir>]
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
import uframe
from uframe.errors import UFrameError
def cmd_render(args: argparse.Namespace) -> int:
"""Render a .uf file to ASCII and/or Micron."""
source = Path(args.file).read_text(encoding="utf-8")
try:
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if args.ascii:
print(result.ascii)
elif args.micron:
print(result.micron)
else:
# Default: show ASCII
print(result.ascii)
if result.warnings:
for w in result.warnings:
print(f"Warning: {w.message}", file=sys.stderr)
if result.is_dynamic:
print(f"\n[Dynamic page — {len(result.script)} bytes of generated script]", file=sys.stderr)
return 0
def cmd_compile(args: argparse.Namespace) -> int:
"""Compile a .uf file to an executable .mu script (dynamic) or static .mu."""
source_path = Path(args.file)
source = source_path.read_text(encoding="utf-8")
try:
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
out_path = Path(args.out) if args.out else source_path.with_suffix(".mu")
if result.is_dynamic and result.script:
out_path.write_text(result.script, encoding="utf-8")
out_path.chmod(0o755)
print(f"Compiled dynamic: {out_path} ({len(result.script)} bytes, +x)")
else:
out_path.write_text(result.micron, encoding="utf-8")
print(f"Compiled static: {out_path} ({len(result.micron)} bytes)")
return 0
def cmd_check(args: argparse.Namespace) -> int:
"""Validate a .uf file without generating output."""
source = Path(args.file).read_text(encoding="utf-8")
try:
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
status = "dynamic" if result.is_dynamic else "static"
print(f"OK: {args.file} ({status}, {len(result.ascii)} chars ASCII, {len(result.micron)} chars Micron)")
if result.warnings:
for w in result.warnings:
print(f" Warning: {w.message}")
return 0
def cmd_deploy(args: argparse.Namespace) -> int:
"""Compile and deploy a .uf file to the NomadNet pages directory."""
source_path = Path(args.file)
source = source_path.read_text(encoding="utf-8")
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
try:
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
dest_dir.mkdir(parents=True, exist_ok=True)
out_path = dest_dir / f"{source_path.stem}.mu"
if result.is_dynamic and result.script:
out_path.write_text(result.script, encoding="utf-8")
out_path.chmod(0o755)
print(f"Deployed dynamic: {out_path}")
else:
out_path.write_text(result.micron, encoding="utf-8")
out_path.chmod(0o644)
print(f"Deployed static: {out_path}")
return 0
def cmd_image(args: argparse.Namespace) -> int:
"""Convert an image file to character art."""
from uframe.imaging import convert_image
try:
lines = convert_image(args.file, mode=args.mode, width=args.width,
dither=args.dither, invert=args.invert)
for line in lines:
print(line)
except ImportError:
print("Error: Pillow is required: pip install Pillow", file=sys.stderr)
return 1
except FileNotFoundError:
print(f"Error: Image not found: {args.file}", file=sys.stderr)
return 1
return 0
def main() -> int:
parser = argparse.ArgumentParser(
prog="uframe",
description="µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron",
)
sub = parser.add_subparsers(dest="command", required=True)
# render
p_render = sub.add_parser("render", help="Render a .uf file")
p_render.add_argument("file", help="Path to .uf source file")
p_render.add_argument("--ascii", action="store_true", help="Output ASCII only")
p_render.add_argument("--micron", action="store_true", help="Output Micron only")
p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)")
p_render.add_argument("--theme", default="", help="Theme name (default, nouveau, gothic, bamboo, circuit, brutalist)")
# compile
p_compile = sub.add_parser("compile", help="Compile to .mu file")
p_compile.add_argument("file", help="Path to .uf source file")
p_compile.add_argument("--out", help="Output file path (default: <name>.mu)")
p_compile.add_argument("--width", type=int, default=64, help="Page width")
p_compile.add_argument("--theme", default="", help="Theme name")
# check
p_check = sub.add_parser("check", help="Validate a .uf file")
p_check.add_argument("file", help="Path to .uf source file")
p_check.add_argument("--width", type=int, default=64, help="Page width")
# deploy
p_deploy = sub.add_parser("deploy", help="Compile and deploy to NomadNet")
p_deploy.add_argument("file", help="Path to .uf source file")
p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
p_deploy.add_argument("--width", type=int, default=64, help="Page width")
p_deploy.add_argument("--theme", default="", help="Theme name")
# image (standalone conversion)
p_image = sub.add_parser("image", help="Convert image to character art")
p_image.add_argument("file", help="Path to image file")
p_image.add_argument("--mode", default="braille", help="braille, block, ascii, halfblock")
p_image.add_argument("--width", type=int, default=40, help="Output width")
p_image.add_argument("--dither", default="floyd", help="floyd, threshold, none")
p_image.add_argument("--invert", action="store_true", help="Invert light/dark")
args = parser.parse_args()
commands = {
"render": cmd_render,
"compile": cmd_compile,
"check": cmd_check,
"deploy": cmd_deploy,
"image": cmd_image,
}
return commands[args.command](args)
if __name__ == "__main__":
sys.exit(main())

417
backend/uframe/codegen.py Normal file
View File

@@ -0,0 +1,417 @@
"""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
from uframe.ir import (
IRNode, Page, Box, Spacer,
Heading, Text, Label, Divider, Link,
Gauge, Status,
Form, Field, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
SourceType,
)
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.
First escapes literal braces (e.g. @color{ff0}{text} → @color{{ff0}}{{text}})
so they survive f-string evaluation, then replaces $var → {var}.
"""
import re
# Use a unique placeholder for $var refs, escape all braces, then restore
_PH = "\x00VAR"
counter = [0]
placeholders: dict[str, str] = {}
def stash_var(m: re.Match) -> str:
var = m.group(1)
if "." in var:
parts = var.split(".")
base = parts[0]
chain = "".join(f"['{p}']" for p in parts[1:])
expr = "{" + base + chain + "}"
else:
expr = "{" + var + "}"
key = f"{_PH}{counter[0]}\x00"
counter[0] += 1
placeholders[key] = expr
return key
# 1. Stash $var references with placeholders
result = re.sub(r'\$([a-zA-Z_][\w.]*)', stash_var, text)
# 2. Escape all remaining braces for f-string safety
result = result.replace("{", "{{").replace("}", "}}")
# 3. Restore $var placeholders (unescaped)
for key, expr in placeholders.items():
result = result.replace(key, expr)
return result
def _resolve_vars_code(text: str) -> str:
"""Convert $var references to bare Python identifiers.
Simple vars: $name → name
Dotted paths: $item.name → item['name'] (dict access)
"""
import re
def replace_var(m: re.Match) -> str:
var = m.group(1)
if "." in var:
parts = var.split(".")
base = parts[0]
chain = "".join(f"['{p}']" for p in parts[1:])
return base + chain
return 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}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})")
elif node.source_type == SourceType.PARAM:
lines.append(f"{ind}{var} = _get_param({node.command!r})")
elif node.source_type == SourceType.RNS:
import shlex as _shlex
safe_cmd = _shlex.quote(node.command)
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
elif node.source_type == SourceType.HTTP:
method = node.http_method or "GET"
url = _resolve_vars(node.command)
hdrs = _resolve_vars(node.http_headers) if node.http_headers else ""
body = _resolve_vars(node.http_body) if node.http_body else ""
if body:
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, body=f'''{body}''', headers=f'''{hdrs}''', timeout={node.timeout})""")
elif hdrs:
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, headers=f'''{hdrs}''', timeout={node.timeout})""")
else:
lines.append(f"{ind}{var} = _http({node.command!r}, method={method!r}, timeout={node.timeout})")
elif node.source_type == SourceType.SQLITE:
lines.append(f"{ind}{var} = _sqlite({node.command!r}, {node.query!r})")
elif node.source_type == SourceType.ENV:
lines.append(f"{ind}{var} = os.environ.get({node.command!r}, '')")
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_code(node.condition)
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_code(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_code(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)
raw_val = getattr(node, "_raw_value", None)
value = _resolve_vars(raw_val) if raw_val and "$" in raw_val else str(node.value)
raw_max = getattr(node, "_raw_max_val", None)
max_val = _resolve_vars(raw_max) if raw_max and "$" in raw_max else str(node.max_val)
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} {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 <source>.uf",
"",
"import os, sys, json, subprocess, datetime, secrets, shlex",
"from datetime import datetime as _dt_cls, timedelta",
"",
"# ─── 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 []',
"",
'def _http(url, method="GET", body="", headers="", timeout=10):',
' """HTTP request, return response body (JSON parsed if possible)."""',
' import urllib.request, urllib.error',
' try:',
' data = body.encode("utf-8") if body else None',
' req = urllib.request.Request(url, data=data, method=method)',
' req.add_header("User-Agent", "uframe/1.0")',
' if body and not headers:',
' req.add_header("Content-Type", "application/json")',
' if headers:',
' for pair in headers.split(";"):',
' if ":" in pair:',
' k, v = pair.split(":", 1)',
' req.add_header(k.strip(), v.strip())',
' with urllib.request.urlopen(req, timeout=timeout) as resp:',
' raw = resp.read().decode("utf-8")',
' try:',
' return json.loads(raw)',
' except (json.JSONDecodeError, ValueError):',
' return raw.strip()',
' except Exception as e:',
' return {"error": str(e)}',
"",
'def _sqlite(db_path, query):',
' """Run a SQLite query, return list of dicts."""',
' import sqlite3',
' try:',
' conn = sqlite3.connect(db_path)',
' conn.row_factory = sqlite3.Row',
' cur = conn.execute(query)',
' rows = [dict(r) for r in cur.fetchall()]',
' conn.close()',
' return rows if len(rows) != 1 else rows[0]',
' except Exception as e:',
' return {"error": str(e)}',
"",
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,
)

View File

@@ -0,0 +1,22 @@
"""ASCII emitter — read CharGrid and output plain text.
Reads only cell.char from each cell. No color, no style tags.
"""
from __future__ import annotations
from uframe.grid import CharGrid
def emit_ascii(grid: CharGrid) -> str:
"""Emit the CharGrid as plain ASCII text."""
lines: list[str] = []
for row in range(grid.height):
line = "".join(cell.char for cell in grid.cells[row]).rstrip()
lines.append(line)
# Strip trailing blank lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)

View File

@@ -0,0 +1,114 @@
"""Micron emitter — read CharGrid and output Micron markup with style tags.
Scans each line left-to-right, tracks style state, and opens/closes
Micron format codes at style transitions. Box-drawing characters pass
through as literal text.
Micron format reference:
`!bold`! `*italic`* `_underline`_
`Fhex text`f `Bhex text`b
`c center`a `r right`a
[label`dest] [label`dest.mu]
>H1 >>H2 >>>H3
"""
from __future__ import annotations
from uframe.grid import CharGrid, CellStyle
from uframe.ir import Page, Heading, HeadingLevel
def _emit_style_open(style: CellStyle) -> str:
"""Emit Micron opening tags for a style."""
tags: list[str] = []
if style.bold:
tags.append("`!")
if style.italic:
tags.append("`*")
if style.underline:
tags.append("`_")
if style.fg:
tags.append(f"`F{style.fg}")
if style.bg:
tags.append(f"`B{style.bg}")
return "".join(tags)
def _emit_style_close(style: CellStyle) -> str:
"""Emit Micron closing tags for a style (in reverse order)."""
tags: list[str] = []
if style.bg:
tags.append("`b")
if style.fg:
tags.append("`f")
if style.underline:
tags.append("`_")
if style.italic:
tags.append("`*")
if style.bold:
tags.append("`!")
return "".join(tags)
_EMPTY_STYLE = CellStyle()
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
"""Emit the CharGrid as Micron markup."""
lines: list[str] = []
for row in range(grid.height):
line_parts: list[str] = []
cur_style = _EMPTY_STYLE
in_link: str | None = None
for col in range(grid.width):
cell = grid.cells[row][col]
ch = cell.char
style = cell.style
link = cell.link
# Handle link transitions
if link != in_link:
if in_link is not None:
# Close previous link: [label`dest]
line_parts.append(f"`{in_link}]")
if link is not None:
# Close any open style before link
if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style))
cur_style = _EMPTY_STYLE
# Open new link — backtick enters formatting mode
# where the parser recognizes `[` as link start
line_parts.append("`[")
in_link = link
# Handle style transitions (not inside links — links handle their own style)
if link is None and style != cur_style:
# Close previous style
if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style))
# Open new style
if style != _EMPTY_STYLE:
line_parts.append(_emit_style_open(style))
cur_style = style
line_parts.append(ch)
# Close any trailing link: [label`dest]
if in_link is not None:
line_parts.append(f"`{in_link}]")
in_link = None
# Close any trailing style
if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style))
line = "".join(line_parts).rstrip()
lines.append(line)
# Strip trailing blank lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)

45
backend/uframe/errors.py Normal file
View File

@@ -0,0 +1,45 @@
"""µFrame error types with source location tracking."""
from __future__ import annotations
class UFrameError(Exception):
"""Base error for all µFrame operations."""
def __init__(self, message: str, line: int | None = None, col: int | None = None):
self.line = line
self.col = col
loc = ""
if line is not None:
loc = f" (line {line}"
if col is not None:
loc += f", col {col}"
loc += ")"
super().__init__(f"{message}{loc}")
class ParseError(UFrameError):
"""Raised when .uf source cannot be parsed."""
pass
class LayoutError(UFrameError):
"""Raised when layout constraints cannot be satisfied."""
pass
class CompileWarning:
"""Non-fatal issue discovered during compilation."""
__slots__ = ("message", "line", "col")
def __init__(self, message: str, line: int | None = None, col: int | None = None):
self.message = message
self.line = line
self.col = col
def __repr__(self) -> str:
loc = ""
if self.line is not None:
loc = f" line={self.line}"
return f"CompileWarning({self.message!r}{loc})"

209
backend/uframe/fonts.py Normal file
View File

@@ -0,0 +1,209 @@
"""µFrame Big Text Fonts — multi-line ASCII art letter definitions.
Each font is a dict mapping characters to a list of strings (one per line).
All characters in a font have the same height.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Block font — solid █ with box-drawing. Height: 6 lines.
# ---------------------------------------------------------------------------
_BLOCK: dict[str, list[str]] = {
"A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
"B": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
"C": [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
"D": ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
"E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
"F": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "██║ ", "╚═╝ "],
"G": [" ██████╗ ", "██╔════╝ ", "██║ ███╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"H": ["██╗ ██╗", "██║ ██║", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
"I": ["██╗", "██║", "██║", "██║", "██║", "╚═╝"],
"J": [" ██╗", " ██║", " ██║", "██ ██║", "╚█████╔╝", " ╚════╝ "],
"K": ["██╗ ██╗", "██║ ██╔╝", "█████╔╝ ", "██╔═██╗ ", "██║ ██╗", "╚═╝ ╚═╝"],
"L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
"M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
"N": ["███╗ ██╗", "████╗ ██║", "██╔██╗ ██║", "██║╚██╗██║", "██║ ╚████║", "╚═╝ ╚═══╝"],
"O": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"P": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔═══╝ ", "██║ ", "╚═╝ "],
"Q": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║▄▄ ██║", "╚██████╔╝", " ╚══▀▀═╝ "],
"R": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██║ ██║", "╚═╝ ╚═╝"],
"S": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
"T": ["████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ██║ ", " ╚═╝ "],
"U": ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"V": ["██╗ ██╗", "██║ ██║", "██║ ██║", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚═══╝ "],
"W": ["██╗ ██╗", "██║ ██║", "██║ █╗ ██║", "██║███╗██║", "╚███╔███╔╝", " ╚══╝╚══╝ "],
"X": ["██╗ ██╗", "╚██╗██╔╝", " ╚███╔╝ ", " ██╔██╗ ", "██╔╝ ██╗", "╚═╝ ╚═╝"],
"Y": ["██╗ ██╗", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚██╔╝ ", " ██║ ", " ╚═╝ "],
"Z": ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"],
"0": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
"1": [" ██╗", "███║", "╚██║", " ██║", " ██║", " ╚═╝"],
"2": ["██████╗ ", "╚════██╗", " █████╔╝", "██╔═══╝ ", "███████╗", "╚══════╝"],
"3": ["██████╗ ", "╚════██╗", " █████╔╝", " ╚═══██╗", "██████╔╝", "╚═════╝ "],
"4": ["██╗ ██╗", "██║ ██║", "███████║", "╚════██║", " ██║", " ╚═╝"],
"5": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
"6": [" ██████╗", "██╔════╝", "██████╗ ", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
"7": ["███████╗", "╚════██║", " ██╔╝", " ██╔╝ ", " ██║ ", " ╚═╝ "],
"8": [" █████╗ ", "██╔══██╗", "╚█████╔╝", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
"9": [" █████╗ ", "██╔══██╗", "╚██████║", " ╚═══██║", " █████╔╝", " ╚════╝ "],
"-": [" ", " ", "██████╗ ", "╚═════╝ ", " ", " "],
" ": [" ", " ", " ", " ", " ", " "],
".": [" ", " ", " ", " ", "██╗", "╚═╝"],
"!": ["██╗", "██║", "██║", "╚═╝", "██╗", "╚═╝"],
}
# ---------------------------------------------------------------------------
# Thin font — light single-stroke. Height: 3 lines.
# ---------------------------------------------------------------------------
_THIN: dict[str, list[str]] = {
"A": ["┌─┐", "├─┤", "┘ └"],
"B": ["┬─┐", "├─┤", "┴─┘"],
"C": ["┌─ ", "", "└─ "],
"D": ["┬─┐", "│ │", "┴─┘"],
"E": ["┬──", "├─ ", "┴──"],
"F": ["┬──", "├─ ", ""],
"G": ["┌─ ", "│ ┐", "└─┘"],
"H": ["┐ ┌", "├─┤", "┘ └"],
"I": ["", "", ""],
"J": ["", "", "└─┘"],
"K": ["┐ ┌", "├┬┘", "┘└ "],
"L": ["", "", "└──"],
"M": ["┌┬┐", "│││", "┘ └"],
"N": ["┌┐ ", "│└┐", "┘ └"],
"O": ["┌─┐", "│ │", "└─┘"],
"P": ["┌─┐", "├─┘", ""],
"Q": ["┌─┐", "│ │", "└─┤"],
"R": ["┌─┐", "├─┤", "┘ └"],
"S": ["┌─ ", "└─┐", " ─┘"],
"T": ["┬─┬", "", ""],
"U": ["┐ ┌", "│ │", "└─┘"],
"V": ["┐ ┌", "│ │", "└┬┘"],
"W": ["┐ ┌", "│││", "└┴┘"],
"X": ["", " ", ""],
"Y": ["┐ ┌", "└┬┘", ""],
"Z": ["──┐", " ", "└──"],
"0": ["┌─┐", "│ │", "└─┘"],
"1": ["", "", ""],
"2": ["─┐", "┌┘", "└─"],
"3": ["─┐", "", "─┘"],
"4": ["┐ ┐", "└─┤", ""],
"5": ["┌─", "└┐", "─┘"],
"6": ["┌─", "├┐", "└┘"],
"7": ["──┐", "", ""],
"8": ["┌┐", "├┤", "└┘"],
"9": ["┌┐", "└┤", "─┘"],
"-": [" ", "── ", " "],
" ": [" ", " ", " "],
".": [" ", " ", "·"],
"!": ["", " ", "·"],
}
# ---------------------------------------------------------------------------
# Pixel font — retro bitmap. Height: 3 lines.
# ---------------------------------------------------------------------------
_PIXEL: dict[str, list[str]] = {
"A": ["▀▀▀▄", "█▀▀█", "▀ ▀"],
"B": ["▀▀▀▄", "█▀▀▄", "▀▀▀ "],
"C": ["▄▀▀▀", "", "▀▀▀ "],
"D": ["▀▀▀▄", "█ █", "▀▀▀ "],
"E": ["▀▀▀ ", "█▀ ", "▀▀▀ "],
"F": ["▀▀▀ ", "█▀ ", ""],
"G": ["▄▀▀ ", "█ ▀█", "▀▀▀ "],
"H": ["▀ ▀ ", "█▀▀█", "▀ ▀"],
"I": ["", "", ""],
"J": ["", "", "▀▀ "],
"K": ["▀ ▄ ", "█▀▄ ", "▀ ▀"],
"L": ["", "", "▀▀▀ "],
"M": ["▀▄▄▀", "█▀▀█", "▀ ▀"],
"N": ["▀▄ ▀", "█ ▀█", "▀ ▀"],
"O": ["▄▀▀▄", "█ █", "▀▀▀ "],
"P": ["▀▀▀▄", "█▀▀ ", ""],
"Q": ["▄▀▀▄", "█ █", "▀▀▀▄"],
"R": ["▀▀▀▄", "█▀▀▄", "▀ ▀"],
"S": ["▄▀▀ ", "▀▀▀▄", " ▀▀ "],
"T": ["▀▀▀▀", "", ""],
"U": ["▀ ▀", "█ █", "▀▀▀ "],
"V": ["▀ ▀", "█ █", " ▀▀ "],
"W": ["▀ ▀", "█▄▄█", "▀▀▀▀"],
"X": ["▀ ▀", " ▀▀ ", "▀ ▀"],
"Y": ["▀ ▀", " ▀▀ ", ""],
"Z": ["▀▀▀▀", " ▄▀ ", "▀▀▀▀"],
"0": ["▄▀▀▄", "█ █", "▀▀▀ "],
"1": ["", "", ""],
"2": ["▀▀▄", " ▄▀", "▀▀▀"],
"3": ["▀▀▄", " ▀▄", "▀▀ "],
"4": ["▀ ▀", "▀▀█", ""],
"5": ["▀▀▀", "▀▀▄", "▀▀ "],
"6": ["▄▀▀", "█▀▄", "▀▀ "],
"7": ["▀▀▀", "", ""],
"8": ["▄▀▄", "█▀█", "▀▀ "],
"9": ["▄▀▄", "▀▀█", "▀▀ "],
"-": [" ", " ▀▀ ", " "],
" ": [" ", " ", " "],
".": [" ", " ", ""],
"!": ["", " ", ""],
}
# ---------------------------------------------------------------------------
# Font registry
# ---------------------------------------------------------------------------
FONTS: dict[str, dict[str, list[str]]] = {
"block": _BLOCK,
"thin": _THIN,
"pixel": _PIXEL,
}
FONT_HEIGHTS: dict[str, int] = {
"block": 6,
"thin": 3,
"pixel": 3,
}
def render_big_text(text: str, font_name: str = "block", kerning: int = 1) -> list[str]:
"""Render text as multi-line ASCII art using the specified font.
Args:
text: the string to render (uppercase recommended for block/pixel)
font_name: "block", "thin", or "pixel"
kerning: space between characters (0=tight, 1=normal, 2=wide)
Returns:
List of strings, one per line of the rendered text.
"""
font = FONTS.get(font_name, _BLOCK)
height = FONT_HEIGHTS.get(font_name, 6)
text_upper = text.upper()
# Build each line by concatenating character columns
lines: list[str] = ["" for _ in range(height)]
spacer = " " * kerning
for i, ch in enumerate(text_upper):
glyph = font.get(ch, font.get("?", [" " * 3] * height))
for row in range(height):
if row < len(glyph):
lines[row] += glyph[row]
else:
lines[row] += " " * (len(glyph[0]) if glyph else 3)
if i < len(text_upper) - 1:
lines[row] += spacer
return lines
def get_text_width(text: str, font_name: str = "block", kerning: int = 1) -> int:
"""Calculate the rendered width of big text without rendering it."""
font = FONTS.get(font_name, _BLOCK)
text_upper = text.upper()
width = 0
for i, ch in enumerate(text_upper):
glyph = font.get(ch, font.get("?", [" " * 3]))
width += len(glyph[0]) if glyph else 3
if i < len(text_upper) - 1:
width += kerning
return width

182
backend/uframe/grid.py Normal file
View File

@@ -0,0 +1,182 @@
"""CharGrid — 2D character buffer with per-cell style annotations.
The CharGrid is the intermediate representation between layout and emission.
Both the ASCII and Micron emitters read from the same grid.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from uframe.chars import BOX_CHARS
from uframe.ir import BorderWeight
@dataclass
class CellStyle:
"""Per-cell visual style for Micron emission."""
fg: str | None = None # 3-digit hex color
bg: str | None = None
bold: bool = False
italic: bool = False
underline: bool = False
def __eq__(self, other: object) -> bool:
if not isinstance(other, CellStyle):
return NotImplemented
return (self.fg == other.fg and self.bg == other.bg
and self.bold == other.bold and self.italic == other.italic
and self.underline == other.underline)
def __hash__(self) -> int:
return hash((self.fg, self.bg, self.bold, self.italic, self.underline))
@dataclass
class Cell:
"""A single cell in the CharGrid."""
char: str = " "
style: CellStyle = field(default_factory=CellStyle)
is_border: bool = False # True for box-drawing characters (for merge pass)
border_weight: BorderWeight | None = None
border_id: int = 0 # Identifies which box this border belongs to
link: str | None = None # Micron link destination
class CharGrid:
"""2D buffer of cells. Origin (0,0) is top-left."""
__slots__ = ("width", "height", "cells", "_border_counter")
def __init__(self, width: int, height: int):
self.width = width
self.height = height
self._border_counter = 0
self.cells: list[list[Cell]] = [
[Cell() for _ in range(width)]
for _ in range(height)
]
def in_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.width and 0 <= y < self.height
def put(self, x: int, y: int, char: str,
style: CellStyle | None = None,
is_border: bool = False,
border_weight: BorderWeight | None = None,
border_id: int = 0,
link: str | None = None) -> None:
"""Write a single character to the grid."""
if not self.in_bounds(x, y):
return
cell = self.cells[y][x]
cell.char = char
if style is not None:
cell.style = style
cell.is_border = is_border
cell.border_weight = border_weight
if border_id:
cell.border_id = border_id
if link is not None:
cell.link = link
def put_text(self, x: int, y: int, text: str,
style: CellStyle | None = None,
link: str | None = None) -> int:
"""Write a string horizontally starting at (x, y).
Returns the number of characters actually written.
"""
written = 0
for i, ch in enumerate(text):
px = x + i
if not self.in_bounds(px, y):
break
self.put(px, y, ch, style=style, link=link)
written += 1
return written
def fill_rect(self, x: int, y: int, w: int, h: int, char: str = " ",
style: CellStyle | None = None) -> None:
"""Fill a rectangular region with a character."""
for row in range(y, y + h):
for col in range(x, x + w):
self.put(col, row, char, style=style)
def draw_border(self, x: int, y: int, w: int, h: int,
weight: BorderWeight = BorderWeight.LIGHT,
title: str = "",
title_style: CellStyle | None = None,
border_chars: dict[str, str] | None = None,
title_caps: tuple[str, str] | None = None) -> None:
"""Draw a box border. Interior is not cleared.
Args:
x, y: top-left corner
w, h: outer dimensions (including border)
weight: border style
title: optional title inset in top border
title_style: style for the title text
"""
if w < 2 or h < 2:
return
self._border_counter += 1
bid = self._border_counter
ch = border_chars or BOX_CHARS[weight]
border_style = CellStyle()
# Corners
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight, border_id=bid)
# Top and bottom edges
for col in range(x + 1, x + w - 1):
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
# Left and right edges
for row in range(y + 1, y + h - 1):
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
# Title in top border
if title and w > 4:
lc = title_caps[0] if title_caps else " "
rc = title_caps[1] if title_caps else " "
title_text = f"{lc}{title}{rc}"
max_title = w - 4
if len(title_text) > max_title:
title_text = title_text[:max_title]
start_x = x + 1
ts = title_style or CellStyle(bold=True)
self.put_text(start_x, y, title_text, style=ts)
def grow_height(self, new_height: int) -> None:
"""Expand the grid vertically if needed."""
if new_height <= self.height:
return
for _ in range(new_height - self.height):
self.cells.append([Cell() for _ in range(self.width)])
self.height = new_height
def get_line(self, row: int) -> str:
"""Get a single row as a plain string (chars only)."""
if 0 <= row < self.height:
return "".join(cell.char for cell in self.cells[row])
return ""
def to_text(self) -> str:
"""Emit the entire grid as plain text (ASCII mode)."""
lines = []
for row in range(self.height):
line = self.get_line(row).rstrip()
lines.append(line)
# Strip trailing blank lines
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)

267
backend/uframe/imaging.py Normal file
View File

@@ -0,0 +1,267 @@
"""µFrame Image Converter — convert images to character art.
Supports braille, block, ascii, and halfblock rendering modes
with optional dithering and color output.
"""
from __future__ import annotations
import os
from pathlib import Path
from uframe.chars import BRAILLE_BASE, BRAILLE_LEFT, BRAILLE_RIGHT
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
# ASCII brightness ramp (light → dark)
ASCII_RAMP = " .:-=+*#%@"
# Block shade ramp (light → dark)
BLOCK_RAMP = " ░▒▓█"
def _load_image(path: str) -> "Image.Image":
"""Load an image from file path."""
if not HAS_PIL:
raise ImportError("Pillow is required for image conversion: pip install Pillow")
resolved = Path(path).expanduser()
if not resolved.is_file():
raise FileNotFoundError(f"Image not found: {path}")
return Image.open(str(resolved))
def _floyd_steinberg(pixels: list[list[float]], w: int, h: int, levels: int = 2) -> list[list[int]]:
"""Apply Floyd-Steinberg dithering to a grayscale pixel array.
Returns quantized values in range [0, levels-1].
"""
result = [[0] * w for _ in range(h)]
err = [row[:] for row in pixels] # copy
for y in range(h):
for x in range(w):
old = err[y][x]
new = round(old * (levels - 1)) / (levels - 1) if levels > 1 else (1.0 if old > 0.5 else 0.0)
result[y][x] = int(round(new * (levels - 1)))
quant_err = old - new
if x + 1 < w:
err[y][x + 1] += quant_err * 7 / 16
if y + 1 < h:
if x - 1 >= 0:
err[y + 1][x - 1] += quant_err * 3 / 16
err[y + 1][x] += quant_err * 5 / 16
if x + 1 < w:
err[y + 1][x + 1] += quant_err * 1 / 16
return result
def convert_braille(img: "Image.Image", width: int, dither: str = "floyd",
invert: bool = False) -> list[str]:
"""Convert image to braille character art.
Each character encodes a 2×4 pixel block. Resolution: 2x horizontal, 4x vertical.
"""
# Resize: each output char = 2 pixels wide × 4 pixels tall
pixel_w = width * 2
aspect = img.height / img.width
pixel_h = int(pixel_w * aspect / 2) # /2 for terminal cell aspect
pixel_h = max(pixel_h, 4)
# Round up to multiple of 4
pixel_h = ((pixel_h + 3) // 4) * 4
img_resized = img.resize((pixel_w, pixel_h)).convert("L")
# Get pixel data as 0.01.0 floats
pixels = []
for y in range(pixel_h):
row = []
for x in range(pixel_w):
v = img_resized.getpixel((x, y)) / 255.0
if invert:
v = 1.0 - v
row.append(v)
pixels.append(row)
# Dither to binary
if dither == "floyd":
binary = _floyd_steinberg(pixels, pixel_w, pixel_h, levels=2)
else:
binary = [[1 if p > 0.5 else 0 for p in row] for row in pixels]
# Map 2×4 blocks to braille characters
lines: list[str] = []
for by in range(0, pixel_h, 4):
line = ""
for bx in range(0, pixel_w, 2):
code = BRAILLE_BASE
for row in range(4):
py = by + row
if py < pixel_h:
# Left column
px_l = bx
if px_l < pixel_w and binary[py][px_l]:
code |= BRAILLE_LEFT[row]
# Right column
px_r = bx + 1
if px_r < pixel_w and binary[py][px_r]:
code |= BRAILLE_RIGHT[row]
line += chr(code)
lines.append(line)
return lines
def convert_block(img: "Image.Image", width: int, dither: str = "none",
invert: bool = False) -> list[str]:
"""Convert image to block shade characters (░▒▓█)."""
aspect = img.height / img.width
height = max(1, int(width * aspect / 2)) # /2 for terminal cell aspect
img_resized = img.resize((width, height)).convert("L")
pixels = []
for y in range(height):
row = []
for x in range(width):
v = img_resized.getpixel((x, y)) / 255.0
if invert:
v = 1.0 - v
row.append(v)
pixels.append(row)
if dither == "floyd":
quantized = _floyd_steinberg(pixels, width, height, levels=len(BLOCK_RAMP))
else:
quantized = [[int(p * (len(BLOCK_RAMP) - 1)) for p in row] for row in pixels]
lines: list[str] = []
for row in quantized:
line = "".join(BLOCK_RAMP[min(v, len(BLOCK_RAMP) - 1)] for v in row)
lines.append(line)
return lines
def convert_ascii(img: "Image.Image", width: int, dither: str = "none",
invert: bool = False) -> list[str]:
"""Convert image to classic ASCII art using brightness ramp."""
aspect = img.height / img.width
height = max(1, int(width * aspect / 2))
img_resized = img.resize((width, height)).convert("L")
lines: list[str] = []
for y in range(height):
line = ""
for x in range(width):
v = img_resized.getpixel((x, y)) / 255.0
if invert:
v = 1.0 - v
idx = int(v * (len(ASCII_RAMP) - 1))
line += ASCII_RAMP[min(idx, len(ASCII_RAMP) - 1)]
lines.append(line)
return lines
def convert_halfblock(img: "Image.Image", width: int,
invert: bool = False, use_color: bool = False) -> list[tuple[str, str | None, str | None]]:
"""Convert image to half-block characters with optional color.
Uses ▄ with foreground (bottom pixel) and background (top pixel) colors.
Returns list of (line_text, fg_colors, bg_colors) tuples.
Each fg/bg color string has one 3-digit hex per character, or None for mono.
"""
aspect = img.height / img.width
height = max(2, int(width * aspect / 2))
# Round up to even
height = height + (height % 2)
img_resized = img.resize((width, height))
if use_color:
img_rgb = img_resized.convert("RGB")
img_gray = img_resized.convert("L")
lines: list[tuple[str, str | None, str | None]] = []
for y in range(0, height, 2):
chars = ""
fgs = "" if use_color else None
bgs = "" if use_color else None
for x in range(width):
top_v = img_gray.getpixel((x, y)) / 255.0
bot_v = img_gray.getpixel((x, y + 1)) / 255.0 if y + 1 < height else 0
if invert:
top_v = 1.0 - top_v
bot_v = 1.0 - bot_v
if use_color:
top_rgb = img_rgb.getpixel((x, y))
bot_rgb = img_rgb.getpixel((x, y + 1)) if y + 1 < height else (0, 0, 0)
# Quantize to 3-digit hex
fg_hex = f"{round(bot_rgb[0]*15/255):x}{round(bot_rgb[1]*15/255):x}{round(bot_rgb[2]*15/255):x}"
bg_hex = f"{round(top_rgb[0]*15/255):x}{round(top_rgb[1]*15/255):x}{round(top_rgb[2]*15/255):x}"
fgs += fg_hex
bgs += bg_hex
chars += ""
lines.append((chars, fgs, bgs))
return lines
def convert_image(path: str, mode: str = "braille", width: int = 30,
dither: str = "floyd", invert: bool = False,
use_color: bool = False) -> list[str]:
"""High-level image conversion — returns list of character art lines.
Args:
path: image file path
mode: "braille", "block", "ascii", "halfblock"
width: output width in characters
dither: "floyd", "threshold", "none"
invert: flip light/dark
use_color: preserve colors (halfblock only for now)
Returns:
List of strings, one per output line.
"""
img = _load_image(path)
if mode == "braille":
return convert_braille(img, width, dither, invert)
elif mode == "block":
return convert_block(img, width, dither, invert)
elif mode == "ascii":
return convert_ascii(img, width, dither, invert)
elif mode == "halfblock":
hb_lines = convert_halfblock(img, width, invert, use_color)
# For non-color mode, just return the character strings
return [line[0] for line in hb_lines]
else:
return convert_braille(img, width, dither, invert)
def get_image_height(path: str, mode: str = "braille", width: int = 30) -> int:
"""Calculate the output height for an image without full conversion."""
try:
img = _load_image(path)
except (ImportError, FileNotFoundError):
return 1
aspect = img.height / img.width
if mode == "braille":
pixel_h = int(width * 2 * aspect / 2)
return max(1, ((pixel_h + 3) // 4))
else:
return max(1, int(width * aspect / 2))

434
backend/uframe/ir.py Normal file
View File

@@ -0,0 +1,434 @@
"""µFrame Intermediate Representation — node types for the IR tree.
Every .uf source parses into a tree of IRNode subclasses. The layout
engine measures, positions, and paints these nodes into a CharGrid.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class BorderWeight(Enum):
LIGHT = auto()
HEAVY = auto()
DOUBLE = auto()
ROUNDED = auto()
class HeadingLevel(Enum):
H1 = 1
H2 = 2
H3 = 3
class DividerStyle(Enum):
LIGHT = auto()
HEAVY = auto()
DOUBLE = auto()
DASH = auto()
DOT = auto()
class ListStyle(Enum):
BULLET = auto()
DASH = auto()
NUMBER = auto()
ARROW = auto()
class Align(Enum):
LEFT = auto()
CENTER = auto()
RIGHT = auto()
# ---------------------------------------------------------------------------
# Style
# ---------------------------------------------------------------------------
@dataclass
class Style:
"""Visual style attached to any node."""
fg: str | None = None # 3-digit hex
bg: str | None = None # 3-digit hex
bold: bool = False
italic: bool = False
underline: bool = False
align: Align = Align.LEFT
# ---------------------------------------------------------------------------
# Inline text spans (parsed from @modifier{} syntax in text content)
# ---------------------------------------------------------------------------
@dataclass
class TextSpan:
"""A run of text with optional inline styling."""
text: str
bold: bool = False
italic: bool = False
underline: bool = False
fg: str | None = None
bg: str | None = None
# ---------------------------------------------------------------------------
# Layout rect — assigned by the layout engine
# ---------------------------------------------------------------------------
@dataclass
class Rect:
x: int = 0
y: int = 0
w: int = 0
h: int = 0
# ---------------------------------------------------------------------------
# Base node
# ---------------------------------------------------------------------------
@dataclass
class IRNode:
"""Base class for all IR nodes."""
children: list[IRNode] = field(default_factory=list)
style: Style = field(default_factory=Style)
rect: Rect = field(default_factory=Rect)
source_line: int | None = None
# Set by measure pass
min_width: int = 0
min_height: int = 0
pref_width: int = 0
pref_height: int = 0
# ---------------------------------------------------------------------------
# Layout nodes
# ---------------------------------------------------------------------------
@dataclass
class Page(IRNode):
"""Root container. One per .uf file."""
title: str = ""
width: int = 64
theme_name: str = ""
@dataclass
class Box(IRNode):
"""Bordered panel with optional title."""
title: str = ""
weight: BorderWeight = BorderWeight.LIGHT
@dataclass
class Row(IRNode):
"""Horizontal layout — children split available width."""
gap: int = 1
@dataclass
class Col(IRNode):
"""Explicit column in a row. Width in chars or None (auto)."""
col_width: int | None = None
@dataclass
class Spacer(IRNode):
"""Vertical whitespace."""
lines: int = 1
@dataclass
class Pad(IRNode):
"""Inner margin for a container."""
top: int = 0
right: int = 0
bottom: int = 0
left: int = 0
# ---------------------------------------------------------------------------
# Content nodes
# ---------------------------------------------------------------------------
@dataclass
class Heading(IRNode):
"""Styled heading (levels 13)."""
level: HeadingLevel = HeadingLevel.H1
text: str = ""
@dataclass
class Text(IRNode):
"""Text content with optional @modifier{} inline styles."""
content: str = ""
spans: list[TextSpan] = field(default_factory=list)
@dataclass
class Label(IRNode):
"""Aligned key-value pair."""
key: str = ""
value: str = ""
@dataclass
class Divider(IRNode):
"""Full-width horizontal rule."""
divider_style: DividerStyle = DividerStyle.LIGHT
@dataclass
class Link(IRNode):
"""Clickable link — visual in ASCII, interactive in Micron."""
display: str = ""
dest: str = ""
@dataclass
class ListNode(IRNode):
"""Bulleted or numbered list."""
list_style: ListStyle = ListStyle.BULLET
@dataclass
class ListItem(IRNode):
"""Single entry in a ListNode."""
content: str = ""
# ---------------------------------------------------------------------------
# Placeholder nodes for future phases
# ---------------------------------------------------------------------------
@dataclass
class Gauge(IRNode):
"""Horizontal bar chart (Phase 4)."""
label: str = ""
value: float = 0
max_val: float = 100
bar_width: int = 28
warn: float | None = None
crit: float | None = None
@dataclass
class Sparkline(IRNode):
"""Braille sparkline (Phase 4)."""
label: str = ""
values: list[float] = field(default_factory=list)
spark_width: int = 20
@dataclass
class Status(IRNode):
"""Status indicator (Phase 4)."""
label: str = ""
state: str = "unknown"
@dataclass
class Table(IRNode):
"""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()
HTTP = auto()
SQLITE = auto()
ENV = 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 = ""
# HTTP-specific
http_method: str = "GET"
http_body: str = ""
http_headers: str = ""
# SQLite-specific: command = db path, query = SQL
query: 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 = ""
# ---------------------------------------------------------------------------
# Components (Phase 8)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Navigation nodes
# ---------------------------------------------------------------------------
@dataclass
class NavItem:
"""A single item in a navigation bar."""
kind: str = "item" # "item", "separator", "heading"
label: str = ""
dest: str = ""
active: bool = False
children: list["NavItem"] = field(default_factory=list)
@dataclass
class HNav(IRNode):
"""Horizontal navigation bar."""
nav_style: str = "bar" # bar, tabs, pills, breadcrumb, underline
items: list[NavItem] = field(default_factory=list)
compact: bool = False
@dataclass
class VNav(IRNode):
"""Vertical navigation panel."""
nav_style: str = "list" # list, boxed, tree, sidebar, minimal
nav_width: int = 0 # 0 = auto-fit
items: list[NavItem] = field(default_factory=list)
@dataclass
class ImageNode(IRNode):
"""Image converted to character art."""
path: str = ""
mode: str = "braille" # braille, block, ascii, halfblock
img_width: int = 30
dither: str = "floyd" # floyd, threshold, none
invert: bool = False
use_color: bool = False
caption: str = ""
@dataclass
class BigTitle(IRNode):
"""Large multi-line ASCII art text."""
text: str = ""
font: str = "block" # block, thin, pixel
@dataclass
class ComponentDef(IRNode):
"""Component definition: component name(arg1, arg2)."""
comp_name: str = ""
params: list[str] = field(default_factory=list)
# children = the component body template
@dataclass
class ComponentUse(IRNode):
"""Component instantiation: name "arg1" "arg2"."""
comp_name: str = ""
args: list[str] = field(default_factory=list)

289
backend/uframe/keywords.py Normal file
View File

@@ -0,0 +1,289 @@
"""µFrame Keyword Registrations — single source of truth for all DSL keywords.
Each keyword is registered here with its metadata (section, detail, snippet,
highlight values). The actual parse/measure/layout/paint/codegen functions
remain in their respective modules for now — this file serves as the
registry that the frontend reads via GET /api/dsl-meta.
To add a new keyword:
1. Define its IR node in ir.py
2. Add a register_keyword() call here
3. Add parse logic in parser.py
4. Add measure/layout/paint logic in their respective files
5. That's it — syntax highlighting and slash commands auto-update from the registry
"""
from uframe.registry import register_keyword, ALL_THEME_NAMES
from uframe.ir import (
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,
BigTitle, ImageNode, HNav, VNav,
ComponentDef, ComponentUse,
)
from uframe.themes import BUILTIN_THEMES
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
register_keyword("page", node_class=Page, section="Layout",
detail='page "Title" 64', snippet='page "${1:Title}" ${2:64}',
is_container=True)
register_keyword("box", node_class=Box, section="Layout",
detail='box light "Title"', snippet='box ${1:light} "${2:Title}"',
highlight_values=["light", "heavy", "double", "rounded"],
is_container=True)
register_keyword("row", node_class=Row, section="Layout",
detail="row [gap]", snippet="row ${1:2}",
is_container=True)
register_keyword("col", node_class=Col, section="Layout",
detail="col [width]", snippet="col ${1}",
is_container=True)
register_keyword("spacer", node_class=Spacer, section="Layout",
detail="spacer [lines]", snippet="spacer",
is_leaf=True)
register_keyword("pad", node_class=Pad, section="Layout",
detail="pad t r b l", snippet="pad ${1:1} ${2:1} ${3:1} ${4:1}",
is_container=True)
# ---------------------------------------------------------------------------
# Content
# ---------------------------------------------------------------------------
register_keyword("heading", node_class=Heading, section="Content",
detail='heading 1 "Text"', snippet='heading ${1:1} "${2:Text}"',
is_leaf=True)
register_keyword("text", node_class=Text, section="Content",
detail='text "Content"', snippet='text "${1:Content}"',
is_leaf=True)
register_keyword("label", node_class=Label, section="Content",
detail='label "Key" "Value"', snippet='label "${1:Key}" "${2:Value}"',
is_leaf=True)
register_keyword("divider", node_class=Divider, section="Content",
detail="divider heavy", snippet="divider ${1:light}",
highlight_values=["light", "heavy", "double", "dash", "dot"],
is_leaf=True)
register_keyword("link", node_class=Link, section="Content",
detail='link "Text" "/path.mu"', snippet='link "${1:Text}" "${2:/page/dest.mu}"',
is_leaf=True)
register_keyword("list", node_class=ListNode, section="Content",
detail="list bullet", snippet='list ${1:bullet}\n item "${2:Entry}"',
highlight_values=["bullet", "dash", "number", "arrow"],
is_container=True)
register_keyword("item", node_class=ListItem, section="",
detail="", snippet="", # not shown in slash commands (child of list)
is_leaf=True)
# ---------------------------------------------------------------------------
# Data Visualization
# ---------------------------------------------------------------------------
register_keyword("gauge", node_class=Gauge, section="Data",
detail="gauge label val max width",
snippet='gauge "${1:Label}" ${2:0} ${3:100} ${4:28} warn=${5:75} crit=${6:90}',
is_leaf=True)
register_keyword("sparkline", node_class=Sparkline, section="Data",
detail="sparkline label values width",
snippet='sparkline "${1:Label}" "${2:1,2,3,4}" ${3:20}',
is_leaf=True)
register_keyword("status", node_class=Status, section="Data",
detail="status label state",
snippet='status "${1:Label}" ${2:online}',
highlight_values=["online", "offline", "degraded", "unknown", "alert"],
is_leaf=True)
register_keyword("table", node_class=Table, section="Data",
detail="table + columns + rows",
snippet='table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"',
is_leaf=True) # table handles its own children (columns/row pseudo-nodes)
register_keyword("columns", section="",
detail="", snippet="") # child of table, not shown in slash commands
# ---------------------------------------------------------------------------
# Big Text
# ---------------------------------------------------------------------------
register_keyword("hnav", node_class=HNav, section="Layout",
detail='hnav bar', snippet='hnav ${1:bar}\n item "${2:Label}" "${3:/page/dest.mu}" active',
highlight_values=["bar", "tabs", "pills", "breadcrumb", "underline"],
is_container=True)
register_keyword("vnav", node_class=VNav, section="Layout",
detail='vnav list', snippet='vnav ${1:list}\n item "${2:Label}" "${3:/page/dest.mu}"',
highlight_values=["list", "boxed", "tree", "sidebar", "minimal"],
is_container=True)
register_keyword("image", node_class=ImageNode, section="Content",
detail='image "path.png" braille 30',
snippet='image "${1:path.png}" ${2:braille} ${3:30}',
highlight_values=["braille", "block", "ascii", "halfblock"],
is_leaf=True)
register_keyword("dither", section="",
detail="", snippet="",
highlight_values=["floyd", "threshold", "none"],
is_style_directive=True)
register_keyword("invert", section="",
detail="", snippet="",
is_style_directive=True)
register_keyword("caption", section="",
detail="", snippet="",
is_style_directive=True)
register_keyword("bigtitle", node_class=BigTitle, section="Content",
detail='bigtitle "TEXT" block',
snippet='bigtitle "${1:TEXT}" ${2:block}',
highlight_values=["block", "thin", "pixel"],
is_leaf=True)
# ---------------------------------------------------------------------------
# Style
# ---------------------------------------------------------------------------
register_keyword("align", section="Style",
detail="align center", snippet="align ${1:center}",
highlight_values=["left", "center", "right"],
is_style_directive=True)
register_keyword("color", section="Style",
detail="color hex", snippet="color ${1:0cf}",
is_style_directive=True)
register_keyword("bg", section="Style",
detail="", snippet="",
is_style_directive=True)
register_keyword("bold", section="Style",
detail="bold", snippet="bold",
is_style_directive=True)
register_keyword("italic", section="Style",
detail="", snippet="",
is_style_directive=True)
register_keyword("underline", section="Style",
detail="", snippet="",
is_style_directive=True)
# ---------------------------------------------------------------------------
# Forms
# ---------------------------------------------------------------------------
register_keyword("form", node_class=Form, section="Form",
detail='form "name"', snippet='form "${1:name}"',
is_container=True)
register_keyword("field", node_class=Field, section="Form",
detail='field "name" 24 "placeholder"',
snippet='field "${1:name}" ${2:24} "${3:placeholder}"',
is_leaf=True)
register_keyword("password", node_class=Password, section="Form",
detail='password "name" 24 "placeholder"',
snippet='password "${1:name}" ${2:24} "${3:placeholder}"',
is_leaf=True)
register_keyword("radio", node_class=Radio, section="Form",
detail='radio "group" "A" | "B" | "C"',
snippet='radio "${1:group}" "${2:Option A}" | "${3:Option B}"',
is_leaf=True)
register_keyword("checkbox", node_class=Checkbox, section="Form",
detail='checkbox "name" "Label"',
snippet='checkbox "${1:name}" "${2:Label}"',
is_leaf=True)
register_keyword("button", node_class=FormButton, section="Form",
detail='button "Label" "/action"',
snippet='button "${1:Label}" "${2:/page/action.mu}"',
is_leaf=True)
# ---------------------------------------------------------------------------
# Dynamic
# ---------------------------------------------------------------------------
register_keyword("let", node_class=Let, section="Dynamic",
detail='let name = "value"', snippet='let ${1:name} = "${2:value}"',
is_metadata=True)
register_keyword("source", node_class=Source, section="Dynamic",
detail='source name : shell "cmd"',
snippet='source ${1:name} : shell "${2:command}"',
highlight_values=["shell", "file", "json", "python", "rns", "param"],
is_metadata=True)
register_keyword("if", node_class=IfBlock, section="Dynamic",
detail="if $var > threshold", snippet="if ${1:condition}",
is_container=True)
register_keyword("elif", section="Dynamic",
detail="", snippet="")
register_keyword("else", section="Dynamic",
detail="", snippet="")
register_keyword("for", node_class=ForLoop, section="Dynamic",
detail="for item in $list", snippet='for ${1:item} in ${2:\\$list}',
is_container=True)
register_keyword("cache", node_class=CacheControl, section="Dynamic",
detail="cache 0", snippet="cache ${1:0}",
is_metadata=True)
register_keyword("on_submit", node_class=OnSubmit, section="Dynamic",
detail='on_submit "form"', snippet='on_submit "${1:form_name}"',
is_container=True)
register_keyword("state", node_class=StateDecl, section="Dynamic",
detail='state "name" "/path.json"',
snippet='state "${1:name}" "${2:/tmp/state.json}"',
is_metadata=True)
register_keyword("set", section="Dynamic", detail="", snippet="")
register_keyword("append", section="Dynamic", detail="", snippet="")
register_keyword("prepend", section="Dynamic", detail="", snippet="")
# ---------------------------------------------------------------------------
# Themes
# ---------------------------------------------------------------------------
register_keyword("theme", section="Theme",
detail="", snippet="",
is_style_directive=True)
# Register each built-in theme as a named entry
for _theme_name, _theme_def in BUILTIN_THEMES.items():
register_keyword(f"theme_{_theme_name}", section="Theme",
detail=f"{_theme_def.description}",
snippet=f"theme {_theme_name}")
ALL_THEME_NAMES.append(_theme_name)
# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------
register_keyword("component", node_class=ComponentDef, section="",
detail="", snippet="",
is_container=True)
register_keyword("use", section="",
detail="", snippet="")

159
backend/uframe/layout.py Normal file
View File

@@ -0,0 +1,159 @@
"""Top-down layout pass — assign (x, y, w, h) positions to every node."""
from __future__ import annotations
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,
BigTitle, ImageNode, HNav, VNav,
)
def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
"""Assign positions to a node and its children.
Args:
node: the IR node to lay out
x, y: top-left position in the grid
w: available width
h: available height (advisory, may grow)
Returns:
The actual height consumed by this node.
"""
node.rect = Rect(x=x, y=y, w=w, h=0)
if isinstance(node, Page):
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, Box):
# Border takes 1 char on each side
inner_x = x + 1
inner_y = y + 1
inner_w = w - 2
cursor_y = inner_y
for child in node.children:
child_h = layout(child, inner_x, cursor_y, inner_w,
h - 2 - (cursor_y - inner_y))
cursor_y += child_h
inner_h = cursor_y - inner_y
node.rect.h = inner_h + 2 # +2 for top/bottom border
return node.rect.h
elif isinstance(node, Row):
n = len(node.children)
if n == 0:
return 0
gap_total = node.gap * (n - 1)
usable = w - gap_total
# Distribute width
widths: list[int] = []
fixed_total = 0
flex_count = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
widths.append(child.col_width)
fixed_total += child.col_width
else:
widths.append(0)
flex_count += 1
flex_each = max(1, (usable - fixed_total) // flex_count) if flex_count > 0 else 0
remainder = (usable - fixed_total) - (flex_each * flex_count) if flex_count > 0 else 0
for i, child in enumerate(node.children):
if widths[i] == 0:
widths[i] = flex_each
if remainder > 0:
widths[i] += 1
remainder -= 1
# Lay out each child at its column position
max_h = 0
col_x = x
for i, child in enumerate(node.children):
child_h = layout(child, col_x, y, widths[i], h)
max_h = max(max_h, child_h)
col_x += widths[i] + node.gap
node.rect.h = max_h
return max_h
elif isinstance(node, Col):
cursor_y = y
col_w = node.col_width if node.col_width is not None else w
col_w = min(col_w, w)
for child in node.children:
child_h = layout(child, x, cursor_y, col_w, h - (cursor_y - y))
cursor_y += child_h
node.rect.w = col_w
node.rect.h = cursor_y - y
return node.rect.h
elif isinstance(node, Spacer):
node.rect.h = node.lines
return node.lines
elif isinstance(node, Pad):
cursor_y = y + node.top
inner_w = w - node.left - node.right
for child in node.children:
child_h = layout(child, x + node.left, cursor_y, inner_w,
h - node.top - node.bottom - (cursor_y - y - node.top))
cursor_y += child_h
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,
Field, Password, Radio, Checkbox, FormButton,
BigTitle, ImageNode, HNav, VNav)):
node.rect.h = node.pref_height
return node.pref_height
elif isinstance(node, ListNode):
cursor_y = y
for child in node.children:
child_h = layout(child, x + 2, cursor_y, w - 2, h - (cursor_y - y))
cursor_y += child_h
node.rect.h = cursor_y - y
return node.rect.h
else:
# Generic vertical stacking
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

328
backend/uframe/measure.py Normal file
View File

@@ -0,0 +1,328 @@
"""Bottom-up measure pass — compute min/preferred width and height for each node."""
from __future__ import annotations
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,
BigTitle, ImageNode, HNav, VNav,
)
from uframe.fonts import FONT_HEIGHTS, get_text_width
from uframe.imaging import get_image_height
def _text_height(text: str, width: int) -> int:
"""Compute how many lines a text string needs at a given width."""
if not text or width <= 0:
return 1
words = text.split()
lines = 1
col = 0
for word in words:
wlen = len(word)
if col > 0 and col + 1 + wlen > width:
lines += 1
col = wlen
elif col == 0:
col = wlen
else:
col += 1 + wlen
return max(lines, 1)
def measure(node: IRNode, available_width: int) -> None:
"""Recursively compute min/preferred sizes for an IR subtree.
This is a bottom-up pass: children are measured before their parent.
"""
# Dispatch to node type
if isinstance(node, Page):
w = node.width
node.pref_width = w
node.min_width = w
# Measure children with full page width
total_h = 0
for child in node.children:
measure(child, w)
total_h += child.pref_height
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Box):
# Box adds 2 chars for borders on each axis (left+right, top+bottom)
inner_w = available_width - 2
total_h = 0
for child in node.children:
measure(child, inner_w)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 4 # minimum: border + 2 chars + border
node.pref_height = total_h + 2 # +2 for top/bottom border
node.min_height = 3 # top border + 1 line + bottom border
elif isinstance(node, Row):
# Children laid out horizontally, split available width
n = len(node.children)
if n == 0:
node.pref_width = available_width
node.pref_height = 0
node.min_width = 0
node.min_height = 0
return
gap_total = node.gap * (n - 1)
usable = available_width - gap_total
# First pass: measure children to get their preferred sizes
# Distribute width proportionally or equally
fixed_width_children = []
flex_children = []
fixed_total = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
fixed_width_children.append(child)
fixed_total += child.col_width
else:
flex_children.append(child)
flex_each = 0
if flex_children:
flex_each = max(1, (usable - fixed_total) // len(flex_children))
max_h = 0
for child in node.children:
if isinstance(child, Col) and child.col_width is not None:
child_w = child.col_width
else:
child_w = flex_each
measure(child, child_w)
max_h = max(max_h, child.pref_height)
node.pref_width = available_width
node.min_width = n # at minimum 1 char per child
node.pref_height = max_h
node.min_height = max_h
elif isinstance(node, Col):
w = node.col_width if node.col_width is not None else available_width
total_h = 0
for child in node.children:
measure(child, w)
total_h += child.pref_height
node.pref_width = w
node.min_width = min(w, 1)
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, Spacer):
node.pref_width = available_width
node.min_width = 0
node.pref_height = node.lines
node.min_height = node.lines
elif isinstance(node, Pad):
inner_w = available_width - node.left - node.right
total_h = 0
for child in node.children:
measure(child, inner_w)
total_h += child.pref_height
node.pref_width = available_width
node.min_width = node.left + node.right + 1
node.pref_height = total_h + node.top + node.bottom
node.min_height = node.top + node.bottom
elif isinstance(node, Heading):
node.pref_width = available_width
node.min_width = len(node.text) + 1
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Text):
node.pref_width = available_width
node.min_width = 1
node.pref_height = _text_height(node.content, available_width)
node.min_height = 1
elif isinstance(node, Label):
node.pref_width = available_width
node.min_width = len(node.key) + 2 + len(node.value)
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Divider):
node.pref_width = available_width
node.min_width = 1
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Link):
node.pref_width = available_width
node.min_width = len(node.display) + 2
node.pref_height = 1
node.min_height = 1
elif isinstance(node, ListNode):
total_h = 0
for child in node.children:
measure(child, available_width - 2) # indent for bullet
total_h += child.pref_height
node.pref_width = available_width
node.min_width = 4
node.pref_height = total_h
node.min_height = total_h
elif isinstance(node, ListItem):
node.pref_width = available_width
node.min_width = len(node.content) + 1
node.pref_height = _text_height(node.content, available_width)
node.min_height = 1
elif isinstance(node, Gauge):
node.pref_width = available_width
node.min_width = node.bar_width + len(node.label) + 6
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Sparkline):
node.pref_width = available_width
node.min_width = node.spark_width + len(node.label) + 4
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Status):
node.pref_width = available_width
node.min_width = len(node.label) + 4
node.pref_height = 1
node.min_height = 1
elif isinstance(node, Table):
num_rows = len(node.rows)
node.pref_width = available_width
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, HNav):
# Height: 3 for bar/tabs/pills (top border + items + bottom border), 2 for underline/breadcrumb
if node.nav_style in ("underline",):
node.pref_height = 2
elif node.nav_style in ("breadcrumb",):
node.pref_height = 1
else:
node.pref_height = 3
node.pref_width = available_width
node.min_width = 10
node.min_height = 1
elif isinstance(node, VNav):
count = sum(1 for it in node.items if it.kind in ("item", "heading"))
sep_count = sum(1 for it in node.items if it.kind == "separator")
h = count + sep_count
if node.nav_style == "boxed":
h += 2 # top + bottom border
node.pref_height = max(h, 1)
if node.nav_width > 0:
node.pref_width = node.nav_width
else:
max_label = max((len(it.label) for it in node.items if it.label), default=8)
node.pref_width = max_label + 6 # marker + padding
node.min_width = 8
node.min_height = 1
elif isinstance(node, ImageNode):
h = get_image_height(node.path, node.mode, node.img_width)
if node.caption:
h += 1 # extra line for caption
node.pref_width = available_width
node.min_width = node.img_width
node.pref_height = h
node.min_height = 1
elif isinstance(node, BigTitle):
# Try the requested font, fall back to smaller if too wide
tw = get_text_width(node.text, node.font)
if tw <= available_width:
h = FONT_HEIGHTS.get(node.font, 6)
elif get_text_width(node.text, "pixel") <= available_width:
h = FONT_HEIGHTS.get("pixel", 3)
elif get_text_width(node.text, "thin") <= available_width:
h = FONT_HEIGHTS.get("thin", 3)
else:
h = 1 # fallback to single styled line
node.pref_width = available_width
node.min_width = 1
node.pref_height = h
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
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 = max(total_h, 1)
node.min_height = 1

620
backend/uframe/paint.py Normal file
View File

@@ -0,0 +1,620 @@
"""Paint pass — write IR nodes into the CharGrid as characters.
Depth-first traversal: each node writes its content at its assigned
rect position. Containers recurse into children after drawing their
own structure (borders, etc.).
"""
from __future__ import annotations
import re
import textwrap
from uframe.chars import (
BOX_CHARS, DIVIDER_CHARS, sparkline_chars,
)
from uframe.grid import CharGrid, CellStyle
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,
BigTitle, ImageNode, HNav, VNav,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
)
from uframe.themes import ThemeDef, THEME_DEFAULT
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
from uframe.imaging import convert_image
def _align_text(text: str, width: int, align: Align) -> str:
"""Align text within a field of the given width."""
if len(text) >= width:
return text[:width]
if align == Align.CENTER:
return text.center(width)
elif align == Align.RIGHT:
return text.rjust(width)
return text.ljust(width)
def _style_from_node(node: IRNode) -> CellStyle:
"""Create a CellStyle from a node's style attributes."""
return CellStyle(
fg=node.style.fg,
bg=node.style.bg,
bold=node.style.bold,
italic=node.style.italic,
underline=node.style.underline,
)
def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
"""Recursively paint an IR node and its children into the grid."""
th = theme or THEME_DEFAULT
x, y, w = node.rect.x, node.rect.y, node.rect.w
# Ensure grid is tall enough
grid.grow_height(y + node.rect.h)
if isinstance(node, Page):
for child in node.children:
paint(child, grid, th)
elif isinstance(node, Box):
# Draw the border with themed characters
title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
grid.draw_border(x, y, w, node.rect.h,
weight=node.weight,
title=node.title,
title_style=title_style,
border_chars=th.border_dict(node.weight.name.lower()),
title_caps=(th.title_caps.left, th.title_caps.right))
# Propagate box alignment/color to children that don't have their own
for child in node.children:
if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
child.style.align = node.style.align
if node.style.fg and not child.style.fg:
child.style.fg = node.style.fg
paint(child, grid, th)
elif isinstance(node, Row):
for child in node.children:
paint(child, grid, th)
elif isinstance(node, Col):
for child in node.children:
paint(child, grid, th)
elif isinstance(node, Spacer):
pass # Just empty space
elif isinstance(node, Pad):
for child in node.children:
paint(child, grid, th)
elif isinstance(node, Heading):
style = CellStyle(bold=True)
if node.level == HeadingLevel.H1:
style.fg = th.palette.accent
elif node.level == HeadingLevel.H2:
style.fg = th.palette.accent2
elif node.level == HeadingLevel.H3:
style.fg = th.palette.accent3
# Underline-style heading
grid.put_text(x, y, node.text[:w], style=style)
elif isinstance(node, Text):
style = _style_from_node(node)
if node.spans and any(
s.bold or s.italic or s.underline or s.fg or s.bg
for s in node.spans
):
# Render with inline spans
col = x
row = y
for span in node.spans:
span_style = CellStyle(
fg=span.fg or style.fg,
bg=span.bg or style.bg,
bold=span.bold or style.bold,
italic=span.italic or style.italic,
underline=span.underline or style.underline,
)
for ch in span.text:
if col >= x + w:
col = x
row += 1
if grid.in_bounds(col, row):
grid.put(col, row, ch, style=span_style)
col += 1
else:
# Simple text with word wrapping
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped):
if y + i < grid.height:
text = _align_text(line, w, node.style.align)
grid.put_text(x, y + i, text, style=style)
elif isinstance(node, Label):
style = _style_from_node(node)
key_style = CellStyle(bold=True, fg=style.fg, bg=style.bg)
# Key: value layout with padding
key_text = f"{node.key}:"
pad = max(1, 16 - len(key_text))
grid.put_text(x, y, key_text, style=key_style)
grid.put_text(x + len(key_text) + pad, y, node.value, style=style)
elif isinstance(node, Divider):
ds = node.divider_style
char = getattr(th.dividers, ds.name.lower(), th.dividers.light)
style = CellStyle(fg=th.palette.muted)
for col in range(x, x + w):
grid.put(col, y, char, style=style)
elif isinstance(node, Link):
style = CellStyle(fg=th.palette.info, underline=True)
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
elif isinstance(node, ListNode):
for child in node.children:
paint(child, grid, th)
elif isinstance(node, ListItem):
style = _style_from_node(node)
# Bullet to the left of the content (safe: put_text clips to bounds)
bullet_x = max(0, x - 2)
grid.put_text(bullet_x, y, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label))
# Wrap content
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped):
if y + i < grid.height:
grid.put_text(x, y + i, line, style=style)
elif isinstance(node, Gauge):
style = _style_from_node(node)
# Label
label_text = f"{node.label} "
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
bar_x = x + len(label_text)
bar_w = min(node.bar_width, w - len(label_text) - 6)
if bar_w > 0:
pct = min(node.value / node.max_val, 1.0) if node.max_val > 0 else 0
filled = int(bar_w * pct)
# Determine color based on thresholds
fg = th.palette.success
if node.crit is not None and node.value >= node.crit:
fg = th.palette.danger
elif node.warn is not None and node.value >= node.warn:
fg = th.palette.warning
for i in range(bar_w):
if i < filled:
grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
else:
grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
# Percentage
pct_text = f" {int(pct * 100)}%"
grid.put_text(bar_x + bar_w, y, pct_text, style=CellStyle(fg=fg))
elif isinstance(node, Sparkline):
style = _style_from_node(node)
label_text = f"{node.label} "
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
spark_x = x + len(label_text)
chars = sparkline_chars(node.values, node.spark_width)
spark_style = CellStyle(fg=th.palette.info)
for i, ch in enumerate(chars):
grid.put(spark_x + i, y, ch, style=spark_style)
elif isinstance(node, Status):
char = getattr(th.indicators, node.state, th.indicators.unknown)
color_map = {"online": th.palette.success, "offline": th.palette.danger,
"degraded": th.palette.warning, "unknown": th.palette.label,
"alert": th.palette.danger}
color = color_map.get(node.state, th.palette.label)
grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label)
elif isinstance(node, HNav):
_paint_hnav(node, grid, x, y, w, th)
elif isinstance(node, VNav):
_paint_vnav(node, grid, x, y, w, th)
elif isinstance(node, ImageNode):
style = CellStyle(fg=node.style.fg or th.palette.accent)
try:
lines = convert_image(node.path, node.mode, node.img_width,
node.dither, node.invert, node.use_color)
# Center if aligned
offset = 0
actual_w = len(lines[0]) if lines else 0
if node.style.align == Align.CENTER:
offset = max(0, (w - actual_w) // 2)
elif node.style.align == Align.RIGHT:
offset = max(0, w - actual_w)
for row_i, line in enumerate(lines):
if y + row_i < grid.height:
grid.put_text(x + offset, y + row_i, line, style=style)
# Caption
if node.caption and y + len(lines) < grid.height:
cap_style = CellStyle(fg=th.palette.label, italic=True)
grid.put_text(x + offset, y + len(lines), node.caption, style=cap_style)
except (ImportError, FileNotFoundError) as e:
# Render placeholder if image can't be loaded
grid.put_text(x, y, f"[image: {node.path}]", style=CellStyle(fg=th.palette.muted))
elif isinstance(node, BigTitle):
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
# Determine which font fits
font = node.font
tw = get_text_width(node.text, font)
if tw > w:
# Try fallback cascade
for fallback in ["pixel", "thin"]:
if get_text_width(node.text, fallback) <= w:
font = fallback
tw = get_text_width(node.text, fallback)
break
else:
# Final fallback: styled single line
styled = f"═══ {node.text.upper()} ═══"
grid.put_text(x, y, styled[:w], style=style)
return
lines = render_big_text(node.text, font)
# Center if align is set
offset = 0
if node.style.align == Align.CENTER:
offset = max(0, (w - tw) // 2)
elif node.style.align == Align.RIGHT:
offset = max(0, w - tw)
for row_i, line in enumerate(lines):
if y + row_i < grid.height:
grid.put_text(x + offset, y + row_i, line, style=style)
elif isinstance(node, Table):
_paint_table(node, grid, x, y, w)
elif isinstance(node, Form):
for child in node.children:
paint(child, grid, th)
elif isinstance(node, Field):
label_style = CellStyle(fg=th.palette.label)
field_style = CellStyle(fg=th.palette.form)
label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style)
fl = th.form.field_l
fr = th.form.field_r
fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
grid.put_text(fx, y, fl, style=field_style)
placeholder = node.placeholder or node.field_name
inner = placeholder.ljust(fw)[:fw]
grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
elif isinstance(node, Password):
label_style = CellStyle(fg=th.palette.label)
field_style = CellStyle(fg=th.palette.form)
label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style)
fl = th.form.field_l
fr = th.form.field_r
fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
grid.put_text(fx, y, fl, style=field_style)
inner = "" * fw
grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
elif isinstance(node, Radio):
label_style = CellStyle(fg=th.palette.label)
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 = th.form.radio_on if i == 0 else th.form.radio_off
opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
grid.put_text(rx, y, dot, style=opt_style)
rx += len(dot) + 1
grid.put_text(rx, y, opt, style=CellStyle())
rx += len(opt) + 2
elif isinstance(node, Checkbox):
check_style = CellStyle(fg=th.palette.form)
box_char = th.form.check_on if node.checked else th.form.check_off
grid.put_text(x, y, box_char, style=check_style)
grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
elif isinstance(node, FormButton):
btn_style = CellStyle(bold=True, fg=th.palette.button)
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:
paint(child, grid, th)
def _paint_hnav(node: HNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
"""Paint a horizontal navigation bar."""
active_style = CellStyle(bold=True, fg=th.palette.accent)
link_style = CellStyle(fg=th.palette.info)
sep_style = CellStyle(fg=th.palette.muted)
border_style = CellStyle()
items = [it for it in node.items if it.kind in ("item", "separator")]
marker = ""
if node.nav_style in ("bar", "tabs", "pills"):
# Bordered bar
bc = th.border_dict("light")
grid.draw_border(x, y, w, 3, border_chars=bc,
title_caps=(th.title_caps.left, th.title_caps.right))
col = x + 2
for it in items:
if it.kind == "separator":
grid.put(col, y + 1, "", style=sep_style)
col += 2
continue
if it.active:
grid.put_text(col, y + 1, marker, style=active_style)
col += len(marker)
grid.put_text(col, y + 1, it.label, style=active_style)
else:
grid.put_text(col, y + 1, it.label, style=link_style, link=it.dest)
col += len(it.label) + 2
if col < x + w - 2:
grid.put(col, y + 1, "", style=sep_style)
col += 2
elif node.nav_style == "breadcrumb":
col = x + 2
sep = ""
for i, it in enumerate(items):
if it.kind == "separator":
continue
if i > 0:
grid.put_text(col, y, sep, style=sep_style)
col += len(sep)
if it.active:
grid.put_text(col, y, it.label, style=active_style)
else:
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
col += len(it.label)
elif node.nav_style == "underline":
col = x + 2
active_start = 0
active_len = 0
for i, it in enumerate(items):
if it.kind == "separator":
continue
if it.active:
active_start = col
active_len = len(it.label)
grid.put_text(col, y, it.label, style=active_style)
else:
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
col += len(it.label) + 5
# Underline beneath active
if active_len > 0:
for c in range(active_start, active_start + active_len):
grid.put(c, y + 1, "", style=CellStyle(fg=th.palette.accent))
def _paint_vnav(node: VNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
"""Paint a vertical navigation panel."""
active_style = CellStyle(bold=True, fg=th.palette.accent)
link_style = CellStyle(fg=th.palette.info)
heading_style = CellStyle(bold=True, fg=th.palette.muted)
sep_style = CellStyle(fg=th.palette.muted)
marker = ""
items = node.items
row_y = y
if node.nav_style == "boxed":
# Draw a box and render items inside
bc = th.border_dict("light")
h = node.rect.h
grid.draw_border(x, y, w, h, border_chars=bc,
title_caps=(th.title_caps.left, th.title_caps.right))
row_y = y + 1
for it in items:
if it.kind == "separator":
# Draw internal separator
for c in range(x + 1, x + w - 1):
grid.put(c, row_y, bc.get("h", ""), style=sep_style,
is_border=True)
grid.put(x, row_y, "", style=sep_style, is_border=True)
grid.put(x + w - 1, row_y, "", style=sep_style, is_border=True)
row_y += 1
elif it.kind == "heading":
grid.put_text(x + 2, row_y, it.label.upper(), style=heading_style)
row_y += 1
elif it.kind == "item":
if it.active:
grid.put_text(x + 2, row_y, marker, style=active_style)
grid.put_text(x + 2 + len(marker), row_y, it.label, style=active_style)
else:
grid.put_text(x + 4, row_y, it.label, style=link_style, link=it.dest)
row_y += 1
elif node.nav_style == "tree":
for idx, it in enumerate(items):
if it.kind == "separator":
for c in range(x, x + w):
grid.put(c, row_y, "", style=sep_style)
row_y += 1
elif it.kind == "heading":
grid.put_text(x, row_y, it.label, style=heading_style)
row_y += 1
elif it.kind == "item":
# Determine connector
remaining = [i for i in items[idx+1:] if i.kind == "item"]
connector = "└── " if not remaining else "├── "
grid.put_text(x, row_y, connector, style=sep_style)
if it.active:
grid.put_text(x + len(connector), row_y, it.label, style=active_style)
grid.put_text(x + len(connector) + len(it.label) + 2, row_y, "",
style=CellStyle(fg=th.palette.accent))
else:
grid.put_text(x + len(connector), row_y, it.label,
style=link_style, link=it.dest)
row_y += 1
else:
# list, sidebar, minimal
for it in items:
if it.kind == "separator":
for c in range(x, min(x + w, x + 16)):
grid.put(c, row_y, "", style=sep_style)
row_y += 1
elif it.kind == "heading":
grid.put_text(x, row_y, it.label.upper(), style=heading_style)
row_y += 1
elif it.kind == "item":
if it.active:
grid.put_text(x, row_y, marker, style=active_style)
grid.put_text(x + len(marker), row_y, it.label, style=active_style)
else:
grid.put_text(x + 2, row_y, it.label, style=link_style, link=it.dest)
row_y += 1
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
"""Paint a box-drawn table with header and data rows."""
if not node.columns:
return
ch = BOX_CHARS[BorderWeight.LIGHT]
border_style = CellStyle()
header_style = CellStyle(bold=True)
num_cols = len(node.columns)
# Calculate column widths
# If columns have explicit widths, use them. Otherwise distribute evenly.
col_widths: list[int] = []
total_explicit = 0
auto_count = 0
for _, cw in node.columns:
if cw > 0:
col_widths.append(cw)
total_explicit += cw
else:
col_widths.append(0)
auto_count += 1
# Available inner width = total - borders (num_cols + 1 border chars)
inner_w = w - (num_cols + 1)
if auto_count > 0:
auto_each = max(1, (inner_w - total_explicit) // auto_count)
for i in range(len(col_widths)):
if col_widths[i] == 0:
col_widths[i] = auto_each
# Compute column x positions (after each left border)
col_x: list[int] = []
cx = x + 1 # after left border
for cw in col_widths:
col_x.append(cx)
cx += cw + 1 # +1 for separator
table_w = cx - x # total table width including right border
row_y = y
# ── Top border ──
grid.put(x, row_y, ch["tl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["tr"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
# Column separator on top border
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_down"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header row ──
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, (col_name, _) in enumerate(node.columns):
text = col_name[:col_widths[ci]].ljust(col_widths[ci])
grid.put_text(col_x[ci], row_y, text, style=header_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Header separator ──
grid.put(x, row_y, ch["t_right"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["t_left"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["cross"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Data rows ──
cell_style = CellStyle()
for row_data in node.rows:
grid.put(x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci in range(num_cols):
cell_text = row_data[ci] if ci < len(row_data) else ""
text = cell_text[:col_widths[ci]].ljust(col_widths[ci])
# Check for @color{hex}{text} modifiers in cell content
if "@" in cell_text:
pattern = re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
pos = 0
styled_parts: list[tuple[str, CellStyle]] = []
for m in pattern.finditer(cell_text):
if m.start() > pos:
styled_parts.append((cell_text[pos:m.start()], cell_style))
styled_parts.append((m.group(2), CellStyle(fg=m.group(1))))
pos = m.end()
if pos < len(cell_text):
styled_parts.append((cell_text[pos:], cell_style))
col_pos = col_x[ci]
for part_text, part_style in styled_parts:
for pch in part_text:
if col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, pch, style=part_style)
col_pos += 1
# Pad remaining
while col_pos < col_x[ci] + col_widths[ci]:
grid.put(col_pos, row_y, " ")
col_pos += 1
else:
grid.put_text(col_x[ci], row_y, text, style=cell_style)
sep_x = col_x[ci] + col_widths[ci]
grid.put(sep_x, row_y, ch["v"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
row_y += 1
# ── Bottom border ──
grid.put(x, row_y, ch["bl"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
grid.put(x + table_w - 1, row_y, ch["br"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
for ci, cw in enumerate(col_widths):
for j in range(cw):
grid.put(col_x[ci] + j, row_y, ch["h"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)
if ci < num_cols - 1:
grid.put(col_x[ci] + cw, row_y, ch["t_up"], border_style, is_border=True, border_weight=BorderWeight.LIGHT)

967
backend/uframe/parser.py Normal file
View File

@@ -0,0 +1,967 @@
"""µFrame parser — .uf source text → IR tree.
Line-oriented, indentation-based (2-space). Each line is parsed as:
(indent_level, keyword, arguments)
Nesting is determined by indentation: children are indented deeper
than their parent.
"""
from __future__ import annotations
import re
import shlex
from uframe.errors import ParseError
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,
BigTitle, ImageNode, HNav, VNav, NavItem,
ComponentDef, ComponentUse,
SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
)
# ---------------------------------------------------------------------------
# Tokenisation helpers
# ---------------------------------------------------------------------------
_INDENT_RE = re.compile(r"^( *)")
_MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}")
def _indent_level(line: str) -> int:
"""Count leading spaces and return indent level (2 spaces = 1 level)."""
spaces = len(_INDENT_RE.match(line).group(1)) # type: ignore[union-attr]
return spaces // 2
def _split_args(text: str) -> list[str]:
"""Split argument string respecting quoted tokens."""
try:
return shlex.split(text)
except ValueError:
return text.split()
def parse_inline(content: str) -> list[TextSpan]:
"""Parse @modifier{text} syntax into a list of TextSpan objects.
Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{},
@bg{hex}{}.
"""
spans: list[TextSpan] = []
pos = 0
# Match @modifier{content} — including nested @color{hex}{text}
pattern = re.compile(
r"@(bold|italic|under|color|bg)"
r"(?:\{([0-9a-fA-F]{3})\})?" # optional hex arg for color/bg
r"\{([^}]*)\}"
)
for m in pattern.finditer(content):
# Add plain text before this modifier
if m.start() > pos:
spans.append(TextSpan(text=content[pos:m.start()]))
mod = m.group(1)
hex_arg = m.group(2)
inner = m.group(3)
span = TextSpan(text=inner)
if mod == "bold":
span.bold = True
elif mod == "italic":
span.italic = True
elif mod == "under":
span.underline = True
elif mod == "color" and hex_arg:
span.fg = hex_arg
elif mod == "bg" and hex_arg:
span.bg = hex_arg
spans.append(span)
pos = m.end()
# Trailing plain text
if pos < len(content):
spans.append(TextSpan(text=content[pos:]))
# If no modifiers found, return single plain span
if not spans:
spans.append(TextSpan(text=content))
return spans
# ---------------------------------------------------------------------------
# Line-level parsing — keyword dispatch
# ---------------------------------------------------------------------------
def _parse_border_weight(s: str) -> BorderWeight:
return {
"light": BorderWeight.LIGHT,
"heavy": BorderWeight.HEAVY,
"double": BorderWeight.DOUBLE,
"rounded": BorderWeight.ROUNDED,
}.get(s.lower(), BorderWeight.LIGHT)
def _parse_divider_style(s: str) -> DividerStyle:
return {
"light": DividerStyle.LIGHT,
"heavy": DividerStyle.HEAVY,
"double": DividerStyle.DOUBLE,
"dash": DividerStyle.DASH,
"dot": DividerStyle.DOT,
}.get(s.lower(), DividerStyle.LIGHT)
def _parse_heading_level(s: str) -> HeadingLevel:
return {
"1": HeadingLevel.H1,
"2": HeadingLevel.H2,
"3": HeadingLevel.H3,
}.get(s, HeadingLevel.H1)
def _parse_list_style(s: str) -> ListStyle:
return {
"bullet": ListStyle.BULLET,
"dash": ListStyle.DASH,
"number": ListStyle.NUMBER,
"arrow": ListStyle.ARROW,
}.get(s.lower(), ListStyle.BULLET)
def _parse_line(keyword: str, args: list[str], line_num: int, raw_args: str = "") -> IRNode:
"""Parse a single line into an IR node based on the keyword."""
if keyword == "page":
title = args[0] if args else "Untitled"
width = int(args[1]) if len(args) > 1 else 64
return Page(title=title, width=width, source_line=line_num)
elif keyword == "box":
# box [weight] "title"
if len(args) >= 2:
weight = _parse_border_weight(args[0])
title = args[1]
elif len(args) == 1:
# Could be weight or title
if args[0].lower() in ("light", "heavy", "double", "rounded"):
weight = _parse_border_weight(args[0])
title = ""
else:
weight = BorderWeight.LIGHT
title = args[0]
else:
weight = BorderWeight.LIGHT
title = ""
return Box(title=title, weight=weight, source_line=line_num)
elif keyword == "row":
# Table row (has | separators) vs layout Row (has gap number or nothing)
raw = " ".join(args)
if "|" in raw:
# Split by | and strip quotes from each cell, preserving @modifiers
cells: list[str] = []
for part in raw.split("|"):
cell = part.strip().strip('"')
cells.append(cell)
return _TableRow(cells, line_num)
gap = int(args[0]) if args else 1
return Row(gap=gap, source_line=line_num)
elif keyword == "col":
w = int(args[0]) if args else None
return Col(col_width=w, source_line=line_num)
elif keyword == "spacer":
lines = int(args[0]) if args else 1
return Spacer(lines=lines, source_line=line_num)
elif keyword == "pad":
vals = [int(a) for a in args[:4]]
while len(vals) < 4:
vals.append(0)
return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3],
source_line=line_num)
elif keyword == "heading":
level_str = args[0] if args else "1"
text = args[1] if len(args) > 1 else ""
return Heading(level=_parse_heading_level(level_str), text=text,
source_line=line_num)
elif keyword == "text":
content = args[0] if args else ""
spans = parse_inline(content)
return Text(content=content, spans=spans, source_line=line_num)
elif keyword == "label":
key = args[0] if args else ""
value = args[1] if len(args) > 1 else ""
return Label(key=key, value=value, source_line=line_num)
elif keyword == "divider":
style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT
return Divider(divider_style=style, source_line=line_num)
elif keyword == "link":
display = args[0] if args else ""
dest = args[1] if len(args) > 1 else ""
return Link(display=display, dest=dest, source_line=line_num)
elif keyword == "list":
style = _parse_list_style(args[0]) if args else ListStyle.BULLET
return ListNode(list_style=style, source_line=line_num)
elif keyword == "item":
# Could be a list item or a nav item — disambiguated by parent in tree builder
if len(args) >= 2 and ("/" in args[1] or ":" in args[1]):
# Nav item: item "Label" "/dest.mu" [active]
label = args[0]
dest = args[1]
active = "active" in args[2:] if len(args) > 2 else False
return _NavItemNode("item", label, dest, active, line_num)
content = args[0] if args else ""
return ListItem(content=content, source_line=line_num)
elif keyword == "separator":
return _NavItemNode("separator", "", "", False, line_num)
# Style modifiers (applied to parent)
elif keyword == "align":
val = args[0].lower() if args else "left"
align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT)
return _StyleDirective("align", align, line_num)
elif keyword == "color":
return _StyleDirective("fg", args[0] if args else None, line_num)
elif keyword == "bg":
return _StyleDirective("bg", args[0] if args else None, line_num)
elif keyword == "bold":
return _StyleDirective("bold", True, line_num)
elif keyword == "italic":
return _StyleDirective("italic", True, line_num)
elif keyword == "underline":
return _StyleDirective("underline", True, line_num)
# Phase 4 placeholders
elif keyword == "gauge":
label = args[0] if args else ""
val_str = args[1] if len(args) > 1 else "0"
max_str = args[2] if len(args) > 2 else "100"
bw_str = args[3] if len(args) > 3 else "28"
try:
value = float(val_str)
except ValueError:
value = 0 # $variable — resolved at runtime
try:
max_val = float(max_str)
except ValueError:
max_val = 100
try:
bar_width = int(bw_str)
except ValueError:
bar_width = 28
# Parse warn=N crit=N from remaining args
warn = crit = None
for a in args[4:]:
if a.startswith("warn="):
warn = float(a[5:])
elif a.startswith("crit="):
crit = float(a[5:])
node = Gauge(label=label, value=value, max_val=max_val,
bar_width=bar_width, warn=warn, crit=crit,
source_line=line_num)
# Store raw strings for deferred component expansion
if "$" in val_str:
node._raw_value = val_str # type: ignore[attr-defined]
if "$" in max_str:
node._raw_max_val = max_str # type: ignore[attr-defined]
return node
elif keyword == "sparkline":
label = args[0] if args else ""
vals_str = args[1] if len(args) > 1 else ""
values: list[float] = []
if vals_str and "$" not in vals_str:
values = [float(v) for v in vals_str.split(",") if v.strip()]
try:
width = int(args[2]) if len(args) > 2 else 20
except ValueError:
width = 20
return Sparkline(label=label, values=values, spark_width=width,
source_line=line_num)
elif keyword == "status":
label = args[0] if args else ""
state = args[1] if len(args) > 1 else "unknown"
return Status(label=label, state=state, source_line=line_num)
elif keyword == "table":
title = args[0] if args else ""
return Table(title=title, source_line=line_num)
elif keyword == "columns":
# columns "Name" 24 | "Hops" 6 | "Status" 10
# Re-join args and split by |
raw = " ".join(args)
cols: list[tuple[str, int]] = []
for part in raw.split("|"):
tokens = _split_args(part.strip())
if tokens:
col_name = tokens[0]
col_w = int(tokens[1]) if len(tokens) > 1 else 0
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 = raw_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"
colon = raw_args.find(":")
if colon != -1:
var_name = raw_args[:colon].strip()
rest = raw_args[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,
"http": SourceType.HTTP,
"sqlite": SourceType.SQLITE,
"env": SourceType.ENV,
}.get(src_type_str.lower(), SourceType.SHELL)
# Parse optional params from remaining parts
timeout = 5
http_method = "GET"
http_body = ""
http_headers = ""
query = ""
extra = parts[2:]
if src_type == SourceType.SQLITE and len(parts) > 2:
# sqlite "/path/db" "SELECT ..."
query = parts[2]
extra = parts[3:]
for p in extra:
if p.startswith("timeout="):
try:
timeout = int(p.split("=", 1)[1])
except (ValueError, IndexError):
pass
elif p.startswith("method="):
http_method = p.split("=", 1)[1].upper()
elif p.startswith("body="):
http_body = p.split("=", 1)[1]
elif p.startswith("headers="):
http_headers = p.split("=", 1)[1]
return Source(var_name=var_name, source_type=src_type,
command=command, timeout=timeout,
http_method=http_method, http_body=http_body,
http_headers=http_headers, query=query,
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)
elif keyword == "hnav":
nav_style = args[0] if args else "bar"
return HNav(nav_style=nav_style, source_line=line_num)
elif keyword == "vnav":
nav_style = args[0] if args else "list"
nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0
return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num)
elif keyword == "image":
path = args[0] if args else ""
mode = args[1] if len(args) > 1 else "braille"
img_width = int(args[2]) if len(args) > 2 else 30
return ImageNode(path=path, mode=mode, img_width=img_width, source_line=line_num)
elif keyword == "dither":
# Style directive for image node
return _StyleDirective("dither_val", args[0] if args else "floyd", line_num)
elif keyword == "invert":
return _StyleDirective("invert_val", True, line_num)
elif keyword == "caption":
return _StyleDirective("caption_val", args[0] if args else "", line_num)
elif keyword == "bigtitle":
text = args[0] if args else ""
font = args[1] if len(args) > 1 else "block"
return BigTitle(text=text, font=font, source_line=line_num)
elif keyword == "theme":
# theme "name" — sets the page theme (handled as _StyleDirective on Page)
theme_name = args[0] if args else "default"
return _ThemeDirective(theme_name, line_num)
elif keyword == "component":
# component name(arg1, arg2)
raw = " ".join(args)
paren = raw.find("(")
if paren != -1:
comp_name = raw[:paren].strip()
params_str = raw[paren + 1:].rstrip(")")
params = [p.strip() for p in params_str.split(",") if p.strip()]
else:
comp_name = args[0] if args else ""
params = []
return ComponentDef(comp_name=comp_name, params=params, source_line=line_num)
elif keyword == "use":
# use std/dashboard — load a library (handled at parse level)
lib_path = args[0] if args else ""
return _UseDirective(lib_path, line_num)
else:
# Try as component invocation: name "arg1" "arg2"
# Only if keyword isn't a known keyword — handled by the tree builder
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
class _NavItemNode(IRNode):
"""Temporary node — absorbed by parent HNav/VNav during tree building."""
def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int):
super().__init__(source_line=line_num)
self.kind = kind
self.label = label
self.dest = dest
self.active = active
class _ThemeDirective(IRNode):
"""Temporary node — sets theme_name on the Page during tree building."""
def __init__(self, theme_name: str, line_num: int):
super().__init__(source_line=line_num)
self.theme_name = theme_name
class _UseDirective(IRNode):
"""Temporary node — triggers library loading during tree building."""
def __init__(self, lib_path: str, line_num: int):
super().__init__(source_line=line_num)
self.lib_path = lib_path
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):
super().__init__(source_line=line_num)
self.columns = columns
class _TableRow(IRNode):
"""Temporary node — absorbed by parent Table during tree building."""
def __init__(self, cells: list[str], line_num: int):
super().__init__(source_line=line_num)
self.cells = cells
# ---------------------------------------------------------------------------
# Style directives — pseudo-nodes that modify their parent's style
# ---------------------------------------------------------------------------
class _StyleDirective(IRNode):
"""Temporary node representing a style modifier (align, color, bold, etc.).
These are absorbed by the parent during tree building and never appear
in the final IR tree.
"""
def __init__(self, attr: str, value: object, line_num: int):
super().__init__(source_line=line_num)
self.attr = attr
self.value = value
# ---------------------------------------------------------------------------
# Component expansion
# ---------------------------------------------------------------------------
def _expand_component(comp_def: ComponentDef, args: list[str]) -> list[IRNode]:
"""Expand a component use into a list of IR nodes by substituting $params.
Clones the component body and replaces $param references with provided args.
"""
import copy
# Build param → arg mapping
param_map: dict[str, str] = {}
for i, param in enumerate(comp_def.params):
param_map[param] = args[i] if i < len(args) else ""
# Deep copy the children and substitute
expanded: list[IRNode] = []
for child in comp_def.children:
clone = copy.deepcopy(child)
_substitute_vars(clone, param_map)
expanded.append(clone)
return expanded
def _substitute_vars(node: IRNode, var_map: dict[str, str]) -> None:
"""Recursively substitute $param references in an IR node tree."""
# Substitute in string fields
for attr_name in ("text", "content", "title", "label", "key", "value",
"display", "dest", "field_name", "placeholder",
"button_label", "checkbox_label", "var_name", "var_value",
"condition", "iterable", "command", "form_name",
"state_name", "path", "group", "state", "comp_name"):
val = getattr(node, attr_name, None)
if isinstance(val, str) and "$" in val:
for param, arg in var_map.items():
val = val.replace(f"${param}", arg)
setattr(node, attr_name, val)
# Substitute in list fields
for attr_name in ("options", "args"):
val = getattr(node, attr_name, None)
if isinstance(val, list):
for i, item in enumerate(val):
if isinstance(item, str) and "$" in item:
for param, arg in var_map.items():
item = item.replace(f"${param}", arg)
val[i] = item
# Substitute in TextSpan list
spans = getattr(node, "spans", None)
if isinstance(spans, list):
for span in spans:
if hasattr(span, "text") and "$" in span.text:
for param, arg in var_map.items():
span.text = span.text.replace(f"${param}", arg)
# After string substitution, resolve deferred numeric fields
for num_attr in ("value", "max_val", "bar_width"):
raw = getattr(node, f"_raw_{num_attr}", None)
if raw is not None:
for param, arg in var_map.items():
raw = raw.replace(f"${param}", arg)
try:
setattr(node, num_attr, float(raw) if "." in raw else int(raw))
except (ValueError, TypeError):
pass
# Recurse
for child in node.children:
_substitute_vars(child, var_map)
# ---------------------------------------------------------------------------
# Standard library loader
# ---------------------------------------------------------------------------
# Built-in component libraries
_STD_LIBRARIES: dict[str, str] = {
"std/dashboard": '''\
component banner(title, subtitle)
box double "$title"
align center
text "$subtitle"
component resources(cpu, mem)
heading 1 "Resources"
gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95
component peer_status(name, state)
status "$name" $state
component info_box(title, content)
box light "$title"
text "$content"
component alert_box(title, content)
box heavy "$title"
color f00
text "$content"
component metric(label, value, max, width)
gauge "$label" $value $max $width
''',
"std/status-bar": '''\
component status_bar(label, value, max)
gauge "$label" $value $max 28
component status_item(name, state)
status "$name" $state
component status_row(name1, state1, name2, state2)
row 2
col 28
status "$name1" $state1
col 28
status "$name2" $state2
''',
"std/nav": '''\
component nav_link(label, dest)
link "$label" "$dest"
component nav_divider()
divider light
component nav_bar(label1, dest1, label2, dest2)
row 2
col 28
link "$label1" "$dest1"
col 28
link "$label2" "$dest2"
''',
"std/network": '''\
component route_table(title)
table "$title"
component peer_list(title)
heading 2 "$title"
component traffic(label_in, vals_in, label_out, vals_out)
sparkline "$label_in" "$vals_in" 20
sparkline "$label_out" "$vals_out" 20
''',
"std/form": '''\
component search_form(name, action)
form "$name"
field "query" 30 "Search..."
button "Search" "$action"
component login_form(action)
form "login"
field "username" 24 "Username"
password "password" 24 "Password"
button "Login" "$action"
''',
}
def _load_library(lib_path: str) -> dict[str, ComponentDef]:
"""Load a standard library and return its component definitions."""
lib_source = _STD_LIBRARIES.get(lib_path, "")
if not lib_source:
return {}
# Parse the library source to extract ComponentDef nodes
comps: dict[str, ComponentDef] = {}
# Use a mini-parse: just extract component definitions
lines = lib_source.split("\n")
current_comp: ComponentDef | None = None
comp_indent = 0
for raw_line in lines:
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = _indent_level(raw_line)
parts = stripped.split(None, 1)
keyword = parts[0].lower()
arg_str = parts[1] if len(parts) > 1 else ""
args = _split_args(arg_str)
if keyword == "component":
raw = " ".join(args)
paren = raw.find("(")
if paren != -1:
comp_name = raw[:paren].strip()
params_str = raw[paren + 1:].rstrip(")")
params = [p.strip() for p in params_str.split(",") if p.strip()]
else:
comp_name = args[0] if args else ""
params = []
current_comp = ComponentDef(comp_name=comp_name, params=params)
comp_indent = indent
comps[comp_name] = current_comp
elif current_comp and indent > comp_indent:
# Parse child node and attach to current component
try:
child = _parse_line(keyword, args, 0)
if not isinstance(child, (_StyleDirective, _TableColumns, _TableRow,
_ElifBranch, _ElseBranch, _UseDirective)):
current_comp.children.append(child)
except ParseError:
pass
else:
current_comp = None
return comps
# ---------------------------------------------------------------------------
# Tree builder
# ---------------------------------------------------------------------------
def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Page:
"""Parse a .uf source string into an IR tree rooted at a Page node.
Args:
source: the .uf DSL source text
components: optional pre-loaded component registry (from `use` directives)
Returns the Page node with all children attached.
"""
lines = source.split("\n")
# Component registry: name → ComponentDef (with children as template)
comp_registry: dict[str, ComponentDef] = dict(components or {})
# Stack: list of (indent_level, node)
stack: list[tuple[int, IRNode]] = []
root: Page | None = None
for line_num, raw_line in enumerate(lines, start=1):
# Skip blank lines and comments
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = _indent_level(raw_line)
# Split into keyword + arguments
parts = stripped.split(None, 1)
keyword = parts[0].lower()
arg_str = parts[1] if len(parts) > 1 else ""
args = _split_args(arg_str)
# Parse this line into a node
node = _parse_line(keyword, args, line_num, raw_args=arg_str)
# Pop stack back to find the parent (parent indent < this indent)
while stack and stack[-1][0] >= indent:
stack.pop()
if isinstance(node, _StyleDirective):
if stack:
parent = stack[-1][1]
# Image-specific directives
if node.attr == "dither_val" and isinstance(parent, ImageNode):
parent.dither = node.value
elif node.attr == "invert_val" and isinstance(parent, ImageNode):
parent.invert = node.value
elif node.attr == "caption_val" and isinstance(parent, ImageNode):
parent.caption = node.value
elif node.attr in ("dither_val", "invert_val", "caption_val"):
pass # ignore if not on ImageNode
else:
setattr(parent.style, node.attr, node.value)
continue
if isinstance(node, _ThemeDirective):
# Set theme on the root Page
if root and isinstance(root, Page):
root.theme_name = node.theme_name
continue
# Table children: columns and rows are absorbed by the Table node
if isinstance(node, _TableColumns):
if stack and isinstance(stack[-1][1], Table):
stack[-1][1].columns = node.columns
continue
if isinstance(node, _TableRow):
if stack and isinstance(stack[-1][1], Table):
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
# Nav items — absorbed by parent HNav/VNav
if isinstance(node, _NavItemNode):
for si in range(len(stack) - 1, -1, -1):
parent = stack[si][1]
if isinstance(parent, (HNav, VNav)):
parent.items.append(NavItem(
kind=node.kind, label=node.label,
dest=node.dest, active=node.active))
break
continue
# Headings inside vnav become nav headings
if isinstance(node, Heading):
for si in range(len(stack) - 1, -1, -1):
if isinstance(stack[si][1], VNav):
stack[si][1].items.append(NavItem(
kind="heading", label=node.text))
break
else:
# Not inside a vnav — proceed as normal heading
pass
if any(isinstance(stack[si][1], VNav) for si in range(len(stack))):
continue
# Use directive — load standard library components
if isinstance(node, _UseDirective):
lib_comps = _load_library(node.lib_path)
comp_registry.update(lib_comps)
continue
# Component definition — register in the component registry
if isinstance(node, ComponentDef):
comp_registry[node.comp_name] = node
stack.append((indent, node)) # push so children attach to it
continue
# Component use — expand inline by cloning the template with args substituted
if isinstance(node, ComponentUse) and node.comp_name in comp_registry:
comp_def = comp_registry[node.comp_name]
expanded = _expand_component(comp_def, node.args)
if stack:
parent = stack[-1][1]
parent.children.extend(expanded)
continue
elif isinstance(node, ComponentUse) and node.comp_name not in comp_registry:
# Unknown component — treat as unknown keyword error
# But be lenient: just skip it with a warning
continue
# Attach to parent
if stack:
parent = stack[-1][1]
parent.children.append(node)
elif isinstance(node, Page):
root = node
else:
# Auto-wrap in a default Page if source doesn't start with `page`
root = Page(title="Untitled", width=64, source_line=0)
root.children.append(node)
stack.append((-1, root))
# Push onto stack
if isinstance(node, Page) and root is node:
stack.append((-1, node))
else:
stack.append((indent, node))
if root is None:
root = Page(title="Untitled", width=64, source_line=0)
return root

130
backend/uframe/registry.py Normal file
View File

@@ -0,0 +1,130 @@
"""µFrame Keyword Registry — single source of truth for all DSL keywords.
Each keyword is registered with its parse, measure, layout, paint, and
codegen functions plus frontend metadata (section, detail, snippet).
Adding a new keyword requires only one registration in keywords.py.
Usage:
from uframe.registry import register, KEYWORD_REGISTRY, NODE_REGISTRY
@register("gauge", section="Data", detail="gauge label val max width",
snippet='gauge "${label}" ${value} ${max:100} ${width:28}')
def _def_gauge():
return KeywordDef(
parse=parse_gauge,
measure=measure_gauge,
paint=paint_gauge,
...
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Any
from uframe.ir import IRNode
@dataclass
class KeywordDef:
"""Complete definition of a µFrame DSL keyword."""
name: str = ""
section: str = "" # "Layout", "Content", "Data", "Style", "Theme", "Form", "Dynamic"
detail: str = "" # slash command detail text
snippet: str = "" # slash command snippet (CodeMirror format)
highlight_values: list[str] = field(default_factory=list) # values to highlight as atoms
# Pipeline functions — all optional, falling back to generic behavior
parse: Callable[..., IRNode] | None = None # (args, line_num) → IRNode
measure: Callable[..., None] | None = None # (node, available_width) → None
layout: Callable[..., int] | None = None # (node, x, y, w, h) → height
paint: Callable[..., None] | None = None # (node, grid, theme) → None
codegen: Callable[..., list[str]] | None = None # (node, indent_level) → [str]
# Flags
is_container: bool = False # has children (affects layout: vertical stack)
is_leaf: bool = False # simple leaf node (layout: return pref_height)
is_metadata: bool = False # zero-height metadata (let, source, cache)
is_style_directive: bool = False # modifies parent's style (align, color, bold)
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
# Keyword name → KeywordDef
KEYWORD_REGISTRY: dict[str, KeywordDef] = {}
# IR node class → KeywordDef (for measure/layout/paint/codegen dispatch)
NODE_REGISTRY: dict[type, KeywordDef] = {}
# All highlight values (populated during registration)
ALL_HIGHLIGHT_VALUES: set[str] = set()
# All theme names
ALL_THEME_NAMES: list[str] = []
def register(name: str, node_class: type | None = None, **kwargs: Any) -> Callable:
"""Decorator to register a keyword definition.
Usage:
@register("gauge", node_class=Gauge, section="Data",
detail="gauge label val max width",
snippet='gauge "${label}" ${value} ...')
def def_gauge():
return KeywordDef(parse=..., measure=..., paint=..., ...)
Or simpler — pass all fields directly:
register_keyword("gauge", node_class=Gauge, section="Data", ...)
"""
def decorator(func: Callable) -> Callable:
kw_def = func()
if isinstance(kw_def, KeywordDef):
kw_def.name = name
for k, v in kwargs.items():
if hasattr(kw_def, k):
setattr(kw_def, k, v)
else:
kw_def = KeywordDef(name=name, **kwargs)
KEYWORD_REGISTRY[name] = kw_def
if node_class is not None:
NODE_REGISTRY[node_class] = kw_def
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
return func
return decorator
def register_keyword(name: str, node_class: type | None = None, **kwargs: Any) -> KeywordDef:
"""Direct registration (non-decorator form)."""
kw_def = KeywordDef(name=name, **kwargs)
KEYWORD_REGISTRY[name] = kw_def
if node_class is not None:
NODE_REGISTRY[node_class] = kw_def
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
return kw_def
def get_dsl_meta() -> dict:
"""Return DSL metadata for the frontend (keywords, values, commands, themes)."""
keywords = sorted(KEYWORD_REGISTRY.keys())
values = sorted(ALL_HIGHLIGHT_VALUES)
commands = []
for kw in KEYWORD_REGISTRY.values():
if kw.detail and kw.snippet:
commands.append({
"label": kw.name,
"detail": kw.detail,
"section": kw.section,
"snippet": kw.snippet,
})
themes = ALL_THEME_NAMES or []
return {
"keywords": keywords,
"values": values,
"commands": commands,
"themes": themes,
}

View File

View File

@@ -0,0 +1,102 @@
"""Tests for BigTitle and font rendering."""
import uframe
from uframe.fonts import render_big_text, get_text_width, FONTS, FONT_HEIGHTS
# ---------------------------------------------------------------------------
# Unit tests for font module
# ---------------------------------------------------------------------------
def test_font_registry():
assert "block" in FONTS
assert "thin" in FONTS
assert "pixel" in FONTS
def test_font_heights():
assert FONT_HEIGHTS["block"] == 6
assert FONT_HEIGHTS["thin"] == 3
assert FONT_HEIGHTS["pixel"] == 3
def test_render_big_text_block():
lines = render_big_text("HI", "block")
assert len(lines) == 6
# Should contain block characters
assert any("" in line for line in lines)
def test_render_big_text_thin():
lines = render_big_text("AB", "thin")
assert len(lines) == 3
assert any("" in line or "" in line for line in lines)
def test_render_big_text_pixel():
lines = render_big_text("OK", "pixel")
assert len(lines) == 3
assert any("" in line or "" in line for line in lines)
def test_get_text_width():
w = get_text_width("A", "block")
assert w > 0
w2 = get_text_width("AB", "block")
assert w2 > w # two chars wider than one
def test_kerning():
w_tight = get_text_width("AB", "block", kerning=0)
w_normal = get_text_width("AB", "block", kerning=1)
w_wide = get_text_width("AB", "block", kerning=2)
assert w_tight < w_normal < w_wide
# ---------------------------------------------------------------------------
# Integration tests — bigtitle through compile pipeline
# ---------------------------------------------------------------------------
def test_bigtitle_block_font():
source = '''page "Test" 80
bigtitle "HI" block'''
result = uframe.compile(source)
assert "" in result.ascii
lines = result.ascii.strip().split("\n")
assert len(lines) >= 6 # block font is 6 lines tall
def test_bigtitle_thin_font():
source = '''page "Test" 80
bigtitle "OK" thin'''
result = uframe.compile(source)
# Thin font uses box-drawing characters
assert any(c in result.ascii for c in "┌├┐┘└┬")
def test_bigtitle_pixel_font():
source = '''page "Test" 80
bigtitle "GO" pixel'''
result = uframe.compile(source)
assert any(c in result.ascii for c in "▀▄█")
def test_bigtitle_fallback_to_smaller_font():
"""When text is too wide for block font, should fall back to smaller fonts."""
source = '''page "Test" 40
bigtitle "ABCDEFGHIJ" block'''
result = uframe.compile(source)
# Should still render with a fallback font (thin uses box-drawing)
assert result.ascii.strip() != ""
# Thin font characters should be present (fell back from block)
assert any(c in result.ascii for c in "┌├┐┘└┬─")
def test_bigtitle_with_alignment():
source = '''page "Test" 80
bigtitle "HI" block
align center'''
result = uframe.compile(source)
assert "" in result.ascii

View File

@@ -0,0 +1,298 @@
"""End-to-end tests for the µFrame compile pipeline."""
import uframe
def test_empty_source():
result = uframe.compile("")
assert result.ascii == ""
assert result.micron == ""
def test_simple_heading():
result = uframe.compile('page "Test" 40\n heading 1 "Hello World"')
assert "Hello World" in result.ascii
assert "Hello World" in result.micron
def test_box_with_title():
source = '''page "Demo" 40
box light "Status"
text "All systems go"'''
result = uframe.compile(source)
# ASCII should have box-drawing characters
assert "" in result.ascii
assert "" in result.ascii
assert "Status" in result.ascii
assert "All systems go" in result.ascii
def test_box_heavy():
source = '''page "Demo" 40
box heavy "Alert"
text "Warning"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Alert" in result.ascii
def test_box_double():
source = '''page "Demo" 40
box double "Title"
text "Content"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Title" in result.ascii
def test_box_rounded():
source = '''page "Demo" 40
box rounded "Panel"
text "Inside"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Panel" in result.ascii
def test_row_with_columns():
source = '''page "Demo" 40
row 2
col 18
text "Left"
col 18
text "Right"'''
result = uframe.compile(source)
assert "Left" in result.ascii
assert "Right" in result.ascii
def test_label():
source = '''page "Demo" 40
label "Name" "Alice"'''
result = uframe.compile(source)
assert "Name:" in result.ascii
assert "Alice" in result.ascii
def test_divider():
source = '''page "Demo" 40
divider heavy'''
result = uframe.compile(source)
assert "" in result.ascii
def test_link():
source = '''page "Demo" 40
link "Home" "/page/index.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
# Micron should have link syntax
assert "index.mu" in result.micron
def test_list():
source = '''page "Demo" 40
list bullet
item "First"
item "Second"'''
result = uframe.compile(source)
assert "First" in result.ascii
assert "Second" in result.ascii
def test_spacer():
source = '''page "Demo" 40
text "Before"
spacer 2
text "After"'''
result = uframe.compile(source)
lines = result.ascii.split("\n")
# Should have blank lines between Before and After
before_idx = next(i for i, l in enumerate(lines) if "Before" in l)
after_idx = next(i for i, l in enumerate(lines) if "After" in l)
assert after_idx - before_idx >= 3 # at least 2 blank lines between
def test_nested_boxes():
source = '''page "Demo" 40
box light "Outer"
box heavy "Inner"
text "Deep"'''
result = uframe.compile(source)
assert "Outer" in result.ascii
assert "Inner" in result.ascii
assert "Deep" in result.ascii
def test_micron_has_style_tags():
source = '''page "Demo" 40
heading 1 "Title"'''
result = uframe.compile(source)
# Micron should contain color tags for the heading
assert "`F" in result.micron or "Title" in result.micron
def test_gauge():
source = '''page "Demo" 40
gauge "CPU" 62 100 20 warn=75 crit=90'''
result = uframe.compile(source)
assert "CPU" in result.ascii
assert "" in result.ascii
assert "62%" in result.ascii
def test_comment_ignored():
source = '''page "Demo" 40
# this is a comment
text "Visible"'''
result = uframe.compile(source)
assert "Visible" in result.ascii
assert "comment" not in result.ascii
def test_inline_modifiers():
source = '''page "Demo" 40
text "Hello @bold{world} today"'''
result = uframe.compile(source)
assert "Hello" in result.ascii
assert "world" in result.ascii
# Micron should have bold tags around "world"
assert "`!" in result.micron
def test_status():
source = '''page "Demo" 40
status "Server" online'''
result = uframe.compile(source)
assert "" in result.ascii
assert "Server" in result.ascii
def test_table():
source = '''page "Demo" 50
table "Routes"
columns "Destination" 20 | "Hops" 6 | "Status" 10
row "relay-east" | "2" | "alive"
row "bridge-south" | "4" | "alive"
row "node-gamma" | "7" | "stale"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "" in result.ascii # column separators at header line
assert "Destination" in result.ascii
assert "relay-east" in result.ascii
assert "bridge-south" in result.ascii
assert "stale" in result.ascii
# Should have proper structure
lines = result.ascii.split("\n")
assert len(lines) >= 6 # top border + header + sep + 3 rows + bottom border
def test_table_with_color():
source = '''page "Demo" 50
table "Peers"
columns "Name" 16 | "State" 12
row "east-relay" | "@color{0f0}{● alive}"
row "node-gamma" | "@color{f00}{○ stale}"'''
result = uframe.compile(source)
assert "" in result.ascii
assert "" in result.ascii
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
box double "Relay Alpha-7"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
spacer
heading 2 "Peers"
label "Active" "7 / 12"
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
divider heavy
link "Home" "/page/index.mu"'''
result = uframe.compile(source)
# Verify key elements are present
assert "Relay Alpha-7" in result.ascii
assert "" in result.ascii # double box
assert "CPU" in result.ascii
assert "MEM" in result.ascii
assert "" in result.ascii # gauge bars
assert "" in result.ascii # status indicators
assert "" in result.ascii # heavy divider
assert "Home" in result.ascii
# Micron should have color tags
assert "`F" in result.micron
assert result.micron # non-empty

View File

@@ -0,0 +1,123 @@
"""Tests for components and standard library."""
import uframe
def test_inline_component():
source = '''page "Test" 50
component greeting(name)
heading 1 "Hello $name"
text "Welcome, $name!"
greeting "Alice"
greeting "Bob"'''
result = uframe.compile(source)
assert "Hello Alice" in result.ascii
assert "Welcome, Alice!" in result.ascii
assert "Hello Bob" in result.ascii
assert "Welcome, Bob!" in result.ascii
def test_component_with_gauge():
source = '''page "Test" 50
component stat(label, value, max)
gauge "$label" $value $max 20
stat "CPU" 62 100
stat "MEM" 84 100'''
result = uframe.compile(source)
assert "CPU" in result.ascii
assert "MEM" in result.ascii
assert "" in result.ascii
def test_use_std_dashboard():
source = '''page "Test" 60
use std/dashboard
banner "My Node" "Mesh Network"'''
result = uframe.compile(source)
assert "My Node" in result.ascii
assert "Mesh Network" in result.ascii
assert "" in result.ascii # double box from banner
def test_use_std_dashboard_resources():
source = '''page "Test" 60
use std/dashboard
resources 42 67'''
result = uframe.compile(source)
assert "CPU" in result.ascii
assert "MEM" in result.ascii
assert "Resources" in result.ascii
def test_use_std_nav():
source = '''page "Test" 50
use std/nav
nav_link "Home" "/page/index.mu"
nav_divider
nav_link "About" "/page/about.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "About" in result.ascii
def test_use_std_status_bar():
source = '''page "Test" 60
use std/status-bar
status_bar "Uptime" 95 100'''
result = uframe.compile(source)
assert "Uptime" in result.ascii
assert "" in result.ascii
def test_use_std_status_bar_item():
source = '''page "Test" 60
use std/status-bar
status_item "Gateway" online'''
result = uframe.compile(source)
assert "Gateway" in result.ascii
assert "" in result.ascii
def test_use_std_status_bar_row():
source = '''page "Test" 60
use std/status-bar
status_row "East" online "West" offline'''
result = uframe.compile(source)
assert "East" in result.ascii
assert "West" in result.ascii
def test_use_std_network_peer_list():
source = '''page "Test" 60
use std/network
peer_list "Active Peers"'''
result = uframe.compile(source)
assert "Active Peers" in result.ascii
def test_use_std_form_search():
source = '''page "Test" 60
use std/form
search_form "search" "/page/results.mu"'''
result = uframe.compile(source)
assert "Search" in result.ascii
assert "results.mu" in result.micron
def test_use_std_form_login():
source = '''page "Test" 60
use std/form
login_form "/page/auth.mu"'''
result = uframe.compile(source)
assert "Login" in result.ascii
assert "auth.mu" in result.micron
def test_unknown_component_ignored():
source = '''page "Test" 50
heading 1 "Hello"
nonexistent_thing "arg"'''
result = uframe.compile(source)
assert "Hello" in result.ascii

View File

@@ -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

View File

@@ -0,0 +1,191 @@
"""Tests for HNav and VNav navigation components."""
import uframe
# ---------------------------------------------------------------------------
# HNav — Horizontal Navigation
# ---------------------------------------------------------------------------
def test_hnav_bar():
source = '''page "Test" 60
hnav bar
item "Home" "/page/index.mu" active
item "About" "/page/about.mu"
item "Settings" "/page/settings.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "About" in result.ascii
assert "Settings" in result.ascii
# Bar style has borders
assert "" in result.ascii or "" in result.ascii
def test_hnav_tabs():
source = '''page "Test" 60
hnav tabs
item "Dashboard" "/page/dash.mu" active
item "Logs" "/page/logs.mu"'''
result = uframe.compile(source)
assert "Dashboard" in result.ascii
assert "Logs" in result.ascii
def test_hnav_pills():
source = '''page "Test" 60
hnav pills
item "All" "/page/all.mu" active
item "Active" "/page/active.mu"
item "Stale" "/page/stale.mu"'''
result = uframe.compile(source)
assert "All" in result.ascii
assert "Active" in result.ascii
assert "Stale" in result.ascii
def test_hnav_breadcrumb():
source = '''page "Test" 60
hnav breadcrumb
item "Home" "/page/index.mu"
item "Network" "/page/network.mu"
item "Node Alpha" "/page/alpha.mu" active'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "Network" in result.ascii
assert "Node Alpha" in result.ascii
# Breadcrumb uses ▸ separator
assert "" in result.ascii
def test_hnav_underline():
source = '''page "Test" 60
hnav underline
item "Overview" "/page/overview.mu" active
item "Details" "/page/details.mu"'''
result = uframe.compile(source)
assert "Overview" in result.ascii
assert "Details" in result.ascii
# Active item has underline
assert "" in result.ascii
def test_hnav_with_separator():
source = '''page "Test" 60
hnav bar
item "Home" "/page/index.mu" active
separator
item "Help" "/page/help.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "Help" in result.ascii
def test_hnav_micron_links():
source = '''page "Test" 60
hnav bar
item "Home" "/page/index.mu"
item "About" "/page/about.mu"'''
result = uframe.compile(source)
assert "index.mu" in result.micron
assert "about.mu" in result.micron
# ---------------------------------------------------------------------------
# VNav — Vertical Navigation
# ---------------------------------------------------------------------------
def test_vnav_list():
source = '''page "Test" 60
vnav list
item "Dashboard" "/page/dash.mu" active
item "Peers" "/page/peers.mu"
item "Routes" "/page/routes.mu"'''
result = uframe.compile(source)
assert "Dashboard" in result.ascii
assert "Peers" in result.ascii
assert "Routes" in result.ascii
# Active item has marker
assert "" in result.ascii
def test_vnav_boxed():
source = '''page "Test" 60
vnav boxed
item "Home" "/page/index.mu" active
item "Settings" "/page/settings.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "Settings" in result.ascii
# Boxed style has borders
assert "" in result.ascii
assert "" in result.ascii
def test_vnav_tree():
source = '''page "Test" 60
vnav tree
item "Root" "/page/root.mu" active
item "Child A" "/page/a.mu"
item "Child B" "/page/b.mu"'''
result = uframe.compile(source)
assert "Root" in result.ascii
assert "Child A" in result.ascii
assert "Child B" in result.ascii
# Tree style has connectors
assert "" in result.ascii or "" in result.ascii
def test_vnav_sidebar():
source = '''page "Test" 60
vnav sidebar
item "Overview" "/page/overview.mu" active
item "Metrics" "/page/metrics.mu"
item "Alerts" "/page/alerts.mu"'''
result = uframe.compile(source)
assert "Overview" in result.ascii
assert "Metrics" in result.ascii
assert "Alerts" in result.ascii
def test_vnav_minimal():
source = '''page "Test" 60
vnav minimal
item "Page 1" "/page/1.mu"
item "Page 2" "/page/2.mu" active
item "Page 3" "/page/3.mu"'''
result = uframe.compile(source)
assert "Page 1" in result.ascii
assert "Page 2" in result.ascii
assert "Page 3" in result.ascii
def test_vnav_with_separator():
source = '''page "Test" 60
vnav list
item "Home" "/page/index.mu" active
separator
item "Help" "/page/help.mu"'''
result = uframe.compile(source)
assert "Home" in result.ascii
assert "Help" in result.ascii
assert "" in result.ascii
def test_vnav_with_width():
source = '''page "Test" 60
vnav boxed 25
item "Nav Item" "/page/nav.mu" active'''
result = uframe.compile(source)
assert "Nav Item" in result.ascii
def test_vnav_micron_links():
source = '''page "Test" 60
vnav list
item "Home" "/page/index.mu"
item "About" "/page/about.mu"'''
result = uframe.compile(source)
assert "index.mu" in result.micron
assert "about.mu" in result.micron

View File

@@ -0,0 +1,154 @@
"""Tests for the theme system."""
import uframe
from uframe.themes import (
get_theme, ThemeDef, BUILTIN_THEMES,
THEME_DEFAULT, THEME_NOUVEAU, THEME_GOTHIC,
THEME_BAMBOO, THEME_CIRCUIT, THEME_BRUTALIST,
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
def test_builtin_themes_exist():
assert len(BUILTIN_THEMES) == 6
for name in ("default", "nouveau", "gothic", "bamboo", "circuit", "brutalist"):
assert name in BUILTIN_THEMES
def test_get_theme_by_name():
theme = get_theme("gothic")
assert theme.name == "gothic"
def test_get_theme_case_insensitive():
theme = get_theme("GOTHIC")
assert theme.name == "gothic"
def test_get_theme_unknown_returns_default():
theme = get_theme("nonexistent")
assert theme.name == "default"
# ---------------------------------------------------------------------------
# Theme structure
# ---------------------------------------------------------------------------
def test_theme_has_all_fields():
for name, theme in BUILTIN_THEMES.items():
assert theme.borders_light is not None
assert theme.borders_heavy is not None
assert theme.borders_double is not None
assert theme.borders_rounded is not None
assert theme.dividers is not None
assert theme.indicators is not None
assert theme.gauge is not None
assert theme.form is not None
assert theme.palette is not None
def test_border_chars_method():
theme = THEME_DEFAULT
bc = theme.border_chars("light")
assert bc.tl == ""
assert bc.tr == ""
def test_border_dict_method():
theme = THEME_DEFAULT
bd = theme.border_dict("double")
assert bd["tl"] == ""
assert bd["h"] == ""
# ---------------------------------------------------------------------------
# Themed rendering — each theme produces correct characters
# ---------------------------------------------------------------------------
def test_compile_with_default_theme():
source = '''page "Test" 40
box light "Panel"
text "Content"'''
result = uframe.compile(source)
assert "" in result.ascii # default light border
def test_compile_with_gothic_theme():
source = '''page "Test" 40
box light "Panel"
text "Content"'''
result = uframe.compile(source, theme="gothic")
# Gothic uses double-style borders for light
assert "" in result.ascii
def test_compile_with_brutalist_theme():
source = '''page "Test" 40
box light "Panel"
text "Content"'''
result = uframe.compile(source, theme="brutalist")
# Brutalist uses block characters for light borders
assert "" in result.ascii
def test_compile_with_bamboo_theme():
source = '''page "Test" 40
box light "Panel"
text "Content"'''
result = uframe.compile(source, theme="bamboo")
# Bamboo uses dashed light borders
assert "" in result.ascii or "" in result.ascii
def test_compile_with_circuit_theme():
source = '''page "Test" 40
gauge "CPU" 50 100 20'''
result = uframe.compile(source, theme="circuit")
# Circuit uses ▰/▱ for gauge
assert "" in result.ascii
assert "" in result.ascii
def test_compile_with_nouveau_theme():
source = '''page "Test" 40
status "Server" online'''
result = uframe.compile(source, theme="nouveau")
# Nouveau uses ❀ for online indicator
assert "" in result.ascii
def test_theme_divider_chars():
source = '''page "Test" 40
divider heavy'''
result_default = uframe.compile(source)
result_gothic = uframe.compile(source, theme="gothic")
# Both should render but potentially with different chars
assert "" in result_default.ascii
assert "" in result_gothic.ascii
def test_theme_gauge_chars():
source = '''page "Test" 40
gauge "Test" 50 100 20'''
result_default = uframe.compile(source)
result_brutalist = uframe.compile(source, theme="brutalist")
# Default: █/░, Brutalist: █/(space)
assert "" in result_default.ascii
assert "" in result_brutalist.ascii
def test_theme_form_chars():
source = '''page "Test" 50
form "test"
checkbox "agree" "Accept"'''
result_default = uframe.compile(source)
result_gothic = uframe.compile(source, theme="gothic")
# Default: [ ], Gothic: ⚐
assert "[ ]" in result_default.ascii or "Accept" in result_default.ascii
assert "" in result_gothic.ascii or "Accept" in result_gothic.ascii

241
backend/uframe/themes.py Normal file
View File

@@ -0,0 +1,241 @@
"""µFrame Theme System — decorative styles for rich terminal UIs.
A theme maps abstract UI elements to concrete character sets and color
palettes. The same .uf source renders with different visual character
when a different theme is applied.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class BorderChars:
tl: str = ""; t: str = ""; tr: str = ""
l: str = ""; r: str = ""
bl: str = ""; b: str = ""; br: str = ""
@dataclass
class Indicators:
online: str = ""
offline: str = ""
degraded: str = ""
unknown: str = ""
alert: str = ""
@dataclass
class GaugeChars:
filled: str = ""
empty: str = ""
@dataclass
class FormChars:
field_l: str = "[ "
field_r: str = " ]"
radio_on: str = "(•)"
radio_off: str = "( )"
check_on: str = "[✓]"
check_off: str = "[ ]"
@dataclass
class Ornaments:
bullet: str = ""
header: str = ""
separator: str = ""
footer: str = ""
@dataclass
class TitleCaps:
left: str = ""
right: str = ""
@dataclass
class Palette:
accent: str = "0f0" # headings, primary highlights
accent2: str = "0cf" # secondary (H2, links)
accent3: str = "88f" # tertiary (H3)
muted: str = "555" # dividers, empty gauge
border: str = "" # border color (empty = no color)
success: str = "0f0" # online, gauge ok
warning: str = "ff0" # degraded, gauge warn
danger: str = "f00" # offline, gauge crit
info: str = "0cf" # links, sparklines
form: str = "0cf" # form element accents
label: str = "888" # labels, field names
button: str = "0f0" # form buttons
@dataclass
class DividerChars:
light: str = ""
heavy: str = ""
double: str = ""
dash: str = ""
dot: str = ""
@dataclass
class ThemeDef:
name: str = "default"
description: str = "Clean engineering — standard box-drawing"
borders_light: BorderChars = field(default_factory=BorderChars)
borders_heavy: BorderChars = field(default_factory=lambda: BorderChars(
tl="", t="", tr="", l="", r="", bl="", b="", br=""))
borders_double: BorderChars = field(default_factory=lambda: BorderChars(
tl="", t="", tr="", l="", r="", bl="", b="", br=""))
borders_rounded: BorderChars = field(default_factory=lambda: BorderChars(
tl="", t="", tr="", l="", r="", bl="", b="", br=""))
dividers: DividerChars = field(default_factory=DividerChars)
title_caps: TitleCaps = field(default_factory=TitleCaps)
indicators: Indicators = field(default_factory=Indicators)
gauge: GaugeChars = field(default_factory=GaugeChars)
form: FormChars = field(default_factory=FormChars)
ornaments: Ornaments = field(default_factory=Ornaments)
palette: Palette = field(default_factory=Palette)
def border_chars(self, weight_name: str) -> BorderChars:
return {
"light": self.borders_light,
"heavy": self.borders_heavy,
"double": self.borders_double,
"rounded": self.borders_rounded,
}.get(weight_name, self.borders_light)
def border_dict(self, weight_name: str) -> dict[str, str]:
"""Return BOX_CHARS-compatible dict for a border weight."""
bc = self.border_chars(weight_name)
return {
"tl": bc.tl, "tr": bc.tr, "bl": bc.bl, "br": bc.br,
"h": bc.t, "v": bc.l,
"t_down": bc.t, "t_up": bc.b, "t_right": bc.l, "t_left": bc.r,
"cross": bc.t,
}
# ---------------------------------------------------------------------------
# Built-in themes
# ---------------------------------------------------------------------------
THEME_DEFAULT = ThemeDef()
THEME_NOUVEAU = ThemeDef(
name="nouveau",
description="Art Nouveau — organic flowing ornament",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="✾─── ", right=" ───✾"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=""),
form=FormChars(field_l=" ", field_r=" ", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet="", header="─✾──────✾─", separator="☙━━━━━━━━━━━━━❧"),
palette=Palette(accent="da5", accent2="8b5", accent3="886", muted="886",
border="a85", success="6b4", warning="da5", danger="a33",
info="68a", form="da5", label="886", button="6b4"),
)
THEME_GOTHIC = ThemeDef(
name="gothic",
description="Gothic — heavy blackletter, monumental",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="═══╡ ", right=" ╞═══"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=""),
form=FormChars(field_l="", field_r="", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet="", header="═══╡══════╞═══"),
palette=Palette(accent="cc8", accent2="a66", accent3="888", muted="666",
border="888", success="8a8", warning="cc8", danger="a44",
info="8ac", form="cc8", label="888", button="cc8"),
)
THEME_BAMBOO = ThemeDef(
name="bamboo",
description="Bamboo — East Asian minimalism, light brush strokes",
borders_heavy=BorderChars(tl="", t=" ", tr="", l=" ", r=" ", bl=" ", b=" ", br=" "),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t=" ", tr="", l=" ", r=" ", bl=" ", b=" ", br=" "),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="┄┄┄ ", right=" ┄┄┄"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=" "),
form=FormChars(field_l="", field_r="", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet=""),
palette=Palette(accent="bca", accent2="ab9", accent3="998", muted="998",
border="776", success="8b8", warning="cc9", danger="b77",
info="9ab", form="bca", label="998", button="8b8"),
)
THEME_CIRCUIT = ThemeDef(
name="circuit",
description="Circuit — digital, technical, neon",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="══[ ", right=" ]═══"),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert=""),
gauge=GaugeChars(filled="", empty=""),
form=FormChars(field_l=">_ [ ", field_r=" ]", radio_on="[▰]", radio_off="[▱]",
check_on="[▰]", check_off="[▱]"),
ornaments=Ornaments(bullet=""),
palette=Palette(accent="0ff", accent2="f0f", accent3="0af", muted="555",
border="0aa", success="0f0", warning="ff0", danger="f00",
info="0ff", form="0ff", label="0aa", button="0f0"),
)
THEME_BRUTALIST = ThemeDef(
name="brutalist",
description="Brutalist — raw blocks, monochrome, anti-decorative",
borders_heavy=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_light=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_double=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
borders_rounded=BorderChars(tl="", t="", tr="", l="", r="", bl="", b="", br=""),
dividers=DividerChars(light="", heavy="", double="", dash="", dot=""),
title_caps=TitleCaps(left="", right=""),
indicators=Indicators(online="", offline="", degraded="", unknown="", alert="!"),
gauge=GaugeChars(filled="", empty=" "),
form=FormChars(field_l="[", field_r="]", radio_on="", radio_off="",
check_on="", check_off=""),
ornaments=Ornaments(bullet=""),
palette=Palette(accent="fff", accent2="fff", accent3="ccc", muted="888",
border="fff", success="fff", warning="fff", danger="fff",
info="fff", form="fff", label="aaa", button="fff"),
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
BUILTIN_THEMES: dict[str, ThemeDef] = {
"default": THEME_DEFAULT,
"nouveau": THEME_NOUVEAU,
"gothic": THEME_GOTHIC,
"bamboo": THEME_BAMBOO,
"circuit": THEME_CIRCUIT,
"brutalist": THEME_BRUTALIST,
}
def get_theme(name: str) -> ThemeDef:
"""Get a built-in theme by name. Returns default if not found."""
return BUILTIN_THEMES.get(name.lower(), THEME_DEFAULT)

View File

@@ -1,14 +1,41 @@
services: services:
micron-editor: micronomicon:
build: . build:
context: .
dockerfile: Dockerfile
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
volumes:
- ~/.nomadnetwork/storage/pages:/data/pages
- ~/.micron-editor/sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock
environment: environment:
- PAGES_DIR=/data/pages - PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources - SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet - NOMADNET_CONTAINER=nomadnet
restart: always - RNS_CONFIG_DIR=/rns
- RNS_SERVER_CONFIG_DIR=/rns-server
- NOMADNET_CONFIG_DIR=/nomadnet
- LOG_LEVEL=DEBUG
volumes:
- pages:/data/pages
- sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./reticulum-client.conf:/rns/config
- ./reticulum.conf:/rns-server/config
- ./nomadnet.conf:/nomadnet/config
restart: unless-stopped
depends_on:
- nomadnet
nomadnet:
image: ghcr.io/markqvist/nomadnet:latest
ports:
- "0.0.0.0:4242:4242"
volumes:
- pages:/root/.nomadnetwork/storage/pages
- nomadnet-config:/root/.nomadnetwork
- ./nomadnet.conf:/root/.nomadnetwork/config
- ./reticulum.conf:/root/.reticulum/config
restart: unless-stopped
volumes:
pages:
sources:
nomadnet-config:

14
deploy.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Building frontend..."
(cd frontend && npm run build)
echo "==> Deploying with docker compose..."
docker compose -f compose.yml up -d --build
echo "==> Done!"
echo " Web IDE: http://localhost:8080"
echo " Reticulum: tcp://0.0.0.0:4242"

850
docs/dynamic-templates.md Normal file
View File

@@ -0,0 +1,850 @@
# µFrame Dynamic Templates — Making Rich UIs Live on NomadNet
## Addendum to the µFrame Design Document (v3)
---
## 1. The NomadNet Dynamic Page Model
Understanding how NomadNet serves dynamic pages is essential to
understanding how µFrame templates become live applications.
### How it works
NomadNet has a simple but powerful execution model, analogous to
CGI on the early web:
```
Client Node Server
────── ───────────
1. Browse to /page/dashboard.mu
───────────▶
2. Is dashboard.mu executable?
YES → run it
NO → send contents as-is
3. Execute: #!/usr/bin/env python3
Script prints Micron to stdout
4. Capture stdout → send to client
◀───────────
5. Render Micron in terminal
```
Key mechanics:
- A `.mu` file without the execute bit is **static** — NomadNet
sends its contents directly to the browsing client
- A `.mu` file with the execute bit set is **dynamic** — NomadNet
runs it as a subprocess and serves whatever it prints to stdout
- The shebang line (`#!/usr/bin/env python3`) determines the
interpreter — Python, Bash, Lua, Rust, anything that runs
- The script must **terminate** — it cannot wait for input or
run indefinitely
- Cache behavior is controlled via a header line: `#!c=0` means
never cache (always re-execute), `#!c=300` means cache for 5 min
### Form data flow
Micron form fields (`\`<field\`placeholder>`, `\`<^|group|val\`label>`,
etc.) collect user input. When the user clicks a link on a page
that contains form fields, the field data is submitted along with
the link request:
```
Page A (has form fields + a submit link)
┌───────────────────────────────────────────────┐
│ Name: `<24|name`Enter name...> │
│ Role: `<^|role|admin`Admin> `<^|role|user`User> │
│ │
│ `[Submit`:/page/handle.mu] │
└───────────────────────────────────────────────┘
User fills in "Alice", selects "Admin", clicks Submit
Page B (handle.mu) — receives field data via environment
variables, generates a response page with Micron output
```
The submitted field data is passed to the executable script
through environment variables in the format:
```
FIELD_name=Alice
FIELD_role=admin
```
Or via stdin as a URL-encoded or structured data payload
(implementation varies by NomadNet version). The executable
script reads these values and uses them to generate its output.
---
## 2. µFrame's Dynamic Compilation Model
Here is the key insight: **µFrame doesn't just emit static
Micron text — it can compile `.uf` templates into executable
Python scripts that generate Micron at request time.**
This gives us three output modes:
```
.uf source ──▶ Parser ──▶ IR ──┬──▶ Plain ASCII (static preview)
├──▶ Static .mu (static page)
└──▶ Dynamic .mu (executable script)
#!/usr/bin/env python3
# Auto-generated by µFrame
# from: dashboard.uf
import os, sys, json, subprocess
...
print(rendered_micron)
```
The dynamic output is a self-contained Python script with:
- The µFrame rendering engine embedded (or imported)
- Data-fetching hooks that run at request time
- Form field processing from environment variables
- Conditional rendering based on submitted data
- The full ASCII art + Micron generation pipeline
### The three modes compared
```
┌──────────────────────────────────────────────────────────────┐
│ µFrame Output Modes │
├────────────────┬───────────────┬─────────────────────────────┤
│ Plain ASCII │ Static .mu │ Dynamic .mu │
├────────────────┼───────────────┼─────────────────────────────┤
│ Box-drawing │ Box-drawing │ Box-drawing │
│ Block chars │ Block chars │ Block chars │
│ Braille │ Braille │ Braille │
│ │ + Color │ + Color │
│ │ + Bold/italic │ + Bold/italic │
│ │ + Links │ + Links │
│ │ + Form fields │ + LIVE form fields │
│ │ │ + Server-side data binding │
│ │ │ + Conditional rendering │
│ │ │ + Form submission handling │
│ │ │ + System data at render time │
│ │ │ + State persistence │
├────────────────┼───────────────┼─────────────────────────────┤
│ Local terminal │ NomadNet page │ NomadNet live application │
│ preview │ (cached) │ (re-executed per request) │
└────────────────┴───────────────┴─────────────────────────────┘
```
---
## 3. DSL Extensions for Dynamic Behavior
### 3.1 Data Sources — `source` blocks
A `source` block declares where live data comes from. At render
time, the generated script executes the source and binds the
result to a variable.
```
# Shell command — output captured as string
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 peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"peers\",[]))); '"
# File read — contents loaded as string or parsed as JSON
source motd : file "/etc/motd"
source config : json "/home/node/.nomadnetwork/config.json"
# Python expression — evaluated inline (available: datetime, timedelta, secrets, os, json)
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
source rand_hex : python "secrets.token_hex(4)"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
# RNS/Reticulum API — direct integration
source peer_list : rns "peers"
source node_info : rns "identity"
```
Sources are resolved **at page render time** — every time a
client requests the page, the commands run fresh.
Usage in templates:
```
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
label "Uptime" "$uptime"
label "Peers" "$peers active"
text "Last updated: $timestamp"
```
### 3.2 Form Handling — `on_submit` blocks
An `on_submit` block defines what happens when a form is
submitted. It receives field values and controls what the
page renders in response.
```
page "Search" 64
form "search"
field "query" 30 "Enter search term..."
radio "scope" "Local" | "Network" | "All"
button "Search" "/page/search.mu"
on_submit "search"
# $query and $scope are now populated from submitted form data
source results : shell "search_index.py '$query' --scope '$scope'"
heading 2 "Results for: $query"
if $results
text "$results"
else
text "No results found."
```
The generated Python script handles this as:
```python
#!/usr/bin/env python3
#!c=0
import os, subprocess
# Read submitted form data
query = os.environ.get("FIELD_query", "")
scope = os.environ.get("FIELD_scope", "Local")
if query:
# Form was submitted — render results
results = subprocess.check_output(
["search_index.py", query, "--scope", scope]
).decode().strip()
# ... render results template with Micron ...
else:
# No submission — render the form
# ... render form template with Micron ...
```
### 3.3 Conditional Rendering — `if` / `else` / `elif`
```
source disk_pct : shell "df / | awk 'NR==2{print int($5)}'"
if $disk_pct > 90
box heavy "DISK CRITICAL"
color f00
gauge "Disk" $disk_pct 100 40 crit=90
text "@bold{@color{f00}{Immediate action required!}}"
elif $disk_pct > 75
box light "Disk Warning"
color ff0
gauge "Disk" $disk_pct 100 40 warn=75
else
gauge "Disk" $disk_pct 100 40
```
### 3.4 Iteration — `for` loops
```
source peer_json : shell "rnstatus --json-peers"
heading 2 "Active Peers"
for peer in $peer_json
row 2
col 30
text "$peer.name"
col 10
status "$peer.name" $peer.state
col 10
text "$peer.latency"
```
The `for` construct works with JSON arrays or newline-delimited
text from shell commands.
### 3.5 State Persistence — `state` blocks
Since each page request is a fresh script execution, state must
be stored externally. µFrame provides a simple key-value store
backed by a JSON file on the node:
```
state "counter" "/tmp/uframe_counter.json"
# Read
let visits = $counter.visits || 0
# Write (increments on each page load)
set counter.visits = $visits + 1
text "This page has been viewed $counter.visits times."
```
For form-driven state (e.g., a guestbook):
```
state "guestbook" "/var/nomadnet/guestbook.json"
form "sign"
field "name" 20 "Your name..."
field "message" 40 "Your message..."
button "Sign" "/page/guestbook.mu"
on_submit "sign"
append guestbook.entries { name: $name, message: $message, time: $timestamp }
text "@color{0f0}{Thanks, $name! Your message has been saved.}"
heading 2 "Guestbook ($guestbook.entries.length entries)"
for entry in $guestbook.entries
box light
text "@bold{$entry.name} — @italic{$entry.time}"
text "$entry.message"
spacer
```
### 3.6 Page Navigation with Data — `link` with parameters
Links can pass data to the target page via query-style encoding:
```
# Simple navigation
link "Home" "/page/index.mu"
# Navigation with parameters
link "View Peer $peer.name" "/page/peer_detail.mu?hash=$peer.hash"
# In the target page, access with:
source peer_hash : param "hash"
```
### 3.7 Cache Control
```
page "Dashboard" 64
cache 0 # never cache — always re-execute
# cache 60 # cache for 60 seconds
# cache none # alias for 0
source cpu : shell "..."
...
```
Translates to the Micron header `#!c=0` in the first line of output.
---
## 4. Compilation Pipeline — .uf to Executable .mu
### 4.1 What the compiler generates
A `.uf` file with dynamic features compiles into a Python script
that:
1. Sets the shebang and cache header
2. Imports required modules + the `uframe` package
3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
4. Executes source commands and evaluates conditionals/loops
5. Dynamically builds a `.uf` source string with resolved variables
6. Compiles that source with `uframe.compile()` at runtime
7. Prints the resulting Micron to stdout
```python
#!/usr/bin/env python3
# Auto-generated by uFrame
# Do not edit — regenerate with: uframe compile <source>.uf
import os, sys, json, subprocess, datetime, secrets, shlex
from datetime import datetime as _dt_cls, timedelta
# ─── 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."""
# ...
def _read_json(path):
"""Read and parse JSON file."""
# ...
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."""
# ...
def _save_state(path, data):
"""Save state to JSON file."""
# ...
def _iter(val):
"""Make a value iterable for for-loops."""
# handles lists, dicts, newline-delimited strings
# ─── µFrame Compile ──────────────────────────────────────────
import uframe
# ─── Page Logic ──────────────────────────────────────────────
_cache_seconds = 0
_uf_source_parts = []
cpu_pct = eval('secrets.randbelow(60) + 20', {'datetime': _dt_cls, ...})
timestamp = eval("datetime.now().strftime('%H:%M:%S')", {'datetime': _dt_cls, ...})
_uf_source_parts.append(f'heading 1 "Resources"')
_uf_source_parts.append(f'gauge "CPU" {cpu_pct} 100 28 warn=75.0 crit=90.0')
_uf_source_parts.append(f'text "Updated: {timestamp}"')
if cpu_pct > 90:
_uf_source_parts.append(f'text "ALERT: CPU critical"')
# ─── Render & Output ─────────────────────────────────────────
_uf_source = f'''page "Live Status" 60
''' + "\n".join(_uf_source_parts)
result = uframe.compile(_uf_source, width=60)
if _cache_seconds >= 0:
print(f"#!c={_cache_seconds}")
print(result.micron)
```
The key insight: the generated script **rebuilds `.uf` source** with
live data substituted in, then compiles it with the full µFrame
pipeline. This means every layout feature (boxes, gauges, tables,
sparklines) works identically in both static and dynamic pages.
### 4.2 CLI usage
```bash
# Compile to dynamic executable .mu
uframe compile dashboard.uf --out dashboard.mu
chmod +x dashboard.mu
# Compile and deploy in one step
uframe deploy dashboard.uf
# → renders, sets +x, copies to ~/.nomadnetwork/storage/pages/
# Compile with embedded vs. imported runtime
uframe compile dashboard.uf --embed # single self-contained file
uframe compile dashboard.uf --import # requires uframe_runtime.py on node
```
### 4.3 Compilation modes
```
┌────────────────────────────────────────────────────────────────┐
│ µFrame Compilation Modes │
├────────────────┬───────────────┬───────────────────────────────┤
│ uframe render │ uframe render │ uframe compile │
│ --ascii │ --micron │ │
├────────────────┼───────────────┼───────────────────────────────┤
│ Static ASCII │ Static .mu │ Executable .mu (Python) │
│ to stdout │ file │ file with +x │
├────────────────┼───────────────┼───────────────────────────────┤
│ All values │ All values │ source{} values fetched │
│ resolved at │ resolved at │ at request time │
│ render time │ render time │ │
│ │ │ Form fields become live │
│ │ │ on_submit{} blocks active │
│ │ │ if/for evaluated per request │
│ │ │ State persists across visits │
└────────────────┴───────────────┴───────────────────────────────┘
```
---
## 5. Complete Dynamic Example
### 5.1 Source — `search_node.uf`
```
page "Node Search" 64
cache 0
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source peer_count : shell "rnstatus 2>/dev/null | grep -c 'Peer'"
state "history" "/var/nomadnet/search_history.json"
box double "Node Search"
align center
text "Find peers and pages on the Reticulum mesh"
text "@italic{$peer_count peers reachable · updated $timestamp}"
spacer
form "search"
field "query" 30 "Search term..."
radio "type" "Nodes" | "Pages" | "Files"
checkbox "cache" "Include cached results"
button "Search" "/page/search_node.mu"
on_submit "search"
# Log the search
append history.queries { q: $query, type: $type, time: $timestamp }
source results : shell "mesh_search.py '$query' --type '$type'"
source result_count : python "len('''$results'''.strip().splitlines())"
divider light
heading 2 "Results for \"$query\" ($result_count found)"
if $result_count > 0
for line in $results
source parts : python "'''$line'''.split('|')"
row 1
col 28
link "$parts.0" "/page/detail.mu?hash=$parts.1"
col 8
text "$parts.2"
col 10
status "$parts.0" $parts.3
else
spacer
text "@center{@color{ff0}{No results found for \"$query\"}}"
spacer
divider light
heading 3 "Recent Searches"
for entry in $history.queries[-5:]
text " $entry.time $entry.q ($entry.type)"
divider heavy
text "@center{@italic{Relay Alpha-7 · $timestamp}}"
```
### 5.2 What the client sees
**Before submission** (form is empty):
```
╔══ Node Search ═══════════════════════════════════════════════╗
║ Find peers and pages on the Reticulum mesh ║
║ 7 peers reachable · updated 14:32:07 ║
╚══════════════════════════════════════════════════════════════╝
Search: [ Search term...___________________ ]
Type: (•) Nodes ( ) Pages ( ) Files
[ ] Include cached results
`[`!Search`!`:/page/search_node.mu]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Relay Alpha-7 · 14:32:07
```
**After submitting "relay"** (script re-executes with FIELD_query=relay):
```
╔══ Node Search ═══════════════════════════════════════════════╗
║ Find peers and pages on the Reticulum mesh ║
║ 7 peers reachable · updated 14:32:15 ║
╚══════════════════════════════════════════════════════════════╝
Search: [ relay__________________________ ]
Type: (•) Nodes ( ) Pages ( ) Files
[ ] Include cached results
`[`!Search`!`:/page/search_node.mu]
──────────────────────────────────────────────────────────────
>> Results for "relay" (3 found)
`F0cfRelay-East`f 2 hops `F0f0●`f online
`F0cfRelay-South`f 4 hops `F0f0●`f online
`F0cfRelay-Backup`f 6 hops `Fff0◐`f degraded
──────────────────────────────────────────────────────────────
>>> Recent Searches
14:32:15 relay (Nodes)
14:28:44 bridge (Pages)
14:25:01 firmware (Files)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Relay Alpha-7 · 14:32:15
```
The form fields retain submitted values, results appear below,
and the search history persists across visits via the state file.
Every piece of box-drawing, every colored indicator, every braille
sparkline renders identically in both ASCII preview and live Micron.
---
## 6. Dynamic Patterns — A Cookbook
### 6.1 Live Dashboard (auto-refresh via cache=0)
```
page "Status" 64
cache 0
source cpu : python "secrets.randbelow(60) + 20"
source mem : python "secrets.randbelow(40) + 50"
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
box heavy "System Status"
row 2
gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95
spacer
label "Uptime" "$uptime"
label "Updated" "$timestamp"
text "@center{@italic{Press Ctrl+R to refresh}}"
```
Client hits the page → script runs → evaluates sources →
renders gauges with live data → client sees it.
Ctrl+R re-requests → fresh execution → updated values.
On a full Linux node, replace the python sources with shell commands
to read real system data:
```
source cpu : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
```
### 6.2 Guestbook with Persistent State
```
page "Guestbook" 64
cache 0
state "gb" "/var/nomadnet/guestbook.json"
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
heading 1 "Guestbook"
form "sign"
field "name" 20 "Your name"
field "msg" 40 "Leave a message..."
button "Sign" "/page/guestbook.mu"
on_submit "sign"
if $name && $msg
prepend gb.entries { name: $name, msg: $msg, time: $timestamp }
text "@color{0f0}{✓ Thanks, $name!}"
divider light
for entry in $gb.entries[:20]
box rounded
text "@bold{$entry.name} @italic{@color{888}{$entry.time}}"
text "$entry.msg"
spacer
```
### 6.3 Multi-Page Wizard with Navigation
```
# Page 1: setup.mu
page "Setup Wizard — Step 1" 64
cache 0
heading 1 "Network Configuration"
form "net"
field "interface" 20 "eth0"
radio "mode" "Auto" | "Manual" | "Mesh Only"
button "Next →" "/page/setup_2.mu"
# Page 2: setup_2.mu
page "Setup Wizard — Step 2" 64
cache 0
source iface : param "interface" # or field from previous page
source mode : param "mode"
heading 1 "Confirm Settings"
label "Interface" "$iface"
label "Mode" "$mode"
form "confirm"
checkbox "apply_now" "Apply immediately"
button "← Back" "/page/setup.mu"
button "Finish ✓" "/page/setup_done.mu?interface=$iface&mode=$mode"
```
### 6.4 Chat Room (community pattern)
```
page "Chat" 64
cache 0
state "chat" "/var/nomadnet/chatlog.json"
source timestamp : python "datetime.now().strftime('%H:%M')"
heading 1 "Node Chat"
# Display last 15 messages
for msg in $chat.messages[-15:]
text "@bold{@color{$msg.color}{$msg.nick}} @color{888}{$msg.time}"
text " $msg.text"
divider light
form "send"
field "nick" 12 "Nickname"
field "text" 40 "Type message..."
button "Send" "/page/chat.mu"
on_submit "send"
if $nick && $text
source color : python "format(hash('$nick')%4095,'03x')"
append chat.messages { nick: $nick, text: $text, time: $timestamp, color: $color }
text "@center{@italic{@color{888}{Ctrl+R to refresh · $chat.messages.length messages}}}"
```
### 6.5 Interactive Data Explorer
```
page "Peer Explorer" 64
cache 0
source peers_json : shell "rnstatus --json 2>/dev/null"
source selected : param "hash"
heading 1 "Peer Explorer"
table "Peers"
columns "Name" 24 | "Hops" 6 | "RTT" 8 | "Status" 10
for peer in $peers_json.peers
row "$peer.name" | "$peer.hops" | "$peer.rtt" | "$peer.status"
if $selected
divider heavy
source detail : shell "rnstatus --peer $selected --json 2>/dev/null"
box double "Peer Detail: $detail.name"
label "Hash" "$detail.hash"
label "Address" "$detail.address"
label "Hops" "$detail.hops"
label "Latency" "$detail.rtt"
label "Last Seen" "$detail.last_seen"
label "Transport" "$detail.transport"
sparkline "Latency (24h)" $detail.latency_history 40
row 2
link "Ping" "/page/action.mu?cmd=ping&hash=$detail.hash"
link "Trace" "/page/action.mu?cmd=trace&hash=$detail.hash"
link "Browse" "$detail.hash:/page/index.mu"
```
---
## 7. Security Considerations
Dynamic pages execute code on the node server. µFrame enforces:
- **Shell command sanitization**: all `$variable` values interpolated
into shell commands are escaped with `shlex.quote()` to prevent
injection
- **State file isolation**: state files are restricted to a
configurable directory (default: `/var/nomadnet/uframe/`)
- **Execution timeout**: all shell sources have a default 5-second
timeout, configurable per source
- **No network egress by default**: source commands run in the
node's local context — they can read local system data but
µFrame does not add network capabilities beyond what the
scripts themselves invoke
- **Input validation**: field values are length-limited and
sanitized before use in sources or state operations
```
# In the DSL, explicit sanitization:
source result : shell "search.py" --arg $query --sanitize
timeout 10
max_length 256
```
---
## 8. Architecture Summary
```
┌──────────────────────────────┐
│ .uf Source │
│ (layout + sources + forms │
│ + conditionals + state) │
└──────────────┬───────────────┘
┌────▼────┐
│ Parse │
└────┬────┘
┌────▼────┐
│ IR │
│ Tree │
└──┬───┬──┘
│ │
┌──────────────┘ └────────────────┐
│ │
┌──────▼──────┐ ┌───────▼───────┐
│ render mode │ │ compile mode │
│ (immediate) │ │ (codegen) │
└──┬───────┬──┘ └───────┬───────┘
│ │ │
┌──────▼┐ ┌───▼─────┐ ┌───────▼────────┐
│ ASCII │ │ Static │ │ Executable .mu │
│ stdout│ │ .mu file│ │ Python script │
└───────┘ └─────────┘ │ with embedded │
│ runtime + │
│ data sources + │
│ form handling │
└───────┬────────┘
chmod +x
deploy to
┌─────────▼─────────┐
│ NomadNet Node │
│ ~/.nomadnetwork/ │
│ storage/pages/ │
│ │
│ Client request → │
│ Execute script → │
│ Stdout = Micron │
│ with live data, │
│ rich ASCII art, │
│ color + forms │
└───────────────────┘
```
The dynamic model turns µFrame from a static template engine into a
**full application framework for NomadNet** — where the same DSL that
defines the visual layout also defines the data flow, user interaction,
and server-side logic. The ASCII art isn't decoration — it's the UI
of a live, interactive, decentralized application running over
encrypted mesh networks.

889
docs/framework-design-v3.md Normal file
View File

@@ -0,0 +1,889 @@
# µFrame — A DSL for Rich Terminal UIs rendered as ASCII and Micron
## Overview
**µFrame** is a declarative DSL that compiles to rich terminal
interfaces built from Unicode box-drawing, block elements, braille
patterns, and careful spatial layout. It produces two outputs from
the same source:
1. **Plain ASCII** — the raw visual layout, viewable in any terminal
2. **Micron `.mu`** — the same visual layout enhanced with Micron's
color, styling, links, and interactive form fields
Both outputs share the same rich character art. Micron doesn't
degrade the visuals — it *elevates* them. The ASCII art passes
through verbatim into the `.mu` file, and Micron tags wrap it
with color, emphasis, alignment, and interactivity that plain
text cannot express.
```
┌──────────────────────────────────┐
┌───▶│ Plain ASCII │
│ │ Box drawing, braille, blocks │
│ │ No color, no links, no forms │
.uf ──▶ Parser ──▶ IR ─┤ └──────────────────────────────────┘
│ ┌──────────────────────────────────┐
└───▶│ Micron .mu │
│ Same visual base │
│ + `Fhex color`f │
│ + `!bold`! `*italic`* │
│ + `[links`/dest] │
│ + `<form fields`> │
│ + `c alignment`a │
└──────────────────────────────────┘
```
The relationship is additive:
```
Plain ASCII = layout + structure + data viz
Micron = layout + structure + data viz + color + style + interaction
```
---
## 1. Design Philosophy
### The terminal is the canvas — in both modes
A NomadNet node browser is a terminal. A local shell is a terminal.
The character grid is the shared substrate. Unicode box-drawing,
block elements, and braille dots render identically in both
contexts. µFrame exploits this fully:
- **Box drawing** (`┌─┐│└┘`) creates bordered panels, tables,
nested layouts — same characters in ASCII and Micron
- **Block elements** (`█▉▊▋▌▍▎▏░▒▓`) build bar charts, gauges,
heatmaps — passed through as literal text in Micron
- **Braille** (```⣿`, 256 patterns) gives 2×4 sub-cell resolution
for sparklines and dot plots — just text, works everywhere
- **Micron then paints on top**: colored bars, highlighted thresholds,
bold headers, clickable links, interactive form fields
### What Micron adds beyond ASCII
Micron's tag system maps perfectly onto the styling layer that
plain ASCII lacks:
| Capability | Plain ASCII | Micron |
|--------------------|---------------------|---------------------------------------|
| Borders & boxes | ✓ box-drawing chars | ✓ same chars + colored with `Fhex` |
| Bar charts | ✓ block elements | ✓ same blocks + colored thresholds |
| Sparklines | ✓ braille dots | ✓ same braille + colored |
| Status indicators | ✓ ● ○ ◐ chars | ✓ same chars + `F0f0` green/red |
| Table data | ✓ monospace align | ✓ same alignment + bold headers |
| Emphasis | ✗ (no mechanism) | ✓ `!bold`! `*italic`* `_underline`_ |
| Color | ✗ (no mechanism)* | ✓ `Fhex text`f / `Bhex text`b |
| Alignment | manual spacing | ✓ `c center`a / `r right`a |
| Links | ✗ display only | ✓ `[click here`/page.mu] |
| Text input | ✗ display only | ✓ `<name`placeholder> |
| Radio / checkbox | ✗ visual only | ✓ `<^|group|val`label> `<?|..`label> |
| Headings | manual styling | ✓ `>` / `>>` / `>>>` with styling |
*ASCII mode can optionally emit ANSI escape codes with `--ansi`,
but the default is pure text.
---
## 2. The Rendering Model
Both renderers share a common **CharGrid** — a 2D matrix of
characters that represents the visual layout. They diverge only
in the final emission step.
```
┌──────────┐
.uf ──▶ Parse ──▶│ IR Tree │
└────┬─────┘
┌────▼─────┐
│ Layout │ ◀── width resolution, row splits,
│ Engine │ border merging, chart rendering
└────┬─────┘
┌────▼─────┐
│ CharGrid │ ◀── 2D array of (char, style) pairs
│ + Styles │ style = {fg, bg, bold, italic,
└──┬────┬──┘ underline, link, field_meta}
│ │
┌───────▼┐ ┌▼─────────┐
│ ASCII │ │ Micron │
│ Emitter │ │ Emitter │
└─────────┘ └──────────┘
chars only chars + tags
```
### The CharGrid
Every cell in the grid stores:
```
Cell:
char : string # the visible character (e.g. "█", "┌", "⣿")
fg : string|null # foreground color, 3-digit hex
bg : string|null # background color, 3-digit hex
bold : bool
italic : bool
underline : bool
link : string|null # destination path for clickable cells
field : FieldMeta|null # form field metadata for interactive cells
```
### ASCII Emitter
Reads only `cell.char` from each cell. Produces a plain text file.
With `--ansi`, reads `fg`, `bg`, `bold`, `italic`, `underline`
and emits ANSI escape codes.
### Micron Emitter
Reads every cell property. Scans each line left-to-right, tracks
style state, and opens/closes Micron tags at style transitions:
```
Line scan: ┌── gauge "CPU" ──────────┐
Chars: C P U █ █ █ ░ ░ 6 2 %
Styles: bold fg:0f0 fg:f00
↓ ↓
Micron: `!CPU`! `F0f0███`f`Ff00░░`f `Ff0062%`f
```
This means the exact same box-drawing layout appears in both
outputs. The Micron version simply has color and emphasis tags
woven between the same characters.
---
## 3. Visual Primitives — The Shared Toolkit
Everything below renders identically in both ASCII and Micron.
The Micron output adds color/style annotations on top.
### 3.1 Box Drawing
Four border weights:
```
Light Heavy Double Rounded
┌──────┐ ┏━━━━━━┓ ╔══════╗ ╭──────╮
│ │ ┃ ┃ ║ ║ │ │
└──────┘ ┗━━━━━━┛ ╚══════╝ ╰──────╯
```
Nested boxes with automatic junction merging:
```
┌─────────────────┬──────────┐
│ Left Panel │ Right │
│ │ │
├─────────────────┴──────────┤
│ Footer spans full width │
└────────────────────────────┘
```
Titled boxes (title inlined in top border):
```
┌─ System Health ─────────────────────────┐
│ │
└─────────────────────────────────────────┘
┏━ ALERT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
```
In Micron, the border characters are plain text. The title
can be wrapped in `!bold`! and `Fhex color`f tags.
### 3.2 Data Visualization
**Horizontal bars** — block elements with optional color thresholds:
```
ASCII: CPU ████████████████████░░░░░░░░░░ 62%
Micron: CPU `F0f0████████████████████`f`F333░░░░░░░░░░`f 62%
↑ green fill ↑ dim empty
```
**Gauges with thresholds** — color shifts at warn/crit boundaries:
```
ASCII: MEM █████████████████████████████░ 93% ⚠
Micron: MEM `Ff00█████████████████████████████`f░ `Ff00 93% ⚠`f
↑ red because value > crit threshold
```
**Vertical bar charts**:
```
ASCII: Micron adds color per bar:
█ `F08f█`f
█ █ `F08f█`f `F0f0█`f
█ █ █ █ `F08f█`f `Fff0█`f `F0f0█`f `Fff0█`f
█ █ █ █ █ █ `F08f█`f `Fff0█`f █ `F0f0█`f █ `Fff0█`f
█ █ █ █ █ █ █ █ ...
─────────────────
0 3 6 9 12 15
```
**Sparklines** — braille characters for inline time series:
```
ASCII: NET ⣀⣤⣶⣿⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤⣀ avg 31%
Micron: NET `F0ff⣀⣤⣶⣿⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤⣀`f avg 31%
↑ cyan sparkline
```
**Heatmap** — using shade blocks with per-cell color:
```
Mon `F0f0░`f`F0f0░`f`F4f0▒`f`F8f0▓`f`Fff0█`f`F8f0▓`f`F4f0▒`f
Tue `F4f0▒`f`F8f0▓`f`Fff0█`f`Fff0█`f`Fff0█`f`F8f0▓`f`F4f0▒`f
Wed `F0f0░`f`F0f0░`f`F0f0░`f`F4f0▒`f`F4f0▒`f`F0f0░`f`F0f0░`f
0 4 8 12 16 20 24
```
In plain ASCII, same characters, no color — the shade density
still communicates intensity.
**Status indicators** — colored in Micron, shape-coded in ASCII:
```
ASCII: ● Online ○ Offline ◐ Degraded ◌ Unknown
Micron: `F0f0●`f Online `Ff00○`f Offline `Fff0◐`f Degraded
```
Both modes are readable — color adds clarity but shape carries
the information alone.
### 3.3 Tables
Tables use box-drawing for structure. Identical in both outputs.
Micron adds bold headers and colored status cells:
```
ASCII:
┌──────────────────────┬──────┬─────────┬──────────┐
│ Destination │ Hops │ Latency │ Status │
├──────────────────────┼──────┼─────────┼──────────┤
│ a7f2::relay-east │ 2 │ 34ms │ ● alive │
│ c4e1::bridge-south │ 4 │ 112ms │ ● alive │
│ 01ab::node-gamma │ 7 │ 580ms │ ○ stale │
└──────────────────────┴──────┴─────────┴──────────┘
Micron:
┌──────────────────────┬──────┬─────────┬──────────┐
│ `!Destination`! │`!Hops`!│`!Latency`!│`!Status`!│
├──────────────────────┼──────┼─────────┼──────────┤
│ a7f2::relay-east │ 2 │ 34ms │ `F0f0●`f alive │
│ c4e1::bridge-south │ 4 │ 112ms │ `F0f0●`f alive │
│ 01ab::node-gamma │ 7 │ 580ms │ `Ff00○`f stale │
└──────────────────────┴──────┴─────────┴──────────┘
```
### 3.4 Form Elements
In ASCII, forms are visual representations. In Micron, the same
characters appear but the input areas become live interactive
fields.
```
ASCII (visual only):
┌─ Search ──────────────────────────────┐
│ │
│ Query: [ _________________________ ] │
│ │
│ Scope: (•) Local ( ) Network │
│ [ ] Include offline nodes │
│ │
│ ┌──────────┐ │
│ │ Search │ │
│ └──────────┘ │
└───────────────────────────────────────┘
Micron (interactive):
┌─ `!Search`! ──────────────────────────┐
│ │
│ Query: `<32|query`Enter search...> │
│ │
│ Scope: `<^|scope|local|*`Local> `<^|scope|net`Network>
│ `<?|offline|yes`Include offline nodes>
│ │
│ `[`!Search`!`:/action/search]
│ │
└───────────────────────────────────────┘
```
The border characters are identical. Micron replaces the
placeholder bracket notation with live form tags and turns
the button into a clickable link.
---
## 4. The DSL — `.uf` Files
### 4.1 Syntax
- **Indentation** defines nesting (2-space)
- **Keywords** lead each line
- **Strings** in double quotes
- **Pipe `|`** separates inline list items
- **`$`** references variables
- **`#`** starts comments
### 4.2 Layout Primitives
```
page "Title" [width]
# Root container. Default width: 64.
box [weight] "Title"
# Bordered region. weight: light|heavy|double|rounded
# Title is inset in the top border.
# In Micron: title gets `!bold`!, border chars are literal.
row [gap]
# Horizontal layout. Children split available width.
# gap: chars between children (default: 1)
col [width]
# Explicit column in a row. Width in chars or percentage.
spacer [lines]
# Vertical whitespace. Default: 1
pad [top] [right] [bottom] [left]
# Inner margin for a container.
```
### 4.3 Content Primitives
```
heading [1|2|3] "Text"
# Rendered with underline/box in ASCII.
# In Micron: > / >> / >>> plus `!bold`!
text "Content with @bold{inline} @color{0f0}{modifiers}"
# @bold{...} → Micron `!...`!
# @italic{...} → Micron `*...*`
# @under{...} → Micron `_..._`
# @color{hex}{...} → Micron `Fhex...`f
# @bg{hex}{...} → Micron `Bhex...`b
# In ASCII: modifiers stripped (or ANSI with --ansi)
label "Key" "Value"
# Aligned key-value pair.
list [bullet|number|dash|arrow]
item "First"
item "Second"
link "Display text" "/destination.mu"
# ASCII: [Display text]
# Micron: `[Display text`/destination.mu]
divider [light|heavy|double|dash|dot]
# Full-width horizontal rule using appropriate chars.
```
### 4.4 Data Visualization Primitives
```
gauge "Label" [value] [max] [width]
# Horizontal progress bar.
# Thresholds: warn=[n] crit=[n]
# ASCII: Label ████████████░░░░ 62%
# Micron: same, with color shifts at thresholds
meter "Label" [value] [max]
# Compact inline gauge (no border, just bar + %)
bar_h "Label" [value] [max] [width]
# Single horizontal bar in a chart context.
bar_v [height]
# Vertical bar chart container.
bar "Label" [value]
bar "Label" [value]
# Uses ▁▂▃▄▅▆▇█ stacked vertically.
sparkline "Label" [values] [width]
# Braille-dot inline chart.
# values: comma-separated or $variable
heatmap [rows] [cols]
# Grid of colored shade blocks.
# Uses ░▒▓█ for intensity.
# Micron adds per-cell `Fhex` color.
status "Label" [online|offline|degraded|unknown|alert]
# Shape-coded indicator + label.
# Micron adds color to the indicator.
table "Title"
columns "Name" [width] | "Name" [width] | ...
row "val" | "val" | ...
# Box-drawn table with header separator.
# Micron: bold headers, colored cells via @modifiers in values.
```
### 4.5 Form Primitives
```
form "name"
field "name" [width] "placeholder"
password "name" [width] "placeholder"
radio "group" "Opt A" | "Opt B" | "Opt C"
checkbox "name" "Label"
toggle "name" "Label" [on|off]
dropdown "name" "Opt A" | "Opt B" | "Opt C"
button "Label" ["/action/path"]
# ASCII: visual placeholders (brackets, radio dots, etc.)
# Micron: live interactive fields using native form tags
```
### 4.6 Style Modifiers
Applied as indented children of any node:
```
align [left|center|right]
color [3-digit hex]
bg [3-digit hex]
border [light|heavy|double|rounded|none]
bold
italic
underline
```
### 4.7 Variables & Components
```
# Variables
let name = "Relay Alpha-7"
let cpu_data = 42, 67, 55, 78, 91, 63, 48
# Component definition
component stat(label, value, max, trend)
box light "$label"
gauge "$label" $value $max 20
text "@italic{$trend}"
# Component usage
row 2
stat "CPU" 62 100 "▲ +5%"
stat "MEM" 84 100 "▼ -2%"
```
---
## 5. Complete Example
### 5.1 Source
```
let node = "Relay Alpha-7"
let uptime = "14d 3h 22m"
page "$node" 66
box double "$node"
align center
text "Reticulum Network Node"
text "Online $uptime"
spacer
heading 1 "Resources"
row 2
col
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "GPU" 21 100 28
col
gauge "MEM" 84 100 28 warn=80 crit=95
gauge "SWP" 3 100 28
spacer
heading 1 "Network"
row 2
col 40
text "Traffic (60s)"
sparkline "IN" 1,3,5,8,7,5,3,2,1,3,6,8,7,4 20
sparkline "OUT" 2,2,3,5,8,7,5,3,2,1,1,3,5,8 20
col
label "Peers" "7 / 12"
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
spacer
heading 1 "Routing"
table "Routes"
columns "Destination" 22 | "Hops" 6 | "RTT" 8 | "State" 10
row "a7f2::relay-east" | "2" | "34ms" | "@color{0f0}{● alive}"
row "c4e1::bridge-south" | "4" | "112ms" | "@color{0f0}{● alive}"
row "01ab::node-gamma" | "7" | "580ms" | "@color{f00}{○ stale}"
row "f390::hub-north" | "1" | "8ms" | "@color{0f0}{● alive}"
spacer
heading 1 "Actions"
box rounded "Quick Command"
form "cmd"
field "target" 30 "Destination hash..."
radio "mode" "Ping" | "Trace" | "Page"
checkbox "verbose" "Verbose output"
button "Execute" "/action/exec"
divider heavy
text "@center{© 2026 $node · Reticulum Network}"
```
### 5.2 ASCII Output
```
╔══ Relay Alpha-7 ═════════════════════════════════════════════════╗
║ Reticulum Network Node ║
║ Online 14d 3h 22m ║
╚══════════════════════════════════════════════════════════════════╝
── Resources ─────────────────────────────────────────────────────
CPU ████████████████████░░░░░░░░ 62% MEM █████████████████████████░░ 84% ⚠
GPU ██████░░░░░░░░░░░░░░░░░░░░░ 21% SWP █░░░░░░░░░░░░░░░░░░░░░░░░░ 3%
── Network ───────────────────────────────────────────────────────
Traffic (60s) Peers: 7 / 12
IN ⣀⣤⣶⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤ 4.2 KB/s East Relay ● online
OUT ⣀⣀⣠⣤⣶⣿⣷⣶⣤⣀⣀⣠⣤⣶⣿ 2.1 KB/s South Bridge ● online
Node Gamma ◐ degraded
── Routing ───────────────────────────────────────────────────────
┌────────────────────────┬────────┬──────────┬────────────┐
│ Destination │ Hops │ RTT │ State │
├────────────────────────┼────────┼──────────┼────────────┤
│ a7f2::relay-east │ 2 │ 34ms │ ● alive │
│ c4e1::bridge-south │ 4 │ 112ms │ ● alive │
│ 01ab::node-gamma │ 7 │ 580ms │ ○ stale │
│ f390::hub-north │ 1 │ 8ms │ ● alive │
└────────────────────────┴────────┴──────────┴────────────┘
── Actions ───────────────────────────────────────────────────────
╭─ Quick Command ──────────────────────────────────────────────╮
│ │
│ target: [ Destination hash...________________ ] │
│ mode: (•) Ping ( ) Trace ( ) Page │
│ [ ] Verbose output │
│ ┌───────────┐ │
│ │ Execute │ │
│ └───────────┘ │
╰──────────────────────────────────────────────────────────────╯
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
© 2026 Relay Alpha-7 · Reticulum Network
```
### 5.3 Micron Output
The **same characters** — every box corner, every bar segment, every
braille dot — with Micron tags added for color and interactivity:
```
╔══ `!Relay Alpha-7`! ═════════════════════════════════════════════╗
║`c Reticulum Network Node`a ║
║`c Online 14d 3h 22m`a ║
╚══════════════════════════════════════════════════════════════════╝
>Resources
`!CPU`! `F0f0████████████████████`f`F555░░░░░░░░`f 62% `!MEM`! `Ff80█████████████████████████`f`F555░░`f `Ff8084%`f ⚠
`!GPU`! `F0f0██████`f`F555░░░░░░░░░░░░░░░░░░░░░`f 21% `!SWP`! `F0f0█`f`F555░░░░░░░░░░░░░░░░░░░░░░░░░`f 3%
>Network
`!Traffic (60s)`! `!Peers:`! 7 / 12
IN `F0cf⣀⣤⣶⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤`f 4.2 KB/s East Relay `F0f0●`f online
OUT `F0cf⣀⣀⣠⣤⣶⣿⣷⣶⣤⣀⣀⣠⣤⣶⣿`f 2.1 KB/s South Bridge `F0f0●`f online
Node Gamma `Fff0◐`f degraded
>Routing
┌────────────────────────┬────────┬──────────┬────────────┐
│ `!Destination`! │ `!Hops`! │ `!RTT`! │ `!State`! │
├────────────────────────┼────────┼──────────┼────────────┤
│ a7f2::relay-east │ 2 │ 34ms │ `F0f0●`f alive │
│ c4e1::bridge-south │ 4 │ 112ms │ `F0f0●`f alive │
│ 01ab::node-gamma │ 7 │ 580ms │ `Ff00○`f stale │
│ f390::hub-north │ 1 │ 8ms │ `F0f0●`f alive │
└────────────────────────┴────────┴──────────┴────────────┘
>Actions
╭─ `!Quick Command`! ──────────────────────────────────────────╮
│ │
│ target: `<30|target`Destination hash...> │
│ mode: `<^|mode|ping|*`Ping> `<^|mode|trace`Trace> `<^|mode|page`Page>
│ `<?|verbose|yes`Verbose output> │
│ `[`!Execute`!`:/action/exec]
│ │
╰──────────────────────────────────────────────────────────────╯
-━
`c© 2026 Relay Alpha-7 · Reticulum Network`a
```
Note how the table borders, box corners, and gauge characters
are **byte-for-byte identical** in both outputs. Micron simply
interleaves its backtick tags around the characters that need
color or emphasis.
---
## 6. Intermediate Representation
### IR Node
```
IRNode:
type : NodeType
label : string | null
children : IRNode[]
styles : {
fg : string | null # 3-digit hex
bg : string | null
bold : bool
italic : bool
underline: bool
align : left | center | right
border : light | heavy | double | rounded | none
}
layout : {
width : int | pct | null
height : int | null
gap : int
pad : [top, right, bottom, left]
}
data : { # type-specific
value : number | null
max : number | null
warn : number | null
crit : number | null
values : number[] | null # sparkline, bar_v
state : enum | null # status indicator
options : string[] | null # radio, dropdown
columns : Column[] | null # table
rows : Row[] | null # table
link : string | null # destination
field : FieldMeta | null # form metadata
}
inline : InlineSpan[] # parsed @modifiers
```
### The CharGrid
```
CharGrid:
width : int
height : int
cells : Cell[height][width]
Cell:
char : char # visible character
style : CellStyle # for Micron emission
field : FieldMeta? # if this cell is part of a form field
link : string? # if this cell is clickable
CellStyle:
fg : string? # 3-digit hex
bg : string?
bold : bool
italic : bool
underline : bool
```
The layout engine fills the CharGrid. Both emitters read it.
The ASCII emitter ignores the style layer. The Micron emitter
scans for style transitions and inserts tags.
---
## 7. Rendering Pipeline
```
Phase 1: Parse
.uf source → token stream → IR tree
Variables resolved, components expanded.
Phase 2: Measure
Bottom-up pass: compute min/preferred width and height
for each node. Leaf nodes (text, gauge, field) report
their intrinsic sizes. Containers aggregate children.
Phase 3: Layout
Top-down pass: assign (x, y, w, h) to every node.
Row nodes divide width among columns.
Box nodes reserve border characters (1 char each side).
Phase 4: Paint
Depth-first traversal. Each node writes characters into
the CharGrid at its assigned position:
- Box: draw border chars, set title style
- Gauge: compute bar length, write █ and ░, set fg color
based on thresholds
- Sparkline: convert values to braille patterns
- Table: draw grid, write cell content, set header bold
- Form: write visual placeholders, attach FieldMeta
- Status: write indicator char, set color by state
Phase 5: Merge Borders
Post-pass: scan for adjacent border characters and replace
with correct junction characters (┬ ┴ ├ ┤ ┼ etc.).
Weight priority: double > heavy > light > rounded.
Phase 6: Emit
ASCII: read cell.char for every cell, join into lines.
Micron: scan each line, diff style between adjacent cells,
open/close Micron tags at transitions.
```
### Border Merging Detail
```
Before: After:
┌────┐┌────┐ ┌────┬────┐
│ ││ │ ──▶ │ │ │
└────┘└────┘ └────┴────┘
┌────────┐ ┌────────┐
│┌──────┐│ ├──────┐ │ (nested box shares
││ ││ ──▶ │ │ │ parent left edge)
│└──────┘│ ├──────┘ │
└────────┘ └────────┘
```
The merging pass checks each cell against its 4 neighbors
and selects from a lookup table of ~40 junction characters.
---
## 8. Character Reference
### Boxes
```
Light: ┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼
Heavy: ┏ ━ ┓ ┃ ┗ ┛ ┣ ┫ ┳ ┻ ╋
Double: ╔ ═ ╗ ║ ╚ ╝ ╠ ╣ ╦ ╩ ╬
Rounded: ╭ ╮ ╰ ╯
Mixed: ╒ ╓ ╕ ╖ ╘ ╙ ╛ ╜ (light+double junctions)
```
### Blocks
```
Horizontal fill: █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ (full → 1/8)
Vertical fill: ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ (1/8 → full)
Shade: ░ ▒ ▓ █ (25% → 100%)
Quadrants: ▖ ▗ ▘ ▝ ▞ ▟ ▙ ▛ ▜ ▚
```
### Braille (sparklines, dot plots)
```
Range: U+2800U+28FF (256 patterns)
Each char = 2×4 dot matrix (2 cols × 4 rows)
Smooth curves: ⠀⣀⣠⣤⣴⣶⣾⣿⣷⣶⣤⣀⠀
```
### Indicators
```
Status: ● ○ ◐ ◑ ◒ ◓ ◌ ◉
Arrows: ▲ ▼ ◀ ▶ ← → ↑ ↓ ↗ ↘
Marks: ✓ ✗ ◆ ◇ ★ ☆ ⚠ ⚡
```
---
## 9. CLI
```bash
uframe render <file.uf> # emit ASCII (stdout) + .mu (file)
--ascii # ASCII only
--micron # .mu only
--ansi # ANSI colors in ASCII output
--width <int> # override page width
--out <dir> # directory for .mu output
uframe preview <file.uf> # live terminal preview
--watch # re-render on file change
uframe check <file.uf> # validate / lint
uframe deploy <file.uf> [dest] # render + copy to NomadNet pages dir
# default dest: ~/.nomadnetwork/storage/pages/
```
---
## 10. Standard Component Library
```
use std/dashboard
use std/filebrowser
use std/board
# Pre-built patterns:
dashboard.banner node_name uptime
dashboard.resources cpu mem gpu swap
dashboard.peers peer_list
dashboard.traffic in_data out_data
filebrowser.tree root_path
filebrowser.listing dir_path
board.recent count
board.compose action_path
nav.tabs items active
nav.breadcrumb path
chart.timeseries label values width
chart.compare items
chart.histogram bins
```
---
## 11. Future Directions
- **Responsive reflow** — breakpoints that stack `row` children
vertically at narrow widths
- **Themes** — `.uf-theme` files defining color palettes and
border preferences shared across pages
- **Animation** — frame-by-frame for live dashboards using
terminal cursor repositioning
- **Bidirectional** — parse existing `.mu` into `.uf` for editing
- **HTML export** — for web-based Reticulum browsers (rBrowser)
- **Shebang mode** — `#!/usr/bin/env uframe --micron` for
executable dynamic NomadNet pages
- **LSP** — editor support: highlighting, completion, live preview

145
docs/multi-node-plan.md Normal file
View File

@@ -0,0 +1,145 @@
# Multi-Node Support for Micronomicon
## Context
Currently Micronomicon runs a single NomadNet node — one identity, one set of pages, one `index.mu`. The user wants to host multiple independent "sites" on the Reticulum network, each with its own node identity, name, and pages. This requires changes across Docker infrastructure, backend API, and frontend UI.
## Approach: Dynamic Node Containers via Docker API
The FastAPI backend already has Docker socket access. Instead of statically defining NomadNet services in `compose.yml`, the backend will create/manage NomadNet containers dynamically — one per node. Each node gets its own directory with pages, sources, config, and identity.
## Storage Layout
```
/data/nodes/
default/
node.json # {"id", "display_name", "port", "created_at"}
nomadnet.conf # generated — unique node_name
pages/ # compiled .mu files (mounted into container)
sources/ # .uf source files
my-relay/
node.json
nomadnet.conf
pages/
sources/
```
## Files to Create
### `backend/nodes.py` — Node lifecycle management
- `NodeConfig` pydantic model: `id, display_name, port, created_at`
- `NODES_DIR = Path(os.environ.get("NODES_DIR", "/data/nodes"))`
- CRUD: `list_nodes()`, `get_node(id)`, `create_node(id, display_name)`, `delete_node(id)`
- `_generate_nomadnet_conf(display_name)` — template from existing `nomadnet.conf` with node-specific `node_name`
- `_next_port()` — allocate TCP ports starting from 4242
- Docker container management via Docker SDK (already a dependency):
- `start_node_container(node)` — create container from `ghcr.io/markqvist/nomadnet:latest`, mount node's `pages/` dir, per-node identity volume, shared `reticulum.conf`, generated `nomadnet.conf`
- `stop_node_container(id)`, `restart_node_container(id)`, `get_node_status(id)`
- `ensure_default_node()` — migration: if `NODES_DIR/default` doesn't exist, create it and move contents from old `PAGES_DIR`/`SOURCES_DIR`
- Startup reconciliation: check existing containers, start any that should be running
- API router:
- `GET /api/nodes` — list all nodes with status
- `POST /api/nodes` — create `{id, display_name}`
- `GET /api/nodes/{node_id}` — node details
- `DELETE /api/nodes/{node_id}` — stop container, remove dir
- `POST /api/nodes/{node_id}/restart`
### `frontend/src/stores/nodesStore.ts` — Node state
- `nodes: NodeMeta[]`, `activeNodeId: string` (persisted to localStorage, defaults to `"default"`)
- `fetchNodes()`, `setActiveNode(id)`, `createNode(id, displayName)`, `deleteNode(id)`, `restartNode(id)`
### `frontend/src/components/shared/NodeSelector.tsx` — Header dropdown
- Dropdown listing nodes with status indicator (green/red dot)
- Switching active node refetches pages
- "Manage Nodes" link to `/nodes`
### `frontend/src/routes/NodesView.tsx` — Node management page
- Table: Name, Status, Port, Created, Actions (restart/delete)
- "Create Node" button with dialog (ID + display name)
## Files to Modify
### `backend/pages.py`
- Replace global `PAGES_DIR`/`SOURCES_DIR` with helpers: `_node_pages_dir(node_id)` -> `NODES_DIR/node_id/pages`, `_node_sources_dir(node_id)` -> `NODES_DIR/node_id/sources`
- Refactor all functions to accept `node_id` parameter
- Add node-scoped endpoints: `GET/POST/DELETE /api/nodes/{node_id}/pages/{name}`
- Keep existing `/api/pages/{name}` as aliases -> `node_id="default"`
- `ensure_default_pages(node_id)` — create `index.mu` per node
### `backend/graph.py`
- Same refactor: accept `node_id`, add `/api/nodes/{node_id}/graph`
- Keep `/api/graph` as alias for default
### `backend/docker_utils.py`
- Deprecate in favor of `nodes.py` restart endpoint
- Keep `/api/restart` as alias -> restart default node
### `backend/main.py`
- Import and include `nodes` router
- Startup: call `ensure_default_node()`, then start all node containers
- Remove direct `ensure_default_pages()` call (handled by node creation)
### `compose.yml`
- Remove static `nomadnet` service (backend manages containers dynamically)
- Replace `pages`/`sources`/`nomadnet-config` volumes with single `nodes` volume
- Add `NODES_DIR=/data/nodes`, `NOMADNET_IMAGE`, `RETICULUM_CONF` env vars
- Add `reticulum` bridge network for container communication
- Keep `reticulum.conf` bind-mount for backend to pass to spawned containers
### `frontend/src/stores/pagesStore.ts`
- Read `activeNodeId` from `nodesStore`
- All fetch calls go to `/api/nodes/{activeNodeId}/pages/...`
### `frontend/src/routes/DashboardView.tsx`
- Show active node name in header
- Restart button restarts active node
- Page list scoped to active node via store
### `frontend/src/routes/EditorView.tsx`
- Save/publish calls use `/api/nodes/{activeNodeId}/pages/{slug}`
- Load page from `/api/nodes/{activeNodeId}/pages/{name}`
- Show active node name in toolbar
### `frontend/src/routes/GraphView.tsx`
- Fetch from `/api/nodes/{activeNodeId}/graph`
### `frontend/src/App.tsx`
- Add `/nodes` route -> `NodesView`
### `frontend/src/components/shared/AppShell.tsx` (or equivalent layout)
- Add `NodeSelector` to header/nav area
## Backward Compatibility
- Existing `/api/pages/...`, `/api/restart`, `/api/graph` remain as aliases for `node_id="default"`
- `ensure_default_node()` migrates old flat `PAGES_DIR`/`SOURCES_DIR` into `NODES_DIR/default/`
- Frontend defaults `activeNodeId` to `"default"` — single-node users see no change
- `NodeSelector` only shows when >1 node exists (or shows as subtle indicator for single node)
## Implementation Order
1. `backend/nodes.py` — core model, storage, Docker container management, API
2. `backend/pages.py` — refactor to accept `node_id`, add node-scoped endpoints
3. `backend/graph.py` — refactor to accept `node_id`
4. `backend/main.py` — wire in nodes router, startup logic
5. `compose.yml` — restructure (remove static nomadnet, add nodes volume + network)
6. `frontend/src/stores/nodesStore.ts` — new store
7. `frontend/src/stores/pagesStore.ts` — parameterize by active node
8. `frontend/src/components/shared/NodeSelector.tsx` — node switcher
9. `frontend/src/routes/NodesView.tsx` — management UI
10. `frontend/src/App.tsx` + layout — add routes, add selector to shell
11. `frontend/src/routes/DashboardView.tsx` — node-aware
12. `frontend/src/routes/EditorView.tsx` — node-aware save/publish/load
13. `frontend/src/routes/GraphView.tsx` — node-aware
## Verification
1. `docker compose up --build -d` — starts backend only (no static nomadnet)
2. Hit `GET /api/nodes` — should return `[{id: "default", display_name: "Micronomicon", status: "running", ...}]`
3. Open web IDE — pages dashboard shows default node's pages, NodeSelector shows "Micronomicon"
4. Create a page, publish — appears in default node's container at `/root/.nomadnetwork/storage/pages/`
5. `POST /api/nodes` with `{id: "relay", display_name: "Relay Alpha"}` — new container starts, `GET /api/nodes` shows 2 nodes
6. Switch to "Relay Alpha" in NodeSelector — empty page list, create and publish `index.mu`
7. From remote NomadNet client, both nodes visible on network with separate identities and pages
8. Delete "Relay Alpha" — container stops, files removed, back to single node
9. Run backend tests: `python -m pytest uframe/tests/ -v` — all 91 pass (DSL unchanged)

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

46
frontend/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Micronomicon — Frontend
React 19 + Vite + TypeScript + shadcn/ui. See the [root README](../README.md) for full project docs.
## Dev
```bash
npm install
npm run dev # http://localhost:5173 — proxies /api to localhost:8080
```
## Build
```bash
npm run build # output → dist/ (copied into Docker image)
```
## Structure
```
src/
App.tsx ← routes
routes/
DashboardView.tsx ← page table
EditorView.tsx ← split-pane editor
GraphView.tsx ← React Flow graph
components/
dashboard/StatusBadge.tsx
editor/
EditorPane.tsx ← CodeMirror 6
PreviewPane.tsx ← micron output (ScrollArea)
ToolBar.tsx
shared/
AppShell.tsx ← TooltipProvider + Toaster wrapper
NavBar.tsx
ui/ ← shadcn components (auto-generated)
lib/
utils.ts ← cn() utility
stores/
editorStore.ts ← Zustand: markdown, micron, dirty state
pagesStore.ts ← Zustand: pages list, fetch/delete
hooks/
useConversion.ts ← debounced POST /api/convert
useGraph.ts ← GET /api/graph
useUnsavedGuard.ts ← beforeunload + dirty guard
```

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Micronomicon</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7697
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
frontend/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "micronomicon",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"lucide-react": "^1.7.0",
"micron-parser": "^1.0.3",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-3d": "^1.29.1",
"react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2",
"shadcn": "^4.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"three-spritetext": "^1.10.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/dompurify": "^3.0.5",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tslib": "^2.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

17
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Routes, Route } from "react-router-dom";
import AppShell from "./components/shared/AppShell";
import ComposeView from "./routes/ComposeView";
import BrowseView from "./routes/BrowseView";
import SettingsView from "./routes/SettingsView";
export default function App() {
return (
<AppShell>
<Routes>
<Route path="/" element={<ComposeView />} />
<Route path="/browse" element={<BrowseView />} />
<Route path="/settings" element={<SettingsView />} />
</Routes>
</AppShell>
);
}

274
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,274 @@
/**
* Centralized API client for all backend communication.
*
* Every fetch call in the app should go through here so that
* endpoint URLs live in one place and are easy to update
* (e.g. when adding multi-node support with /api/nodes/{id}/...).
*/
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function json<T>(res: Response): Promise<T> {
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
export interface PageMeta {
name: string;
title: string | null;
published: boolean;
has_source: boolean;
last_modified: number | null;
size: number | null;
}
export interface PageDetail {
name: string;
source: string | null;
}
export async function fetchPages(): Promise<PageMeta[]> {
const res = await fetch("/api/pages");
return json(res);
}
export async function fetchPage(name: string): Promise<PageDetail> {
const res = await fetch(`/api/pages/${name}`);
return json(res);
}
export async function savePage(
name: string,
source: string,
publish: boolean,
): Promise<PageMeta> {
const res = await fetch(`/api/pages/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, publish }),
});
return json(res);
}
export async function deletePage(name: string): Promise<void> {
const res = await fetch(`/api/pages/${name}`, { method: "DELETE" });
if (!res.ok) throw new Error(await res.text());
}
// ---------------------------------------------------------------------------
// Compile
// ---------------------------------------------------------------------------
export interface CompileResult {
ascii: string;
micron: string;
script: string;
is_dynamic: boolean;
warnings: string[];
}
export async function compile(
source: string,
signal?: AbortSignal,
): Promise<CompileResult> {
const res = await fetch("/api/compile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source }),
signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
throw new Error(err.detail || "Compile failed");
}
return res.json();
}
// ---------------------------------------------------------------------------
// DSL Metadata
// ---------------------------------------------------------------------------
export interface DslCommand {
label: string;
detail: string;
section: string;
snippet: string;
}
export interface DslMeta {
keywords: string[];
values: string[];
commands: DslCommand[];
themes: string[];
}
export async function fetchDslMeta(): Promise<DslMeta> {
const res = await fetch("/api/dsl-meta");
return json(res);
}
// ---------------------------------------------------------------------------
// Node Management
// ---------------------------------------------------------------------------
export async function restartNode(): Promise<void> {
const res = await fetch("/api/restart", { method: "POST" });
if (!res.ok) throw new Error(await res.text());
}
// ---------------------------------------------------------------------------
// Browse — network node discovery + remote pages
// ---------------------------------------------------------------------------
export interface NetworkNode {
hash: string;
name: string;
last_seen: number;
is_self: boolean;
type?: "node" | "interface";
interface?: string | null;
online?: boolean;
target?: string | null;
txb?: number;
rxb?: number;
bitrate?: number;
clients?: number | null;
}
export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
const res = await fetch("/api/browse/nodes");
const data = await json<NetworkNode[] | unknown>(res);
return Array.isArray(data) ? data : [];
}
export function subscribeBrowseNodes(
onNode: (node: NetworkNode) => void,
): () => void {
const es = new EventSource("/api/browse/nodes/stream");
es.onmessage = (e) => {
try {
onNode(JSON.parse(e.data));
} catch { /* ignore parse errors */ }
};
return () => es.close();
}
export async function fetchRemotePage(
hash: string,
path: string = "index.mu",
): Promise<{ content: string | null; error?: string }> {
const res = await fetch(
`/api/browse/page/${hash}?path=${encodeURIComponent(path)}`,
);
return json(res);
}
// ---------------------------------------------------------------------------
// File Browser
// ---------------------------------------------------------------------------
export interface FileEntry {
name: string;
path: string;
type: "file" | "folder" | "env";
size: number | null;
last_modified: number | null;
title: string | null;
published: boolean;
}
export async function fetchFiles(path: string = ""): Promise<FileEntry[]> {
const params = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/files${params}`);
return json(res);
}
export async function createFolder(path: string): Promise<void> {
const res = await fetch("/api/files/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function moveFile(from: string, to: string): Promise<void> {
const res = await fetch("/api/files/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function fetchEnv(): Promise<string> {
const res = await fetch("/api/files/env");
const data = await json<{ content: string }>(res);
return data.content;
}
export async function saveEnv(content: string): Promise<void> {
const res = await fetch("/api/files/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error(await res.text());
}
// ---------------------------------------------------------------------------
// Config (Reticulum + NomadNet)
// ---------------------------------------------------------------------------
export interface NodeIdentity {
name: string;
hash: string | null;
}
export async function fetchIdentity(): Promise<NodeIdentity> {
const res = await fetch("/api/browse/identity");
return json(res);
}
export async function fetchConfig(kind: "reticulum" | "reticulum-client" | "nomadnet"): Promise<string> {
const res = await fetch(`/api/browse/config/${kind}`);
const data = await json<{ content: string }>(res);
return data.content;
}
export async function saveConfig(kind: "reticulum" | "reticulum-client" | "nomadnet", content: string): Promise<void> {
const res = await fetch(`/api/browse/config/${kind}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function restartServices(): Promise<{ nomadnet_restarted: boolean }> {
const res = await fetch("/api/browse/restart", { method: "POST" });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ---------------------------------------------------------------------------
// Images
// ---------------------------------------------------------------------------
export interface UploadResult {
filename: string;
path: string;
}
export async function uploadImage(file: File): Promise<UploadResult> {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload-image", { method: "POST", body: form });
return json(res);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 293 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 154 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 980 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 552 KiB

View File

@@ -0,0 +1,77 @@
import FloatingWindow from "@/components/shared/FloatingWindow";
import Loader from "@/components/shared/Loader";
import type { ManagedWindow } from "@/hooks/useWindowManager";
import type { BrowseWinData } from "./types";
interface BrowseNodeWindowProps {
win: ManagedWindow<BrowseWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<BrowseWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
onNavBack: (winId: string, data: BrowseWinData) => void;
onNavForward: (winId: string, data: BrowseWinData) => void;
onNavReload: (winId: string, data: BrowseWinData) => void;
onContentClick: (e: React.MouseEvent, winId: string, data: BrowseWinData) => void;
}
export default function BrowseNodeWindow({
win, focused, onUpdate, onClose, onFocus,
onNavBack, onNavForward, onNavReload, onContentClick,
}: BrowseNodeWindowProps) {
const d = win.data;
const canBack = d.historyIndex > 0;
const canFwd = d.historyIndex < d.history.length - 1;
return (
<FloatingWindow
id={win.id}
title={d.node.name}
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={onClose}
onFocus={onFocus}
addressBar={
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
<button onClick={() => onNavBack(win.id, d)} disabled={!canBack || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
&#9664;
</button>
<button onClick={() => onNavForward(win.id, d)} disabled={!canFwd || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
&#9654;
</button>
<button onClick={() => onNavReload(win.id, d)} disabled={d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
&#8635;
</button>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate cursor-text"
onClick={(e) => { if (e.target === e.currentTarget) { const sel = window.getSelection(); if (sel) { const range = document.createRange(); range.selectNodeContents(e.currentTarget); sel.removeAllRanges(); sel.addRange(range); } } }}>
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}\u2026/</span>{d.currentPath}
</div>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
: d.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
</span>
</div>
}
footer={
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{d.node.type ?? "peer"}</span>
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
</div>
}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="p-3 h-full overflow-auto" onClick={(e) => onContentClick(e, win.id, d)}>
{d.pageLoading && <div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground text-xs"><Loader /> Requesting page...</div>}
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
</div>
</FloatingWindow>
);
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
import type { NetworkNode } from "@/api/client";
interface BrowseSearchBarProps {
filter: string;
onFilterChange: (value: string) => void;
onClear: () => void;
allNodes: NetworkNode[];
suggestions: NetworkNode[];
onSelectNode: (node: NetworkNode) => void;
onHighlightNode: (node: NetworkNode | null) => void;
onSearchFocusChange: (focused: boolean) => void;
focusedWinId: string | null;
windowCount: number;
}
export default function BrowseSearchBar({
filter, onFilterChange, onClear,
allNodes, suggestions, onSelectNode, onHighlightNode, onSearchFocusChange,
focusedWinId, windowCount,
}: BrowseSearchBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
const [focused, setFocused] = useState(false);
// The visible list: when focused with no filter, show all nodes; otherwise filtered suggestions
const visibleList = focused && !filter.trim() ? allNodes : suggestions;
const maxVisible = 12;
const displayList = visibleList.slice(0, maxVisible);
const hasMore = visibleList.length > maxVisible;
// Reset selection when list changes
useEffect(() => { setSelectedSuggestion(-1); }, [visibleList.length, filter]);
// Notify parent of highlight changes for camera fly-to
useEffect(() => {
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : null;
onHighlightNode(entry ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSuggestion, onHighlightNode]);
// Auto-focus search when no windows open
useEffect(() => { searchInputRef.current?.focus(); }, []);
useEffect(() => {
if (windowCount === 0) searchInputRef.current?.focus();
}, [windowCount]);
// Capture typing into search when no window is focused
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (focusedWinId) return;
if (document.activeElement === searchInputRef.current) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key.length !== 1) return;
searchInputRef.current?.focus();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [focusedWinId]);
const handleFocus = useCallback(() => {
setFocused(true);
onSearchFocusChange(true);
}, [onSearchFocusChange]);
const handleBlur = useCallback(() => {
// Delay to allow click on suggestion to fire before closing
setTimeout(() => {
setFocused(false);
onSearchFocusChange(false);
setSelectedSuggestion(-1);
}, 150);
}, [onSearchFocusChange]);
// Draggable position
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 307 }); }, []);
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
e.preventDefault();
const pos = searchPos ?? { x: 0, y: 0 };
searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); };
const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [searchPos]);
if (!searchPos) return null;
const showDropdown = focused && displayList.length > 0;
return createPortal(
<div onMouseDown={onSearchDragStart}
className="fixed z-999 flex flex-col bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
<div className="flex items-center gap-3 px-3 py-1.5">
<input ref={searchInputRef} type="text" value={filter}
onChange={(e) => onFilterChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Escape") { onClear(); e.currentTarget.blur(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, displayList.length - 1)); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
if (e.key === "Enter") {
e.preventDefault();
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : displayList[0];
if (entry && entry.type !== "interface") { onSelectNode(entry); onClear(); }
return;
}
}}
placeholder="Search nodes..."
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
{filter && (
<button onClick={onClear} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
&times;
</button>
)}
{!filter && focused && (
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">
{allNodes.length} nodes
</span>
)}
</div>
{showDropdown && (
<div className="border-t border-border max-h-[360px] overflow-y-auto">
{displayList.map((entry, i) => (
<button
key={entry.hash}
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { onSelectNode(entry); onClear(); } }}
onMouseEnter={() => setSelectedSuggestion(i)}
className={`w-full text-left px-3 py-1.5 text-xs font-mono flex items-center gap-2 transition-colors ${i === selectedSuggestion ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/50"
}`}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{
backgroundColor: (() => {
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
if (age < 300) return "var(--primary)";
if (age < 3600) return "var(--muted-foreground)";
return "var(--border)";
})(),
}} />
<span className="truncate">{entry.name}</span>
<span className="ml-auto text-[9px] text-muted-foreground uppercase shrink-0">
{entry.interface ?? "peer"}
</span>
</button>
))}
{hasMore && (
<div className="px-3 py-1 text-[9px] text-muted-foreground text-center border-t border-border/50">
{visibleList.length - maxVisible} more type to narrow
</div>
)}
</div>
)}
</div>, document.body);
}

View File

@@ -0,0 +1,106 @@
import type { NetworkNode } from "@/api/client";
import { statusRGBA, rgbaToHex, type StatusColors } from "./graphColors";
export interface GraphNode {
id: string;
name: string;
entry: NetworkNode;
type: "self" | "peer" | "interface";
color: string;
size: number;
cluster?: string;
x?: number;
y?: number;
z?: number;
}
export interface GraphLink {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
clusterNames: string[];
}
function findParentIface(
entry: NetworkNode,
interfaces: NetworkNode[],
peerIndex: number,
): NetworkNode | undefined {
if (entry.interface) {
const iface = interfaces.find(i => i.name === entry.interface);
if (iface) return iface;
}
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length];
return undefined;
}
export function buildGraphData(
rawNodes: NetworkNode[],
prevPositions: Map<string, { x: number; y: number; z: number }>,
theme: StatusColors,
): GraphData {
const interfaces = rawNodes
.filter(e => e.type === "interface")
.sort((a, b) => a.name.localeCompare(b.name));
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
// Add interface nodes
for (const iface of interfaces) {
const prev = prevPositions.get(iface.hash);
nodes.push({
id: iface.hash,
name: iface.name,
entry: iface,
type: "interface",
color: rgbaToHex(theme.stale),
size: 2,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
}
// Add self node
if (selfNode) {
const prev = prevPositions.get(selfNode.hash);
nodes.push({
id: selfNode.hash,
name: selfNode.name,
entry: selfNode,
type: "self",
color: "#ffffff",
size: 2,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
}
// Add peer nodes + links to parent interface
peers.forEach((peer, i) => {
const prev = prevPositions.get(peer.hash);
const parentIface = findParentIface(peer, interfaces, i);
nodes.push({
id: peer.hash,
name: peer.name,
entry: peer,
type: "peer",
color: rgbaToHex(statusRGBA(peer, theme)),
size: 2,
cluster: parentIface?.name,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
if (parentIface) {
links.push({ source: parentIface.hash, target: peer.hash });
}
});
const clusterNames = interfaces.map(i => i.name);
return { nodes, links, clusterNames };
}

View File

@@ -0,0 +1,64 @@
import type { NetworkNode } from "@/api/client";
export type RGBA = [number, number, number, number];
export function cssVarToRGBA(varName: string): RGBA {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
if (!raw) return [0.5, 0.5, 0.5, 1];
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.fillStyle = raw;
// ctx.fillStyle normalizes to #rrggbb
const hex = ctx.fillStyle;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return [r, g, b, 1];
}
export function lerpRGBA(a: RGBA, b: RGBA, t: number): RGBA {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
a[3] + (b[3] - a[3]) * t,
];
}
export function brighten(c: RGBA, amount: number): RGBA {
return [
Math.min(1, c[0] + amount),
Math.min(1, c[1] + amount),
Math.min(1, c[2] + amount),
c[3],
];
}
export interface StatusColors {
online: RGBA;
stale: RGBA;
offline: RGBA;
}
export function getThemeStatusColors(): StatusColors {
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
const muted = cssVarToRGBA("--muted-foreground");
return {
online: primary,
stale: lerpRGBA(primary, muted, 0.4),
offline: muted,
};
}
export function statusRGBA(entry: NetworkNode, theme: StatusColors): RGBA {
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
if (age < 300) return theme.online;
if (age < 3600) return theme.stale;
return theme.offline;
}
export function rgbaToHex(c: RGBA): string {
const r = Math.round(c[0] * 255);
const g = Math.round(c[1] * 255);
const b = Math.round(c[2] * 255);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,17 @@
import type { NetworkNode } from "@/api/client";
export interface HistoryEntry {
path: string;
html: string | null;
error: string | null;
}
export interface BrowseWinData {
node: NetworkNode;
pageHtml: string | null;
pageLoading: boolean;
pageError: string | null;
currentPath: string;
history: HistoryEntry[];
historyIndex: number;
}

View File

@@ -0,0 +1,12 @@
import { Badge } from "@/components/ui/badge";
interface Props {
published: boolean;
hasSource: boolean;
}
export default function StatusBadge({ published, hasSource }: Props) {
if (published && hasSource) return <Badge>Published</Badge>;
if (!published && hasSource) return <Badge variant="secondary">Draft</Badge>;
return <Badge variant="outline">Orphan</Badge>;
}

View File

@@ -0,0 +1,135 @@
import { useEffect, useRef } from "react";
import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewPlugin } from "@codemirror/view";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import type { Extension } from "@codemirror/state";
import type { DecorationSet } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { searchKeymap } from "@codemirror/search";
import { oneDark } from "./oneDarkTheme";
function toRoman(n: number): string {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
let result = "";
for (let i = 0; i < vals.length; i++) {
while (n >= vals[i]) {
result += syms[i];
n -= vals[i];
}
}
return result;
}
const dotDeco = Decoration.mark({ class: "cm-dot-space" });
function buildSpaceDeco(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
const text = view.state.sliceDoc(from, to);
for (let i = 0; i < text.length; i++) {
if (text[i] === " ") {
builder.add(from + i, from + i + 1, dotDeco);
}
}
}
return builder.finish();
}
const subtleWhitespace = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildSpaceDeco(view);
}
update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildSpaceDeco(update.view);
}
}
},
{ decorations: (v) => v.decorations },
);
interface Props {
value: string;
onChange: (value: string) => void;
extensions?: Extension[];
}
export default function EditorPane({ value, onChange, extensions = [] }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
history(),
lineNumbers({ formatNumber: toRoman }),
highlightActiveLine(),
subtleWhitespace,
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
oneDark,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const pos = update.state.selection.main.head;
const coords = update.view.coordsAtPos(pos);
if (coords) {
window.dispatchEvent(
new CustomEvent("cm-cursor-move", { detail: { top: coords.top, left: coords.left } }),
);
}
}
}),
EditorView.theme({
"&": { height: "100%", fontSize: "14px" },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "monospace", padding: "16px" },
".cm-cursor, .cm-cursor-primary": {
borderLeftColor: "var(--primary)",
borderLeftWidth: "0.5em",
},
".cm-dot-space": {
color: "transparent",
position: "relative",
},
".cm-dot-space:before": {
content: '"\\22C5"',
position: "absolute",
left: "0",
right: "0",
textAlign: "center",
color: "var(--muted-foreground)",
opacity: "0.5",
pointerEvents: "none",
},
}),
...extensions,
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
return () => view.destroy();
}, []); // Only mount once
// Sync external value changes (e.g. loading a page)
useEffect(() => {
const view = viewRef.current;
if (view && view.state.doc.toString() !== value) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}
}, [value]);
return <div ref={containerRef} style={{ height: "100%" }} />;
}

View File

@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import pointerSvg from "@/assets/pointer.min.svg";
import { useLazyEyes } from "@/hooks/useLazyEyes";
/**
* Floating pointer that tracks the CodeMirror cursor vertically,
* pinned to the left edge of the editor. Positions relative to
* containerRef (for floating windows).
*/
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
const [visible, setVisible] = useState(false);
const [editorLeft, setEditorLeft] = useState<number | null>(null);
const targetRef = useRef(0);
const currentRef = useRef(0);
const rafRef = useRef(0);
const activeRef = useRef(false);
const [clickKey, setClickKey] = useState(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cmRef = useRef<Element | null>(null);
const pointerElRef = useRef<HTMLDivElement>(null);
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef: eyeAnchorRef });
const iris1Ref = useRef<HTMLDivElement>(null);
const iris2Ref = useRef<HTMLDivElement>(null);
// Register/unregister iris elements
useEffect(() => {
const i1 = iris1Ref.current;
const i2 = iris2Ref.current;
if (i1) registerIris(i1);
if (i2) registerIris(i2);
return () => {
if (i1) unregisterIris(i1);
if (i2) unregisterIris(i2);
};
}, [visible, registerIris, unregisterIris]);
useEffect(() => {
const ease = 0.09;
const getContainerRect = () =>
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
const updateLeft = () => {
if (!cmRef.current || !containerRef?.current) return;
const cmLeft = cmRef.current.getBoundingClientRect().left;
const containerLeft = containerRef.current.getBoundingClientRect().left;
setEditorLeft(cmLeft - containerLeft);
};
const onEditorClick = () => {
clearTimeout(clickTimerRef.current);
setClickKey((k) => k + 1);
clickTimerRef.current = setTimeout(() => setClickKey(0), 200);
};
const onCursorMove = (e: Event) => {
const { top } = (e as CustomEvent).detail;
if (!cmRef.current) {
const scope = containerRef?.current ?? document;
const cm = scope.querySelector(".cm-editor");
if (cm) {
cmRef.current = cm;
cm.addEventListener("mousedown", onEditorClick);
ro.observe(cm);
}
}
const containerTop = getContainerRect().top;
targetRef.current = top - containerTop;
updateLeft();
if (!activeRef.current) {
currentRef.current = targetRef.current;
if (pointerElRef.current) {
pointerElRef.current.style.top = `${targetRef.current}px`;
}
setVisible(true);
activeRef.current = true;
}
};
const tick = () => {
if (activeRef.current) {
const diff = targetRef.current - currentRef.current;
if (Math.abs(diff) < 0.5) {
currentRef.current = targetRef.current;
} else {
currentRef.current += diff * ease;
}
// Direct DOM update — no setState
if (pointerElRef.current) {
pointerElRef.current.style.top = `${currentRef.current}px`;
}
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
const containerTop = getContainerRect().top;
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
}
rafRef.current = requestAnimationFrame(tick);
};
window.addEventListener("cm-cursor-move", onCursorMove);
rafRef.current = requestAnimationFrame(tick);
const ro = new ResizeObserver(() => updateLeft());
const scope = containerRef?.current ?? document;
const existingCm = scope.querySelector(".cm-editor");
if (existingCm) {
cmRef.current = existingCm;
ro.observe(existingCm);
}
window.addEventListener("resize", updateLeft);
return () => {
window.removeEventListener("cm-cursor-move", onCursorMove);
window.removeEventListener("resize", updateLeft);
cmRef.current?.removeEventListener("mousedown", onEditorClick);
cancelAnimationFrame(rafRef.current);
ro.disconnect();
};
}, [containerRef]);
if (!focused || !visible || editorLeft === null) return null;
return (
<div
ref={pointerElRef}
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
key={clickKey}
style={{ left: editorLeft }}
>
<div
className="editor-pointer-img"
style={{
WebkitMaskImage: `url(${pointerSvg})`,
maskImage: `url(${pointerSvg})`,
}}
/>
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
<div
ref={iris1Ref}
className="editor-pointer-iris"
/>
</div>
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
<div
ref={iris2Ref}
className="editor-pointer-iris"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
import { useStore, type StoreApi } from "zustand";
import type { EditorStore } from "@/stores/editorStore";
export const EditorStoreContext = createContext<StoreApi<EditorStore> | null>(null);
export function useEditorCtx<T>(selector: (s: EditorStore) => T): T {
const store = useContext(EditorStoreContext);
if (!store) throw new Error("useEditorCtx must be used within EditorStoreContext.Provider");
return useStore(store, selector);
}

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { autocompletion } from "@codemirror/autocomplete";
import type { StoreApi } from "zustand";
import { useStore } from "zustand";
import * as api from "@/api/client";
import { createEditorStore, type EditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore";
import { useCompile } from "@/hooks/useCompile";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
import { uframeHighlight } from "./uframeHighlight";
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "./uframeCommands";
import { keywordHoverTooltip } from "./uframeHover";
import { EditorStoreContext } from "./EditorStoreContext";
import EditorPointer from "./EditorPointer";
import PreviewPane from "./PreviewPane";
import SourcePane from "./SourcePane";
import ToolBar from "./ToolBar";
import FloatingWindow from "@/components/shared/FloatingWindow";
import type { ManagedWindow } from "@/hooks/useWindowManager";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
export interface EditorWinData {
pageName: string;
isNew: boolean;
}
interface EditorWindowProps {
win: ManagedWindow<EditorWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<EditorWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}
export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus }: EditorWindowProps) {
const storeRef = useRef<StoreApi<EditorStore>>(null);
if (!storeRef.current) storeRef.current = createEditorStore();
const store = storeRef.current;
const windowRef = useRef<HTMLDivElement>(null);
const ufSource = useStore(store, (s) => s.ufSource);
const isDirty = useStore(store, (s) => s.isDirty);
const setSource = useStore(store, (s) => s.setSource);
const setDirty = useStore(store, (s) => s.setDirty);
const setCurrentPage = useStore(store, (s) => s.setCurrentPage);
const { fetchPages } = usePagesStore();
const [pageName, setPageName] = useState(win.data.pageName);
const [saving, setSaving] = useState(false);
const extensions = useMemo(
() => [
...uframeHighlight(),
autocompletion({
override: [uframeCommandSource, uframeValueHintSource],
icons: false,
activateOnTyping: true,
}),
keywordHoverTooltip,
],
[],
);
useEffect(() => { loadCommandsFromApi(); }, []);
useCompile(store);
useUnsavedGuard(store);
// Load page on mount
useEffect(() => {
if (!win.data.isNew && win.data.pageName) {
api.fetchPage(win.data.pageName).then((data) => {
if (data.source != null) {
store.setState({ ufSource: data.source, isDirty: false });
}
});
}
return () => store.getState().reset();
}, []);
const handleSave = useCallback(
async (publish: boolean) => {
const slug = pageName.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
if (!slug) { toast.error("Enter a page name."); return; }
setSaving(true);
try {
const meta = await api.savePage(slug, ufSource, publish);
setCurrentPage(meta);
setDirty(false);
fetchPages();
toast.success(publish ? "Published" : "Draft saved");
if (win.data.isNew) {
setPageName(slug);
onUpdate(win.id, { data: { pageName: slug, isNew: false } });
}
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
},
[pageName, ufSource, win.data.isNew, win.id],
);
useKeyboardSave(
useCallback(() => handleSave(false), [handleSave]),
useCallback(() => handleSave(true), [handleSave]),
focused,
);
// Confirm close if dirty
const handleClose = useCallback((id: string) => {
if (isDirty) {
if (!window.confirm("You have unsaved changes. Close anyway?")) return;
}
onClose(id);
}, [isDirty, onClose]);
return (
<FloatingWindow
id={win.id}
title={pageName || "new page"}
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={handleClose}
onFocus={onFocus}
minW={480} minH={300}
containerRef={windowRef}
>
<EditorStoreContext.Provider value={store}>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
<ToolBar
pageName={pageName}
onNameChange={win.data.isNew ? setPageName : undefined}
onSaveDraft={() => handleSave(false)}
onPublish={() => handleSave(true)}
saving={saving}
isDirty={isDirty}
/>
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0">
<ResizablePanel defaultSize={50} minSize={20}>
<SourcePane ufSource={ufSource} setSource={setSource} extensions={extensions} />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<PreviewPane />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</EditorStoreContext.Provider>
</FloatingWindow>
);
}

View File

@@ -0,0 +1,96 @@
import { useCallback } from "react";
import { useEditorCtx } from "./EditorStoreContext";
import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils";
import Loader from "@/components/shared/Loader";
type PreviewMode = "micron" | "raw" | "script";
export default function PreviewPane() {
const previewMode = useEditorCtx((s) => s.previewMode);
const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
const compiledMicron = useEditorCtx((s) => s.compiledMicron);
const compiledScript = useEditorCtx((s) => s.compiledScript);
const isDynamic = useEditorCtx((s) => s.isDynamic);
const isCompiling = useEditorCtx((s) => s.isCompiling);
const compileError = useEditorCtx((s) => s.compileError);
const handlePreviewClick = useCallback((e: React.MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a");
if (anchor) e.preventDefault();
}, []);
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
{ value: "micron", label: "Micron", show: true },
{ value: "raw", label: "Raw", show: true },
{ value: "script", label: "Script", show: isDynamic },
];
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
<span className="font-medium text-foreground flex-1">
Preview
{isDynamic && (
<span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page"></span>
)}
{isCompiling && (
<Loader size={10} className="ml-1.5 inline-block" />
)}
{compileError && (
<span className="ml-1 text-red-400 text-[10px]" title={compileError}></span>
)}
</span>
<div className="flex items-center">
{tabs
.filter((t) => t.show)
.map((tab, i, arr) => (
<span key={tab.value} className="flex items-center">
<button
onClick={() => setPreviewMode(tab.value)}
className={cn(
"text-[10px] uppercase tracking-wider transition-colors cursor-pointer px-1.5",
previewMode === tab.value
? "text-foreground font-bold"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
{i < arr.length - 1 && (
<span className="text-muted-foreground text-[8px]">·</span>
)}
</span>
))}
</div>
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="flex-1 bg-background overflow-auto min-h-0" onClick={handlePreviewClick}>
{previewMode === "micron" ? (
compiledMicron ? (
<div
className="p-2 font-mono text-[11px] leading-tight"
dangerouslySetInnerHTML={{
__html: renderMicron(compiledMicron, true),
}}
/>
) : (
<div className="p-4">
<span className="text-muted-foreground text-sm">
Micron preview will appear here
</span>
</div>
)
) : previewMode === "script" ? (
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-amber-200/80 leading-relaxed">
{compiledScript || "No dynamic script generated."}
</pre>
) : (
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground">
{compiledMicron || "Raw Micron output will appear here…"}
</pre>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import { toast } from "sonner";
import { BookOpen, Upload } from "lucide-react";
import type { Extension } from "@codemirror/state";
import * as api from "@/api/client";
import EditorPane from "./EditorPane";
import { EXAMPLES } from "./examples";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
} from "@/components/ui/popover";
interface SourcePaneProps {
ufSource: string;
setSource: (s: string) => void;
extensions: Extension[];
}
export default function SourcePane({ ufSource, setSource, extensions }: SourcePaneProps) {
const [examplesOpen, setExamplesOpen] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const data = await api.uploadImage(file);
toast.success(`Uploaded ${data.filename}`);
setSource(`image "${data.path}" braille 30\n align center`);
} catch (err) {
toast.error(`Upload failed: ${err}`);
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
<span className="font-medium text-foreground flex-1">Source</span>
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
<PopoverTrigger
render={
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
<BookOpen className="h-3 w-3" />
Examples
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={8}>
<PopoverHeader>
<PopoverTitle>Insert Example</PopoverTitle>
</PopoverHeader>
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
{EXAMPLES.map((ex) => (
<button
key={ex.name}
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
>
<span className="text-sm font-medium">{ex.name}</span>
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
>
<Upload className="h-3 w-3" />
{uploading ? "Uploading\u2026" : "Image"}
</button>
</div>
<div className="flex-1 overflow-auto min-h-0">
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useRef, useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface Props {
pageName: string;
onNameChange?: (name: string) => void;
onSaveDraft: () => void;
onPublish: () => void;
saving: boolean;
isDirty: boolean;
}
export default function ToolBar({
pageName,
onNameChange,
onSaveDraft,
onPublish,
saving,
isDirty,
}: Props) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-border shrink-0">
<PageNameField pageName={pageName} onNameChange={onNameChange} />
{isDirty && (
<span className="text-[10px] text-muted-foreground/50"></span>
)}
<div className="flex-1" />
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
Save Draft
</Button>
<Button size="sm" onClick={onPublish} disabled={saving}>
Publish
</Button>
</div>
);
}
function PageNameField({
pageName,
onNameChange,
}: {
pageName: string;
onNameChange?: (name: string) => void;
}) {
const [editing, setEditing] = useState(!pageName || !!onNameChange);
const inputRef = useRef<HTMLInputElement>(null);
if (editing || onNameChange) {
return (
<Input
ref={inputRef}
value={pageName}
onChange={(e) => onNameChange?.(e.target.value)}
onBlur={() => { if (pageName) setEditing(false); }}
onKeyDown={(e) => { if (e.key === "Enter" && pageName) setEditing(false); }}
placeholder="page-name"
className="font-mono w-36 h-6 text-xs border-0 bg-transparent px-1"
autoFocus
/>
);
}
return (
<button
onClick={() => setEditing(true)}
className="flex items-center gap-1 font-mono text-xs font-semibold hover:text-primary transition-colors cursor-pointer"
>
{pageName}
<Pencil className="w-2.5 h-2.5 text-muted-foreground" />
</button>
);
}

View File

@@ -0,0 +1,446 @@
export interface Example {
name: string;
description: string;
source: string;
}
export const EXAMPLES: Example[] = [
{
name: "Hello World",
description: "Minimal page with a heading and text",
source: `page "Hello" 50
heading 1 "Welcome"
text "This is your first page."
spacer
heading 2 "About"
text "Built with the uFrame DSL."
divider light
link "Home" "/page/index.mu"`,
},
{
name: "Node Dashboard",
description: "System gauges, status indicators, and network info",
source: `page "Node Status" 60
box double "Relay Alpha-7"
align center
text "Reticulum Network Node"
text "Online 14d 3h 22m"
spacer
heading 1 "Resources"
row 2
col 28
gauge "CPU" 62 100 24 warn=75 crit=90
col 28
gauge "MEM" 84 100 24 warn=80 crit=95
spacer
heading 1 "Network"
row 2
col 30
label "Peers" "7 / 12"
label "Uptime" "14d 3h 22m"
col 28
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
status "Hub North" offline
divider heavy
text "Press Ctrl+R to refresh"`,
},
{
name: "Routing Table",
description: "Box-drawn table with colored status indicators",
source: `page "Routes" 60
heading 1 "Routing Table"
table "Active Routes"
columns "Destination" 22 | "Hops" 6 | "RTT" 8 | "State" 12
row "a7f2::relay-east" | "2" | "34ms" | "@color{0f0}{● alive}"
row "c4e1::bridge-south" | "4" | "112ms" | "@color{0f0}{● alive}"
row "01ab::node-gamma" | "7" | "580ms" | "@color{f00}{○ stale}"
row "f390::hub-north" | "1" | "8ms" | "@color{0f0}{● alive}"
spacer
label "Total routes" "4"
label "Average RTT" "183ms"
divider light
link "Refresh" "/page/routes.mu"`,
},
{
name: "Nested Boxes",
description: "Four box styles with nested content",
source: `page "Box Styles" 50
heading 1 "Box Styles"
spacer
box light "Light Border"
text "Standard border style"
text "Good for general content"
spacer
box heavy "Heavy Border"
text "Thick borders for emphasis"
text "Use for alerts or highlights"
spacer
box double "Double Border"
text "Double-line borders"
text "Great for titles and headers"
spacer
box rounded "Rounded Border"
text "Soft corners"
text "A more modern feel"
spacer
heading 2 "Nested"
box double "Outer"
text "This box contains another:"
box light "Inner"
text "Nested content here"`,
},
{
name: "Data Visualization",
description: "Gauges, sparklines, and status indicators",
source: `page "Metrics" 60
box heavy "System Metrics"
align center
text "Real-time monitoring dashboard"
spacer
heading 1 "CPU & Memory"
gauge "CPU" 42 100 28
gauge "GPU" 21 100 28
gauge "MEM" 67 100 28 warn=80 crit=95
gauge "SWP" 3 100 28
spacer
heading 1 "Network Traffic"
sparkline "Inbound" "1,3,5,8,7,5,3,2,1,3,6,8,7,4" 20
sparkline "Outbound" "2,2,3,5,8,7,5,3,2,1,1,3,5,8" 20
spacer
heading 1 "Services"
row 2
col 28
status "NomadNet" online
status "LXMF Router" online
status "Sideband" online
col 28
status "Reticulum" online
status "TCP Interface" degraded
status "I2P Transport" offline
divider heavy
text "Last updated: just now"`,
},
{
name: "Full Node Page",
description: "Complete node page with all primitives",
source: `page "Relay Alpha-7" 64
box double "Relay Alpha-7"
align center
text "Reticulum Mesh Node"
text "Sector 7 - Grid Reference 4E"
spacer
heading 1 "Resources"
row 2
col 30
gauge "CPU" 62 100 26 warn=75 crit=90
gauge "MEM" 84 100 26 warn=80 crit=95
col 30
gauge "DISK" 45 100 26 warn=85 crit=95
gauge "NET" 23 100 26
spacer
heading 1 "Network"
sparkline "Traffic IN" "1,4,6,8,7,5,3,2,3,5,7,8,6,4" 20
sparkline "Traffic OUT" "2,3,5,7,8,6,4,3,4,6,8,7,5,3" 20
spacer
heading 1 "Routing"
table "Active Routes"
columns "Destination" 20 | "Hops" 6 | "RTT" 8 | "State" 10
row "relay-east" | "2" | "34ms" | "@color{0f0}{● up}"
row "bridge-south" | "4" | "112ms" | "@color{0f0}{● up}"
row "node-gamma" | "7" | "580ms" | "@color{f00}{○ down}"
row "hub-north" | "1" | "8ms" | "@color{0f0}{● up}"
spacer
heading 1 "Peers"
row 2
col 30
status "East Relay" online
status "South Bridge" online
status "Backup Node" degraded
col 30
label "Active" "7 / 12"
label "Uptime" "14d 3h"
label "Version" "0.7.2"
divider heavy
row 2
col 30
link "Home" "/page/index.mu"
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: "Components",
description: "Reusable component definitions with use std/dashboard",
source: `page "Node Overview" 60
use std/dashboard
banner "Relay Alpha-7" "Reticulum Mesh Node"
spacer
resources 62 84
spacer
heading 2 "Peers"
peer_status "East Relay" online
peer_status "South Bridge" online
peer_status "Node Gamma" degraded
divider heavy
link "Home" "/page/index.mu"`,
},
{
name: "Navigation",
description: "Horizontal bar + vertical sidebar navigation",
source: `page "Dashboard" 64
hnav bar
item "Status" "/page/status.mu" active
item "Peers" "/page/peers.mu"
item "Files" "/page/files.mu"
item "Config" "/page/config.mu"
spacer
row 1
col 18
vnav boxed
heading "Network"
item "Overview" "/page/overview.mu" active
item "Peers" "/page/peers.mu"
item "Routes" "/page/routes.mu"
separator
heading "Tools"
item "Ping" "/page/ping.mu"
item "Trace" "/page/trace.mu"
col
heading 1 "Overview"
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
spacer
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded`,
},
{
name: "Image Art",
description: "Convert images to character art (upload an image first)",
source: `page "Image Embedding" 60
heading 1 "Image Embedding"
text "Upload an image with the Image button above,"
text "then use the image keyword to embed it."
spacer
box light "Syntax"
text 'image "path.png" mode width'
spacer
label "mode" "braille | block | ascii | halfblock"
label "width" "output width in characters"
spacer
heading 2 "Options"
label "dither" "floyd | threshold | none"
label "invert" "flip light and dark"
label "align" "left | center | right"
label "caption" "text below the image"
spacer
box rounded "Example"
text 'image "logo.png" braille 30'
text ' dither floyd'
text ' align center'
text ' caption "My logo"'`,
},
{
name: "Big Title",
description: "Large ASCII art text in block, thin, and pixel fonts",
source: `page "Banner" 64
bigtitle "RELAY" block
align center
color 0cf
spacer
text "@center{Alpha-7 — Reticulum Network Node}"
spacer
bigtitle "STATUS" thin
align center
spacer
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
divider heavy
bigtitle "OK" pixel
align center
color 0f0`,
},
{
name: "Themed Page",
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",
source: `page "Node Status" 50
theme nouveau
box heavy "Relay Alpha-7"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 62 100 24 warn=75 crit=90
gauge "MEM" 84 100 24 warn=80 crit=95
spacer
heading 2 "Peers"
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
divider heavy
link "Home" "/page/index.mu"`,
},
{
name: "Dynamic Dashboard",
description: "Live data sources, conditionals, and cache control",
source: `page "Live Status" 60
cache 0
source cpu_pct : python "secrets.randbelow(60) + 20"
source mem_pct : python "secrets.randbelow(40) + 50"
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
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"`,
},
];

View File

@@ -0,0 +1,80 @@
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { tags } from "@lezer/highlight";
/**
* CodeMirror 6 syntax highlighting for INI-style config files
* (Reticulum .conf / NomadNet .conf).
*
* Supports: [sections], [[subsections]], key = value, # comments,
* booleans, numbers, and quoted strings.
*/
const BOOLEANS = new Set([
"true", "false", "yes", "no", "on", "off", "none",
]);
const iniLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.match(/\s*#/)) {
stream.skipToEnd();
return "lineComment";
}
// Skip whitespace
if (stream.eatSpace()) return null;
// Subsection headers [[name]]
if (stream.match(/\[\[.*?\]\]/)) return "heading";
// Section headers [name]
if (stream.match(/\[.*?\]/)) return "typeName";
// Quoted strings
if (stream.match(/"/)) {
while (!stream.eol()) {
if (stream.next() === '"') break;
}
return "string";
}
// Assignment operator
if (stream.match(/=/)) return "punctuation";
// Numbers (integers, floats, ports, IPs with dots)
if (stream.match(/\b\d[\d.]*\b/)) return "number";
// Words
if (stream.match(/[\w\-_.]+/)) {
const word = stream.current().toLowerCase();
if (BOOLEANS.has(word)) return "atom";
// Keys appear before '=', values after — both are plain words
return null;
}
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const iniStyle = HighlightStyle.define([
{ tag: tags.typeName, color: "#c792ea", fontWeight: "bold" }, // [section]
{ tag: tags.heading, color: "#82aaff", fontWeight: "bold" }, // [[subsection]]
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
{ tag: tags.string, color: "#c3e88d" },
{ tag: tags.number, color: "#f78c6c" },
{ tag: tags.atom, color: "#89ddff" }, // booleans
{ tag: tags.punctuation, color: "#89ddff" }, // =
]);
export function iniHighlight() {
return [iniLanguage, syntaxHighlighting(iniStyle)];
}

View File

@@ -0,0 +1,25 @@
/**
* Micron markup → HTML renderer using the micron-parser library.
* Reference: https://github.com/RFnexus/micron-parser-js
*
* Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml
* to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags.
*/
import MicronParser from "micron-parser";
let darkParser: MicronParser | null = null;
let lightParser: MicronParser | null = null;
export function renderMicron(source: string, darkTheme: boolean = true): string {
const parser = darkTheme
? (darkParser ??= new MicronParser(true, true))
: (lightParser ??= new MicronParser(false, true));
const fragment = parser.convertMicronToFragment(source);
// Serialize the fragment to HTML string
const div = document.createElement("div");
div.appendChild(fragment);
return div.innerHTML;
}

View File

@@ -0,0 +1,30 @@
import { EditorView } from "@codemirror/view";
export const oneDark = EditorView.theme(
{
"&": {
backgroundColor: "var(--background)",
color: "var(--foreground)",
},
".cm-cursor, .cm-cursor-primary": {
borderLeftColor: "var(--primary)",
borderLeftWidth: "0.5em",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "color-mix(in oklch, var(--primary) 25%, transparent)",
},
".cm-activeLine": {
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
},
".cm-gutters": {
backgroundColor: "var(--background)",
color: "var(--muted-foreground)",
borderRight: "1px solid var(--border)",
},
".cm-activeLineGutter": {
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
color: "var(--primary)",
},
},
{ dark: true }
);

View File

@@ -0,0 +1,190 @@
import { snippet } from "@codemirror/autocomplete";
import type {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
import { fetchDslMeta } from "@/api/client";
/**
* µFrame slash command palette — auto-populated from the backend DSL registry.
*
* On first load, uses a minimal fallback set. Once the API responds,
* the full command list replaces it via loadCommandsFromApi().
*/
interface CmdEntry {
label: string;
detail: string;
section: string;
apply: Completion["apply"];
}
// Module-level cache — survives re-renders but not full page reload.
let commands: CmdEntry[] | null = null;
let loaded = false;
function insert(text: string): Completion["apply"] {
return (view: EditorView, _c: Completion, from: number, to: number) => {
view.dispatch({ changes: { from: from - 1, to, insert: text } });
};
}
function slashSnippet(template: string): Completion["apply"] {
const snip = snippet(template);
return (view: EditorView, c: Completion, from: number, to: number) => {
snip(view, c, from - 1, to);
};
}
const FALLBACK_COMMANDS: CmdEntry[] = [
{ label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') },
{ label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') },
{ label: "heading", detail: 'heading 1 "Text"', section: "Content", apply: slashSnippet('heading ${level:1} "${text}"') },
{ label: "text", detail: 'text "Content"', section: "Content", apply: slashSnippet('text "${content}"') },
{ label: "gauge", detail: "gauge label val max", section: "Data", apply: slashSnippet('gauge "${label}" ${value} ${max:100} ${width:28}') },
{ label: "status", detail: "status label state", section: "Data", apply: slashSnippet('status "${label}" ${state:online}') },
];
function getCommands(): CmdEntry[] {
return commands ?? FALLBACK_COMMANDS;
}
/**
* Load the full command list from the backend DSL registry.
* Called once on editor mount.
*/
export async function loadCommandsFromApi(): Promise<void> {
if (loaded) return;
try {
const data = await fetchDslMeta();
const apiCommands: CmdEntry[] = [];
for (const cmd of data.commands ?? []) {
if (!cmd.detail || !cmd.snippet) continue;
apiCommands.push({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.snippet.includes("${")
? slashSnippet(cmd.snippet)
: insert(cmd.snippet),
});
}
// Dashboard template (not in registry)
apiCommands.push({
label: "dashboard",
detail: "Full dashboard template",
section: "Template",
apply: insert(
`page "Dashboard" 64\n box double "Node Status"\n align center\n text "Reticulum Network Node"\n\n spacer\n\n heading 1 "Resources"\n\n gauge "CPU" 0 100 28 warn=75 crit=90\n gauge "MEM" 0 100 28 warn=80 crit=95\n\n spacer\n\n heading 2 "Network"\n\n status "Relay East" online\n status "Bridge South" online\n\n divider heavy\n link "Home" "/page/index.mu"`,
),
});
commands = apiCommands;
loaded = true;
} catch {
// Keep using fallback
}
}
export function uframeCommandSource(
ctx: CompletionContext,
): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null;
return {
from: match.from + 1,
filter: true,
options: getCommands().map((cmd) => ({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.apply,
boost: 99,
})),
};
}
/**
* Attribute value hints — suggests valid values based on the keyword
* on the current line.
*/
const KEYWORD_VALUES: Record<string, { values: string[]; hint: string }> = {
box: { values: ["light", "heavy", "double", "rounded"], hint: "border weight" },
divider: { values: ["light", "heavy", "double", "dash", "dot"], hint: "divider style" },
heading: { values: ["1", "2", "3"], hint: "heading level" },
list: { values: ["bullet", "dash", "number", "arrow"], hint: "list style" },
align: { values: ["left", "center", "right"], hint: "alignment" },
status: { values: ["online", "offline", "degraded", "unknown", "alert"], hint: "state" },
hnav: { values: ["bar", "tabs", "pills", "breadcrumb", "underline"], hint: "nav style" },
vnav: { values: ["list", "boxed", "tree", "sidebar", "minimal"], hint: "nav style" },
bigtitle: { values: ["block", "thin", "pixel"], hint: "font" },
image: { values: ["braille", "block", "ascii", "halfblock"], hint: "render mode" },
dither: { values: ["floyd", "threshold", "none"], hint: "dither algorithm" },
source: { values: ["shell", "file", "json", "python", "rns", "param"], hint: "source type" },
theme: { values: ["default", "nouveau", "gothic", "bamboo", "circuit", "brutalist"], hint: "theme" },
};
export function uframeValueHintSource(
ctx: CompletionContext,
): CompletionResult | null {
const line = ctx.state.doc.lineAt(ctx.pos);
const textBefore = line.text.slice(0, ctx.pos - line.from);
if (textBefore.trimStart().startsWith("/")) return null;
const kwMatch = textBefore.match(/^\s*(\w+)\s/);
if (!kwMatch) return null;
const keyword = kwMatch[1].toLowerCase();
const entry = KEYWORD_VALUES[keyword];
if (!entry) return null;
const quotesBefore = (textBefore.match(/"/g) || []).length;
if (quotesBefore % 2 !== 0) return null;
const colonWordMatch = ctx.matchBefore(/\w+:\w*/);
if (colonWordMatch) {
return {
from: colonWordMatch.from,
filter: false,
options: entry.values.map((v) => ({
label: v,
detail: entry.hint,
type: "enum" as const,
})),
};
}
const wordMatch = ctx.matchBefore(/\w+/);
if (!wordMatch) {
if (!ctx.explicit) return null;
return {
from: ctx.pos,
options: entry.values.map((v) => ({
label: v,
detail: entry.hint,
type: "enum" as const,
})),
};
}
const typed = ctx.state.sliceDoc(wordMatch.from, wordMatch.to);
if (typed.toLowerCase() === keyword) return null;
return {
from: wordMatch.from,
filter: true,
options: entry.values.map((v) => ({
label: v,
detail: entry.hint,
type: "enum" as const,
boost: -1,
})),
};
}

View File

@@ -0,0 +1,135 @@
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { tags } from "@lezer/highlight";
/**
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
*
* Keywords and values can be updated dynamically via setDslKeywords()
* which is called when the frontend fetches /api/dsl-meta.
*/
// Mutable sets — updated from /api/dsl-meta
let KEYWORDS = new Set([
// Layout
"page", "box", "row", "col", "spacer", "pad",
// Content
"heading", "text", "label", "divider", "link", "list", "item",
"bigtitle", "image", "dither", "invert", "caption",
// Data
"gauge", "sparkline", "status", "table", "columns",
// Navigation
"hnav", "vnav", "separator",
// Form
"form", "field", "radio", "checkbox", "button", "password",
// Style
"align", "color", "bg", "bold", "italic", "underline", "theme",
// Dynamic
"source", "let", "if", "elif", "else", "for", "cache", "state", "on_submit",
"set", "append", "prepend",
// Components
"component", "use",
]);
let WEIGHT_VALS = new Set([
// Border weights
"light", "heavy", "double", "rounded",
// List styles
"bullet", "dash", "number", "arrow",
// Alignment
"left", "center", "right",
// Status states
"online", "offline", "degraded", "unknown", "alert",
// Source types
"shell", "file", "json", "python", "rns", "param",
// Themes
"default", "nouveau", "gothic", "bamboo", "circuit", "brutalist",
// Nav styles
"bar", "tabs", "pills", "breadcrumb", "underline",
"boxed", "tree", "sidebar", "minimal",
// Fonts
"block", "thin", "pixel",
// Image modes
"braille", "ascii", "halfblock",
// Dither
"floyd", "threshold", "none",
// Divider styles
"dot",
// Misc
"active", "compact",
]);
/** Update keywords and values from /api/dsl-meta response. */
export function setDslKeywords(keywords: string[], values: string[]) {
if (keywords.length > 0) KEYWORDS = new Set(keywords);
if (values.length > 0) WEIGHT_VALS = new Set(values);
}
const uframeLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.sol() && stream.match(/\s*#/)) {
stream.skipToEnd();
return "lineComment";
}
// Skip whitespace
if (stream.eatSpace()) return null;
// Quoted strings
if (stream.match(/"/)) {
while (!stream.eol()) {
if (stream.next() === '"') break;
}
return "string";
}
// @modifier{} syntax
if (stream.match(/@\w+/)) return "keyword";
// $variable references
if (stream.match(/\$[\w.]+/)) return "variableName";
// Numbers (integers, floats, hex colors)
if (stream.match(/\b\d+(\.\d+)?\b/)) return "number";
// warn=N, crit=N parameters
if (stream.match(/\b(warn|crit)=/)) return "attributeName";
// Pipe separator for inline lists
if (stream.match(/\|/)) return "punctuation";
// Keywords and values
if (stream.match(/\b\w+\b/)) {
const word = stream.current();
if (KEYWORDS.has(word)) return "keyword";
if (WEIGHT_VALS.has(word)) return "atom";
return null;
}
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const uframeStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#c792ea", fontWeight: "bold" },
{ tag: tags.string, color: "#c3e88d" },
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
{ tag: tags.variableName, color: "#f78c6c" },
{ tag: tags.number, color: "#f78c6c" },
{ tag: tags.atom, color: "#89ddff" },
{ tag: tags.attributeName, color: "#ffcb6b" },
{ tag: tags.punctuation, color: "#89ddff" },
]);
export function uframeHighlight() {
return [uframeLanguage, syntaxHighlighting(uframeStyle)];
}

View File

@@ -0,0 +1,281 @@
import { hoverTooltip, type EditorView, type Tooltip } from "@codemirror/view";
/**
* µFrame keyword hover tooltips — shows syntax, attributes, and examples
* when hovering over a DSL keyword in the editor.
*/
interface KeywordDoc {
syntax: string;
attrs: string;
example: string;
}
const KEYWORD_DOCS: Record<string, KeywordDoc> = {
// Layout
page: {
syntax: 'page "title" [width]',
attrs: "title: string — page title\nwidth: number — page width in chars (default: 64)",
example: 'page "Node Status" 60',
},
box: {
syntax: 'box [weight] "title"',
attrs: "weight: light | heavy | double | rounded\ntitle: string — title in top border",
example: 'box double "System"\n text "Content here"',
},
row: {
syntax: "row [gap]",
attrs: "gap: number — space between columns (default: 1)",
example: "row 2\n col 20\n text \"Left\"\n col\n text \"Right\"",
},
col: {
syntax: "col [width]",
attrs: "width: number — column width in chars (auto if omitted)",
example: "col 30\n text \"Fixed width column\"",
},
spacer: {
syntax: "spacer [lines]",
attrs: "lines: number — vertical space (default: 1)",
example: "spacer 2",
},
pad: {
syntax: "pad [top] [right] [bottom] [left]",
attrs: "top, right, bottom, left: number — padding in chars",
example: "pad 1 2 1 2\n text \"Padded content\"",
},
// Content
heading: {
syntax: 'heading [level] "text"',
attrs: "level: 1 | 2 | 3 — heading size\ntext: string — heading text",
example: 'heading 1 "Main Title"',
},
text: {
syntax: 'text "content"',
attrs: "content: string — supports @bold{}, @italic{},\n @color{hex}{}, @bg{hex}{}, @under{}",
example: 'text "Hello @bold{world} @color{0f0}{green}"',
},
label: {
syntax: 'label "key" "value"',
attrs: "key: string — label name (bold)\nvalue: string — label value",
example: 'label "Uptime" "14d 3h 22m"',
},
divider: {
syntax: "divider [style]",
attrs: "style: light | heavy | double | dash | dot",
example: "divider heavy",
},
link: {
syntax: 'link "display" "destination"',
attrs: "display: string — visible text\ndestination: string — Micron page path",
example: 'link "Home" "/page/index.mu"',
},
list: {
syntax: "list [style]",
attrs: "style: bullet | dash | number | arrow",
example: 'list bullet\n item "First entry"\n item "Second entry"',
},
item: {
syntax: 'item "text" OR item "label" "dest" [active]',
attrs: "text: string — list item content\nlabel, dest: nav item with link\nactive: flag — highlight as current",
example: 'item "Entry" — in list\nitem "Home" "/page/index.mu" active — in nav',
},
// Data Visualization
gauge: {
syntax: 'gauge "label" value max width [warn=N] [crit=N]',
attrs: "label: string — gauge name\nvalue: number — current value\nmax: number — maximum\nwidth: number — bar width in chars\nwarn: number — warning threshold\ncrit: number — critical threshold",
example: 'gauge "CPU" 62 100 28 warn=75 crit=90',
},
sparkline: {
syntax: 'sparkline "label" "values" width',
attrs: "label: string — chart name\nvalues: string — comma-separated numbers\nwidth: number — chart width in chars",
example: 'sparkline "Traffic" "1,3,5,8,7,5,3" 20',
},
status: {
syntax: 'status "label" state',
attrs: "label: string — indicator name\nstate: online | offline | degraded | unknown | alert",
example: 'status "East Relay" online',
},
table: {
syntax: 'table "title"',
attrs: "title: string — table caption\nchildren: columns + row entries",
example: 'table "Routes"\n columns "Dest" 20 | "Hops" 6\n row "east" | "2"',
},
columns: {
syntax: 'columns "name" width | "name" width | ...',
attrs: "name: string — column header\nwidth: number — column width in chars\nseparated by | pipes",
example: 'columns "Name" 20 | "Status" 10',
},
// Navigation
hnav: {
syntax: "hnav [style]",
attrs: "style: bar | tabs | pills | breadcrumb | underline\nchildren: item, separator",
example: 'hnav bar\n item "Status" "/page/status.mu" active\n item "Peers" "/page/peers.mu"',
},
vnav: {
syntax: "vnav [style] [width]",
attrs: "style: list | boxed | tree | sidebar | minimal\nwidth: number — panel width (auto if omitted)\nchildren: item, separator, heading",
example: 'vnav boxed\n heading "Section"\n item "Page" "/page/page.mu" active',
},
// Forms
form: {
syntax: 'form "name"',
attrs: "name: string — form identifier\nchildren: field, password, radio, checkbox, button",
example: 'form "search"\n field "query" 30 "Search..."\n button "Go" "/page/search.mu"',
},
field: {
syntax: 'field "name" [width] "placeholder"',
attrs: "name: string — field name\nwidth: number — input width (default: 24)\nplaceholder: string — hint text",
example: 'field "query" 30 "Enter search term..."',
},
password: {
syntax: 'password "name" [width] "placeholder"',
attrs: "name: string — field name\nwidth: number — input width (default: 24)\nplaceholder: string — hint text",
example: 'password "pass" 24 "Enter password"',
},
radio: {
syntax: 'radio "group" "opt1" | "opt2" | "opt3"',
attrs: "group: string — radio group name\noptions: strings separated by | pipes",
example: 'radio "mode" "Ping" | "Trace" | "Page"',
},
checkbox: {
syntax: 'checkbox "name" "label"',
attrs: "name: string — field name\nlabel: string — display text",
example: 'checkbox "verbose" "Verbose output"',
},
button: {
syntax: 'button "label" "destination"',
attrs: "label: string — button text\ndestination: string — link target on click",
example: 'button "Submit" "/page/submit.mu"',
},
// Big Text & Image
bigtitle: {
syntax: 'bigtitle "text" [font]',
attrs: "text: string — text to render large\nfont: block | thin | pixel\nmodifiers: align, color",
example: 'bigtitle "RELAY" block\n align center\n color 0cf',
},
image: {
syntax: 'image "path" [mode] [width]',
attrs: "path: string — image file path\nmode: braille | block | ascii | halfblock\nwidth: number — output width in chars\nmodifiers: dither, invert, align, caption",
example: 'image "logo.png" braille 30\n dither floyd\n caption "Logo"',
},
// Style
align: {
syntax: "align [direction]",
attrs: "direction: left | center | right",
example: "align center",
},
color: {
syntax: "color [hex]",
attrs: "hex: 3-digit hex color (e.g. 0f0, f00, 0cf)",
example: "color 0cf",
},
bold: {
syntax: "bold",
attrs: "no arguments — applies bold to parent",
example: "box light \"Title\"\n bold\n text \"Bold content\"",
},
theme: {
syntax: "theme [name]",
attrs: "name: default | nouveau | gothic | bamboo | circuit | brutalist",
example: "theme nouveau",
},
// Dynamic
source: {
syntax: 'source name : type "command"',
attrs: "name: string — variable name\ntype: shell | file | json | python | rns | param\ncommand: string — command to execute",
example: 'source cpu : shell "cat /proc/loadavg"',
},
let: {
syntax: 'let name = "value"',
attrs: "name: string — variable name\nvalue: string or number",
example: 'let node_name = "Relay Alpha"',
},
cache: {
syntax: "cache [seconds]",
attrs: "seconds: number — cache duration (0 = never cache)",
example: "cache 0",
},
if: {
syntax: "if condition",
attrs: "condition: expression with $variables\nchildren: content to show when true",
example: "if $cpu > 90\n text \"ALERT: CPU critical\"",
},
for: {
syntax: "for var in $collection",
attrs: "var: string — loop variable name\ncollection: $variable — iterable data",
example: "for peer in $peers\n status \"$peer\" online",
},
on_submit: {
syntax: 'on_submit "form_name"',
attrs: "form_name: string — form to handle\nchildren: handler logic with $field vars",
example: 'on_submit "search"\n source results : shell "search.py \'$query\'"',
},
state: {
syntax: 'state "name" "path"',
attrs: "name: string — state variable name\npath: string — JSON file path for persistence",
example: 'state "counter" "/tmp/counter.json"',
},
// Components
component: {
syntax: "component name(arg1, arg2, ...)",
attrs: "name: string — component name\nargs: parameter names\nchildren: component body template",
example: 'component stat(label, value, max)\n gauge "$label" $value $max 20',
},
use: {
syntax: 'use "library"',
attrs: "library: std/dashboard | std/status-bar | std/nav |\n std/network | std/form | or file path",
example: "use std/dashboard\nbanner \"My Node\" \"Mesh\"",
},
};
function getWordAt(view: EditorView, pos: number): { word: string; from: number; to: number } | null {
const line = view.state.doc.lineAt(pos);
const text = line.text;
const col = pos - line.from;
let start = col;
let end = col;
while (start > 0 && /\w/.test(text[start - 1])) start--;
while (end < text.length && /\w/.test(text[end])) end++;
if (start === end) return null;
return { word: text.slice(start, end), from: line.from + start, to: line.from + end };
}
export const keywordHoverTooltip = hoverTooltip((view, pos) => {
const result = getWordAt(view, pos);
if (!result) return null;
const doc = KEYWORD_DOCS[result.word.toLowerCase()];
if (!doc) return null;
return {
pos: result.from,
end: result.to,
above: true,
create() {
const dom = document.createElement("div");
dom.className = "cm-keyword-tooltip";
dom.innerHTML = `
<div style="font-family:var(--font-mono);font-size:11px;max-width:360px;line-height:1.4">
<div style="color:var(--primary);font-weight:bold;margin-bottom:4px;font-size:12px">${escHtml(doc.syntax)}</div>
<div style="color:var(--muted-foreground);white-space:pre-wrap;margin-bottom:6px;border-bottom:1px solid var(--border);padding-bottom:6px">${escHtml(doc.attrs)}</div>
<div style="color:var(--foreground);opacity:0.8;white-space:pre;background:var(--muted);padding:4px 6px;margin:-2px -6px -6px">${escHtml(doc.example)}</div>
</div>
`;
return { dom };
},
} satisfies Tooltip;
});
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import browserSvg from "@/assets/browser.min.svg";
import NavMenu from "./NavMenu";
import LazyEyes from "./LazyEyes";
const TITLE = `
▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄████████ ▄██████▄ ███▄▄▄▄ ▄██████▄ ▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄██████▄ ███▄▄▄▄
▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███▀▀▀██▄ ███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███▀▀▀██▄
███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███
███ ███ ███ ███▌ ███ ▄███▄▄▄▄██▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
███ ███ ███ ███▌ ███ ▀▀███▀▀▀▀▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
███ ███ ███ ███ ███ █▄ ▀███████████ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ ███ ███ ███ ███
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
▀█ ███ █▀ █▀ ████████▀ ███ ███ ▀██████▀ ▀█ █▀ ▀██████▀ ▀█ ███ █▀ █▀ ████████▀ ▀██████▀ ▀█ █▀
███ ███
`;
type Theme = "terra" | "azure";
function getStoredTheme(): Theme {
try { return (localStorage.getItem("micronomicon-theme") as Theme) || "terra"; }
catch { return "terra"; }
}
function toRoman(n: number): string {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
let r = "";
for (let i = 0; i < vals.length; i++) {
while (n >= vals[i]) { r += syms[i]; n -= vals[i]; }
}
return r;
}
function RomanDrum({ value }: { value: string }) {
return (
<span
style={{
display: "inline-block",
overflow: "hidden",
height: "1em",
lineHeight: "1em",
verticalAlign: "bottom",
}}
>
<span
key={value}
style={{
display: "inline-block",
animation: "romanSlideIn 0.25s cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
{value}
</span>
</span>
);
}
function RomanClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
const h = time.getHours();
const m = time.getMinutes();
const s = time.getSeconds();
return (
<span style={{ display: "inline-flex", alignItems: "baseline", gap: 0 }}>
<RomanDrum value={toRoman(h || 12)} />
<span>:</span>
<RomanDrum value={toRoman(m || 1)} />
<span>:</span>
<RomanDrum value={toRoman(s || 1)} />
</span>
);
}
// ---------------------------------------------------------------------------
// Per-frame layout configs
// ---------------------------------------------------------------------------
interface FrameLayout {
svg: string;
frameHeight: number;
containerMaxW: string;
containerMinW: string;
title: { paddingTop: number; height: number; paddingLeft: number; paddingRight: number; paddingBottom: number };
content: { marginLeft: number; marginTop: number; marginRight: number; width: number; height: number; paddingTop: number; paddingBottom: number };
nav: { top: number; left: number };
}
const frameLayout: FrameLayout = {
svg: browserSvg,
frameHeight: 884,
containerMaxW: "max-w-5xl",
containerMinW: "min-w-5xl",
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
content: { marginLeft: 268, marginTop: 63, marginRight: 0, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
nav: { top: 150, left: -210 },
};
export default function AppShell({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [theme, setTheme] = useState<Theme>(getStoredTheme);
const layout = frameLayout;
useEffect(() => {
const root = document.documentElement;
// Remove all theme classes, then apply
root.classList.remove("dark", "theme-azure");
if (theme === "terra") {
root.classList.add("dark");
} else {
root.classList.add("theme-azure");
}
localStorage.setItem("micronomicon-theme", theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
return (
<TooltipProvider>
<div className="flex flex-col h-screen bg-background text-foreground">
<main className="flex-1 overflow-auto" data-scroll-root>
<div className={`relative ${layout.containerMaxW} ${layout.containerMinW} mx-auto min-h-full`}>
{/* Frame SVG — background */}
<div
aria-hidden
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
style={{
zIndex: 0,
height: `${layout.frameHeight}px`,
background: "var(--primary)",
WebkitMaskImage: `url(${layout.svg})`,
maskImage: `url(${layout.svg})`,
WebkitMaskSize: "cover",
maskSize: "cover",
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
objectFit: "fill"
}}
/>
{/* Lazy eyes on the frame figure */}
<LazyEyes
eyes={[
{ top: 81, left: 554, size: 5, irisSize: 2 },
{ top: 85, left: 562, size: 5, irisSize: 2 },
]}
maxShift={2}
/>
{/* Top hole — ASCII title */}
<div
className="relative z-10 flex flex-col overflow-hidden"
style={layout.title}
>
<pre
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center cursor-pointer bg-background"
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
onClick={() => navigate("/")}
>
{TITLE}
</pre>
<span className="font-mono text-[10px] w-fit text-muted-foreground bg-background"><RomanClock /></span>
</div>
{/* Bottom hole — main content */}
<div
className="relative z-10 overflow-auto"
style={layout.content}
>
{children}
</div>
{/* Nav menu — anchored below the frame, left side */}
<div
className="absolute z-20"
style={layout.nav}
>
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
</div>
</div>
</main>
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
<span>Created with hubris · MMXXVI</span>
</footer>
</div>
<Toaster />
</TooltipProvider>
);
}

View File

@@ -0,0 +1,154 @@
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
export const DITHERED_SHADOW = `
3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border),
4px 4px 0 0 transparent, 6px 4px 0 0 var(--border),
3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border),
4px 6px 0 0 var(--border), 6px 6px 0 0 transparent
`;
export interface FloatingWindowProps {
id: string;
title: string;
x: number;
y: number;
w: number;
h: number;
zIndex: number;
focused: boolean;
onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
minW?: number;
minH?: number;
addressBar?: ReactNode;
footer?: ReactNode;
containerRef?: React.RefObject<HTMLDivElement | null>;
children: ReactNode;
}
export default function FloatingWindow({
id, title, x, y, w, h, zIndex, focused,
onUpdate, onClose, onFocus,
minW = 320, minH = 200,
addressBar, footer, containerRef, children,
}: FloatingWindowProps) {
const internalRef = useRef<HTMLDivElement>(null);
const ref = containerRef ?? internalRef;
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
const velocityRef = useRef({ vx: 0, vy: 0, lastX: 0, lastY: 0, lastT: 0 });
const inertiaRef = useRef(0);
const posRef = useRef({ x, y });
posRef.current = { x, y };
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
// Cancel any running inertia animation
const stopInertia = useCallback(() => {
if (inertiaRef.current) { cancelAnimationFrame(inertiaRef.current); inertiaRef.current = 0; }
}, []);
const onDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); onFocus(id);
ref.current?.focus();
stopInertia();
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y };
velocityRef.current = { vx: 0, vy: 0, lastX: e.clientX, lastY: e.clientY, lastT: performance.now() };
document.documentElement.classList.add("cursor-grabbing");
const onMove = (ev: MouseEvent) => {
if (!dragRef.current) return;
const now = performance.now();
const dt = now - velocityRef.current.lastT;
if (dt > 0) {
const smooth = 0.3;
const rawVx = (ev.clientX - velocityRef.current.lastX) / dt * 16;
const rawVy = (ev.clientY - velocityRef.current.lastY) / dt * 16;
velocityRef.current.vx = velocityRef.current.vx * (1 - smooth) + rawVx * smooth;
velocityRef.current.vy = velocityRef.current.vy * (1 - smooth) + rawVy * smooth;
velocityRef.current.lastX = ev.clientX;
velocityRef.current.lastY = ev.clientY;
velocityRef.current.lastT = now;
}
onUpdate(id, {
x: dragRef.current.origX + (ev.clientX - dragRef.current.startX),
y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)),
});
};
const onUp = () => {
const { vx, vy } = velocityRef.current;
dragRef.current = null;
document.documentElement.classList.remove("cursor-grabbing");
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
// Kick off inertia if there's meaningful velocity
if (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5) {
let curVx = vx;
let curVy = vy;
const friction = 0.92;
const el = ref.current;
const tick = () => {
curVx *= friction;
curVy *= friction;
if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) {
inertiaRef.current = 0;
// Sync final position to React state once
onUpdate(id, posRef.current);
return;
}
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
// Direct DOM update during animation — skip React reconciliation
if (el) {
el.style.left = `${posRef.current.x}px`;
el.style.top = `${posRef.current.y}px`;
}
inertiaRef.current = requestAnimationFrame(tick);
};
inertiaRef.current = requestAnimationFrame(tick);
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [id, x, y, onUpdate, onFocus, stopInertia]);
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onFocus(id);
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h };
document.documentElement.classList.add("cursor-nwse-resize");
const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); };
const onUp = () => { resizeRef.current = null; document.documentElement.classList.remove("cursor-nwse-resize"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [id, w, h, minW, minH, onUpdate, onFocus]);
return createPortal(
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)}
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
{/* Title bar */}
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
<div className="flex items-center gap-1.5">
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
</div>
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
</div>
{/* Optional address bar */}
{addressBar}
{/* Content */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Optional footer */}
{footer}
{/* Resize handle */}
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
</div>
</div>, document.body);
}

View File

@@ -0,0 +1,77 @@
import { useRef, useEffect, useCallback } from "react";
import { useLazyEyes } from "@/hooks/useLazyEyes";
interface EyeSpec {
top: number;
left: number;
size?: number;
irisSize?: number;
}
interface LazyEyesProps {
eyes: EyeSpec[];
/** Viewport-relative anchor the eyes "live" at. If omitted, uses the component's own position. */
anchor?: { x: number; y: number };
maxShift?: number;
ease?: number;
className?: string;
}
export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: LazyEyesProps) {
const containerRef = useRef<HTMLDivElement>(null);
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
useEffect(() => {
if (anchor) {
anchorRef.current = anchor;
return;
}
const update = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
anchorRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [anchor]);
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef, maxShift, ease });
const irisRef = useCallback((el: HTMLElement | null) => {
if (el) registerIris(el);
return () => { if (el) unregisterIris(el); };
}, [registerIris, unregisterIris]);
return (
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
{eyes.map((eye, i) => {
const size = eye.size ?? 5;
const irisSize = eye.irisSize ?? 2;
return (
<div
key={i}
className="editor-pointer-eye"
style={{ top: eye.top, left: eye.left, width: size, height: size }}
>
<div
ref={irisRef}
className="editor-pointer-iris"
style={{
width: irisSize,
height: irisSize,
marginTop: -(irisSize / 2) - 0.5,
marginLeft: -(irisSize / 2) - 0.5,
}}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import loadingSvg from "@/assets/loading.min.svg";
interface LoaderProps {
size?: number;
className?: string;
}
export default function Loader({ size = 120, className = "" }: LoaderProps) {
return (
<div
className={`animate-spin-slow ${className}`}
style={{
width: size,
height: size,
background: "var(--primary)",
maskImage: `url(${loadingSvg})`,
maskSize: "contain",
maskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskImage: `url(${loadingSvg})`,
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
}}
/>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import menuSvg from "@/assets/menu.min.svg";
const NAV_ITEMS = [
{ label: "Browse", path: "/browse" },
{ label: "Compose", path: "/" },
{ label: "Settings", path: "/settings" },
] as const;
/**
* Smooth scroll-following: tracks the scroll container and lerps
* the menu's vertical offset so it glides into place with eased acceleration.
*/
function useSmoothScroll(scrollSelector: string, ease = 0.08) {
const [offset, setOffset] = useState(0);
const targetRef = useRef(0);
const currentRef = useRef(0);
const rafRef = useRef(0);
useEffect(() => {
const container = document.querySelector(scrollSelector);
if (!container) return;
const onScroll = () => {
targetRef.current = container.scrollTop;
};
container.addEventListener("scroll", onScroll, { passive: true });
const tick = () => {
const diff = targetRef.current - currentRef.current;
if (Math.abs(diff) < 0.5) {
currentRef.current = targetRef.current;
} else {
currentRef.current += diff * ease;
}
setOffset(currentRef.current);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
container.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
};
}, [scrollSelector, ease]);
return offset;
}
interface NavMenuProps {
theme: "terra" | "azure";
onToggleTheme: () => void;
}
export default function NavMenu({ theme, onToggleTheme }: NavMenuProps) {
const navigate = useNavigate();
const location = useLocation();
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
return (
<div
className="nav-menu-root"
style={{ transform: `translateY(${scrollY}px)` }}
>
{/* Frame — menu.min.svg as mask, colored by theme */}
<div
className="nav-menu-frame"
style={{
WebkitMaskImage: `url(${menuSvg})`,
maskImage: `url(${menuSvg})`,
}}
/>
{/* Navigation links */}
<nav className="nav-menu-links">
{NAV_ITEMS.map(({ label, path }) => {
const active = location.pathname === path;
return (
<button
key={path}
onClick={() => navigate(path)}
className="nav-menu-item"
data-active={active || undefined}
>
{label}
</button>
);
})}
</nav>
{/* Theme toggle — bottom container */}
<button
onClick={onToggleTheme}
className="nav-menu-theme-toggle"
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
>
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
</button>
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,59 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
data-variant={variant}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-9999"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-heading font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,48 @@
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -0,0 +1,47 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-7 px-1.5 text-left align-middle font-medium whitespace-nowrap text-foreground text-xs [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"px-1.5 py-1 align-middle whitespace-nowrap text-xs [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,67 @@
import { useCallback, useEffect, useRef } from "react";
import { useStore, type StoreApi } from "zustand";
import { useEditorStore } from "@/stores/editorStore";
import type { EditorStore } from "@/stores/editorStore";
import { compile } from "@/api/client";
const DEBOUNCE_MS = 400;
/**
* Debounced hook that compiles µFrame source via the API.
* Automatically triggers on ufSource changes.
* Accepts an optional store API for per-window instances; falls back to the global singleton.
*/
export function useCompile(storeApi?: StoreApi<EditorStore>) {
const store = storeApi ?? useEditorStore;
const ufSource = useStore(store, (s) => s.ufSource);
const setCompileResult = useStore(store, (s) => s.setCompileResult);
const setCompiling = useStore(store, (s) => s.setCompiling);
const setCompileError = useStore(store, (s) => s.setCompileError);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const doCompile = useCallback(
async (source: string) => {
if (!source.trim()) {
setCompileResult("", "", "", false, []);
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setCompiling(true);
try {
const data = await compile(source, controller.signal);
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");
}
},
[setCompileResult, setCompiling, setCompileError],
);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => doCompile(ufSource), DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [ufSource, doCompile]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { fetchDslMeta, type DslMeta } from "@/api/client";
export type { DslMeta } from "@/api/client";
const DEFAULT_META: DslMeta = {
keywords: [],
values: [],
commands: [],
themes: [],
};
let cachedMeta: DslMeta | null = null;
export function useDslMeta(): DslMeta {
const [meta, setMeta] = useState<DslMeta>(cachedMeta || DEFAULT_META);
useEffect(() => {
if (cachedMeta) return;
fetchDslMeta()
.then((data) => {
cachedMeta = data;
setMeta(data);
})
.catch(() => {});
}, []);
return meta;
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
/**
* Registers Ctrl/Cmd+S (save) and optionally Ctrl/Cmd+P (publish) keyboard shortcuts.
* Pass `enabled = false` to temporarily disable (e.g. when a window is not focused).
*/
export function useKeyboardSave(
onSave: () => void,
onPublish?: () => void,
enabled = true,
) {
useEffect(() => {
if (!enabled) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
onSave();
}
if (onPublish && (e.metaKey || e.ctrlKey) && e.key === "p") {
e.preventDefault();
onPublish();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onSave, onPublish, enabled]);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useRef, useCallback } from "react";
interface LazyEyesOptions {
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
anchorRef: React.RefObject<{ x: number; y: number } | null>;
/** Max pixel shift for the iris (default 1.5) */
maxShift?: number;
/** Lerp ease factor 01 for slow drift (default 0.04) */
ease?: number;
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
saccadeThreshold?: number;
/** Fast ease for saccade snap (default 0.35) */
saccadeEase?: number;
}
/**
* Returns a register function to attach iris elements for direct DOM updates.
* No React state is set per frame — transforms are applied directly.
*
* Movement model:
* - Small mouse moves → slow, lazy drift (ease)
* - Large jumps → quick saccade snap (saccadeEase), then settle
* - Tiny random micro-drift to avoid perfectly still eyes
*/
export function useLazyEyes({
anchorRef,
maxShift = 1.5,
ease = 0.04,
saccadeThreshold = 0.8,
saccadeEase = 0.35,
}: LazyEyesOptions) {
const targetRef = useRef({ x: 0, y: 0 });
const currentRef = useRef({ x: 0, y: 0 });
const velocityRef = useRef({ x: 0, y: 0 });
const irisesRef = useRef<Set<HTMLElement>>(new Set());
const offsetRef = useRef({ x: 0, y: 0 });
const registerIris = useCallback((el: HTMLElement | null) => {
if (el) {
irisesRef.current.add(el);
}
}, []);
const unregisterIris = useCallback((el: HTMLElement | null) => {
if (el) {
irisesRef.current.delete(el);
}
}, []);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
const anchor = anchorRef.current;
if (!anchor) return;
const dx = e.clientX - anchor.x;
const dy = e.clientY - anchor.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
targetRef.current = {
x: (dx / dist) * maxShift,
y: (dy / dist) * maxShift,
};
};
window.addEventListener("mousemove", onMouseMove);
let raf = 0;
const tick = () => {
const ec = currentRef.current;
const et = targetRef.current;
const vel = velocityRef.current;
const dx = et.x - ec.x;
const dy = et.y - ec.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
ec.x += vel.x;
ec.y += vel.y;
if (dist < 0.1) {
ec.x += (Math.random() - 0.5) * 0.02;
ec.y += (Math.random() - 0.5) * 0.02;
}
offsetRef.current.x = ec.x;
offsetRef.current.y = ec.y;
// Direct DOM updates — no React re-render
for (const iris of irisesRef.current) {
iris.style.transform = `translate(${ec.x}px, ${ec.y}px)`;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(raf);
};
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
return { offsetRef, registerIris, unregisterIris };
}

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