feat: dynamic mode

This commit is contained in:
2026-04-01 08:28:44 +02:00
parent df11705875
commit 619d3ec538
16 changed files with 1184 additions and 38 deletions

View File

@@ -1,23 +1,25 @@
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";
type PreviewMode = "ascii" | "micron" | "raw" | "script";
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 compiledScript = useEditorStore((s) => s.compiledScript);
const isDynamic = useEditorStore((s) => s.isDynamic);
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" },
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
{ value: "ascii", label: "ASCII", show: true },
{ value: "micron", label: "Micron", show: true },
{ value: "raw", label: "Raw", show: true },
{ value: "script", label: "Script", show: isDynamic },
];
return (
@@ -25,6 +27,11 @@ export default function PreviewPane() {
<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
{isDynamic && (
<span className="ml-1.5 text-amber-400" title="This page has dynamic features (source, if, for)">
dynamic
</span>
)}
{isCompiling && (
<span className="ml-2 text-yellow-500 animate-pulse">
compiling
@@ -37,20 +44,22 @@ export default function PreviewPane() {
)}
</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>
))}
{tabs
.filter((t) => t.show)
.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">
@@ -77,6 +86,10 @@ export default function PreviewPane() {
</span>
</div>
)
) : previewMode === "script" ? (
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-amber-200/80 leading-relaxed">
{compiledScript || "No dynamic script generated."}
</pre>
) : (
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
{compiledMicron || "Raw Micron output will appear here…"}

View File

@@ -227,4 +227,72 @@ export const EXAMPLES: Example[] = [
col 30
link "Settings" "/page/settings.mu"`,
},
{
name: "Interactive Form",
description: "Text fields, radio buttons, checkboxes, and submit",
source: `page "Search" 56
box rounded "Node Search"
align center
text "Find peers and pages on the mesh"
spacer
form "search"
field "query" 30 "Enter search term..."
radio "scope" "Local" | "Network" | "All"
checkbox "cache" "Include cached results"
spacer
button "Search" "/page/search.mu"
divider light
heading 2 "Quick Actions"
form "ping"
field "target" 30 "Destination hash..."
radio "mode" "Ping" | "Trace" | "Page"
checkbox "verbose" "Verbose output"
spacer
button "Execute" "/page/action.mu"`,
},
{
name: "Dynamic Dashboard",
description: "Live data sources, conditionals, and cache control",
source: `page "Live Status" 60
cache 0
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 timestamp : python "datetime.now().strftime('%H:%M:%S')"
box double "Node Monitor"
align center
text "Live System Dashboard"
text "Updated: $timestamp"
spacer
heading 1 "Resources"
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
spacer
if $cpu_pct > 90
box heavy "ALERT"
color f00
text "CPU critical! Immediate action required."
elif $cpu_pct > 75
text "@color{ff0}{Warning: CPU usage elevated}"
spacer
label "Uptime" "$uptime"
divider heavy
text "Press Ctrl+R to refresh"`,
},
];

View File

@@ -68,6 +68,31 @@ function renderInline(raw: string): string {
} else if (code === "l") {
out += `<span style="display:block;text-align:left">`;
openTags.push("</span>"); i += 2; continue;
} else if (code === "<") {
// Form element: `<...> or `<!...> or `<?...> or `<^...>
const closeAngle = raw.indexOf(">", i + 2);
if (closeAngle !== -1) {
const inner = raw.slice(i + 2, closeAngle);
out += renderFormTag(inner);
i = closeAngle + 1;
continue;
}
} else if (code === "[") {
// Link with inline formatting: `[`!Label`!`:/dest]
const closeBracket = raw.indexOf("]", i + 2);
if (closeBracket !== -1) {
const inner = raw.slice(i + 2, closeBracket);
const colonIdx = inner.indexOf("`:");
if (colonIdx !== -1) {
const labelRaw = inner.slice(0, colonIdx);
const dest = inner.slice(colonIdx + 2);
// Strip formatting tags from label for display
const label = labelRaw.replace(/`[!*_]/g, "");
out += `<a href="${escapeHtml(dest)}" style="color:#7dc4e4;text-decoration:underline">${escapeHtml(label)}</a>`;
i = closeBracket + 1;
continue;
}
}
}
}
@@ -95,6 +120,46 @@ function renderInline(raw: string): string {
return out;
}
/** Render a Micron form tag `<...> as styled HTML. */
function renderFormTag(inner: string): string {
const esc = escapeHtml;
// Text field: width|name`placeholder or name`placeholder
// Password: !width|name`placeholder
// Checkbox: ?|name|value`label or ?|name|value|*`label
// Radio: ^|group|value`label or ^|group|value|*`label
if (inner.startsWith("?")) {
// Checkbox
const backtick = inner.indexOf("`");
const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
const checked = inner.includes("|*");
const box = checked ? "☑" : "☐";
return `<span style="color:#d4a8f8">${box} ${esc(label)}</span>`;
}
if (inner.startsWith("^")) {
// Radio button
const backtick = inner.indexOf("`");
const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
const selected = inner.includes("|*");
const dot = selected ? "◉" : "○";
return `<span style="color:#d4a8f8">${dot} ${esc(label)}</span>`;
}
if (inner.startsWith("!")) {
// Password field
const backtick = inner.indexOf("`");
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">🔒 ${esc(placeholder || "••••••")}</span>`;
}
// Regular text field: width|name`placeholder or name`placeholder
const backtick = inner.indexOf("`");
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">${esc(placeholder || "...")}</span>`;
}
/** Render a form element line as a styled badge. */
function renderForm(line: string): string {
const inner = escapeHtml(line);
@@ -145,9 +210,6 @@ export function renderMicron(source: string): string {
// 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));
}

View File

@@ -19,7 +19,7 @@ export function useCompile() {
const compile = useCallback(
async (source: string) => {
if (!source.trim()) {
setCompileResult("", "", []);
setCompileResult("", "", "", false, []);
return;
}
@@ -45,7 +45,7 @@ export function useCompile() {
}
const data = await res.json();
setCompileResult(data.ascii, data.micron, data.warnings || []);
setCompileResult(data.ascii, data.micron, data.script || "", data.is_dynamic || false, data.warnings || []);
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") return;
setCompileError(e instanceof Error ? e.message : "Compile failed");

View File

@@ -18,21 +18,23 @@ interface EditorStore {
// Compiled output
compiledAscii: string;
compiledMicron: string;
compiledScript: string;
isDynamic: boolean;
compileWarnings: string[];
isCompiling: boolean;
compileError: string | null;
// Preview
previewMode: "ascii" | "micron" | "raw";
previewMode: "ascii" | "micron" | "raw" | "script";
// Actions
setSource: (s: string) => void;
setCurrentPage: (p: PageMeta | null) => void;
setDirty: (v: boolean) => void;
setCompileResult: (ascii: string, micron: string, warnings: string[]) => void;
setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => void;
setCompiling: (v: boolean) => void;
setCompileError: (e: string | null) => void;
setPreviewMode: (mode: "ascii" | "micron" | "raw") => void;
setPreviewMode: (mode: "ascii" | "micron" | "raw" | "script") => void;
reset: () => void;
}
@@ -43,6 +45,8 @@ export const useEditorStore = create<EditorStore>((set) => ({
compiledAscii: "",
compiledMicron: "",
compiledScript: "",
isDynamic: false,
compileWarnings: [],
isCompiling: false,
compileError: null,
@@ -52,10 +56,12 @@ export const useEditorStore = create<EditorStore>((set) => ({
setSource: (s) => set({ ufSource: s, isDirty: true }),
setCurrentPage: (p) => set({ currentPage: p }),
setDirty: (v) => set({ isDirty: v }),
setCompileResult: (ascii, micron, warnings) =>
setCompileResult: (ascii, micron, script, isDynamic, warnings) =>
set({
compiledAscii: ascii,
compiledMicron: micron,
compiledScript: script,
isDynamic,
compileWarnings: warnings,
isCompiling: false,
compileError: null,
@@ -70,6 +76,8 @@ export const useEditorStore = create<EditorStore>((set) => ({
currentPage: null,
compiledAscii: "",
compiledMicron: "",
compiledScript: "",
isDynamic: false,
compileWarnings: [],
isCompiling: false,
compileError: null,