feat: added a twist

This commit is contained in:
2026-04-01 00:53:55 +02:00
parent 0b7deee59e
commit b40c6436cd
76 changed files with 15121 additions and 64 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": "/Users/dtoro/Projects/micronomicon/frontend/node_modules/.bin/vite",
"runtimeArgs": ["/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.

290
CLAUDE.md Normal file
View File

@@ -0,0 +1,290 @@
# Micronomicon → µFrame
A self-hosted web IDE for building rich terminal UIs that publish as `.mu` pages to a NomadNet node.
> **Evolution:** Started as a raw Micron text editor (Phases 12, complete).
> Now pivoting to **µFrame** — a declarative DSL that compiles `.uf` files into
> both plain ASCII art and styled Micron `.mu` output from the same source.
## Status
Phases 12 complete (raw Micron editor). Phase 3 (µFrame engine) is next.
## Tech Stack
| Layer | Technology |
|------------|--------------------------------------------------|
| Backend | Python 3.13 + FastAPI + uvicorn |
| µFrame | Pure Python: parser → IR → CharGrid → emitters |
| Frontend | React 19 + Vite + TypeScript |
| UI | shadcn/ui + Tailwind CSS v4 + tw-animate-css |
| Editor | CodeMirror 6 |
| Graph | React Flow (@xyflow/react) + dagre |
| State | Zustand |
| Container | Docker + Compose |
## Directory Layout
```
micronomicon/
Dockerfile
compose.yml
docs/
framework-design-v3.md # µFrame DSL spec + rendering model
dynamic-templates.md # Dynamic page addendum (Phases 56)
backend/
main.py # FastAPI app + static file serving
converter.py # POST /api/compile (µFrame → ASCII + Micron)
pages.py # file management (CRUD /api/pages)
graph.py # link parser (GET /api/graph)
docker_utils.py # container restart (POST /api/restart)
requirements.txt
uframe/ # µFrame engine (Phase 3+)
__init__.py # compile(source, width) → CompileResult
errors.py # ParseError, LayoutError
ir.py # IR node dataclasses
parser.py # .uf DSL → IR tree
grid.py # CharGrid (2D char + style buffer)
chars.py # Unicode lookup tables (box-drawing, braille)
measure.py # bottom-up size computation
layout.py # top-down position assignment
paint.py # IR → CharGrid rendering
borders.py # junction merging post-pass
emit_ascii.py # CharGrid → plain text
emit_micron.py # CharGrid → Micron with style tags
viz.py # gauge, sparkline, status (Phase 4)
table.py # table layout + box-drawn grid (Phase 4)
frontend/
src/
App.tsx
routes/ # DashboardView, EditorView, GraphView
components/
dashboard/ # page list, status badges
editor/
EditorPane.tsx # CodeMirror host
PreviewPane.tsx # ASCII + Micron + Raw preview tabs
ToolBar.tsx # save/publish + backlinks
uframeHighlight.ts # CM6 .uf syntax highlighting (Phase 3+)
uframeCommands.ts # "/" command palette for .uf (Phase 3+)
micronRenderer.ts # Micron → HTML (renders compiled output)
BacklinkIndicator.tsx
shared/
ui/ # shadcn components
stores/ # editorStore, pagesStore (Zustand)
hooks/ # useCompile, useGraph, useUnsavedGuard
lib/ # utils (cn)
```
## Local Development
**Backend:**
```bash
cd backend
source .venv/bin/activate
PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080
```
**Frontend:**
```bash
cd frontend
npm run dev # proxies /api -> localhost:8080
```
Frontend at http://localhost:5173, backend at http://localhost:8080.
## API Endpoints
| Method | Path | Description |
|--------|---------------------|------------------------------------------------------|
| GET | /api/health | Health check |
| POST | /api/compile | Compile `.uf``{ascii, micron, warnings}` |
| GET | /api/pages | List all pages with metadata |
| GET | /api/pages/{name} | Read page source (`.uf` or legacy `.mu`) |
| POST | /api/pages/{name} | Save page — body `{ source, publish: bool }` |
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
| GET | /api/graph | Graph nodes + edges (parsed from links in source) |
| POST | /api/restart | Restart NomadNet Docker container |
## Storage
```
~/.micron-editor/sources/ ← .uf source files (draft + published)
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
```
On publish: `.uf` is compiled to `.mu` and copied to the NomadNet pages directory.
## Conventions
- Frontend UI components live in `frontend/src/components/ui/` (shadcn)
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
- State management via Zustand stores in `frontend/src/stores/`
- Backend is pure FastAPI; no ORM, flat file storage
- µFrame engine is pure Python stdlib — no external dependencies
## µFrame DSL Reference
Full spec: `docs/framework-design-v3.md`
### Layout primitives
```
page "Title" [width] # root container (default 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 primitives
```
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 (Phase 4)
```
gauge "Label" $val $max $width warn=N crit=N
sparkline "Label" $values $width # braille patterns
status "Label" [online|offline|degraded] # ●○◐ indicators
table "Title"
columns "Name" 24 | "Hops" 6 | "Status" 10
row "value" | "value" | "value"
```
### Forms (Phase 5)
```
form "name"
field "name" [width] "placeholder"
radio "group" "Opt A" | "Opt B"
checkbox "name" "Label"
button "Label" "/action/path"
```
### Dynamic features (Phase 6)
```
source cpu : shell "cat /proc/loadavg"
on_submit "form_name"
# handle form data
if $val > threshold
# conditional rendering
for item in $collection
# iteration
state "store" "/path.json"
cache 0 # never cache (re-execute per request)
```
### Rendering pipeline
```
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
├→ ASCII emitter (plain text)
└→ Micron emitter (styled .mu)
```
### Micron syntax (legacy raw editor, still used for compiled output)
```
>H1 >>H2 >>>H3 headings
`!bold`! `*italic`* `_underline`_ formatting
`Fhex text`f `Bhex text`b colors (3-digit hex)
`c text`a `r text`a `l text`a alignment
[label`slug] links
-─ -━ -═ -★ dividers
`= ... `= literal mode
# comment hidden in output
```
## Roadmap
### Phase 1 — Core Editor + Dashboard + Graph ✅
- Split-pane Micron editor with live preview
- Save draft / publish to NomadNet pages dir
- Pages dashboard with status badges
- Page graph (React Flow + dagre)
- NomadNet restart, dark/light theme, keyboard shortcuts
### Phase 2 — Linking + Editor Enhancements ✅
- `[[` page link autocomplete
- Backlink indicator with popover
- Pivot to direct Micron editor (removed Markdown pipeline)
- `/` slash command palette for Micron syntax
- Micron syntax highlighting + HTML preview renderer
- Syntax completeness per micron-composer spec
### Phase 3 — µFrame Core Engine (next)
Build `backend/uframe/` — the rendering pipeline:
1. `errors.py` + `ir.py` — data structures
2. `chars.py` — Unicode lookup tables (box-drawing, blocks, braille)
3. `parser.py``.uf` DSL → IR tree (indentation-based, line-oriented)
4. `grid.py` — CharGrid class (2D char + style buffer)
5. `measure.py` + `layout.py` — size computation + position assignment
6. `paint.py` — IR nodes → CharGrid
7. `borders.py` — junction merging post-pass
8. `emit_ascii.py` + `emit_micron.py` — CharGrid → output strings
9. `__init__.py` — public `compile()` API
10. Update `converter.py``POST /api/compile` endpoint
11. Update `pages.py``.uf` sources, compile-on-publish
12. Update `graph.py` — parse `.uf` for links
### Phase 4 — Data Visualization
- gauge, meter, bar_h, bar_v (block elements)
- sparkline (braille sub-cell rendering)
- heatmap (shade blocks with per-cell color)
- status indicators (●○◐ with color)
- table (box-drawn with header separator)
- Border merging across nested tables
### Phase 5 — Web IDE Integration
- Replace Micron editor with µFrame DSL editor
- CodeMirror `.uf` syntax highlighting + autocomplete
- `useCompile` hook (debounced API calls)
- Triple preview: ASCII | Micron rendered | Raw Micron
- Page storage: `.uf` sources, `.mu` compiled output
- Migration script for legacy `.mu` sources
### Phase 6 — Forms & Interactivity
- Form primitives: field, password, radio, checkbox, button
- ASCII: visual placeholders; Micron: live fields
- @modifier inline syntax
- Variables and `let` bindings
### Phase 7 — Dynamic Pages
- `source` blocks for live data (shell, file, json, python, rns)
- Compile to executable Python scripts with embedded runtime
- `on_submit` form handling via `FIELD_*` env vars
- Conditionals (`if`/`elif`/`else`) and loops (`for`)
- State persistence (`state` + JSON store)
- Cache control headers
- CLI: `uframe compile` / `uframe deploy`
### Phase 8 — Components & Standard Library
- `component` definitions with argument bindings
- Standard library: `std/dashboard`, `std/filebrowser`, `std/board`
- Themes (`.uf-theme` color palette files)
- `uframe check` linter
- Production deployment: systemd + Tailscale
## References
- µFrame design: `docs/framework-design-v3.md`
- µFrame dynamic: `docs/dynamic-templates.md`
- micron-composer: https://github.com/fr33n0w/micron-composer
- micron-parser-js: https://rfnexus.github.io/micron-parser-js/
- NomadNet: https://github.com/markqvist/NomadNet
- md2txt (legacy): https://codeberg.org/randogoth/md2txt

View File

@@ -1,28 +1,34 @@
"""µFrame compile endpoint — POST /api/compile."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import uframe
from uframe.errors import UFrameError
router = APIRouter()
class ConvertRequest(BaseModel):
markdown: str
width: int = 80
class CompileRequest(BaseModel):
source: str
width: int = 64
class ConvertResponse(BaseModel):
class CompileResponse(BaseModel):
ascii: str
micron: str
warnings: list[str]
@router.post("/convert", response_model=ConvertResponse)
async def convert(req: ConvertRequest):
@router.post("/compile", response_model=CompileResponse)
async def compile_source(req: CompileRequest):
"""Compile µFrame .uf source into ASCII and Micron output."""
try:
from md2txt import convert_markdown
result = convert_markdown(
req.markdown,
width=req.width,
renderer_name="micron",
result = uframe.compile(req.source, width=req.width)
return CompileResponse(
ascii=result.ascii,
micron=result.micron,
warnings=[w.message for w in result.warnings],
)
return ConvertResponse(micron=result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
except UFrameError as e:
raise HTTPException(status_code=422, detail=str(e))

View File

@@ -10,8 +10,8 @@ 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_-]+)\)")
# Matches Micron links: [label`slug] or [label`slug.mu]
_INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
class GraphNode(BaseModel):
@@ -38,16 +38,16 @@ def _all_page_names() -> set[str]:
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix == ".md" and f.is_file():
if f.suffix == ".mu" and f.is_file():
names.add(f.stem)
return names
def _extract_title(markdown: str) -> str | None:
for line in markdown.splitlines():
def _extract_title(micron: str) -> str | None:
for line in micron.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
if stripped.startswith(">") and not stripped.startswith(">>"):
return stripped[1:].strip()
return None
@@ -58,15 +58,14 @@ async def get_graph():
edges: list[GraphEdge] = []
for name in sorted(all_names):
md_path = SOURCES_DIR / f"{name}.md"
src_path = SOURCES_DIR / f"{name}.mu"
mu_path = PAGES_DIR / f"{name}.mu"
title = None
if md_path.is_file():
content = md_path.read_text(encoding="utf-8")
if src_path.is_file():
content = src_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:

View File

@@ -4,12 +4,12 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from converter import router as converter_router
from pages import router as pages_router
from graph import router as graph_router
from docker_utils import router as docker_router
from converter import router as converter_router
app = FastAPI(title="Micron Page Editor")
app = FastAPI(title="µFrame Editor")
app.include_router(converter_router, prefix="/api")
app.include_router(pages_router, prefix="/api")

View File

@@ -1,9 +1,12 @@
import os
import shlex
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import uframe
router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
@@ -21,20 +24,42 @@ class PageMeta(BaseModel):
class PageDetail(BaseModel):
name: str
markdown: str | None = None
micron: str | None = None
source: str | None = None
class SaveRequest(BaseModel):
markdown: str
source: str
publish: bool = False
def _extract_title(markdown: str) -> str | None:
for line in markdown.splitlines():
def _extract_title(source: str) -> str | None:
"""Extract title from µFrame source or legacy Micron."""
for line in source.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
if not stripped or stripped.startswith("#"):
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
@@ -46,18 +71,29 @@ def _list_all_page_names() -> set[str]:
names.add(f.stem)
if SOURCES_DIR.is_dir():
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)
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:
src_path = _source_path(name)
mu_path = PAGES_DIR / f"{name}.mu"
md_path = SOURCES_DIR / f"{name}.md"
title = None
if md_path.is_file():
title = _extract_title(md_path.read_text(encoding="utf-8"))
if src_path.is_file():
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()
last_modified = mu_path.stat().st_mtime if published else None
@@ -67,7 +103,7 @@ def _page_meta(name: str) -> PageMeta:
name=name,
title=title,
published=published,
has_source=md_path.is_file(),
has_source=src_path.is_file(),
last_modified=last_modified,
size=size,
)
@@ -80,16 +116,19 @@ async def list_pages():
@router.get("/pages/{name}", response_model=PageDetail)
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"
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")
markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None
micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None
source = (
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)
@@ -97,39 +136,40 @@ async def save_page(name: str, req: SaveRequest):
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
PAGES_DIR.mkdir(parents=True, exist_ok=True)
# Always save markdown source
md_path = SOURCES_DIR / f"{name}.md"
md_path.write_text(req.markdown, encoding="utf-8")
# Save source as .uf
src_path = SOURCES_DIR / f"{name}.uf"
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:
try:
from md2txt import convert_markdown
micron = convert_markdown(
req.markdown,
width=80,
renderer_name="micron",
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
result = uframe.compile(req.source)
mu_path = PAGES_DIR / f"{name}.mu"
mu_path.write_text(micron, encoding="utf-8")
mu_path.write_text(result.micron, encoding="utf-8")
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Compile failed during publish: {e}",
)
return _page_meta(name)
@router.delete("/pages/{name}")
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"
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")
if md_path.is_file():
md_path.unlink()
if src_path.is_file():
src_path.unlink()
if mu_path.is_file():
mu_path.unlink()

View File

@@ -0,0 +1,74 @@
"""µ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
@dataclass
class CompileResult:
"""Result of compiling a .uf source."""
ascii: str = ""
micron: str = ""
warnings: list[CompileWarning] = field(default_factory=list)
def compile(source: str, width: int = 64) -> 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
# 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)
# 5. Merge borders
merge_borders(grid)
# 6. Emit
ascii_out = emit_ascii(grid)
micron_out = emit_micron(grid, page_title=page.title)
return CompileResult(
ascii=ascii_out,
micron=micron_out,
warnings=warnings,
)

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

@@ -0,0 +1,148 @@
"""Border merging post-pass — fix junction characters where borders meet.
Scans the CharGrid for adjacent border cells and replaces with the
correct junction character (T-junctions, crosses, corners) from the
Unicode box-drawing set.
"""
from __future__ import annotations
from uframe.grid import CharGrid
from uframe.ir import BorderWeight
# ---------------------------------------------------------------------------
# Connection detection
# ---------------------------------------------------------------------------
# For each border cell, check which directions have adjacent borders.
# Direction flags:
UP = 1
DOWN = 2
LEFT = 4
RIGHT = 8
# Junction lookup: connections bitmask → character
# Only light weight for now (most common case)
_LIGHT_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_HEAVY_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_DOUBLE_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_JUNCTION_TABLES = {
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
}
# Weight priority for mixed-weight junctions
_WEIGHT_PRIORITY = {
BorderWeight.DOUBLE: 3,
BorderWeight.HEAVY: 2,
BorderWeight.LIGHT: 1,
BorderWeight.ROUNDED: 0,
}
def merge_borders(grid: CharGrid) -> None:
"""Scan the grid for adjacent border cells and fix junction characters.
This pass resolves cases where two boxes share an edge or corner,
replacing the overlapping border characters with proper junctions.
"""
for row in range(grid.height):
for col in range(grid.width):
cell = grid.cells[row][col]
if not cell.is_border:
continue
# Detect connections in 4 directions
connections = 0
max_weight = cell.border_weight or BorderWeight.LIGHT
# Check each neighbor
if row > 0 and grid.cells[row - 1][col].is_border:
connections |= UP
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
connections |= DOWN
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if col > 0 and grid.cells[row][col - 1].is_border:
connections |= LEFT
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
connections |= RIGHT
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
# Skip rounded corners — they should preserve ╭╮╰╯
if cell.border_weight == BorderWeight.ROUNDED and connections in (
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
):
continue
# Look up the junction character
if connections:
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
junction = table.get(connections)
if junction:
cell.char = junction

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": "",
}

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,121 @@
"""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.
Args:
grid: the rendered character grid
page_title: optional page title for a leading >Title line
Returns:
Micron source string
"""
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
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})"

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

@@ -0,0 +1,170 @@
"""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
link: str | None = None # Micron link destination
class CharGrid:
"""2D buffer of cells. Origin (0,0) is top-left."""
__slots__ = ("width", "height", "cells")
def __init__(self, width: int, height: int):
self.width = width
self.height = height
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,
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 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) -> 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
ch = BOX_CHARS[weight]
border_style = CellStyle()
# Corners
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight)
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight)
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight)
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight)
# 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)
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight)
# 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)
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight)
# Title in top border
if title and w > 4:
title_text = f" {title} "
max_title = w - 4 # leave room for corners + padding
if len(title_text) > max_title:
title_text = title_text[:max_title]
start_x = x + 2
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)

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

@@ -0,0 +1,243 @@
"""µ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
from typing import Any
# ---------------------------------------------------------------------------
# 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
@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 (Phase 4)."""
title: str = ""
columns: list[tuple[str, int]] = field(default_factory=list) # (name, width)
rows: list[list[str]] = field(default_factory=list)

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

@@ -0,0 +1,134 @@
"""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,
)
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, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status)):
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

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

@@ -0,0 +1,206 @@
"""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,
)
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
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

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

@@ -0,0 +1,216 @@
"""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 textwrap
from uframe.chars import (
DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
STATUS_CHARS, STATUS_COLORS, 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,
HeadingLevel, DividerStyle, ListStyle, Align,
)
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) -> None:
"""Recursively paint an IR node and its children into the grid."""
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)
elif isinstance(node, Box):
# Draw the border
title_style = CellStyle(bold=True, fg=node.style.fg)
grid.draw_border(x, y, w, node.rect.h,
weight=node.weight,
title=node.title,
title_style=title_style)
# Paint children inside the border
for child in node.children:
paint(child, grid)
elif isinstance(node, Row):
for child in node.children:
paint(child, grid)
elif isinstance(node, Col):
for child in node.children:
paint(child, grid)
elif isinstance(node, Spacer):
pass # Just empty space
elif isinstance(node, Pad):
for child in node.children:
paint(child, grid)
elif isinstance(node, Heading):
style = CellStyle(bold=True)
if node.level == HeadingLevel.H1:
style.fg = "0f0" # green
elif node.level == HeadingLevel.H2:
style.fg = "0cf" # cyan
elif node.level == HeadingLevel.H3:
style.fg = "88f" # light blue
# 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 = DIVIDER_CHARS.get(ds.name.lower(), "")
style = CellStyle(fg="555")
for col in range(x, x + w):
grid.put(col, y, char, style=style)
elif isinstance(node, Link):
style = CellStyle(fg="0cf", underline=True)
# In ASCII mode, display as [text]. In Micron, the emitter wraps with link syntax.
# Write just the display text — the link metadata goes on cells for Micron emission.
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)
elif isinstance(node, ListItem):
style = _style_from_node(node)
# Parent determines bullet style — use a simple bullet for now
bullet = ""
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
# 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 = "0f0" # green
if node.crit is not None and node.value >= node.crit:
fg = "f00" # red
elif node.warn is not None and node.value >= node.warn:
fg = "ff0" # yellow
for i in range(bar_w):
if i < filled:
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
else:
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
# 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="0cf")
for i, ch in enumerate(chars):
grid.put(spark_x + i, y, ch, style=spark_style)
elif isinstance(node, Status):
char = STATUS_CHARS.get(node.state, "")
color = STATUS_COLORS.get(node.state, "888")
grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label)
else:
# Generic: paint children
for child in node.children:
paint(child, grid)

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

@@ -0,0 +1,350 @@
"""µ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 typing import Sequence
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,
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) -> 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":
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":
content = args[0] if args else ""
return ListItem(content=content, source_line=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 ""
value = float(args[1]) if len(args) > 1 else 0
max_val = float(args[2]) if len(args) > 2 else 100
bar_width = int(args[3]) if len(args) > 3 else 28
# 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:])
return Gauge(label=label, value=value, max_val=max_val,
bar_width=bar_width, warn=warn, crit=crit,
source_line=line_num)
elif keyword == "sparkline":
label = args[0] if args else ""
vals_str = args[1] if len(args) > 1 else ""
values = [float(v) for v in vals_str.split(",") if v.strip()] if vals_str else []
width = int(args[2]) if len(args) > 2 else 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)
else:
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Tree builder
# ---------------------------------------------------------------------------
def parse(source: str) -> Page:
"""Parse a .uf source string into an IR tree rooted at a Page node.
Returns the Page node with all children attached.
"""
lines = source.split("\n")
# 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)
# Pop stack back to find the parent (parent indent < this indent)
while stack and stack[-1][0] >= indent:
stack.pop()
if isinstance(node, _StyleDirective):
# Apply style directive to the current top of stack (parent)
if stack:
parent = stack[-1][1]
setattr(parent.style, node.attr, node.value)
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

View File

View File

@@ -0,0 +1,212 @@
"""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_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

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

@@ -0,0 +1,883 @@
# µ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
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
source rand_hex : python "secrets.token_hex(4)"
# 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
3. Reads environment variables (form data)
4. Executes source commands (shell, file, python, rns)
5. Evaluates conditionals and loops
6. Renders the IR tree into a CharGrid
7. Emits the CharGrid as Micron with style tags
8. Prints to stdout
```python
#!/usr/bin/env python3
#!c=0
# Auto-generated by µFrame from dashboard.uf
# Do not edit — regenerate with: uframe compile dashboard.uf
import os, sys, json, subprocess, datetime, secrets
# ─── µFrame Runtime (embedded) ───────────────────────────────
class CharGrid:
"""2D character grid with style annotations."""
def __init__(self, width, height):
self.w = width
self.h = height
self.chars = [[' ']*width for _ in range(height)]
self.styles = [[None]*width for _ in range(height)]
def put(self, x, y, ch, style=None):
if 0 <= x < self.w and 0 <= y < self.h:
self.chars[y][x] = ch
self.styles[y][x] = style
def box(self, x, y, w, h, weight='light', title=None, title_style=None):
"""Draw a box with automatic border characters."""
# ... border drawing logic ...
def gauge(self, x, y, w, value, max_val, label=None,
warn=None, crit=None):
"""Render a horizontal gauge bar with threshold colors."""
pct = min(value / max_val, 1.0)
filled = int(w * pct)
for i in range(w):
ch = '█' if i < filled else '░'
fg = None
if crit and value >= crit: fg = 'f00'
elif warn and value >= warn: fg = 'ff0'
elif i < filled: fg = '0f0'
else: fg = '555'
self.put(x + i, y, ch, {'fg': fg})
# ... label and percentage ...
def sparkline(self, x, y, w, values):
"""Render braille sparkline from value array."""
# ... braille pattern generation ...
def emit_micron(self):
"""Scan grid and emit Micron with style tags."""
lines = []
for row_idx in range(self.h):
line = []
cur_style = None
for col_idx in range(self.w):
ch = self.chars[row_idx][col_idx]
st = self.styles[row_idx][col_idx]
if st != cur_style:
# Close previous style tags
if cur_style:
if cur_style.get('fg'): line.append('`f')
if cur_style.get('bold'): line.append('`!')
# Open new style tags
if st:
if st.get('bold'): line.append('`!')
if st.get('fg'): line.append(f'`F{st["fg"]}')
cur_style = st
line.append(ch)
# Close final style
if cur_style:
if cur_style.get('fg'): line.append('`f')
if cur_style.get('bold'): line.append('`!')
lines.append(''.join(line).rstrip())
return '\n'.join(lines)
# ─── Form Data ───────────────────────────────────────────────
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))
# ─── Data Sources ────────────────────────────────────────────
def shell(cmd):
"""Execute shell command, return stdout."""
try:
return subprocess.check_output(
cmd, shell=True, timeout=5
).decode().strip()
except Exception:
return ''
# ─── Resolve Sources ─────────────────────────────────────────
cpu_pct = int(shell(
"grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
) or 0)
mem_pct = int(shell(
"free | awk '/Mem/{print int($3/$2*100)}'"
) or 0)
uptime_str = shell("uptime -p")
peer_count = shell("rnstatus -j 2>/dev/null | python3 -c "
"'import sys,json; print(len(json.load(sys.stdin).get(\"peers\",[])))'")
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
# ─── Build Grid & Render ────────────────────────────────────
grid = CharGrid(66, 40)
# ... all the box(), gauge(), sparkline(), text() calls
# ... exactly as the layout engine would produce them ...
# ─── Output ──────────────────────────────────────────────────
print('#!c=0') # cache header: never cache
print(grid.emit_micron())
```
### 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 : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source net_in : shell "net_traffic.sh in"
source net_out : shell "net_traffic.sh out"
source net_history_in : shell "net_spark.sh in 20"
source net_history_out : shell "net_spark.sh out 20"
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 "IN" "$net_in KB/s"
sparkline "IN" $net_history_in 28
label "OUT" "$net_out KB/s"
sparkline "OUT" $net_history_out 28
text "@center{@italic{Press Ctrl+R to refresh}}"
```
Client hits the page → script runs → reads `/proc` → renders
gauges and sparklines with real data → client sees it.
Ctrl+R re-requests → fresh execution → updated values.
### 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

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>

