Compare commits

..

4 Commits

Author SHA1 Message Date
175de893aa feat: big title 2026-04-01 12:16:16 +02:00
8d3245b7b1 feat: registry 2026-04-01 12:16:05 +02:00
01a3e0095c feat: themes 2026-04-01 10:45:02 +02:00
0316e50233 feat: preview 2026-04-01 10:13:14 +02:00
26 changed files with 1794 additions and 617 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,10 +1,12 @@
"""µFrame compile endpoint — POST /api/compile.""" """µFrame compile + DSL metadata endpoints."""
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
import uframe import uframe
import uframe.keywords # noqa: F401 — triggers keyword registration
from uframe.errors import UFrameError from uframe.errors import UFrameError
from uframe.registry import get_dsl_meta
router = APIRouter() router = APIRouter()
@@ -36,3 +38,9 @@ async def compile_source(req: CompileRequest):
) )
except UFrameError as e: except UFrameError as e:
raise HTTPException(status_code=422, detail=str(e)) raise HTTPException(status_code=422, detail=str(e))
@router.get("/dsl-meta")
async def dsl_meta():
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
return get_dsl_meta()

View File

@@ -21,6 +21,7 @@ from uframe.ir import (
IRNode, Field, Password, Radio, Checkbox, FormButton, IRNode, Field, Password, Radio, Checkbox, FormButton,
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl, Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
) )
from uframe.themes import get_theme, ThemeDef
@dataclass @dataclass
@@ -79,7 +80,7 @@ def _micron_form_line(node: IRNode) -> str:
return "" return ""
def compile(source: str, width: int = 64) -> CompileResult: def compile(source: str, width: int = 64, theme: str = "") -> CompileResult:
"""Compile a µFrame .uf source string into ASCII and Micron output. """Compile a µFrame .uf source string into ASCII and Micron output.
Args: Args:
@@ -102,6 +103,10 @@ def compile(source: str, width: int = 64) -> CompileResult:
w = page.width w = page.width
# 1b. Resolve theme (CLI flag overrides source directive)
theme_name = theme or page.theme_name or "default"
theme_def = get_theme(theme_name)
# 2. Measure # 2. Measure
measure(page, w) measure(page, w)
@@ -110,7 +115,7 @@ def compile(source: str, width: int = 64) -> CompileResult:
# 4. Create grid and paint # 4. Create grid and paint
grid = CharGrid(w, max(total_h, 1)) grid = CharGrid(w, max(total_h, 1))
paint(page, grid) paint(page, grid, theme_def)
# 5. Merge borders # 5. Merge borders
merge_borders(grid) merge_borders(grid)

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
@@ -24,7 +23,7 @@ def cmd_render(args: argparse.Namespace) -> int:
source = Path(args.file).read_text(encoding="utf-8") source = Path(args.file).read_text(encoding="utf-8")
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -53,7 +52,7 @@ def cmd_compile(args: argparse.Namespace) -> int:
source = source_path.read_text(encoding="utf-8") source = source_path.read_text(encoding="utf-8")
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -76,7 +75,7 @@ def cmd_check(args: argparse.Namespace) -> int:
source = Path(args.file).read_text(encoding="utf-8") source = Path(args.file).read_text(encoding="utf-8")
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -98,7 +97,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages")) dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
try: try:
result = uframe.compile(source, width=args.width) result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
except UFrameError as e: except UFrameError as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return 1 return 1
@@ -131,12 +130,14 @@ def main() -> int:
p_render.add_argument("--ascii", action="store_true", help="Output ASCII only") p_render.add_argument("--ascii", action="store_true", help="Output ASCII only")
p_render.add_argument("--micron", action="store_true", help="Output Micron only") p_render.add_argument("--micron", action="store_true", help="Output Micron only")
p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)") p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)")
p_render.add_argument("--theme", default="", help="Theme name (default, nouveau, gothic, bamboo, circuit, brutalist)")
# compile # compile
p_compile = sub.add_parser("compile", help="Compile to .mu file") p_compile = sub.add_parser("compile", help="Compile to .mu file")
p_compile.add_argument("file", help="Path to .uf source file") p_compile.add_argument("file", help="Path to .uf source file")
p_compile.add_argument("--out", help="Output file path (default: <name>.mu)") p_compile.add_argument("--out", help="Output file path (default: <name>.mu)")
p_compile.add_argument("--width", type=int, default=64, help="Page width") p_compile.add_argument("--width", type=int, default=64, help="Page width")
p_compile.add_argument("--theme", default="", help="Theme name")
# check # check
p_check = sub.add_parser("check", help="Validate a .uf file") p_check = sub.add_parser("check", help="Validate a .uf file")
@@ -148,6 +149,7 @@ def main() -> int:
p_deploy.add_argument("file", help="Path to .uf source file") p_deploy.add_argument("file", help="Path to .uf source file")
p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)") p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
p_deploy.add_argument("--width", type=int, default=64, help="Page width") p_deploy.add_argument("--width", type=int, default=64, help="Page width")
p_deploy.add_argument("--theme", default="", help="Theme name")
args = parser.parse_args() args = parser.parse_args()

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):

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

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

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
@@ -101,7 +106,9 @@ class CharGrid:
def draw_border(self, x: int, y: int, w: int, h: int, def draw_border(self, x: int, y: int, w: int, h: int,
weight: BorderWeight = BorderWeight.LIGHT, weight: BorderWeight = BorderWeight.LIGHT,
title: str = "", title: str = "",
title_style: CellStyle | None = None) -> None: title_style: CellStyle | None = None,
border_chars: dict[str, str] | None = None,
title_caps: tuple[str, str] | None = None) -> None:
"""Draw a box border. Interior is not cleared. """Draw a box border. Interior is not cleared.
Args: Args:
@@ -114,33 +121,38 @@ class CharGrid:
if w < 2 or h < 2: if w < 2 or h < 2:
return return
ch = BOX_CHARS[weight] self._border_counter += 1
bid = self._border_counter
ch = border_chars or 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:
title_text = f" {title} " lc = title_caps[0] if title_caps else " "
max_title = w - 4 # leave room for corners + padding rc = title_caps[1] if title_caps else " "
title_text = f"{lc}{title}{rc}"
max_title = w - 4
if len(title_text) > max_title: if len(title_text) > max_title:
title_text = title_text[:max_title] title_text = title_text[:max_title]
start_x = x + 2 start_x = x + 1
ts = title_style or CellStyle(bold=True) ts = title_style or CellStyle(bold=True)
self.put_text(start_x, y, title_text, style=ts) self.put_text(start_x, y, title_text, style=ts)

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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -119,6 +118,7 @@ class Page(IRNode):
"""Root container. One per .uf file.""" """Root container. One per .uf file."""
title: str = "" title: str = ""
width: int = 64 width: int = 64
theme_name: str = ""
@dataclass @dataclass
@@ -361,6 +361,13 @@ class StateDecl(IRNode):
# Components (Phase 8) # Components (Phase 8)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass
class BigTitle(IRNode):
"""Large multi-line ASCII art text."""
text: str = ""
font: str = "block" # block, thin, pixel
@dataclass @dataclass
class ComponentDef(IRNode): class ComponentDef(IRNode):
"""Component definition: component name(arg1, arg2).""" """Component definition: component name(arg1, arg2)."""

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

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

