Compare commits

...

2 Commits

Author SHA1 Message Date
a95594f446 feat: navigation 2026-04-01 16:04:06 +02:00
a0a5f18128 feat: styles 2026-04-01 16:03:55 +02:00
15 changed files with 510 additions and 107 deletions

View File

@@ -361,6 +361,36 @@ class StateDecl(IRNode):
# Components (Phase 8) # Components (Phase 8)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Navigation nodes
# ---------------------------------------------------------------------------
@dataclass
class NavItem:
"""A single item in a navigation bar."""
kind: str = "item" # "item", "separator", "heading"
label: str = ""
dest: str = ""
active: bool = False
children: list["NavItem"] = field(default_factory=list)
@dataclass
class HNav(IRNode):
"""Horizontal navigation bar."""
nav_style: str = "bar" # bar, tabs, pills, breadcrumb, underline
items: list[NavItem] = field(default_factory=list)
compact: bool = False
@dataclass
class VNav(IRNode):
"""Vertical navigation panel."""
nav_style: str = "list" # list, boxed, tree, sidebar, minimal
nav_width: int = 0 # 0 = auto-fit
items: list[NavItem] = field(default_factory=list)
@dataclass @dataclass
class ImageNode(IRNode): class ImageNode(IRNode):
"""Image converted to character art.""" """Image converted to character art."""

View File

