feat: preview

This commit is contained in:
2026-04-01 10:13:14 +02:00
parent 8776459ffb
commit 0316e50233
13 changed files with 590 additions and 538 deletions

465
CLAUDE.md
View File

@@ -1,290 +1,321 @@
# Micronomicon → µFrame # µFrame (Micronomicon)
A self-hosted web IDE for building rich terminal UIs that publish as `.mu` pages to a NomadNet node. A self-hosted web IDE and CLI for building rich terminal UIs using a declarative DSL. Compiles `.uf` source files into both plain ASCII art and styled Micron `.mu` pages for NomadNet — a decentralized communication platform running on the Reticulum mesh network.
> **Evolution:** Started as a raw Micron text editor (Phases 12, complete). ## What It Does
> 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
Write this:
``` ```
micronomicon/ page "Node Status" 60
Dockerfile box double "Relay Alpha-7"
compose.yml align center
docs/ text "Reticulum Network Node"
framework-design-v3.md # µFrame DSL spec + rendering model gauge "CPU" 62 100 28 warn=75 crit=90
dynamic-templates.md # Dynamic page addendum (Phases 56) status "East Relay" online
backend/ table "Routes"
main.py # FastAPI app + static file serving columns "Dest" 20 | "Hops" 6 | "Status" 10
converter.py # POST /api/compile (µFrame → ASCII + Micron) row "relay-east" | "2" | "@color{0f0}{alive}"
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 Get this (ASCII):
```
╔═ Relay Alpha-7 ════════════════════════════════════════════╗
║ Reticulum Network Node ║
╚════════════════════════════════════════════════════════════╝
CPU ████████████████████░░░░░░░░ 62%
● East Relay
┌────────────────────┬──────┬──────────┐
│Dest │Hops │Status │
├────────────────────┼──────┼──────────┤
│relay-east │2 │● alive │
└────────────────────┴──────┴──────────┘
```
And the same content as Micron `.mu` with color tags, bold, links, and interactive form fields — ready to serve on NomadNet.
## Quick Start
### Web IDE
**Backend:**
```bash ```bash
# Backend
cd backend cd backend
source .venv/bin/activate source .venv/bin/activate
PAGES_DIR=~/.nomadnetwork/storage/pages \ PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \ SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080 uvicorn main:app --reload --port 8080
```
**Frontend:** # Frontend (separate terminal)
```bash
cd frontend cd frontend
npm run dev # proxies /api -> localhost:8080 npm run dev
``` ```
Frontend at http://localhost:5173, backend at http://localhost:8080. Open http://localhost:5173 → click **New Page** → click **Examples** → pick a template.
### CLI
```bash
cd backend
source .venv/bin/activate
# Render to terminal
python -m uframe render page.uf
# Render Micron only
python -m uframe render page.uf --micron
# Compile to .mu file
python -m uframe compile page.uf --out page.mu
# Validate without output
python -m uframe check page.uf
# Compile and deploy to NomadNet
python -m uframe deploy page.uf
```
## Tech Stack
| Layer | Technology |
|------------|-------------------------------------------------|
| Backend | Python 3.13 + FastAPI + uvicorn |
| µFrame | Pure Python (zero deps): parser → IR → CharGrid → emitters |
| Frontend | React 19 + Vite + TypeScript |
| UI | shadcn/ui + Tailwind CSS v4 |
| Editor | CodeMirror 6 (custom µFrame syntax mode) |
| Graph | React Flow (@xyflow/react) + dagre |
| State | Zustand |
| Container | Docker + Compose |
## Project Structure
```
micronomicon/
├── backend/
│ ├── main.py # FastAPI app + static file serving
│ ├── converter.py # POST /api/compile endpoint
│ ├── pages.py # CRUD /api/pages (.uf sources + .mu publish)
│ ├── graph.py # GET /api/graph (link parser)
│ ├── docker_utils.py # POST /api/restart (NomadNet container)
│ └── uframe/ # µFrame engine (16 modules, ~3800 LOC)
│ ├── __init__.py # compile(source, width) → CompileResult
│ ├── parser.py # .uf DSL → IR tree (indentation-based)
│ ├── ir.py # 30+ IR node dataclasses
│ ├── grid.py # CharGrid — 2D char + style buffer
│ ├── chars.py # Unicode tables (box-drawing, braille)
│ ├── measure.py # Bottom-up size computation
│ ├── layout.py # Top-down position assignment
│ ├── paint.py # IR nodes → CharGrid rendering
│ ├── borders.py # Junction merging post-pass
│ ├── emit_ascii.py # CharGrid → plain text
│ ├── emit_micron.py # CharGrid → Micron with style tags
│ ├── codegen.py # Dynamic page → executable Python script
│ ├── cli.py # CLI: render / compile / check / deploy
│ ├── errors.py # ParseError, LayoutError, CompileWarning
│ └── tests/ # 42 tests (compile, dynamic, components)
├── frontend/src/
│ ├── routes/ # DashboardView, EditorView, GraphView
│ ├── components/editor/
│ │ ├── EditorPane.tsx # CodeMirror 6 host
│ │ ├── PreviewPane.tsx # ASCII / Micron / Raw / Script tabs
│ │ ├── ToolBar.tsx # Save, Publish, Examples, Backlinks
│ │ ├── uframeHighlight.ts # µFrame syntax highlighting
│ │ ├── uframeCommands.ts # "/" slash command palette
│ │ ├── micronRenderer.ts # Micron → HTML preview renderer
│ │ └── examples.ts # 9 built-in example templates
│ ├── hooks/
│ │ ├── useCompile.ts # Debounced POST /api/compile
│ │ └── useUnsavedGuard.ts # Prevent accidental navigation
│ └── stores/
│ ├── editorStore.ts # Zustand: source, compiled output, preview mode
│ └── pagesStore.ts # Zustand: page list, delete, unpublish
└── docs/
├── framework-design-v3.md # Full DSL spec + rendering model
└── dynamic-templates.md # Dynamic page addendum
```
## API Endpoints ## API Endpoints
| Method | Path | Description | | Method | Path | Description |
|--------|---------------------|------------------------------------------------------| |--------|---------------------|------------------------------------------------------|
| GET | /api/health | Health check | | GET | /api/health | Health check |
| POST | /api/compile | Compile `.uf``{ascii, micron, warnings}` | | POST | /api/compile | Compile `.uf``{ascii, micron, script, is_dynamic}` |
| GET | /api/pages | List all pages with metadata | | GET | /api/pages | List all pages with metadata |
| GET | /api/pages/{name} | Read page source (`.uf` or legacy `.mu`) | | GET | /api/pages/{name} | Read page source |
| POST | /api/pages/{name} | Save page — body `{ source, publish: bool }` | | POST | /api/pages/{name} | Save page — `{ source, publish }` |
| DELETE | /api/pages/{name} | Delete source and/or .mu file | | DELETE | /api/pages/{name} | Delete source and/or .mu file |
| GET | /api/graph | Graph nodes + edges (parsed from links in source) | | GET | /api/graph | Page link graph (nodes + edges) |
| POST | /api/restart | Restart NomadNet Docker container | | POST | /api/restart | Restart NomadNet Docker container |
## Storage ## Storage
``` ```
~/.micron-editor/sources/ ← .uf source files (draft + published) ~/.micron-editor/sources/ ← .uf source files (drafts + published)
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet ~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
``` ```
On publish: `.uf` is compiled to `.mu` and copied to the NomadNet pages directory. - **Save Draft**: writes `.uf` to sources dir only
- **Publish (static)**: compiles `.uf``.mu`, writes to pages dir (chmod 644)
- **Publish (dynamic)**: compiles `.uf` → executable Python script, writes to pages dir (chmod 755)
## Conventions NomadNet auto-detects the execute bit: static pages are served as-is, dynamic pages are executed and their stdout is served.
- 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 ## µFrame DSL Reference
Full spec: `docs/framework-design-v3.md` ### Layout
### Layout primitives
``` ```
page "Title" [width] # root container (default 64) page "Title" [width] # root (default width 64)
box [light|heavy|double|rounded] "Title" # bordered panel box [light|heavy|double|rounded] "Title" # bordered panel
row [gap] # horizontal layout row [gap] # horizontal layout
col [width] # column in a row col [width] # column in a row
spacer [lines] # vertical whitespace spacer [lines] # vertical whitespace
pad [t] [r] [b] [l] # inner margin pad [t] [r] [b] [l] # inner margin
``` ```
### Content primitives ### Content
``` ```
heading [1|2|3] "Text" # styled heading heading [1|2|3] "Text" # styled heading
text "Content with @bold{inline} @color{hex}{modifiers}" text "Content with @bold{inline} @color{hex}{modifiers}"
label "Key" "Value" # aligned key-value pair label "Key" "Value" # aligned key-value pair
list [bullet|dash] list [bullet|dash]
item "Entry" item "Entry"
link "Display text" "/dest.mu" # clickable in Micron link "Display text" "/dest.mu" # clickable in Micron
divider [light|heavy|double|dash|dot] # horizontal rule divider [light|heavy|double|dash|dot] # horizontal rule
# comment # ignored in output # comment # ignored in output
``` ```
### Data visualization (Phase 4) ### Data Visualization
``` ```
gauge "Label" $val $max $width warn=N crit=N gauge "Label" value max width [warn=N crit=N] # ████░░░░ bar with thresholds
sparkline "Label" $values $width # braille patterns sparkline "Label" "1,3,5,8,7,5" width # ⣀⣤⣶⣿⣷⣤ braille chart
status "Label" [online|offline|degraded] # ●○◐ indicators status "Label" [online|offline|degraded] # ●○◐ colored indicators
table "Title" table "Title"
columns "Name" 24 | "Hops" 6 | "Status" 10 columns "Name" 20 | "Hops" 6 | "Status" 10
row "value" | "value" | "value" row "relay" | "2" | "@color{0f0}{● alive}"
``` ```
### Forms (Phase 5) ### Forms
``` ```
form "name" form "name"
field "name" [width] "placeholder" field "name" [width] "placeholder" # text input
radio "group" "Opt A" | "Opt B" password "name" [width] "placeholder" # masked input
checkbox "name" "Label" radio "group" "Opt A" | "Opt B" | "Opt C" # radio buttons
button "Label" "/action/path" checkbox "name" "Label" # checkbox
button "Label" "/action/path" # submit link
``` ```
### Dynamic features (Phase 6) ### Dynamic Features
``` ```
source cpu : shell "cat /proc/loadavg" cache 0 # never cache (re-execute)
on_submit "form_name" source cpu : shell "cat /proc/loadavg" # live data at render time
# handle form data source config : json "/path/config.json" # JSON file read
if $val > threshold source ts : python "datetime.now().isoformat()" # Python expression
# conditional rendering let name = "Relay Alpha" # variable assignment
for item in $collection
# iteration if $cpu > 90
state "store" "/path.json" text "ALERT: CPU critical"
cache 0 # never cache (re-execute per request) elif $cpu > 75
text "Warning: elevated"
for peer in $peers
status "$peer.name" $peer.state
on_submit "search"
source results : shell "search.py '$query'"
text "$results"
state "counter" "/tmp/counter.json" # persistent JSON store
``` ```
### Rendering pipeline ### Components
``` ```
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid # Define a reusable component
├→ ASCII emitter (plain text) component stat(label, value, max)
└→ Micron emitter (styled .mu) gauge "$label" $value $max 20
# Use it
stat "CPU" 62 100
stat "MEM" 84 100
# Import standard library
use std/dashboard
banner "My Node" "Mesh Network"
resources 62 84
``` ```
### Micron syntax (legacy raw editor, still used for compiled output) ### Standard Libraries
| Library | Components |
|---------|-----------|
| `std/dashboard` | `banner(title, subtitle)`, `resources(cpu, mem)`, `peer_status(name, state)` |
| `std/status-bar` | `status_bar(label, value, max)`, `status_item(name, state)` |
| `std/nav` | `nav_link(label, dest)`, `nav_divider()` |
## Rendering Pipeline
``` ```
>H1 >>H2 >>>H3 headings .uf source
`!bold`! `*italic`* `_underline`_ formatting
`Fhex text`f `Bhex text`b colors (3-digit hex)
`c text`a `r text`a `l text`a alignment Parse ──→ IR Tree (30+ node types)
[label`slug] links
-─ -━ -═ -★ dividers
`= ... `= literal mode Measure (bottom-up: compute sizes)
# comment hidden in output
Layout (top-down: assign positions)
Paint (depth-first: write chars into CharGrid)
Merge Borders (fix junction characters)
├──→ ASCII emitter → plain text
├──→ Micron emitter → styled .mu (with colors, links, form tags)
└──→ Codegen (if dynamic) → executable Python script
``` ```
## Roadmap ## Web IDE Features
### Phase 1 — Core Editor + Dashboard + Graph ✅ - **Split-pane editor**: µFrame DSL source (left) / live preview (right)
- **Syntax highlighting**: keywords, strings, variables, comments in distinct colors
- **`/` command palette**: type `/` to insert layout, content, data viz, form, and style primitives
- **4 preview tabs**: ASCII | Micron (rendered) | Raw (Micron source) | Script (dynamic pages only)
- **`⚡ dynamic` badge**: auto-detected when source contains `source`, `if`, `for`, etc.
- **Examples dropdown**: 9 built-in templates (Hello World → Full Node Page → Dynamic Dashboard)
- **Pages dashboard**: table view with Published/Draft/Orphan status badges
- **Page graph**: React Flow visualization of inter-page links
- **Keyboard shortcuts**: `Ctrl+S` save draft, `Ctrl+P` publish
- **Unsaved changes guard**: warns before navigating away
- **Backlink indicator**: shows which pages link to the current page
- Split-pane Micron editor with live preview ## Running Tests
- 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 ✅ ```bash
cd backend
source .venv/bin/activate
python -m pytest uframe/tests/ -v
```
- `[[` page link autocomplete 42 tests covering:
- Backlink indicator with popover - Static compilation (boxes, headings, text, gauges, tables, links, lists, spacers, dividers)
- Pivot to direct Micron editor (removed Markdown pipeline) - Form elements (field, radio, checkbox, button)
- `/` slash command palette for Micron syntax - Dynamic pages (source, if/for, let, state, on_submit, codegen)
- Micron syntax highlighting + HTML preview renderer - Components (inline definitions, standard library, parameter substitution)
- Syntax completeness per micron-composer spec
### Phase 3 — µFrame Core Engine (next) ## Conventions
Build `backend/uframe/` — the rendering pipeline: - µFrame engine is **pure Python stdlib** — zero external dependencies
- Backend is FastAPI; no ORM, flat file storage
1. `errors.py` + `ir.py` — data structures - Frontend uses shadcn/ui components in `frontend/src/components/ui/`
2. `chars.py` — Unicode lookup tables (box-drawing, blocks, braille) - Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
3. `parser.py``.uf` DSL → IR tree (indentation-based, line-oriented) - State management via Zustand stores
4. `grid.py` — CharGrid class (2D char + style buffer) - All `.uf` sources stored in `SOURCES_DIR`, compiled `.mu` in `PAGES_DIR`
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 ## References
- µFrame design: `docs/framework-design-v3.md` - µFrame design spec: `docs/framework-design-v3.md`
- µFrame dynamic: `docs/dynamic-templates.md` - Dynamic templates spec: `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 - NomadNet: https://github.com/markqvist/NomadNet
- md2txt (legacy): https://codeberg.org/randogoth/md2txt - Micron syntax: https://github.com/fr33n0w/micron-composer
- Reticulum: https://github.com/markqvist/Reticulum

281
README.md
View File

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

View File

@@ -1,148 +1,19 @@
"""Border merging post-pass — fix junction characters where borders meet. """Border merging post-pass (currently no-op).
Scans the CharGrid for adjacent border cells and replaces with the The draw_border and _paint_table functions produce correct border characters
correct junction character (T-junctions, crosses, corners) from the directly. The original merge pass caused garbled junctions when borders from
Unicode box-drawing set. different boxes were adjacent, so it was disabled.
If future features need cross-box junction merging (e.g. tables sharing
edges with parent boxes), add targeted logic here using border_id from
the grid cells to only merge within the same border group.
""" """
from __future__ import annotations from __future__ import annotations
from uframe.grid import CharGrid 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: def merge_borders(grid: CharGrid) -> None:
"""Scan the grid for adjacent border cells and fix junction characters. """No-op — borders are correctly painted by draw_border and _paint_table."""
pass
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

View File

@@ -11,7 +11,6 @@ from __future__ import annotations
import argparse import argparse
import os import os
import stat
import sys import sys
from pathlib import Path from pathlib import Path

View File

@@ -12,16 +12,13 @@ state) and generates a self-contained Python script that:
from __future__ import annotations from __future__ import annotations
import textwrap
from pathlib import Path
from uframe.ir import ( from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad, IRNode, Page, Box, Spacer,
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link,
Gauge, Sparkline, Status, Table, Gauge, Status,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle, SourceType,
) )
@@ -70,11 +67,14 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
elif node.source_type == SourceType.JSON: elif node.source_type == SourceType.JSON:
lines.append(f"{ind}{var} = _read_json({node.command!r})") lines.append(f"{ind}{var} = _read_json({node.command!r})")
elif node.source_type == SourceType.PYTHON: elif node.source_type == SourceType.PYTHON:
lines.append(f"{ind}{var} = eval({node.command!r})") # Restricted eval — only datetime/secrets modules available
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})")
elif node.source_type == SourceType.PARAM: elif node.source_type == SourceType.PARAM:
lines.append(f"{ind}{var} = _get_param({node.command!r})") lines.append(f"{ind}{var} = _get_param({node.command!r})")
elif node.source_type == SourceType.RNS: elif node.source_type == SourceType.RNS:
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})") import shlex as _shlex
safe_cmd = _shlex.quote(node.command)
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
elif isinstance(node, CacheControl): elif isinstance(node, CacheControl):
lines.append(f"{ind}_cache_seconds = {node.seconds}") lines.append(f"{ind}_cache_seconds = {node.seconds}")

View File

@@ -54,15 +54,7 @@ _EMPTY_STYLE = CellStyle()
def emit_micron(grid: CharGrid, page_title: str = "") -> str: def emit_micron(grid: CharGrid, page_title: str = "") -> str:
"""Emit the CharGrid as Micron markup. """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] = [] lines: list[str] = []
for row in range(grid.height): for row in range(grid.height):

View File

@@ -39,17 +39,19 @@ class Cell:
style: CellStyle = field(default_factory=CellStyle) style: CellStyle = field(default_factory=CellStyle)
is_border: bool = False # True for box-drawing characters (for merge pass) is_border: bool = False # True for box-drawing characters (for merge pass)
border_weight: BorderWeight | None = None border_weight: BorderWeight | None = None
border_id: int = 0 # Identifies which box this border belongs to
link: str | None = None # Micron link destination link: str | None = None # Micron link destination
class CharGrid: class CharGrid:
"""2D buffer of cells. Origin (0,0) is top-left.""" """2D buffer of cells. Origin (0,0) is top-left."""
__slots__ = ("width", "height", "cells") __slots__ = ("width", "height", "cells", "_border_counter")
def __init__(self, width: int, height: int): def __init__(self, width: int, height: int):
self.width = width self.width = width
self.height = height self.height = height
self._border_counter = 0
self.cells: list[list[Cell]] = [ self.cells: list[list[Cell]] = [
[Cell() for _ in range(width)] [Cell() for _ in range(width)]
for _ in range(height) for _ in range(height)
@@ -62,6 +64,7 @@ class CharGrid:
style: CellStyle | None = None, style: CellStyle | None = None,
is_border: bool = False, is_border: bool = False,
border_weight: BorderWeight | None = None, border_weight: BorderWeight | None = None,
border_id: int = 0,
link: str | None = None) -> None: link: str | None = None) -> None:
"""Write a single character to the grid.""" """Write a single character to the grid."""
if not self.in_bounds(x, y): if not self.in_bounds(x, y):
@@ -72,6 +75,8 @@ class CharGrid:
cell.style = style cell.style = style
cell.is_border = is_border cell.is_border = is_border
cell.border_weight = border_weight cell.border_weight = border_weight
if border_id:
cell.border_id = border_id
if link is not None: if link is not None:
cell.link = link cell.link = link
@@ -114,24 +119,27 @@ class CharGrid:
if w < 2 or h < 2: if w < 2 or h < 2:
return return
self._border_counter += 1
bid = self._border_counter
ch = BOX_CHARS[weight] ch = BOX_CHARS[weight]
border_style = CellStyle() border_style = CellStyle()
# Corners # Corners
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight) self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight) self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight) self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight) self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight, border_id=bid)
# Top and bottom edges # Top and bottom edges
for col in range(x + 1, x + w - 1): 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, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight) self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
# Left and right edges # Left and right edges
for row in range(y + 1, y + h - 1): 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, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight) self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
# Title in top border # Title in top border
if title and w > 4: if title and w > 4:

View File

@@ -8,7 +8,6 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum, auto from enum import Enum, auto
from typing import Any
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -7,6 +7,7 @@ own structure (borders, etc.).
from __future__ import annotations from __future__ import annotations
import re
import textwrap import textwrap
from uframe.chars import ( from uframe.chars import (
@@ -63,8 +64,12 @@ def paint(node: IRNode, grid: CharGrid) -> None:
weight=node.weight, weight=node.weight,
title=node.title, title=node.title,
title_style=title_style) title_style=title_style)
# Paint children inside the border # Propagate box alignment/color to children that don't have their own
for child in node.children: for child in node.children:
if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
child.style.align = node.style.align
if node.style.fg and not child.style.fg:
child.style.fg = node.style.fg
paint(child, grid) paint(child, grid)
elif isinstance(node, Row): elif isinstance(node, Row):
@@ -155,9 +160,9 @@ def paint(node: IRNode, grid: CharGrid) -> None:
elif isinstance(node, ListItem): elif isinstance(node, ListItem):
style = _style_from_node(node) style = _style_from_node(node)
# Parent determines bullet style — use a simple bullet for now # Bullet to the left of the content (safe: put_text clips to bounds)
bullet = "" bullet_x = max(0, x - 2)
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888")) grid.put_text(bullet_x, y, "", style=CellStyle(fg="888"))
# Wrap content # Wrap content
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""] wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
for i, line in enumerate(wrapped): for i, line in enumerate(wrapped):
@@ -357,9 +362,7 @@ def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
# Check for @color{hex}{text} modifiers in cell content # Check for @color{hex}{text} modifiers in cell content
if "@" in cell_text: if "@" in cell_text:
spans = [] pattern = re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
import re as _re
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
pos = 0 pos = 0
styled_parts: list[tuple[str, CellStyle]] = [] styled_parts: list[tuple[str, CellStyle]] = []
for m in pattern.finditer(cell_text): for m in pattern.finditer(cell_text):

View File

@@ -11,7 +11,6 @@ from __future__ import annotations
import re import re
import shlex import shlex
from typing import Sequence
from uframe.errors import ParseError from uframe.errors import ParseError
from uframe.ir import ( from uframe.ir import (
@@ -594,6 +593,18 @@ component resources(cpu, mem)
component peer_status(name, state) component peer_status(name, state)
status "$name" $state status "$name" $state
component info_box(title, content)
box light "$title"
text "$content"
component alert_box(title, content)
box heavy "$title"
color f00
text "$content"
component metric(label, value, max, width)
gauge "$label" $value $max $width
''', ''',
"std/status-bar": '''\ "std/status-bar": '''\
component status_bar(label, value, max) component status_bar(label, value, max)
@@ -601,6 +612,13 @@ component status_bar(label, value, max)
component status_item(name, state) component status_item(name, state)
status "$name" $state status "$name" $state
component status_row(name1, state1, name2, state2)
row 2
col 28
status "$name1" $state1
col 28
status "$name2" $state2
''', ''',
"std/nav": '''\ "std/nav": '''\
component nav_link(label, dest) component nav_link(label, dest)
@@ -608,6 +626,36 @@ component nav_link(label, dest)
component nav_divider() component nav_divider()
divider light divider light
component nav_bar(label1, dest1, label2, dest2)
row 2
col 28
link "$label1" "$dest1"
col 28
link "$label2" "$dest2"
''',
"std/network": '''\
component route_table(title)
table "$title"
component peer_list(title)
heading 2 "$title"
component traffic(label_in, vals_in, label_out, vals_out)
sparkline "$label_in" "$vals_in" 20
sparkline "$label_out" "$vals_out" 20
''',
"std/form": '''\
component search_form(name, action)
form "$name"
field "query" 30 "Search..."
button "Search" "$action"
component login_form(action)
form "login"
field "username" 24 "Username"
password "password" 24 "Password"
button "Login" "$action"
''', ''',
} }

View File

@@ -1,4 +1,3 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { useEditorStore } from "@/stores/editorStore"; import { useEditorStore } from "@/stores/editorStore";
import { renderMicron } from "./micronRenderer"; import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -62,9 +61,9 @@ export default function PreviewPane() {
))} ))}
</div> </div>
</div> </div>
<ScrollArea className="flex-1 bg-background"> <div className="flex-1 bg-background overflow-auto">
{previewMode === "ascii" ? ( {previewMode === "ascii" ? (
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90"> <pre className="p-2 font-mono text-[11px] whitespace-pre leading-tight text-green-100/90">
{compiledAscii || ( {compiledAscii || (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
ASCII preview will appear here ASCII preview will appear here
@@ -74,7 +73,7 @@ export default function PreviewPane() {
) : previewMode === "micron" ? ( ) : previewMode === "micron" ? (
compiledMicron ? ( compiledMicron ? (
<div <div
className="p-4 font-mono text-sm whitespace-pre leading-tight" className="p-2 font-mono text-[11px] whitespace-pre leading-tight"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: renderMicron(compiledMicron), __html: renderMicron(compiledMicron),
}} }}
@@ -91,11 +90,11 @@ export default function PreviewPane() {
{compiledScript || "No dynamic script generated."} {compiledScript || "No dynamic script generated."}
</pre> </pre>
) : ( ) : (
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground"> <pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground">
{compiledMicron || "Raw Micron output will appear here…"} {compiledMicron || "Raw Micron output will appear here…"}
</pre> </pre>
)} )}
</ScrollArea> </div>
</div> </div>
); );
} }

View File

@@ -15,59 +15,82 @@ function escapeHtml(text: string): string {
function renderInline(raw: string): string { function renderInline(raw: string): string {
let out = ""; let out = "";
let i = 0; let i = 0;
const openTags: string[] = [];
const closeAll = () => { // Track open/close state for toggle-style tags
while (openTags.length) out += openTags.pop()!; let boldOpen = false;
}; let italicOpen = false;
let underOpen = false;
let fgOpen = false;
let bgOpen = false;
let alignOpen = false;
while (i < raw.length) { while (i < raw.length) {
// Backtick formatting codes // Backtick formatting codes
if (raw[i] === "`") { if (raw[i] === "`") {
const code = raw[i + 1]; const code = raw[i + 1];
if (code === "!") { if (code === "!") {
out += "<strong>"; openTags.push("</strong>"); i += 2; continue; if (boldOpen) { out += "</strong>"; boldOpen = false; }
else { out += "<strong>"; boldOpen = true; }
i += 2; continue;
} else if (code === "*") { } else if (code === "*") {
out += "<em>"; openTags.push("</em>"); i += 2; continue; if (italicOpen) { out += "</em>"; italicOpen = false; }
else { out += "<em>"; italicOpen = true; }
i += 2; continue;
} else if (code === "_") { } else if (code === "_") {
out += "<u>"; openTags.push("</u>"); i += 2; continue; if (underOpen) { out += "</u>"; underOpen = false; }
else { out += "<u>"; underOpen = true; }
i += 2; continue;
} else if (code === "`") { } else if (code === "`") {
closeAll(); i += 2; continue; // Reset all
} else if (code === "f" || code === "b") { if (boldOpen) { out += "</strong>"; boldOpen = false; }
out += "</span>"; i += 2; continue; if (italicOpen) { out += "</em>"; italicOpen = false; }
if (underOpen) { out += "</u>"; underOpen = false; }
if (fgOpen) { out += "</span>"; fgOpen = false; }
if (bgOpen) { out += "</span>"; bgOpen = false; }
if (alignOpen) { out += "</span>"; alignOpen = false; }
i += 2; continue;
} else if (code === "f") {
if (fgOpen) { out += "</span>"; fgOpen = false; }
i += 2; continue;
} else if (code === "b") {
if (bgOpen) { out += "</span>"; bgOpen = false; }
i += 2; continue;
} else if (code === "a") { } else if (code === "a") {
out += "</span>"; i += 2; continue; if (alignOpen) { out += "</span>"; alignOpen = false; }
i += 2; continue;
} else if (code === "F") { } else if (code === "F") {
// Foreground color — 3-digit hex only per spec // Foreground color — 3-digit hex only per spec
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/); const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/);
if (hexMatch) { if (hexMatch) {
if (fgOpen) { out += "</span>"; }
const [r, g, b] = hexMatch[1].split(""); const [r, g, b] = hexMatch[1].split("");
const hex = r + r + g + g + b + b; const hex = r + r + g + g + b + b;
out += `<span style="color:#${hex}">`; out += `<span style="color:#${hex}">`;
openTags.push("</span>"); fgOpen = true;
i += 2 + hexMatch[1].length; i += 2 + hexMatch[1].length;
continue; continue;
} }
} else if (code === "B") { } else if (code === "B") {
// Background color — 3-digit hex only per spec // Background color — 3-digit hex only per spec
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/); const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/);
if (hexMatch) { if (hexMatch) {
if (bgOpen) { out += "</span>"; }
const [r, g, b] = hexMatch[1].split(""); const [r, g, b] = hexMatch[1].split("");
const hex = r + r + g + g + b + b; const hex = r + r + g + g + b + b;
out += `<span style="background:#${hex}">`; out += `<span style="background:#${hex}">`;
openTags.push("</span>"); bgOpen = true;
i += 2 + hexMatch[1].length; i += 2 + hexMatch[1].length;
continue; continue;
} }
} else if (code === "c") { } else if (code === "c") {
out += `<span style="display:block;text-align:center">`; out += `<span style="display:block;text-align:center">`;
openTags.push("</span>"); i += 2; continue; alignOpen = true; i += 2; continue;
} else if (code === "r") { } else if (code === "r") {
out += `<span style="display:block;text-align:right">`; out += `<span style="display:block;text-align:right">`;
openTags.push("</span>"); i += 2; continue; alignOpen = true; i += 2; continue;
} else if (code === "l") { } else if (code === "l") {
out += `<span style="display:block;text-align:left">`; out += `<span style="display:block;text-align:left">`;
openTags.push("</span>"); i += 2; continue; alignOpen = true; i += 2; continue;
} else if (code === "<") { } else if (code === "<") {
// Form element: `<...> or `<!...> or `<?...> or `<^...> // Form element: `<...> or `<!...> or `<?...> or `<^...>
const closeAngle = raw.indexOf(">", i + 2); const closeAngle = raw.indexOf(">", i + 2);
@@ -116,7 +139,13 @@ function renderInline(raw: string): string {
i++; i++;
} }
closeAll(); // Close any remaining open tags
if (boldOpen) out += "</strong>";
if (italicOpen) out += "</em>";
if (underOpen) out += "</u>";
if (fgOpen) out += "</span>";
if (bgOpen) out += "</span>";
if (alignOpen) out += "</span>";
return out; return out;
} }

View File

@@ -127,4 +127,34 @@
html { html {
@apply font-sans; @apply font-sans;
} }
/* Dark-mode scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: oklch(0.35 0 0) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: oklch(0.35 0 0);
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: oklch(0.45 0 0);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
.dark *::-webkit-scrollbar-thumb {
background: oklch(0.3 0 0);
}
.dark *::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0 0);
}
} }