View File

@@ -8,6 +8,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
) )
@@ -135,7 +136,8 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem, elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Field, Password, Radio, Checkbox, FormButton)): Field, Password, Radio, Checkbox, FormButton,
BigTitle)):
node.rect.h = node.pref_height node.rect.h = node.pref_height
return node.pref_height return node.pref_height

View File

@@ -8,7 +8,9 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
) )
from uframe.fonts import FONT_HEIGHTS, get_text_width
def _text_height(text: str, width: int) -> int: def _text_height(text: str, width: int) -> int:
@@ -243,6 +245,22 @@ def measure(node: IRNode, available_width: int) -> None:
node.pref_height = 1 node.pref_height = 1
node.min_height = 1 node.min_height = 1
elif isinstance(node, BigTitle):
# Try the requested font, fall back to smaller if too wide
tw = get_text_width(node.text, node.font)
if tw <= available_width:
h = FONT_HEIGHTS.get(node.font, 6)
elif get_text_width(node.text, "pixel") <= available_width:
h = FONT_HEIGHTS.get("pixel", 3)
elif get_text_width(node.text, "thin") <= available_width:
h = FONT_HEIGHTS.get("thin", 3)
else:
h = 1 # fallback to single styled line
node.pref_width = available_width
node.min_width = 1
node.pref_height = h
node.min_height = 1
elif isinstance(node, (Let, Source, CacheControl, StateDecl)): elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
# Zero-height metadata nodes — no visual output # Zero-height metadata nodes — no visual output
node.pref_width = 0 node.pref_width = 0

