From 0316e5023360d6552c113c7dc21cd6e58717c1f8 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 10:13:14 +0200 Subject: [PATCH] feat: preview --- CLAUDE.md | 465 ++++++++++-------- README.md | 281 ++++++----- backend/uframe/borders.py | 149 +----- backend/uframe/cli.py | 1 - backend/uframe/codegen.py | 20 +- backend/uframe/emit_micron.py | 10 +- backend/uframe/grid.py | 26 +- backend/uframe/ir.py | 1 - backend/uframe/paint.py | 17 +- backend/uframe/parser.py | 50 +- .../src/components/editor/PreviewPane.tsx | 11 +- .../src/components/editor/micronRenderer.ts | 67 ++- frontend/src/index.css | 30 ++ 13 files changed, 590 insertions(+), 538 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a5c5df..e6ec608 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 1–2, 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 1–2 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 5–6) - 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 diff --git a/README.md b/README.md index 8362508..6596760 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/uframe/borders.py b/backend/uframe/borders.py index a906eae..e1e9352 100644 --- a/backend/uframe/borders.py +++ b/backend/uframe/borders.py @@ -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 diff --git a/backend/uframe/cli.py b/backend/uframe/cli.py index 70ac586..e6c703c 100644 --- a/backend/uframe/cli.py +++ b/backend/uframe/cli.py @@ -11,7 +11,6 @@ from __future__ import annotations import argparse import os -import stat import sys from pathlib import Path diff --git a/backend/uframe/codegen.py b/backend/uframe/codegen.py index 5db6ccd..a5c7b60 100644 --- a/backend/uframe/codegen.py +++ b/backend/uframe/codegen.py @@ -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}") diff --git a/backend/uframe/emit_micron.py b/backend/uframe/emit_micron.py index 022f753..0924c12 100644 --- a/backend/uframe/emit_micron.py +++ b/backend/uframe/emit_micron.py @@ -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): diff --git a/backend/uframe/grid.py b/backend/uframe/grid.py index fabf176..a887e35 100644 --- a/backend/uframe/grid.py +++ b/backend/uframe/grid.py @@ -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: diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index ba88347..3007a99 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -8,7 +8,6 @@ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any # --------------------------------------------------------------------------- diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index cb4727d..ec84f2d 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -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): diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index 52b4dab..bdb3edf 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -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" ''', } diff --git a/frontend/src/components/editor/PreviewPane.tsx b/frontend/src/components/editor/PreviewPane.tsx index 538b9af..6b0de98 100644 --- a/frontend/src/components/editor/PreviewPane.tsx +++ b/frontend/src/components/editor/PreviewPane.tsx @@ -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() { ))} - +
{previewMode === "ascii" ? ( -
+          
             {compiledAscii || (
               
                 ASCII preview will appear here…
@@ -74,7 +73,7 @@ export default function PreviewPane() {
         ) : previewMode === "micron" ? (
           compiledMicron ? (
             
) : ( -
+          
             {compiledMicron || "Raw Micron output will appear here…"}
           
)} - +
); } diff --git a/frontend/src/components/editor/micronRenderer.ts b/frontend/src/components/editor/micronRenderer.ts index 8c238c4..63dd745 100644 --- a/frontend/src/components/editor/micronRenderer.ts +++ b/frontend/src/components/editor/micronRenderer.ts @@ -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 += ""; openTags.push(""); i += 2; continue; + if (boldOpen) { out += ""; boldOpen = false; } + else { out += ""; boldOpen = true; } + i += 2; continue; } else if (code === "*") { - out += ""; openTags.push(""); i += 2; continue; + if (italicOpen) { out += ""; italicOpen = false; } + else { out += ""; italicOpen = true; } + i += 2; continue; } else if (code === "_") { - out += ""; openTags.push(""); i += 2; continue; + if (underOpen) { out += ""; underOpen = false; } + else { out += ""; underOpen = true; } + i += 2; continue; } else if (code === "`") { - closeAll(); i += 2; continue; - } else if (code === "f" || code === "b") { - out += ""; i += 2; continue; + // Reset all + if (boldOpen) { out += ""; boldOpen = false; } + if (italicOpen) { out += ""; italicOpen = false; } + if (underOpen) { out += ""; underOpen = false; } + if (fgOpen) { out += ""; fgOpen = false; } + if (bgOpen) { out += ""; bgOpen = false; } + if (alignOpen) { out += ""; alignOpen = false; } + i += 2; continue; + } else if (code === "f") { + if (fgOpen) { out += ""; fgOpen = false; } + i += 2; continue; + } else if (code === "b") { + if (bgOpen) { out += ""; bgOpen = false; } + i += 2; continue; } else if (code === "a") { - out += ""; i += 2; continue; + if (alignOpen) { out += ""; 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 += ""; } const [r, g, b] = hexMatch[1].split(""); const hex = r + r + g + g + b + b; out += ``; - openTags.push(""); + 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 += ""; } const [r, g, b] = hexMatch[1].split(""); const hex = r + r + g + g + b + b; out += ``; - openTags.push(""); + bgOpen = true; i += 2 + hexMatch[1].length; continue; } } else if (code === "c") { out += ``; - openTags.push(""); i += 2; continue; + alignOpen = true; i += 2; continue; } else if (code === "r") { out += ``; - openTags.push(""); i += 2; continue; + alignOpen = true; i += 2; continue; } else if (code === "l") { out += ``; - openTags.push(""); 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 += ""; + if (italicOpen) out += ""; + if (underOpen) out += ""; + if (fgOpen) out += ""; + if (bgOpen) out += ""; + if (alignOpen) out += ""; return out; } diff --git a/frontend/src/index.css b/frontend/src/index.css index fb3c7e9..2909308 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); + } } \ No newline at end of file