@@ -20,7 +20,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, ImageNode, BigTitle, ImageNode, HNav, VNav,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
) )
from uframe.themes import BUILTIN_THEMES from uframe.themes import BUILTIN_THEMES
@@ -120,6 +120,16 @@ register_keyword("columns", section="",
# Big Text # Big Text
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
register_keyword("hnav", node_class=HNav, section="Layout",
detail='hnav bar', snippet='hnav ${style:bar}\n item "${label}" "${dest}" active',
highlight_values=["bar", "tabs", "pills", "breadcrumb", "underline"],
is_container=True)
register_keyword("vnav", node_class=VNav, section="Layout",
detail='vnav list', snippet='vnav ${style:list}\n item "${label}" "${dest}"',
highlight_values=["list", "boxed", "tree", "sidebar", "minimal"],
is_container=True)
register_keyword("image", node_class=ImageNode, section="Content", register_keyword("image", node_class=ImageNode, section="Content",
detail='image "path.png" braille 30', detail='image "path.png" braille 30',
snippet='image "${path}" ${mode:braille} ${width:30}', snippet='image "${path}" ${mode:braille} ${width:30}',

View File

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

View File

@@ -8,7 +8,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, ImageNode, BigTitle, ImageNode, HNav, VNav,
) )
from uframe.fonts import FONT_HEIGHTS, get_text_width from uframe.fonts import FONT_HEIGHTS, get_text_width
from uframe.imaging import get_image_height from uframe.imaging import get_image_height
@@ -246,6 +246,33 @@ def measure(node: IRNode, available_width: int) -> None:
node.pref_height = 1 node.pref_height = 1
node.min_height = 1 node.min_height = 1
elif isinstance(node, HNav):
# Height: 3 for bar/tabs/pills (top border + items + bottom border), 2 for underline/breadcrumb
if node.nav_style in ("underline",):
node.pref_height = 2
elif node.nav_style in ("breadcrumb",):
node.pref_height = 1
else:
node.pref_height = 3
node.pref_width = available_width
node.min_width = 10
node.min_height = 1
elif isinstance(node, VNav):
count = sum(1 for it in node.items if it.kind in ("item", "heading"))
sep_count = sum(1 for it in node.items if it.kind == "separator")
h = count + sep_count
if node.nav_style == "boxed":
h += 2 # top + bottom border
node.pref_height = max(h, 1)
if node.nav_width > 0:
node.pref_width = node.nav_width
else:
max_label = max((len(it.label) for it in node.items if it.label), default=8)
node.pref_width = max_label + 6 # marker + padding
node.min_width = 8
node.min_height = 1
elif isinstance(node, ImageNode): elif isinstance(node, ImageNode):
h = get_image_height(node.path, node.mode, node.img_width) h = get_image_height(node.path, node.mode, node.img_width)
if node.caption: if node.caption:

View File

@@ -19,7 +19,7 @@ from uframe.ir import (
Heading, Text, Label, Divider, Link, ListNode, ListItem, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
BigTitle, ImageNode, BigTitle, ImageNode, HNav, VNav,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
from uframe.themes import ThemeDef, THEME_DEFAULT from uframe.themes import ThemeDef, THEME_DEFAULT
@@ -223,6 +223,12 @@ def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
grid.put(x, y, char, style=CellStyle(fg=color)) grid.put(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label) grid.put_text(x + 2, y, node.label)
elif isinstance(node, HNav):
_paint_hnav(node, grid, x, y, w, th)
elif isinstance(node, VNav):
_paint_vnav(node, grid, x, y, w, th)
elif isinstance(node, ImageNode): elif isinstance(node, ImageNode):
style = CellStyle(fg=node.style.fg or th.palette.accent) style = CellStyle(fg=node.style.fg or th.palette.accent)
try: try:
@@ -346,6 +352,153 @@ def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
paint(child, grid, th) paint(child, grid, th)
def _paint_hnav(node: HNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
"""Paint a horizontal navigation bar."""
active_style = CellStyle(bold=True, fg=th.palette.accent)
link_style = CellStyle(fg=th.palette.info)
sep_style = CellStyle(fg=th.palette.muted)
border_style = CellStyle()
items = [it for it in node.items if it.kind in ("item", "separator")]
marker = ""
if node.nav_style in ("bar", "tabs", "pills"):
# Bordered bar
bc = th.border_dict("light")
grid.draw_border(x, y, w, 3, border_chars=bc,
title_caps=(th.title_caps.left, th.title_caps.right))
col = x + 2
for it in items:
if it.kind == "separator":
grid.put(col, y + 1, "", style=sep_style)
col += 2
continue
if it.active:
grid.put_text(col, y + 1, marker, style=active_style)
col += len(marker)
grid.put_text(col, y + 1, it.label, style=active_style)
else:
grid.put_text(col, y + 1, it.label, style=link_style, link=it.dest)
col += len(it.label) + 2
if col < x + w - 2:
grid.put(col, y + 1, "", style=sep_style)
col += 2
elif node.nav_style == "breadcrumb":
col = x + 2
sep = ""
for i, it in enumerate(items):
if it.kind == "separator":
continue
if i > 0:
grid.put_text(col, y, sep, style=sep_style)
col += len(sep)
if it.active:
grid.put_text(col, y, it.label, style=active_style)
else:
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
col += len(it.label)
elif node.nav_style == "underline":
col = x + 2
active_start = 0
active_len = 0
for i, it in enumerate(items):
if it.kind == "separator":
continue
if it.active:
active_start = col
active_len = len(it.label)
grid.put_text(col, y, it.label, style=active_style)
else:
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
col += len(it.label) + 5
# Underline beneath active
if active_len > 0:
for c in range(active_start, active_start + active_len):
grid.put(c, y + 1, "", style=CellStyle(fg=th.palette.accent))
def _paint_vnav(node: VNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
"""Paint a vertical navigation panel."""
active_style = CellStyle(bold=True, fg=th.palette.accent)
link_style = CellStyle(fg=th.palette.info)
heading_style = CellStyle(bold=True, fg=th.palette.muted)
sep_style = CellStyle(fg=th.palette.muted)
marker = ""
items = node.items
row_y = y
if node.nav_style == "boxed":
# Draw a box and render items inside
bc = th.border_dict("light")
h = node.rect.h
grid.draw_border(x, y, w, h, border_chars=bc,
title_caps=(th.title_caps.left, th.title_caps.right))
row_y = y + 1
for it in items:
if it.kind == "separator":
# Draw internal separator
for c in range(x + 1, x + w - 1):
grid.put(c, row_y, bc.get("h", ""), style=sep_style,
is_border=True)
grid.put(x, row_y, "", style=sep_style, is_border=True)
grid.put(x + w - 1, row_y, "", style=sep_style, is_border=True)
row_y += 1
elif it.kind == "heading":
grid.put_text(x + 2, row_y, it.label.upper(), style=heading_style)
row_y += 1
elif it.kind == "item":
if it.active:
grid.put_text(x + 2, row_y, marker, style=active_style)
grid.put_text(x + 2 + len(marker), row_y, it.label, style=active_style)
else:
grid.put_text(x + 4, row_y, it.label, style=link_style, link=it.dest)
row_y += 1
elif node.nav_style == "tree":
for idx, it in enumerate(items):
if it.kind == "separator":
for c in range(x, x + w):
grid.put(c, row_y, "", style=sep_style)
row_y += 1
elif it.kind == "heading":
grid.put_text(x, row_y, it.label, style=heading_style)
row_y += 1
elif it.kind == "item":
# Determine connector
remaining = [i for i in items[idx+1:] if i.kind == "item"]
connector = "└── " if not remaining else "├── "
grid.put_text(x, row_y, connector, style=sep_style)
if it.active:
grid.put_text(x + len(connector), row_y, it.label, style=active_style)
grid.put_text(x + len(connector) + len(it.label) + 2, row_y, "",
style=CellStyle(fg=th.palette.accent))
else:
grid.put_text(x + len(connector), row_y, it.label,
style=link_style, link=it.dest)
row_y += 1
else:
# list, sidebar, minimal
for it in items:
if it.kind == "separator":
for c in range(x, min(x + w, x + 16)):
grid.put(c, row_y, "", style=sep_style)
row_y += 1
elif it.kind == "heading":
grid.put_text(x, row_y, it.label.upper(), style=heading_style)
row_y += 1
elif it.kind == "item":
if it.active:
grid.put_text(x, row_y, marker, style=active_style)
grid.put_text(x + len(marker), row_y, it.label, style=active_style)
else:
grid.put_text(x + 2, row_y, it.label, style=link_style, link=it.dest)
row_y += 1
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None: def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
"""Paint a box-drawn table with header and data rows.""" """Paint a box-drawn table with header and data rows."""
if not node.columns: if not node.columns:

View File

@@ -19,7 +19,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, TextSpan, Gauge, Sparkline, Status, Table, TextSpan,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, ImageNode, BigTitle, ImageNode, HNav, VNav, NavItem,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
SourceType, SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
@@ -222,9 +222,19 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return ListNode(list_style=style, source_line=line_num) return ListNode(list_style=style, source_line=line_num)
elif keyword == "item": elif keyword == "item":
# Could be a list item or a nav item — disambiguated by parent in tree builder
if len(args) >= 2 and ("/" in args[1] or ":" in args[1]):
# Nav item: item "Label" "/dest.mu" [active]
label = args[0]
dest = args[1]
active = "active" in args[2:] if len(args) > 2 else False
return _NavItemNode("item", label, dest, active, line_num)
content = args[0] if args else "" content = args[0] if args else ""
return ListItem(content=content, source_line=line_num) return ListItem(content=content, source_line=line_num)
elif keyword == "separator":
return _NavItemNode("separator", "", "", False, line_num)
# Style modifiers (applied to parent) # Style modifiers (applied to parent)
elif keyword == "align": elif keyword == "align":
val = args[0].lower() if args else "left" val = args[0].lower() if args else "left"
@@ -427,6 +437,15 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
content = " ".join([keyword] + args) content = " ".join([keyword] + args)
return Text(content=content, source_line=line_num) return Text(content=content, source_line=line_num)
elif keyword == "hnav":
nav_style = args[0] if args else "bar"
return HNav(nav_style=nav_style, source_line=line_num)
elif keyword == "vnav":
nav_style = args[0] if args else "list"
nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0
return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num)
elif keyword == "image": elif keyword == "image":
path = args[0] if args else "" path = args[0] if args else ""
mode = args[1] if len(args) > 1 else "braille" mode = args[1] if len(args) > 1 else "braille"
@@ -477,6 +496,16 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
return ComponentUse(comp_name=keyword, args=args, source_line=line_num) return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
class _NavItemNode(IRNode):
"""Temporary node — absorbed by parent HNav/VNav during tree building."""
def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int):
super().__init__(source_line=line_num)
self.kind = kind
self.label = label
self.dest = dest
self.active = active
class _ThemeDirective(IRNode): class _ThemeDirective(IRNode):
"""Temporary node — sets theme_name on the Page during tree building.""" """Temporary node — sets theme_name on the Page during tree building."""
def __init__(self, theme_name: str, line_num: int): def __init__(self, theme_name: str, line_num: int):
@@ -840,6 +869,30 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
break break
continue continue
# Nav items — absorbed by parent HNav/VNav
if isinstance(node, _NavItemNode):
for si in range(len(stack) - 1, -1, -1):
parent = stack[si][1]
if isinstance(parent, (HNav, VNav)):
parent.items.append(NavItem(
kind=node.kind, label=node.label,
dest=node.dest, active=node.active))
break
continue
# Headings inside vnav become nav headings
if isinstance(node, Heading):
for si in range(len(stack) - 1, -1, -1):
if isinstance(stack[si][1], VNav):
stack[si][1].items.append(NavItem(
kind="heading", label=node.text))
break
else:
# Not inside a vnav — proceed as normal heading
pass
if any(isinstance(stack[si][1], VNav) for si in range(len(stack))):
continue
# Use directive — load standard library components # Use directive — load standard library components
if isinstance(node, _UseDirective): if isinstance(node, _UseDirective):
lib_comps = _load_library(node.lib_path) lib_comps = _load_library(node.lib_path)

View File

@@ -1,11 +1,11 @@
{ {
"name": "frontend", "name": "micronomicon",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "micronomicon",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
@@ -18,6 +18,7 @@
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1077,6 +1078,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.12", "version": "1.19.12",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",

View File

@@ -20,6 +20,7 @@
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -42,7 +42,7 @@ export default function PreviewPane() {
</span> </span>
)} )}
</span> </span>
<div className="flex gap-0.5 bg-muted rounded-md p-0.5"> <div className="flex gap-1">
{tabs {tabs
.filter((t) => t.show) .filter((t) => t.show)
.map((tab) => ( .map((tab) => (
@@ -50,10 +50,10 @@ export default function PreviewPane() {
key={tab.value} key={tab.value}
onClick={() => setPreviewMode(tab.value)} onClick={() => setPreviewMode(tab.value)}
className={cn( className={cn(
"text-xs px-2 py-0.5 rounded transition-colors", "text-xs px-2 py-0.5 uppercase tracking-wider transition-all border-2",
previewMode === tab.value previewMode === tab.value
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground border-border"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground border-transparent",
)} )}
> >
{tab.label} {tab.label}

View File

@@ -1,5 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { BookOpen, ImagePlus } from "lucide-react"; import { BookOpen, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -55,7 +55,7 @@ export default function ToolBar({
<PopoverTrigger <PopoverTrigger
render={ render={
<button <button
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium h-8 px-3 border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors" className="inline-flex items-center justify-center gap-1.5 text-xs font-medium h-8 px-3 border-2 border-border bg-background hover:bg-accent hover:text-accent-foreground transition-all uppercase tracking-wider "
title="Insert example template" title="Insert example template"
> >
<BookOpen className="h-3.5 w-3.5" /> <BookOpen className="h-3.5 w-3.5" />
@@ -152,10 +152,10 @@ function UploadImageButton({ onUploaded }: { onUploaded: (path: string) => void
<button <button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
disabled={uploading} disabled={uploading}
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium h-8 px-3 border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors disabled:opacity-50" className="inline-flex items-center justify-center gap-1.5 text-xs font-medium h-8 px-3 border-2 border-border bg-background hover:bg-accent hover:text-accent-foreground transition-all uppercase tracking-wider disabled:opacity-50"
title="Upload image for embedding" title="Upload image for embedding"
> >
<ImagePlus className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
<span>{uploading ? "Uploading…" : "Image"}</span> <span>{uploading ? "Uploading…" : "Image"}</span>
</button> </button>
</> </>

View File

@@ -280,6 +280,39 @@ export const EXAMPLES: Example[] = [
divider heavy divider heavy
link "Home" "/page/index.mu"`, link "Home" "/page/index.mu"`,
}, },
{
name: "Navigation",
description: "Horizontal bar + vertical sidebar navigation",
source: `page "Dashboard" 64
hnav bar
item "Status" "/page/status.mu" active
item "Peers" "/page/peers.mu"
item "Files" "/page/files.mu"
item "Config" "/page/config.mu"
spacer
row 1
col 18
vnav boxed
heading "Network"
item "Overview" "/page/overview.mu" active
item "Peers" "/page/peers.mu"
item "Routes" "/page/routes.mu"
separator
heading "Tools"
item "Ping" "/page/ping.mu"
item "Trace" "/page/trace.mu"
col
heading 1 "Overview"
gauge "CPU" 62 100 28 warn=75 crit=90
gauge "MEM" 84 100 28 warn=80 crit=95
spacer
status "East Relay" online
status "South Bridge" online
status "Node Gamma" degraded`,
},
{ {
name: "Image Art", name: "Image Art",
description: "Convert images to braille/block/ascii character art", description: "Convert images to braille/block/ascii character art",

View File

@@ -43,7 +43,9 @@ export default function NavBar() {
return ( return (
<nav className="flex items-center gap-1 px-4 h-12 border-b bg-card shrink-0"> <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> <span className="mr-6 text-primary font-bold tracking-widest" title="Micronomicon"
style={{ fontVariantLigatures: "none" }}
> MICRONOMICON </span>
<NavLink to="/" end className={navLink}> <NavLink to="/" end className={navLink}>
Dashboard Dashboard

View File

@@ -49,6 +49,7 @@ function Button({
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
data-variant={variant}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />

View File

@@ -31,7 +31,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)} className={cn("", className)}
{...props} {...props}
/> />
) )

View File

@@ -1,13 +1,14 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "@fontsource-variable/geist"; @import "@fontsource-variable/jetbrains-mono";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--font-heading: var(--font-sans); --font-heading: var(--font-mono);
--font-sans: 'Geist Variable', sans-serif; --font-sans: 'JetBrains Mono Variable', 'Courier New', monospace;
--font-mono: 'JetBrains Mono Variable', 'Courier New', monospace;
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -39,122 +40,204 @@
--color-card: var(--card); --color-card: var(--card);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: 0px;
--radius-md: calc(var(--radius) * 0.8); --radius-md: 0px;
--radius-lg: var(--radius); --radius-lg: 0px;
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: 0px;
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: 0px;
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: 0px;
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: 0px;
} }
/* ── Terracotta Light Theme ── */
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.94 0.02 55);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.18 0.03 45);
--card: oklch(1 0 0); --card: oklch(0.91 0.025 55);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.18 0.03 45);
--popover: oklch(1 0 0); --popover: oklch(0.91 0.025 55);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.18 0.03 45);
--primary: oklch(0.205 0 0); --primary: oklch(0.55 0.14 45);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.95 0.02 55);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.86 0.03 55);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.18 0.03 45);
--muted: oklch(0.97 0 0); --muted: oklch(0.86 0.025 55);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.45 0.04 45);
--accent: oklch(0.97 0 0); --accent: oklch(0.84 0.035 55);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.18 0.03 45);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.5 0.2 25);
--border: oklch(0.922 0 0); --border: oklch(0.6 0.08 45);
--input: oklch(0.922 0 0); --input: oklch(0.78 0.04 55);
--ring: oklch(0.708 0 0); --ring: oklch(0.55 0.14 45);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.55 0.14 45);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.65 0.1 70);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.5 0.08 30);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.6 0.06 90);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.4 0.04 45);
--radius: 0.625rem; --radius: 0px;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.90 0.025 55);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.18 0.03 45);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.55 0.14 45);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.95 0.02 55);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.84 0.035 55);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.18 0.03 45);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.6 0.08 45);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.55 0.14 45);
} }
/* ── Black-Figure Dark Theme (primary) ──
Inspired by Greek pottery: black ground, terracotta figures, gold accents
*/
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.12 0.015 45);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.72 0.08 55);
--card: oklch(0.205 0 0); --card: oklch(0.16 0.02 45);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.72 0.08 55);
--popover: oklch(0.205 0 0); --popover: oklch(0.16 0.02 45);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.72 0.08 55);
--primary: oklch(0.922 0 0); --primary: oklch(0.7 0.13 55);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.12 0.015 45);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.22 0.025 45);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.7 0.08 55);
--muted: oklch(0.269 0 0); --muted: oklch(0.22 0.02 45);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.5 0.05 55);
--accent: oklch(0.269 0 0); --accent: oklch(0.24 0.03 45);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.72 0.08 55);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.55 0.18 25);
--border: oklch(1 0 0 / 10%); --border: oklch(0.38 0.06 45);
--input: oklch(1 0 0 / 15%); --input: oklch(0.26 0.03 45);
--ring: oklch(0.556 0 0); --ring: oklch(0.7 0.13 55);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.7 0.13 55);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.65 0.1 70);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.55 0.08 30);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.6 0.06 90);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.45 0.04 45);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.14 0.018 45);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.72 0.08 55);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.7 0.13 55);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.12 0.015 45);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.24 0.03 45);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.72 0.08 55);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(0.38 0.06 45);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.7 0.13 55);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} font-size: 13px;
letter-spacing: -0.01em;
}
html { html {
@apply font-sans; @apply font-mono;
} }
/* Dark-mode scrollbars */ /* Terracotta scrollbars */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: oklch(0.35 0 0) transparent; scrollbar-color: oklch(0.35 0.05 45) transparent;
} }
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: 6px; width: 5px;
height: 6px; height: 5px;
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: oklch(0.35 0 0); background: oklch(0.35 0.05 45);
border-radius: 3px; border-radius: 0;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
background: oklch(0.45 0 0); background: oklch(0.5 0.08 55);
} }
*::-webkit-scrollbar-corner { *::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
.dark *::-webkit-scrollbar-thumb { /* Terracotta cursor */
background: oklch(0.3 0 0); .cm-cursor {
border-left-color: oklch(0.7 0.13 55) !important;
} }
.dark *::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0 0); /* Bold console aesthetic */
button, [role="button"], [data-slot="button"] {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 11px;
cursor: pointer;
}
/* Ensure all interactive elements have pointer cursor */
a, [role="link"], [role="tab"], [role="option"],
[data-slot="popover-trigger"], [data-slot="toggle-group-item"],
[data-slot="alert-dialog-action"], [data-slot="alert-dialog-cancel"],
.cm-tooltip-autocomplete [role="option"] {
cursor: pointer;
}
nav {
border-bottom-width: 3px;
}
/* ── Dithered offset shadow ──
Simulates a stippled/dot-matrix shadow using multiple box-shadow
dots at alternating positions. Works even with overflow:auto.
*/
/* Panels and containers — checkerboard dot shadow */
[data-slot="table-container"],
[data-slot="alert-dialog-content"],
[data-slot="popover-content"],
[data-slot="card"],
.panel-shadow {
border: 2px solid var(--border);
box-shadow:
3px 3px 0 0 var(--border),
5px 3px 0 0 transparent,
7px 3px 0 0 var(--border),
4px 4px 0 0 transparent,
6px 4px 0 0 var(--border),
3px 5px 0 0 var(--border),
5px 5px 0 0 transparent,
7px 5px 0 0 var(--border),
4px 6px 0 0 var(--border),
6px 6px 0 0 transparent;
}
/* Primary (default) buttons only — dithered shadow */
button[data-slot="button"][data-variant="default"] {
border: 2px solid var(--border);
box-shadow:
2px 2px 0 0 var(--border),
4px 2px 0 0 transparent,
6px 2px 0 0 var(--border),
3px 3px 0 0 transparent,
5px 3px 0 0 var(--border),
2px 4px 0 0 var(--border),
4px 4px 0 0 transparent,
6px 4px 0 0 var(--border);
transition: box-shadow 0.1s, transform 0.1s;
}
button[data-slot="button"][data-variant="default"]:active {
box-shadow: none;
transform: translate(2px, 2px);
}
/* All other buttons — no shadow */
button[data-slot="button"][data-variant="outline"],
button[data-slot="button"][data-variant="secondary"],
button[data-slot="button"][data-variant="ghost"],
button[data-slot="button"][data-variant="link"],
button[data-slot="button"][data-variant="destructive"] {
box-shadow: none !important;
}
/* Input fields */
input[data-slot="input"] {
border: 2px solid var(--border);
} }
} }