View File

@@ -7,11 +7,11 @@ 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 (
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY, BOX_CHARS, DIVIDER_CHARS, sparkline_chars,
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
) )
from uframe.grid import CharGrid, CellStyle from uframe.grid import CharGrid, CellStyle
from uframe.ir import ( from uframe.ir import (
@@ -19,8 +19,11 @@ from uframe.ir import (
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
BigTitle,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
from uframe.themes import ThemeDef, THEME_DEFAULT
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
def _align_text(text: str, width: int, align: Align) -> str: def _align_text(text: str, width: int, align: Align) -> str:
@@ -45,8 +48,9 @@ def _style_from_node(node: IRNode) -> CellStyle:
) )
def paint(node: IRNode, grid: CharGrid) -> None: def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
"""Recursively paint an IR node and its children into the grid.""" """Recursively paint an IR node and its children into the grid."""
th = theme or THEME_DEFAULT
x, y, w = node.rect.x, node.rect.y, node.rect.w x, y, w = node.rect.x, node.rect.y, node.rect.w
# Ensure grid is tall enough # Ensure grid is tall enough
@@ -54,42 +58,48 @@ def paint(node: IRNode, grid: CharGrid) -> None:
if isinstance(node, Page): if isinstance(node, Page):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Box): elif isinstance(node, Box):
# Draw the border # Draw the border with themed characters
title_style = CellStyle(bold=True, fg=node.style.fg) title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
grid.draw_border(x, y, w, node.rect.h, grid.draw_border(x, y, w, node.rect.h,
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 border_chars=th.border_dict(node.weight.name.lower()),
title_caps=(th.title_caps.left, th.title_caps.right))
# Propagate box alignment/color to children that don't have their own
for child in node.children: for child in node.children:
paint(child, grid) if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
child.style.align = node.style.align
if node.style.fg and not child.style.fg:
child.style.fg = node.style.fg
paint(child, grid, th)
elif isinstance(node, Row): elif isinstance(node, Row):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Col): elif isinstance(node, Col):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Spacer): elif isinstance(node, Spacer):
pass # Just empty space pass # Just empty space
elif isinstance(node, Pad): elif isinstance(node, Pad):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Heading): elif isinstance(node, Heading):
style = CellStyle(bold=True) style = CellStyle(bold=True)
if node.level == HeadingLevel.H1: if node.level == HeadingLevel.H1:
style.fg = "0f0" # green style.fg = th.palette.accent
elif node.level == HeadingLevel.H2: elif node.level == HeadingLevel.H2:
style.fg = "0cf" # cyan style.fg = th.palette.accent2
elif node.level == HeadingLevel.H3: elif node.level == HeadingLevel.H3:
style.fg = "88f" # light blue style.fg = th.palette.accent3
# Underline-style heading # Underline-style heading
grid.put_text(x, y, node.text[:w], style=style) grid.put_text(x, y, node.text[:w], style=style)
@@ -138,26 +148,24 @@ def paint(node: IRNode, grid: CharGrid) -> None:
elif isinstance(node, Divider): elif isinstance(node, Divider):
ds = node.divider_style ds = node.divider_style
char = DIVIDER_CHARS.get(ds.name.lower(), "") char = getattr(th.dividers, ds.name.lower(), th.dividers.light)
style = CellStyle(fg="555") style = CellStyle(fg=th.palette.muted)
for col in range(x, x + w): for col in range(x, x + w):
grid.put(col, y, char, style=style) grid.put(col, y, char, style=style)
elif isinstance(node, Link): elif isinstance(node, Link):
style = CellStyle(fg="0cf", underline=True) style = CellStyle(fg=th.palette.info, 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) grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
elif isinstance(node, ListNode): elif isinstance(node, ListNode):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
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, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label))
# 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):
@@ -178,17 +186,17 @@ def paint(node: IRNode, grid: CharGrid) -> None:
filled = int(bar_w * pct) filled = int(bar_w * pct)
# Determine color based on thresholds # Determine color based on thresholds
fg = "0f0" # green fg = th.palette.success
if node.crit is not None and node.value >= node.crit: if node.crit is not None and node.value >= node.crit:
fg = "f00" # red fg = th.palette.danger
elif node.warn is not None and node.value >= node.warn: elif node.warn is not None and node.value >= node.warn:
fg = "ff0" # yellow fg = th.palette.warning
for i in range(bar_w): for i in range(bar_w):
if i < filled: if i < filled:
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg)) grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
else: else:
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555")) grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
# Percentage # Percentage
pct_text = f" {int(pct * 100)}%" pct_text = f" {int(pct * 100)}%"
@@ -201,77 +209,115 @@ def paint(node: IRNode, grid: CharGrid) -> None:
spark_x = x + len(label_text) spark_x = x + len(label_text)
chars = sparkline_chars(node.values, node.spark_width) chars = sparkline_chars(node.values, node.spark_width)
spark_style = CellStyle(fg="0cf") spark_style = CellStyle(fg=th.palette.info)
for i, ch in enumerate(chars): for i, ch in enumerate(chars):
grid.put(spark_x + i, y, ch, style=spark_style) grid.put(spark_x + i, y, ch, style=spark_style)
elif isinstance(node, Status): elif isinstance(node, Status):
char = STATUS_CHARS.get(node.state, "") char = getattr(th.indicators, node.state, th.indicators.unknown)
color = STATUS_COLORS.get(node.state, "888") color_map = {"online": th.palette.success, "offline": th.palette.danger,
"degraded": th.palette.warning, "unknown": th.palette.label,
"alert": th.palette.danger}
color = color_map.get(node.state, th.palette.label)
grid.put(x, y, char, style=CellStyle(fg=color)) grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label) grid.put_text(x + 2, y, node.label)
elif isinstance(node, BigTitle):
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
# Determine which font fits
font = node.font
tw = get_text_width(node.text, font)
if tw > w:
# Try fallback cascade
for fallback in ["pixel", "thin"]:
if get_text_width(node.text, fallback) <= w:
font = fallback
tw = get_text_width(node.text, fallback)
break
else:
# Final fallback: styled single line
styled = f"═══ {node.text.upper()} ═══"
grid.put_text(x, y, styled[:w], style=style)
return
lines = render_big_text(node.text, font)
# Center if align is set
offset = 0
if node.style.align == Align.CENTER:
offset = max(0, (w - tw) // 2)
elif node.style.align == Align.RIGHT:
offset = max(0, w - tw)
for row_i, line in enumerate(lines):
if y + row_i < grid.height:
grid.put_text(x + offset, y + row_i, line, style=style)
elif isinstance(node, Table): elif isinstance(node, Table):
_paint_table(node, grid, x, y, w) _paint_table(node, grid, x, y, w)
elif isinstance(node, Form): elif isinstance(node, Form):
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
elif isinstance(node, Field): elif isinstance(node, Field):
label_style = CellStyle(fg="888") label_style = CellStyle(fg=th.palette.label)
field_style = CellStyle(fg="0cf") field_style = CellStyle(fg=th.palette.form)
label_text = f"{node.field_name}: " label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style) grid.put_text(x, y, label_text, style=label_style)
# Draw [ placeholder_______ ] fl = th.form.field_l
fr = th.form.field_r
fx = x + len(label_text) fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2) fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
grid.put(fx, y, "[", style=field_style) grid.put_text(fx, y, fl, style=field_style)
placeholder = node.placeholder or node.field_name placeholder = node.placeholder or node.field_name
inner = f" {placeholder}".ljust(fw - 1)[:fw - 1] inner = placeholder.ljust(fw)[:fw]
grid.put_text(fx + 1, y, inner, style=CellStyle(fg="555")) grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
grid.put(fx + fw, y, "]", style=field_style) grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
elif isinstance(node, Password): elif isinstance(node, Password):
label_style = CellStyle(fg="888") label_style = CellStyle(fg=th.palette.label)
field_style = CellStyle(fg="0cf") field_style = CellStyle(fg=th.palette.form)
label_text = f"{node.field_name}: " label_text = f"{node.field_name}: "
grid.put_text(x, y, label_text, style=label_style) grid.put_text(x, y, label_text, style=label_style)
fl = th.form.field_l
fr = th.form.field_r
fx = x + len(label_text) fx = x + len(label_text)
fw = min(node.field_width, w - len(label_text) - 2) fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
grid.put(fx, y, "[", style=field_style) grid.put_text(fx, y, fl, style=field_style)
inner = " " + "" * (fw - 2) inner = "" * fw
grid.put_text(fx + 1, y, inner[:fw - 1], style=CellStyle(fg="555")) grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
grid.put(fx + fw, y, "]", style=field_style) grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
elif isinstance(node, Radio): elif isinstance(node, Radio):
label_style = CellStyle(fg="888") label_style = CellStyle(fg=th.palette.label)
label_text = f"{node.group}: " label_text = f"{node.group}: "
grid.put_text(x, y, label_text, style=label_style) grid.put_text(x, y, label_text, style=label_style)
rx = x + len(label_text) rx = x + len(label_text)
for i, opt in enumerate(node.options): for i, opt in enumerate(node.options):
dot = "(•)" if i == 0 else "( )" dot = th.form.radio_on if i == 0 else th.form.radio_off
opt_style = CellStyle(fg="0cf" if i == 0 else "888") opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
grid.put_text(rx, y, dot, style=opt_style) grid.put_text(rx, y, dot, style=opt_style)
rx += 4 rx += len(dot) + 1
grid.put_text(rx, y, opt, style=CellStyle()) grid.put_text(rx, y, opt, style=CellStyle())
rx += len(opt) + 2 rx += len(opt) + 2
elif isinstance(node, Checkbox): elif isinstance(node, Checkbox):
check_style = CellStyle(fg="0cf") check_style = CellStyle(fg=th.palette.form)
box_char = "[✓]" if node.checked else "[ ]" box_char = th.form.check_on if node.checked else th.form.check_off
grid.put_text(x, y, box_char, style=check_style) grid.put_text(x, y, box_char, style=check_style)
grid.put_text(x + 4, y, node.checkbox_label) grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
elif isinstance(node, FormButton): elif isinstance(node, FormButton):
btn_style = CellStyle(bold=True, fg="0f0") btn_style = CellStyle(bold=True, fg=th.palette.button)
btn_text = f"[ {node.button_label} ]" btn_text = f"[ {node.button_label} ]"
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest) grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
else: else:
# Generic: paint children # Generic: paint children
for child in node.children: for child in node.children:
paint(child, grid) paint(child, grid, th)
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None: def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
@@ -357,9 +403,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 (
@@ -20,6 +19,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, TextSpan, Gauge, Sparkline, Status, Table, TextSpan,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
SourceType, SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
@@ -427,6 +427,16 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
content = " ".join([keyword] + args) content = " ".join([keyword] + args)
return Text(content=content, source_line=line_num) return Text(content=content, source_line=line_num)
elif keyword == "bigtitle":
text = args[0] if args else ""
font = args[1] if len(args) > 1 else "block"
return BigTitle(text=text, font=font, source_line=line_num)
elif keyword == "theme":
# theme "name" — sets the page theme (handled as _StyleDirective on Page)
theme_name = args[0] if args else "default"
return _ThemeDirective(theme_name, line_num)
elif keyword == "component": elif keyword == "component":
# component name(arg1, arg2) # component name(arg1, arg2)
raw = " ".join(args) raw = " ".join(args)
@@ -451,6 +461,13 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return ComponentUse(comp_name=keyword, args=args, source_line=line_num) return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
class _ThemeDirective(IRNode):
"""Temporary node — sets theme_name on the Page during tree building."""
def __init__(self, theme_name: str, line_num: int):
super().__init__(source_line=line_num)
self.theme_name = theme_name
class _UseDirective(IRNode): class _UseDirective(IRNode):
"""Temporary node — triggers library loading during tree building.""" """Temporary node — triggers library loading during tree building."""
def __init__(self, lib_path: str, line_num: int): def __init__(self, lib_path: str, line_num: int):
@@ -594,6 +611,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 +630,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 +644,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"
''', ''',
} }
@@ -708,12 +774,17 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
stack.pop() stack.pop()
if isinstance(node, _StyleDirective): if isinstance(node, _StyleDirective):
# Apply style directive to the current top of stack (parent)
if stack: if stack:
parent = stack[-1][1] parent = stack[-1][1]
setattr(parent.style, node.attr, node.value) setattr(parent.style, node.attr, node.value)
continue continue
if isinstance(node, _ThemeDirective):
# Set theme on the root Page
if root and isinstance(root, Page):
root.theme_name = node.theme_name
continue
# Table children: columns and rows are absorbed by the Table node # Table children: columns and rows are absorbed by the Table node
if isinstance(node, _TableColumns): if isinstance(node, _TableColumns):
if stack and isinstance(stack[-1][1], Table): if stack and isinstance(stack[-1][1], Table):

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

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

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

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