7444
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"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",
"@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"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",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@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

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

@@ -0,0 +1,18 @@
import { Routes, Route } from "react-router-dom";
import AppShell from "./components/shared/AppShell";
import DashboardView from "./routes/DashboardView";
import EditorView from "./routes/EditorView";
import GraphView from "./routes/GraphView";
export default function App() {
return (
<AppShell>
<Routes>
<Route path="/" element={<DashboardView />} />
<Route path="/editor/new" element={<EditorView />} />
<Route path="/editor/:name" element={<EditorView />} />
<Route path="/graph" element={<GraphView />} />
</Routes>
</AppShell>
);
}

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,44 @@
import { Link } from "react-router-dom";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import type { BacklinkPage } from "@/hooks/useBacklinks";
interface Props {
backlinks: BacklinkPage[];
}
export default function BacklinkIndicator({ backlinks }: Props) {
if (backlinks.length === 0) return null;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
{backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="end">
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
Pages linking here
</p>
<ul className="space-y-0.5">
{backlinks.map((page) => (
<li key={page.name}>
<Link
to={`/editor/${page.name}`}
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
>
<span>{page.title ?? page.name}</span>
{page.title && (
<span className="text-xs text-muted-foreground font-mono">
({page.name})
</span>
)}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from "react";
import { EditorView, keymap } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import type { Extension } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { searchKeymap } from "@codemirror/search";
import { oneDark } from "./oneDarkTheme";
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(),
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
oneDark,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
"&": { height: "100%", fontSize: "14px" },
".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "monospace", padding: "16px" },
}),
...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,88 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { useEditorStore } from "@/stores/editorStore";
import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils";
type PreviewMode = "ascii" | "micron" | "raw";
export default function PreviewPane() {
const previewMode = useEditorStore((s) => s.previewMode);
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
const compiledAscii = useEditorStore((s) => s.compiledAscii);
const compiledMicron = useEditorStore((s) => s.compiledMicron);
const isCompiling = useEditorStore((s) => s.isCompiling);
const compileError = useEditorStore((s) => s.compileError);
const tabs: { value: PreviewMode; label: string }[] = [
{ value: "ascii", label: "ASCII" },
{ value: "micron", label: "Micron" },
{ value: "raw", label: "Raw" },
];
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
<span className="text-xs text-muted-foreground flex-1">
Preview
{isCompiling && (
<span className="ml-2 text-yellow-500 animate-pulse">
compiling
</span>
)}
{compileError && (
<span className="ml-2 text-red-400" title={compileError}>
error
</span>
)}
</span>
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => setPreviewMode(tab.value)}
className={cn(
"text-xs px-2 py-0.5 rounded transition-colors",
previewMode === tab.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</button>
))}
</div>
</div>
<ScrollArea className="flex-1 bg-background">
{previewMode === "ascii" ? (
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
{compiledAscii || (
<span className="text-muted-foreground">
ASCII preview will appear here
</span>
)}
</pre>
) : previewMode === "micron" ? (
compiledMicron ? (
<div
className="p-4 font-mono text-sm whitespace-pre leading-tight"
dangerouslySetInnerHTML={{
__html: renderMicron(compiledMicron),
}}
/>
) : (
<div className="p-4">
<span className="text-muted-foreground text-sm">
Micron preview will appear here
</span>
</div>
)
) : (
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
{compiledMicron || "Raw Micron output will appear here…"}
</pre>
)}
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useEditorStore } from "@/stores/editorStore";
import { useBacklinks } from "@/hooks/useBacklinks";
import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
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) {
const currentPage = useEditorStore((s) => s.currentPage);
const backlinks = useBacklinks(currentPage?.name);
return (
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
{onNameChange ? (
<Input
value={pageName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="page-name"
className="font-mono w-48 h-8 text-sm"
/>
) : (
<span className="font-mono font-semibold text-sm">{pageName}</span>
)}
<div className="flex-1" />
<BacklinkIndicator backlinks={backlinks} />
{isDirty && (
<span className="text-xs text-muted-foreground">Unsaved</span>
)}
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
Save Draft
</Button>
<Button size="sm" onClick={onPublish} disabled={saving}>
Publish
</Button>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const micronLanguage = StreamLanguage.define({
token(stream) {
if (stream.sol()) {
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
// Headings — longest prefix first
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
// Dividers: line starting with - followed by a non-space, non-dash char
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
// Comment lines
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
// Standalone depth-reset "<"
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
}
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
// Links [label`url] — consume the whole bracket expression
if (stream.match(/\[[^\]]*\]/)) return "link";
// Form elements <fieldname`default> etc.
if (stream.match(/<[^>]+>/)) return "string";
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const micronStyle = HighlightStyle.define([
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
{ tag: tags.string, color: "#d4a8f8" }, // form elements
]);
export function micronHighlight() {
return [micronLanguage, syntaxHighlighting(micronStyle)];
}

View File

@@ -0,0 +1,157 @@
/**
* Micron markup → HTML renderer for the editor preview pane.
* Spec: https://github.com/fr33n0w/micron-composer
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Render inline Micron formatting codes within a line of text. */
function renderInline(raw: string): string {
let out = "";
let i = 0;
const openTags: string[] = [];
const closeAll = () => {
while (openTags.length) out += openTags.pop()!;
};
while (i < raw.length) {
// Backtick formatting codes
if (raw[i] === "`") {
const code = raw[i + 1];
if (code === "!") {
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
} else if (code === "*") {
out += "<em>"; openTags.push("</em>"); i += 2; continue;
} else if (code === "_") {
out += "<u>"; openTags.push("</u>"); i += 2; continue;
} else if (code === "`") {
closeAll(); i += 2; continue;
} else if (code === "f" || code === "b") {
out += "</span>"; i += 2; continue;
} else if (code === "a") {
out += "</span>"; i += 2; continue;
} else if (code === "F") {
// Foreground color — 3-digit hex only per spec
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
if (hexMatch) {
const [r, g, b] = hexMatch[1].split("");
const hex = r + r + g + g + b + b;
out += `<span style="color:#${hex}">`;
openTags.push("</span>");
i += 2 + hexMatch[1].length;
continue;
}
} else if (code === "B") {
// Background color — 3-digit hex only per spec
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
if (hexMatch) {
const [r, g, b] = hexMatch[1].split("");
const hex = r + r + g + g + b + b;
out += `<span style="background:#${hex}">`;
openTags.push("</span>");
i += 2 + hexMatch[1].length;
continue;
}
} else if (code === "c") {
out += `<span style="display:block;text-align:center">`;
openTags.push("</span>"); i += 2; continue;
} else if (code === "r") {
out += `<span style="display:block;text-align:right">`;
openTags.push("</span>"); i += 2; continue;
} else if (code === "l") {
out += `<span style="display:block;text-align:left">`;
openTags.push("</span>"); i += 2; continue;
}
}
// Links: [label`slug] or [label`slug.mu]
if (raw[i] === "[") {
const close = raw.indexOf("]", i);
if (close !== -1) {
const inner = raw.slice(i + 1, close);
const backtick = inner.indexOf("`");
if (backtick !== -1) {
const label = escapeHtml(inner.slice(0, backtick));
const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, ""));
out += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
i = close + 1;
continue;
}
}
}
out += escapeHtml(raw[i]);
i++;
}
closeAll();
return out;
}
/** Render a form element line as a styled badge. */
function renderForm(line: string): string {
const inner = escapeHtml(line);
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</span>`;
}
export function renderMicron(source: string): string {
const lines = source.split("\n");
const htmlLines: string[] = [];
let literalMode = false;
for (const line of lines) {
// Toggle literal mode on standalone `= line
if (line.trimEnd() === "`=") {
literalMode = !literalMode;
continue;
}
// In literal mode — render verbatim
if (literalMode) {
htmlLines.push(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
continue;
}
// Comment lines — hidden in output
if (line.startsWith("#")) continue;
// All output uses inline spans — the parent container has white-space:pre
// so newlines come from the \n join at the end.
// Depth-4+ indent (before >>> check)
if (line.startsWith(">>>>")) {
htmlLines.push(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
// Headings
} else if (line.startsWith(">>>")) {
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
} else if (line.startsWith(">>")) {
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
} else if (line.startsWith(">")) {
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
// Dividers: - followed by a non-space, non-dash character
} else if (/^-[^\s-]/.test(line)) {
const char = line[1];
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
// Standalone depth-reset "<"
} else if (line.trim() === "<") {
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
// Empty line
} else if (line.trim() === "") {
htmlLines.push("");
// Form elements on their own line
} else if (/^`?<[^>]+>$/.test(line)) {
htmlLines.push(renderForm(line));
} else {
htmlLines.push(renderInline(line));
}
}
return htmlLines.join("\n");
}

View File

@@ -0,0 +1,28 @@
import { EditorView } from "@codemirror/view";
export const oneDark = EditorView.theme(
{
"&": {
backgroundColor: "#0d1117",
color: "#c9d1d9",
},
".cm-cursor": {
borderLeftColor: "#c9d1d9",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "#264f78",
},
".cm-activeLine": {
backgroundColor: "#161b2266",
},
".cm-gutters": {
backgroundColor: "#0d1117",
color: "#484f58",
borderRight: "1px solid #21262d",
},
".cm-activeLineGutter": {
backgroundColor: "#161b2266",
},
},
{ dark: true }
);

View File

@@ -0,0 +1,84 @@
import { snippet } from "@codemirror/autocomplete";
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
interface SlashEntry {
label: string;
detail: string;
section: string;
apply: Completion["apply"];
}
// Insert text, replacing from the "/" character (from-1) through the cursor
function insert(text: string): Completion["apply"] {
return (view: EditorView, _completion: Completion, from: number, to: number) => {
view.dispatch({ changes: { from: from - 1, to, insert: text } });
};
}
// Wrap snippet() to also replace the preceding "/" character
function slashSnippet(template: string): Completion["apply"] {
const snip = snippet(template);
return (view: EditorView, completion: Completion, from: number, to: number) => {
snip(view, completion, from - 1, to - 1);
};
}
const COMMANDS: SlashEntry[] = [
// Headings
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
// Text formatting
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
// Alignment
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
// Color (3-digit hex)
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
// Links
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
// Dividers
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
// Forms — pipe separators per micron-composer spec
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
// Depth
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
];
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null;
return {
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
from: match.from + 1,
filter: true,
options: COMMANDS.map((cmd) => ({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.apply,
boost: 99,
})),
};
}

View File

@@ -0,0 +1,195 @@
import { snippet } from "@codemirror/autocomplete";
import type {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
interface CmdEntry {
label: string;
detail: string;
section: string;
apply: Completion["apply"];
}
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 - 1);
};
}
const COMMANDS: CmdEntry[] = [
// Layout
{
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: "row",
detail: "row [gap]",
section: "Layout",
apply: slashSnippet("row ${gap:2}"),
},
{
label: "col",
detail: "col [width]",
section: "Layout",
apply: slashSnippet("col ${width}"),
},
{
label: "spacer",
detail: "spacer [lines]",
section: "Layout",
apply: insert("spacer"),
},
{
label: "pad",
detail: "pad t r b l",
section: "Layout",
apply: slashSnippet("pad ${top:1} ${right:1} ${bottom:1} ${left:1}"),
},
// Content
{
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: "label",
detail: 'label "Key" "Value"',
section: "Content",
apply: slashSnippet('label "${key}" "${value}"'),
},
{
label: "divider",
detail: "divider heavy",
section: "Content",
apply: slashSnippet("divider ${style:light}"),
},
{
label: "link",
detail: 'link "Text" "/path.mu"',
section: "Content",
apply: slashSnippet('link "${display}" "${dest}"'),
},
{
label: "list",
detail: "list bullet",
section: "Content",
apply: slashSnippet("list ${style:bullet}\n item \"${entry}\""),
},
// Data Viz
{
label: "gauge",
detail: "gauge label val max width",
section: "Data",
apply: slashSnippet(
'gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
),
},
{
label: "sparkline",
detail: "sparkline label values width",
section: "Data",
apply: slashSnippet('sparkline "${label}" "${values}" ${width:20}'),
},
{
label: "status",
detail: "status label state",
section: "Data",
apply: slashSnippet('status "${label}" ${state:online}'),
},
// Style
{
label: "align",
detail: "align center",
section: "Style",
apply: slashSnippet("align ${align:center}"),
},
{
label: "color",
detail: "color hex",
section: "Style",
apply: slashSnippet("color ${hex}"),
},
{
label: "bold",
detail: "bold",
section: "Style",
apply: insert("bold"),
},
// Templates
{
label: "dashboard",
detail: "Full dashboard template",
section: "Template",
apply: insert(
`page "Dashboard" 64
box double "Node Status"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 0 100 28 warn=75 crit=90
gauge "MEM" 0 100 28 warn=80 crit=95
spacer
heading 2 "Network"
status "Relay East" online
status "Bridge South" online
divider heavy
link "Home" "/page/index.mu"`,
),
},
];
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: COMMANDS.map((cmd) => ({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.apply,
boost: 99,
})),
};
}

View File

@@ -0,0 +1,101 @@
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { tags } from "@lezer/highlight";
/**
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
*
* Keywords: page, box, row, col, spacer, pad, heading, text, label,
* divider, link, list, item, gauge, sparkline, status,
* table, columns, form, field, radio, checkbox, button,
* source, let, if, elif, else, for, align, color, bg,
* bold, italic, underline, cache, state, on_submit
*/
const KEYWORDS = new Set([
"page", "box", "row", "col", "spacer", "pad",
"heading", "text", "label", "divider", "link",
"list", "item", "gauge", "sparkline", "status",
"table", "columns", "form", "field", "radio",
"checkbox", "button", "source", "let", "if",
"elif", "else", "for", "align", "color", "bg",
"bold", "italic", "underline", "cache", "state",
"on_submit", "meter", "bar_h", "bar_v", "bar",
"heatmap", "component", "use",
]);
const WEIGHT_VALS = new Set([
"light", "heavy", "double", "rounded",
"bullet", "dash", "number", "arrow",
"left", "center", "right",
"online", "offline", "degraded", "unknown",
]);
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,23 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import type { MutableRefObject } from "react";
import type { PageMeta } from "@/stores/editorStore";
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
return (context: CompletionContext): CompletionResult | null => {
const match = context.matchBefore(/\[\[[\w-]*/);
if (!match || (match.from === match.to && !context.explicit)) return null;
const options: Completion[] = pagesRef.current.map((page) => ({
label: page.title ?? page.name,
detail: page.name,
apply: (view, _completion, from, to) => {
const title = page.title ?? page.name;
view.dispatch({
changes: { from, to, insert: `[${title}\`${page.name}]` },
});
},
}));
return { from: match.from, options, filter: true };
};
}

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import NavBar from "./NavBar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
export default function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider>
<div className="flex flex-col h-screen bg-background text-foreground">
<NavBar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
<Toaster />
</TooltipProvider>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { toast } from "sonner";
import { RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
const navLink = ({ isActive }: { isActive: boolean }) =>
cn(
"px-3 py-1.5 text-sm rounded-md transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
);
export default function NavBar() {
const [restartOpen, setRestartOpen] = useState(false);
const [restarting, setRestarting] = useState(false);
const handleRestart = async () => {
setRestarting(true);
try {
const res = await fetch("/api/restart", { method: "POST" });
if (!res.ok) throw new Error(await res.text());
toast.success("NomadNet restarted");
} catch (e) {
toast.error(`Restart failed: ${e}`);
} finally {
setRestarting(false);
setRestartOpen(false);
}
};
return (
<nav className="flex items-center gap-1 px-4 h-12 border-b bg-card shrink-0">
<span className="font-bold mr-6 text-foreground">Micronomicon</span>
<NavLink to="/" end className={navLink}>
Dashboard
</NavLink>
<NavLink to="/editor/new" className={navLink}>
New Page
</NavLink>
<NavLink to="/graph" className={navLink}>
Graph
</NavLink>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
disabled={restarting}
onClick={() => setRestartOpen(true)}
>
<RotateCcw className="w-4 h-4 mr-2" />
Restart NomadNet
</Button>
<AlertDialog open={restartOpen} onOpenChange={setRestartOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restart NomadNet?</AlertDialogTitle>
<AlertDialogDescription>
This will briefly interrupt mesh network connectivity.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleRestart}>
Restart
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</nav>
);
}

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,58 @@
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"
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-50"
>
<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,54 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

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("[&_tr:last-child]:border-0", 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-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&: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,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] 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 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

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,21 @@
import { useMemo } from "react";
import { useGraph } from "@/hooks/useGraph";
export interface BacklinkPage {
name: string;
title: string | null;
}
export function useBacklinks(currentSlug: string | undefined): BacklinkPage[] {
const { data } = useGraph();
return useMemo(() => {
if (!data || !currentSlug) return [];
const nodeMap = new Map(data.nodes.map((n) => [n.id, n]));
return data.edges
.filter((e) => e.target === currentSlug)
.map((e) => ({
name: e.source,
title: nodeMap.get(e.source)?.title ?? null,
}));
}, [data, currentSlug]);
}

View File

@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef } from "react";
import { useEditorStore } from "@/stores/editorStore";
const DEBOUNCE_MS = 400;
/**
* Debounced hook that compiles µFrame source via POST /api/compile.
* Automatically triggers on ufSource changes.
*/
export function useCompile() {
const ufSource = useEditorStore((s) => s.ufSource);
const setCompileResult = useEditorStore((s) => s.setCompileResult);
const setCompiling = useEditorStore((s) => s.setCompiling);
const setCompileError = useEditorStore((s) => s.setCompileError);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const compile = useCallback(
async (source: string) => {
if (!source.trim()) {
setCompileResult("", "", []);
return;
}
// Abort any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setCompiling(true);
try {
const res = await fetch("/api/compile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source }),
signal: controller.signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
setCompileError(err.detail || "Compile failed");
return;
}
const data = await res.json();
setCompileResult(data.ascii, data.micron, 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(() => compile(ufSource), DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [ufSource, compile]);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
export interface GraphNode {
id: string;
published: boolean;
title: string | null;
}
export interface GraphEdge {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
export function useGraph() {
const [data, setData] = useState<GraphData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/graph");
setData(await res.json());
} finally {
setLoading(false);
}
})();
}, []);
return { data, loading };
}

View File

@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useEditorStore } from "@/stores/editorStore";
export function useUnsavedGuard() {
const isDirty = useEditorStore((s) => s.isDirty);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
}
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
}

