feat: preview

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

465
CLAUDE.md
View File

@@ -1,290 +1,321 @@
# Micronomicon → µFrame
# µFrame (Micronomicon)
A self-hosted web IDE for building rich terminal UIs that publish as `.mu` pages to a NomadNet node.
A self-hosted web IDE and CLI for building rich terminal UIs using a declarative DSL. Compiles `.uf` source files into both plain ASCII art and styled Micron `.mu` pages for NomadNet — a decentralized communication platform running on the Reticulum mesh network.
> **Evolution:** Started as a raw Micron text editor (Phases 12, complete).
> 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
## What It Does
Write this:
```
micronomicon/
Dockerfile
compose.yml
docs/
framework-design-v3.md # µFrame DSL spec + rendering model
dynamic-templates.md # Dynamic page addendum (Phases 56)
backend/
main.py # FastAPI app + static file serving
converter.py # POST /api/compile (µFrame → ASCII + Micron)
pages.py # file management (CRUD /api/pages)
graph.py # link parser (GET /api/graph)
docker_utils.py # container restart (POST /api/restart)
requirements.txt
uframe/ # µFrame engine (Phase 3+)
__init__.py # compile(source, width) → CompileResult
errors.py # ParseError, LayoutError
ir.py # IR node dataclasses
parser.py # .uf DSL → IR tree
grid.py # CharGrid (2D char + style buffer)
chars.py # Unicode lookup tables (box-drawing, braille)
measure.py # bottom-up size computation
layout.py # top-down position assignment
paint.py # IR → CharGrid rendering
borders.py # junction merging post-pass
emit_ascii.py # CharGrid → plain text
emit_micron.py # CharGrid → Micron with style tags
viz.py # gauge, sparkline, status (Phase 4)
table.py # table layout + box-drawn grid (Phase 4)
frontend/
src/
App.tsx
routes/ # DashboardView, EditorView, GraphView
components/
dashboard/ # page list, status badges
editor/
EditorPane.tsx # CodeMirror host
PreviewPane.tsx # ASCII + Micron + Raw preview tabs
ToolBar.tsx # save/publish + backlinks
uframeHighlight.ts # CM6 .uf syntax highlighting (Phase 3+)
uframeCommands.ts # "/" command palette for .uf (Phase 3+)
micronRenderer.ts # Micron → HTML (renders compiled output)
BacklinkIndicator.tsx
shared/
ui/ # shadcn components
stores/ # editorStore, pagesStore (Zustand)
hooks/ # useCompile, useGraph, useUnsavedGuard
lib/ # utils (cn)
page "Node Status" 60
box double "Relay Alpha-7"
align center
text "Reticulum Network Node"
gauge "CPU" 62 100 28 warn=75 crit=90
status "East Relay" online
table "Routes"
columns "Dest" 20 | "Hops" 6 | "Status" 10
row "relay-east" | "2" | "@color{0f0}{alive}"
```
## 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
# Backend
cd backend
source .venv/bin/activate
PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080
```
**Frontend:**
```bash
# Frontend (separate terminal)
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
| Method | Path | Description |
|--------|---------------------|------------------------------------------------------|
| 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/{name} | Read page source (`.uf` or legacy `.mu`) |
| POST | /api/pages/{name} | Save page — body `{ source, publish: bool }` |
| GET | /api/pages/{name} | Read page source |
| POST | /api/pages/{name} | Save page — `{ source, publish }` |
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
| GET | /api/graph | Graph nodes + edges (parsed from links in source) |
| GET | /api/graph | Page link graph (nodes + edges) |
| POST | /api/restart | Restart NomadNet Docker container |
## 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
```
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
- 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
NomadNet auto-detects the execute bit: static pages are served as-is, dynamic pages are executed and their stdout is served.
## µFrame DSL Reference
Full spec: `docs/framework-design-v3.md`
### Layout primitives
### Layout
```
page "Title" [width] # root container (default 64)
box [light|heavy|double|rounded] "Title" # bordered panel
row [gap] # horizontal layout
col [width] # column in a row
spacer [lines] # vertical whitespace
pad [t] [r] [b] [l] # inner margin
page "Title" [width] # root (default width 64)
box [light|heavy|double|rounded] "Title" # bordered panel
row [gap] # horizontal layout
col [width] # column in a row
spacer [lines] # vertical whitespace
pad [t] [r] [b] [l] # inner margin
```
### Content primitives
### Content
```
heading [1|2|3] "Text" # styled heading
heading [1|2|3] "Text" # styled heading
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]
item "Entry"
link "Display text" "/dest.mu" # clickable in Micron
divider [light|heavy|double|dash|dot] # horizontal rule
# comment # ignored in output
link "Display text" "/dest.mu" # clickable in Micron
divider [light|heavy|double|dash|dot] # horizontal rule
# comment # ignored in output
```
### Data visualization (Phase 4)
### Data Visualization
```
gauge "Label" $val $max $width warn=N crit=N
sparkline "Label" $values $width # braille patterns
status "Label" [online|offline|degraded] # ●○◐ indicators
gauge "Label" value max width [warn=N crit=N] # ████░░░░ bar with thresholds
sparkline "Label" "1,3,5,8,7,5" width # ⣀⣤⣶⣿⣷⣤ braille chart
status "Label" [online|offline|degraded] # ●○◐ colored indicators
table "Title"
columns "Name" 24 | "Hops" 6 | "Status" 10
row "value" | "value" | "value"
columns "Name" 20 | "Hops" 6 | "Status" 10
row "relay" | "2" | "@color{0f0}{● alive}"
```
### Forms (Phase 5)
### Forms
```
form "name"
field "name" [width] "placeholder"
radio "group" "Opt A" | "Opt B"
checkbox "name" "Label"
button "Label" "/action/path"
field "name" [width] "placeholder" # text input
password "name" [width] "placeholder" # masked input
radio "group" "Opt A" | "Opt B" | "Opt C" # radio buttons
checkbox "name" "Label" # checkbox
button "Label" "/action/path" # submit link
```
### Dynamic features (Phase 6)
### Dynamic Features
```
source cpu : shell "cat /proc/loadavg"
on_submit "form_name"
# handle form data
if $val > threshold
# conditional rendering
for item in $collection
# iteration
state "store" "/path.json"
cache 0 # never cache (re-execute per request)
cache 0 # never cache (re-execute)
source cpu : shell "cat /proc/loadavg" # live data at render time
source config : json "/path/config.json" # JSON file read
source ts : python "datetime.now().isoformat()" # Python expression
let name = "Relay Alpha" # variable assignment
if $cpu > 90
text "ALERT: CPU critical"
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
├→ ASCII emitter (plain text)
└→ Micron emitter (styled .mu)
# Define a reusable component
component stat(label, value, max)
gauge "$label" $value $max 20
# Use it
stat "CPU" 62 100
stat "MEM" 84 100
# Import standard library
use std/dashboard
banner "My Node" "Mesh Network"
resources 62 84
```
### 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
`!bold`! `*italic`* `_underline`_ formatting
`Fhex text`f `Bhex text`b colors (3-digit hex)
`c text`a `r text`a `l text`a alignment
[label`slug] links
-─ -━ -═ -★ dividers
`= ... `= literal mode
# comment hidden in output
.uf source
Parse ──→ IR Tree (30+ node types)
Measure (bottom-up: compute sizes)
Layout (top-down: assign positions)
Paint (depth-first: write chars into CharGrid)
Merge Borders (fix junction characters)
├──→ ASCII emitter → plain text
├──→ Micron emitter → styled .mu (with colors, links, form tags)
└──→ Codegen (if dynamic) → executable Python script
```
## 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
- Save draft / publish to NomadNet pages dir
- Pages dashboard with status badges
- Page graph (React Flow + dagre)
- NomadNet restart, dark/light theme, keyboard shortcuts
## Running Tests
### Phase 2 — Linking + Editor Enhancements ✅
```bash
cd backend
source .venv/bin/activate
python -m pytest uframe/tests/ -v
```
- `[[` page link autocomplete
- Backlink indicator with popover
- Pivot to direct Micron editor (removed Markdown pipeline)
- `/` slash command palette for Micron syntax
- Micron syntax highlighting + HTML preview renderer
- Syntax completeness per micron-composer spec
42 tests covering:
- Static compilation (boxes, headings, text, gauges, tables, links, lists, spacers, dividers)
- Form elements (field, radio, checkbox, button)
- Dynamic pages (source, if/for, let, state, on_submit, codegen)
- Components (inline definitions, standard library, parameter substitution)
### Phase 3 — µFrame Core Engine (next)
## Conventions
Build `backend/uframe/` — the rendering pipeline:
1. `errors.py` + `ir.py` — data structures
2. `chars.py` — Unicode lookup tables (box-drawing, blocks, braille)
3. `parser.py``.uf` DSL → IR tree (indentation-based, line-oriented)
4. `grid.py` — CharGrid class (2D char + style buffer)
5. `measure.py` + `layout.py` — size computation + position assignment
6. `paint.py` — IR nodes → CharGrid
7. `borders.py` — junction merging post-pass
8. `emit_ascii.py` + `emit_micron.py` — CharGrid → output strings
9. `__init__.py` — public `compile()` API
10. Update `converter.py``POST /api/compile` endpoint
11. Update `pages.py``.uf` sources, compile-on-publish
12. Update `graph.py` — parse `.uf` for links
### Phase 4 — Data Visualization
- gauge, meter, bar_h, bar_v (block elements)
- sparkline (braille sub-cell rendering)
- heatmap (shade blocks with per-cell color)
- status indicators (●○◐ with color)
- table (box-drawn with header separator)
- Border merging across nested tables
### Phase 5 — Web IDE Integration
- Replace Micron editor with µFrame DSL editor
- CodeMirror `.uf` syntax highlighting + autocomplete
- `useCompile` hook (debounced API calls)
- Triple preview: ASCII | Micron rendered | Raw Micron
- Page storage: `.uf` sources, `.mu` compiled output
- Migration script for legacy `.mu` sources
### Phase 6 — Forms & Interactivity
- Form primitives: field, password, radio, checkbox, button
- ASCII: visual placeholders; Micron: live fields
- @modifier inline syntax
- Variables and `let` bindings
### Phase 7 — Dynamic Pages
- `source` blocks for live data (shell, file, json, python, rns)
- Compile to executable Python scripts with embedded runtime
- `on_submit` form handling via `FIELD_*` env vars
- Conditionals (`if`/`elif`/`else`) and loops (`for`)
- State persistence (`state` + JSON store)
- Cache control headers
- CLI: `uframe compile` / `uframe deploy`
### Phase 8 — Components & Standard Library
- `component` definitions with argument bindings
- Standard library: `std/dashboard`, `std/filebrowser`, `std/board`
- Themes (`.uf-theme` color palette files)
- `uframe check` linter
- Production deployment: systemd + Tailscale
- µFrame engine is **pure Python stdlib** — zero external dependencies
- Backend is FastAPI; no ORM, flat file storage
- Frontend uses shadcn/ui components in `frontend/src/components/ui/`
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
- State management via Zustand stores
- All `.uf` sources stored in `SOURCES_DIR`, compiled `.mu` in `PAGES_DIR`
## References
- µFrame design: `docs/framework-design-v3.md`
- µFrame dynamic: `docs/dynamic-templates.md`
- micron-composer: https://github.com/fr33n0w/micron-composer
- micron-parser-js: https://rfnexus.github.io/micron-parser-js/
- µFrame design spec: `docs/framework-design-v3.md`
- Dynamic templates spec: `docs/dynamic-templates.md`
- 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
- 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)
### Web IDE
```bash
# 1. Build the frontend
cd frontend
npm install
npm run build
cd ..
# 2. Create source directories
# 1. Create directories
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
# 3. Start the stack
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
# 2. Backend
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
PAGES_DIR=~/.nomadnetwork/storage/pages \
SOURCES_DIR=~/.micron-editor/sources \
uvicorn main:app --reload --port 8080
```
**Frontend**
```bash
# 3. Frontend (separate terminal)
cd frontend
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 |
|----------------------|------------------|--------------------------------------|
| `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 |
---
## API Reference
## Tests
| Method | Path | Description |
|----------|---------------------|-----------------------------------------------|
| `GET` | `/api/health` | Health check |
| `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)
```bash
cd backend && source .venv/bin/activate
python -m pytest uframe/tests/ -v # 42 tests
```
---
## Page Lifecycle
## References
```
New Page → /editor/new → Save Draft → .md saved to sources/
→ Publish → .md saved + .mu written to pages/
```
- **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)
- [NomadNet](https://github.com/markqvist/NomadNet) — decentralized communication
- [Reticulum](https://github.com/markqvist/Reticulum) — mesh networking stack
- [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

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
correct junction character (T-junctions, crosses, corners) from the
Unicode box-drawing set.
The draw_border and _paint_table functions produce correct border characters
directly. The original merge pass caused garbled junctions when borders from
different boxes were adjacent, so it was disabled.
If future features need cross-box junction merging (e.g. tables sharing
edges with parent boxes), add targeted logic here using border_id from
the grid cells to only merge within the same border group.
"""
from __future__ import annotations
from uframe.grid import CharGrid
from uframe.ir import BorderWeight
# ---------------------------------------------------------------------------
# Connection detection
# ---------------------------------------------------------------------------
# For each border cell, check which directions have adjacent borders.
# Direction flags:
UP = 1
DOWN = 2
LEFT = 4
RIGHT = 8
# Junction lookup: connections bitmask → character
# Only light weight for now (most common case)
_LIGHT_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_HEAVY_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_DOUBLE_JUNCTIONS: dict[int, str] = {
UP | DOWN: "",
LEFT | RIGHT: "",
DOWN | RIGHT: "",
DOWN | LEFT: "",
UP | RIGHT: "",
UP | LEFT: "",
UP | DOWN | RIGHT: "",
UP | DOWN | LEFT: "",
LEFT | RIGHT | DOWN: "",
LEFT | RIGHT | UP: "",
UP | DOWN | LEFT | RIGHT: "",
RIGHT: "",
LEFT: "",
UP: "",
DOWN: "",
}
_JUNCTION_TABLES = {
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
}
# Weight priority for mixed-weight junctions
_WEIGHT_PRIORITY = {
BorderWeight.DOUBLE: 3,
BorderWeight.HEAVY: 2,
BorderWeight.LIGHT: 1,
BorderWeight.ROUNDED: 0,
}
def merge_borders(grid: CharGrid) -> None:
"""Scan the grid for adjacent border cells and fix junction characters.
This pass resolves cases where two boxes share an edge or corner,
replacing the overlapping border characters with proper junctions.
"""
for row in range(grid.height):
for col in range(grid.width):
cell = grid.cells[row][col]
if not cell.is_border:
continue
# Detect connections in 4 directions
connections = 0
max_weight = cell.border_weight or BorderWeight.LIGHT
# Check each neighbor
if row > 0 and grid.cells[row - 1][col].is_border:
connections |= UP
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
connections |= DOWN
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if col > 0 and grid.cells[row][col - 1].is_border:
connections |= LEFT
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
connections |= RIGHT
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
max_weight = nw
# Skip rounded corners — they should preserve ╭╮╰╯
if cell.border_weight == BorderWeight.ROUNDED and connections in (
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
):
continue
# Look up the junction character
if connections:
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
junction = table.get(connections)
if junction:
cell.char = junction
"""No-op — borders are correctly painted by draw_border and _paint_table."""
pass

View File

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

View File

@@ -12,16 +12,13 @@ state) and generates a self-contained Python script that:
from __future__ import annotations
import textwrap
from pathlib import Path
from uframe.ir import (
IRNode, Page, Box, Row, Col, Spacer, Pad,
Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton,
IRNode, Page, Box, Spacer,
Heading, Text, Label, Divider, Link,
Gauge, Status,
Form, Field, FormButton,
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:
lines.append(f"{ind}{var} = _read_json({node.command!r})")
elif node.source_type == SourceType.PYTHON:
lines.append(f"{ind}{var} = eval({node.command!r})")
# 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:
lines.append(f"{ind}{var} = _get_param({node.command!r})")
elif node.source_type == SourceType.RNS:
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})")
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):
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:
"""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
"""
"""Emit the CharGrid as Micron markup."""
lines: list[str] = []
for row in range(grid.height):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,4 +127,34 @@
html {
@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);
}
}