diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..26d042c --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,30 @@ +## Design Context + +### Users +Layonara community contributors — NWScript coders, area builders, item designers, and DMs who maintain a 20-year-old Neverwinter Nights persistent world. They range from experienced developers to hobbyists learning scripting. Usage patterns vary: late-night hobby tinkering, focused multi-hour work sessions, and quick surgical edits. The tool runs locally in a browser via Docker — always localhost, never a hosted SaaS. + +### Brand Personality +**Arcane, precise, deep.** + +The Forge is an enchanter's workbench — not flashy magic, but the quiet kind. Tools laid out with intention, everything in its place. It respects the craft and the people who show up to do it. Warm but serious. Competent without being cold. + +### Aesthetic Direction +- **Tone:** A craftsman's workshop that happens to be digital. Dark, warm, information-dense but never cluttered. The warmth comes from material choices (amber/gold tones, warm neutrals) not from rounded corners and playful colors. +- **Primary theme:** Dark. Light mode exists as a secondary option but is not the priority. +- **Accent color:** Evolve the current gold (#946200) — keep the amber/forge warmth but refine it. Richer, more intentional. Consider a warm amber in OKLCH that reads well on dark surfaces without looking muddy. +- **Reference:** VS Code's layout patterns (sidebar, tabs, panels, terminal) are the right structural model. Users will feel at home. But the soul should feel purpose-built for Neverwinter Nights, not generic. +- **Anti-references:** Generic SaaS dashboards (white cards, blue primary buttons). Gamer aesthetic (neon, RGB, aggressive angles). Toy-like UI (bubbly, round, playful). Retro/pixel nostalgia. Corporate enterprise gray. AI slop (purple gradients, Inter font, cards nested in cards, bounce animations). + +### Design Principles +1. **Tools, not decoration.** Every element earns its space. No ornamental cards, no hero metrics, no dashboard widgets that exist to fill a grid. If it doesn't help someone build a module, it doesn't belong. +2. **Warm darks, not cold ones.** Tint surfaces toward the brand amber. Pure gray and pure black are banned. The dark theme should feel like firelight on stone, not a terminal at 3am. +3. **Density with breathing room.** IDE users expect information density. Give it to them, but use varied spacing to create rhythm and hierarchy. Tight where things are related, generous where sections change. +4. **Familiar structure, distinctive character.** VS Code conventions for navigation and layout. But the typography, color, and details should make it unmistakably Layonara Forge — not another Electron app clone. +5. **Craft the details.** Focus states, transitions, hover treatments, scrollbar styling, selection colors — these are where "functional but ugly" becomes "someone cared about this." + +### Technical Constraints +- React 19 + Vite 6 + Tailwind CSS 4 (no component library — custom components throughout) +- Monaco Editor (brings its own theming system, must integrate with app tokens) +- xterm.js terminal (needs theme integration) +- Must work in Chrome/Firefox/Edge on desktop. No mobile requirement. +- No Google Fonts dependency for body text preferred — the app runs on localhost, offline-capable is a plus. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..fbd0984 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,452 @@ +# Layonara Forge — Agent Handoff Document + +## What Is This + +Layonara Forge is a purpose-built NWN (Neverwinter Nights) Development IDE that runs as a local web application in Docker. It lets contributors fork, clone, edit, build, and run a complete Layonara NWNX NWN server with zero native tooling — only Docker required. The project name was chosen during brainstorming: "Forge" evokes crafting/building in a fantasy context. + +## Project Location + +- **Forge codebase**: `/home/jmg/dev/layonara/layonara-forge/` +- **Design spec**: `/home/jmg/dev/docs/superpowers/specs/2026-04-20-layonara-forge-design.md` +- **Implementation plans**: `/home/jmg/dev/docs/superpowers/plans/2026-04-20-forge-plan-{1..6}-*.md` +- **Gitea integration plan**: See Cursor plan file `gitea_+_forge_integration_8e0df077.plan.md` +- **Design context**: `.impeccable.md` at project root (personality: arcane, precise, deep) + +## Architecture + +``` +Host Machine +├── Browser → localhost:3000 (Forge UI) +├── NWN Toolset → writes GFFs to modules/temp0/ +├── ~/layonara-workspace/ (repos, server data, config) +└── Docker Socket + +Docker Network +├── layonara-forge (Node.js + React, serves UI, manages everything via Docker socket) +├── layonara-builder (ephemeral: nasher, nwn_script_comp, layonara_nwn, cmake, gcc) +├── layonara-nwserver (ghcr.io/plenarius/unified — NWN:EE + NWNX) +└── layonara-mariadb (mariadb:10.11 — game database) +``` + +The Forge container manages sibling containers via the Docker socket (Portainer pattern). Contributors clone one repo, set two paths in `.env`, run `docker compose up`, and open a browser. + +## Tech Stack + +- **Backend**: Node.js 20 + Express 5 + TypeScript (ES modules) +- **Frontend**: React 19 + Vite 6 + Tailwind CSS 4 (utility classes unreliable — inline styles used for layout) +- **Icons**: lucide-react (SVG icons throughout) +- **Fonts**: Self-hosted variable fonts via @fontsource-variable (Manrope, Alegreya, JetBrains Mono) +- **Code editor**: Monaco Editor with NWScript Monarch tokenizer +- **NWScript LSP**: Forked `layonara/nwscript-ee-language-server` connected via WebSocket JSON-RPC +- **Terminal**: xterm.js with child_process.spawn shell sessions +- **Docker API**: dockerode +- **Git**: simple-git (named import: `import { simpleGit } from "simple-git"`) +- **Git provider**: Gitea at `https://gitea.layonara.com` (NOT GitHub — see Gitea section) +- **Real-time**: WebSocket via ws library + +## What's Built (55 commits + UI overhaul session) + +All 6 implementation plans are complete. The codebase compiles and both Docker images build successfully. A complete UI/UX overhaul was performed (see "UI/UX Overhaul" section below). + +### Backend Services (`packages/backend/src/services/`) + + +| Service | File | Purpose | +| ------------ | ------------------------- | ------------------------------------------------------------------ | +| WebSocket | `ws.service.ts` | Event broadcasting to all connected clients | +| Workspace | `workspace.service.ts` | Directory structure management, forge.json config | +| Docker | `docker.service.ts` | Container CRUD, image pulls, ephemeral container runs | +| Build | `build.service.ts` | Module compile/pack, hot-reload, hak builds, NWNX builds | +| Server | `server.service.ts` | NWN server stack lifecycle (start/stop/restart MariaDB + nwserver) | +| Toolset | `toolset.service.ts` | temp0/ file watcher, GFF→JSON conversion, change management | +| Editor | `editor.service.ts` | File CRUD, directory trees, workspace search | +| Git | `git.service.ts` | Clone, pull, commit, push, diff, upstream polling | +| Git Provider | `git-provider.service.ts` | Gitea API (fork, PR, token validation) | +| Terminal | `terminal.service.ts` | Shell session management via child_process | +| LSP | `lsp.service.ts` | NWScript language server process management | + + +### Backend Routes (`packages/backend/src/routes/`) + + +| Route | Prefix | Endpoints | +| --------- | ---------------- | ----------------------------------------------------------------- | +| workspace | `/api/workspace` | GET /config, PUT /config, POST /init | +| docker | `/api/docker` | containers, pull, start/stop/restart, logs | +| build | `/api/build` | module/compile, module/pack, deploy, compile-single, haks, nwnx | +| server | `/api/server` | status, start, stop, restart, generate-config, seed-db, sql | +| toolset | `/api/toolset` | status, start, stop, changes, apply, apply-all, discard | +| editor | `/api/editor` | tree, file CRUD, search, resref, tlk, 2da, gff-schema | +| github | `/api/github` | validate-pat (actually Gitea token), fork, forks, pr, prs | +| repos | `/api/repos` | clone, list (/), status (/:repo/status), pull, commit, push, diff | +| terminal | `/api/terminal` | sessions CRUD | + + +### Frontend Pages (`packages/frontend/src/pages/`) + + +| Page | Route | Purpose | +| --------- | ----------- | ----------------------------------------------------------- | +| Dashboard | `/` | Server status, repo summary, quick actions (3-column cards) | +| Editor | `/editor` | Monaco editor with file explorer, tabs, GFF visual editors | +| Build | `/build` | Module/hak/NWNX build sections with streaming output | +| Server | `/server` | Controls, log viewer with filter, SQL console | +| Toolset | `/toolset` | temp0/ watcher status, change table, diff viewer | +| Repos | `/repos` | Git status cards, commit dialog, PR creation | +| Settings | `/settings` | PAT, theme, editable paths, Docker images, shortcuts, reset | +| Setup | `/setup` | 4-phase onboarding wizard with 10 steps | + + +### Special Features + +- **NWScript syntax highlighting**: Monarch tokenizer with keyword/type/comment/string/preprocessor rules +- **SQL highlighting in NWScript strings**: Detects `NWNX_SQL_PrepareQuery()` calls, highlights SQL keywords in teal +- **Resref auto-lookup**: Backend indexes all GFF JSON files, hover on resref strings shows the item/creature/area +- **TLK preview**: Hover on integer literals shows the TLK string (handles 16777216 custom offset) +- **2DA intellisense**: Parses 2da files, provides completion for `Get2DAString` calls +- **Visual GFF editors**: Form-based editors for .uti, .utc, .are, .dlg, .utp, .utm JSON files +- **Conventional commit enforcement**: Type dropdown (feat/fix/refactor/etc), rejects malformed messages +- **Dark/light theme**: OKLCH CSS custom properties toggled via `light` class on root element + +## UI/UX Overhaul (April 21, 2026 session) + +A complete design overhaul was performed using the [Impeccable](https://impeccable.style/) design skill system. The design context is documented in `.impeccable.md`. + +### Design System + +**Personality**: Arcane, precise, deep — a craftsman's workbench. + +**Fonts** (all self-hosted via `@fontsource-variable`, no Google Fonts): + +- **Body/UI**: Manrope Variable — warm geometric sans +- **Headings**: Alegreya Variable — calligraphic serif with manuscript roots +- **Code/mono**: JetBrains Mono Variable + +**Color palette** (full OKLCH, 30+ tokens in `globals.css`): + +- Surfaces tinted toward amber (hue 65) — "warm darks, not cold ones" +- 3-level depth: `--forge-bg` → `--forge-surface` → `--forge-surface-raised` +- Accent: evolved gold `oklch(58% 0.155 65)` with hover and subtle variants +- Semantic colors: success (forest green, hue 150), danger (brick red, hue 25), warning (golden, hue 80), info (steel blue, hue 230) +- Each semantic color has base, bg, and border variants for both dark and light modes +- Log panels: dedicated `--forge-log-bg` / `--forge-log-text` tokens + +**Icons**: lucide-react SVG icons throughout (Code2, Wrench, Hammer, Play, GitBranch, Settings, Sun/Moon, Terminal, etc.) + +**Type scale** (fixed rem for IDE density): + +- `--text-xs` (11px) through `--text-2xl` (28px), ~1.25 ratio + +### What Changed + +**Foundation**: + +- Replaced Inter font with Manrope Variable, Baskerville with Alegreya Variable +- Removed Google Fonts `` — all fonts bundled as npm deps +- Full OKLCH palette replacing all hex values (~60 hard-coded colors replaced) +- All Tailwind semantic color classes (`green-400`, `red-500/20`, etc.) replaced with forge tokens +- Global CSS: scrollbar theming, selection color, input/button base styles, `:focus-visible` ring, `prefers-reduced-motion` + +**IDE Shell** (`IDELayout.tsx`): + +- Lucide SVG icons replacing Unicode emoji in nav rail +- Removed 3px left border stripe (impeccable anti-pattern ban) +- Sidebar only shows on `/editor` route (was showing on all pages) +- All layout uses inline styles (Tailwind flex classes were not reliably applying) +- Terminal toggle bar with Terminal/Chevron icons + +**All 8 pages rewritten** with consistent patterns: + +- Card containers: `--forge-surface` bg, `--forge-border`, `0.75rem` radius +- Section headers: uppercase, `--text-xs`, icon + label +- Buttons: accent primary, outline secondary, danger for destructive +- Status badges: semantic colors with dots +- All inline styles (Tailwind utility classes unreliable for layout in this project) + +**Setup wizard**: + +- 4-phase indicator (Environment → Authentication → Repositories → Finalize) with numbered circles + connecting lines, matching James's work app wizard pattern +- Steps reordered: Workspace + NWN Home before Gitea Token +- PathInput component with folder icon for path fields +- StatusDot component replacing emoji (✅❌⏳) with styled HTML elements +- Navigation: ghost "← Back" left, accent "Next →" right, border-top separator + +**Performance**: + +- Routes code-split via `React.lazy()` — 10 chunks instead of 1 (760KB → initial 15KB app shell) +- Page chunks: Editor 98KB, Setup 16KB, Repos 13KB, others 5-8KB each + +**Accessibility**: + +- `:focus-visible` outline on all interactive elements +- `aria-label="Main navigation"` on nav +- Tab close button changed from `` to ` @@ -133,7 +133,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps) onClick={() => handleSubmit(true)} disabled={!isValid || loading} className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50" - style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }} + style={{ backgroundColor: "var(--forge-warning-bg)", borderColor: "var(--forge-warning-border)", color: "var(--forge-warning)" }} > Commit & Push diff --git a/packages/frontend/src/components/ErrorDisplay.tsx b/packages/frontend/src/components/ErrorDisplay.tsx index 1b3ac2b..4a866df 100644 --- a/packages/frontend/src/components/ErrorDisplay.tsx +++ b/packages/frontend/src/components/ErrorDisplay.tsx @@ -25,10 +25,10 @@ export function ErrorDisplay({ className="rounded-lg p-6" style={{ backgroundColor: "var(--forge-surface)", - border: "1px solid #7f1d1d", + border: "1px solid var(--forge-danger-border)", }} > -