View File

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

@@ -278,6 +278,64 @@ export const EXAMPLES: Example[] = [
peer_status "Node Gamma" degraded peer_status "Node Gamma" degraded
divider heavy divider heavy
link "Home" "/page/index.mu"`,
},
{
name: "Big Title",
description: "Large ASCII art text in block, thin, and pixel fonts",
source: `page "Banner" 64
bigtitle "RELAY" block
align center
color 0cf
spacer
text "@center{Alpha-7 — Reticulum Network Node}"
spacer
bigtitle "STATUS" thin
align center
spacer
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
divider heavy
bigtitle "OK" pixel
align center
color 0f0`,
},
{
name: "Themed Page",
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",
source: `page "Node Status" 50
theme nouveau
box heavy "Relay Alpha-7"
align center
text "Reticulum Network Node"
spacer
heading 1 "Resources"
gauge "CPU" 62 100 24 warn=75 crit=90
gauge "MEM" 84 100 24 warn=80 crit=95
spacer
heading 2 "Peers"
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded
divider heavy
link "Home" "/page/index.mu"`, link "Home" "/page/index.mu"`,
}, },
{ {

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

@@ -155,6 +155,44 @@ const COMMANDS: CmdEntry[] = [
), ),
}, },
// Themes — type /theme to filter all 6
{
label: "theme_default",
detail: "clean box-drawing ┌─┐●█░",
section: "Theme",
apply: insert("theme default"),
},
{
label: "theme_nouveau",
detail: "flowing ornament ☙❧❀▐",
section: "Theme",
apply: insert("theme nouveau"),
},
{
label: "theme_gothic",
detail: "blackletter ╬═║⚑▓",
section: "Theme",
apply: insert("theme gothic"),
},
{
label: "theme_bamboo",
detail: "minimal brush 〔〕◉┄",
section: "Theme",
apply: insert("theme bamboo"),
},
{
label: "theme_circuit",
detail: "digital neon ╒▰◈⚡",
section: "Theme",
apply: insert("theme circuit"),
},
{
label: "theme_brutalist",
detail: "raw blocks █▌■□",
section: "Theme",
apply: insert("theme brutalist"),
},
// Templates // Templates
{ {
label: "dashboard", label: "dashboard",
@@ -186,15 +224,39 @@ const COMMANDS: CmdEntry[] = [
}, },
]; ];
// Dynamic commands from /api/dsl-meta — merged into COMMANDS
let dynamicCommands: CmdEntry[] = [];
/** Update slash commands from /api/dsl-meta response. */
export function setDslCommands(
commands: { label: string; detail: string; section: string; snippet: string }[],
) {
// Build dynamic commands from API data, only for entries not already in COMMANDS
const existingLabels = new Set(COMMANDS.map((c) => c.label));
dynamicCommands = commands
.filter((c) => c.detail && c.snippet && !existingLabels.has(c.label))
.map((c) => ({
label: c.label,
detail: c.detail,
section: c.section,
apply: c.snippet.includes("${")
? slashSnippet(c.snippet)
: insert(c.snippet),
}));
}
export function uframeCommandSource( export function uframeCommandSource(
ctx: CompletionContext, ctx: CompletionContext,
): CompletionResult | null { ): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/); const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null; if (!match || (match.from === match.to && !ctx.explicit)) return null;
const allCommands = [...COMMANDS, ...dynamicCommands];
return { return {
from: match.from + 1, from: match.from + 1,
filter: true, filter: true,
options: COMMANDS.map((cmd) => ({ options: allCommands.map((cmd) => ({
label: cmd.label, label: cmd.label,
detail: cmd.detail, detail: cmd.detail,
section: cmd.section, section: cmd.section,

View File

@@ -8,14 +8,13 @@ import { tags } from "@lezer/highlight";
/** /**
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL. * CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
* *
* Keywords: page, box, row, col, spacer, pad, heading, text, label, * Keywords and values can be updated dynamically via setDslKeywords()
* divider, link, list, item, gauge, sparkline, status, * which is called when the frontend fetches /api/dsl-meta.
* 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([ // Mutable sets — updated from /api/dsl-meta
let KEYWORDS = new Set([
// Fallback defaults (used before API response arrives)
"page", "box", "row", "col", "spacer", "pad", "page", "box", "row", "col", "spacer", "pad",
"heading", "text", "label", "divider", "link", "heading", "text", "label", "divider", "link",
"list", "item", "gauge", "sparkline", "status", "list", "item", "gauge", "sparkline", "status",
@@ -23,17 +22,24 @@ const KEYWORDS = new Set([
"checkbox", "button", "source", "let", "if", "checkbox", "button", "source", "let", "if",
"elif", "else", "for", "align", "color", "bg", "elif", "else", "for", "align", "color", "bg",
"bold", "italic", "underline", "cache", "state", "bold", "italic", "underline", "cache", "state",
"on_submit", "meter", "bar_h", "bar_v", "bar", "on_submit", "theme", "component", "use",
"heatmap", "component", "use",
]); ]);
const WEIGHT_VALS = new Set([ let WEIGHT_VALS = new Set([
"light", "heavy", "double", "rounded", "light", "heavy", "double", "rounded",
"bullet", "dash", "number", "arrow", "bullet", "dash", "number", "arrow",
"left", "center", "right", "left", "center", "right",
"online", "offline", "degraded", "unknown", "online", "offline", "degraded", "unknown",
"shell", "file", "json", "python", "rns", "param",
"default", "nouveau", "gothic", "bamboo", "circuit", "brutalist",
]); ]);
/** Update keywords and values from /api/dsl-meta response. */
export function setDslKeywords(keywords: string[], values: string[]) {
if (keywords.length > 0) KEYWORDS = new Set(keywords);
if (values.length > 0) WEIGHT_VALS = new Set(values);
}
const uframeLanguage = StreamLanguage.define({ const uframeLanguage = StreamLanguage.define({
token(stream) { token(stream) {
// Comments // Comments

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
export interface DslMeta {
keywords: string[];
values: string[];
commands: { label: string; detail: string; section: string; snippet: string }[];
themes: string[];
}
const DEFAULT_META: DslMeta = {
keywords: [],
values: [],
commands: [],
themes: [],
};
let cachedMeta: DslMeta | null = null;
export function useDslMeta(): DslMeta {
const [meta, setMeta] = useState<DslMeta>(cachedMeta || DEFAULT_META);
useEffect(() => {
if (cachedMeta) return;
fetch("/api/dsl-meta")
.then((r) => r.json())
.then((data: DslMeta) => {
cachedMeta = data;
setMeta(data);
})
.catch(() => {});
}, []);
return meta;
}

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);
}
} }

View File

@@ -6,8 +6,9 @@ import { useEditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore"; import { usePagesStore } from "@/stores/pagesStore";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { useCompile } from "@/hooks/useCompile"; import { useCompile } from "@/hooks/useCompile";
import { uframeHighlight } from "@/components/editor/uframeHighlight"; import { useDslMeta } from "@/hooks/useDslMeta";
import { uframeCommandSource } from "@/components/editor/uframeCommands"; import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight";
import { uframeCommandSource, setDslCommands } from "@/components/editor/uframeCommands";
import EditorPane from "@/components/editor/EditorPane"; import EditorPane from "@/components/editor/EditorPane";
import PreviewPane from "@/components/editor/PreviewPane"; import PreviewPane from "@/components/editor/PreviewPane";
import ToolBar from "@/components/editor/ToolBar"; import ToolBar from "@/components/editor/ToolBar";
@@ -40,11 +41,22 @@ export default function EditorView() {
autocompletion({ autocompletion({
override: [uframeCommandSource], override: [uframeCommandSource],
icons: false, icons: false,
activateOnTyping: true,
maxOptions: 50,
}), }),
], ],
[], [],
); );
// Load DSL metadata (keywords, values, commands) from backend
const dslMeta = useDslMeta();
useEffect(() => {
if (dslMeta.keywords.length > 0) {
setDslKeywords(dslMeta.keywords, dslMeta.values);
setDslCommands(dslMeta.commands);
}
}, [dslMeta]);
// Auto-compile on source changes // Auto-compile on source changes
useCompile(); useCompile();
useUnsavedGuard(); useUnsavedGuard();