feat: added a twist
This commit is contained in:
17
.claude/launch.json
Normal file
17
.claude/launch.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Backend (uvicorn)",
|
||||||
|
"runtimeExecutable": "/bin/sh",
|
||||||
|
"runtimeArgs": ["-c", "PAGES_DIR=$HOME/.nomadnetwork/storage/pages SOURCES_DIR=$HOME/.micron-editor/sources /Users/dtoro/Projects/micronomicon/backend/.venv/bin/uvicorn main:app --reload --port 8080 --app-dir /Users/dtoro/Projects/micronomicon/backend"],
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frontend (vite)",
|
||||||
|
"runtimeExecutable": "/Users/dtoro/Projects/micronomicon/frontend/node_modules/.bin/vite",
|
||||||
|
"runtimeArgs": ["/Users/dtoro/Projects/micronomicon/frontend"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
353
.claude/plans/nifty-beaming-hanrahan-agent-a35bf8f885e907d39.md
Normal file
353
.claude/plans/nifty-beaming-hanrahan-agent-a35bf8f885e907d39.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# uFrame Integration Plan for Micronomicon
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Replace the existing raw-Micron editor with a uFrame IDE. uFrame is a declarative DSL that compiles `.uf` files into ASCII art and Micron `.mu` files. The user edits `.uf` source; the backend compiles it; the frontend shows dual ASCII + Micron previews. On publish, the backend compiles `.uf` to `.mu` and writes the result to PAGES_DIR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture (as-is)
|
||||||
|
|
||||||
|
**Backend** (`backend/`): 5 Python files, no package structure.
|
||||||
|
- `main.py` — FastAPI app, mounts routers at `/api`, serves static frontend
|
||||||
|
- `pages.py` — CRUD for `.mu` files across SOURCES_DIR and PAGES_DIR
|
||||||
|
- `converter.py` — POST `/api/convert` wrapping md2txt (markdown -> micron)
|
||||||
|
- `graph.py` — GET `/api/graph` parsing Micron `[label` + `slug]` links
|
||||||
|
- `docker_utils.py` — POST `/api/restart` for NomadNet container
|
||||||
|
|
||||||
|
**Frontend** (`frontend/src/`):
|
||||||
|
- `EditorView.tsx` — main route, split pane: `EditorPane` (CodeMirror) + `PreviewPane` (Micron HTML)
|
||||||
|
- `editorStore.ts` — state: `micronSource`, `isDirty`, `currentPage`, `previewMode`
|
||||||
|
- `pagesStore.ts` — page list, fetch/delete/unpublish
|
||||||
|
- `micronHighlight.ts` — CodeMirror StreamLanguage for Micron syntax
|
||||||
|
- `micronRenderer.ts` — Micron markup to HTML for preview
|
||||||
|
- `slashCommands.ts` — `/H1`, `/Bold`, etc. autocomplete for Micron codes
|
||||||
|
- `wikiLinkCompletion.ts` — `[[` autocomplete inserting Micron links
|
||||||
|
|
||||||
|
**Storage model**: Sources stored as `.mu` in SOURCES_DIR, published copies in PAGES_DIR. Both are Micron markup. No intermediate format.
|
||||||
|
|
||||||
|
**Key invariant**: NomadNet reads `.mu` files from PAGES_DIR. This must be preserved — uFrame compiles to `.mu` for publishing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — uFrame Core Engine (Backend Python Package)
|
||||||
|
|
||||||
|
### 1.1 Package Structure
|
||||||
|
|
||||||
|
Create `backend/uframe/` as a Python package:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/uframe/
|
||||||
|
__init__.py # Public API: compile(source, width) -> CompileResult
|
||||||
|
parser.py # Line-by-line indentation parser -> IR tree
|
||||||
|
ir.py # IR node dataclasses (Page, Box, Row, Col, Text, etc.)
|
||||||
|
grid.py # CharGrid class — 2D character + style buffer
|
||||||
|
measure.py # Bottom-up size computation pass
|
||||||
|
layout.py # Top-down position assignment pass
|
||||||
|
paint.py # Write chars into CharGrid
|
||||||
|
borders.py # Border merge pass — fix junction characters
|
||||||
|
emit_ascii.py # CharGrid -> plain text string
|
||||||
|
emit_micron.py # CharGrid -> Micron markup with style tags
|
||||||
|
errors.py # ParseError, LayoutError exception types
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 New Files to Create
|
||||||
|
|
||||||
|
**`backend/uframe/__init__.py`**
|
||||||
|
Public API surface. Exports `compile(source: str, width: int = 72) -> CompileResult`.
|
||||||
|
CompileResult is a dataclass with fields: `ascii: str`, `micron: str`, `warnings: list[str]`.
|
||||||
|
The function orchestrates: parse -> measure -> layout -> paint -> merge_borders -> emit.
|
||||||
|
|
||||||
|
**`backend/uframe/ir.py`**
|
||||||
|
IR node types as Python dataclasses. Every node has:
|
||||||
|
- `children: list[Node]`
|
||||||
|
- `width`, `height` (computed by measure)
|
||||||
|
- `x`, `y` (computed by layout)
|
||||||
|
- `style: Style` (fg, bg, bold, italic, underline)
|
||||||
|
|
||||||
|
Node types for Phase 1:
|
||||||
|
- `Page` — root, has `width` property, optional `title`
|
||||||
|
- `Box` — border weight (none/light/heavy/double/rounded), padding
|
||||||
|
- `Row` — horizontal container, children laid out left-to-right
|
||||||
|
- `Col` — vertical container, children laid out top-to-bottom
|
||||||
|
- `Spacer` — flexible space filler
|
||||||
|
- `Pad` — explicit padding wrapper
|
||||||
|
- `Heading` — level 1-3, renders with Micron `>`, `>>`, `>>>`
|
||||||
|
- `Text` — body text with inline @modifiers (@bold, @italic, @color)
|
||||||
|
- `Label` — fixed-width key:value pair
|
||||||
|
- `Divider` — horizontal rule with character choice
|
||||||
|
- `Link` — generates `[label` + `target]` in Micron output
|
||||||
|
- `List` — bulleted or numbered list items
|
||||||
|
|
||||||
|
**`backend/uframe/parser.py`**
|
||||||
|
Line-by-line, indentation-based parser. Design:
|
||||||
|
- Each line is `(indent_level, keyword, props_string)`
|
||||||
|
- Indent = 2 spaces per level (configurable)
|
||||||
|
- Keywords: page, box, row, col, spacer, pad, heading, text, label, divider, link, list
|
||||||
|
- Properties parsed from the rest of the line after the keyword
|
||||||
|
- Multiline text: subsequent indented lines without a keyword are text content
|
||||||
|
- Returns ParseTree with root Page node and warnings list
|
||||||
|
- Raises ParseError with line number + message for unrecoverable errors
|
||||||
|
|
||||||
|
**`backend/uframe/grid.py`**
|
||||||
|
CharGrid — the core rendering buffer:
|
||||||
|
- 2D array of Cell objects: (char, fg, bg, bold, italic, underline, border_flag)
|
||||||
|
- Methods: put(x, y, char, style), put_str(x, y, text, style), hline(x, y, length, char), vline(x, y, length, char), rect(x, y, w, h, weight)
|
||||||
|
- Border characters stored with border=True for the merge pass
|
||||||
|
- width and height properties
|
||||||
|
|
||||||
|
**`backend/uframe/measure.py`**
|
||||||
|
Bottom-up pass computing sizes:
|
||||||
|
- Leaf nodes (Text, Heading, Label, Divider) compute natural size
|
||||||
|
- Text wraps to available width, computes resulting height
|
||||||
|
- Row: width = sum(child widths), height = max(child heights)
|
||||||
|
- Col: width = max(child widths), height = sum(child heights)
|
||||||
|
- Box: adds 2 for borders (if weight != none), plus padding
|
||||||
|
- Percentage-based widths resolved relative to parent
|
||||||
|
|
||||||
|
**`backend/uframe/layout.py`**
|
||||||
|
Top-down pass assigning positions:
|
||||||
|
- Page starts at (0, 0)
|
||||||
|
- Row distributes x positions left-to-right
|
||||||
|
- Col distributes y positions top-to-bottom
|
||||||
|
- Spacer expands to fill remaining space
|
||||||
|
|
||||||
|
**`backend/uframe/paint.py`**
|
||||||
|
Renders each node into the CharGrid:
|
||||||
|
- Box: draws border rectangle, then paints children inside
|
||||||
|
- Text: writes word-wrapped text with style
|
||||||
|
- Heading: writes text, marks heading level for Micron emitter
|
||||||
|
- Divider: draws horizontal line
|
||||||
|
- Label: writes "key: value" with key highlighted
|
||||||
|
- Link: writes label text, stores target in metadata for Micron emitter
|
||||||
|
|
||||||
|
**`backend/uframe/borders.py`**
|
||||||
|
Post-paint pass over CharGrid:
|
||||||
|
- Scans for adjacent border cells
|
||||||
|
- Replaces with correct junction characters (T, cross, corner)
|
||||||
|
- Unicode box-drawing character lookup tables
|
||||||
|
- Handles mixed weights (light meets heavy, etc.)
|
||||||
|
|
||||||
|
**`backend/uframe/emit_ascii.py`**
|
||||||
|
Reads CharGrid row by row, outputs plain characters only. Strips trailing whitespace per line.
|
||||||
|
|
||||||
|
**`backend/uframe/emit_micron.py`**
|
||||||
|
Reads CharGrid row by row, inserts Micron formatting codes:
|
||||||
|
- Track current style state, emit format codes on change
|
||||||
|
- Headings get `>`, `>>`, `>>>` prefixes
|
||||||
|
- Links emit Micron link syntax
|
||||||
|
- Reset formatting at end of styled runs
|
||||||
|
|
||||||
|
**`backend/uframe/errors.py`**
|
||||||
|
Exception types: UFrameError (base), ParseError, LayoutError. Each carries line/col/message.
|
||||||
|
|
||||||
|
### 1.3 Existing Files to Modify
|
||||||
|
|
||||||
|
**`backend/converter.py`** — REPLACE contents.
|
||||||
|
Remove md2txt wrapper. Add uFrame compile endpoint:
|
||||||
|
- POST `/api/compile` accepts `{source: str, width: int}`, returns `{ascii: str, micron: str, warnings: list[str]}`
|
||||||
|
- Old POST `/api/convert` can remain as deprecated during transition
|
||||||
|
|
||||||
|
**`backend/pages.py`** — Modify storage model.
|
||||||
|
- SOURCES_DIR stores `.uf` files instead of `.mu` files
|
||||||
|
- `_list_all_page_names()`: scan for `.uf` in SOURCES_DIR, `.mu` in PAGES_DIR
|
||||||
|
- `get_page()`: returns `.uf` source from SOURCES_DIR
|
||||||
|
- `save_page()`: accepts `.uf` source, saves as `.uf`
|
||||||
|
- On publish: compile .uf -> .mu via uframe.compile(), write .mu to PAGES_DIR
|
||||||
|
- `_extract_title()`: parse .uf source for heading or page title= property
|
||||||
|
- PageDetail model: add `source` field (the .uf content)
|
||||||
|
- SaveRequest model: `source` field replaces `micron`
|
||||||
|
|
||||||
|
**`backend/graph.py`** — Modify link extraction.
|
||||||
|
- Parse .uf source files for link nodes instead of Micron link pattern
|
||||||
|
- Use uframe.parser.parse() to get IR tree, walk for Link nodes
|
||||||
|
- Or: simpler regex for link keyword in .uf syntax
|
||||||
|
- `_extract_title()`: parse .uf for page title= or first heading
|
||||||
|
|
||||||
|
**`backend/requirements.txt`** — Remove md2txt dependency (eventually).
|
||||||
|
|
||||||
|
### 1.4 API Changes
|
||||||
|
|
||||||
|
| Method | Path | Before | After |
|
||||||
|
|--------|------|--------|-------|
|
||||||
|
| POST | `/api/convert` | markdown -> micron | DEPRECATED |
|
||||||
|
| POST | `/api/compile` | NEW | .uf source -> {ascii, micron, warnings} |
|
||||||
|
| GET | `/api/pages/{name}` | returns {name, micron} | returns {name, source, micron} |
|
||||||
|
| POST | `/api/pages/{name}` | body: {micron, publish} | body: {source, publish} |
|
||||||
|
|
||||||
|
### 1.5 Migration Strategy for Existing Pages
|
||||||
|
|
||||||
|
Existing .mu source files in SOURCES_DIR need a migration path:
|
||||||
|
- Option A (recommended): One-time migration script `backend/migrate_mu_to_uf.py` wrapping existing Micron in minimal .uf template
|
||||||
|
- Option B: Keep .mu fallback — if .uf not found but .mu exists in SOURCES_DIR, return raw Micron as read-only legacy
|
||||||
|
- pages.py get_page should check for .uf first, fall back to .mu
|
||||||
|
|
||||||
|
### 1.6 Verification Steps
|
||||||
|
|
||||||
|
1. **Unit tests** — `backend/tests/test_uframe/`:
|
||||||
|
- test_parser.py, test_grid.py, test_measure.py, test_layout.py
|
||||||
|
- test_borders.py, test_emit_ascii.py, test_emit_micron.py, test_compile.py
|
||||||
|
|
||||||
|
2. **Golden file tests** — `backend/tests/test_uframe/golden/`:
|
||||||
|
- .uf input files paired with expected .ascii and .mu outputs
|
||||||
|
- Cases: single box, nested rows/cols, heading+text page, mixed content
|
||||||
|
|
||||||
|
3. **API test** — verify POST /api/compile returns valid response
|
||||||
|
4. **Edge cases**: empty page, deeply nested boxes, text overflow, zero-width
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Data Visualization Nodes
|
||||||
|
|
||||||
|
### 2.1 New Files
|
||||||
|
|
||||||
|
**`backend/uframe/viz.py`** — Viz node implementations:
|
||||||
|
- Gauge, Meter, BarH, BarV, Sparkline (braille), Heatmap, Status, Table
|
||||||
|
|
||||||
|
### 2.2 Modified Files
|
||||||
|
|
||||||
|
- `backend/uframe/ir.py` — add viz node dataclasses
|
||||||
|
- `backend/uframe/parser.py` — add viz keyword handlers
|
||||||
|
- `backend/uframe/measure.py` — add viz size computation
|
||||||
|
- `backend/uframe/paint.py` — add viz rendering
|
||||||
|
|
||||||
|
### 2.3 Verification
|
||||||
|
|
||||||
|
- Golden file tests for each viz type
|
||||||
|
- Edge cases: empty data, single value, overflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Web IDE (Frontend Replacement)
|
||||||
|
|
||||||
|
### 3.1 New Files
|
||||||
|
|
||||||
|
**`frontend/src/components/editor/uframeHighlight.ts`**
|
||||||
|
CodeMirror language support for .uf syntax:
|
||||||
|
- Keywords: page, box, row, col, spacer, pad, heading, text, label, divider, link, list, gauge, sparkline, etc.
|
||||||
|
- Properties: width=, height=, weight=, title=, char=, @modifiers
|
||||||
|
- String literals, comments (#), indentation awareness
|
||||||
|
|
||||||
|
**`frontend/src/components/editor/uframeCommands.ts`**
|
||||||
|
Slash commands for .uf syntax:
|
||||||
|
- /box, /row, /col, /heading, /text, /divider, etc.
|
||||||
|
- Each inserts a snippet with correct indentation
|
||||||
|
- Property suggestions after =
|
||||||
|
|
||||||
|
**`frontend/src/components/editor/AsciiPreviewPane.tsx`**
|
||||||
|
ASCII output preview:
|
||||||
|
- Monospace rendering of plain-text ASCII art
|
||||||
|
- Terminal aesthetic (green-on-black optional)
|
||||||
|
|
||||||
|
**`frontend/src/hooks/useCompile.ts`**
|
||||||
|
Hook calling POST /api/compile:
|
||||||
|
- Debounced (300ms) on source change
|
||||||
|
- Returns { ascii, micron, warnings, isCompiling, error }
|
||||||
|
|
||||||
|
### 3.2 Modified Files
|
||||||
|
|
||||||
|
**`frontend/src/stores/editorStore.ts`** — Major rewrite:
|
||||||
|
- `ufSource` replaces `micronSource`
|
||||||
|
- Add `compiledAscii`, `compiledMicron`, `warnings`, `isCompiling`
|
||||||
|
- `previewMode`: "ascii" | "micron" | "raw" (was "preview" | "raw")
|
||||||
|
|
||||||
|
**`frontend/src/routes/EditorView.tsx`** — Significant changes:
|
||||||
|
- Use ufSource instead of micronSource
|
||||||
|
- Add compile-on-change hook (debounced /api/compile)
|
||||||
|
- Save sends source field not micron
|
||||||
|
- Replace micronHighlight with uframeHighlight
|
||||||
|
- Replace slashCommands with uframeCommands
|
||||||
|
- Replace single PreviewPane with tabbed ASCII + Micron preview
|
||||||
|
|
||||||
|
**`frontend/src/components/editor/PreviewPane.tsx`** — Major rewrite:
|
||||||
|
- Tab bar: "ASCII" | "Micron" | "Raw Micron"
|
||||||
|
- ASCII tab: compiledAscii in monospace pre block
|
||||||
|
- Micron tab: compiledMicron through existing renderMicron()
|
||||||
|
- Raw tab: raw Micron source
|
||||||
|
|
||||||
|
**`frontend/src/components/editor/ToolBar.tsx`** — Minor:
|
||||||
|
- Compile warnings count in status area
|
||||||
|
|
||||||
|
**`frontend/src/stores/pagesStore.ts`** — Minor:
|
||||||
|
- unpublishPage sends source not micron
|
||||||
|
|
||||||
|
**`frontend/src/components/editor/wikiLinkCompletion.ts`** — Adapt to .uf link syntax
|
||||||
|
|
||||||
|
### 3.3 Removed Files
|
||||||
|
|
||||||
|
- `frontend/src/components/editor/slashCommands.ts` (replaced by uframeCommands.ts)
|
||||||
|
- `frontend/src/components/editor/micronHighlight.ts` (replaced by uframeHighlight.ts)
|
||||||
|
|
||||||
|
### 3.4 Files Kept As-Is
|
||||||
|
|
||||||
|
- `micronRenderer.ts` — still needed for Micron preview tab
|
||||||
|
- `oneDarkTheme.ts` — theme remains
|
||||||
|
- `DashboardView.tsx`, `GraphView.tsx` — no changes
|
||||||
|
- All shadcn/ui components — unchanged
|
||||||
|
|
||||||
|
### 3.5 Verification
|
||||||
|
|
||||||
|
- Create page with boxes, headings, text — verify ASCII and Micron previews
|
||||||
|
- Publish writes correct .mu to PAGES_DIR
|
||||||
|
- Page load round-trip: save .uf, reload, content preserved
|
||||||
|
- Dashboard still shows pages correctly
|
||||||
|
- Graph still shows links
|
||||||
|
- Keyboard shortcuts (Ctrl+S, Ctrl+P) still work
|
||||||
|
- Unsaved changes guard still fires
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4+ — Forms, Dynamic, Components (Future)
|
||||||
|
|
||||||
|
### Forms (Phase 4)
|
||||||
|
- IR nodes: Field, Password, Radio, Checkbox, Button
|
||||||
|
- Parser + measure + paint for each
|
||||||
|
- Micron emit generates form codes
|
||||||
|
|
||||||
|
### Dynamic (Phase 5)
|
||||||
|
- source blocks, on_submit, if/else, for loops, state
|
||||||
|
- Requires runtime component beyond static compilation
|
||||||
|
|
||||||
|
### Components (Phase 6)
|
||||||
|
- Reusable definitions, import, parameterized props
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order (Phase 1 detail)
|
||||||
|
|
||||||
|
Build sequence within Phase 1, each step testable in isolation:
|
||||||
|
|
||||||
|
1. `errors.py` — Exception types (no dependencies)
|
||||||
|
2. `ir.py` — Node dataclasses (depends on nothing)
|
||||||
|
3. `parser.py` — Parse .uf -> IR tree (depends on ir, errors)
|
||||||
|
4. `grid.py` — CharGrid buffer (depends on nothing)
|
||||||
|
5. `measure.py` — Size computation (depends on ir)
|
||||||
|
6. `layout.py` — Position assignment (depends on ir)
|
||||||
|
7. `paint.py` — Render to grid (depends on ir, grid)
|
||||||
|
8. `borders.py` — Junction resolution (depends on grid)
|
||||||
|
9. `emit_ascii.py` — ASCII output (depends on grid)
|
||||||
|
10. `emit_micron.py` — Micron output (depends on grid)
|
||||||
|
11. `__init__.py` — Public API tying it together
|
||||||
|
12. `converter.py` update — Wire /api/compile endpoint
|
||||||
|
13. `pages.py` update — .uf storage model
|
||||||
|
14. `graph.py` update — .uf link extraction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Separate measure/layout/paint passes** — enables caching (re-layout without re-parse) and independent testability.
|
||||||
|
|
||||||
|
2. **CharGrid as intermediate** — decouples layout from emission. Same grid produces ASCII and Micron. Adding new emitters (HTML, ANSI) later is trivial.
|
||||||
|
|
||||||
|
3. **Border flag on cells** — merge pass identifies borders without confusing them with box-drawing text content.
|
||||||
|
|
||||||
|
4. **Style state in cells** — each cell carries its own style so Micron emitter can diff adjacent cells and emit minimal formatting codes.
|
||||||
|
|
||||||
|
5. **Line-based parser** — matches indentation-sensitive DSL. Simpler than full tokenizer. Upgradeable to PEG grammar later.
|
||||||
|
|
||||||
|
6. **No new Python dependencies** — pure stdlib. Box-drawing and braille characters are Unicode literals.
|
||||||
|
|
||||||
|
7. **SOURCES_DIR uses .uf extension** — clean break from .mu sources. Published files remain .mu (NomadNet requirement).
|
||||||
|
|
||||||
|
8. **Compile on backend** — single source of truth. Frontend is thin editor + preview client. Enables future CLI compilation and batch processing.
|
||||||
290
CLAUDE.md
Normal file
290
CLAUDE.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Micronomicon → µFrame
|
||||||
|
|
||||||
|
A self-hosted web IDE for building rich terminal UIs that publish as `.mu` pages to a NomadNet node.
|
||||||
|
|
||||||
|
> **Evolution:** Started as a raw Micron text editor (Phases 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
|
||||||
|
|
||||||
|
```
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||||
|
SOURCES_DIR=~/.micron-editor/sources \
|
||||||
|
uvicorn main:app --reload --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev # proxies /api -> localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend at http://localhost:5173, backend at http://localhost:8080.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|---------------------|------------------------------------------------------|
|
||||||
|
| GET | /api/health | Health check |
|
||||||
|
| POST | /api/compile | Compile `.uf` → `{ascii, micron, warnings}` |
|
||||||
|
| GET | /api/pages | List all pages with metadata |
|
||||||
|
| GET | /api/pages/{name} | Read page source (`.uf` or legacy `.mu`) |
|
||||||
|
| POST | /api/pages/{name} | Save page — body `{ source, publish: bool }` |
|
||||||
|
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
|
||||||
|
| GET | /api/graph | Graph nodes + edges (parsed from links in source) |
|
||||||
|
| POST | /api/restart | Restart NomadNet Docker container |
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.micron-editor/sources/ ← .uf source files (draft + published)
|
||||||
|
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
|
||||||
|
```
|
||||||
|
|
||||||
|
On publish: `.uf` is compiled to `.mu` and copied to the NomadNet pages directory.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Frontend UI components live in `frontend/src/components/ui/` (shadcn)
|
||||||
|
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
|
||||||
|
- State management via Zustand stores in `frontend/src/stores/`
|
||||||
|
- Backend is pure FastAPI; no ORM, flat file storage
|
||||||
|
- µFrame engine is pure Python stdlib — no external dependencies
|
||||||
|
|
||||||
|
## µFrame DSL Reference
|
||||||
|
|
||||||
|
Full spec: `docs/framework-design-v3.md`
|
||||||
|
|
||||||
|
### Layout primitives
|
||||||
|
```
|
||||||
|
page "Title" [width] # root container (default 64)
|
||||||
|
box [light|heavy|double|rounded] "Title" # bordered panel
|
||||||
|
row [gap] # horizontal layout
|
||||||
|
col [width] # column in a row
|
||||||
|
spacer [lines] # vertical whitespace
|
||||||
|
pad [t] [r] [b] [l] # inner margin
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content primitives
|
||||||
|
```
|
||||||
|
heading [1|2|3] "Text" # styled heading
|
||||||
|
text "Content with @bold{inline} @color{hex}{modifiers}"
|
||||||
|
label "Key" "Value" # aligned key-value pair
|
||||||
|
list [bullet|dash]
|
||||||
|
item "Entry"
|
||||||
|
link "Display text" "/dest.mu" # clickable in Micron
|
||||||
|
divider [light|heavy|double|dash|dot] # horizontal rule
|
||||||
|
# comment # ignored in output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data visualization (Phase 4)
|
||||||
|
```
|
||||||
|
gauge "Label" $val $max $width warn=N crit=N
|
||||||
|
sparkline "Label" $values $width # braille patterns
|
||||||
|
status "Label" [online|offline|degraded] # ●○◐ indicators
|
||||||
|
table "Title"
|
||||||
|
columns "Name" 24 | "Hops" 6 | "Status" 10
|
||||||
|
row "value" | "value" | "value"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forms (Phase 5)
|
||||||
|
```
|
||||||
|
form "name"
|
||||||
|
field "name" [width] "placeholder"
|
||||||
|
radio "group" "Opt A" | "Opt B"
|
||||||
|
checkbox "name" "Label"
|
||||||
|
button "Label" "/action/path"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic features (Phase 6)
|
||||||
|
```
|
||||||
|
source cpu : shell "cat /proc/loadavg"
|
||||||
|
on_submit "form_name"
|
||||||
|
# handle form data
|
||||||
|
if $val > threshold
|
||||||
|
# conditional rendering
|
||||||
|
for item in $collection
|
||||||
|
# iteration
|
||||||
|
state "store" "/path.json"
|
||||||
|
cache 0 # never cache (re-execute per request)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering pipeline
|
||||||
|
```
|
||||||
|
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
|
||||||
|
├→ ASCII emitter (plain text)
|
||||||
|
└→ Micron emitter (styled .mu)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Micron syntax (legacy raw editor, still used for compiled output)
|
||||||
|
```
|
||||||
|
>H1 >>H2 >>>H3 headings
|
||||||
|
`!bold`! `*italic`* `_underline`_ formatting
|
||||||
|
`Fhex text`f `Bhex text`b colors (3-digit hex)
|
||||||
|
`c text`a `r text`a `l text`a alignment
|
||||||
|
[label`slug] links
|
||||||
|
-─ -━ -═ -★ dividers
|
||||||
|
`= ... `= literal mode
|
||||||
|
# comment hidden in output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Phase 1 — Core Editor + Dashboard + Graph ✅
|
||||||
|
|
||||||
|
- Split-pane Micron editor with live preview
|
||||||
|
- Save draft / publish to NomadNet pages dir
|
||||||
|
- Pages dashboard with status badges
|
||||||
|
- Page graph (React Flow + dagre)
|
||||||
|
- NomadNet restart, dark/light theme, keyboard shortcuts
|
||||||
|
|
||||||
|
### Phase 2 — Linking + Editor Enhancements ✅
|
||||||
|
|
||||||
|
- `[[` page link autocomplete
|
||||||
|
- Backlink indicator with popover
|
||||||
|
- Pivot to direct Micron editor (removed Markdown pipeline)
|
||||||
|
- `/` slash command palette for Micron syntax
|
||||||
|
- Micron syntax highlighting + HTML preview renderer
|
||||||
|
- Syntax completeness per micron-composer spec
|
||||||
|
|
||||||
|
### Phase 3 — µFrame Core Engine (next)
|
||||||
|
|
||||||
|
Build `backend/uframe/` — the rendering pipeline:
|
||||||
|
|
||||||
|
1. `errors.py` + `ir.py` — data structures
|
||||||
|
2. `chars.py` — Unicode lookup tables (box-drawing, blocks, braille)
|
||||||
|
3. `parser.py` — `.uf` DSL → IR tree (indentation-based, line-oriented)
|
||||||
|
4. `grid.py` — CharGrid class (2D char + style buffer)
|
||||||
|
5. `measure.py` + `layout.py` — size computation + position assignment
|
||||||
|
6. `paint.py` — IR nodes → CharGrid
|
||||||
|
7. `borders.py` — junction merging post-pass
|
||||||
|
8. `emit_ascii.py` + `emit_micron.py` — CharGrid → output strings
|
||||||
|
9. `__init__.py` — public `compile()` API
|
||||||
|
10. Update `converter.py` → `POST /api/compile` endpoint
|
||||||
|
11. Update `pages.py` — `.uf` sources, compile-on-publish
|
||||||
|
12. Update `graph.py` — parse `.uf` for links
|
||||||
|
|
||||||
|
### Phase 4 — Data Visualization
|
||||||
|
|
||||||
|
- gauge, meter, bar_h, bar_v (block elements)
|
||||||
|
- sparkline (braille sub-cell rendering)
|
||||||
|
- heatmap (shade blocks with per-cell color)
|
||||||
|
- status indicators (●○◐ with color)
|
||||||
|
- table (box-drawn with header separator)
|
||||||
|
- Border merging across nested tables
|
||||||
|
|
||||||
|
### Phase 5 — Web IDE Integration
|
||||||
|
|
||||||
|
- Replace Micron editor with µFrame DSL editor
|
||||||
|
- CodeMirror `.uf` syntax highlighting + autocomplete
|
||||||
|
- `useCompile` hook (debounced API calls)
|
||||||
|
- Triple preview: ASCII | Micron rendered | Raw Micron
|
||||||
|
- Page storage: `.uf` sources, `.mu` compiled output
|
||||||
|
- Migration script for legacy `.mu` sources
|
||||||
|
|
||||||
|
### Phase 6 — Forms & Interactivity
|
||||||
|
|
||||||
|
- Form primitives: field, password, radio, checkbox, button
|
||||||
|
- ASCII: visual placeholders; Micron: live fields
|
||||||
|
- @modifier inline syntax
|
||||||
|
- Variables and `let` bindings
|
||||||
|
|
||||||
|
### Phase 7 — Dynamic Pages
|
||||||
|
|
||||||
|
- `source` blocks for live data (shell, file, json, python, rns)
|
||||||
|
- Compile to executable Python scripts with embedded runtime
|
||||||
|
- `on_submit` form handling via `FIELD_*` env vars
|
||||||
|
- Conditionals (`if`/`elif`/`else`) and loops (`for`)
|
||||||
|
- State persistence (`state` + JSON store)
|
||||||
|
- Cache control headers
|
||||||
|
- CLI: `uframe compile` / `uframe deploy`
|
||||||
|
|
||||||
|
### Phase 8 — Components & Standard Library
|
||||||
|
|
||||||
|
- `component` definitions with argument bindings
|
||||||
|
- Standard library: `std/dashboard`, `std/filebrowser`, `std/board`
|
||||||
|
- Themes (`.uf-theme` color palette files)
|
||||||
|
- `uframe check` linter
|
||||||
|
- Production deployment: systemd + Tailscale
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- µFrame design: `docs/framework-design-v3.md`
|
||||||
|
- µFrame dynamic: `docs/dynamic-templates.md`
|
||||||
|
- micron-composer: https://github.com/fr33n0w/micron-composer
|
||||||
|
- micron-parser-js: https://rfnexus.github.io/micron-parser-js/
|
||||||
|
- NomadNet: https://github.com/markqvist/NomadNet
|
||||||
|
- md2txt (legacy): https://codeberg.org/randogoth/md2txt
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
|
"""µFrame compile endpoint — POST /api/compile."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import uframe
|
||||||
|
from uframe.errors import UFrameError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class ConvertRequest(BaseModel):
|
class CompileRequest(BaseModel):
|
||||||
markdown: str
|
source: str
|
||||||
width: int = 80
|
width: int = 64
|
||||||
|
|
||||||
|
|
||||||
class ConvertResponse(BaseModel):
|
class CompileResponse(BaseModel):
|
||||||
|
ascii: str
|
||||||
micron: str
|
micron: str
|
||||||
|
warnings: list[str]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/convert", response_model=ConvertResponse)
|
@router.post("/compile", response_model=CompileResponse)
|
||||||
async def convert(req: ConvertRequest):
|
async def compile_source(req: CompileRequest):
|
||||||
|
"""Compile µFrame .uf source into ASCII and Micron output."""
|
||||||
try:
|
try:
|
||||||
from md2txt import convert_markdown
|
result = uframe.compile(req.source, width=req.width)
|
||||||
|
return CompileResponse(
|
||||||
result = convert_markdown(
|
ascii=result.ascii,
|
||||||
req.markdown,
|
micron=result.micron,
|
||||||
width=req.width,
|
warnings=[w.message for w in result.warnings],
|
||||||
renderer_name="micron",
|
|
||||||
)
|
)
|
||||||
return ConvertResponse(micron=result)
|
except UFrameError as e:
|
||||||
except Exception as e:
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ router = APIRouter()
|
|||||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||||
|
|
||||||
# Matches markdown links: [text](slug) where slug has no protocol or path separators
|
# Matches Micron links: [label`slug] or [label`slug.mu]
|
||||||
_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)")
|
_INTERNAL_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
|
||||||
|
|
||||||
|
|
||||||
class GraphNode(BaseModel):
|
class GraphNode(BaseModel):
|
||||||
@@ -38,16 +38,16 @@ def _all_page_names() -> set[str]:
|
|||||||
names.add(f.stem)
|
names.add(f.stem)
|
||||||
if SOURCES_DIR.is_dir():
|
if SOURCES_DIR.is_dir():
|
||||||
for f in SOURCES_DIR.iterdir():
|
for f in SOURCES_DIR.iterdir():
|
||||||
if f.suffix == ".md" and f.is_file():
|
if f.suffix == ".mu" and f.is_file():
|
||||||
names.add(f.stem)
|
names.add(f.stem)
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def _extract_title(markdown: str) -> str | None:
|
def _extract_title(micron: str) -> str | None:
|
||||||
for line in markdown.splitlines():
|
for line in micron.splitlines():
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("# "):
|
if stripped.startswith(">") and not stripped.startswith(">>"):
|
||||||
return stripped[2:].strip()
|
return stripped[1:].strip()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -58,15 +58,14 @@ async def get_graph():
|
|||||||
edges: list[GraphEdge] = []
|
edges: list[GraphEdge] = []
|
||||||
|
|
||||||
for name in sorted(all_names):
|
for name in sorted(all_names):
|
||||||
md_path = SOURCES_DIR / f"{name}.md"
|
src_path = SOURCES_DIR / f"{name}.mu"
|
||||||
mu_path = PAGES_DIR / f"{name}.mu"
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
|
||||||
title = None
|
title = None
|
||||||
if md_path.is_file():
|
if src_path.is_file():
|
||||||
content = md_path.read_text(encoding="utf-8")
|
content = src_path.read_text(encoding="utf-8")
|
||||||
title = _extract_title(content)
|
title = _extract_title(content)
|
||||||
|
|
||||||
# Parse internal links
|
|
||||||
for match in _INTERNAL_LINK.finditer(content):
|
for match in _INTERNAL_LINK.finditer(content):
|
||||||
target = match.group(2)
|
target = match.group(2)
|
||||||
if target in all_names:
|
if target in all_names:
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from converter import router as converter_router
|
|
||||||
from pages import router as pages_router
|
from pages import router as pages_router
|
||||||
from graph import router as graph_router
|
from graph import router as graph_router
|
||||||
from docker_utils import router as docker_router
|
from docker_utils import router as docker_router
|
||||||
|
from converter import router as converter_router
|
||||||
|
|
||||||
app = FastAPI(title="Micron Page Editor")
|
app = FastAPI(title="µFrame Editor")
|
||||||
|
|
||||||
app.include_router(converter_router, prefix="/api")
|
app.include_router(converter_router, prefix="/api")
|
||||||
app.include_router(pages_router, prefix="/api")
|
app.include_router(pages_router, prefix="/api")
|
||||||
|
|||||||
112
backend/pages.py
112
backend/pages.py
@@ -1,9 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import uframe
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||||
@@ -21,20 +24,42 @@ class PageMeta(BaseModel):
|
|||||||
|
|
||||||
class PageDetail(BaseModel):
|
class PageDetail(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
markdown: str | None = None
|
source: str | None = None
|
||||||
micron: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SaveRequest(BaseModel):
|
class SaveRequest(BaseModel):
|
||||||
markdown: str
|
source: str
|
||||||
publish: bool = False
|
publish: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _extract_title(markdown: str) -> str | None:
|
def _extract_title(source: str) -> str | None:
|
||||||
for line in markdown.splitlines():
|
"""Extract title from µFrame source or legacy Micron."""
|
||||||
|
for line in source.splitlines():
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped.startswith("# "):
|
if not stripped or stripped.startswith("#"):
|
||||||
return stripped[2:].strip()
|
continue
|
||||||
|
# µFrame: page "Title" [width]
|
||||||
|
if stripped.lower().startswith("page "):
|
||||||
|
try:
|
||||||
|
parts = shlex.split(stripped)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[1]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
# µFrame: heading 1 "Title"
|
||||||
|
if stripped.lower().startswith("heading "):
|
||||||
|
try:
|
||||||
|
parts = shlex.split(stripped)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return parts[2]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
# Legacy Micron: >Title
|
||||||
|
if stripped.startswith(">") and not stripped.startswith(">>"):
|
||||||
|
return stripped[1:].strip()
|
||||||
|
break
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -46,18 +71,29 @@ def _list_all_page_names() -> set[str]:
|
|||||||
names.add(f.stem)
|
names.add(f.stem)
|
||||||
if SOURCES_DIR.is_dir():
|
if SOURCES_DIR.is_dir():
|
||||||
for f in SOURCES_DIR.iterdir():
|
for f in SOURCES_DIR.iterdir():
|
||||||
if f.suffix == ".md" and f.is_file():
|
if f.suffix in (".uf", ".mu") and f.is_file():
|
||||||
names.add(f.stem)
|
names.add(f.stem)
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _source_path(name: str) -> Path:
|
||||||
|
"""Get source file path, preferring .uf over legacy .mu."""
|
||||||
|
uf = SOURCES_DIR / f"{name}.uf"
|
||||||
|
if uf.is_file():
|
||||||
|
return uf
|
||||||
|
mu = SOURCES_DIR / f"{name}.mu"
|
||||||
|
return mu if mu.is_file() else uf # default to .uf for new files
|
||||||
|
|
||||||
|
|
||||||
def _page_meta(name: str) -> PageMeta:
|
def _page_meta(name: str) -> PageMeta:
|
||||||
|
src_path = _source_path(name)
|
||||||
mu_path = PAGES_DIR / f"{name}.mu"
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
md_path = SOURCES_DIR / f"{name}.md"
|
|
||||||
|
|
||||||
title = None
|
title = None
|
||||||
if md_path.is_file():
|
if src_path.is_file():
|
||||||
title = _extract_title(md_path.read_text(encoding="utf-8"))
|
title = _extract_title(src_path.read_text(encoding="utf-8"))
|
||||||
|
elif mu_path.is_file():
|
||||||
|
title = _extract_title(mu_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
published = mu_path.is_file()
|
published = mu_path.is_file()
|
||||||
last_modified = mu_path.stat().st_mtime if published else None
|
last_modified = mu_path.stat().st_mtime if published else None
|
||||||
@@ -67,7 +103,7 @@ def _page_meta(name: str) -> PageMeta:
|
|||||||
name=name,
|
name=name,
|
||||||
title=title,
|
title=title,
|
||||||
published=published,
|
published=published,
|
||||||
has_source=md_path.is_file(),
|
has_source=src_path.is_file(),
|
||||||
last_modified=last_modified,
|
last_modified=last_modified,
|
||||||
size=size,
|
size=size,
|
||||||
)
|
)
|
||||||
@@ -80,16 +116,19 @@ async def list_pages():
|
|||||||
|
|
||||||
@router.get("/pages/{name}", response_model=PageDetail)
|
@router.get("/pages/{name}", response_model=PageDetail)
|
||||||
async def get_page(name: str):
|
async def get_page(name: str):
|
||||||
md_path = SOURCES_DIR / f"{name}.md"
|
src_path = _source_path(name)
|
||||||
mu_path = PAGES_DIR / f"{name}.mu"
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
|
||||||
if not md_path.is_file() and not mu_path.is_file():
|
if not src_path.is_file() and not mu_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Page not found")
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
markdown = md_path.read_text(encoding="utf-8") if md_path.is_file() else None
|
source = (
|
||||||
micron = mu_path.read_text(encoding="utf-8") if mu_path.is_file() else None
|
src_path.read_text(encoding="utf-8")
|
||||||
|
if src_path.is_file()
|
||||||
|
else mu_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
return PageDetail(name=name, markdown=markdown, micron=micron)
|
return PageDetail(name=name, source=source)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pages/{name}", response_model=PageMeta)
|
@router.post("/pages/{name}", response_model=PageMeta)
|
||||||
@@ -97,39 +136,40 @@ async def save_page(name: str, req: SaveRequest):
|
|||||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Always save markdown source
|
# Save source as .uf
|
||||||
md_path = SOURCES_DIR / f"{name}.md"
|
src_path = SOURCES_DIR / f"{name}.uf"
|
||||||
md_path.write_text(req.markdown, encoding="utf-8")
|
src_path.write_text(req.source, encoding="utf-8")
|
||||||
|
|
||||||
# Optionally publish
|
# Remove legacy .mu source if it exists
|
||||||
|
legacy_mu = SOURCES_DIR / f"{name}.mu"
|
||||||
|
if legacy_mu.is_file():
|
||||||
|
legacy_mu.unlink()
|
||||||
|
|
||||||
|
# Publish: compile .uf → .mu and write to pages dir
|
||||||
if req.publish:
|
if req.publish:
|
||||||
try:
|
try:
|
||||||
from md2txt import convert_markdown
|
result = uframe.compile(req.source)
|
||||||
|
|
||||||
micron = convert_markdown(
|
|
||||||
req.markdown,
|
|
||||||
width=80,
|
|
||||||
renderer_name="micron",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Conversion failed: {e}")
|
|
||||||
|
|
||||||
mu_path = PAGES_DIR / f"{name}.mu"
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
mu_path.write_text(micron, encoding="utf-8")
|
mu_path.write_text(result.micron, encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Compile failed during publish: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
return _page_meta(name)
|
return _page_meta(name)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/pages/{name}")
|
@router.delete("/pages/{name}")
|
||||||
async def delete_page(name: str):
|
async def delete_page(name: str):
|
||||||
md_path = SOURCES_DIR / f"{name}.md"
|
src_path = _source_path(name)
|
||||||
mu_path = PAGES_DIR / f"{name}.mu"
|
mu_path = PAGES_DIR / f"{name}.mu"
|
||||||
|
|
||||||
if not md_path.is_file() and not mu_path.is_file():
|
if not src_path.is_file() and not mu_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Page not found")
|
raise HTTPException(status_code=404, detail="Page not found")
|
||||||
|
|
||||||
if md_path.is_file():
|
if src_path.is_file():
|
||||||
md_path.unlink()
|
src_path.unlink()
|
||||||
if mu_path.is_file():
|
if mu_path.is_file():
|
||||||
mu_path.unlink()
|
mu_path.unlink()
|
||||||
|
|
||||||
|
|||||||
74
backend/uframe/__init__.py
Normal file
74
backend/uframe/__init__.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""µFrame — A DSL for rich terminal UIs rendered as ASCII and Micron.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
compile(source, width=64) → CompileResult
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from uframe.errors import CompileWarning, UFrameError
|
||||||
|
from uframe.parser import parse
|
||||||
|
from uframe.measure import measure
|
||||||
|
from uframe.layout import layout
|
||||||
|
from uframe.paint import paint
|
||||||
|
from uframe.borders import merge_borders
|
||||||
|
from uframe.grid import CharGrid
|
||||||
|
from uframe.emit_ascii import emit_ascii
|
||||||
|
from uframe.emit_micron import emit_micron
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CompileResult:
|
||||||
|
"""Result of compiling a .uf source."""
|
||||||
|
ascii: str = ""
|
||||||
|
micron: str = ""
|
||||||
|
warnings: list[CompileWarning] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def compile(source: str, width: int = 64) -> CompileResult:
|
||||||
|
"""Compile a µFrame .uf source string into ASCII and Micron output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: the .uf DSL source text
|
||||||
|
width: page width in characters (default 64)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompileResult with ascii, micron, and any warnings
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ParseError: if the source cannot be parsed
|
||||||
|
LayoutError: if layout constraints fail
|
||||||
|
"""
|
||||||
|
warnings: list[CompileWarning] = []
|
||||||
|
|
||||||
|
# 1. Parse
|
||||||
|
page = parse(source)
|
||||||
|
if page.width == 64 and width != 64:
|
||||||
|
page.width = width
|
||||||
|
|
||||||
|
w = page.width
|
||||||
|
|
||||||
|
# 2. Measure
|
||||||
|
measure(page, w)
|
||||||
|
|
||||||
|
# 3. Layout
|
||||||
|
total_h = layout(page, 0, 0, w, page.pref_height + 100)
|
||||||
|
|
||||||
|
# 4. Create grid and paint
|
||||||
|
grid = CharGrid(w, max(total_h, 1))
|
||||||
|
paint(page, grid)
|
||||||
|
|
||||||
|
# 5. Merge borders
|
||||||
|
merge_borders(grid)
|
||||||
|
|
||||||
|
# 6. Emit
|
||||||
|
ascii_out = emit_ascii(grid)
|
||||||
|
micron_out = emit_micron(grid, page_title=page.title)
|
||||||
|
|
||||||
|
return CompileResult(
|
||||||
|
ascii=ascii_out,
|
||||||
|
micron=micron_out,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
148
backend/uframe/borders.py
Normal file
148
backend/uframe/borders.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Border merging post-pass — fix junction characters where borders meet.
|
||||||
|
|
||||||
|
Scans the CharGrid for adjacent border cells and replaces with the
|
||||||
|
correct junction character (T-junctions, crosses, corners) from the
|
||||||
|
Unicode box-drawing set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uframe.grid import CharGrid
|
||||||
|
from uframe.ir import BorderWeight
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# For each border cell, check which directions have adjacent borders.
|
||||||
|
# Direction flags:
|
||||||
|
UP = 1
|
||||||
|
DOWN = 2
|
||||||
|
LEFT = 4
|
||||||
|
RIGHT = 8
|
||||||
|
|
||||||
|
# Junction lookup: connections bitmask → character
|
||||||
|
# Only light weight for now (most common case)
|
||||||
|
_LIGHT_JUNCTIONS: dict[int, str] = {
|
||||||
|
UP | DOWN: "│",
|
||||||
|
LEFT | RIGHT: "─",
|
||||||
|
DOWN | RIGHT: "┌",
|
||||||
|
DOWN | LEFT: "┐",
|
||||||
|
UP | RIGHT: "└",
|
||||||
|
UP | LEFT: "┘",
|
||||||
|
UP | DOWN | RIGHT: "├",
|
||||||
|
UP | DOWN | LEFT: "┤",
|
||||||
|
LEFT | RIGHT | DOWN: "┬",
|
||||||
|
LEFT | RIGHT | UP: "┴",
|
||||||
|
UP | DOWN | LEFT | RIGHT: "┼",
|
||||||
|
RIGHT: "─",
|
||||||
|
LEFT: "─",
|
||||||
|
UP: "│",
|
||||||
|
DOWN: "│",
|
||||||
|
}
|
||||||
|
|
||||||
|
_HEAVY_JUNCTIONS: dict[int, str] = {
|
||||||
|
UP | DOWN: "┃",
|
||||||
|
LEFT | RIGHT: "━",
|
||||||
|
DOWN | RIGHT: "┏",
|
||||||
|
DOWN | LEFT: "┓",
|
||||||
|
UP | RIGHT: "┗",
|
||||||
|
UP | LEFT: "┛",
|
||||||
|
UP | DOWN | RIGHT: "┣",
|
||||||
|
UP | DOWN | LEFT: "┫",
|
||||||
|
LEFT | RIGHT | DOWN: "┳",
|
||||||
|
LEFT | RIGHT | UP: "┻",
|
||||||
|
UP | DOWN | LEFT | RIGHT: "╋",
|
||||||
|
RIGHT: "━",
|
||||||
|
LEFT: "━",
|
||||||
|
UP: "┃",
|
||||||
|
DOWN: "┃",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DOUBLE_JUNCTIONS: dict[int, str] = {
|
||||||
|
UP | DOWN: "║",
|
||||||
|
LEFT | RIGHT: "═",
|
||||||
|
DOWN | RIGHT: "╔",
|
||||||
|
DOWN | LEFT: "╗",
|
||||||
|
UP | RIGHT: "╚",
|
||||||
|
UP | LEFT: "╝",
|
||||||
|
UP | DOWN | RIGHT: "╠",
|
||||||
|
UP | DOWN | LEFT: "╣",
|
||||||
|
LEFT | RIGHT | DOWN: "╦",
|
||||||
|
LEFT | RIGHT | UP: "╩",
|
||||||
|
UP | DOWN | LEFT | RIGHT: "╬",
|
||||||
|
RIGHT: "═",
|
||||||
|
LEFT: "═",
|
||||||
|
UP: "║",
|
||||||
|
DOWN: "║",
|
||||||
|
}
|
||||||
|
|
||||||
|
_JUNCTION_TABLES = {
|
||||||
|
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
|
||||||
|
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
|
||||||
|
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
|
||||||
|
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Weight priority for mixed-weight junctions
|
||||||
|
_WEIGHT_PRIORITY = {
|
||||||
|
BorderWeight.DOUBLE: 3,
|
||||||
|
BorderWeight.HEAVY: 2,
|
||||||
|
BorderWeight.LIGHT: 1,
|
||||||
|
BorderWeight.ROUNDED: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_borders(grid: CharGrid) -> None:
|
||||||
|
"""Scan the grid for adjacent border cells and fix junction characters.
|
||||||
|
|
||||||
|
This pass resolves cases where two boxes share an edge or corner,
|
||||||
|
replacing the overlapping border characters with proper junctions.
|
||||||
|
"""
|
||||||
|
for row in range(grid.height):
|
||||||
|
for col in range(grid.width):
|
||||||
|
cell = grid.cells[row][col]
|
||||||
|
if not cell.is_border:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect connections in 4 directions
|
||||||
|
connections = 0
|
||||||
|
max_weight = cell.border_weight or BorderWeight.LIGHT
|
||||||
|
|
||||||
|
# Check each neighbor
|
||||||
|
if row > 0 and grid.cells[row - 1][col].is_border:
|
||||||
|
connections |= UP
|
||||||
|
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
|
||||||
|
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||||
|
max_weight = nw
|
||||||
|
|
||||||
|
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
|
||||||
|
connections |= DOWN
|
||||||
|
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
|
||||||
|
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||||
|
max_weight = nw
|
||||||
|
|
||||||
|
if col > 0 and grid.cells[row][col - 1].is_border:
|
||||||
|
connections |= LEFT
|
||||||
|
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
|
||||||
|
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||||
|
max_weight = nw
|
||||||
|
|
||||||
|
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
|
||||||
|
connections |= RIGHT
|
||||||
|
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
|
||||||
|
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||||
|
max_weight = nw
|
||||||
|
|
||||||
|
# Skip rounded corners — they should preserve ╭╮╰╯
|
||||||
|
if cell.border_weight == BorderWeight.ROUNDED and connections in (
|
||||||
|
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look up the junction character
|
||||||
|
if connections:
|
||||||
|
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
|
||||||
|
junction = table.get(connections)
|
||||||
|
if junction:
|
||||||
|
cell.char = junction
|
||||||
158
backend/uframe/chars.py
Normal file
158
backend/uframe/chars.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Unicode character lookup tables for box-drawing, block elements, braille, and indicators."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uframe.ir import BorderWeight
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Box-drawing characters by weight
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Keys: (weight) → dict of part names → char
|
||||||
|
BOX_CHARS: dict[BorderWeight, dict[str, str]] = {
|
||||||
|
BorderWeight.LIGHT: {
|
||||||
|
"tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
|
||||||
|
"h": "─", "v": "│",
|
||||||
|
"t_down": "┬", "t_up": "┴", "t_right": "├", "t_left": "┤",
|
||||||
|
"cross": "┼",
|
||||||
|
},
|
||||||
|
BorderWeight.HEAVY: {
|
||||||
|
"tl": "┏", "tr": "┓", "bl": "┗", "br": "┛",
|
||||||
|
"h": "━", "v": "┃",
|
||||||
|
"t_down": "┳", "t_up": "┻", "t_right": "┣", "t_left": "┫",
|
||||||
|
"cross": "╋",
|
||||||
|
},
|
||||||
|
BorderWeight.DOUBLE: {
|
||||||
|
"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝",
|
||||||
|
"h": "═", "v": "║",
|
||||||
|
"t_down": "╦", "t_up": "╩", "t_right": "╠", "t_left": "╣",
|
||||||
|
"cross": "╬",
|
||||||
|
},
|
||||||
|
BorderWeight.ROUNDED: {
|
||||||
|
"tl": "╭", "tr": "╮", "bl": "╰", "br": "╯",
|
||||||
|
"h": "─", "v": "│",
|
||||||
|
"t_down": "┬", "t_up": "┴", "t_right": "├", "t_left": "┤",
|
||||||
|
"cross": "┼",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Block elements for gauges and bars
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Horizontal fill blocks: full → 1/8
|
||||||
|
HFILL = "█▉▊▋▌▍▎▏"
|
||||||
|
|
||||||
|
# Vertical fill blocks: 1/8 → full (bottom-up)
|
||||||
|
VFILL = "▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
|
# Shade blocks: 25% → 100%
|
||||||
|
SHADE = "░▒▓█"
|
||||||
|
|
||||||
|
# Gauge characters
|
||||||
|
GAUGE_FILLED = "█"
|
||||||
|
GAUGE_EMPTY = "░"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Braille patterns for sparklines
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Braille base: U+2800. Each character is a 2×4 dot matrix.
|
||||||
|
# Dot positions (bit index):
|
||||||
|
# 0 3
|
||||||
|
# 1 4
|
||||||
|
# 2 5
|
||||||
|
# 6 7
|
||||||
|
BRAILLE_BASE = 0x2800
|
||||||
|
|
||||||
|
# Row dot bits for left column (bits 0,1,2,6) and right column (bits 3,4,5,7)
|
||||||
|
BRAILLE_LEFT = [0x01, 0x02, 0x04, 0x40] # rows 0-3
|
||||||
|
BRAILLE_RIGHT = [0x08, 0x10, 0x20, 0x80] # rows 0-3
|
||||||
|
|
||||||
|
|
||||||
|
def braille_char(dots: list[tuple[int, int]]) -> str:
|
||||||
|
"""Build a braille character from a list of (col, row) positions.
|
||||||
|
|
||||||
|
col: 0 (left) or 1 (right)
|
||||||
|
row: 0 (top) to 3 (bottom)
|
||||||
|
"""
|
||||||
|
code = BRAILLE_BASE
|
||||||
|
for col, row in dots:
|
||||||
|
if 0 <= row <= 3:
|
||||||
|
if col == 0:
|
||||||
|
code |= BRAILLE_LEFT[row]
|
||||||
|
else:
|
||||||
|
code |= BRAILLE_RIGHT[row]
|
||||||
|
return chr(code)
|
||||||
|
|
||||||
|
|
||||||
|
def sparkline_chars(values: list[float], width: int) -> list[str]:
|
||||||
|
"""Convert a list of values into braille sparkline characters.
|
||||||
|
|
||||||
|
Each output character represents two consecutive values (left + right columns).
|
||||||
|
Values are normalized to 0–7 (mapping to 4 braille rows × 2 resolution).
|
||||||
|
"""
|
||||||
|
if not values:
|
||||||
|
return []
|
||||||
|
|
||||||
|
lo = min(values)
|
||||||
|
hi = max(values)
|
||||||
|
span = hi - lo if hi != lo else 1.0
|
||||||
|
|
||||||
|
# Normalize to 0–7 range (8 vertical positions: 4 rows × 2 resolution)
|
||||||
|
norm = [int((v - lo) / span * 7) for v in values]
|
||||||
|
|
||||||
|
# Pad to even length
|
||||||
|
if len(norm) % 2:
|
||||||
|
norm.append(norm[-1])
|
||||||
|
|
||||||
|
chars = []
|
||||||
|
for i in range(0, min(len(norm), width * 2), 2):
|
||||||
|
left_val = norm[i]
|
||||||
|
right_val = norm[i + 1] if i + 1 < len(norm) else norm[i]
|
||||||
|
|
||||||
|
dots = []
|
||||||
|
# Fill dots from bottom up for each column
|
||||||
|
for row in range(3, -1, -1):
|
||||||
|
threshold = (3 - row) * 2 # row 3=0, row 2=2, row 1=4, row 0=6
|
||||||
|
if left_val >= threshold:
|
||||||
|
dots.append((0, row))
|
||||||
|
if right_val >= threshold:
|
||||||
|
dots.append((1, row))
|
||||||
|
|
||||||
|
chars.append(braille_char(dots))
|
||||||
|
|
||||||
|
return chars[:width]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status indicators
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_CHARS: dict[str, str] = {
|
||||||
|
"online": "●",
|
||||||
|
"offline": "○",
|
||||||
|
"degraded": "◐",
|
||||||
|
"unknown": "◌",
|
||||||
|
"alert": "⚠",
|
||||||
|
}
|
||||||
|
|
||||||
|
STATUS_COLORS: dict[str, str] = {
|
||||||
|
"online": "0f0",
|
||||||
|
"offline": "f00",
|
||||||
|
"degraded": "ff0",
|
||||||
|
"unknown": "888",
|
||||||
|
"alert": "f00",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Divider characters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DIVIDER_CHARS: dict[str, str] = {
|
||||||
|
"light": "─",
|
||||||
|
"heavy": "━",
|
||||||
|
"double": "═",
|
||||||
|
"dash": "╌",
|
||||||
|
"dot": "┄",
|
||||||
|
}
|
||||||
22
backend/uframe/emit_ascii.py
Normal file
22
backend/uframe/emit_ascii.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""ASCII emitter — read CharGrid and output plain text.
|
||||||
|
|
||||||
|
Reads only cell.char from each cell. No color, no style tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uframe.grid import CharGrid
|
||||||
|
|
||||||
|
|
||||||
|
def emit_ascii(grid: CharGrid) -> str:
|
||||||
|
"""Emit the CharGrid as plain ASCII text."""
|
||||||
|
lines: list[str] = []
|
||||||
|
for row in range(grid.height):
|
||||||
|
line = "".join(cell.char for cell in grid.cells[row]).rstrip()
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
# Strip trailing blank lines
|
||||||
|
while lines and not lines[-1]:
|
||||||
|
lines.pop()
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
121
backend/uframe/emit_micron.py
Normal file
121
backend/uframe/emit_micron.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Micron emitter — read CharGrid and output Micron markup with style tags.
|
||||||
|
|
||||||
|
Scans each line left-to-right, tracks style state, and opens/closes
|
||||||
|
Micron format codes at style transitions. Box-drawing characters pass
|
||||||
|
through as literal text.
|
||||||
|
|
||||||
|
Micron format reference:
|
||||||
|
`!bold`! `*italic`* `_underline`_
|
||||||
|
`Fhex text`f `Bhex text`b
|
||||||
|
`c center`a `r right`a
|
||||||
|
[label`dest] [label`dest.mu]
|
||||||
|
>H1 >>H2 >>>H3
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uframe.grid import CharGrid, CellStyle
|
||||||
|
from uframe.ir import Page, Heading, HeadingLevel
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_style_open(style: CellStyle) -> str:
|
||||||
|
"""Emit Micron opening tags for a style."""
|
||||||
|
tags: list[str] = []
|
||||||
|
if style.bold:
|
||||||
|
tags.append("`!")
|
||||||
|
if style.italic:
|
||||||
|
tags.append("`*")
|
||||||
|
if style.underline:
|
||||||
|
tags.append("`_")
|
||||||
|
if style.fg:
|
||||||
|
tags.append(f"`F{style.fg}")
|
||||||
|
if style.bg:
|
||||||
|
tags.append(f"`B{style.bg}")
|
||||||
|
return "".join(tags)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_style_close(style: CellStyle) -> str:
|
||||||
|
"""Emit Micron closing tags for a style (in reverse order)."""
|
||||||
|
tags: list[str] = []
|
||||||
|
if style.bg:
|
||||||
|
tags.append("`b")
|
||||||
|
if style.fg:
|
||||||
|
tags.append("`f")
|
||||||
|
if style.underline:
|
||||||
|
tags.append("`_")
|
||||||
|
if style.italic:
|
||||||
|
tags.append("`*")
|
||||||
|
if style.bold:
|
||||||
|
tags.append("`!")
|
||||||
|
return "".join(tags)
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY_STYLE = CellStyle()
|
||||||
|
|
||||||
|
|
||||||
|
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
||||||
|
"""Emit the CharGrid as Micron markup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
grid: the rendered character grid
|
||||||
|
page_title: optional page title for a leading >Title line
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Micron source string
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
for row in range(grid.height):
|
||||||
|
line_parts: list[str] = []
|
||||||
|
cur_style = _EMPTY_STYLE
|
||||||
|
in_link: str | None = None
|
||||||
|
|
||||||
|
for col in range(grid.width):
|
||||||
|
cell = grid.cells[row][col]
|
||||||
|
ch = cell.char
|
||||||
|
style = cell.style
|
||||||
|
link = cell.link
|
||||||
|
|
||||||
|
# Handle link transitions
|
||||||
|
if link != in_link:
|
||||||
|
if in_link is not None:
|
||||||
|
# Close previous link: [label`dest]
|
||||||
|
line_parts.append(f"`{in_link}]")
|
||||||
|
if link is not None:
|
||||||
|
# Close any open style before link
|
||||||
|
if cur_style != _EMPTY_STYLE:
|
||||||
|
line_parts.append(_emit_style_close(cur_style))
|
||||||
|
cur_style = _EMPTY_STYLE
|
||||||
|
# Open new link
|
||||||
|
line_parts.append("[")
|
||||||
|
in_link = link
|
||||||
|
|
||||||
|
# Handle style transitions (not inside links — links handle their own style)
|
||||||
|
if link is None and style != cur_style:
|
||||||
|
# Close previous style
|
||||||
|
if cur_style != _EMPTY_STYLE:
|
||||||
|
line_parts.append(_emit_style_close(cur_style))
|
||||||
|
# Open new style
|
||||||
|
if style != _EMPTY_STYLE:
|
||||||
|
line_parts.append(_emit_style_open(style))
|
||||||
|
cur_style = style
|
||||||
|
|
||||||
|
line_parts.append(ch)
|
||||||
|
|
||||||
|
# Close any trailing link: [label`dest]
|
||||||
|
if in_link is not None:
|
||||||
|
line_parts.append(f"`{in_link}]")
|
||||||
|
in_link = None
|
||||||
|
|
||||||
|
# Close any trailing style
|
||||||
|
if cur_style != _EMPTY_STYLE:
|
||||||
|
line_parts.append(_emit_style_close(cur_style))
|
||||||
|
|
||||||
|
line = "".join(line_parts).rstrip()
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
# Strip trailing blank lines
|
||||||
|
while lines and not lines[-1]:
|
||||||
|
lines.pop()
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
45
backend/uframe/errors.py
Normal file
45
backend/uframe/errors.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""µFrame error types with source location tracking."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class UFrameError(Exception):
|
||||||
|
"""Base error for all µFrame operations."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, line: int | None = None, col: int | None = None):
|
||||||
|
self.line = line
|
||||||
|
self.col = col
|
||||||
|
loc = ""
|
||||||
|
if line is not None:
|
||||||
|
loc = f" (line {line}"
|
||||||
|
if col is not None:
|
||||||
|
loc += f", col {col}"
|
||||||
|
loc += ")"
|
||||||
|
super().__init__(f"{message}{loc}")
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(UFrameError):
|
||||||
|
"""Raised when .uf source cannot be parsed."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutError(UFrameError):
|
||||||
|
"""Raised when layout constraints cannot be satisfied."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CompileWarning:
|
||||||
|
"""Non-fatal issue discovered during compilation."""
|
||||||
|
|
||||||
|
__slots__ = ("message", "line", "col")
|
||||||
|
|
||||||
|
def __init__(self, message: str, line: int | None = None, col: int | None = None):
|
||||||
|
self.message = message
|
||||||
|
self.line = line
|
||||||
|
self.col = col
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
loc = ""
|
||||||
|
if self.line is not None:
|
||||||
|
loc = f" line={self.line}"
|
||||||
|
return f"CompileWarning({self.message!r}{loc})"
|
||||||
170
backend/uframe/grid.py
Normal file
170
backend/uframe/grid.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""CharGrid — 2D character buffer with per-cell style annotations.
|
||||||
|
|
||||||
|
The CharGrid is the intermediate representation between layout and emission.
|
||||||
|
Both the ASCII and Micron emitters read from the same grid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from uframe.chars import BOX_CHARS
|
||||||
|
from uframe.ir import BorderWeight
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CellStyle:
|
||||||
|
"""Per-cell visual style for Micron emission."""
|
||||||
|
fg: str | None = None # 3-digit hex color
|
||||||
|
bg: str | None = None
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
underline: bool = False
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, CellStyle):
|
||||||
|
return NotImplemented
|
||||||
|
return (self.fg == other.fg and self.bg == other.bg
|
||||||
|
and self.bold == other.bold and self.italic == other.italic
|
||||||
|
and self.underline == other.underline)
|
||||||
|
|
||||||
|
def __hash__(self) -> int:
|
||||||
|
return hash((self.fg, self.bg, self.bold, self.italic, self.underline))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Cell:
|
||||||
|
"""A single cell in the CharGrid."""
|
||||||
|
char: str = " "
|
||||||
|
style: CellStyle = field(default_factory=CellStyle)
|
||||||
|
is_border: bool = False # True for box-drawing characters (for merge pass)
|
||||||
|
border_weight: BorderWeight | None = None
|
||||||
|
link: str | None = None # Micron link destination
|
||||||
|
|
||||||
|
|
||||||
|
class CharGrid:
|
||||||
|
"""2D buffer of cells. Origin (0,0) is top-left."""
|
||||||
|
|
||||||
|
__slots__ = ("width", "height", "cells")
|
||||||
|
|
||||||
|
def __init__(self, width: int, height: int):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.cells: list[list[Cell]] = [
|
||||||
|
[Cell() for _ in range(width)]
|
||||||
|
for _ in range(height)
|
||||||
|
]
|
||||||
|
|
||||||
|
def in_bounds(self, x: int, y: int) -> bool:
|
||||||
|
return 0 <= x < self.width and 0 <= y < self.height
|
||||||
|
|
||||||
|
def put(self, x: int, y: int, char: str,
|
||||||
|
style: CellStyle | None = None,
|
||||||
|
is_border: bool = False,
|
||||||
|
border_weight: BorderWeight | None = None,
|
||||||
|
link: str | None = None) -> None:
|
||||||
|
"""Write a single character to the grid."""
|
||||||
|
if not self.in_bounds(x, y):
|
||||||
|
return
|
||||||
|
cell = self.cells[y][x]
|
||||||
|
cell.char = char
|
||||||
|
if style is not None:
|
||||||
|
cell.style = style
|
||||||
|
cell.is_border = is_border
|
||||||
|
cell.border_weight = border_weight
|
||||||
|
if link is not None:
|
||||||
|
cell.link = link
|
||||||
|
|
||||||
|
def put_text(self, x: int, y: int, text: str,
|
||||||
|
style: CellStyle | None = None,
|
||||||
|
link: str | None = None) -> int:
|
||||||
|
"""Write a string horizontally starting at (x, y).
|
||||||
|
|
||||||
|
Returns the number of characters actually written.
|
||||||
|
"""
|
||||||
|
written = 0
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
px = x + i
|
||||||
|
if not self.in_bounds(px, y):
|
||||||
|
break
|
||||||
|
self.put(px, y, ch, style=style, link=link)
|
||||||
|
written += 1
|
||||||
|
return written
|
||||||
|
|
||||||
|
def fill_rect(self, x: int, y: int, w: int, h: int, char: str = " ",
|
||||||
|
style: CellStyle | None = None) -> None:
|
||||||
|
"""Fill a rectangular region with a character."""
|
||||||
|
for row in range(y, y + h):
|
||||||
|
for col in range(x, x + w):
|
||||||
|
self.put(col, row, char, style=style)
|
||||||
|
|
||||||
|
def draw_border(self, x: int, y: int, w: int, h: int,
|
||||||
|
weight: BorderWeight = BorderWeight.LIGHT,
|
||||||
|
title: str = "",
|
||||||
|
title_style: CellStyle | None = None) -> None:
|
||||||
|
"""Draw a box border. Interior is not cleared.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x, y: top-left corner
|
||||||
|
w, h: outer dimensions (including border)
|
||||||
|
weight: border style
|
||||||
|
title: optional title inset in top border
|
||||||
|
title_style: style for the title text
|
||||||
|
"""
|
||||||
|
if w < 2 or h < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
ch = BOX_CHARS[weight]
|
||||||
|
border_style = CellStyle()
|
||||||
|
|
||||||
|
# Corners
|
||||||
|
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight)
|
||||||
|
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight)
|
||||||
|
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight)
|
||||||
|
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight)
|
||||||
|
|
||||||
|
# Top and bottom edges
|
||||||
|
for col in range(x + 1, x + w - 1):
|
||||||
|
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight)
|
||||||
|
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight)
|
||||||
|
|
||||||
|
# Left and right edges
|
||||||
|
for row in range(y + 1, y + h - 1):
|
||||||
|
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
||||||
|
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
||||||
|
|
||||||
|
# Title in top border
|
||||||
|
if title and w > 4:
|
||||||
|
title_text = f" {title} "
|
||||||
|
max_title = w - 4 # leave room for corners + padding
|
||||||
|
if len(title_text) > max_title:
|
||||||
|
title_text = title_text[:max_title]
|
||||||
|
|
||||||
|
start_x = x + 2
|
||||||
|
ts = title_style or CellStyle(bold=True)
|
||||||
|
self.put_text(start_x, y, title_text, style=ts)
|
||||||
|
|
||||||
|
def grow_height(self, new_height: int) -> None:
|
||||||
|
"""Expand the grid vertically if needed."""
|
||||||
|
if new_height <= self.height:
|
||||||
|
return
|
||||||
|
for _ in range(new_height - self.height):
|
||||||
|
self.cells.append([Cell() for _ in range(self.width)])
|
||||||
|
self.height = new_height
|
||||||
|
|
||||||
|
def get_line(self, row: int) -> str:
|
||||||
|
"""Get a single row as a plain string (chars only)."""
|
||||||
|
if 0 <= row < self.height:
|
||||||
|
return "".join(cell.char for cell in self.cells[row])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def to_text(self) -> str:
|
||||||
|
"""Emit the entire grid as plain text (ASCII mode)."""
|
||||||
|
lines = []
|
||||||
|
for row in range(self.height):
|
||||||
|
line = self.get_line(row).rstrip()
|
||||||
|
lines.append(line)
|
||||||
|
# Strip trailing blank lines
|
||||||
|
while lines and not lines[-1]:
|
||||||
|
lines.pop()
|
||||||
|
return "\n".join(lines)
|
||||||
243
backend/uframe/ir.py
Normal file
243
backend/uframe/ir.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""µFrame Intermediate Representation — node types for the IR tree.
|
||||||
|
|
||||||
|
Every .uf source parses into a tree of IRNode subclasses. The layout
|
||||||
|
engine measures, positions, and paints these nodes into a CharGrid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BorderWeight(Enum):
|
||||||
|
LIGHT = auto()
|
||||||
|
HEAVY = auto()
|
||||||
|
DOUBLE = auto()
|
||||||
|
ROUNDED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class HeadingLevel(Enum):
|
||||||
|
H1 = 1
|
||||||
|
H2 = 2
|
||||||
|
H3 = 3
|
||||||
|
|
||||||
|
|
||||||
|
class DividerStyle(Enum):
|
||||||
|
LIGHT = auto()
|
||||||
|
HEAVY = auto()
|
||||||
|
DOUBLE = auto()
|
||||||
|
DASH = auto()
|
||||||
|
DOT = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class ListStyle(Enum):
|
||||||
|
BULLET = auto()
|
||||||
|
DASH = auto()
|
||||||
|
NUMBER = auto()
|
||||||
|
ARROW = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class Align(Enum):
|
||||||
|
LEFT = auto()
|
||||||
|
CENTER = auto()
|
||||||
|
RIGHT = auto()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Style
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Style:
|
||||||
|
"""Visual style attached to any node."""
|
||||||
|
fg: str | None = None # 3-digit hex
|
||||||
|
bg: str | None = None # 3-digit hex
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
underline: bool = False
|
||||||
|
align: Align = Align.LEFT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline text spans (parsed from @modifier{} syntax in text content)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TextSpan:
|
||||||
|
"""A run of text with optional inline styling."""
|
||||||
|
text: str
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
underline: bool = False
|
||||||
|
fg: str | None = None
|
||||||
|
bg: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layout rect — assigned by the layout engine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rect:
|
||||||
|
x: int = 0
|
||||||
|
y: int = 0
|
||||||
|
w: int = 0
|
||||||
|
h: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Base node
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IRNode:
|
||||||
|
"""Base class for all IR nodes."""
|
||||||
|
children: list[IRNode] = field(default_factory=list)
|
||||||
|
style: Style = field(default_factory=Style)
|
||||||
|
rect: Rect = field(default_factory=Rect)
|
||||||
|
source_line: int | None = None
|
||||||
|
|
||||||
|
# Set by measure pass
|
||||||
|
min_width: int = 0
|
||||||
|
min_height: int = 0
|
||||||
|
pref_width: int = 0
|
||||||
|
pref_height: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layout nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Page(IRNode):
|
||||||
|
"""Root container. One per .uf file."""
|
||||||
|
title: str = ""
|
||||||
|
width: int = 64
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Box(IRNode):
|
||||||
|
"""Bordered panel with optional title."""
|
||||||
|
title: str = ""
|
||||||
|
weight: BorderWeight = BorderWeight.LIGHT
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Row(IRNode):
|
||||||
|
"""Horizontal layout — children split available width."""
|
||||||
|
gap: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Col(IRNode):
|
||||||
|
"""Explicit column in a row. Width in chars or None (auto)."""
|
||||||
|
col_width: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Spacer(IRNode):
|
||||||
|
"""Vertical whitespace."""
|
||||||
|
lines: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Pad(IRNode):
|
||||||
|
"""Inner margin for a container."""
|
||||||
|
top: int = 0
|
||||||
|
right: int = 0
|
||||||
|
bottom: int = 0
|
||||||
|
left: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Content nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Heading(IRNode):
|
||||||
|
"""Styled heading (levels 1–3)."""
|
||||||
|
level: HeadingLevel = HeadingLevel.H1
|
||||||
|
text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Text(IRNode):
|
||||||
|
"""Text content with optional @modifier{} inline styles."""
|
||||||
|
content: str = ""
|
||||||
|
spans: list[TextSpan] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Label(IRNode):
|
||||||
|
"""Aligned key-value pair."""
|
||||||
|
key: str = ""
|
||||||
|
value: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Divider(IRNode):
|
||||||
|
"""Full-width horizontal rule."""
|
||||||
|
divider_style: DividerStyle = DividerStyle.LIGHT
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Link(IRNode):
|
||||||
|
"""Clickable link — visual in ASCII, interactive in Micron."""
|
||||||
|
display: str = ""
|
||||||
|
dest: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListNode(IRNode):
|
||||||
|
"""Bulleted or numbered list."""
|
||||||
|
list_style: ListStyle = ListStyle.BULLET
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListItem(IRNode):
|
||||||
|
"""Single entry in a ListNode."""
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Placeholder nodes for future phases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Gauge(IRNode):
|
||||||
|
"""Horizontal bar chart (Phase 4)."""
|
||||||
|
label: str = ""
|
||||||
|
value: float = 0
|
||||||
|
max_val: float = 100
|
||||||
|
bar_width: int = 28
|
||||||
|
warn: float | None = None
|
||||||
|
crit: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sparkline(IRNode):
|
||||||
|
"""Braille sparkline (Phase 4)."""
|
||||||
|
label: str = ""
|
||||||
|
values: list[float] = field(default_factory=list)
|
||||||
|
spark_width: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Status(IRNode):
|
||||||
|
"""Status indicator (Phase 4)."""
|
||||||
|
label: str = ""
|
||||||
|
state: str = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Table(IRNode):
|
||||||
|
"""Box-drawn table (Phase 4)."""
|
||||||
|
title: str = ""
|
||||||
|
columns: list[tuple[str, int]] = field(default_factory=list) # (name, width)
|
||||||
|
rows: list[list[str]] = field(default_factory=list)
|
||||||
134
backend/uframe/layout.py
Normal file
134
backend/uframe/layout.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Top-down layout pass — assign (x, y, w, h) positions to every node."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uframe.ir import (
|
||||||
|
IRNode, Page, Box, Row, Col, Spacer, Pad, Rect,
|
||||||
|
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||||
|
Gauge, Sparkline, Status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
|
||||||
|
"""Assign positions to a node and its children.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
node: the IR node to lay out
|
||||||
|
x, y: top-left position in the grid
|
||||||
|
w: available width
|
||||||
|
h: available height (advisory, may grow)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The actual height consumed by this node.
|
||||||
|
"""
|
||||||
|
node.rect = Rect(x=x, y=y, w=w, h=0)
|
||||||
|
|
||||||
|
if isinstance(node, Page):
|
||||||
|
cursor_y = y
|
||||||
|
for child in node.children:
|
||||||
|
child_h = layout(child, x, cursor_y, w, h - (cursor_y - y))
|
||||||
|
cursor_y += child_h
|
||||||
|
node.rect.h = cursor_y - y
|
||||||
|
return node.rect.h
|
||||||
|
|
||||||
|
elif isinstance(node, Box):
|
||||||
|
# Border takes 1 char on each side
|
||||||
|
inner_x = x + 1
|
||||||
|
inner_y = y + 1
|
||||||
|
inner_w = w - 2
|
||||||
|
cursor_y = inner_y
|
||||||
|
for child in node.children:
|
||||||
|
child_h = layout(child, inner_x, cursor_y, inner_w,
|
||||||
|
h - 2 - (cursor_y - inner_y))
|
||||||
|
cursor_y += child_h
|
||||||
|
inner_h = cursor_y - inner_y
|
||||||
|
node.rect.h = inner_h + 2 # +2 for top/bottom border
|
||||||
|
return node.rect.h
|
||||||
|
|
||||||
|
elif isinstance(node, Row):
|
||||||
|
n = len(node.children)
|
||||||
|
if n == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
gap_total = node.gap * (n - 1)
|
||||||
|
usable = w - gap_total
|
||||||
|
|
||||||
|
# Distribute width
|
||||||
|
widths: list[int] = []
|
||||||
|
fixed_total = 0
|
||||||
|
flex_count = 0
|
||||||
|
for child in node.children:
|
||||||
|
if isinstance(child, Col) and child.col_width is not None:
|
||||||
|
widths.append(child.col_width)
|
||||||
|
fixed_total += child.col_width
|
||||||
|
else:
|
||||||
|
widths.append(0)
|
||||||
|
flex_count += 1
|
||||||
|
|
||||||
|
flex_each = max(1, (usable - fixed_total) // flex_count) if flex_count > 0 else 0
|
||||||
|
remainder = (usable - fixed_total) - (flex_each * flex_count) if flex_count > 0 else 0
|
||||||
|
|
||||||
|
for i, child in enumerate(node.children):
|
||||||
|
if widths[i] == 0:
|
||||||
|
widths[i] = flex_each
|
||||||
|
if remainder > 0:
|
||||||
|
widths[i] += 1
|
||||||
|
remainder -= 1
|
||||||
|
|
||||||
|
# Lay out each child at its column position
|
||||||
|
max_h = 0
|
||||||
|
col_x = x
|
||||||
|
for i, child in enumerate(node.children):
|
||||||
|
child_h = layout(child, col_x, y, widths[i], h)
|
||||||
|
max_h = max(max_h, child_h)
|
||||||
|
col_x += widths[i] + node.gap
|
||||||
|
|
||||||
|
node.rect.h = max_h
|
||||||
|
return max_h
|
||||||
|
|
||||||
|
elif isinstance(node, Col):
|
||||||
|
cursor_y = y
|
||||||
|
col_w = node.col_width if node.col_width is not None else w
|
||||||
|
col_w = min(col_w, w)
|
||||||
|
for child in node.children:
|
||||||
|
child_h = layout(child, x, cursor_y, col_w, h - (cursor_y - y))
|
||||||
|
cursor_y += child_h
|
||||||
|
node.rect.w = col_w
|
||||||
|
node.rect.h = cursor_y - y
|
||||||
|
return node.rect.h
|
||||||
|
|
||||||
|
elif isinstance(node, Spacer):
|
||||||
|
node.rect.h = node.lines
|
||||||
|
return node.lines
|
||||||
|
|
||||||
|
elif isinstance(node, Pad):
|
||||||
|
cursor_y = y + node.top
|
||||||
|
inner_w = w - node.left - node.right
|
||||||
|
for child in node.children:
|
||||||
|
child_h = layout(child, x + node.left, cursor_y, inner_w,
|
||||||
|
h - node.top - node.bottom - (cursor_y - y - node.top))
|
||||||
|
cursor_y += child_h
|
||||||
|
node.rect.h = (cursor_y - y) + node.bottom
|
||||||
|
return node.rect.h
|
||||||
|
|
||||||
|
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
|
||||||
|
Gauge, Sparkline, Status)):
|
||||||
|
node.rect.h = node.pref_height
|
||||||
|
return node.pref_height
|
||||||
|
|
||||||
|
elif isinstance(node, ListNode):
|
||||||
|
cursor_y = y
|
||||||
|
for child in node.children:
|
||||||
|
child_h = layout(child, x + 2, cursor_y, w - 2, h - (cursor_y - y))
|
||||||
|
cursor_y += child_h
|
||||||
|
node.rect.h = cursor_y - y
|
||||||
|
return node.rect.h
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Generic vertical stacking
|
||||||
|
cursor_y = y
|
||||||
|
for child in node.children:
|
||||||
|
child_h = layout(child, x, cursor_y, w, h - (cursor_y - y))
|
||||||
|
cursor_y += child_h
|
||||||
|
node.rect.h = cursor_y - y
|
||||||
|
return node.rect.h
|
||||||
206
backend/uframe/measure.py
Normal file
206
backend/uframe/measure.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""Bottom-up measure pass — compute min/preferred width and height for each node."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uframe.ir import (
|
||||||
|
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
||||||
|
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||||
|
Gauge, Sparkline, Status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text_height(text: str, width: int) -> int:
|
||||||
|
"""Compute how many lines a text string needs at a given width."""
|
||||||
|
if not text or width <= 0:
|
||||||
|
return 1
|
||||||
|
words = text.split()
|
||||||
|
lines = 1
|
||||||
|
col = 0
|
||||||
|
for word in words:
|
||||||
|
wlen = len(word)
|
||||||
|
if col > 0 and col + 1 + wlen > width:
|
||||||
|
lines += 1
|
||||||
|
col = wlen
|
||||||
|
elif col == 0:
|
||||||
|
col = wlen
|
||||||
|
else:
|
||||||
|
col += 1 + wlen
|
||||||
|
return max(lines, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(node: IRNode, available_width: int) -> None:
|
||||||
|
"""Recursively compute min/preferred sizes for an IR subtree.
|
||||||
|
|
||||||
|
This is a bottom-up pass: children are measured before their parent.
|
||||||
|
"""
|
||||||
|
# Dispatch to node type
|
||||||
|
if isinstance(node, Page):
|
||||||
|
w = node.width
|
||||||
|
node.pref_width = w
|
||||||
|
node.min_width = w
|
||||||
|
# Measure children with full page width
|
||||||
|
total_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
measure(child, w)
|
||||||
|
total_h += child.pref_height
|
||||||
|
node.pref_height = total_h
|
||||||
|
node.min_height = total_h
|
||||||
|
|
||||||
|
elif isinstance(node, Box):
|
||||||
|
# Box adds 2 chars for borders on each axis (left+right, top+bottom)
|
||||||
|
inner_w = available_width - 2
|
||||||
|
total_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
measure(child, inner_w)
|
||||||
|
total_h += child.pref_height
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = 4 # minimum: border + 2 chars + border
|
||||||
|
node.pref_height = total_h + 2 # +2 for top/bottom border
|
||||||
|
node.min_height = 3 # top border + 1 line + bottom border
|
||||||
|
|
||||||
|
elif isinstance(node, Row):
|
||||||
|
# Children laid out horizontally, split available width
|
||||||
|
n = len(node.children)
|
||||||
|
if n == 0:
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.pref_height = 0
|
||||||
|
node.min_width = 0
|
||||||
|
node.min_height = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
gap_total = node.gap * (n - 1)
|
||||||
|
usable = available_width - gap_total
|
||||||
|
|
||||||
|
# First pass: measure children to get their preferred sizes
|
||||||
|
# Distribute width proportionally or equally
|
||||||
|
fixed_width_children = []
|
||||||
|
flex_children = []
|
||||||
|
fixed_total = 0
|
||||||
|
|
||||||
|
for child in node.children:
|
||||||
|
if isinstance(child, Col) and child.col_width is not None:
|
||||||
|
fixed_width_children.append(child)
|
||||||
|
fixed_total += child.col_width
|
||||||
|
else:
|
||||||
|
flex_children.append(child)
|
||||||
|
|
||||||
|
flex_each = 0
|
||||||
|
if flex_children:
|
||||||
|
flex_each = max(1, (usable - fixed_total) // len(flex_children))
|
||||||
|
|
||||||
|
max_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
if isinstance(child, Col) and child.col_width is not None:
|
||||||
|
child_w = child.col_width
|
||||||
|
else:
|
||||||
|
child_w = flex_each
|
||||||
|
measure(child, child_w)
|
||||||
|
max_h = max(max_h, child.pref_height)
|
||||||
|
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = n # at minimum 1 char per child
|
||||||
|
node.pref_height = max_h
|
||||||
|
node.min_height = max_h
|
||||||
|
|
||||||
|
elif isinstance(node, Col):
|
||||||
|
w = node.col_width if node.col_width is not None else available_width
|
||||||
|
total_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
measure(child, w)
|
||||||
|
total_h += child.pref_height
|
||||||
|
node.pref_width = w
|
||||||
|
node.min_width = min(w, 1)
|
||||||
|
node.pref_height = total_h
|
||||||
|
node.min_height = total_h
|
||||||
|
|
||||||
|
elif isinstance(node, Spacer):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = 0
|
||||||
|
node.pref_height = node.lines
|
||||||
|
node.min_height = node.lines
|
||||||
|
|
||||||
|
elif isinstance(node, Pad):
|
||||||
|
inner_w = available_width - node.left - node.right
|
||||||
|
total_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
measure(child, inner_w)
|
||||||
|
total_h += child.pref_height
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = node.left + node.right + 1
|
||||||
|
node.pref_height = total_h + node.top + node.bottom
|
||||||
|
node.min_height = node.top + node.bottom
|
||||||
|
|
||||||
|
elif isinstance(node, Heading):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = len(node.text) + 1
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Text):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = 1
|
||||||
|
node.pref_height = _text_height(node.content, available_width)
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Label):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = len(node.key) + 2 + len(node.value)
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Divider):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = 1
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Link):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = len(node.display) + 2
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, ListNode):
|
||||||
|
total_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
measure(child, available_width - 2) # indent for bullet
|
||||||
|
total_h += child.pref_height
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = 4
|
||||||
|
node.pref_height = total_h
|
||||||
|
node.min_height = total_h
|
||||||
|
|
||||||
|
elif isinstance(node, ListItem):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = len(node.content) + 1
|
||||||
|
node.pref_height = _text_height(node.content, available_width)
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Gauge):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = node.bar_width + len(node.label) + 6
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Sparkline):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = node.spark_width + len(node.label) + 4
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
elif isinstance(node, Status):
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = len(node.label) + 4
|
||||||
|
node.pref_height = 1
|
||||||
|
node.min_height = 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Generic: just measure children
|
||||||
|
total_h = 0
|
||||||
|
for child in node.children:
|
||||||
|
measure(child, available_width)
|
||||||
|
total_h += child.pref_height
|
||||||
|
node.pref_width = available_width
|
||||||
|
node.min_width = 1
|
||||||
|
node.pref_height = max(total_h, 1)
|
||||||
|
node.min_height = 1
|
||||||
216
backend/uframe/paint.py
Normal file
216
backend/uframe/paint.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Paint pass — write IR nodes into the CharGrid as characters.
|
||||||
|
|
||||||
|
Depth-first traversal: each node writes its content at its assigned
|
||||||
|
rect position. Containers recurse into children after drawing their
|
||||||
|
own structure (borders, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from uframe.chars import (
|
||||||
|
DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
|
||||||
|
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
|
||||||
|
)
|
||||||
|
from uframe.grid import CharGrid, CellStyle
|
||||||
|
from uframe.ir import (
|
||||||
|
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
||||||
|
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||||
|
Gauge, Sparkline, Status,
|
||||||
|
HeadingLevel, DividerStyle, ListStyle, Align,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _align_text(text: str, width: int, align: Align) -> str:
|
||||||
|
"""Align text within a field of the given width."""
|
||||||
|
if len(text) >= width:
|
||||||
|
return text[:width]
|
||||||
|
if align == Align.CENTER:
|
||||||
|
return text.center(width)
|
||||||
|
elif align == Align.RIGHT:
|
||||||
|
return text.rjust(width)
|
||||||
|
return text.ljust(width)
|
||||||
|
|
||||||
|
|
||||||
|
def _style_from_node(node: IRNode) -> CellStyle:
|
||||||
|
"""Create a CellStyle from a node's style attributes."""
|
||||||
|
return CellStyle(
|
||||||
|
fg=node.style.fg,
|
||||||
|
bg=node.style.bg,
|
||||||
|
bold=node.style.bold,
|
||||||
|
italic=node.style.italic,
|
||||||
|
underline=node.style.underline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paint(node: IRNode, grid: CharGrid) -> None:
|
||||||
|
"""Recursively paint an IR node and its children into the grid."""
|
||||||
|
x, y, w = node.rect.x, node.rect.y, node.rect.w
|
||||||
|
|
||||||
|
# Ensure grid is tall enough
|
||||||
|
grid.grow_height(y + node.rect.h)
|
||||||
|
|
||||||
|
if isinstance(node, Page):
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
|
|
||||||
|
elif isinstance(node, Box):
|
||||||
|
# Draw the border
|
||||||
|
title_style = CellStyle(bold=True, fg=node.style.fg)
|
||||||
|
grid.draw_border(x, y, w, node.rect.h,
|
||||||
|
weight=node.weight,
|
||||||
|
title=node.title,
|
||||||
|
title_style=title_style)
|
||||||
|
# Paint children inside the border
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
|
|
||||||
|
elif isinstance(node, Row):
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
|
|
||||||
|
elif isinstance(node, Col):
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
|
|
||||||
|
elif isinstance(node, Spacer):
|
||||||
|
pass # Just empty space
|
||||||
|
|
||||||
|
elif isinstance(node, Pad):
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
|
|
||||||
|
elif isinstance(node, Heading):
|
||||||
|
style = CellStyle(bold=True)
|
||||||
|
if node.level == HeadingLevel.H1:
|
||||||
|
style.fg = "0f0" # green
|
||||||
|
elif node.level == HeadingLevel.H2:
|
||||||
|
style.fg = "0cf" # cyan
|
||||||
|
elif node.level == HeadingLevel.H3:
|
||||||
|
style.fg = "88f" # light blue
|
||||||
|
|
||||||
|
# Underline-style heading
|
||||||
|
grid.put_text(x, y, node.text[:w], style=style)
|
||||||
|
|
||||||
|
elif isinstance(node, Text):
|
||||||
|
style = _style_from_node(node)
|
||||||
|
|
||||||
|
if node.spans and any(
|
||||||
|
s.bold or s.italic or s.underline or s.fg or s.bg
|
||||||
|
for s in node.spans
|
||||||
|
):
|
||||||
|
# Render with inline spans
|
||||||
|
col = x
|
||||||
|
row = y
|
||||||
|
for span in node.spans:
|
||||||
|
span_style = CellStyle(
|
||||||
|
fg=span.fg or style.fg,
|
||||||
|
bg=span.bg or style.bg,
|
||||||
|
bold=span.bold or style.bold,
|
||||||
|
italic=span.italic or style.italic,
|
||||||
|
underline=span.underline or style.underline,
|
||||||
|
)
|
||||||
|
for ch in span.text:
|
||||||
|
if col >= x + w:
|
||||||
|
col = x
|
||||||
|
row += 1
|
||||||
|
if grid.in_bounds(col, row):
|
||||||
|
grid.put(col, row, ch, style=span_style)
|
||||||
|
col += 1
|
||||||
|
else:
|
||||||
|
# Simple text with word wrapping
|
||||||
|
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
|
||||||
|
for i, line in enumerate(wrapped):
|
||||||
|
if y + i < grid.height:
|
||||||
|
text = _align_text(line, w, node.style.align)
|
||||||
|
grid.put_text(x, y + i, text, style=style)
|
||||||
|
|
||||||
|
elif isinstance(node, Label):
|
||||||
|
style = _style_from_node(node)
|
||||||
|
key_style = CellStyle(bold=True, fg=style.fg, bg=style.bg)
|
||||||
|
# Key: value layout with padding
|
||||||
|
key_text = f"{node.key}:"
|
||||||
|
pad = max(1, 16 - len(key_text))
|
||||||
|
grid.put_text(x, y, key_text, style=key_style)
|
||||||
|
grid.put_text(x + len(key_text) + pad, y, node.value, style=style)
|
||||||
|
|
||||||
|
elif isinstance(node, Divider):
|
||||||
|
ds = node.divider_style
|
||||||
|
char = DIVIDER_CHARS.get(ds.name.lower(), "─")
|
||||||
|
style = CellStyle(fg="555")
|
||||||
|
for col in range(x, x + w):
|
||||||
|
grid.put(col, y, char, style=style)
|
||||||
|
|
||||||
|
elif isinstance(node, Link):
|
||||||
|
style = CellStyle(fg="0cf", underline=True)
|
||||||
|
# In ASCII mode, display as [text]. In Micron, the emitter wraps with link syntax.
|
||||||
|
# Write just the display text — the link metadata goes on cells for Micron emission.
|
||||||
|
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
|
||||||
|
|
||||||
|
elif isinstance(node, ListNode):
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
|
|
||||||
|
elif isinstance(node, ListItem):
|
||||||
|
style = _style_from_node(node)
|
||||||
|
# Parent determines bullet style — use a simple bullet for now
|
||||||
|
bullet = "• "
|
||||||
|
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
|
||||||
|
# Wrap content
|
||||||
|
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
|
||||||
|
for i, line in enumerate(wrapped):
|
||||||
|
if y + i < grid.height:
|
||||||
|
grid.put_text(x, y + i, line, style=style)
|
||||||
|
|
||||||
|
elif isinstance(node, Gauge):
|
||||||
|
style = _style_from_node(node)
|
||||||
|
# Label
|
||||||
|
label_text = f"{node.label} "
|
||||||
|
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
|
||||||
|
|
||||||
|
bar_x = x + len(label_text)
|
||||||
|
bar_w = min(node.bar_width, w - len(label_text) - 6)
|
||||||
|
|
||||||
|
if bar_w > 0:
|
||||||
|
pct = min(node.value / node.max_val, 1.0) if node.max_val > 0 else 0
|
||||||
|
filled = int(bar_w * pct)
|
||||||
|
|
||||||
|
# Determine color based on thresholds
|
||||||
|
fg = "0f0" # green
|
||||||
|
if node.crit is not None and node.value >= node.crit:
|
||||||
|
fg = "f00" # red
|
||||||
|
elif node.warn is not None and node.value >= node.warn:
|
||||||
|
fg = "ff0" # yellow
|
||||||
|
|
||||||
|
for i in range(bar_w):
|
||||||
|
if i < filled:
|
||||||
|
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
|
||||||
|
else:
|
||||||
|
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
|
||||||
|
|
||||||
|
# Percentage
|
||||||
|
pct_text = f" {int(pct * 100)}%"
|
||||||
|
grid.put_text(bar_x + bar_w, y, pct_text, style=CellStyle(fg=fg))
|
||||||
|
|
||||||
|
elif isinstance(node, Sparkline):
|
||||||
|
style = _style_from_node(node)
|
||||||
|
label_text = f"{node.label} "
|
||||||
|
grid.put_text(x, y, label_text, style=CellStyle(bold=True))
|
||||||
|
|
||||||
|
spark_x = x + len(label_text)
|
||||||
|
chars = sparkline_chars(node.values, node.spark_width)
|
||||||
|
spark_style = CellStyle(fg="0cf")
|
||||||
|
for i, ch in enumerate(chars):
|
||||||
|
grid.put(spark_x + i, y, ch, style=spark_style)
|
||||||
|
|
||||||
|
elif isinstance(node, Status):
|
||||||
|
char = STATUS_CHARS.get(node.state, "◌")
|
||||||
|
color = STATUS_COLORS.get(node.state, "888")
|
||||||
|
grid.put(x, y, char, style=CellStyle(fg=color))
|
||||||
|
grid.put_text(x + 2, y, node.label)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Generic: paint children
|
||||||
|
for child in node.children:
|
||||||
|
paint(child, grid)
|
||||||
350
backend/uframe/parser.py
Normal file
350
backend/uframe/parser.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""µFrame parser — .uf source text → IR tree.
|
||||||
|
|
||||||
|
Line-oriented, indentation-based (2-space). Each line is parsed as:
|
||||||
|
(indent_level, keyword, arguments)
|
||||||
|
|
||||||
|
Nesting is determined by indentation: children are indented deeper
|
||||||
|
than their parent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from uframe.errors import ParseError
|
||||||
|
from uframe.ir import (
|
||||||
|
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
||||||
|
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||||
|
Gauge, Sparkline, Status, Table, TextSpan,
|
||||||
|
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tokenisation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_INDENT_RE = re.compile(r"^( *)")
|
||||||
|
_MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _indent_level(line: str) -> int:
|
||||||
|
"""Count leading spaces and return indent level (2 spaces = 1 level)."""
|
||||||
|
spaces = len(_INDENT_RE.match(line).group(1)) # type: ignore[union-attr]
|
||||||
|
return spaces // 2
|
||||||
|
|
||||||
|
|
||||||
|
def _split_args(text: str) -> list[str]:
|
||||||
|
"""Split argument string respecting quoted tokens."""
|
||||||
|
try:
|
||||||
|
return shlex.split(text)
|
||||||
|
except ValueError:
|
||||||
|
return text.split()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_inline(content: str) -> list[TextSpan]:
|
||||||
|
"""Parse @modifier{text} syntax into a list of TextSpan objects.
|
||||||
|
|
||||||
|
Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{},
|
||||||
|
@bg{hex}{}.
|
||||||
|
"""
|
||||||
|
spans: list[TextSpan] = []
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
# Match @modifier{content} — including nested @color{hex}{text}
|
||||||
|
pattern = re.compile(
|
||||||
|
r"@(bold|italic|under|color|bg)"
|
||||||
|
r"(?:\{([0-9a-fA-F]{3})\})?" # optional hex arg for color/bg
|
||||||
|
r"\{([^}]*)\}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in pattern.finditer(content):
|
||||||
|
# Add plain text before this modifier
|
||||||
|
if m.start() > pos:
|
||||||
|
spans.append(TextSpan(text=content[pos:m.start()]))
|
||||||
|
|
||||||
|
mod = m.group(1)
|
||||||
|
hex_arg = m.group(2)
|
||||||
|
inner = m.group(3)
|
||||||
|
|
||||||
|
span = TextSpan(text=inner)
|
||||||
|
if mod == "bold":
|
||||||
|
span.bold = True
|
||||||
|
elif mod == "italic":
|
||||||
|
span.italic = True
|
||||||
|
elif mod == "under":
|
||||||
|
span.underline = True
|
||||||
|
elif mod == "color" and hex_arg:
|
||||||
|
span.fg = hex_arg
|
||||||
|
elif mod == "bg" and hex_arg:
|
||||||
|
span.bg = hex_arg
|
||||||
|
|
||||||
|
spans.append(span)
|
||||||
|
pos = m.end()
|
||||||
|
|
||||||
|
# Trailing plain text
|
||||||
|
if pos < len(content):
|
||||||
|
spans.append(TextSpan(text=content[pos:]))
|
||||||
|
|
||||||
|
# If no modifiers found, return single plain span
|
||||||
|
if not spans:
|
||||||
|
spans.append(TextSpan(text=content))
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Line-level parsing — keyword dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_border_weight(s: str) -> BorderWeight:
|
||||||
|
return {
|
||||||
|
"light": BorderWeight.LIGHT,
|
||||||
|
"heavy": BorderWeight.HEAVY,
|
||||||
|
"double": BorderWeight.DOUBLE,
|
||||||
|
"rounded": BorderWeight.ROUNDED,
|
||||||
|
}.get(s.lower(), BorderWeight.LIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_divider_style(s: str) -> DividerStyle:
|
||||||
|
return {
|
||||||
|
"light": DividerStyle.LIGHT,
|
||||||
|
"heavy": DividerStyle.HEAVY,
|
||||||
|
"double": DividerStyle.DOUBLE,
|
||||||
|
"dash": DividerStyle.DASH,
|
||||||
|
"dot": DividerStyle.DOT,
|
||||||
|
}.get(s.lower(), DividerStyle.LIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_heading_level(s: str) -> HeadingLevel:
|
||||||
|
return {
|
||||||
|
"1": HeadingLevel.H1,
|
||||||
|
"2": HeadingLevel.H2,
|
||||||
|
"3": HeadingLevel.H3,
|
||||||
|
}.get(s, HeadingLevel.H1)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_list_style(s: str) -> ListStyle:
|
||||||
|
return {
|
||||||
|
"bullet": ListStyle.BULLET,
|
||||||
|
"dash": ListStyle.DASH,
|
||||||
|
"number": ListStyle.NUMBER,
|
||||||
|
"arrow": ListStyle.ARROW,
|
||||||
|
}.get(s.lower(), ListStyle.BULLET)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||||
|
"""Parse a single line into an IR node based on the keyword."""
|
||||||
|
|
||||||
|
if keyword == "page":
|
||||||
|
title = args[0] if args else "Untitled"
|
||||||
|
width = int(args[1]) if len(args) > 1 else 64
|
||||||
|
return Page(title=title, width=width, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "box":
|
||||||
|
# box [weight] "title"
|
||||||
|
if len(args) >= 2:
|
||||||
|
weight = _parse_border_weight(args[0])
|
||||||
|
title = args[1]
|
||||||
|
elif len(args) == 1:
|
||||||
|
# Could be weight or title
|
||||||
|
if args[0].lower() in ("light", "heavy", "double", "rounded"):
|
||||||
|
weight = _parse_border_weight(args[0])
|
||||||
|
title = ""
|
||||||
|
else:
|
||||||
|
weight = BorderWeight.LIGHT
|
||||||
|
title = args[0]
|
||||||
|
else:
|
||||||
|
weight = BorderWeight.LIGHT
|
||||||
|
title = ""
|
||||||
|
return Box(title=title, weight=weight, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "row":
|
||||||
|
gap = int(args[0]) if args else 1
|
||||||
|
return Row(gap=gap, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "col":
|
||||||
|
w = int(args[0]) if args else None
|
||||||
|
return Col(col_width=w, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "spacer":
|
||||||
|
lines = int(args[0]) if args else 1
|
||||||
|
return Spacer(lines=lines, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "pad":
|
||||||
|
vals = [int(a) for a in args[:4]]
|
||||||
|
while len(vals) < 4:
|
||||||
|
vals.append(0)
|
||||||
|
return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3],
|
||||||
|
source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "heading":
|
||||||
|
level_str = args[0] if args else "1"
|
||||||
|
text = args[1] if len(args) > 1 else ""
|
||||||
|
return Heading(level=_parse_heading_level(level_str), text=text,
|
||||||
|
source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "text":
|
||||||
|
content = args[0] if args else ""
|
||||||
|
spans = parse_inline(content)
|
||||||
|
return Text(content=content, spans=spans, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "label":
|
||||||
|
key = args[0] if args else ""
|
||||||
|
value = args[1] if len(args) > 1 else ""
|
||||||
|
return Label(key=key, value=value, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "divider":
|
||||||
|
style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT
|
||||||
|
return Divider(divider_style=style, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "link":
|
||||||
|
display = args[0] if args else ""
|
||||||
|
dest = args[1] if len(args) > 1 else ""
|
||||||
|
return Link(display=display, dest=dest, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "list":
|
||||||
|
style = _parse_list_style(args[0]) if args else ListStyle.BULLET
|
||||||
|
return ListNode(list_style=style, source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "item":
|
||||||
|
content = args[0] if args else ""
|
||||||
|
return ListItem(content=content, source_line=line_num)
|
||||||
|
|
||||||
|
# Style modifiers (applied to parent)
|
||||||
|
elif keyword == "align":
|
||||||
|
val = args[0].lower() if args else "left"
|
||||||
|
align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT)
|
||||||
|
return _StyleDirective("align", align, line_num)
|
||||||
|
|
||||||
|
elif keyword == "color":
|
||||||
|
return _StyleDirective("fg", args[0] if args else None, line_num)
|
||||||
|
|
||||||
|
elif keyword == "bg":
|
||||||
|
return _StyleDirective("bg", args[0] if args else None, line_num)
|
||||||
|
|
||||||
|
elif keyword == "bold":
|
||||||
|
return _StyleDirective("bold", True, line_num)
|
||||||
|
|
||||||
|
elif keyword == "italic":
|
||||||
|
return _StyleDirective("italic", True, line_num)
|
||||||
|
|
||||||
|
elif keyword == "underline":
|
||||||
|
return _StyleDirective("underline", True, line_num)
|
||||||
|
|
||||||
|
# Phase 4 placeholders
|
||||||
|
elif keyword == "gauge":
|
||||||
|
label = args[0] if args else ""
|
||||||
|
value = float(args[1]) if len(args) > 1 else 0
|
||||||
|
max_val = float(args[2]) if len(args) > 2 else 100
|
||||||
|
bar_width = int(args[3]) if len(args) > 3 else 28
|
||||||
|
# Parse warn=N crit=N from remaining args
|
||||||
|
warn = crit = None
|
||||||
|
for a in args[4:]:
|
||||||
|
if a.startswith("warn="):
|
||||||
|
warn = float(a[5:])
|
||||||
|
elif a.startswith("crit="):
|
||||||
|
crit = float(a[5:])
|
||||||
|
return Gauge(label=label, value=value, max_val=max_val,
|
||||||
|
bar_width=bar_width, warn=warn, crit=crit,
|
||||||
|
source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "sparkline":
|
||||||
|
label = args[0] if args else ""
|
||||||
|
vals_str = args[1] if len(args) > 1 else ""
|
||||||
|
values = [float(v) for v in vals_str.split(",") if v.strip()] if vals_str else []
|
||||||
|
width = int(args[2]) if len(args) > 2 else 20
|
||||||
|
return Sparkline(label=label, values=values, spark_width=width,
|
||||||
|
source_line=line_num)
|
||||||
|
|
||||||
|
elif keyword == "status":
|
||||||
|
label = args[0] if args else ""
|
||||||
|
state = args[1] if len(args) > 1 else "unknown"
|
||||||
|
return Status(label=label, state=state, source_line=line_num)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Style directives — pseudo-nodes that modify their parent's style
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _StyleDirective(IRNode):
|
||||||
|
"""Temporary node representing a style modifier (align, color, bold, etc.).
|
||||||
|
|
||||||
|
These are absorbed by the parent during tree building and never appear
|
||||||
|
in the final IR tree.
|
||||||
|
"""
|
||||||
|
def __init__(self, attr: str, value: object, line_num: int):
|
||||||
|
super().__init__(source_line=line_num)
|
||||||
|
self.attr = attr
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tree builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse(source: str) -> Page:
|
||||||
|
"""Parse a .uf source string into an IR tree rooted at a Page node.
|
||||||
|
|
||||||
|
Returns the Page node with all children attached.
|
||||||
|
"""
|
||||||
|
lines = source.split("\n")
|
||||||
|
|
||||||
|
# Stack: list of (indent_level, node)
|
||||||
|
stack: list[tuple[int, IRNode]] = []
|
||||||
|
root: Page | None = None
|
||||||
|
|
||||||
|
for line_num, raw_line in enumerate(lines, start=1):
|
||||||
|
# Skip blank lines and comments
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
indent = _indent_level(raw_line)
|
||||||
|
|
||||||
|
# Split into keyword + arguments
|
||||||
|
parts = stripped.split(None, 1)
|
||||||
|
keyword = parts[0].lower()
|
||||||
|
arg_str = parts[1] if len(parts) > 1 else ""
|
||||||
|
args = _split_args(arg_str)
|
||||||
|
|
||||||
|
# Parse this line into a node
|
||||||
|
node = _parse_line(keyword, args, line_num)
|
||||||
|
|
||||||
|
# Pop stack back to find the parent (parent indent < this indent)
|
||||||
|
while stack and stack[-1][0] >= indent:
|
||||||
|
stack.pop()
|
||||||
|
|
||||||
|
if isinstance(node, _StyleDirective):
|
||||||
|
# Apply style directive to the current top of stack (parent)
|
||||||
|
if stack:
|
||||||
|
parent = stack[-1][1]
|
||||||
|
setattr(parent.style, node.attr, node.value)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Attach to parent
|
||||||
|
if stack:
|
||||||
|
parent = stack[-1][1]
|
||||||
|
parent.children.append(node)
|
||||||
|
elif isinstance(node, Page):
|
||||||
|
root = node
|
||||||
|
else:
|
||||||
|
# Auto-wrap in a default Page if source doesn't start with `page`
|
||||||
|
root = Page(title="Untitled", width=64, source_line=0)
|
||||||
|
root.children.append(node)
|
||||||
|
stack.append((-1, root))
|
||||||
|
|
||||||
|
# Push onto stack
|
||||||
|
if isinstance(node, Page) and root is node:
|
||||||
|
stack.append((-1, node))
|
||||||
|
else:
|
||||||
|
stack.append((indent, node))
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
root = Page(title="Untitled", width=64, source_line=0)
|
||||||
|
|
||||||
|
return root
|
||||||
0
backend/uframe/tests/__init__.py
Normal file
0
backend/uframe/tests/__init__.py
Normal file
212
backend/uframe/tests/test_compile.py
Normal file
212
backend/uframe/tests/test_compile.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""End-to-end tests for the µFrame compile pipeline."""
|
||||||
|
|
||||||
|
import uframe
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_source():
|
||||||
|
result = uframe.compile("")
|
||||||
|
assert result.ascii == ""
|
||||||
|
assert result.micron == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_heading():
|
||||||
|
result = uframe.compile('page "Test" 40\n heading 1 "Hello World"')
|
||||||
|
assert "Hello World" in result.ascii
|
||||||
|
assert "Hello World" in result.micron
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_with_title():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
box light "Status"
|
||||||
|
text "All systems go"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
# ASCII should have box-drawing characters
|
||||||
|
assert "┌" in result.ascii
|
||||||
|
assert "└" in result.ascii
|
||||||
|
assert "Status" in result.ascii
|
||||||
|
assert "All systems go" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_heavy():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
box heavy "Alert"
|
||||||
|
text "Warning"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "┏" in result.ascii
|
||||||
|
assert "Alert" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_double():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
box double "Title"
|
||||||
|
text "Content"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "╔" in result.ascii
|
||||||
|
assert "Title" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_rounded():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
box rounded "Panel"
|
||||||
|
text "Inside"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "╭" in result.ascii
|
||||||
|
assert "Panel" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_row_with_columns():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
row 2
|
||||||
|
col 18
|
||||||
|
text "Left"
|
||||||
|
col 18
|
||||||
|
text "Right"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "Left" in result.ascii
|
||||||
|
assert "Right" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_label():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
label "Name" "Alice"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "Name:" in result.ascii
|
||||||
|
assert "Alice" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_divider():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
divider heavy'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "━" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_link():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
link "Home" "/page/index.mu"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "Home" in result.ascii
|
||||||
|
# Micron should have link syntax
|
||||||
|
assert "index.mu" in result.micron
|
||||||
|
|
||||||
|
|
||||||
|
def test_list():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
list bullet
|
||||||
|
item "First"
|
||||||
|
item "Second"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "First" in result.ascii
|
||||||
|
assert "Second" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_spacer():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
text "Before"
|
||||||
|
spacer 2
|
||||||
|
text "After"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
lines = result.ascii.split("\n")
|
||||||
|
# Should have blank lines between Before and After
|
||||||
|
before_idx = next(i for i, l in enumerate(lines) if "Before" in l)
|
||||||
|
after_idx = next(i for i, l in enumerate(lines) if "After" in l)
|
||||||
|
assert after_idx - before_idx >= 3 # at least 2 blank lines between
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_boxes():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
box light "Outer"
|
||||||
|
box heavy "Inner"
|
||||||
|
text "Deep"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "Outer" in result.ascii
|
||||||
|
assert "Inner" in result.ascii
|
||||||
|
assert "Deep" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_micron_has_style_tags():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
heading 1 "Title"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
# Micron should contain color tags for the heading
|
||||||
|
assert "`F" in result.micron or "Title" in result.micron
|
||||||
|
|
||||||
|
|
||||||
|
def test_gauge():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
gauge "CPU" 62 100 20 warn=75 crit=90'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "CPU" in result.ascii
|
||||||
|
assert "█" in result.ascii
|
||||||
|
assert "62%" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_ignored():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
# this is a comment
|
||||||
|
text "Visible"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "Visible" in result.ascii
|
||||||
|
assert "comment" not in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_modifiers():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
text "Hello @bold{world} today"'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "Hello" in result.ascii
|
||||||
|
assert "world" in result.ascii
|
||||||
|
# Micron should have bold tags around "world"
|
||||||
|
assert "`!" in result.micron
|
||||||
|
|
||||||
|
|
||||||
|
def test_status():
|
||||||
|
source = '''page "Demo" 40
|
||||||
|
status "Server" online'''
|
||||||
|
result = uframe.compile(source)
|
||||||
|
assert "●" in result.ascii
|
||||||
|
assert "Server" in result.ascii
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_dashboard():
|
||||||
|
"""Integration test: a realistic dashboard layout."""
|
||||||
|
source = '''page "Node Status" 60
|
||||||
|
box double "Relay Alpha-7"
|
||||||
|
align center
|
||||||
|
text "Reticulum Network Node"
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 1 "Resources"
|
||||||
|
|
||||||
|
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||||
|
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 2 "Peers"
|
||||||
|
|
||||||
|
label "Active" "7 / 12"
|
||||||
|
status "East Relay" online
|
||||||
|
status "South Bridge" online
|
||||||
|
status "Node Gamma" degraded
|
||||||
|
|
||||||
|
divider heavy
|
||||||
|
|
||||||
|
link "Home" "/page/index.mu"'''
|
||||||
|
|
||||||
|
result = uframe.compile(source)
|
||||||
|
|
||||||
|
# Verify key elements are present
|
||||||
|
assert "Relay Alpha-7" in result.ascii
|
||||||
|
assert "╔" in result.ascii # double box
|
||||||
|
assert "CPU" in result.ascii
|
||||||
|
assert "MEM" in result.ascii
|
||||||
|
assert "█" in result.ascii # gauge bars
|
||||||
|
assert "●" in result.ascii # status indicators
|
||||||
|
assert "━" in result.ascii # heavy divider
|
||||||
|
assert "Home" in result.ascii
|
||||||
|
|
||||||
|
# Micron should have color tags
|
||||||
|
assert "`F" in result.micron
|
||||||
|
assert result.micron # non-empty
|
||||||
883
docs/dynamic-templates.md
Normal file
883
docs/dynamic-templates.md
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
# µFrame Dynamic Templates — Making Rich UIs Live on NomadNet
|
||||||
|
|
||||||
|
## Addendum to the µFrame Design Document (v3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The NomadNet Dynamic Page Model
|
||||||
|
|
||||||
|
Understanding how NomadNet serves dynamic pages is essential to
|
||||||
|
understanding how µFrame templates become live applications.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
NomadNet has a simple but powerful execution model, analogous to
|
||||||
|
CGI on the early web:
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Node Server
|
||||||
|
────── ───────────
|
||||||
|
1. Browse to /page/dashboard.mu
|
||||||
|
───────────▶
|
||||||
|
2. Is dashboard.mu executable?
|
||||||
|
YES → run it
|
||||||
|
NO → send contents as-is
|
||||||
|
|
||||||
|
3. Execute: #!/usr/bin/env python3
|
||||||
|
Script prints Micron to stdout
|
||||||
|
|
||||||
|
4. Capture stdout → send to client
|
||||||
|
◀───────────
|
||||||
|
5. Render Micron in terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Key mechanics:
|
||||||
|
|
||||||
|
- A `.mu` file without the execute bit is **static** — NomadNet
|
||||||
|
sends its contents directly to the browsing client
|
||||||
|
- A `.mu` file with the execute bit set is **dynamic** — NomadNet
|
||||||
|
runs it as a subprocess and serves whatever it prints to stdout
|
||||||
|
- The shebang line (`#!/usr/bin/env python3`) determines the
|
||||||
|
interpreter — Python, Bash, Lua, Rust, anything that runs
|
||||||
|
- The script must **terminate** — it cannot wait for input or
|
||||||
|
run indefinitely
|
||||||
|
- Cache behavior is controlled via a header line: `#!c=0` means
|
||||||
|
never cache (always re-execute), `#!c=300` means cache for 5 min
|
||||||
|
|
||||||
|
### Form data flow
|
||||||
|
|
||||||
|
Micron form fields (`\`<field\`placeholder>`, `\`<^|group|val\`label>`,
|
||||||
|
etc.) collect user input. When the user clicks a link on a page
|
||||||
|
that contains form fields, the field data is submitted along with
|
||||||
|
the link request:
|
||||||
|
|
||||||
|
```
|
||||||
|
Page A (has form fields + a submit link)
|
||||||
|
|
||||||
|
┌───────────────────────────────────────────────┐
|
||||||
|
│ Name: `<24|name`Enter name...> │
|
||||||
|
│ Role: `<^|role|admin`Admin> `<^|role|user`User> │
|
||||||
|
│ │
|
||||||
|
│ `[Submit`:/page/handle.mu] │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
User fills in "Alice", selects "Admin", clicks Submit
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Page B (handle.mu) — receives field data via environment
|
||||||
|
variables, generates a response page with Micron output
|
||||||
|
```
|
||||||
|
|
||||||
|
The submitted field data is passed to the executable script
|
||||||
|
through environment variables in the format:
|
||||||
|
|
||||||
|
```
|
||||||
|
FIELD_name=Alice
|
||||||
|
FIELD_role=admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via stdin as a URL-encoded or structured data payload
|
||||||
|
(implementation varies by NomadNet version). The executable
|
||||||
|
script reads these values and uses them to generate its output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. µFrame's Dynamic Compilation Model
|
||||||
|
|
||||||
|
Here is the key insight: **µFrame doesn't just emit static
|
||||||
|
Micron text — it can compile `.uf` templates into executable
|
||||||
|
Python scripts that generate Micron at request time.**
|
||||||
|
|
||||||
|
This gives us three output modes:
|
||||||
|
|
||||||
|
```
|
||||||
|
.uf source ──▶ Parser ──▶ IR ──┬──▶ Plain ASCII (static preview)
|
||||||
|
├──▶ Static .mu (static page)
|
||||||
|
└──▶ Dynamic .mu (executable script)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# Auto-generated by µFrame
|
||||||
|
# from: dashboard.uf
|
||||||
|
import os, sys, json, subprocess
|
||||||
|
...
|
||||||
|
print(rendered_micron)
|
||||||
|
```
|
||||||
|
|
||||||
|
The dynamic output is a self-contained Python script with:
|
||||||
|
- The µFrame rendering engine embedded (or imported)
|
||||||
|
- Data-fetching hooks that run at request time
|
||||||
|
- Form field processing from environment variables
|
||||||
|
- Conditional rendering based on submitted data
|
||||||
|
- The full ASCII art + Micron generation pipeline
|
||||||
|
|
||||||
|
### The three modes compared
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ µFrame Output Modes │
|
||||||
|
├────────────────┬───────────────┬─────────────────────────────┤
|
||||||
|
│ Plain ASCII │ Static .mu │ Dynamic .mu │
|
||||||
|
├────────────────┼───────────────┼─────────────────────────────┤
|
||||||
|
│ Box-drawing │ Box-drawing │ Box-drawing │
|
||||||
|
│ Block chars │ Block chars │ Block chars │
|
||||||
|
│ Braille │ Braille │ Braille │
|
||||||
|
│ │ + Color │ + Color │
|
||||||
|
│ │ + Bold/italic │ + Bold/italic │
|
||||||
|
│ │ + Links │ + Links │
|
||||||
|
│ │ + Form fields │ + LIVE form fields │
|
||||||
|
│ │ │ + Server-side data binding │
|
||||||
|
│ │ │ + Conditional rendering │
|
||||||
|
│ │ │ + Form submission handling │
|
||||||
|
│ │ │ + System data at render time │
|
||||||
|
│ │ │ + State persistence │
|
||||||
|
├────────────────┼───────────────┼─────────────────────────────┤
|
||||||
|
│ Local terminal │ NomadNet page │ NomadNet live application │
|
||||||
|
│ preview │ (cached) │ (re-executed per request) │
|
||||||
|
└────────────────┴───────────────┴─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. DSL Extensions for Dynamic Behavior
|
||||||
|
|
||||||
|
### 3.1 Data Sources — `source` blocks
|
||||||
|
|
||||||
|
A `source` block declares where live data comes from. At render
|
||||||
|
time, the generated script executes the source and binds the
|
||||||
|
result to a variable.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Shell command — output captured as string
|
||||||
|
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
||||||
|
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
||||||
|
source uptime : shell "uptime -p"
|
||||||
|
source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"peers\",[]))); '"
|
||||||
|
|
||||||
|
# File read — contents loaded as string or parsed as JSON
|
||||||
|
source motd : file "/etc/motd"
|
||||||
|
source config : json "/home/node/.nomadnetwork/config.json"
|
||||||
|
|
||||||
|
# Python expression — evaluated inline
|
||||||
|
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
|
||||||
|
source rand_hex : python "secrets.token_hex(4)"
|
||||||
|
|
||||||
|
# RNS/Reticulum API — direct integration
|
||||||
|
source peer_list : rns "peers"
|
||||||
|
source node_info : rns "identity"
|
||||||
|
```
|
||||||
|
|
||||||
|
Sources are resolved **at page render time** — every time a
|
||||||
|
client requests the page, the commands run fresh.
|
||||||
|
|
||||||
|
Usage in templates:
|
||||||
|
|
||||||
|
```
|
||||||
|
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
|
||||||
|
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
|
||||||
|
label "Uptime" "$uptime"
|
||||||
|
label "Peers" "$peers active"
|
||||||
|
text "Last updated: $timestamp"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Form Handling — `on_submit` blocks
|
||||||
|
|
||||||
|
An `on_submit` block defines what happens when a form is
|
||||||
|
submitted. It receives field values and controls what the
|
||||||
|
page renders in response.
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Search" 64
|
||||||
|
|
||||||
|
form "search"
|
||||||
|
field "query" 30 "Enter search term..."
|
||||||
|
radio "scope" "Local" | "Network" | "All"
|
||||||
|
button "Search" "/page/search.mu"
|
||||||
|
|
||||||
|
on_submit "search"
|
||||||
|
# $query and $scope are now populated from submitted form data
|
||||||
|
source results : shell "search_index.py '$query' --scope '$scope'"
|
||||||
|
|
||||||
|
heading 2 "Results for: $query"
|
||||||
|
|
||||||
|
if $results
|
||||||
|
text "$results"
|
||||||
|
else
|
||||||
|
text "No results found."
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated Python script handles this as:
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
#!c=0
|
||||||
|
import os, subprocess
|
||||||
|
|
||||||
|
# Read submitted form data
|
||||||
|
query = os.environ.get("FIELD_query", "")
|
||||||
|
scope = os.environ.get("FIELD_scope", "Local")
|
||||||
|
|
||||||
|
if query:
|
||||||
|
# Form was submitted — render results
|
||||||
|
results = subprocess.check_output(
|
||||||
|
["search_index.py", query, "--scope", scope]
|
||||||
|
).decode().strip()
|
||||||
|
# ... render results template with Micron ...
|
||||||
|
else:
|
||||||
|
# No submission — render the form
|
||||||
|
# ... render form template with Micron ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Conditional Rendering — `if` / `else` / `elif`
|
||||||
|
|
||||||
|
```
|
||||||
|
source disk_pct : shell "df / | awk 'NR==2{print int($5)}'"
|
||||||
|
|
||||||
|
if $disk_pct > 90
|
||||||
|
box heavy "DISK CRITICAL"
|
||||||
|
color f00
|
||||||
|
gauge "Disk" $disk_pct 100 40 crit=90
|
||||||
|
text "@bold{@color{f00}{Immediate action required!}}"
|
||||||
|
elif $disk_pct > 75
|
||||||
|
box light "Disk Warning"
|
||||||
|
color ff0
|
||||||
|
gauge "Disk" $disk_pct 100 40 warn=75
|
||||||
|
else
|
||||||
|
gauge "Disk" $disk_pct 100 40
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Iteration — `for` loops
|
||||||
|
|
||||||
|
```
|
||||||
|
source peer_json : shell "rnstatus --json-peers"
|
||||||
|
|
||||||
|
heading 2 "Active Peers"
|
||||||
|
|
||||||
|
for peer in $peer_json
|
||||||
|
row 2
|
||||||
|
col 30
|
||||||
|
text "$peer.name"
|
||||||
|
col 10
|
||||||
|
status "$peer.name" $peer.state
|
||||||
|
col 10
|
||||||
|
text "$peer.latency"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `for` construct works with JSON arrays or newline-delimited
|
||||||
|
text from shell commands.
|
||||||
|
|
||||||
|
### 3.5 State Persistence — `state` blocks
|
||||||
|
|
||||||
|
Since each page request is a fresh script execution, state must
|
||||||
|
be stored externally. µFrame provides a simple key-value store
|
||||||
|
backed by a JSON file on the node:
|
||||||
|
|
||||||
|
```
|
||||||
|
state "counter" "/tmp/uframe_counter.json"
|
||||||
|
|
||||||
|
# Read
|
||||||
|
let visits = $counter.visits || 0
|
||||||
|
|
||||||
|
# Write (increments on each page load)
|
||||||
|
set counter.visits = $visits + 1
|
||||||
|
|
||||||
|
text "This page has been viewed $counter.visits times."
|
||||||
|
```
|
||||||
|
|
||||||
|
For form-driven state (e.g., a guestbook):
|
||||||
|
|
||||||
|
```
|
||||||
|
state "guestbook" "/var/nomadnet/guestbook.json"
|
||||||
|
|
||||||
|
form "sign"
|
||||||
|
field "name" 20 "Your name..."
|
||||||
|
field "message" 40 "Your message..."
|
||||||
|
button "Sign" "/page/guestbook.mu"
|
||||||
|
|
||||||
|
on_submit "sign"
|
||||||
|
append guestbook.entries { name: $name, message: $message, time: $timestamp }
|
||||||
|
text "@color{0f0}{Thanks, $name! Your message has been saved.}"
|
||||||
|
|
||||||
|
heading 2 "Guestbook ($guestbook.entries.length entries)"
|
||||||
|
|
||||||
|
for entry in $guestbook.entries
|
||||||
|
box light
|
||||||
|
text "@bold{$entry.name} — @italic{$entry.time}"
|
||||||
|
text "$entry.message"
|
||||||
|
spacer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Page Navigation with Data — `link` with parameters
|
||||||
|
|
||||||
|
Links can pass data to the target page via query-style encoding:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Simple navigation
|
||||||
|
link "Home" "/page/index.mu"
|
||||||
|
|
||||||
|
# Navigation with parameters
|
||||||
|
link "View Peer $peer.name" "/page/peer_detail.mu?hash=$peer.hash"
|
||||||
|
|
||||||
|
# In the target page, access with:
|
||||||
|
source peer_hash : param "hash"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 Cache Control
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Dashboard" 64
|
||||||
|
cache 0 # never cache — always re-execute
|
||||||
|
# cache 60 # cache for 60 seconds
|
||||||
|
# cache none # alias for 0
|
||||||
|
|
||||||
|
source cpu : shell "..."
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Translates to the Micron header `#!c=0` in the first line of output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Compilation Pipeline — .uf to Executable .mu
|
||||||
|
|
||||||
|
### 4.1 What the compiler generates
|
||||||
|
|
||||||
|
A `.uf` file with dynamic features compiles into a Python script
|
||||||
|
that:
|
||||||
|
|
||||||
|
1. Sets the shebang and cache header
|
||||||
|
2. Imports required modules
|
||||||
|
3. Reads environment variables (form data)
|
||||||
|
4. Executes source commands (shell, file, python, rns)
|
||||||
|
5. Evaluates conditionals and loops
|
||||||
|
6. Renders the IR tree into a CharGrid
|
||||||
|
7. Emits the CharGrid as Micron with style tags
|
||||||
|
8. Prints to stdout
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
#!c=0
|
||||||
|
# Auto-generated by µFrame from dashboard.uf
|
||||||
|
# Do not edit — regenerate with: uframe compile dashboard.uf
|
||||||
|
|
||||||
|
import os, sys, json, subprocess, datetime, secrets
|
||||||
|
|
||||||
|
# ─── µFrame Runtime (embedded) ───────────────────────────────
|
||||||
|
|
||||||
|
class CharGrid:
|
||||||
|
"""2D character grid with style annotations."""
|
||||||
|
def __init__(self, width, height):
|
||||||
|
self.w = width
|
||||||
|
self.h = height
|
||||||
|
self.chars = [[' ']*width for _ in range(height)]
|
||||||
|
self.styles = [[None]*width for _ in range(height)]
|
||||||
|
|
||||||
|
def put(self, x, y, ch, style=None):
|
||||||
|
if 0 <= x < self.w and 0 <= y < self.h:
|
||||||
|
self.chars[y][x] = ch
|
||||||
|
self.styles[y][x] = style
|
||||||
|
|
||||||
|
def box(self, x, y, w, h, weight='light', title=None, title_style=None):
|
||||||
|
"""Draw a box with automatic border characters."""
|
||||||
|
# ... border drawing logic ...
|
||||||
|
|
||||||
|
def gauge(self, x, y, w, value, max_val, label=None,
|
||||||
|
warn=None, crit=None):
|
||||||
|
"""Render a horizontal gauge bar with threshold colors."""
|
||||||
|
pct = min(value / max_val, 1.0)
|
||||||
|
filled = int(w * pct)
|
||||||
|
for i in range(w):
|
||||||
|
ch = '█' if i < filled else '░'
|
||||||
|
fg = None
|
||||||
|
if crit and value >= crit: fg = 'f00'
|
||||||
|
elif warn and value >= warn: fg = 'ff0'
|
||||||
|
elif i < filled: fg = '0f0'
|
||||||
|
else: fg = '555'
|
||||||
|
self.put(x + i, y, ch, {'fg': fg})
|
||||||
|
# ... label and percentage ...
|
||||||
|
|
||||||
|
def sparkline(self, x, y, w, values):
|
||||||
|
"""Render braille sparkline from value array."""
|
||||||
|
# ... braille pattern generation ...
|
||||||
|
|
||||||
|
def emit_micron(self):
|
||||||
|
"""Scan grid and emit Micron with style tags."""
|
||||||
|
lines = []
|
||||||
|
for row_idx in range(self.h):
|
||||||
|
line = []
|
||||||
|
cur_style = None
|
||||||
|
for col_idx in range(self.w):
|
||||||
|
ch = self.chars[row_idx][col_idx]
|
||||||
|
st = self.styles[row_idx][col_idx]
|
||||||
|
if st != cur_style:
|
||||||
|
# Close previous style tags
|
||||||
|
if cur_style:
|
||||||
|
if cur_style.get('fg'): line.append('`f')
|
||||||
|
if cur_style.get('bold'): line.append('`!')
|
||||||
|
# Open new style tags
|
||||||
|
if st:
|
||||||
|
if st.get('bold'): line.append('`!')
|
||||||
|
if st.get('fg'): line.append(f'`F{st["fg"]}')
|
||||||
|
cur_style = st
|
||||||
|
line.append(ch)
|
||||||
|
# Close final style
|
||||||
|
if cur_style:
|
||||||
|
if cur_style.get('fg'): line.append('`f')
|
||||||
|
if cur_style.get('bold'): line.append('`!')
|
||||||
|
lines.append(''.join(line).rstrip())
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
# ─── Form Data ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_field(name, default=''):
|
||||||
|
"""Read submitted form field from environment."""
|
||||||
|
return os.environ.get(f'FIELD_{name}', default)
|
||||||
|
|
||||||
|
def get_param(name, default=''):
|
||||||
|
"""Read URL parameter."""
|
||||||
|
return os.environ.get(f'PARAM_{name}',
|
||||||
|
os.environ.get(f'var_{name}', default))
|
||||||
|
|
||||||
|
# ─── Data Sources ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def shell(cmd):
|
||||||
|
"""Execute shell command, return stdout."""
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(
|
||||||
|
cmd, shell=True, timeout=5
|
||||||
|
).decode().strip()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# ─── Resolve Sources ─────────────────────────────────────────
|
||||||
|
|
||||||
|
cpu_pct = int(shell(
|
||||||
|
"grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
||||||
|
) or 0)
|
||||||
|
mem_pct = int(shell(
|
||||||
|
"free | awk '/Mem/{print int($3/$2*100)}'"
|
||||||
|
) or 0)
|
||||||
|
uptime_str = shell("uptime -p")
|
||||||
|
peer_count = shell("rnstatus -j 2>/dev/null | python3 -c "
|
||||||
|
"'import sys,json; print(len(json.load(sys.stdin).get(\"peers\",[])))'")
|
||||||
|
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||||
|
|
||||||
|
# ─── Build Grid & Render ────────────────────────────────────
|
||||||
|
|
||||||
|
grid = CharGrid(66, 40)
|
||||||
|
|
||||||
|
# ... all the box(), gauge(), sparkline(), text() calls
|
||||||
|
# ... exactly as the layout engine would produce them ...
|
||||||
|
|
||||||
|
# ─── Output ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print('#!c=0') # cache header: never cache
|
||||||
|
print(grid.emit_micron())
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 CLI usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compile to dynamic executable .mu
|
||||||
|
uframe compile dashboard.uf --out dashboard.mu
|
||||||
|
chmod +x dashboard.mu
|
||||||
|
|
||||||
|
# Compile and deploy in one step
|
||||||
|
uframe deploy dashboard.uf
|
||||||
|
# → renders, sets +x, copies to ~/.nomadnetwork/storage/pages/
|
||||||
|
|
||||||
|
# Compile with embedded vs. imported runtime
|
||||||
|
uframe compile dashboard.uf --embed # single self-contained file
|
||||||
|
uframe compile dashboard.uf --import # requires uframe_runtime.py on node
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Compilation modes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ µFrame Compilation Modes │
|
||||||
|
├────────────────┬───────────────┬───────────────────────────────┤
|
||||||
|
│ uframe render │ uframe render │ uframe compile │
|
||||||
|
│ --ascii │ --micron │ │
|
||||||
|
├────────────────┼───────────────┼───────────────────────────────┤
|
||||||
|
│ Static ASCII │ Static .mu │ Executable .mu (Python) │
|
||||||
|
│ to stdout │ file │ file with +x │
|
||||||
|
├────────────────┼───────────────┼───────────────────────────────┤
|
||||||
|
│ All values │ All values │ source{} values fetched │
|
||||||
|
│ resolved at │ resolved at │ at request time │
|
||||||
|
│ render time │ render time │ │
|
||||||
|
│ │ │ Form fields become live │
|
||||||
|
│ │ │ on_submit{} blocks active │
|
||||||
|
│ │ │ if/for evaluated per request │
|
||||||
|
│ │ │ State persists across visits │
|
||||||
|
└────────────────┴───────────────┴───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Complete Dynamic Example
|
||||||
|
|
||||||
|
### 5.1 Source — `search_node.uf`
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Node Search" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||||
|
source peer_count : shell "rnstatus 2>/dev/null | grep -c 'Peer'"
|
||||||
|
state "history" "/var/nomadnet/search_history.json"
|
||||||
|
|
||||||
|
box double "Node Search"
|
||||||
|
align center
|
||||||
|
text "Find peers and pages on the Reticulum mesh"
|
||||||
|
text "@italic{$peer_count peers reachable · updated $timestamp}"
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
form "search"
|
||||||
|
field "query" 30 "Search term..."
|
||||||
|
radio "type" "Nodes" | "Pages" | "Files"
|
||||||
|
checkbox "cache" "Include cached results"
|
||||||
|
button "Search" "/page/search_node.mu"
|
||||||
|
|
||||||
|
on_submit "search"
|
||||||
|
# Log the search
|
||||||
|
append history.queries { q: $query, type: $type, time: $timestamp }
|
||||||
|
|
||||||
|
source results : shell "mesh_search.py '$query' --type '$type'"
|
||||||
|
source result_count : python "len('''$results'''.strip().splitlines())"
|
||||||
|
|
||||||
|
divider light
|
||||||
|
|
||||||
|
heading 2 "Results for \"$query\" ($result_count found)"
|
||||||
|
|
||||||
|
if $result_count > 0
|
||||||
|
for line in $results
|
||||||
|
source parts : python "'''$line'''.split('|')"
|
||||||
|
row 1
|
||||||
|
col 28
|
||||||
|
link "$parts.0" "/page/detail.mu?hash=$parts.1"
|
||||||
|
col 8
|
||||||
|
text "$parts.2"
|
||||||
|
col 10
|
||||||
|
status "$parts.0" $parts.3
|
||||||
|
else
|
||||||
|
spacer
|
||||||
|
text "@center{@color{ff0}{No results found for \"$query\"}}"
|
||||||
|
spacer
|
||||||
|
|
||||||
|
divider light
|
||||||
|
heading 3 "Recent Searches"
|
||||||
|
|
||||||
|
for entry in $history.queries[-5:]
|
||||||
|
text " $entry.time $entry.q ($entry.type)"
|
||||||
|
|
||||||
|
divider heavy
|
||||||
|
text "@center{@italic{Relay Alpha-7 · $timestamp}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 What the client sees
|
||||||
|
|
||||||
|
**Before submission** (form is empty):
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══ Node Search ═══════════════════════════════════════════════╗
|
||||||
|
║ Find peers and pages on the Reticulum mesh ║
|
||||||
|
║ 7 peers reachable · updated 14:32:07 ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
Search: [ Search term...___________________ ]
|
||||||
|
Type: (•) Nodes ( ) Pages ( ) Files
|
||||||
|
[ ] Include cached results
|
||||||
|
|
||||||
|
`[`!Search`!`:/page/search_node.mu]
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Relay Alpha-7 · 14:32:07
|
||||||
|
```
|
||||||
|
|
||||||
|
**After submitting "relay"** (script re-executes with FIELD_query=relay):
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══ Node Search ═══════════════════════════════════════════════╗
|
||||||
|
║ Find peers and pages on the Reticulum mesh ║
|
||||||
|
║ 7 peers reachable · updated 14:32:15 ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
Search: [ relay__________________________ ]
|
||||||
|
Type: (•) Nodes ( ) Pages ( ) Files
|
||||||
|
[ ] Include cached results
|
||||||
|
|
||||||
|
`[`!Search`!`:/page/search_node.mu]
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
>> Results for "relay" (3 found)
|
||||||
|
|
||||||
|
`F0cfRelay-East`f 2 hops `F0f0●`f online
|
||||||
|
`F0cfRelay-South`f 4 hops `F0f0●`f online
|
||||||
|
`F0cfRelay-Backup`f 6 hops `Fff0◐`f degraded
|
||||||
|
|
||||||
|
──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
>>> Recent Searches
|
||||||
|
14:32:15 relay (Nodes)
|
||||||
|
14:28:44 bridge (Pages)
|
||||||
|
14:25:01 firmware (Files)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Relay Alpha-7 · 14:32:15
|
||||||
|
```
|
||||||
|
|
||||||
|
The form fields retain submitted values, results appear below,
|
||||||
|
and the search history persists across visits via the state file.
|
||||||
|
Every piece of box-drawing, every colored indicator, every braille
|
||||||
|
sparkline renders identically in both ASCII preview and live Micron.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dynamic Patterns — A Cookbook
|
||||||
|
|
||||||
|
### 6.1 Live Dashboard (auto-refresh via cache=0)
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Status" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
source cpu : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'"
|
||||||
|
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
||||||
|
source net_in : shell "net_traffic.sh in"
|
||||||
|
source net_out : shell "net_traffic.sh out"
|
||||||
|
source net_history_in : shell "net_spark.sh in 20"
|
||||||
|
source net_history_out : shell "net_spark.sh out 20"
|
||||||
|
|
||||||
|
box heavy "System Status"
|
||||||
|
row 2
|
||||||
|
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
||||||
|
gauge "MEM" $mem 100 28 warn=80 crit=95
|
||||||
|
spacer
|
||||||
|
label "IN" "$net_in KB/s"
|
||||||
|
sparkline "IN" $net_history_in 28
|
||||||
|
label "OUT" "$net_out KB/s"
|
||||||
|
sparkline "OUT" $net_history_out 28
|
||||||
|
|
||||||
|
text "@center{@italic{Press Ctrl+R to refresh}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Client hits the page → script runs → reads `/proc` → renders
|
||||||
|
gauges and sparklines with real data → client sees it.
|
||||||
|
Ctrl+R re-requests → fresh execution → updated values.
|
||||||
|
|
||||||
|
### 6.2 Guestbook with Persistent State
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Guestbook" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
state "gb" "/var/nomadnet/guestbook.json"
|
||||||
|
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
|
||||||
|
|
||||||
|
heading 1 "Guestbook"
|
||||||
|
|
||||||
|
form "sign"
|
||||||
|
field "name" 20 "Your name"
|
||||||
|
field "msg" 40 "Leave a message..."
|
||||||
|
button "Sign" "/page/guestbook.mu"
|
||||||
|
|
||||||
|
on_submit "sign"
|
||||||
|
if $name && $msg
|
||||||
|
prepend gb.entries { name: $name, msg: $msg, time: $timestamp }
|
||||||
|
text "@color{0f0}{✓ Thanks, $name!}"
|
||||||
|
|
||||||
|
divider light
|
||||||
|
|
||||||
|
for entry in $gb.entries[:20]
|
||||||
|
box rounded
|
||||||
|
text "@bold{$entry.name} @italic{@color{888}{$entry.time}}"
|
||||||
|
text "$entry.msg"
|
||||||
|
spacer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Multi-Page Wizard with Navigation
|
||||||
|
|
||||||
|
```
|
||||||
|
# Page 1: setup.mu
|
||||||
|
page "Setup Wizard — Step 1" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
heading 1 "Network Configuration"
|
||||||
|
form "net"
|
||||||
|
field "interface" 20 "eth0"
|
||||||
|
radio "mode" "Auto" | "Manual" | "Mesh Only"
|
||||||
|
button "Next →" "/page/setup_2.mu"
|
||||||
|
|
||||||
|
# Page 2: setup_2.mu
|
||||||
|
page "Setup Wizard — Step 2" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
source iface : param "interface" # or field from previous page
|
||||||
|
source mode : param "mode"
|
||||||
|
|
||||||
|
heading 1 "Confirm Settings"
|
||||||
|
label "Interface" "$iface"
|
||||||
|
label "Mode" "$mode"
|
||||||
|
|
||||||
|
form "confirm"
|
||||||
|
checkbox "apply_now" "Apply immediately"
|
||||||
|
button "← Back" "/page/setup.mu"
|
||||||
|
button "Finish ✓" "/page/setup_done.mu?interface=$iface&mode=$mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Chat Room (community pattern)
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Chat" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
state "chat" "/var/nomadnet/chatlog.json"
|
||||||
|
source timestamp : python "datetime.now().strftime('%H:%M')"
|
||||||
|
|
||||||
|
heading 1 "Node Chat"
|
||||||
|
|
||||||
|
# Display last 15 messages
|
||||||
|
for msg in $chat.messages[-15:]
|
||||||
|
text "@bold{@color{$msg.color}{$msg.nick}} @color{888}{$msg.time}"
|
||||||
|
text " $msg.text"
|
||||||
|
|
||||||
|
divider light
|
||||||
|
|
||||||
|
form "send"
|
||||||
|
field "nick" 12 "Nickname"
|
||||||
|
field "text" 40 "Type message..."
|
||||||
|
button "Send" "/page/chat.mu"
|
||||||
|
|
||||||
|
on_submit "send"
|
||||||
|
if $nick && $text
|
||||||
|
source color : python "format(hash('$nick')%4095,'03x')"
|
||||||
|
append chat.messages { nick: $nick, text: $text, time: $timestamp, color: $color }
|
||||||
|
|
||||||
|
text "@center{@italic{@color{888}{Ctrl+R to refresh · $chat.messages.length messages}}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Interactive Data Explorer
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Peer Explorer" 64
|
||||||
|
cache 0
|
||||||
|
|
||||||
|
source peers_json : shell "rnstatus --json 2>/dev/null"
|
||||||
|
source selected : param "hash"
|
||||||
|
|
||||||
|
heading 1 "Peer Explorer"
|
||||||
|
|
||||||
|
table "Peers"
|
||||||
|
columns "Name" 24 | "Hops" 6 | "RTT" 8 | "Status" 10
|
||||||
|
for peer in $peers_json.peers
|
||||||
|
row "$peer.name" | "$peer.hops" | "$peer.rtt" | "$peer.status"
|
||||||
|
|
||||||
|
if $selected
|
||||||
|
divider heavy
|
||||||
|
source detail : shell "rnstatus --peer $selected --json 2>/dev/null"
|
||||||
|
|
||||||
|
box double "Peer Detail: $detail.name"
|
||||||
|
label "Hash" "$detail.hash"
|
||||||
|
label "Address" "$detail.address"
|
||||||
|
label "Hops" "$detail.hops"
|
||||||
|
label "Latency" "$detail.rtt"
|
||||||
|
label "Last Seen" "$detail.last_seen"
|
||||||
|
label "Transport" "$detail.transport"
|
||||||
|
|
||||||
|
sparkline "Latency (24h)" $detail.latency_history 40
|
||||||
|
|
||||||
|
row 2
|
||||||
|
link "Ping" "/page/action.mu?cmd=ping&hash=$detail.hash"
|
||||||
|
link "Trace" "/page/action.mu?cmd=trace&hash=$detail.hash"
|
||||||
|
link "Browse" "$detail.hash:/page/index.mu"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security Considerations
|
||||||
|
|
||||||
|
Dynamic pages execute code on the node server. µFrame enforces:
|
||||||
|
|
||||||
|
- **Shell command sanitization**: all `$variable` values interpolated
|
||||||
|
into shell commands are escaped with `shlex.quote()` to prevent
|
||||||
|
injection
|
||||||
|
- **State file isolation**: state files are restricted to a
|
||||||
|
configurable directory (default: `/var/nomadnet/uframe/`)
|
||||||
|
- **Execution timeout**: all shell sources have a default 5-second
|
||||||
|
timeout, configurable per source
|
||||||
|
- **No network egress by default**: source commands run in the
|
||||||
|
node's local context — they can read local system data but
|
||||||
|
µFrame does not add network capabilities beyond what the
|
||||||
|
scripts themselves invoke
|
||||||
|
- **Input validation**: field values are length-limited and
|
||||||
|
sanitized before use in sources or state operations
|
||||||
|
|
||||||
|
```
|
||||||
|
# In the DSL, explicit sanitization:
|
||||||
|
source result : shell "search.py" --arg $query --sanitize
|
||||||
|
timeout 10
|
||||||
|
max_length 256
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Architecture Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ .uf Source │
|
||||||
|
│ (layout + sources + forms │
|
||||||
|
│ + conditionals + state) │
|
||||||
|
└──────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
┌────▼────┐
|
||||||
|
│ Parse │
|
||||||
|
└────┬────┘
|
||||||
|
│
|
||||||
|
┌────▼────┐
|
||||||
|
│ IR │
|
||||||
|
│ Tree │
|
||||||
|
└──┬───┬──┘
|
||||||
|
│ │
|
||||||
|
┌──────────────┘ └────────────────┐
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ ┌───────▼───────┐
|
||||||
|
│ render mode │ │ compile mode │
|
||||||
|
│ (immediate) │ │ (codegen) │
|
||||||
|
└──┬───────┬──┘ └───────┬───────┘
|
||||||
|
│ │ │
|
||||||
|
┌──────▼┐ ┌───▼─────┐ ┌───────▼────────┐
|
||||||
|
│ ASCII │ │ Static │ │ Executable .mu │
|
||||||
|
│ stdout│ │ .mu file│ │ Python script │
|
||||||
|
└───────┘ └─────────┘ │ with embedded │
|
||||||
|
│ runtime + │
|
||||||
|
│ data sources + │
|
||||||
|
│ form handling │
|
||||||
|
└───────┬────────┘
|
||||||
|
│
|
||||||
|
chmod +x
|
||||||
|
deploy to
|
||||||
|
│
|
||||||
|
┌─────────▼─────────┐
|
||||||
|
│ NomadNet Node │
|
||||||
|
│ ~/.nomadnetwork/ │
|
||||||
|
│ storage/pages/ │
|
||||||
|
│ │
|
||||||
|
│ Client request → │
|
||||||
|
│ Execute script → │
|
||||||
|
│ Stdout = Micron │
|
||||||
|
│ with live data, │
|
||||||
|
│ rich ASCII art, │
|
||||||
|
│ color + forms │
|
||||||
|
└───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The dynamic model turns µFrame from a static template engine into a
|
||||||
|
**full application framework for NomadNet** — where the same DSL that
|
||||||
|
defines the visual layout also defines the data flow, user interaction,
|
||||||
|
and server-side logic. The ASCII art isn't decoration — it's the UI
|
||||||
|
of a live, interactive, decentralized application running over
|
||||||
|
encrypted mesh networks.
|
||||||
889
docs/framework-design-v3.md
Normal file
889
docs/framework-design-v3.md
Normal file
@@ -0,0 +1,889 @@
|
|||||||
|
# µFrame — A DSL for Rich Terminal UIs rendered as ASCII and Micron
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**µFrame** is a declarative DSL that compiles to rich terminal
|
||||||
|
interfaces built from Unicode box-drawing, block elements, braille
|
||||||
|
patterns, and careful spatial layout. It produces two outputs from
|
||||||
|
the same source:
|
||||||
|
|
||||||
|
1. **Plain ASCII** — the raw visual layout, viewable in any terminal
|
||||||
|
2. **Micron `.mu`** — the same visual layout enhanced with Micron's
|
||||||
|
color, styling, links, and interactive form fields
|
||||||
|
|
||||||
|
Both outputs share the same rich character art. Micron doesn't
|
||||||
|
degrade the visuals — it *elevates* them. The ASCII art passes
|
||||||
|
through verbatim into the `.mu` file, and Micron tags wrap it
|
||||||
|
with color, emphasis, alignment, and interactivity that plain
|
||||||
|
text cannot express.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
┌───▶│ Plain ASCII │
|
||||||
|
│ │ Box drawing, braille, blocks │
|
||||||
|
│ │ No color, no links, no forms │
|
||||||
|
.uf ──▶ Parser ──▶ IR ─┤ └──────────────────────────────────┘
|
||||||
|
│ ┌──────────────────────────────────┐
|
||||||
|
└───▶│ Micron .mu │
|
||||||
|
│ Same visual base │
|
||||||
|
│ + `Fhex color`f │
|
||||||
|
│ + `!bold`! `*italic`* │
|
||||||
|
│ + `[links`/dest] │
|
||||||
|
│ + `<form fields`> │
|
||||||
|
│ + `c alignment`a │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The relationship is additive:
|
||||||
|
|
||||||
|
```
|
||||||
|
Plain ASCII = layout + structure + data viz
|
||||||
|
Micron = layout + structure + data viz + color + style + interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Philosophy
|
||||||
|
|
||||||
|
### The terminal is the canvas — in both modes
|
||||||
|
|
||||||
|
A NomadNet node browser is a terminal. A local shell is a terminal.
|
||||||
|
The character grid is the shared substrate. Unicode box-drawing,
|
||||||
|
block elements, and braille dots render identically in both
|
||||||
|
contexts. µFrame exploits this fully:
|
||||||
|
|
||||||
|
- **Box drawing** (`┌─┐│└┘`) creates bordered panels, tables,
|
||||||
|
nested layouts — same characters in ASCII and Micron
|
||||||
|
- **Block elements** (`█▉▊▋▌▍▎▏░▒▓`) build bar charts, gauges,
|
||||||
|
heatmaps — passed through as literal text in Micron
|
||||||
|
- **Braille** (`⠀`–`⣿`, 256 patterns) gives 2×4 sub-cell resolution
|
||||||
|
for sparklines and dot plots — just text, works everywhere
|
||||||
|
- **Micron then paints on top**: colored bars, highlighted thresholds,
|
||||||
|
bold headers, clickable links, interactive form fields
|
||||||
|
|
||||||
|
### What Micron adds beyond ASCII
|
||||||
|
|
||||||
|
Micron's tag system maps perfectly onto the styling layer that
|
||||||
|
plain ASCII lacks:
|
||||||
|
|
||||||
|
| Capability | Plain ASCII | Micron |
|
||||||
|
|--------------------|---------------------|---------------------------------------|
|
||||||
|
| Borders & boxes | ✓ box-drawing chars | ✓ same chars + colored with `Fhex` |
|
||||||
|
| Bar charts | ✓ block elements | ✓ same blocks + colored thresholds |
|
||||||
|
| Sparklines | ✓ braille dots | ✓ same braille + colored |
|
||||||
|
| Status indicators | ✓ ● ○ ◐ chars | ✓ same chars + `F0f0` green/red |
|
||||||
|
| Table data | ✓ monospace align | ✓ same alignment + bold headers |
|
||||||
|
| Emphasis | ✗ (no mechanism) | ✓ `!bold`! `*italic`* `_underline`_ |
|
||||||
|
| Color | ✗ (no mechanism)* | ✓ `Fhex text`f / `Bhex text`b |
|
||||||
|
| Alignment | manual spacing | ✓ `c center`a / `r right`a |
|
||||||
|
| Links | ✗ display only | ✓ `[click here`/page.mu] |
|
||||||
|
| Text input | ✗ display only | ✓ `<name`placeholder> |
|
||||||
|
| Radio / checkbox | ✗ visual only | ✓ `<^|group|val`label> `<?|..`label> |
|
||||||
|
| Headings | manual styling | ✓ `>` / `>>` / `>>>` with styling |
|
||||||
|
|
||||||
|
*ASCII mode can optionally emit ANSI escape codes with `--ansi`,
|
||||||
|
but the default is pure text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The Rendering Model
|
||||||
|
|
||||||
|
Both renderers share a common **CharGrid** — a 2D matrix of
|
||||||
|
characters that represents the visual layout. They diverge only
|
||||||
|
in the final emission step.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐
|
||||||
|
.uf ──▶ Parse ──▶│ IR Tree │
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
┌────▼─────┐
|
||||||
|
│ Layout │ ◀── width resolution, row splits,
|
||||||
|
│ Engine │ border merging, chart rendering
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
┌────▼─────┐
|
||||||
|
│ CharGrid │ ◀── 2D array of (char, style) pairs
|
||||||
|
│ + Styles │ style = {fg, bg, bold, italic,
|
||||||
|
└──┬────┬──┘ underline, link, field_meta}
|
||||||
|
│ │
|
||||||
|
┌───────▼┐ ┌▼─────────┐
|
||||||
|
│ ASCII │ │ Micron │
|
||||||
|
│ Emitter │ │ Emitter │
|
||||||
|
└─────────┘ └──────────┘
|
||||||
|
chars only chars + tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### The CharGrid
|
||||||
|
|
||||||
|
Every cell in the grid stores:
|
||||||
|
|
||||||
|
```
|
||||||
|
Cell:
|
||||||
|
char : string # the visible character (e.g. "█", "┌", "⣿")
|
||||||
|
fg : string|null # foreground color, 3-digit hex
|
||||||
|
bg : string|null # background color, 3-digit hex
|
||||||
|
bold : bool
|
||||||
|
italic : bool
|
||||||
|
underline : bool
|
||||||
|
link : string|null # destination path for clickable cells
|
||||||
|
field : FieldMeta|null # form field metadata for interactive cells
|
||||||
|
```
|
||||||
|
|
||||||
|
### ASCII Emitter
|
||||||
|
|
||||||
|
Reads only `cell.char` from each cell. Produces a plain text file.
|
||||||
|
With `--ansi`, reads `fg`, `bg`, `bold`, `italic`, `underline`
|
||||||
|
and emits ANSI escape codes.
|
||||||
|
|
||||||
|
### Micron Emitter
|
||||||
|
|
||||||
|
Reads every cell property. Scans each line left-to-right, tracks
|
||||||
|
style state, and opens/closes Micron tags at style transitions:
|
||||||
|
|
||||||
|
```
|
||||||
|
Line scan: ┌── gauge "CPU" ──────────┐
|
||||||
|
Chars: C P U █ █ █ ░ ░ 6 2 %
|
||||||
|
Styles: bold fg:0f0 fg:f00
|
||||||
|
↓ ↓
|
||||||
|
Micron: `!CPU`! `F0f0███`f`Ff00░░`f `Ff0062%`f
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the exact same box-drawing layout appears in both
|
||||||
|
outputs. The Micron version simply has color and emphasis tags
|
||||||
|
woven between the same characters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Visual Primitives — The Shared Toolkit
|
||||||
|
|
||||||
|
Everything below renders identically in both ASCII and Micron.
|
||||||
|
The Micron output adds color/style annotations on top.
|
||||||
|
|
||||||
|
### 3.1 Box Drawing
|
||||||
|
|
||||||
|
Four border weights:
|
||||||
|
|
||||||
|
```
|
||||||
|
Light Heavy Double Rounded
|
||||||
|
┌──────┐ ┏━━━━━━┓ ╔══════╗ ╭──────╮
|
||||||
|
│ │ ┃ ┃ ║ ║ │ │
|
||||||
|
└──────┘ ┗━━━━━━┛ ╚══════╝ ╰──────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
Nested boxes with automatic junction merging:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┬──────────┐
|
||||||
|
│ Left Panel │ Right │
|
||||||
|
│ │ │
|
||||||
|
├─────────────────┴──────────┤
|
||||||
|
│ Footer spans full width │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Titled boxes (title inlined in top border):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ System Health ─────────────────────────┐
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┏━ ALERT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||||
|
┃ ┃
|
||||||
|
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||||
|
```
|
||||||
|
|
||||||
|
In Micron, the border characters are plain text. The title
|
||||||
|
can be wrapped in `!bold`! and `Fhex color`f tags.
|
||||||
|
|
||||||
|
### 3.2 Data Visualization
|
||||||
|
|
||||||
|
**Horizontal bars** — block elements with optional color thresholds:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII: CPU ████████████████████░░░░░░░░░░ 62%
|
||||||
|
|
||||||
|
Micron: CPU `F0f0████████████████████`f`F333░░░░░░░░░░`f 62%
|
||||||
|
↑ green fill ↑ dim empty
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gauges with thresholds** — color shifts at warn/crit boundaries:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII: MEM █████████████████████████████░ 93% ⚠
|
||||||
|
|
||||||
|
Micron: MEM `Ff00█████████████████████████████`f░ `Ff00 93% ⚠`f
|
||||||
|
↑ red because value > crit threshold
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vertical bar charts**:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII: Micron adds color per bar:
|
||||||
|
|
||||||
|
█ `F08f█`f
|
||||||
|
█ █ `F08f█`f `F0f0█`f
|
||||||
|
█ █ █ █ `F08f█`f `Fff0█`f `F0f0█`f `Fff0█`f
|
||||||
|
█ █ █ █ █ █ `F08f█`f `Fff0█`f █ `F0f0█`f █ `Fff0█`f
|
||||||
|
█ █ █ █ █ █ █ █ ...
|
||||||
|
─────────────────
|
||||||
|
0 3 6 9 12 15
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sparklines** — braille characters for inline time series:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII: NET ⣀⣤⣶⣿⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤⣀ avg 31%
|
||||||
|
|
||||||
|
Micron: NET `F0ff⣀⣤⣶⣿⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤⣀`f avg 31%
|
||||||
|
↑ cyan sparkline
|
||||||
|
```
|
||||||
|
|
||||||
|
**Heatmap** — using shade blocks with per-cell color:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mon `F0f0░`f`F0f0░`f`F4f0▒`f`F8f0▓`f`Fff0█`f`F8f0▓`f`F4f0▒`f
|
||||||
|
Tue `F4f0▒`f`F8f0▓`f`Fff0█`f`Fff0█`f`Fff0█`f`F8f0▓`f`F4f0▒`f
|
||||||
|
Wed `F0f0░`f`F0f0░`f`F0f0░`f`F4f0▒`f`F4f0▒`f`F0f0░`f`F0f0░`f
|
||||||
|
0 4 8 12 16 20 24
|
||||||
|
```
|
||||||
|
|
||||||
|
In plain ASCII, same characters, no color — the shade density
|
||||||
|
still communicates intensity.
|
||||||
|
|
||||||
|
**Status indicators** — colored in Micron, shape-coded in ASCII:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII: ● Online ○ Offline ◐ Degraded ◌ Unknown
|
||||||
|
|
||||||
|
Micron: `F0f0●`f Online `Ff00○`f Offline `Fff0◐`f Degraded
|
||||||
|
```
|
||||||
|
|
||||||
|
Both modes are readable — color adds clarity but shape carries
|
||||||
|
the information alone.
|
||||||
|
|
||||||
|
### 3.3 Tables
|
||||||
|
|
||||||
|
Tables use box-drawing for structure. Identical in both outputs.
|
||||||
|
Micron adds bold headers and colored status cells:
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII:
|
||||||
|
┌──────────────────────┬──────┬─────────┬──────────┐
|
||||||
|
│ Destination │ Hops │ Latency │ Status │
|
||||||
|
├──────────────────────┼──────┼─────────┼──────────┤
|
||||||
|
│ a7f2::relay-east │ 2 │ 34ms │ ● alive │
|
||||||
|
│ c4e1::bridge-south │ 4 │ 112ms │ ● alive │
|
||||||
|
│ 01ab::node-gamma │ 7 │ 580ms │ ○ stale │
|
||||||
|
└──────────────────────┴──────┴─────────┴──────────┘
|
||||||
|
|
||||||
|
Micron:
|
||||||
|
┌──────────────────────┬──────┬─────────┬──────────┐
|
||||||
|
│ `!Destination`! │`!Hops`!│`!Latency`!│`!Status`!│
|
||||||
|
├──────────────────────┼──────┼─────────┼──────────┤
|
||||||
|
│ a7f2::relay-east │ 2 │ 34ms │ `F0f0●`f alive │
|
||||||
|
│ c4e1::bridge-south │ 4 │ 112ms │ `F0f0●`f alive │
|
||||||
|
│ 01ab::node-gamma │ 7 │ 580ms │ `Ff00○`f stale │
|
||||||
|
└──────────────────────┴──────┴─────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Form Elements
|
||||||
|
|
||||||
|
In ASCII, forms are visual representations. In Micron, the same
|
||||||
|
characters appear but the input areas become live interactive
|
||||||
|
fields.
|
||||||
|
|
||||||
|
```
|
||||||
|
ASCII (visual only):
|
||||||
|
┌─ Search ──────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Query: [ _________________________ ] │
|
||||||
|
│ │
|
||||||
|
│ Scope: (•) Local ( ) Network │
|
||||||
|
│ [ ] Include offline nodes │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ │
|
||||||
|
│ │ Search │ │
|
||||||
|
│ └──────────┘ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
|
||||||
|
Micron (interactive):
|
||||||
|
┌─ `!Search`! ──────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Query: `<32|query`Enter search...> │
|
||||||
|
│ │
|
||||||
|
│ Scope: `<^|scope|local|*`Local> `<^|scope|net`Network>
|
||||||
|
│ `<?|offline|yes`Include offline nodes>
|
||||||
|
│ │
|
||||||
|
│ `[`!Search`!`:/action/search]
|
||||||
|
│ │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The border characters are identical. Micron replaces the
|
||||||
|
placeholder bracket notation with live form tags and turns
|
||||||
|
the button into a clickable link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The DSL — `.uf` Files
|
||||||
|
|
||||||
|
### 4.1 Syntax
|
||||||
|
|
||||||
|
- **Indentation** defines nesting (2-space)
|
||||||
|
- **Keywords** lead each line
|
||||||
|
- **Strings** in double quotes
|
||||||
|
- **Pipe `|`** separates inline list items
|
||||||
|
- **`$`** references variables
|
||||||
|
- **`#`** starts comments
|
||||||
|
|
||||||
|
### 4.2 Layout Primitives
|
||||||
|
|
||||||
|
```
|
||||||
|
page "Title" [width]
|
||||||
|
# Root container. Default width: 64.
|
||||||
|
|
||||||
|
box [weight] "Title"
|
||||||
|
# Bordered region. weight: light|heavy|double|rounded
|
||||||
|
# Title is inset in the top border.
|
||||||
|
# In Micron: title gets `!bold`!, border chars are literal.
|
||||||
|
|
||||||
|
row [gap]
|
||||||
|
# Horizontal layout. Children split available width.
|
||||||
|
# gap: chars between children (default: 1)
|
||||||
|
|
||||||
|
col [width]
|
||||||
|
# Explicit column in a row. Width in chars or percentage.
|
||||||
|
|
||||||
|
spacer [lines]
|
||||||
|
# Vertical whitespace. Default: 1
|
||||||
|
|
||||||
|
pad [top] [right] [bottom] [left]
|
||||||
|
# Inner margin for a container.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Content Primitives
|
||||||
|
|
||||||
|
```
|
||||||
|
heading [1|2|3] "Text"
|
||||||
|
# Rendered with underline/box in ASCII.
|
||||||
|
# In Micron: > / >> / >>> plus `!bold`!
|
||||||
|
|
||||||
|
text "Content with @bold{inline} @color{0f0}{modifiers}"
|
||||||
|
# @bold{...} → Micron `!...`!
|
||||||
|
# @italic{...} → Micron `*...*`
|
||||||
|
# @under{...} → Micron `_..._`
|
||||||
|
# @color{hex}{...} → Micron `Fhex...`f
|
||||||
|
# @bg{hex}{...} → Micron `Bhex...`b
|
||||||
|
# In ASCII: modifiers stripped (or ANSI with --ansi)
|
||||||
|
|
||||||
|
label "Key" "Value"
|
||||||
|
# Aligned key-value pair.
|
||||||
|
|
||||||
|
list [bullet|number|dash|arrow]
|
||||||
|
item "First"
|
||||||
|
item "Second"
|
||||||
|
|
||||||
|
link "Display text" "/destination.mu"
|
||||||
|
# ASCII: [Display text]
|
||||||
|
# Micron: `[Display text`/destination.mu]
|
||||||
|
|
||||||
|
divider [light|heavy|double|dash|dot]
|
||||||
|
# Full-width horizontal rule using appropriate chars.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Data Visualization Primitives
|
||||||
|
|
||||||
|
```
|
||||||
|
gauge "Label" [value] [max] [width]
|
||||||
|
# Horizontal progress bar.
|
||||||
|
# Thresholds: warn=[n] crit=[n]
|
||||||
|
# ASCII: Label ████████████░░░░ 62%
|
||||||
|
# Micron: same, with color shifts at thresholds
|
||||||
|
|
||||||
|
meter "Label" [value] [max]
|
||||||
|
# Compact inline gauge (no border, just bar + %)
|
||||||
|
|
||||||
|
bar_h "Label" [value] [max] [width]
|
||||||
|
# Single horizontal bar in a chart context.
|
||||||
|
|
||||||
|
bar_v [height]
|
||||||
|
# Vertical bar chart container.
|
||||||
|
bar "Label" [value]
|
||||||
|
bar "Label" [value]
|
||||||
|
# Uses ▁▂▃▄▅▆▇█ stacked vertically.
|
||||||
|
|
||||||
|
sparkline "Label" [values] [width]
|
||||||
|
# Braille-dot inline chart.
|
||||||
|
# values: comma-separated or $variable
|
||||||
|
|
||||||
|
heatmap [rows] [cols]
|
||||||
|
# Grid of colored shade blocks.
|
||||||
|
# Uses ░▒▓█ for intensity.
|
||||||
|
# Micron adds per-cell `Fhex` color.
|
||||||
|
|
||||||
|
status "Label" [online|offline|degraded|unknown|alert]
|
||||||
|
# Shape-coded indicator + label.
|
||||||
|
# Micron adds color to the indicator.
|
||||||
|
|
||||||
|
table "Title"
|
||||||
|
columns "Name" [width] | "Name" [width] | ...
|
||||||
|
row "val" | "val" | ...
|
||||||
|
# Box-drawn table with header separator.
|
||||||
|
# Micron: bold headers, colored cells via @modifiers in values.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Form Primitives
|
||||||
|
|
||||||
|
```
|
||||||
|
form "name"
|
||||||
|
field "name" [width] "placeholder"
|
||||||
|
password "name" [width] "placeholder"
|
||||||
|
radio "group" "Opt A" | "Opt B" | "Opt C"
|
||||||
|
checkbox "name" "Label"
|
||||||
|
toggle "name" "Label" [on|off]
|
||||||
|
dropdown "name" "Opt A" | "Opt B" | "Opt C"
|
||||||
|
button "Label" ["/action/path"]
|
||||||
|
|
||||||
|
# ASCII: visual placeholders (brackets, radio dots, etc.)
|
||||||
|
# Micron: live interactive fields using native form tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Style Modifiers
|
||||||
|
|
||||||
|
Applied as indented children of any node:
|
||||||
|
|
||||||
|
```
|
||||||
|
align [left|center|right]
|
||||||
|
color [3-digit hex]
|
||||||
|
bg [3-digit hex]
|
||||||
|
border [light|heavy|double|rounded|none]
|
||||||
|
bold
|
||||||
|
italic
|
||||||
|
underline
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Variables & Components
|
||||||
|
|
||||||
|
```
|
||||||
|
# Variables
|
||||||
|
let name = "Relay Alpha-7"
|
||||||
|
let cpu_data = 42, 67, 55, 78, 91, 63, 48
|
||||||
|
|
||||||
|
# Component definition
|
||||||
|
component stat(label, value, max, trend)
|
||||||
|
box light "$label"
|
||||||
|
gauge "$label" $value $max 20
|
||||||
|
text "@italic{$trend}"
|
||||||
|
|
||||||
|
# Component usage
|
||||||
|
row 2
|
||||||
|
stat "CPU" 62 100 "▲ +5%"
|
||||||
|
stat "MEM" 84 100 "▼ -2%"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Complete Example
|
||||||
|
|
||||||
|
### 5.1 Source
|
||||||
|
|
||||||
|
```
|
||||||
|
let node = "Relay Alpha-7"
|
||||||
|
let uptime = "14d 3h 22m"
|
||||||
|
|
||||||
|
page "$node" 66
|
||||||
|
|
||||||
|
box double "$node"
|
||||||
|
align center
|
||||||
|
text "Reticulum Network Node"
|
||||||
|
text "Online $uptime"
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 1 "Resources"
|
||||||
|
|
||||||
|
row 2
|
||||||
|
col
|
||||||
|
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||||
|
gauge "GPU" 21 100 28
|
||||||
|
col
|
||||||
|
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||||
|
gauge "SWP" 3 100 28
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 1 "Network"
|
||||||
|
|
||||||
|
row 2
|
||||||
|
col 40
|
||||||
|
text "Traffic (60s)"
|
||||||
|
sparkline "IN" 1,3,5,8,7,5,3,2,1,3,6,8,7,4 20
|
||||||
|
sparkline "OUT" 2,2,3,5,8,7,5,3,2,1,1,3,5,8 20
|
||||||
|
col
|
||||||
|
label "Peers" "7 / 12"
|
||||||
|
status "East Relay" online
|
||||||
|
status "South Bridge" online
|
||||||
|
status "Node Gamma" degraded
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 1 "Routing"
|
||||||
|
|
||||||
|
table "Routes"
|
||||||
|
columns "Destination" 22 | "Hops" 6 | "RTT" 8 | "State" 10
|
||||||
|
row "a7f2::relay-east" | "2" | "34ms" | "@color{0f0}{● alive}"
|
||||||
|
row "c4e1::bridge-south" | "4" | "112ms" | "@color{0f0}{● alive}"
|
||||||
|
row "01ab::node-gamma" | "7" | "580ms" | "@color{f00}{○ stale}"
|
||||||
|
row "f390::hub-north" | "1" | "8ms" | "@color{0f0}{● alive}"
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 1 "Actions"
|
||||||
|
|
||||||
|
box rounded "Quick Command"
|
||||||
|
form "cmd"
|
||||||
|
field "target" 30 "Destination hash..."
|
||||||
|
radio "mode" "Ping" | "Trace" | "Page"
|
||||||
|
checkbox "verbose" "Verbose output"
|
||||||
|
button "Execute" "/action/exec"
|
||||||
|
|
||||||
|
divider heavy
|
||||||
|
|
||||||
|
text "@center{© 2026 $node · Reticulum Network}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 ASCII Output
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══ Relay Alpha-7 ═════════════════════════════════════════════════╗
|
||||||
|
║ Reticulum Network Node ║
|
||||||
|
║ Online 14d 3h 22m ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
── Resources ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CPU ████████████████████░░░░░░░░ 62% MEM █████████████████████████░░ 84% ⚠
|
||||||
|
GPU ██████░░░░░░░░░░░░░░░░░░░░░ 21% SWP █░░░░░░░░░░░░░░░░░░░░░░░░░ 3%
|
||||||
|
|
||||||
|
── Network ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Traffic (60s) Peers: 7 / 12
|
||||||
|
IN ⣀⣤⣶⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤ 4.2 KB/s East Relay ● online
|
||||||
|
OUT ⣀⣀⣠⣤⣶⣿⣷⣶⣤⣀⣀⣠⣤⣶⣿ 2.1 KB/s South Bridge ● online
|
||||||
|
Node Gamma ◐ degraded
|
||||||
|
|
||||||
|
── Routing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
┌────────────────────────┬────────┬──────────┬────────────┐
|
||||||
|
│ Destination │ Hops │ RTT │ State │
|
||||||
|
├────────────────────────┼────────┼──────────┼────────────┤
|
||||||
|
│ a7f2::relay-east │ 2 │ 34ms │ ● alive │
|
||||||
|
│ c4e1::bridge-south │ 4 │ 112ms │ ● alive │
|
||||||
|
│ 01ab::node-gamma │ 7 │ 580ms │ ○ stale │
|
||||||
|
│ f390::hub-north │ 1 │ 8ms │ ● alive │
|
||||||
|
└────────────────────────┴────────┴──────────┴────────────┘
|
||||||
|
|
||||||
|
── Actions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
╭─ Quick Command ──────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ target: [ Destination hash...________________ ] │
|
||||||
|
│ mode: (•) Ping ( ) Trace ( ) Page │
|
||||||
|
│ [ ] Verbose output │
|
||||||
|
│ ┌───────────┐ │
|
||||||
|
│ │ Execute │ │
|
||||||
|
│ └───────────┘ │
|
||||||
|
╰──────────────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
© 2026 Relay Alpha-7 · Reticulum Network
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Micron Output
|
||||||
|
|
||||||
|
The **same characters** — every box corner, every bar segment, every
|
||||||
|
braille dot — with Micron tags added for color and interactivity:
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══ `!Relay Alpha-7`! ═════════════════════════════════════════════╗
|
||||||
|
║`c Reticulum Network Node`a ║
|
||||||
|
║`c Online 14d 3h 22m`a ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
>Resources
|
||||||
|
|
||||||
|
`!CPU`! `F0f0████████████████████`f`F555░░░░░░░░`f 62% `!MEM`! `Ff80█████████████████████████`f`F555░░`f `Ff8084%`f ⚠
|
||||||
|
`!GPU`! `F0f0██████`f`F555░░░░░░░░░░░░░░░░░░░░░`f 21% `!SWP`! `F0f0█`f`F555░░░░░░░░░░░░░░░░░░░░░░░░░`f 3%
|
||||||
|
|
||||||
|
>Network
|
||||||
|
|
||||||
|
`!Traffic (60s)`! `!Peers:`! 7 / 12
|
||||||
|
IN `F0cf⣀⣤⣶⣿⣷⣶⣤⣀⣀⣤⣶⣿⣷⣤`f 4.2 KB/s East Relay `F0f0●`f online
|
||||||
|
OUT `F0cf⣀⣀⣠⣤⣶⣿⣷⣶⣤⣀⣀⣠⣤⣶⣿`f 2.1 KB/s South Bridge `F0f0●`f online
|
||||||
|
Node Gamma `Fff0◐`f degraded
|
||||||
|
|
||||||
|
>Routing
|
||||||
|
|
||||||
|
┌────────────────────────┬────────┬──────────┬────────────┐
|
||||||
|
│ `!Destination`! │ `!Hops`! │ `!RTT`! │ `!State`! │
|
||||||
|
├────────────────────────┼────────┼──────────┼────────────┤
|
||||||
|
│ a7f2::relay-east │ 2 │ 34ms │ `F0f0●`f alive │
|
||||||
|
│ c4e1::bridge-south │ 4 │ 112ms │ `F0f0●`f alive │
|
||||||
|
│ 01ab::node-gamma │ 7 │ 580ms │ `Ff00○`f stale │
|
||||||
|
│ f390::hub-north │ 1 │ 8ms │ `F0f0●`f alive │
|
||||||
|
└────────────────────────┴────────┴──────────┴────────────┘
|
||||||
|
|
||||||
|
>Actions
|
||||||
|
|
||||||
|
╭─ `!Quick Command`! ──────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ target: `<30|target`Destination hash...> │
|
||||||
|
│ mode: `<^|mode|ping|*`Ping> `<^|mode|trace`Trace> `<^|mode|page`Page>
|
||||||
|
│ `<?|verbose|yes`Verbose output> │
|
||||||
|
│ `[`!Execute`!`:/action/exec]
|
||||||
|
│ │
|
||||||
|
╰──────────────────────────────────────────────────────────────╯
|
||||||
|
|
||||||
|
-━
|
||||||
|
|
||||||
|
`c© 2026 Relay Alpha-7 · Reticulum Network`a
|
||||||
|
```
|
||||||
|
|
||||||
|
Note how the table borders, box corners, and gauge characters
|
||||||
|
are **byte-for-byte identical** in both outputs. Micron simply
|
||||||
|
interleaves its backtick tags around the characters that need
|
||||||
|
color or emphasis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Intermediate Representation
|
||||||
|
|
||||||
|
### IR Node
|
||||||
|
|
||||||
|
```
|
||||||
|
IRNode:
|
||||||
|
type : NodeType
|
||||||
|
label : string | null
|
||||||
|
children : IRNode[]
|
||||||
|
styles : {
|
||||||
|
fg : string | null # 3-digit hex
|
||||||
|
bg : string | null
|
||||||
|
bold : bool
|
||||||
|
italic : bool
|
||||||
|
underline: bool
|
||||||
|
align : left | center | right
|
||||||
|
border : light | heavy | double | rounded | none
|
||||||
|
}
|
||||||
|
layout : {
|
||||||
|
width : int | pct | null
|
||||||
|
height : int | null
|
||||||
|
gap : int
|
||||||
|
pad : [top, right, bottom, left]
|
||||||
|
}
|
||||||
|
data : { # type-specific
|
||||||
|
value : number | null
|
||||||
|
max : number | null
|
||||||
|
warn : number | null
|
||||||
|
crit : number | null
|
||||||
|
values : number[] | null # sparkline, bar_v
|
||||||
|
state : enum | null # status indicator
|
||||||
|
options : string[] | null # radio, dropdown
|
||||||
|
columns : Column[] | null # table
|
||||||
|
rows : Row[] | null # table
|
||||||
|
link : string | null # destination
|
||||||
|
field : FieldMeta | null # form metadata
|
||||||
|
}
|
||||||
|
inline : InlineSpan[] # parsed @modifiers
|
||||||
|
```
|
||||||
|
|
||||||
|
### The CharGrid
|
||||||
|
|
||||||
|
```
|
||||||
|
CharGrid:
|
||||||
|
width : int
|
||||||
|
height : int
|
||||||
|
cells : Cell[height][width]
|
||||||
|
|
||||||
|
Cell:
|
||||||
|
char : char # visible character
|
||||||
|
style : CellStyle # for Micron emission
|
||||||
|
field : FieldMeta? # if this cell is part of a form field
|
||||||
|
link : string? # if this cell is clickable
|
||||||
|
|
||||||
|
CellStyle:
|
||||||
|
fg : string? # 3-digit hex
|
||||||
|
bg : string?
|
||||||
|
bold : bool
|
||||||
|
italic : bool
|
||||||
|
underline : bool
|
||||||
|
```
|
||||||
|
|
||||||
|
The layout engine fills the CharGrid. Both emitters read it.
|
||||||
|
The ASCII emitter ignores the style layer. The Micron emitter
|
||||||
|
scans for style transitions and inserts tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rendering Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Parse
|
||||||
|
.uf source → token stream → IR tree
|
||||||
|
Variables resolved, components expanded.
|
||||||
|
|
||||||
|
Phase 2: Measure
|
||||||
|
Bottom-up pass: compute min/preferred width and height
|
||||||
|
for each node. Leaf nodes (text, gauge, field) report
|
||||||
|
their intrinsic sizes. Containers aggregate children.
|
||||||
|
|
||||||
|
Phase 3: Layout
|
||||||
|
Top-down pass: assign (x, y, w, h) to every node.
|
||||||
|
Row nodes divide width among columns.
|
||||||
|
Box nodes reserve border characters (1 char each side).
|
||||||
|
|
||||||
|
Phase 4: Paint
|
||||||
|
Depth-first traversal. Each node writes characters into
|
||||||
|
the CharGrid at its assigned position:
|
||||||
|
- Box: draw border chars, set title style
|
||||||
|
- Gauge: compute bar length, write █ and ░, set fg color
|
||||||
|
based on thresholds
|
||||||
|
- Sparkline: convert values to braille patterns
|
||||||
|
- Table: draw grid, write cell content, set header bold
|
||||||
|
- Form: write visual placeholders, attach FieldMeta
|
||||||
|
- Status: write indicator char, set color by state
|
||||||
|
|
||||||
|
Phase 5: Merge Borders
|
||||||
|
Post-pass: scan for adjacent border characters and replace
|
||||||
|
with correct junction characters (┬ ┴ ├ ┤ ┼ etc.).
|
||||||
|
Weight priority: double > heavy > light > rounded.
|
||||||
|
|
||||||
|
Phase 6: Emit
|
||||||
|
ASCII: read cell.char for every cell, join into lines.
|
||||||
|
Micron: scan each line, diff style between adjacent cells,
|
||||||
|
open/close Micron tags at transitions.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Border Merging Detail
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: After:
|
||||||
|
┌────┐┌────┐ ┌────┬────┐
|
||||||
|
│ ││ │ ──▶ │ │ │
|
||||||
|
└────┘└────┘ └────┴────┘
|
||||||
|
|
||||||
|
┌────────┐ ┌────────┐
|
||||||
|
│┌──────┐│ ├──────┐ │ (nested box shares
|
||||||
|
││ ││ ──▶ │ │ │ parent left edge)
|
||||||
|
│└──────┘│ ├──────┘ │
|
||||||
|
└────────┘ └────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The merging pass checks each cell against its 4 neighbors
|
||||||
|
and selects from a lookup table of ~40 junction characters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Character Reference
|
||||||
|
|
||||||
|
### Boxes
|
||||||
|
|
||||||
|
```
|
||||||
|
Light: ┌ ─ ┐ │ └ ┘ ├ ┤ ┬ ┴ ┼
|
||||||
|
Heavy: ┏ ━ ┓ ┃ ┗ ┛ ┣ ┫ ┳ ┻ ╋
|
||||||
|
Double: ╔ ═ ╗ ║ ╚ ╝ ╠ ╣ ╦ ╩ ╬
|
||||||
|
Rounded: ╭ ╮ ╰ ╯
|
||||||
|
Mixed: ╒ ╓ ╕ ╖ ╘ ╙ ╛ ╜ (light+double junctions)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blocks
|
||||||
|
|
||||||
|
```
|
||||||
|
Horizontal fill: █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ (full → 1/8)
|
||||||
|
Vertical fill: ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ (1/8 → full)
|
||||||
|
Shade: ░ ▒ ▓ █ (25% → 100%)
|
||||||
|
Quadrants: ▖ ▗ ▘ ▝ ▞ ▟ ▙ ▛ ▜ ▚
|
||||||
|
```
|
||||||
|
|
||||||
|
### Braille (sparklines, dot plots)
|
||||||
|
|
||||||
|
```
|
||||||
|
Range: U+2800–U+28FF (256 patterns)
|
||||||
|
Each char = 2×4 dot matrix (2 cols × 4 rows)
|
||||||
|
Smooth curves: ⠀⣀⣠⣤⣴⣶⣾⣿⣷⣶⣤⣀⠀
|
||||||
|
```
|
||||||
|
|
||||||
|
### Indicators
|
||||||
|
|
||||||
|
```
|
||||||
|
Status: ● ○ ◐ ◑ ◒ ◓ ◌ ◉
|
||||||
|
Arrows: ▲ ▼ ◀ ▶ ← → ↑ ↓ ↗ ↘
|
||||||
|
Marks: ✓ ✗ ◆ ◇ ★ ☆ ⚠ ⚡
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uframe render <file.uf> # emit ASCII (stdout) + .mu (file)
|
||||||
|
--ascii # ASCII only
|
||||||
|
--micron # .mu only
|
||||||
|
--ansi # ANSI colors in ASCII output
|
||||||
|
--width <int> # override page width
|
||||||
|
--out <dir> # directory for .mu output
|
||||||
|
|
||||||
|
uframe preview <file.uf> # live terminal preview
|
||||||
|
--watch # re-render on file change
|
||||||
|
|
||||||
|
uframe check <file.uf> # validate / lint
|
||||||
|
|
||||||
|
uframe deploy <file.uf> [dest] # render + copy to NomadNet pages dir
|
||||||
|
# default dest: ~/.nomadnetwork/storage/pages/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Standard Component Library
|
||||||
|
|
||||||
|
```
|
||||||
|
use std/dashboard
|
||||||
|
use std/filebrowser
|
||||||
|
use std/board
|
||||||
|
|
||||||
|
# Pre-built patterns:
|
||||||
|
dashboard.banner node_name uptime
|
||||||
|
dashboard.resources cpu mem gpu swap
|
||||||
|
dashboard.peers peer_list
|
||||||
|
dashboard.traffic in_data out_data
|
||||||
|
|
||||||
|
filebrowser.tree root_path
|
||||||
|
filebrowser.listing dir_path
|
||||||
|
|
||||||
|
board.recent count
|
||||||
|
board.compose action_path
|
||||||
|
|
||||||
|
nav.tabs items active
|
||||||
|
nav.breadcrumb path
|
||||||
|
|
||||||
|
chart.timeseries label values width
|
||||||
|
chart.compare items
|
||||||
|
chart.histogram bins
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Future Directions
|
||||||
|
|
||||||
|
- **Responsive reflow** — breakpoints that stack `row` children
|
||||||
|
vertically at narrow widths
|
||||||
|
- **Themes** — `.uf-theme` files defining color palettes and
|
||||||
|
border preferences shared across pages
|
||||||
|
- **Animation** — frame-by-frame for live dashboards using
|
||||||
|
terminal cursor repositioning
|
||||||
|
- **Bidirectional** — parse existing `.mu` into `.uf` for editing
|
||||||
|
- **HTML export** — for web-based Reticulum browsers (rBrowser)
|
||||||
|
- **Shebang mode** — `#!/usr/bin/env uframe --micron` for
|
||||||
|
executable dynamic NomadNet pages
|
||||||
|
- **LSP** — editor support: highlighting, completion, live preview
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Micronomicon — Frontend
|
||||||
|
|
||||||
|
React 19 + Vite + TypeScript + shadcn/ui. See the [root README](../README.md) for full project docs.
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 — proxies /api to localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # output → dist/ (copied into Docker image)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
App.tsx ← routes
|
||||||
|
routes/
|
||||||
|
DashboardView.tsx ← page table
|
||||||
|
EditorView.tsx ← split-pane editor
|
||||||
|
GraphView.tsx ← React Flow graph
|
||||||
|
components/
|
||||||
|
dashboard/StatusBadge.tsx
|
||||||
|
editor/
|
||||||
|
EditorPane.tsx ← CodeMirror 6
|
||||||
|
PreviewPane.tsx ← micron output (ScrollArea)
|
||||||
|
ToolBar.tsx
|
||||||
|
shared/
|
||||||
|
AppShell.tsx ← TooltipProvider + Toaster wrapper
|
||||||
|
NavBar.tsx
|
||||||
|
ui/ ← shadcn components (auto-generated)
|
||||||
|
lib/
|
||||||
|
utils.ts ← cn() utility
|
||||||
|
stores/
|
||||||
|
editorStore.ts ← Zustand: markdown, micron, dirty state
|
||||||
|
pagesStore.ts ← Zustand: pages list, fetch/delete
|
||||||
|
hooks/
|
||||||
|
useConversion.ts ← debounced POST /api/convert
|
||||||
|
useGraph.ts ← GET /api/graph
|
||||||
|
useUnsavedGuard.ts ← beforeunload + dirty guard
|
||||||
|
```
|
||||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Micronomicon</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7444
frontend/package-lock.json
generated
Normal file
7444
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "micronomicon",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.40.0",
|
||||||
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@xyflow/react": "^12.10.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-resizable-panels": "^4.8.0",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
|
"shadcn": "^4.1.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
18
frontend/src/App.tsx
Normal file
18
frontend/src/App.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import AppShell from "./components/shared/AppShell";
|
||||||
|
import DashboardView from "./routes/DashboardView";
|
||||||
|
import EditorView from "./routes/EditorView";
|
||||||
|
import GraphView from "./routes/GraphView";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardView />} />
|
||||||
|
<Route path="/editor/new" element={<EditorView />} />
|
||||||
|
<Route path="/editor/:name" element={<EditorView />} />
|
||||||
|
<Route path="/graph" element={<GraphView />} />
|
||||||
|
</Routes>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/components/dashboard/StatusBadge.tsx
Normal file
12
frontend/src/components/dashboard/StatusBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
published: boolean;
|
||||||
|
hasSource: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ published, hasSource }: Props) {
|
||||||
|
if (published && hasSource) return <Badge>Published</Badge>;
|
||||||
|
if (!published && hasSource) return <Badge variant="secondary">Draft</Badge>;
|
||||||
|
return <Badge variant="outline">Orphan</Badge>;
|
||||||
|
}
|
||||||
44
frontend/src/components/editor/BacklinkIndicator.tsx
Normal file
44
frontend/src/components/editor/BacklinkIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { BacklinkPage } from "@/hooks/useBacklinks";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
backlinks: BacklinkPage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BacklinkIndicator({ backlinks }: Props) {
|
||||||
|
if (backlinks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
|
||||||
|
← {backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-64 p-2" align="end">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
|
||||||
|
Pages linking here
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{backlinks.map((page) => (
|
||||||
|
<li key={page.name}>
|
||||||
|
<Link
|
||||||
|
to={`/editor/${page.name}`}
|
||||||
|
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span>{page.title ?? page.name}</span>
|
||||||
|
{page.title && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({page.name})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/components/editor/EditorPane.tsx
Normal file
61
frontend/src/components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { EditorView, keymap } from "@codemirror/view";
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import type { Extension } from "@codemirror/state";
|
||||||
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||||
|
import { searchKeymap } from "@codemirror/search";
|
||||||
|
import { oneDark } from "./oneDarkTheme";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
extensions?: Extension[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorPane({ value, onChange, extensions = [] }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView>(null);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: [
|
||||||
|
history(),
|
||||||
|
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||||
|
oneDark,
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
onChangeRef.current(update.state.doc.toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.theme({
|
||||||
|
"&": { height: "100%", fontSize: "14px" },
|
||||||
|
".cm-scroller": { overflow: "auto" },
|
||||||
|
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
||||||
|
}),
|
||||||
|
...extensions,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({ state, parent: containerRef.current });
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
return () => view.destroy();
|
||||||
|
}, []); // Only mount once
|
||||||
|
|
||||||
|
// Sync external value changes (e.g. loading a page)
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (view && view.state.doc.toString() !== value) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ height: "100%" }} />;
|
||||||
|
}
|
||||||
88
frontend/src/components/editor/PreviewPane.tsx
Normal file
88
frontend/src/components/editor/PreviewPane.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
import { renderMicron } from "./micronRenderer";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PreviewMode = "ascii" | "micron" | "raw";
|
||||||
|
|
||||||
|
export default function PreviewPane() {
|
||||||
|
const previewMode = useEditorStore((s) => s.previewMode);
|
||||||
|
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
|
||||||
|
const compiledAscii = useEditorStore((s) => s.compiledAscii);
|
||||||
|
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
||||||
|
const isCompiling = useEditorStore((s) => s.isCompiling);
|
||||||
|
const compileError = useEditorStore((s) => s.compileError);
|
||||||
|
|
||||||
|
const tabs: { value: PreviewMode; label: string }[] = [
|
||||||
|
{ value: "ascii", label: "ASCII" },
|
||||||
|
{ value: "micron", label: "Micron" },
|
||||||
|
{ value: "raw", label: "Raw" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground flex-1">
|
||||||
|
Preview
|
||||||
|
{isCompiling && (
|
||||||
|
<span className="ml-2 text-yellow-500 animate-pulse">
|
||||||
|
compiling…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{compileError && (
|
||||||
|
<span className="ml-2 text-red-400" title={compileError}>
|
||||||
|
✗ error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => setPreviewMode(tab.value)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded transition-colors",
|
||||||
|
previewMode === tab.value
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1 bg-background">
|
||||||
|
{previewMode === "ascii" ? (
|
||||||
|
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
|
||||||
|
{compiledAscii || (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
ASCII preview will appear here…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
) : previewMode === "micron" ? (
|
||||||
|
compiledMicron ? (
|
||||||
|
<div
|
||||||
|
className="p-4 font-mono text-sm whitespace-pre leading-tight"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderMicron(compiledMicron),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-4">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Micron preview will appear here…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
|
||||||
|
{compiledMicron || "Raw Micron output will appear here…"}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/components/editor/ToolBar.tsx
Normal file
56
frontend/src/components/editor/ToolBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
import { useBacklinks } from "@/hooks/useBacklinks";
|
||||||
|
import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pageName: string;
|
||||||
|
onNameChange?: (name: string) => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolBar({
|
||||||
|
pageName,
|
||||||
|
onNameChange,
|
||||||
|
onSaveDraft,
|
||||||
|
onPublish,
|
||||||
|
saving,
|
||||||
|
isDirty,
|
||||||
|
}: Props) {
|
||||||
|
const currentPage = useEditorStore((s) => s.currentPage);
|
||||||
|
const backlinks = useBacklinks(currentPage?.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
|
||||||
|
{onNameChange ? (
|
||||||
|
<Input
|
||||||
|
value={pageName}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="page-name"
|
||||||
|
className="font-mono w-48 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono font-semibold text-sm">{pageName}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<BacklinkIndicator backlinks={backlinks} />
|
||||||
|
|
||||||
|
{isDirty && (
|
||||||
|
<span className="text-xs text-muted-foreground">Unsaved</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||||
|
Save Draft
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||||
|
Publish
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/components/editor/micronHighlight.ts
Normal file
57
frontend/src/components/editor/micronHighlight.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
|
||||||
|
const micronLanguage = StreamLanguage.define({
|
||||||
|
token(stream) {
|
||||||
|
if (stream.sol()) {
|
||||||
|
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
|
||||||
|
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
|
||||||
|
// Headings — longest prefix first
|
||||||
|
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
|
||||||
|
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
|
||||||
|
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
|
||||||
|
// Dividers: line starting with - followed by a non-space, non-dash char
|
||||||
|
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
|
||||||
|
// Comment lines
|
||||||
|
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
|
||||||
|
// Standalone depth-reset "<"
|
||||||
|
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
|
||||||
|
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
|
||||||
|
|
||||||
|
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
|
||||||
|
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
|
||||||
|
|
||||||
|
// Links [label`url] — consume the whole bracket expression
|
||||||
|
if (stream.match(/\[[^\]]*\]/)) return "link";
|
||||||
|
|
||||||
|
// Form elements <fieldname`default> etc.
|
||||||
|
if (stream.match(/<[^>]+>/)) return "string";
|
||||||
|
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
startState: () => ({}),
|
||||||
|
copyState: (s) => ({ ...s }),
|
||||||
|
blankLine: () => {},
|
||||||
|
languageData: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const micronStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
|
||||||
|
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
|
||||||
|
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
|
||||||
|
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
|
||||||
|
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
|
||||||
|
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
|
||||||
|
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
|
||||||
|
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
|
||||||
|
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
|
||||||
|
{ tag: tags.string, color: "#d4a8f8" }, // form elements
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function micronHighlight() {
|
||||||
|
return [micronLanguage, syntaxHighlighting(micronStyle)];
|
||||||
|
}
|
||||||
157
frontend/src/components/editor/micronRenderer.ts
Normal file
157
frontend/src/components/editor/micronRenderer.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Micron markup → HTML renderer for the editor preview pane.
|
||||||
|
* Spec: https://github.com/fr33n0w/micron-composer
|
||||||
|
*/
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render inline Micron formatting codes within a line of text. */
|
||||||
|
function renderInline(raw: string): string {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
const openTags: string[] = [];
|
||||||
|
|
||||||
|
const closeAll = () => {
|
||||||
|
while (openTags.length) out += openTags.pop()!;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (i < raw.length) {
|
||||||
|
// Backtick formatting codes
|
||||||
|
if (raw[i] === "`") {
|
||||||
|
const code = raw[i + 1];
|
||||||
|
if (code === "!") {
|
||||||
|
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
|
||||||
|
} else if (code === "*") {
|
||||||
|
out += "<em>"; openTags.push("</em>"); i += 2; continue;
|
||||||
|
} else if (code === "_") {
|
||||||
|
out += "<u>"; openTags.push("</u>"); i += 2; continue;
|
||||||
|
} else if (code === "`") {
|
||||||
|
closeAll(); i += 2; continue;
|
||||||
|
} else if (code === "f" || code === "b") {
|
||||||
|
out += "</span>"; i += 2; continue;
|
||||||
|
} else if (code === "a") {
|
||||||
|
out += "</span>"; i += 2; continue;
|
||||||
|
} else if (code === "F") {
|
||||||
|
// Foreground color — 3-digit hex only per spec
|
||||||
|
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
||||||
|
if (hexMatch) {
|
||||||
|
const [r, g, b] = hexMatch[1].split("");
|
||||||
|
const hex = r + r + g + g + b + b;
|
||||||
|
out += `<span style="color:#${hex}">`;
|
||||||
|
openTags.push("</span>");
|
||||||
|
i += 2 + hexMatch[1].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (code === "B") {
|
||||||
|
// Background color — 3-digit hex only per spec
|
||||||
|
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
||||||
|
if (hexMatch) {
|
||||||
|
const [r, g, b] = hexMatch[1].split("");
|
||||||
|
const hex = r + r + g + g + b + b;
|
||||||
|
out += `<span style="background:#${hex}">`;
|
||||||
|
openTags.push("</span>");
|
||||||
|
i += 2 + hexMatch[1].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (code === "c") {
|
||||||
|
out += `<span style="display:block;text-align:center">`;
|
||||||
|
openTags.push("</span>"); i += 2; continue;
|
||||||
|
} else if (code === "r") {
|
||||||
|
out += `<span style="display:block;text-align:right">`;
|
||||||
|
openTags.push("</span>"); i += 2; continue;
|
||||||
|
} else if (code === "l") {
|
||||||
|
out += `<span style="display:block;text-align:left">`;
|
||||||
|
openTags.push("</span>"); i += 2; continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links: [label`slug] or [label`slug.mu]
|
||||||
|
if (raw[i] === "[") {
|
||||||
|
const close = raw.indexOf("]", i);
|
||||||
|
if (close !== -1) {
|
||||||
|
const inner = raw.slice(i + 1, close);
|
||||||
|
const backtick = inner.indexOf("`");
|
||||||
|
if (backtick !== -1) {
|
||||||
|
const label = escapeHtml(inner.slice(0, backtick));
|
||||||
|
const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, ""));
|
||||||
|
out += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out += escapeHtml(raw[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAll();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a form element line as a styled badge. */
|
||||||
|
function renderForm(line: string): string {
|
||||||
|
const inner = escapeHtml(line);
|
||||||
|
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMicron(source: string): string {
|
||||||
|
const lines = source.split("\n");
|
||||||
|
const htmlLines: string[] = [];
|
||||||
|
let literalMode = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Toggle literal mode on standalone `= line
|
||||||
|
if (line.trimEnd() === "`=") {
|
||||||
|
literalMode = !literalMode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In literal mode — render verbatim
|
||||||
|
if (literalMode) {
|
||||||
|
htmlLines.push(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment lines — hidden in output
|
||||||
|
if (line.startsWith("#")) continue;
|
||||||
|
|
||||||
|
// All output uses inline spans — the parent container has white-space:pre
|
||||||
|
// so newlines come from the \n join at the end.
|
||||||
|
|
||||||
|
// Depth-4+ indent (before >>> check)
|
||||||
|
if (line.startsWith(">>>>")) {
|
||||||
|
htmlLines.push(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
|
||||||
|
// Headings
|
||||||
|
} else if (line.startsWith(">>>")) {
|
||||||
|
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
|
||||||
|
} else if (line.startsWith(">>")) {
|
||||||
|
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
|
||||||
|
} else if (line.startsWith(">")) {
|
||||||
|
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
|
||||||
|
// Dividers: - followed by a non-space, non-dash character
|
||||||
|
} else if (/^-[^\s-]/.test(line)) {
|
||||||
|
const char = line[1];
|
||||||
|
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
|
||||||
|
// Standalone depth-reset "<"
|
||||||
|
} else if (line.trim() === "<") {
|
||||||
|
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
|
||||||
|
// Empty line
|
||||||
|
} else if (line.trim() === "") {
|
||||||
|
htmlLines.push("");
|
||||||
|
// Form elements on their own line
|
||||||
|
} else if (/^`?<[^>]+>$/.test(line)) {
|
||||||
|
htmlLines.push(renderForm(line));
|
||||||
|
} else {
|
||||||
|
htmlLines.push(renderInline(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlLines.join("\n");
|
||||||
|
}
|
||||||
28
frontend/src/components/editor/oneDarkTheme.ts
Normal file
28
frontend/src/components/editor/oneDarkTheme.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
export const oneDark = EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
backgroundColor: "#0d1117",
|
||||||
|
color: "#c9d1d9",
|
||||||
|
},
|
||||||
|
".cm-cursor": {
|
||||||
|
borderLeftColor: "#c9d1d9",
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||||
|
backgroundColor: "#264f78",
|
||||||
|
},
|
||||||
|
".cm-activeLine": {
|
||||||
|
backgroundColor: "#161b2266",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "#0d1117",
|
||||||
|
color: "#484f58",
|
||||||
|
borderRight: "1px solid #21262d",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
backgroundColor: "#161b2266",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true }
|
||||||
|
);
|
||||||
84
frontend/src/components/editor/slashCommands.ts
Normal file
84
frontend/src/components/editor/slashCommands.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { snippet } from "@codemirror/autocomplete";
|
||||||
|
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
interface SlashEntry {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
section: string;
|
||||||
|
apply: Completion["apply"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert text, replacing from the "/" character (from-1) through the cursor
|
||||||
|
function insert(text: string): Completion["apply"] {
|
||||||
|
return (view: EditorView, _completion: Completion, from: number, to: number) => {
|
||||||
|
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap snippet() to also replace the preceding "/" character
|
||||||
|
function slashSnippet(template: string): Completion["apply"] {
|
||||||
|
const snip = snippet(template);
|
||||||
|
return (view: EditorView, completion: Completion, from: number, to: number) => {
|
||||||
|
snip(view, completion, from - 1, to - 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS: SlashEntry[] = [
|
||||||
|
// Headings
|
||||||
|
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
|
||||||
|
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
|
||||||
|
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
|
||||||
|
|
||||||
|
// Text formatting
|
||||||
|
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
|
||||||
|
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
|
||||||
|
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
|
||||||
|
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
|
||||||
|
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
|
||||||
|
|
||||||
|
// Alignment
|
||||||
|
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
|
||||||
|
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
|
||||||
|
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
|
||||||
|
|
||||||
|
// Color (3-digit hex)
|
||||||
|
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
|
||||||
|
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
|
||||||
|
|
||||||
|
// Links
|
||||||
|
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
|
||||||
|
|
||||||
|
// Dividers
|
||||||
|
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
|
||||||
|
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
|
||||||
|
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
|
||||||
|
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
|
||||||
|
|
||||||
|
// Forms — pipe separators per micron-composer spec
|
||||||
|
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
|
||||||
|
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
|
||||||
|
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
|
||||||
|
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
|
||||||
|
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
|
||||||
|
|
||||||
|
// Depth
|
||||||
|
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
|
||||||
|
const match = ctx.matchBefore(/\/\w*/);
|
||||||
|
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||||
|
return {
|
||||||
|
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
|
||||||
|
from: match.from + 1,
|
||||||
|
filter: true,
|
||||||
|
options: COMMANDS.map((cmd) => ({
|
||||||
|
label: cmd.label,
|
||||||
|
detail: cmd.detail,
|
||||||
|
section: cmd.section,
|
||||||
|
apply: cmd.apply,
|
||||||
|
boost: 99,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
195
frontend/src/components/editor/uframeCommands.ts
Normal file
195
frontend/src/components/editor/uframeCommands.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { snippet } from "@codemirror/autocomplete";
|
||||||
|
import type {
|
||||||
|
Completion,
|
||||||
|
CompletionContext,
|
||||||
|
CompletionResult,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
interface CmdEntry {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
section: string;
|
||||||
|
apply: Completion["apply"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function insert(text: string): Completion["apply"] {
|
||||||
|
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||||
|
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function slashSnippet(template: string): Completion["apply"] {
|
||||||
|
const snip = snippet(template);
|
||||||
|
return (view: EditorView, c: Completion, from: number, to: number) => {
|
||||||
|
snip(view, c, from - 1, to - 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDS: CmdEntry[] = [
|
||||||
|
// Layout
|
||||||
|
{
|
||||||
|
label: "page",
|
||||||
|
detail: 'page "Title" 64',
|
||||||
|
section: "Layout",
|
||||||
|
apply: slashSnippet('page "${title}" ${width:64}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "box",
|
||||||
|
detail: 'box light "Title"',
|
||||||
|
section: "Layout",
|
||||||
|
apply: slashSnippet('box ${weight:light} "${title}"'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "row",
|
||||||
|
detail: "row [gap]",
|
||||||
|
section: "Layout",
|
||||||
|
apply: slashSnippet("row ${gap:2}"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "col",
|
||||||
|
detail: "col [width]",
|
||||||
|
section: "Layout",
|
||||||
|
apply: slashSnippet("col ${width}"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "spacer",
|
||||||
|
detail: "spacer [lines]",
|
||||||
|
section: "Layout",
|
||||||
|
apply: insert("spacer"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "pad",
|
||||||
|
detail: "pad t r b l",
|
||||||
|
section: "Layout",
|
||||||
|
apply: slashSnippet("pad ${top:1} ${right:1} ${bottom:1} ${left:1}"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content
|
||||||
|
{
|
||||||
|
label: "heading",
|
||||||
|
detail: 'heading 1 "Text"',
|
||||||
|
section: "Content",
|
||||||
|
apply: slashSnippet('heading ${level:1} "${text}"'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "text",
|
||||||
|
detail: 'text "Content"',
|
||||||
|
section: "Content",
|
||||||
|
apply: slashSnippet('text "${content}"'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "label",
|
||||||
|
detail: 'label "Key" "Value"',
|
||||||
|
section: "Content",
|
||||||
|
apply: slashSnippet('label "${key}" "${value}"'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "divider",
|
||||||
|
detail: "divider heavy",
|
||||||
|
section: "Content",
|
||||||
|
apply: slashSnippet("divider ${style:light}"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "link",
|
||||||
|
detail: 'link "Text" "/path.mu"',
|
||||||
|
section: "Content",
|
||||||
|
apply: slashSnippet('link "${display}" "${dest}"'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "list",
|
||||||
|
detail: "list bullet",
|
||||||
|
section: "Content",
|
||||||
|
apply: slashSnippet("list ${style:bullet}\n item \"${entry}\""),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data Viz
|
||||||
|
{
|
||||||
|
label: "gauge",
|
||||||
|
detail: "gauge label val max width",
|
||||||
|
section: "Data",
|
||||||
|
apply: slashSnippet(
|
||||||
|
'gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "sparkline",
|
||||||
|
detail: "sparkline label values width",
|
||||||
|
section: "Data",
|
||||||
|
apply: slashSnippet('sparkline "${label}" "${values}" ${width:20}'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "status",
|
||||||
|
detail: "status label state",
|
||||||
|
section: "Data",
|
||||||
|
apply: slashSnippet('status "${label}" ${state:online}'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Style
|
||||||
|
{
|
||||||
|
label: "align",
|
||||||
|
detail: "align center",
|
||||||
|
section: "Style",
|
||||||
|
apply: slashSnippet("align ${align:center}"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "color",
|
||||||
|
detail: "color hex",
|
||||||
|
section: "Style",
|
||||||
|
apply: slashSnippet("color ${hex}"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "bold",
|
||||||
|
detail: "bold",
|
||||||
|
section: "Style",
|
||||||
|
apply: insert("bold"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
{
|
||||||
|
label: "dashboard",
|
||||||
|
detail: "Full dashboard template",
|
||||||
|
section: "Template",
|
||||||
|
apply: insert(
|
||||||
|
`page "Dashboard" 64
|
||||||
|
box double "Node Status"
|
||||||
|
align center
|
||||||
|
text "Reticulum Network Node"
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 1 "Resources"
|
||||||
|
|
||||||
|
gauge "CPU" 0 100 28 warn=75 crit=90
|
||||||
|
gauge "MEM" 0 100 28 warn=80 crit=95
|
||||||
|
|
||||||
|
spacer
|
||||||
|
|
||||||
|
heading 2 "Network"
|
||||||
|
|
||||||
|
status "Relay East" online
|
||||||
|
status "Bridge South" online
|
||||||
|
|
||||||
|
divider heavy
|
||||||
|
link "Home" "/page/index.mu"`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function uframeCommandSource(
|
||||||
|
ctx: CompletionContext,
|
||||||
|
): CompletionResult | null {
|
||||||
|
const match = ctx.matchBefore(/\/\w*/);
|
||||||
|
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||||
|
return {
|
||||||
|
from: match.from + 1,
|
||||||
|
filter: true,
|
||||||
|
options: COMMANDS.map((cmd) => ({
|
||||||
|
label: cmd.label,
|
||||||
|
detail: cmd.detail,
|
||||||
|
section: cmd.section,
|
||||||
|
apply: cmd.apply,
|
||||||
|
boost: 99,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
101
frontend/src/components/editor/uframeHighlight.ts
Normal file
101
frontend/src/components/editor/uframeHighlight.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
StreamLanguage,
|
||||||
|
HighlightStyle,
|
||||||
|
syntaxHighlighting,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
||||||
|
*
|
||||||
|
* Keywords: page, box, row, col, spacer, pad, heading, text, label,
|
||||||
|
* divider, link, list, item, gauge, sparkline, status,
|
||||||
|
* table, columns, form, field, radio, checkbox, button,
|
||||||
|
* source, let, if, elif, else, for, align, color, bg,
|
||||||
|
* bold, italic, underline, cache, state, on_submit
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEYWORDS = new Set([
|
||||||
|
"page", "box", "row", "col", "spacer", "pad",
|
||||||
|
"heading", "text", "label", "divider", "link",
|
||||||
|
"list", "item", "gauge", "sparkline", "status",
|
||||||
|
"table", "columns", "form", "field", "radio",
|
||||||
|
"checkbox", "button", "source", "let", "if",
|
||||||
|
"elif", "else", "for", "align", "color", "bg",
|
||||||
|
"bold", "italic", "underline", "cache", "state",
|
||||||
|
"on_submit", "meter", "bar_h", "bar_v", "bar",
|
||||||
|
"heatmap", "component", "use",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const WEIGHT_VALS = new Set([
|
||||||
|
"light", "heavy", "double", "rounded",
|
||||||
|
"bullet", "dash", "number", "arrow",
|
||||||
|
"left", "center", "right",
|
||||||
|
"online", "offline", "degraded", "unknown",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uframeLanguage = StreamLanguage.define({
|
||||||
|
token(stream) {
|
||||||
|
// Comments
|
||||||
|
if (stream.sol() && stream.match(/\s*#/)) {
|
||||||
|
stream.skipToEnd();
|
||||||
|
return "lineComment";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
if (stream.eatSpace()) return null;
|
||||||
|
|
||||||
|
// Quoted strings
|
||||||
|
if (stream.match(/"/)) {
|
||||||
|
while (!stream.eol()) {
|
||||||
|
if (stream.next() === '"') break;
|
||||||
|
}
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
// @modifier{} syntax
|
||||||
|
if (stream.match(/@\w+/)) return "keyword";
|
||||||
|
|
||||||
|
// $variable references
|
||||||
|
if (stream.match(/\$[\w.]+/)) return "variableName";
|
||||||
|
|
||||||
|
// Numbers (integers, floats, hex colors)
|
||||||
|
if (stream.match(/\b\d+(\.\d+)?\b/)) return "number";
|
||||||
|
|
||||||
|
// warn=N, crit=N parameters
|
||||||
|
if (stream.match(/\b(warn|crit)=/)) return "attributeName";
|
||||||
|
|
||||||
|
// Pipe separator for inline lists
|
||||||
|
if (stream.match(/\|/)) return "punctuation";
|
||||||
|
|
||||||
|
// Keywords and values
|
||||||
|
if (stream.match(/\b\w+\b/)) {
|
||||||
|
const word = stream.current();
|
||||||
|
if (KEYWORDS.has(word)) return "keyword";
|
||||||
|
if (WEIGHT_VALS.has(word)) return "atom";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
startState: () => ({}),
|
||||||
|
copyState: (s) => ({ ...s }),
|
||||||
|
blankLine: () => {},
|
||||||
|
languageData: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uframeStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.keyword, color: "#c792ea", fontWeight: "bold" },
|
||||||
|
{ tag: tags.string, color: "#c3e88d" },
|
||||||
|
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
|
||||||
|
{ tag: tags.variableName, color: "#f78c6c" },
|
||||||
|
{ tag: tags.number, color: "#f78c6c" },
|
||||||
|
{ tag: tags.atom, color: "#89ddff" },
|
||||||
|
{ tag: tags.attributeName, color: "#ffcb6b" },
|
||||||
|
{ tag: tags.punctuation, color: "#89ddff" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function uframeHighlight() {
|
||||||
|
return [uframeLanguage, syntaxHighlighting(uframeStyle)];
|
||||||
|
}
|
||||||
23
frontend/src/components/editor/wikiLinkCompletion.ts
Normal file
23
frontend/src/components/editor/wikiLinkCompletion.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import type { PageMeta } from "@/stores/editorStore";
|
||||||
|
|
||||||
|
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
const match = context.matchBefore(/\[\[[\w-]*/);
|
||||||
|
if (!match || (match.from === match.to && !context.explicit)) return null;
|
||||||
|
|
||||||
|
const options: Completion[] = pagesRef.current.map((page) => ({
|
||||||
|
label: page.title ?? page.name,
|
||||||
|
detail: page.name,
|
||||||
|
apply: (view, _completion, from, to) => {
|
||||||
|
const title = page.title ?? page.name;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert: `[${title}\`${page.name}]` },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { from: match.from, options, filter: true };
|
||||||
|
};
|
||||||
|
}
|
||||||
16
frontend/src/components/shared/AppShell.tsx
Normal file
16
frontend/src/components/shared/AppShell.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import NavBar from "./NavBar";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
|
export default function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||||
|
<NavBar />
|
||||||
|
<main className="flex-1 overflow-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
frontend/src/components/shared/NavBar.tsx
Normal file
88
frontend/src/components/shared/NavBar.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
cn(
|
||||||
|
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function NavBar() {
|
||||||
|
const [restartOpen, setRestartOpen] = useState(false);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/restart", { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
toast.success("NomadNet restarted");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Restart failed: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
setRestartOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center gap-1 px-4 h-12 border-b bg-card shrink-0">
|
||||||
|
<span className="font-bold mr-6 text-foreground">Micronomicon</span>
|
||||||
|
|
||||||
|
<NavLink to="/" end className={navLink}>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/editor/new" className={navLink}>
|
||||||
|
New Page
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/graph" className={navLink}>
|
||||||
|
Graph
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={restarting}
|
||||||
|
onClick={() => setRestartOpen(true)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Restart NomadNet
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog open={restartOpen} onOpenChange={setRestartOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Restart NomadNet?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will briefly interrupt mesh network connectivity.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleRestart}>
|
||||||
|
Restart
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
52
frontend/src/components/ui/badge.tsx
Normal file
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
20
frontend/src/components/ui/input.tsx
Normal file
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
88
frontend/src/components/ui/popover.tsx
Normal file
88
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-heading font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/resizable.tsx
Normal file
48
frontend/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ResizablePrimitive.GroupProps) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ResizablePrimitive.SeparatorProps & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||||
54
frontend/src/components/ui/scroll-area.tsx
Normal file
54
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
25
frontend/src/components/ui/separator.tsx
Normal file
25
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
47
frontend/src/components/ui/sonner.tsx
Normal file
47
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
89
frontend/src/components/ui/toggle-group.tsx
Normal file
89
frontend/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||||
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle"
|
||||||
|
|
||||||
|
const ToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
spacing: 0,
|
||||||
|
orientation: "horizontal",
|
||||||
|
})
|
||||||
|
|
||||||
|
function ToggleGroup({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
spacing = 0,
|
||||||
|
orientation = "horizontal",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ToggleGroupPrimitive.Props &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive
|
||||||
|
data-slot="toggle-group"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
data-orientation={orientation}
|
||||||
|
style={{ "--gap": spacing } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ToggleGroupContext.Provider
|
||||||
|
value={{ variant, size, spacing, orientation }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleGroupItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
const context = React.useContext(ToggleGroupContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle-group-item"
|
||||||
|
data-variant={context.variant || variant}
|
||||||
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||||
|
toggleVariants({
|
||||||
|
variant: context.variant || variant,
|
||||||
|
size: context.size || size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TogglePrimitive>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ToggleGroup, ToggleGroupItem }
|
||||||
43
frontend/src/components/ui/toggle.tsx
Normal file
43
frontend/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toggleVariants = cva(
|
||||||
|
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-muted",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||||
|
return (
|
||||||
|
<TogglePrimitive
|
||||||
|
data-slot="toggle"
|
||||||
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toggle, toggleVariants }
|
||||||
66
frontend/src/components/ui/tooltip.tsx
Normal file
66
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
21
frontend/src/hooks/useBacklinks.ts
Normal file
21
frontend/src/hooks/useBacklinks.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useGraph } from "@/hooks/useGraph";
|
||||||
|
|
||||||
|
export interface BacklinkPage {
|
||||||
|
name: string;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBacklinks(currentSlug: string | undefined): BacklinkPage[] {
|
||||||
|
const { data } = useGraph();
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!data || !currentSlug) return [];
|
||||||
|
const nodeMap = new Map(data.nodes.map((n) => [n.id, n]));
|
||||||
|
return data.edges
|
||||||
|
.filter((e) => e.target === currentSlug)
|
||||||
|
.map((e) => ({
|
||||||
|
name: e.source,
|
||||||
|
title: nodeMap.get(e.source)?.title ?? null,
|
||||||
|
}));
|
||||||
|
}, [data, currentSlug]);
|
||||||
|
}
|
||||||
71
frontend/src/hooks/useCompile.ts
Normal file
71
frontend/src/hooks/useCompile.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced hook that compiles µFrame source via POST /api/compile.
|
||||||
|
* Automatically triggers on ufSource changes.
|
||||||
|
*/
|
||||||
|
export function useCompile() {
|
||||||
|
const ufSource = useEditorStore((s) => s.ufSource);
|
||||||
|
const setCompileResult = useEditorStore((s) => s.setCompileResult);
|
||||||
|
const setCompiling = useEditorStore((s) => s.setCompiling);
|
||||||
|
const setCompileError = useEditorStore((s) => s.setCompileError);
|
||||||
|
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const compile = useCallback(
|
||||||
|
async (source: string) => {
|
||||||
|
if (!source.trim()) {
|
||||||
|
setCompileResult("", "", []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort any in-flight request
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setCompiling(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/compile", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
|
||||||
|
setCompileError(err.detail || "Compile failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setCompileResult(data.ascii, data.micron, data.warnings || []);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||||
|
setCompileError(e instanceof Error ? e.message : "Compile failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setCompileResult, setCompiling, setCompileError],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => compile(ufSource), DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [ufSource, compile]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
35
frontend/src/hooks/useGraph.ts
Normal file
35
frontend/src/hooks/useGraph.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
published: boolean;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraph() {
|
||||||
|
const [data, setData] = useState<GraphData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/graph");
|
||||||
|
setData(await res.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading };
|
||||||
|
}
|
||||||
16
frontend/src/hooks/useUnsavedGuard.ts
Normal file
16
frontend/src/hooks/useUnsavedGuard.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
|
||||||
|
export function useUnsavedGuard() {
|
||||||
|
const isDirty = useEditorStore((s) => s.isDirty);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeunload", handler);
|
||||||
|
return () => window.removeEventListener("beforeunload", handler);
|
||||||
|
}, [isDirty]);
|
||||||
|
}
|
||||||
130
frontend/src/index.css
Normal file
130
frontend/src/index.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "@fontsource-variable/geist";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: 'Geist Variable', sans-serif;
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import "./index.css";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
149
frontend/src/routes/DashboardView.tsx
Normal file
149
frontend/src/routes/DashboardView.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { usePagesStore } from "@/stores/pagesStore";
|
||||||
|
import StatusBadge from "@/components/dashboard/StatusBadge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export default function DashboardView() {
|
||||||
|
const { pages, isLoading, fetchPages, deletePage } = usePagesStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!pageToDelete) return;
|
||||||
|
await deletePage(pageToDelete);
|
||||||
|
toast.success(`"${pageToDelete}" deleted`);
|
||||||
|
setPageToDelete(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-xl font-semibold">Pages</h1>
|
||||||
|
<Button onClick={() => navigate("/editor/new")}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Size</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pages.map((p) => (
|
||||||
|
<TableRow key={p.name}>
|
||||||
|
<TableCell className="font-mono">
|
||||||
|
{p.name}
|
||||||
|
{p.name === "index" && (
|
||||||
|
<span className="ml-2 text-xs text-primary">homepage</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{p.title ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge published={p.published} hasSource={p.has_source} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{p.size != null ? `${p.size} B` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
{p.has_source && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/editor/${p.name}`)}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setPageToDelete(p.name)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{pages.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="text-center text-muted-foreground py-8"
|
||||||
|
>
|
||||||
|
No pages yet. Create one to get started.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={pageToDelete !== null}
|
||||||
|
onOpenChange={(open) => !open && setPageToDelete(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This permanently deletes the page and its source. This cannot be
|
||||||
|
undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/routes/EditorView.tsx
Normal file
150
frontend/src/routes/EditorView.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { autocompletion } from "@codemirror/autocomplete";
|
||||||
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
import { usePagesStore } from "@/stores/pagesStore";
|
||||||
|
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||||
|
import { useCompile } from "@/hooks/useCompile";
|
||||||
|
import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||||
|
import { uframeCommandSource } from "@/components/editor/uframeCommands";
|
||||||
|
import EditorPane from "@/components/editor/EditorPane";
|
||||||
|
import PreviewPane from "@/components/editor/PreviewPane";
|
||||||
|
import ToolBar from "@/components/editor/ToolBar";
|
||||||
|
import {
|
||||||
|
ResizablePanelGroup,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandle,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
|
||||||
|
export default function EditorView() {
|
||||||
|
const { name } = useParams<{ name: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = !name;
|
||||||
|
|
||||||
|
const ufSource = useEditorStore((s) => s.ufSource);
|
||||||
|
const isDirty = useEditorStore((s) => s.isDirty);
|
||||||
|
const setSource = useEditorStore((s) => s.setSource);
|
||||||
|
const setDirty = useEditorStore((s) => s.setDirty);
|
||||||
|
const setCurrentPage = useEditorStore((s) => s.setCurrentPage);
|
||||||
|
const reset = useEditorStore((s) => s.reset);
|
||||||
|
|
||||||
|
const { fetchPages } = usePagesStore();
|
||||||
|
const [pageName, setPageName] = useState(name ?? "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// µFrame extensions: syntax highlighting + slash commands
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => [
|
||||||
|
...uframeHighlight(),
|
||||||
|
autocompletion({
|
||||||
|
override: [uframeCommandSource],
|
||||||
|
icons: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-compile on source changes
|
||||||
|
useCompile();
|
||||||
|
useUnsavedGuard();
|
||||||
|
|
||||||
|
// Fetch pages for backlinks
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load page on mount / route change
|
||||||
|
useEffect(() => {
|
||||||
|
reset();
|
||||||
|
if (name) {
|
||||||
|
setPageName(name);
|
||||||
|
fetch(`/api/pages/${name}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.source != null) {
|
||||||
|
useEditorStore.setState({
|
||||||
|
ufSource: data.source,
|
||||||
|
isDirty: false,
|
||||||
|
currentPage: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => reset();
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (publish: boolean) => {
|
||||||
|
const slug = pageName
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]/g, "-");
|
||||||
|
if (!slug) {
|
||||||
|
toast.error("Enter a page name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/pages/${slug}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source: ufSource, publish }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const meta = await res.json();
|
||||||
|
setCurrentPage(meta);
|
||||||
|
setDirty(false);
|
||||||
|
fetchPages();
|
||||||
|
toast.success(publish ? "Published" : "Draft saved");
|
||||||
|
if (isNew) navigate(`/editor/${slug}`, { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Save failed: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pageName, ufSource, isNew, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave(false);
|
||||||
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [handleSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<ToolBar
|
||||||
|
pageName={pageName}
|
||||||
|
onNameChange={isNew ? setPageName : undefined}
|
||||||
|
onSaveDraft={() => handleSave(false)}
|
||||||
|
onPublish={() => handleSave(true)}
|
||||||
|
saving={saving}
|
||||||
|
isDirty={isDirty}
|
||||||
|
/>
|
||||||
|
<ResizablePanelGroup orientation="horizontal" className="flex-1">
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20}>
|
||||||
|
<EditorPane
|
||||||
|
value={ufSource}
|
||||||
|
onChange={setSource}
|
||||||
|
extensions={extensions}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20}>
|
||||||
|
<PreviewPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/routes/GraphView.tsx
Normal file
101
frontend/src/routes/GraphView.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import dagre from "@dagrejs/dagre";
|
||||||
|
import { useGraph } from "@/hooks/useGraph";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
|
|
||||||
|
const NODE_WIDTH = 160;
|
||||||
|
const NODE_HEIGHT = 50;
|
||||||
|
|
||||||
|
function layoutGraph(
|
||||||
|
nodes: Node[],
|
||||||
|
edges: Edge[]
|
||||||
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
const g = new dagre.graphlib.Graph();
|
||||||
|
g.setDefaultEdgeLabel(() => ({}));
|
||||||
|
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
|
||||||
|
|
||||||
|
nodes.forEach((n) =>
|
||||||
|
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||||
|
);
|
||||||
|
edges.forEach((e) => g.setEdge(e.source, e.target));
|
||||||
|
dagre.layout(g);
|
||||||
|
|
||||||
|
const laid = nodes.map((n) => {
|
||||||
|
const pos = g.node(n.id);
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: laid, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GraphView() {
|
||||||
|
const { data, loading } = useGraph();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
if (!data) return { nodes: [], edges: [] };
|
||||||
|
|
||||||
|
const rfNodes: Node[] = data.nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
data: { label: n.title ?? n.id },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
style: {
|
||||||
|
background: n.published
|
||||||
|
? "oklch(0.488 0.14 145)"
|
||||||
|
: "oklch(0.7 0.15 80)",
|
||||||
|
color: "#fff",
|
||||||
|
border:
|
||||||
|
n.id === "index"
|
||||||
|
? "2px solid oklch(0.6 0.2 250)"
|
||||||
|
: "1px solid oklch(1 0 0 / 10%)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 16px",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: n.id === "index" ? 700 : 400,
|
||||||
|
width: NODE_WIDTH,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rfEdges: Edge[] = data.edges.map((e, i) => ({
|
||||||
|
id: `e-${i}`,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
style: { stroke: "oklch(0.556 0 0)" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return layoutGraph(rfNodes, rfEdges);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
Loading graph...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full bg-background">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodeClick={(_, node) => navigate(`/editor/${node.id}`)}
|
||||||
|
fitView
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background color="oklch(0.269 0 0)" gap={20} />
|
||||||
|
<Controls />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/src/stores/editorStore.ts
Normal file
78
frontend/src/stores/editorStore.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export interface PageMeta {
|
||||||
|
name: string;
|
||||||
|
title: string | null;
|
||||||
|
published: boolean;
|
||||||
|
has_source: boolean;
|
||||||
|
last_modified: number | null;
|
||||||
|
size: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorStore {
|
||||||
|
// Source
|
||||||
|
ufSource: string;
|
||||||
|
isDirty: boolean;
|
||||||
|
currentPage: PageMeta | null;
|
||||||
|
|
||||||
|
// Compiled output
|
||||||
|
compiledAscii: string;
|
||||||
|
compiledMicron: string;
|
||||||
|
compileWarnings: string[];
|
||||||
|
isCompiling: boolean;
|
||||||
|
compileError: string | null;
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
previewMode: "ascii" | "micron" | "raw";
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSource: (s: string) => void;
|
||||||
|
setCurrentPage: (p: PageMeta | null) => void;
|
||||||
|
setDirty: (v: boolean) => void;
|
||||||
|
setCompileResult: (ascii: string, micron: string, warnings: string[]) => void;
|
||||||
|
setCompiling: (v: boolean) => void;
|
||||||
|
setCompileError: (e: string | null) => void;
|
||||||
|
setPreviewMode: (mode: "ascii" | "micron" | "raw") => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorStore>((set) => ({
|
||||||
|
ufSource: "",
|
||||||
|
isDirty: false,
|
||||||
|
currentPage: null,
|
||||||
|
|
||||||
|
compiledAscii: "",
|
||||||
|
compiledMicron: "",
|
||||||
|
compileWarnings: [],
|
||||||
|
isCompiling: false,
|
||||||
|
compileError: null,
|
||||||
|
|
||||||
|
previewMode: "ascii",
|
||||||
|
|
||||||
|
setSource: (s) => set({ ufSource: s, isDirty: true }),
|
||||||
|
setCurrentPage: (p) => set({ currentPage: p }),
|
||||||
|
setDirty: (v) => set({ isDirty: v }),
|
||||||
|
setCompileResult: (ascii, micron, warnings) =>
|
||||||
|
set({
|
||||||
|
compiledAscii: ascii,
|
||||||
|
compiledMicron: micron,
|
||||||
|
compileWarnings: warnings,
|
||||||
|
isCompiling: false,
|
||||||
|
compileError: null,
|
||||||
|
}),
|
||||||
|
setCompiling: (v) => set({ isCompiling: v }),
|
||||||
|
setCompileError: (e) => set({ compileError: e, isCompiling: false }),
|
||||||
|
setPreviewMode: (mode) => set({ previewMode: mode }),
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
ufSource: "",
|
||||||
|
isDirty: false,
|
||||||
|
currentPage: null,
|
||||||
|
compiledAscii: "",
|
||||||
|
compiledMicron: "",
|
||||||
|
compileWarnings: [],
|
||||||
|
isCompiling: false,
|
||||||
|
compileError: null,
|
||||||
|
previewMode: "ascii",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
45
frontend/src/stores/pagesStore.ts
Normal file
45
frontend/src/stores/pagesStore.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { PageMeta } from "@/stores/editorStore";
|
||||||
|
|
||||||
|
interface PagesStore {
|
||||||
|
pages: PageMeta[];
|
||||||
|
isLoading: boolean;
|
||||||
|
fetchPages: () => Promise<void>;
|
||||||
|
deletePage: (name: string) => Promise<void>;
|
||||||
|
unpublishPage: (name: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePagesStore = create<PagesStore>((set, get) => ({
|
||||||
|
pages: [],
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
fetchPages: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/pages");
|
||||||
|
const pages = await res.json();
|
||||||
|
set({ pages });
|
||||||
|
} finally {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePage: async (name: string) => {
|
||||||
|
await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
||||||
|
await get().fetchPages();
|
||||||
|
},
|
||||||
|
|
||||||
|
unpublishPage: async (name: string) => {
|
||||||
|
// Fetch current source, re-save as draft only
|
||||||
|
const res = await fetch(`/api/pages/${name}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.source) {
|
||||||
|
await fetch(`/api/pages/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source: data.source, publish: false }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await get().fetchPages();
|
||||||
|
},
|
||||||
|
}));
|
||||||
34
frontend/tsconfig.app.json
Normal file
34
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
|
/* Path alias */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
base: "/",
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user