+

{title}

@@ -50,7 +50,7 @@ export function ErrorDisplay({ style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)", - fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontFamily: "var(--font-mono)", }} > {fullLog} @@ -64,7 +64,7 @@ export function ErrorDisplay({ diff --git a/packages/frontend/src/components/Toast.tsx b/packages/frontend/src/components/Toast.tsx index 68cf7d4..90cb43c 100644 --- a/packages/frontend/src/components/Toast.tsx +++ b/packages/frontend/src/components/Toast.tsx @@ -29,9 +29,9 @@ export function useToast() { } const COLORS: Record = { - success: { bg: "#052e16", border: "#166534", text: "#4ade80" }, - error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" }, - info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" }, + success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" }, + error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" }, + info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" }, }; const AUTO_DISMISS: Record = { @@ -49,7 +49,7 @@ function ToastItem({ }) { const { bg, border, text } = COLORS[toast.type]; const timeout = AUTO_DISMISS[toast.type]; - const timerRef = useRef>(); + const timerRef = useRef>(undefined); useEffect(() => { if (timeout) { @@ -92,7 +92,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { return ( {children} -

+
{toasts.map((t) => ( ))} diff --git a/packages/frontend/src/components/editor/EditorTabs.tsx b/packages/frontend/src/components/editor/EditorTabs.tsx index 437ba4b..91f76aa 100644 --- a/packages/frontend/src/components/editor/EditorTabs.tsx +++ b/packages/frontend/src/components/editor/EditorTabs.tsx @@ -56,16 +56,18 @@ export function EditorTabs({ /> )} - { e.stopPropagation(); onClose(tab.path); }} className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100" - style={{ color: "var(--forge-text-secondary)" }} + style={{ appearance: "none", border: "none", background: "transparent", cursor: "pointer", color: "var(--forge-text-secondary)" }} > × - + ); })} diff --git a/packages/frontend/src/components/editor/FileExplorer.tsx b/packages/frontend/src/components/editor/FileExplorer.tsx index 5c372e5..f78ea07 100644 --- a/packages/frontend/src/components/editor/FileExplorer.tsx +++ b/packages/frontend/src/components/editor/FileExplorer.tsx @@ -87,7 +87,7 @@ function FileTreeNode({ paddingLeft: `${depth * 16 + 8}px`, backgroundColor: isSelected ? "var(--forge-surface)" : undefined, color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)", - fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontFamily: "var(--font-mono)", fontSize: "13px", }} > @@ -186,8 +186,17 @@ export function FileExplorer({ )} {error && ( -
- {error} +
+ {error.includes("ENOENT") ? ( +
+

Repository not cloned

+

+ Clone repositories from the Repos page or run the setup wizard. +

+
+ ) : ( +

{error}

+ )}
)} diff --git a/packages/frontend/src/components/editor/MonacoEditor.tsx b/packages/frontend/src/components/editor/MonacoEditor.tsx index 9e03914..ac424b8 100644 --- a/packages/frontend/src/components/editor/MonacoEditor.tsx +++ b/packages/frontend/src/components/editor/MonacoEditor.tsx @@ -167,13 +167,14 @@ function registerNWScript(monaco: Parameters[1]) { function defineForgeTheme(monaco: Parameters[1]) { const style = getComputedStyle(document.documentElement); - const bg = style.getPropertyValue("--forge-bg").trim() || "#121212"; - const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e"; - const accent = style.getPropertyValue("--forge-accent").trim() || "#946200"; - const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2"; + const bg = style.getPropertyValue("--forge-bg").trim() || "#1f1a14"; + const surface = style.getPropertyValue("--forge-surface").trim() || "#2a2419"; + const accent = style.getPropertyValue("--forge-accent").trim() || "#b07a1a"; + const text = style.getPropertyValue("--forge-text").trim() || "#ede8e0"; const textSecondary = - style.getPropertyValue("--forge-text-secondary").trim() || "#888888"; - const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e"; + style.getPropertyValue("--forge-text-secondary").trim() || "#9a9080"; + const border = style.getPropertyValue("--forge-border").trim() || "#3d3528"; + const accentSubtle = style.getPropertyValue("--forge-accent-subtle").trim() || "#2e2818"; monaco.editor.defineTheme("forge-dark", { base: "vs-dark", @@ -196,7 +197,7 @@ function defineForgeTheme(monaco: Parameters[1]) { "editor.lineHighlightBackground": surface, "editorLineNumber.foreground": textSecondary, "editorLineNumber.activeForeground": text, - "editor.selectionBackground": "#264f7840", + "editor.selectionBackground": accentSubtle, "editorWidget.background": surface, "editorWidget.border": border, "editorSuggestWidget.background": surface, diff --git a/packages/frontend/src/components/editor/SearchPanel.tsx b/packages/frontend/src/components/editor/SearchPanel.tsx index 481bad7..bc4589c 100644 --- a/packages/frontend/src/components/editor/SearchPanel.tsx +++ b/packages/frontend/src/components/editor/SearchPanel.tsx @@ -82,9 +82,9 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) { const toggleBtnStyle = (active: boolean): React.CSSProperties => ({ backgroundColor: active ? "var(--forge-accent)" : "transparent", - color: active ? "#121212" : "var(--forge-text-secondary)", + color: active ? "var(--forge-accent-text)" : "var(--forge-text-secondary)", border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`, - fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontFamily: "var(--font-mono)", fontSize: "12px", lineHeight: "1", }); @@ -119,7 +119,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) { backgroundColor: "var(--forge-surface)", color: "var(--forge-text)", border: "1px solid var(--forge-border)", - fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontFamily: "var(--font-mono)", fontSize: "13px", }} /> @@ -172,7 +172,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) { className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50" style={{ backgroundColor: "var(--forge-accent)", - color: "#121212", + color: "var(--forge-accent-text)", }} > {loading ? "Searching..." : "Search"} @@ -181,7 +181,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
{error && ( -
{error}
+
{error}
)} {searched && !loading && !error && ( @@ -213,7 +213,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) { {group.file} @@ -240,7 +240,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) { className="shrink-0 text-xs" style={{ color: "var(--forge-text-secondary)", - fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontFamily: "var(--font-mono)", fontSize: "11px", minWidth: "32px", textAlign: "right", @@ -251,7 +251,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) { {type === "entry" ? "E" : "R"}{index} @@ -287,7 +287,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }: onClick={handleSave} disabled={!dirty || saving} className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40" - style={{ backgroundColor: "var(--forge-accent)", color: "#fff" }} + style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }} > {saving ? "Saving..." : "Save"} @@ -317,7 +317,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }: {/* Content */}
{error && ( -

{error}

+

{error}

)} {activeTab === "tree" && ( diff --git a/packages/frontend/src/components/gff/GffEditor.tsx b/packages/frontend/src/components/gff/GffEditor.tsx index b00cbff..1cad62d 100644 --- a/packages/frontend/src/components/gff/GffEditor.tsx +++ b/packages/frontend/src/components/gff/GffEditor.tsx @@ -215,13 +215,13 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende className="flex-1 rounded border px-2 py-1.5 font-mono text-sm" style={{ backgroundColor: "var(--forge-bg)", - borderColor: valid ? "var(--forge-border)" : "#ef4444", + borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)", color: "var(--forge-text)", }} /> {str.length}/16 @@ -421,7 +421,7 @@ export function GffEditor({ return (
-

{error}

+

{error}

{onSwitchToRaw && (
@@ -480,7 +480,7 @@ export function GffEditor({ className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40" style={{ backgroundColor: "var(--forge-accent)", - color: "#fff", + color: "var(--forge-accent-text)", }} > {saving ? "Saving..." : "Save"} diff --git a/packages/frontend/src/components/gff/ItemEditor.tsx b/packages/frontend/src/components/gff/ItemEditor.tsx index 665da74..9ed1c81 100644 --- a/packages/frontend/src/components/gff/ItemEditor.tsx +++ b/packages/frontend/src/components/gff/ItemEditor.tsx @@ -141,7 +141,7 @@ function PropertiesListOverride({ value }: FieldOverrideProps) { diff --git a/packages/frontend/src/components/terminal/Terminal.tsx b/packages/frontend/src/components/terminal/Terminal.tsx index 740046b..fa03dc0 100644 --- a/packages/frontend/src/components/terminal/Terminal.tsx +++ b/packages/frontend/src/components/terminal/Terminal.tsx @@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) { useEffect(() => { if (!containerRef.current) return; + const style = getComputedStyle(document.documentElement); + const bg = style.getPropertyValue("--forge-bg").trim(); + const fg = style.getPropertyValue("--forge-text").trim(); + const accent = style.getPropertyValue("--forge-accent").trim(); + const secondary = style.getPropertyValue("--forge-text-secondary").trim(); + const accentHover = style.getPropertyValue("--forge-accent-hover").trim(); + const term = new XTerm({ theme: { - background: "#121212", - foreground: "#f2f2f2", - cursor: "#946200", - selectionBackground: "#946200", - selectionForeground: "#f2f2f2", - black: "#121212", - brightBlack: "#666666", - white: "#f2f2f2", - brightWhite: "#ffffff", - yellow: "#946200", - brightYellow: "#c48800", + background: bg, + foreground: fg, + cursor: accent, + selectionBackground: accent, + selectionForeground: fg, + black: bg, + brightBlack: secondary, + white: fg, + brightWhite: fg, + yellow: accent, + brightYellow: accentHover, }, - fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontFamily: "var(--font-mono)", fontSize: 13, cursorBlink: true, }); @@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
); } diff --git a/packages/frontend/src/layouts/IDELayout.tsx b/packages/frontend/src/layouts/IDELayout.tsx index 536639d..5bf7799 100644 --- a/packages/frontend/src/layouts/IDELayout.tsx +++ b/packages/frontend/src/layouts/IDELayout.tsx @@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"; import { Terminal } from "../components/terminal/Terminal"; import { useWebSocket } from "../hooks/useWebSocket"; import { useTheme } from "../hooks/useTheme"; +import { + Code2, + Wrench, + Hammer, + Play, + GitBranch, + Settings, + Sun, + Moon, + Terminal as TerminalIcon, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; -const NAV_ITEMS = [ - { path: "/editor", label: "Editor", icon: "\u270E" }, - { path: "/toolset", label: "Toolset", icon: "\u2699" }, - { path: "/build", label: "Build", icon: "\u2692" }, - { path: "/server", label: "Server", icon: "\u25B6" }, - { path: "/repos", label: "Repos", icon: "\u2387" }, - { path: "/settings", label: "Settings", icon: "\u2318" }, +const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [ + { path: "/editor", label: "Editor", Icon: Code2 }, + { path: "/toolset", label: "Toolset", Icon: Wrench }, + { path: "/build", label: "Build", Icon: Hammer }, + { path: "/server", label: "Server", Icon: Play }, + { path: "/repos", label: "Repos", Icon: GitBranch }, + { path: "/settings", label: "Settings", Icon: Settings }, ]; export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { @@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { const navigate = useNavigate(); const { subscribe } = useWebSocket(); const { theme, toggleTheme } = useTheme(); + const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/"); useEffect(() => { return subscribe("git:upstream-update", (event) => { @@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) { }; return ( -
+
{/* Left sidebar nav */} {/* Main content area */} -
+
-
- - Layonara Forge - -
-
+ + Layonara Forge +
-
- {sidebar && ( +
+ {sidebar && showSidebar && ( )} -
+
{terminalOpen && (
diff --git a/packages/frontend/src/layouts/SetupLayout.tsx b/packages/frontend/src/layouts/SetupLayout.tsx index 5086ce1..e0d417c 100644 --- a/packages/frontend/src/layouts/SetupLayout.tsx +++ b/packages/frontend/src/layouts/SetupLayout.tsx @@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom"; export function SetupLayout() { return (
-
-

- Layonara Forge -

+
+
+

+ Layonara Forge +

+

+ Development environment setup +

+
diff --git a/packages/frontend/src/pages/Build.tsx b/packages/frontend/src/pages/Build.tsx index 7ef15a5..fa93788 100644 --- a/packages/frontend/src/pages/Build.tsx +++ b/packages/frontend/src/pages/Build.tsx @@ -1,6 +1,17 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { api } from "../services/api"; import { useWebSocket } from "../hooks/useWebSocket"; +import { + Hammer, + Package, + Cpu, + Play, + Archive, + Upload, + ChevronDown, + ChevronUp, + AlertTriangle, +} from "lucide-react"; type BuildStatus = "idle" | "building" | "success" | "failed"; @@ -11,15 +22,38 @@ interface BuildSectionState { } function StatusBadge({ status }: { status: BuildStatus }) { - const colors: Record = { - idle: "bg-gray-500/20 text-gray-400", - building: "bg-yellow-500/20 text-yellow-400", - success: "bg-green-500/20 text-green-400", - failed: "bg-red-500/20 text-red-400", + const styles: Record = { + idle: { + backgroundColor: "var(--forge-surface-raised)", + color: "var(--forge-text-secondary)", + }, + building: { + backgroundColor: "var(--forge-warning-bg)", + color: "var(--forge-warning)", + }, + success: { + backgroundColor: "var(--forge-success-bg)", + color: "var(--forge-success)", + }, + failed: { + backgroundColor: "var(--forge-danger-bg)", + color: "var(--forge-danger)", + }, }; return ( - + {status} ); @@ -43,31 +77,47 @@ function BuildOutput({ }, [lines, collapsed]); return ( -
+
{!collapsed && (
{lines.length === 0 ? ( - No output yet + + No output yet + ) : ( lines.map((line, i) => ( -
+
{line}
)) @@ -83,27 +133,29 @@ function ActionButton({ onClick, disabled, variant = "default", + icon, }: { label: string; onClick: () => void; disabled?: boolean; variant?: "default" | "primary" | "warning"; + icon?: React.ReactNode; }) { - const styles = { + const variantStyles: Record = { default: { - backgroundColor: "var(--forge-surface)", - borderColor: "var(--forge-border)", + backgroundColor: "var(--forge-surface-raised)", + border: "1px solid var(--forge-border)", color: "var(--forge-text)", }, primary: { backgroundColor: "var(--forge-accent)", - borderColor: "var(--forge-accent)", - color: "#fff", + border: "none", + color: "var(--forge-accent-text)", }, warning: { - backgroundColor: "#854d0e", - borderColor: "#a16207", - color: "#fef08a", + backgroundColor: "var(--forge-warning-bg)", + border: "1px solid var(--forge-warning-border)", + color: "var(--forge-warning)", }, }; @@ -111,9 +163,22 @@ function ActionButton({ ); @@ -191,41 +256,103 @@ export function Build() { [], ); - const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building"; + const isBuilding = + module.status === "building" || haks.status === "building" || nwnx.status === "building"; + + const cardStyle: React.CSSProperties = { + backgroundColor: "var(--forge-surface)", + border: "1px solid var(--forge-border)", + borderRadius: "0.75rem", + padding: "1.25rem", + marginBottom: "1rem", + }; + + const sectionHeaderStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + marginBottom: "1rem", + }; + + const sectionTitleStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "0.5rem", + textTransform: "uppercase", + fontSize: "var(--text-xs)", + fontWeight: 600, + letterSpacing: "0.05em", + color: "var(--forge-text-secondary)", + fontFamily: "var(--font-heading)", + }; + + const buttonRowStyle: React.CSSProperties = { + display: "flex", + flexWrap: "wrap", + gap: "0.5rem", + alignItems: "center", + }; return ( -
-

- Build Pipeline -

+
+
+

+ Build Pipeline +

+

+ Compile, pack, and deploy module resources +

+
{/* Module Section */} -
-
-

Module

+
+
+
+ + Module +
-
+
} disabled={isBuilding} onClick={() => handleAction(() => api.build.compileModule(), "module")} /> } disabled={isBuilding} onClick={() => handleAction(() => api.build.packModule(), "module")} /> } disabled={isBuilding} onClick={() => handleAction(() => api.build.deploy(), "module")} /> @@ -238,21 +365,19 @@ export function Build() {
{/* Haks Section */} -
-
-

Haks

+
+
+
+ + Haks +
-
+
} disabled={isBuilding} onClick={() => handleAction(() => api.build.buildHaks(), "haks")} /> @@ -265,38 +390,49 @@ export function Build() {
{/* NWNX Section */} -
-
-

- NWNX (Advanced) -

+
+
+
+ + NWNX + + (Advanced) + +
-
+
} disabled={isBuilding} onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")} />
-
+
setNwnxTarget(e.target.value)} placeholder="Target (e.g. Item, Creature)" - className="rounded border px-3 py-1.5 text-sm" style={{ backgroundColor: "var(--forge-bg)", - borderColor: "var(--forge-border)", + border: "1px solid var(--forge-border)", + borderRadius: "0.375rem", + padding: "0.5rem 0.75rem", + fontSize: "var(--text-sm)", color: "var(--forge-text)", + fontFamily: "var(--font-sans)", + outline: "none", + flex: "0 1 16rem", }} />

- ⚠ Requires server restart to pick up changes + + Requires server restart to pick up changes

({ + width: "0.5rem", + height: "0.5rem", + borderRadius: "50%", + backgroundColor: color, + flexShrink: 0, +}); + +const primaryBtn: React.CSSProperties = { + backgroundColor: "var(--forge-accent)", + color: "var(--forge-accent-text)", + border: "none", + borderRadius: "0.375rem", + padding: "0.5rem 1rem", + fontSize: "var(--text-sm)", + fontWeight: 600, + cursor: "pointer", + width: "100%", + transition: "background-color 150ms", +}; function StatusBadge({ status }: { status: string }) { const color = status === "running" - ? "#4ade80" - : status === "stopped" - ? "#f87171" - : "#fbbf24"; + ? "var(--forge-success)" + : status === "stopped" || status === "exited" || status === "not created" + ? "var(--forge-danger)" + : "var(--forge-warning)"; return ( - + {status} ); @@ -57,49 +104,36 @@ function ServerCard() { } }; + const isRunning = status.nwserver === "running"; + return ( -
-

- Server Status -

-
-
-
- - NWServer - - -
-
- - MariaDB - - -
+
+

Server

+
+
+ NWServer + +
+
+ MariaDB +
-
+
@@ -120,39 +154,32 @@ function ReposSummary() { }, []); return ( -
-

- Repositories -

-
- {repos.map((repo) => { +
+

Repositories

+
+ {repos.map((repo, i) => { const s = repoStatus[repo]; const branch = (s?.branch as string) || "\u2014"; const clean = s?.clean !== false; return (
0 ? "1px solid var(--forge-border)" : undefined, + }} > - + {repo} -
- +
+ {branch} - +
); @@ -166,41 +193,38 @@ function QuickActions() { const navigate = useNavigate(); const actions = [ - { label: "Build Module", onClick: () => navigate("/build") }, - { label: "Build Haks", onClick: () => navigate("/build") }, - { label: "Open Editor", onClick: () => navigate("/editor") }, - { - label: "Open Terminal", - onClick: () => { - /* terminal is toggled from IDELayout via Ctrl+` */ - navigate("/editor"); - }, - }, + { label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") }, + { label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") }, + { label: "Server Logs", Icon: Database, onClick: () => navigate("/server") }, ]; return ( -
-

- Quick Actions -

-
+
+

Quick Actions

+
{actions.map((a) => ( ))} @@ -211,24 +235,16 @@ function QuickActions() { export function Dashboard() { return ( -
-
-

- Layonara Forge +
+
+

+ Dashboard

-

- NWN Development Environment +

+ Server, repositories, and quick actions

-
-
- -
+
+
diff --git a/packages/frontend/src/pages/Editor.tsx b/packages/frontend/src/pages/Editor.tsx index cfa6565..4a5f940 100644 --- a/packages/frontend/src/pages/Editor.tsx +++ b/packages/frontend/src/pages/Editor.tsx @@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor"; import { CreatureEditor } from "../components/gff/CreatureEditor"; import { AreaEditor } from "../components/gff/AreaEditor"; import { DialogEditor } from "../components/gff/DialogEditor"; +import { FileCode, Code2, Eye } from "lucide-react"; const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"]; @@ -50,7 +51,6 @@ export function Editor({ editorState }: EditorProps) { markClean, } = editorState; - // Track per-tab editor mode: "visual" or "raw". GFF files default to visual. const [editorModes, setEditorModes] = useState>({}); const tabs = useMemo( @@ -105,8 +105,28 @@ export function Editor({ editorState }: EditorProps) { const renderEditor = () => { if (!activeTab) { return ( -
-

+

+ +

Open a file from the File Explorer to start editing

@@ -139,22 +159,41 @@ export function Editor({ editorState }: EditorProps) { } return ( -
+
{isActiveGff && activeMode === "raw" && (
)} -
+
+
-
+
{renderEditor()}
diff --git a/packages/frontend/src/pages/Repos.tsx b/packages/frontend/src/pages/Repos.tsx index 3f20be1..fec49c6 100644 --- a/packages/frontend/src/pages/Repos.tsx +++ b/packages/frontend/src/pages/Repos.tsx @@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "../services/api"; import { CommitDialog } from "../components/CommitDialog"; import { useWebSocket } from "../hooks/useWebSocket"; +import { + GitBranch, + GitCommit, + GitPullRequest, + Download, + Upload, + Copy, + FileCode, + AlertCircle, + CheckCircle, + X, +} from "lucide-react"; interface RepoStatus { modified: string[]; @@ -26,6 +38,54 @@ interface PrForm { body: string; } +const badge = ( + bg: string, + fg: string, + extra?: React.CSSProperties, +): React.CSSProperties => ({ + display: "inline-flex", + alignItems: "center", + gap: "0.3rem", + padding: "0.15rem 0.55rem", + borderRadius: "9999px", + fontSize: "var(--text-xs)", + fontWeight: 600, + lineHeight: 1.4, + backgroundColor: bg, + color: fg, + whiteSpace: "nowrap", + ...extra, +}); + +const btnBase: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: "0.4rem", + padding: "0.4rem 0.85rem", + borderRadius: "0.5rem", + fontSize: "var(--text-sm)", + fontWeight: 600, + fontFamily: "var(--font-sans)", + border: "1px solid", + cursor: "pointer", + transition: "opacity 0.15s", + lineHeight: 1.4, +}; + +const outlineBtn: React.CSSProperties = { + ...btnBase, + backgroundColor: "var(--forge-surface)", + borderColor: "var(--forge-border)", + color: "var(--forge-text)", +}; + +const accentBtn: React.CSSProperties = { + ...btnBase, + backgroundColor: "var(--forge-accent)", + borderColor: "var(--forge-accent)", + color: "var(--forge-accent-text)", +}; + export function Repos() { const [repos, setRepos] = useState([]); const [loading, setLoading] = useState(true); @@ -133,106 +193,247 @@ export function Repos() { const isDirty = (status?: RepoStatus) => status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0); + const disabledStyle = (disabled: boolean | undefined): React.CSSProperties => + disabled ? { opacity: 0.45, pointerEvents: "none" } : {}; + if (loading) { return ( -
- Loading repositories... +
+ Loading repositories…
); } return ( -
-

- Repositories -

+
+ {/* Page heading */} +
+

+ Repositories +

+

+ Clone, sync, and manage your Layonara repos +

+
+ {/* Error banner */} {error && ( -
- {error} - +
+ + {error} +
)} + {/* PR success banner */} {prResult && ( -
- PR created: {prResult.url} - +
+ + + PR created:{" "} + + {prResult.url} + + +
)} -
+ {/* Repo cards */} +
{repos.map((repo) => (
-
-
-

{repo.name}

- + {/* Card header */} +
+
+

+ {repo.name} +

+ + + {repo.branch} + {repo.cloned && repo.status && ( <> - - {isDirty(repo.status) ? "dirty" : "clean"} - - {repo.status.behind > 0 && ( - - {repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream + {isDirty(repo.status) ? ( + + dirty + + ) : ( + + + clean )} + + {repo.status.behind > 0 && ( + + + {repo.status.behind} behind + + )} + {repo.status.ahead > 0 && ( - + + {repo.status.ahead} ahead )} )}
- + + {repo.upstream}
+ {/* Actions */} {!repo.cloned ? ( ) : ( <> -
+
- + + + +
+ {/* Changed files list */} {repo.status && isDirty(repo.status) && ( -
-
- Changes +
+
+ + + Changes +
-
+ +
{repo.status.modified.map((f) => (
handleShowDiff(repo.name, f)} + style={{ + display: "flex", + alignItems: "center", + gap: "0.5rem", + padding: "0.35rem 0.75rem", + fontSize: "var(--text-xs)", + cursor: "pointer", + borderBottom: "1px solid var(--forge-border)", + }} > - M - {f} + M + {f}
))} {repo.status.staged.map((f) => ( -
- S - {f} +
+ S + {f}
))} {repo.status.untracked.map((f) => ( -
- ? - {f} +
+ ? + {f}
))}
@@ -285,6 +551,7 @@ export function Repos() { ))}
+ {/* Commit dialog */} {commitRepo && ( )} + {/* PR form modal */} {prForm && ( -
setPrForm(null)}> +
setPrForm(null)} + style={{ + position: "fixed", + inset: 0, + zIndex: 50, + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0,0,0,0.6)", + }} + >
e.stopPropagation()} + style={{ + width: "100%", + maxWidth: "32rem", + backgroundColor: "var(--forge-surface)", + border: "1px solid var(--forge-border)", + borderRadius: "0.75rem", + padding: "1.5rem", + }} > -

- Create Pull Request — {prForm.repo} -

+
+ +

+ Create Pull Request +

+ + — {prForm.repo} + +
+ setPrForm({ ...prForm, title: e.target.value })} placeholder="PR Title" - className="mb-3 w-full rounded border px-3 py-1.5 text-sm" - style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }} + style={{ + width: "100%", + padding: "0.5rem 0.75rem", + marginBottom: "0.75rem", + borderRadius: "0.5rem", + border: "1px solid var(--forge-border)", + backgroundColor: "var(--forge-bg)", + color: "var(--forge-text)", + fontSize: "var(--text-sm)", + fontFamily: "var(--font-sans)", + boxSizing: "border-box", + outline: "none", + }} /> +