130
frontend/src/index.css Normal file
View File

@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import "./index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { usePagesStore } from "@/stores/pagesStore";
import StatusBadge from "@/components/dashboard/StatusBadge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function DashboardView() {
const { pages, isLoading, fetchPages, deletePage } = usePagesStore();
const navigate = useNavigate();
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
useEffect(() => {
fetchPages();
}, []);
const handleDelete = async () => {
if (!pageToDelete) return;
await deletePage(pageToDelete);
toast.success(`"${pageToDelete}" deleted`);
setPageToDelete(null);
};
if (isLoading)
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
);
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-semibold">Pages</h1>
<Button onClick={() => navigate("/editor/new")}>
<Plus className="w-4 h-4 mr-2" />
New Page
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Size</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{pages.map((p) => (
<TableRow key={p.name}>
<TableCell className="font-mono">
{p.name}
{p.name === "index" && (
<span className="ml-2 text-xs text-primary">homepage</span>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{p.title ?? "—"}
</TableCell>
<TableCell>
<StatusBadge published={p.published} hasSource={p.has_source} />
</TableCell>
<TableCell className="text-muted-foreground">
{p.size != null ? `${p.size} B` : "—"}
</TableCell>
<TableCell>
<div className="flex gap-1 justify-end">
{p.has_source && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/editor/${p.name}`)}
>
<Pencil className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setPageToDelete(p.name)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{pages.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-8"
>
No pages yet. Create one to get started.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<AlertDialog
open={pageToDelete !== null}
onOpenChange={(open) => !open && setPageToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes the page and its source. This cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { autocompletion } from "@codemirror/autocomplete";
import { useEditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { useCompile } from "@/hooks/useCompile";
import { uframeHighlight } from "@/components/editor/uframeHighlight";
import { uframeCommandSource } from "@/components/editor/uframeCommands";
import EditorPane from "@/components/editor/EditorPane";
import PreviewPane from "@/components/editor/PreviewPane";
import ToolBar from "@/components/editor/ToolBar";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
export default function EditorView() {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const isNew = !name;
const ufSource = useEditorStore((s) => s.ufSource);
const isDirty = useEditorStore((s) => s.isDirty);
const setSource = useEditorStore((s) => s.setSource);
const setDirty = useEditorStore((s) => s.setDirty);
const setCurrentPage = useEditorStore((s) => s.setCurrentPage);
const reset = useEditorStore((s) => s.reset);
const { fetchPages } = usePagesStore();
const [pageName, setPageName] = useState(name ?? "");
const [saving, setSaving] = useState(false);
// µFrame extensions: syntax highlighting + slash commands
const extensions = useMemo(
() => [
...uframeHighlight(),
autocompletion({
override: [uframeCommandSource],
icons: false,
}),
],
[],
);
// Auto-compile on source changes
useCompile();
useUnsavedGuard();
// Fetch pages for backlinks
useEffect(() => {
fetchPages();
}, []);
// Load page on mount / route change
useEffect(() => {
reset();
if (name) {
setPageName(name);
fetch(`/api/pages/${name}`)
.then((r) => r.json())
.then((data) => {
if (data.source != null) {
useEditorStore.setState({
ufSource: data.source,
isDirty: false,
currentPage: data,
});
}
});
}
return () => reset();
}, [name]);
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 res = await fetch(`/api/pages/${slug}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: ufSource, publish }),
});
if (!res.ok) throw new Error(await res.text());
const meta = await res.json();
setCurrentPage(meta);
setDirty(false);
fetchPages();
toast.success(publish ? "Published" : "Draft saved");
if (isNew) navigate(`/editor/${slug}`, { replace: true });
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
},
[pageName, ufSource, isNew, navigate],
);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
handleSave(false);
}
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
e.preventDefault();
handleSave(true);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleSave]);
return (
<div className="flex flex-col h-full">
<ToolBar
pageName={pageName}
onNameChange={isNew ? setPageName : undefined}
onSaveDraft={() => handleSave(false)}
onPublish={() => handleSave(true)}
saving={saving}
isDirty={isDirty}
/>
<ResizablePanelGroup orientation="horizontal" className="flex-1">
<ResizablePanel defaultSize={50} minSize={20}>
<EditorPane
value={ufSource}
onChange={setSource}
extensions={extensions}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<PreviewPane />
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
ReactFlow,
Background,
Controls,
type Node,
type Edge,
} from "@xyflow/react";
import dagre from "@dagrejs/dagre";
import { useGraph } from "@/hooks/useGraph";
import "@xyflow/react/dist/style.css";
const NODE_WIDTH = 160;
const NODE_HEIGHT = 50;
function layoutGraph(
nodes: Node[],
edges: Edge[]
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
nodes.forEach((n) =>
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
);
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
const laid = nodes.map((n) => {
const pos = g.node(n.id);
return {
...n,
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
};
});
return { nodes: laid, edges };
}
export default function GraphView() {
const { data, loading } = useGraph();
const navigate = useNavigate();
const { nodes, edges } = useMemo(() => {
if (!data) return { nodes: [], edges: [] };
const rfNodes: Node[] = data.nodes.map((n) => ({
id: n.id,
data: { label: n.title ?? n.id },
position: { x: 0, y: 0 },
style: {
background: n.published
? "oklch(0.488 0.14 145)"
: "oklch(0.7 0.15 80)",
color: "#fff",
border:
n.id === "index"
? "2px solid oklch(0.6 0.2 250)"
: "1px solid oklch(1 0 0 / 10%)",
borderRadius: 8,
padding: "8px 16px",
fontSize: 13,
fontWeight: n.id === "index" ? 700 : 400,
width: NODE_WIDTH,
},
}));
const rfEdges: Edge[] = data.edges.map((e, i) => ({
id: `e-${i}`,
source: e.source,
target: e.target,
style: { stroke: "oklch(0.556 0 0)" },
}));
return layoutGraph(rfNodes, rfEdges);
}, [data]);
if (loading)
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading graph...
</div>
);
return (
<div className="h-full bg-background">
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={(_, node) => navigate(`/editor/${node.id}`)}
fitView
proOptions={{ hideAttribution: true }}
>
<Background color="oklch(0.269 0 0)" gap={20} />
<Controls />
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { create } from "zustand";
export interface PageMeta {
name: string;
title: string | null;
published: boolean;
has_source: boolean;
last_modified: number | null;
size: number | null;
}
interface EditorStore {
// Source
ufSource: string;
isDirty: boolean;
currentPage: PageMeta | null;
// Compiled output
compiledAscii: string;
compiledMicron: string;
compileWarnings: string[];
isCompiling: boolean;
compileError: string | null;
// Preview
previewMode: "ascii" | "micron" | "raw";
// Actions
setSource: (s: string) => void;
setCurrentPage: (p: PageMeta | null) => void;
setDirty: (v: boolean) => void;
setCompileResult: (ascii: string, micron: string, warnings: string[]) => void;
setCompiling: (v: boolean) => void;
setCompileError: (e: string | null) => void;
setPreviewMode: (mode: "ascii" | "micron" | "raw") => void;
reset: () => void;
}
export const useEditorStore = create<EditorStore>((set) => ({
ufSource: "",
isDirty: false,
currentPage: null,
compiledAscii: "",
compiledMicron: "",
compileWarnings: [],
isCompiling: false,
compileError: null,
previewMode: "ascii",
setSource: (s) => set({ ufSource: s, isDirty: true }),
setCurrentPage: (p) => set({ currentPage: p }),
setDirty: (v) => set({ isDirty: v }),
setCompileResult: (ascii, micron, warnings) =>
set({
compiledAscii: ascii,
compiledMicron: micron,
compileWarnings: warnings,
isCompiling: false,
compileError: null,
}),
setCompiling: (v) => set({ isCompiling: v }),
setCompileError: (e) => set({ compileError: e, isCompiling: false }),
setPreviewMode: (mode) => set({ previewMode: mode }),
reset: () =>
set({
ufSource: "",
isDirty: false,
currentPage: null,
compiledAscii: "",
compiledMicron: "",
compileWarnings: [],
isCompiling: false,
compileError: null,
previewMode: "ascii",
}),
}));

View File

@@ -0,0 +1,45 @@
import { create } from "zustand";
import type { PageMeta } from "@/stores/editorStore";
interface PagesStore {
pages: PageMeta[];
isLoading: boolean;
fetchPages: () => Promise<void>;
deletePage: (name: string) => Promise<void>;
unpublishPage: (name: string) => Promise<void>;
}
export const usePagesStore = create<PagesStore>((set, get) => ({
pages: [],
isLoading: false,
fetchPages: async () => {
set({ isLoading: true });
try {
const res = await fetch("/api/pages");
const pages = await res.json();
set({ pages });
} finally {
set({ isLoading: false });
}
},
deletePage: async (name: string) => {
await fetch(`/api/pages/${name}`, { method: "DELETE" });
await get().fetchPages();
},
unpublishPage: async (name: string) => {
// Fetch current source, re-save as draft only
const res = await fetch(`/api/pages/${name}`);
const data = await res.json();
if (data.source) {
await fetch(`/api/pages/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: data.source, publish: false }),
});
}
await get().fetchPages();
},
}));

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

13
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

19
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "/",
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://localhost:8080",
},
},
});