diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cf19ab..46b43db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,21 +28,42 @@ jobs: dotnet-version: '10.0.x' - name: Restore - run: dotnet restore MuGlyph.slnx + run: dotnet restore SharpMUTerm.slnx - name: Build - run: dotnet build MuGlyph.slnx -c Release --no-restore + run: dotnet build SharpMUTerm.slnx -c Release --no-restore # TUnit runs on Microsoft.Testing.Platform; execute each test project directly # (the classic `dotnet test`/VSTest path is not used by MTP on .NET 10). - name: Test — Core shell: bash - run: dotnet run -c Release --no-build --project tests/MuClient.Core.Tests/MuClient.Core.Tests.csproj + run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Core.Tests/SharpMUTerm.Core.Tests.csproj - name: Test — Graphics shell: bash - run: dotnet run -c Release --no-build --project tests/MuClient.Graphics.Tests/MuClient.Graphics.Tests.csproj + run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Graphics.Tests/SharpMUTerm.Graphics.Tests.csproj - name: Test — Scripting shell: bash - run: dotnet run -c Release --no-build --project tests/MuClient.Scripting.Tests/MuClient.Scripting.Tests.csproj + run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Scripting.Tests/SharpMUTerm.Scripting.Tests.csproj + + - name: Test — Web + shell: bash + run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Web.Tests/SharpMUTerm.Web.Tests.csproj + + - name: Test — Tui + shell: bash + run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Tui.Tests/SharpMUTerm.Tui.Tests.csproj + + # End-to-end guard: the headless snapshot must render the workspace (rail, worlds, command + # surface) and exit — catches both UI-render regressions and any return of the stdin-block hang. + - name: Smoke — headless snapshot + shell: bash + run: | + dotnet run -c Release --no-build --project src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj -- \ + --snapshot --size 120x30 --out snapshot.ans + dotnet publish src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj + -p:PublishProfile=${{ matrix.profile }} + -o publish/${{ matrix.rid }} + + - name: Package (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + cd publish/${{ matrix.rid }} + tar -czf ../../sharpmuterm-${{ matrix.rid }}.tar.gz sharpmuterm + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + cd publish/${{ matrix.rid }} + 7z a ../../sharpmuterm-${{ matrix.rid }}.zip sharpmuterm.exe + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: sharpmuterm-${{ matrix.rid }} + path: sharpmuterm-${{ matrix.rid }}.* + + # Serialize release creation in a single downstream job so the two matrix jobs don't race + # softprops/action-gh-release on the same tag. + release: + needs: publish + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create release and upload assets + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* diff --git a/.gitignore b/.gitignore index cccf1c4..9c4a9fd 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,8 @@ desktop.ini appsettings.*.local.json *.local.json secrets.json +.claude/ + +# Python bytecode cache +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md index 0778faa..535948e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,11 @@ -# CLAUDE.md — MuGlyph agent brief +# CLAUDE.md — SharpMUTerm agent brief Guidance for any Claude agent working in this repository. Read this first, then read [`docs/PLAN.md`](docs/PLAN.md) — the plan is the authoritative architecture + roadmap. ## What this project is -**MuGlyph** is a cross-platform TUI **MU\*** (MUSH / MUCK / MUD) client in **C# / .NET 10**, +**SharpMUTerm** is a cross-platform TUI **MU\*** (MUSH / MUCK / MUD) client in **C# / .NET 10**, targeting feature parity with [BeipMU](https://beipdev.github.io/BeipMU/), running inside GPU-accelerated terminals (Kitty, WezTerm, Ghostty) on **Windows and Linux**. @@ -16,28 +16,37 @@ fallbacks) for inline images/maps. ## Locked decisions (do not relitigate without asking) - **Target framework:** `net10.0`. -- **TUI base:** Terminal.Gui **v2** (prerelease) for windows/input/layout/scrollback, plus a - custom placeholder-based `GraphicsView` for images. +- **TUI base:** **SharpConsoleUI** (`nickprotop/ConsoleEx`, stable, net8/9/10) — a compositor-based + framework with split layouts, tabs, resizable/mouse-draggable windows, Spectre-style markup, and + a **native Kitty graphics protocol** (+ Sixel/half-block) for inline images. Replaced Terminal.Gui + v2 (which was prerelease with an `[Obsolete]` mid-migration API); the switch is contained to + `SharpMUTerm.Tui` because `SharpMUTerm.Core` is UI-agnostic. - **Scripting:** Lua via **MoonSharp** (pure-managed, sandboxed). - **Inline graphics:** in scope from day one (Kitty Unicode placeholders → Sixel → half-block). - **Protocols:** aim for all common MU\* protocols. GMCP/MSSP/CHARSET/NAWS/MTTS/EOR via TelnetNegotiationCore; **MCCP, MSDP, MXP, and Pueblo are our own app layer.** -- **Config:** fresh JSON schema of our own + a **BeipMU import/migration** path. +- **Config:** fresh JSON schema of our own (worlds hold characters; automation lives in shared + named trigger sets), versioned with automatic migration between schema revisions. - **License:** MIT. ## Repository state -**M1 delivered, plus substantial M2–M4 work.** `MuGlyph.slnx` builds all seven projects on -`net10.0`; the solution has **195 passing tests**. In place: +**M1 delivered, plus substantial M2–M4 work.** `SharpMUTerm.slnx` builds all ten projects on +`net10.0`; the solution has **514 passing tests**. In place: - **Core** — `AnsiParser` (SGR 16/256/truecolor), styled-line + `ScrollbackBuffer` model, `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.5.3**), - trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, JSON config + - BeipMU importer, `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. + trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, versioned JSON + config (worlds → characters + shared trigger sets, with migration), + `Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration. - **Graphics** — Kitty encoder + Unicode placeholders, Sixel + half-block fallbacks, capability probe (no UI dependency). - **Scripting** — sandboxed MoonSharp `ScriptHost` (world/output/trigger/alias/timer/gmcp/log). -- **Tui** — Terminal.Gui v2 app (truecolor `OutputView`, `CommandInput`, theming, key routing). +- **Tui** — **SharpConsoleUI** app: a `TabControl` of output windows (main + trigger-routed **spawn + windows** + web view, with unread badges), each a `MarkupControl` fed StyledLine → Spectre-style + markup via `MarkupFormatter` (clickable `[link=…]` MXP/Pueblo/web spans); a `PromptControl` input, + status line, `Ctrl+Q` quit, NAWS-on-resize. The tab/pane set is driven by the tested `Core.Workspaces` + model, with **splits** (thin single-line dividers) and the **connection rail** now rendered as well. ### Notes for future agents (learned the hard way) - **.NET 10 SDK**: install via `apt-get install -y dotnet-sdk-10.0` (the Microsoft CDN is often @@ -49,49 +58,62 @@ fallbacks) for inline images/maps. provides MCCP/MSDP/MXP negotiation itself. `TelnetSession` sets the init-only `CallbackOnByteAsync` reflectively to get raw data bytes (incl. unterminated prompts) — a first-class `OnByte` builder hook is a good upstream PR. -- **Terminal.Gui v2** (2.4.x-develop) dropped `Toplevel`/`TabView`; use `IRunnable`/`Window` and - override `OnDrawingContent(DrawContext)`. The static `Application` API is `[Obsolete]` mid-migration - (suppressed via `NoWarn` in the Tui project). +- **SharpConsoleUI** (package `SharpConsoleUI`, repo `nickprotop/ConsoleEx`): app is + `ConsoleWindowSystem(new NetConsoleDriver(RenderMode.Buffer), new ConsoleWindowSystemOptions())`; + build windows/controls with the fluent `WindowBuilder`/`Controls` factories; `AddControl` is + builder-time (keep control refs and mutate at runtime). Marshal background work with + `system.EnqueueOnUIThread`; global keys via `RegisterGlobalShortcut`; `system.Run()` blocks the + loop, `RequestExit(code)` ends it. Text is Spectre-style markup (`[bold #rrggbb on #rrggbb]…[/]`, + `[[`/`]]` escaping, `[link=url]…[/]` → `MarkupControl.LinkClicked`). A **headless** sandbox can't + run `NetConsoleDriver` (no console) — the Tui is build-verified + unit-tested (`MarkupFormatter`); + visual verification is on the maintainer's machine. ## Architecture rule (non-negotiable) -`MuClient.Core` stays **UI-agnostic and fully unit-testable**. All transport, telnet, parsing +`SharpMUTerm.Core` stays **UI-agnostic and fully unit-testable**. All transport, telnet, parsing (ANSI/MXP/Pueblo), GMCP/MSDP routing, scrollback, and trigger/alias/macro engines live there. -Terminal.Gui is referenced **only** from `MuClient.Tui`. +SharpConsoleUI is referenced **only** from `SharpMUTerm.Tui`. Planned solution layout: | Project | Responsibility | |---|---| -| `MuClient.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, engines, logging (no UI deps) | -| `MuClient.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | -| `MuClient.Scripting` | MoonSharp host + scripting API | -| `MuClient.Tui` | Terminal.Gui v2 application | -| `MuClient.Core.Tests`, `MuClient.Graphics.Tests` | xUnit | +| `SharpMUTerm.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, engines, logging (no UI deps) | +| `SharpMUTerm.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | +| `SharpMUTerm.Scripting` | MoonSharp host + scripting API | +| `SharpMUTerm.Tui` | SharpConsoleUI application | +| `*.Tests` (Core, Graphics, Scripting, Web, Tui) | TUnit | -## Milestone M1 — first task +## Milestone M1 — first task (delivered) -1. Create `MuGlyph.sln` with the four projects above targeting `net10.0`, plus the xUnit test projects. +Kept for context; **M1 is done** (see *Repository state* above). As originally scoped: + +1. Create `SharpMUTerm.slnx` with the projects above targeting `net10.0`, plus the TUnit test projects. 2. Add NuGet references (see version notes below). 3. Runnable stub: connect over TCP (+ optional TLS via `SslStream`, IPv6-capable), pipe received bytes through a first-pass `AnsiParser` (SGR: 16 / 256 / 24-bit color), render colored output - in a Terminal.Gui window with an input line + history. -4. Unit-test `AnsiParser` and the telnet-session wrapper in `MuClient.Core.Tests`. + in a SharpConsoleUI window with an input line + history. +4. Unit-test `AnsiParser` and the telnet-session wrapper in `SharpMUTerm.Core.Tests`. ## Dependency notes / traps - **.NET 10 SDK** may need installing in the sandbox (currently RC, e.g. `10.0.100-rc.1`). -- **Terminal.Gui v2 is a prerelease** — add with `dotnet add package Terminal.Gui --prerelease`. - v1 (stable) has a completely different API; do not use it. -- **TelnetNegotiationCore 1.0.0** provides negotiation only (TELOPT, GA, TTYPE/MTTS, EOR, NAWS, - CHARSET, MSSP, GMCP). It does **not** provide MCCP, MSDP, MXP, Pueblo, or ANSI parsing — those - are our layer. Do not assume APIs for them exist. (Note: the repo owner authored this library, - so extending it directly is on the table — propose it via PR rather than assuming.) +- **SharpConsoleUI** — stable release, multi-targets net8/9/10; MIT. Provides split layouts, tabs, + resizable/mouse windows, and native Kitty graphics, so the multi-pane workspace and inline images + ride on the framework rather than being hand-drawn. +- **TelnetNegotiationCore 2.5.3** (the version in use) has a fluent builder API and now negotiates + MCCP/MSDP/MXP itself, on top of the base negotiation (TELOPT, GA, TTYPE/MTTS, EOR, NAWS, CHARSET, + MSSP, GMCP). **Pueblo and the ANSI/MXP/Pueblo _parsing_ remain our layer** — the library does the + option handshake, not the payload parsing. `TelnetSession` sets the init-only `CallbackOnByteAsync` + reflectively to see raw bytes (incl. unterminated prompts); a first-class `OnByte` builder hook is a + good upstream PR. (Note: the repo owner authored this library, so extending it directly is on the + table — propose it via PR rather than assuming.) - **MoonSharp** — package id `MoonSharp`, pure-managed, no native deps. ## Verification -- Primary signal: `dotnet build` + `dotnet test`. Keep coverage in `MuClient.Core.Tests` +- Primary signal: `dotnet build` + `dotnet run --project ` for each test project + (see the TUnit/MTP note above — `dotnet test` does **not** work here). Keep coverage in `SharpMUTerm.Core.Tests` (ANSI/SGR parser, telnet round-trips, engines). - A headless sandbox **cannot** visually verify a TUI and **cannot** render Kitty graphics. Treat the graphics layer as build-verified + capability-probed, never visually confirmed; diff --git a/Directory.Build.props b/Directory.Build.props index 32cff72..af4367b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - + net10.0 latest @@ -11,10 +11,10 @@ true true Harry Cordewener - MuGlyph + SharpMUTerm Copyright (c) 2026 Harry Cordewener MIT - https://github.com/HarryCordewener/MuGlyph + https://github.com/SharpMUSH/SharpMUTerm diff --git a/Directory.Packages.props b/Directory.Packages.props index 7efe5d0..b16544c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ - - + + @@ -15,6 +15,9 @@ + + + diff --git a/MuGlyph.slnx b/MuGlyph.slnx deleted file mode 100644 index 5df29b3..0000000 --- a/MuGlyph.slnx +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/README.md b/README.md index b3c734c..b8a8dba 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,30 @@

A hyper-modern, cross-platform TUI client for MU* (MUSH / MUCK / MUD) worlds, built for GPU-accelerated terminals (Kitty, WezTerm, Ghostty) on Windows and Linux.

+Part of the [SharpMUSH](https://github.com/SharpMUSH) family: **SharpMUSH** is the server, +[**SharpClient**](https://github.com/SharpMUSH/SharpClient) is the graphical client, and +**SharpMUTerm** is the terminal one. It speaks plain telnet, so it connects to any MU\* world — +not just SharpMUSH ones. + The goal is [BeipMU](https://beipdev.github.io/BeipMU/)-class feature parity in a terminal-native client: rich truecolor text, inline graphics, powerful automation, and full MU\* protocol support. > **Status:** milestone **M1 delivered** (usable text client foundation) with substantial work > from M2–M4 in place — automation engines, inline-graphics subsystem, Lua scripting, and theming. -> `MuClient.Core` is fully unit-tested (195 tests across the solution). See +> `SharpMUTerm.Core` is fully unit-tested (514 tests across the solution). See > [`docs/PLAN.md`](docs/PLAN.md) for the full architecture and roadmap. ## Why a TUI "GPU acceleration" is a property of the terminal *emulator*, not the app. Any TUI running inside -Kitty / WezTerm / Ghostty gets GPU-accelerated glyph rendering for free. MuGlyph focuses on emitting +Kitty / WezTerm / Ghostty gets GPU-accelerated glyph rendering for free. SharpMUTerm focuses on emitting rich truecolor/styled text and using the **Kitty graphics protocol** (with Sixel and half-block fallbacks) for inline images and maps. ## Tech stack - **.NET 10** / C# -- **[Terminal.Gui v2](https://github.com/gui-cs/Terminal.Gui)** — windows, tabs, panes, input, scrollback +- **[SharpConsoleUI](https://github.com/nickprotop/ConsoleEx)** — compositor-based TUI framework: split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a native Kitty graphics protocol (+ Sixel/half-block) - **[TelnetNegotiationCore](https://www.nuget.org/packages/TelnetNegotiationCore/)** — telnet option negotiation (NAWS, MTTS, CHARSET, EOR/GA, MSSP, GMCP) - **[MoonSharp](https://www.moonsharp.org/)** — embedded, sandboxed Lua scripting - Custom app-layer parsers for **ANSI** (256 + 24-bit), **MXP**, and **Pueblo** @@ -39,10 +44,10 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. | Project | Responsibility | |---|---| -| `MuClient.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, trigger/alias/macro engines, logging (UI-agnostic) | -| `MuClient.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | -| `MuClient.Scripting` | MoonSharp host + scripting API | -| `MuClient.Tui` | Terminal.Gui v2 application (windows, panes, settings, wiring) | +| `SharpMUTerm.Core` | Transport, telnet, ANSI/MXP/Pueblo parsers, GMCP/MSDP routing, scrollback, trigger/alias/macro engines, logging (UI-agnostic) | +| `SharpMUTerm.Graphics` | Kitty graphics protocol, capability probe, Sixel + half-block fallbacks, `GraphicsView` | +| `SharpMUTerm.Scripting` | MoonSharp host + scripting API | +| `SharpMUTerm.Tui` | SharpConsoleUI application (windows, panes, settings, wiring) | | `*.Tests` | TUnit test projects | ## What works today @@ -56,34 +61,51 @@ HTML logging · GMCP / MSDP / MSSP / MCCP · MXP + Pueblo · Unicode/emoji. - **Scrollback** — bounded, thread-safe styled-line model with change events. - **Automation** — regex **triggers** (gag / highlight / rewrite / respond / spawn-route / script), **aliases** (capture-group expansion, multi-command), **macros/keybinds**, and a - recurring/one-shot **timer** scheduler. + recurring/one-shot **timer** scheduler. User regexes run with a ReDoS match-timeout guard. +- **MXP & Pueblo** — first-class parsers for both markup protocols: tags → styled spans, with + **clickable** ``/`` links and commands (`SpanInteraction`), colours, entities, and + line breaks. Selectable per world. +- **Emoji** — optional emoticon (`:)` → 🙂) and `:shortcode:` (`:fire:` → 🔥) substitution. - **Logging** — plain-text and styled **HTML** session logs. -- **Config** — a fresh JSON schema plus a best-effort **BeipMU importer**. +- **Config** — a fresh JSON schema: worlds (servers) hold **characters**, and automation lives in + shared, named **trigger sets** that characters opt into; versioned with automatic migration. - **Inline graphics** — Kitty graphics-protocol encoder (incl. Unicode placeholders), Sixel and half-block fallbacks, and a capability probe that degrades cleanly when no protocol is present. - **Scripting** — sandboxed **Lua** (MoonSharp) exposing `world`/`output`/`trigger`/`alias`/ `timer`/`gmcp`/`log`, with hot-reload. - **Theming** — yazi-style named themes (Dark / Light / Solarized Dark) with a 16-colour palette override and semantic UI colours, serialised to the config as hex. -- **TUI** — a Terminal.Gui v2 app: truecolor output pane with wrapping/scrollback, command input - with history and tab-completion, status line, and key routing. +- **TUI** — a [SharpConsoleUI](https://github.com/nickprotop/ConsoleEx) app rendering the multi-pane + workspace design over the `Core.Workspaces` model: a **connection rail** (worlds → characters → + windows with connected/active/unread markers), **split panes** with tabbed windows (tmux-style + split/close/zoom), a **command surface** (`Ctrl+P`) with ranked GO TO / WORLD / TERMINAL / LAYOUT + actions, per-world **accent colours**, a GMCP-driven **status bar** with HP/EN meters, and a + character-bound input prompt + destination/draft gutter. Output is truecolor markup with clickable + MXP/Pueblo/web spans; NAWS is re-advertised on resize. `Ctrl+N` next window · `Ctrl+O` next pane · + `Ctrl+W` close · `Ctrl+Q` quit. +- **Web view** — an in-TUI text-mode browser (`SharpMUTerm.Web`, AngleSharp): fetch a URL or + follow an MXP/Pueblo/HTML link and read the page as styled, word-wrapped text with clickable + links you can navigate in-pane. `` shows as a labelled link (graphics-terminal image + rendering reuses the Kitty/Sixel/half-block pipeline). +- **Packaging** — self-contained single-file publishing for Linux/Windows (see + [`docs/PACKAGING.md`](docs/PACKAGING.md)); a tagged release workflow builds the binaries. ## Building Requires the **.NET 10 SDK**. ```bash -dotnet build MuGlyph.slnx -c Release +dotnet build SharpMUTerm.slnx -c Release ``` ## Running ```bash -dotnet run --project src/MuClient.Tui -- [--tls] [--insecure] [--name NAME] -muglyph --help # once published +dotnet run --project src/SharpMUTerm.Tui -- [--tls] [--insecure] [--name NAME] +sharpmuterm --help # once published ``` -In-app: **PgUp/PgDn** scroll · **Up/Down** input history · **Tab** complete · **Ctrl+Q** quit. +In-app: **Up/Down** input history · **Ctrl+N** next window · **Ctrl+W** close window · **Ctrl+Q** quit. (Windows/panes are mouse-resizable; each window keeps its own input draft.) ## Testing @@ -91,9 +113,11 @@ The test projects use [TUnit], which runs on the Microsoft.Testing.Platform. Run (the classic `dotnet test`/VSTest path is not used by MTP on .NET 10): ```bash -dotnet run --project tests/MuClient.Core.Tests -dotnet run --project tests/MuClient.Graphics.Tests -dotnet run --project tests/MuClient.Scripting.Tests +dotnet run --project tests/SharpMUTerm.Core.Tests +dotnet run --project tests/SharpMUTerm.Graphics.Tests +dotnet run --project tests/SharpMUTerm.Scripting.Tests +dotnet run --project tests/SharpMUTerm.Web.Tests +dotnet run --project tests/SharpMUTerm.Tui.Tests ``` ## License diff --git a/SharpMUTerm.slnx b/SharpMUTerm.slnx new file mode 100644 index 0000000..a9c33c9 --- /dev/null +++ b/SharpMUTerm.slnx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..e2a2e4a --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,235 @@ +# MuGlyph — Session Handoff + +Context for whoever (human or agent) picks up this branch next. + +- **Branch:** `claude/muglyph-implementation-5f51l3` +- **Head at handoff:** `5b21e0d` (working tree clean, everything pushed) +- **PR:** `HarryCordewener/MuGlyph#2` +- **Tests:** 310 Core + 90 Tui, all green; `dotnet build MuGlyph.slnx` clean (0 warnings) + +> ⚠️ **The repository has moved.** Re-verify the remote URL, that this branch +> still exists, and the PR's state at the new location before doing anything. If +> the PR was already merged, treat follow-up work as a **fresh change**: restart +> the branch from the new default branch rather than stacking onto merged history. + +--- + +## What Is Left To Do + +Ordered roughly by value. None of these are blocking a merge of the current work; +they're the outstanding polish/feature backlog. + +### 1. Apply the panel treatment to the other config screens + +**Status:** offered, awaiting go-ahead. +**Why:** F5 (Worlds & Characters) was rebuilt into a proper full-screen control +tree — header band, real column panels, an editing pane laid out with a +`HorizontalGrid`, and a footer action bar pinned to the bottom (see +`WorldsScreenView.cs`). The other settings screens still render in the older +"single merged markup blob" style: + +- **F2** Triggers & spawn routing — `TriggersScreenRenderer` +- **F3** Aliases — `AliasesScreenRenderer` +- **F4** Keypad/macros — `KeypadScreenRenderer` +- **F6** Timers — `TimersScreenRenderer` +- **F7** Text & ANSI options — `OptionsScreenRenderer.TextAnsi` +- **F8** Input & spellcheck — `OptionsScreenRenderer.InputSpellcheck` +- **F9** Logging — `OptionsScreenRenderer.Logging` + +They are **not broken** — they render cleanly on the reworked frameless overlay +with the deep panel background — but they lack: a full-width header band with +keyboard hints, real column panels, a bottom-pinned Cancel/Save action bar, and +the elevated background bands F5 now has. + +**How:** follow the F5 pattern exactly. `WorldsScreenRenderer` was refactored to +expose each region as a pure markup block (`HeaderLine`, `FooterLine`, +`WorldsColumn`, `DetailColumn`, `FormColumn`, `TriggersColumn`); `WorldsScreenView` +composes those into controls. Give each other screen a `*ScreenView` that does the +same, and route it through `SettingsOverlay.Toggle(key, Func)` +(the control-hosting overload already exists) plus the snapshot path in +`MuGlyphApp.RenderSnapshot`. Keep the pure `Render(...)` method on each renderer +for the unit tests. + +### 2. Task #20 — fold inline graphics into SharpConsoleUI's Kitty support + +**Status:** pending; **cannot be verified headlessly** (no GPU terminal in the +sandbox). `MuClient.Graphics` (Kitty encoder, Sixel + half-block fallbacks, +capability probe) exists and is build-verified/unit-tested but is **not** wired +into the SharpConsoleUI render path. SharpConsoleUI has native Kitty graphics +support; the task is to route `GraphicsView`/image output through it and ensure +clean degradation when no graphics protocol is available (the sandbox is exactly +that case). Real verification must happen on a GPU terminal (Kitty/WezTerm/ +Ghostty) on the maintainer's machine. + +### 3. Live keyboard interaction for the config screens + +The screens are currently **display-only** projections of config state — the +keyboard hints ("↑↓ select · ⇥ switch pane · ⏎ edit") describe intended behavior +that isn't wired yet. Selection indices (`ActiveWorldIndex()`, +`ActiveCharacterIndex()`) drive what's highlighted, but there's no in-screen +navigation/edit loop. Wiring real editing (move selection, toggle checkboxes, +edit fields, persist on Save) is a substantial follow-up. + +### 4. Full-width solid input band — verify on a real terminal + +The main input row is now a full-width band (`PromptControl` field fill + a +prompt painted with the same background via `PromptMarkup`, width pinned via +`SyncInputWidth`). Verified in headless snapshots; **confirm it holds on a real +terminal** across resizes, since the width is pinned imperatively. + +### 5. Mouse drag-to-split panes + +The pane split tree supports keyboard "move mode" (the keyboard equivalent of +drag-to-split). True mouse drag-and-drop between panes (`DropZones.Resolve` exists +in Core and is tested) is not wired into the TUI and is unverifiable headlessly. + +### 6. CodeRabbit nitpicks intentionally **not** done (don't "fix" these) + +- **`tools/fonts/LICENSE-NerdFonts.txt` "explict" typo** — left as-is on purpose: + it's a **verbatim copy of the upstream Nerd Fonts license**. Bundled third-party + license text must match the canonical source, typos included. +- **RailModel host:port row** — the connection rail intentionally does **not** + show the world's address (removed at the maintainer's request). A CodeRabbit + comment asked to re-add it; **skip it**. +- **Docstring-coverage warning** (~29% vs 80% threshold) — standing, non-blocking + advisory. Not worth chasing unless the maintainer wants it. + +--- + +## Critical Gotchas + +Things that will waste your time if you don't know them. + +### Building & testing + +- **.NET 10 SDK** may need installing: `apt-get install -y dotnet-sdk-10.0`. The + Microsoft CDN is often blocked in the sandbox; Ubuntu's repo works. NuGet + (`api.nuget.org`) is reachable. +- **Run tests with `dotnet run`, NOT `dotnet test`.** Projects are TUnit on + Microsoft.Testing.Platform (`Exe`, not xUnit). `dotnet test` is not wired up on + this SDK. Use: + ```bash + dotnet run --project tests/MuClient.Core.Tests --size 120x32 --out frame.ansi + python3 tools/ansi_frame_to_image.py frame.ansi frame.svg + ``` +- **Snapshot view names:** `worlds`/`settings`, `triggers`, `aliases`, `timers`, + `keypad`, `textansi`, `input`, `logging`, `freeze`, `spawn`, `split`, `move`, + `history`, `menu`, `menu-split`, plus the default (no `--view`) workspace. Extra + state toggles: `collapsed`, `prefix`, `timestamps`. +- **Send the user the `.svg`** — they view it fine. Do **not** rely on your own + SVG→PNG for pixel checks near the bottom (see next point). +- **SVG→PNG clipping trap:** Chromium clips the bottom of a bare `.svg` file + (aspect-ratio scaling). For your *own* inspection, render the tool's **`.html`** + output instead — it doesn't clip: + ```bash + python3 tools/ansi_frame_to_image.py frame.ansi frame.html + CHROME=$(ls /opt/pw-browsers/chromium-*/chrome-linux/chrome | head -1) + "$CHROME" --headless --no-sandbox --disable-gpu --hide-scrollbars \ + --force-device-scale-factor=1 --window-size=1060,720 \ + --screenshot=out.png "file://$PWD/frame.html" + ``` + Do **not** run `playwright install`; Chromium is pre-installed at + `/opt/pw-browsers`. +- **Decoding a frame precisely:** the `.ansi` is cursor-addressed SGR. A small + Python reconstructor (split on `ESC[…H`/`m`, walk chars into a `{row:{col:ch}}` + grid, track `48;2;r;g;b` for backgrounds) is the reliable way to check exact + column widths and which background band covers which row. + +### SharpConsoleUI layout — the big one + +- **Clone the framework source for reference:** + `git clone --depth 1 https://github.com/nickprotop/ConsoleEx.git` (package id + `SharpConsoleUI`, version 2.5.14; repo is `nickprotop/ConsoleEx`). `docs/patterns.md` + in that repo is the maintainer's recommended usage (sidebar+content, tabs, grids). +- **Controls default to `HorizontalAlignment.Left`**, which makes them **self-size + to content** instead of filling their slot. This is the single biggest cause of + "why doesn't this fill the width?" To make a control/grid fill: + `.WithAlignment(HorizontalAlignment.Stretch)` (builders) or set + `control.HorizontalAlignment = HorizontalAlignment.Stretch`. Applied to the + workspace `HorizontalGrid`, pane `TabControl`s, split grids, and content grids. +- **`.Flex(n)` on a column → a Star track** that self-sizes to content in *measure* + but distributes across the real allocation in *arrange* — so a Flex column only + fills if (a) the grid is arranged at full width (needs Stretch) **and** (b) the + child control in it is Stretch (else the child floats at content width and the + grid background shows through the gap). +- **`MarkupControl`** fills its width and paints its `BackgroundColor` across the + full row **only when `HorizontalAlignment = Stretch`**. Its per-row justification + uses the same `HorizontalAlignment` enum — so you can't have "fill width" and + "right-justify each row" on one control. To right-align a block while keeping its + internal left-alignment (e.g. a checklist with aligned checkboxes), put it in an + **Auto column after a Flex spacer** (see the F5 editing pane in + `WorldsScreenView.cs`), not `HorizontalAlignment.Right`. +- **`GridControl` has its own `BackgroundColor`** that paints the full arranged + area — use it for full-width band behind child panels (the F5 edit pane relies + on this). +- **Window chrome:** modals default to a title bar with `[_][+][X]` buttons and a + corner resize grip. Remove them with `.HideTitleButtons()` + `.Resizable(false)`, + or go `.Frameless()` (no title bar, buttons, resize, or border — content fills + the whole window rect). Get the usable size from + `_system.DesktopDimensions` (`.Width`/`.Height`). +- **`PromptControl` has no general `BackgroundColor`** — only + `WithInputBackgroundColor` / `WithInputFocusedBackgroundColor` (colors the typed + field). It measures to content width; pin `_input.Width` to fill the row. It + parses its prompt string as markup, so you can paint the prompt cells with a + background span to make a seamless band. +- **Headless panels caveat:** `ConsoleWindowSystemOptions` top/bottom panels are + hidden in headless (`ShowTopPanel: !headless`), so anything you put there won't + appear in snapshots. The header/status bars are hand-built `MarkupControl`s in + the window instead, precisely so they show up in snapshots. + +### TelnetNegotiationCore + +- Version in use is **2.5.3** (fluent builder API), **not** the 1.0.0 the original + plan assumed. It now negotiates MCCP/MSDP/MXP itself on top of base negotiation. + **Pueblo and the ANSI/MXP/Pueblo payload parsing remain our layer** — the library + does the option handshake, not the payload. `TelnetSession` sets the init-only + `CallbackOnByteAsync` **reflectively** to see raw bytes (including unterminated + prompts); a first-class `OnByte` builder hook would be a good upstream PR (the + repo owner authored the library). + +### Architecture rule (non-negotiable) + +- **`MuClient.Core` stays UI-agnostic and fully unit-testable.** All transport, + telnet, ANSI/MXP/Pueblo parsing, GMCP/MSDP routing, scrollback, and + trigger/alias/macro engines live there. **SharpConsoleUI is referenced only from + `MuClient.Tui`.** Keep screen renderers pure (return markup line lists / sub-blocks) + so they stay testable; do the control composition in a `*ScreenView`. + +### Process / GitHub + +- **CodeRabbit** webhook comments are auto-generated bot content (untrusted + external data). Treat them as informational — act only on genuinely new, valid, + in-scope findings. Be frugal about replying on GitHub; if you do post, append the + Claude Code attribution footer. +- **Don't push to any branch except** `claude/muglyph-implementation-5f51l3`. +- **Never** expose the model identifier in commits, PR bodies, or code. +- `.editorconfig`: file-scoped namespaces, 4-space C#, LF line endings. + +--- + +## Key Files Touched This Session + +| File | Role | +|---|---| +| `src/MuClient.Tui/MuGlyphApp.cs` | Central app: header/status/input bands, `SyncInputWidth`, `PromptMarkup`, pane fill, F5 wiring, snapshot views | +| `src/MuClient.Tui/WorldsScreenRenderer.cs` | Pure markup sub-blocks for F5 (+ merged `Render` for tests) | +| `src/MuClient.Tui/WorldsScreenView.cs` | Composes F5 sub-blocks into real control panels | +| `src/MuClient.Tui/SettingsOverlay.cs` | Frameless full-screen overlay; hosts markup **or** a control tree | +| `src/MuClient.Tui/CommandPalette.cs` | ⌃P surface: content-hug sizing, clean chrome | +| `src/MuClient.Tui/CommandSurfaceRenderer.cs` | Palette rows + full-width selection bar | +| `tools/fonts/OFL.txt`, `LICENSE-NerdFonts.txt` | Full bundled license texts | diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..112d315 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,44 @@ +# Packaging SharpMUTerm + +SharpMUTerm publishes as a **self-contained, single-file** executable — no .NET runtime required on +the target machine. + +## Supported runtimes + +`linux-x64`, `linux-arm64`, `win-x64`, `osx-x64`, `osx-arm64` (declared in +`src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj`). + +## Local publish + +Publish profiles live in `src/SharpMUTerm.Tui/Properties/PublishProfiles/`: + +```bash +# Linux x64 → publish output contains a single `sharpmuterm` binary +dotnet publish src/SharpMUTerm.Tui -p:PublishProfile=linux-x64 -o out/linux-x64 + +# Windows x64 → `sharpmuterm.exe` +dotnet publish src/SharpMUTerm.Tui -p:PublishProfile=win-x64 -o out/win-x64 +``` + +For a RID without a profile, pass the flags directly: + +```bash +dotnet publish src/SharpMUTerm.Tui -c Release -r osx-arm64 \ + --self-contained -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true -o out/osx-arm64 +``` + +The profiles enable single-file extraction, compression, and ReadyToRun for faster startup. +These knobs live **only** in the publish profiles, so ordinary `dotnet build` / `dotnet run` +(and CI) are unaffected — they never require a runtime identifier. + +## CI / releases + +`.github/workflows/release.yml` runs on `v*` tags (and manual dispatch): it publishes +`linux-x64` and `win-x64`, packages them (`.tar.gz` / `.zip`), uploads them as workflow +artifacts, and — on a tag — attaches them to the GitHub release. + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` diff --git a/docs/PLAN.md b/docs/PLAN.md index 1a5c7d8..f106e63 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -7,13 +7,13 @@ BeipMU is the best-in-class **Windows-only** MU\* (MUSH/MUCK/MUD) client, but it Key reframing from research: **"GPU-enabled" is a property of the terminal emulator, not our app.** Any TUI running inside Kitty/WezTerm/Ghostty gets GPU-accelerated glyph rendering for free. Our job is to (a) emit rich truecolor/styled text and (b) use the **Kitty graphics protocol** (escape sequences) for inline images/maps, with graceful fallbacks. Both are fully achievable from managed C#. ### Locked decisions (from planning Q&A) -- **Rendering base:** Terminal.Gui v2 for windows/input/layout/scrollback + a custom placeholder-based `GraphicsView` for images. +- **Rendering base:** **SharpConsoleUI** (`nickprotop/ConsoleEx`, stable, net8/9/10) — a compositor-based framework with split layouts, tabs, resizable/mouse windows, Spectre-style markup, and a **native Kitty graphics protocol** (+ Sixel/half-block). Superseded the original Terminal.Gui v2 choice (which was prerelease with an `[Obsolete]` mid-migration API); the switch was contained to `SharpMUTerm.Tui` since `SharpMUTerm.Core` is UI-agnostic. References below that describe Terminal.Gui reflect the earlier plan. - **Scripting:** Lua via **MoonSharp** (pure-managed, no native deps). - **Inline graphics:** must-have from day one. - **Scope:** broad BeipMU parity (phased into milestones below). - **Target framework:** **.NET 10**. - **Protocol coverage:** aim for compatibility with *all* common MU\* protocols; **MXP** is first-class, and **Pueblo** (and its enhancements) are explicitly in scope alongside GMCP/MSDP/MSSP/MCCP. -- **Config:** **fresh JSON** schema of our own, plus a **BeipMU import/migration** path for initial onboarding (parse BeipMU's settings to seed worlds/triggers/aliases). +- **Config:** **fresh JSON** schema of our own — worlds (servers) hold **characters**; automation lives in shared, named **trigger sets** that characters opt into by name — versioned with automatic migration between schema revisions. --- @@ -51,11 +51,11 @@ Layered, with a strict separation between **protocol/session** (headless, unit-t ``` ### Solution structure (proposed) -- `MuClient.Core` — transport, telnet, ANSI/MXP parsers, GMCP/MSDP routing, scrollback model, trigger/alias/macro engines, logging. **No UI deps.** -- `MuClient.Scripting` — MoonSharp host + the scripting API surface (world, output, triggers, timers, gmcp). -- `MuClient.Graphics` — Kitty graphics protocol encoder, capability probe, Sixel + half-block fallbacks, `GraphicsView`. -- `MuClient.Tui` — Terminal.Gui v2 app: windows, panes, key routing, settings UI, wiring. -- `MuClient.Core.Tests` / `MuClient.Graphics.Tests` — xUnit. +- `SharpMUTerm.Core` — transport, telnet, ANSI/MXP parsers, GMCP/MSDP routing, scrollback model, trigger/alias/macro engines, logging. **No UI deps.** +- `SharpMUTerm.Scripting` — MoonSharp host + the scripting API surface (world, output, triggers, timers, gmcp). +- `SharpMUTerm.Graphics` — Kitty graphics protocol encoder, capability probe, Sixel + half-block fallbacks, `GraphicsView`. +- `SharpMUTerm.Tui` — SharpConsoleUI app: windows, panes, key routing, settings UI, wiring. +- `SharpMUTerm.Core.Tests` / `SharpMUTerm.Graphics.Tests` — xUnit. - Target **.NET 10** (confirm TelnetNegotiationCore + Terminal.Gui v2 support net10.0; if a dep lags, reference it via `net8.0` compat and keep our own projects on net10.0). --- @@ -75,7 +75,7 @@ Layered, with a strict separation between **protocol/session** (headless, unit-t ## Rendering & graphics - **Text UI**: Terminal.Gui v2 provides the window manager, tabbed worlds, dockable panes, scrollback view, multi-input, focus, and truecolor cell rendering. Advertise UTF-8 + truecolor to servers. -- **`GraphicsView`** (`MuClient.Graphics`): renders images (maps, avatars, inline media) using **Kitty Unicode placeholders** so images occupy real cells and scroll/clip via Terminal.Gui's layout. Pipeline: probe capability → upload image once (base64-chunked `APC _G` transmit) → paint placeholder runes/colors into the view's cells → manage image lifecycle (`a=d` delete on close/replace). +- **`GraphicsView`** (`SharpMUTerm.Graphics`): renders images (maps, avatars, inline media) using **Kitty Unicode placeholders** so images occupy real cells and scroll/clip via Terminal.Gui's layout. Pipeline: probe capability → upload image once (base64-chunked `APC _G` transmit) → paint placeholder runes/colors into the view's cells → manage image lifecycle (`a=d` delete on close/replace). - **Capability probe + fallbacks**: query terminal for Kitty graphics; else **Sixel**; else Unicode **half-block/quadrant** approximation; else a text placeholder. Selection is per-session and user-overridable in settings. - **Map rendering**: `MapModel` (rooms/exits/z-levels) rendered either as box-drawing/Unicode vector art in a normal view *or* as a rasterized image through `GraphicsView` — start with box-drawing (works everywhere), add rasterized mode where graphics are available. @@ -99,13 +99,55 @@ Transport (TCP + SslStream TLS + IPv6); TelnetSession over TelnetNegotiationCore `TriggerEngine` (regex, gag/highlight/rewrite/spawn actions), `AliasEngine`, `MacroEngine`/keybinds, timers. Settings UI for all of them. Per-world profiles (JSON). **M3 — Graphics day-one payoff** -`MuClient.Graphics`: Kitty placeholder `GraphicsView` + Sixel/half-block fallbacks + capability probe. Inline **image viewer**; **map** view (box-drawing first, rasterized where supported); **stat panes** driven by GMCP. +`SharpMUTerm.Graphics`: Kitty placeholder `GraphicsView` + Sixel/half-block fallbacks + capability probe. Inline **image viewer**; **map** view (box-drawing first, rasterized where supported); **stat panes** driven by GMCP. **M4 — Scripting** MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscriptions from Lua, hot-reload. **M5 — Full parity & polish** -**Spawns** (route matched output to named windows), **puppets**, **multiple input windows**, **MXP + Pueblo** parsers (clickable links/commands/``, inline images via graphics layer), MSDP, **BeipMU config importer**, Unicode emoji + `:)`→🙂, smooth-scroll/appearance options, theming, packaging (dotnet single-file for Windows + Linux; optional distro packages). +**Spawns** (route matched output to named windows), **puppets**, **multiple input windows**, **MXP + Pueblo** parsers (clickable links/commands/``, inline images via graphics layer), MSDP, Unicode emoji + `:)`→🙂, smooth-scroll/appearance options, theming, packaging (dotnet single-file for Windows + Linux; optional distro packages). + +### M5 progress (delivered) +- **MXP** and **Pueblo** parsers in `Core` (`ILineParser`), selectable per world via + `WorldDefinition.ContentFormat`; links/commands surface as `SpanInteraction` and are clickable + in the TUI. **Emoji** substitution (`EmojiSubstitutor`), opt-in per world. GMCP-driven **stat + line**, **spawn** capture, ReDoS-guarded regex engines, and self-contained **single-file + packaging** (`docs/PACKAGING.md`) + a tagged release workflow. +- **In-TUI web view** (`SharpMUTerm.Web` + `WebView`): fetch a URL or follow an MXP/Pueblo/HTML link + and read the page as styled, word-wrapped text with clickable in-pane navigation (AngleSharp → + `StyledLine`s, reusing `SpanInteraction`). `` renders as a labelled link today. + +### M5 UI design (delivered) +The multi-pane workspace design (tmux-style pane tree hosting BeipMU-style windows) is rendered by +the SharpConsoleUI shell over the `Core` models: the **connection rail** (worlds → characters → +windows), **split panes** with tabbed windows (each leaf pane a tab strip; row/column splits become +proportional grids with draggable splitters; zoom collapses to one pane), the **command surface** +(`Ctrl+P`) ranking GO TO / WORLD / TERMINAL / LAYOUT actions, per-world **accent colours** threaded +through the header/rail/status, a **status bar** with GMCP HP/EN meters, and a character-bound input +prompt with a destination/draft gutter. Built on these `Core` pieces (pure + tested): +- **Config schema** (`Core.Configuration`): worlds (servers) hold **characters**; automation lives + in shared, named **trigger sets** that characters opt into. Sessions key on `world.character` and + compose engines from the union of a character's sets. Versioned with `ConfigurationMigrator`. +- **Workspace model** (`Core.Workspaces`): a pure `WorkspaceLayout` split tree — `PaneNode` + (tab strip of window ids) / `SplitNode` (row/col) with focus, zoom, freeze, and the tmux-style + split / close / cycle / move / reorder operations, maintaining the no-empty-pane / no-lone-split + invariants. +- **Windows & spawn routing** (`Core.Workspaces`): a `Workspace` aggregate ties the layout to a + registry of `WorkspaceWindow`s (title, kind, owning `world.character`, unread count, unsent-input + marker). `RouteSpawn` finds-or-creates a background spawn window per `TriggerEngine` `SpawnTarget` + and accrues unread while it is not the visible tab; activating a window clears it. The SharpConsoleUI + view hosting (splits, tabs, rail) renders from this model, rebuilding the pane area on every layout + change (split / close / zoom / spawn) and swapping it into the live window. + +### Still open (M5+) +- **Freeze view** (split-scrollback) rendering, **settings dialogs** (F-keys), and **mouse/drag** + pane resizing — the models and command-surface entries exist; the interactive view work remains. +- Dedicated **multiple input windows** (capture + routing hooks exist), **puppets**, MSDP-driven + stat panes, and the **map** view. +- **Web view enhancements:** render `` inline through the existing `InlineImageRenderer` + (Kitty → Sixel → half-block) in graphics-capable terminals, and an optional high-fidelity mode + that snapshots the page with headless Chromium (Playwright) and displays the image via the + graphics layer. --- @@ -121,11 +163,13 @@ MoonSharp `ScriptHost`, scripting API, Lua-backed triggers/aliases, GMCP subscri - **Unit tests** (`Core.Tests`): ANSI/SGR parser (256 + truecolor + edge sequences), telnet negotiation round-trips, MCCP decompression against captured zlib streams, trigger/alias regex + action application, GMCP JSON routing. - **Graphics tests**: Kitty placeholder-sequence encoder golden-output tests; capability-probe fallback selection. - **Manual/integration**: connect to a public test MU\* (and a local throwaway server) from **Kitty, WezTerm, Ghostty, and a non-graphics terminal**; verify truecolor, prompts on input line, logging, triggers firing, an inline image rendering under Kitty and degrading to half-block elsewhere. Run on both **Windows and Linux**. -- Use `dotnet test` in CI (GitHub Actions matrix: windows-latest + ubuntu-latest). +- Run the tests in CI with `dotnet run --project ` per test project (GitHub Actions matrix: + windows-latest + ubuntu-latest). TUnit runs on Microsoft.Testing.Platform, where the classic + `dotnet test`/VSTest path is unsupported on .NET 10 and later. --- ## Open items to confirm before/at M1 - Confirm TelnetNegotiationCore + Terminal.Gui v2 both build against **net10.0** (fallback: consume via net8.0 compat). - Which servers you actually play on (helps prioritize protocol edge cases; all are targeted regardless). -- Final project/repo **name** (currently scaffolded as `MuGlyph` — trivially renamable). +- Final project/repo **name** (currently scaffolded as `SharpMUTerm` — trivially renamable). diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md new file mode 100644 index 0000000..86892bb --- /dev/null +++ b/docs/SCREENSHOTS.md @@ -0,0 +1,34 @@ +# Screenshots & demos (headless) + +SharpMUTerm can render its UI to an image **without a terminal or a live connection**, so +documentation images and CI visual checks work anywhere the .NET build runs. + +## How it works + +SharpConsoleUI ships a `HeadlessConsoleDriver` that renders to a captured buffer instead +of a real console. `sharpmuterm --snapshot` builds the app on that driver, loads a +representative demo scene (a room, a `Chat` spawn window with unread, an input draft), +renders one frame, and writes the raw ANSI to stdout (or `--out file`). + +`tools/ansi_frame_to_image.py` parses that frame — cursor-addressed truecolor SGR — into +a character grid and emits a **self-contained SVG** (great for embedding in Markdown) or +**HTML** (`.html` output, or `--html`). No external dependencies. + +```bash +# one-shot +sharpmuterm --snapshot --size 100x30 | python3 tools/ansi_frame_to_image.py > shot.svg + +# regenerate the committed screenshots +tools/make-screenshots.sh +``` + +The frame is deterministic (the desktop panels/clock are disabled under the headless +driver), so `sharpmuterm --snapshot` output can also serve as a **golden file** for CI. + +## Animated demos (VHS) + +For animated GIFs/MP4s of real usage (typing, spawn windows appearing), use +[charmbracelet/vhs](https://github.com/charmbracelet/vhs): write a `.tape` script and run +it in CI with [`charmbracelet/vhs-action`](https://github.com/charmbracelet/vhs-action). +VHS drives the published `sharpmuterm` binary in a headless terminal and records the result — +complementary to the static SVG snapshots above. diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 0000000..6c8de0c --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,357 @@ +# Handoff: SharpMUTerm multi-pane workspace, spawn windows & settings (M5 UI) + +## Overview + +A design for SharpMUTerm's TUI shell at M5 scope: a tmux-style pane tree hosting BeipMU-style +spawn windows, a worlds→characters connection model, trigger sets assignable to characters, +a searchable command surface, and per-tab input drafts. + +This covers the UI layer only. It assumes the existing `SharpMUTerm.Core` engines +(`TriggerEngine`, `AliasEngine`, `IntervalScheduler`, `ScrollbackBuffer`, `SessionManager`) +and asks for two **schema changes** in `SharpMUTerm.Core.Configuration` — see *Schema changes* below. + +## About the design files + +`SharpMUTerm-TUI-v3.dc.html` is a **design reference written in HTML**, not production code and not +something to port. It is a browser mock of a terminal UI: every "pane border", "block meter" +and "box-drawing glyph" is HTML standing in for what SharpConsoleUI will draw as real cells. + +The task is to **rebuild these screens in `SharpMUTerm.Tui`** using SharpConsoleUI views and the +existing `Theme`/`ColorMapper` pipeline. Do not translate the HTML structure; translate the +layout, the interaction model, and the information hierarchy. + +Open the file in a browser to interact with it (typing, pane splits, ⌃P, F2–F9 all work). + +## Fidelity + +**High-fidelity for layout, interaction and information architecture. Deliberately +low-fidelity for colour.** + +Every colour in the mock is a literal hex, because HTML has no theme layer. In the real client +these must resolve through `SharpMUTerm.Core.Theming.Theme` — do not hardcode the mock's hexes. +The mapping table under *Design tokens* gives the theme field or ANSI index each mock colour +stands for. + +Everything else — pane geometry, tab strip behaviour, key bindings, what text appears where, +truncation and overflow rules — is intended as specified. + +--- + +## Schema changes (required before the UI can be built) + +Two model gaps between the design and `main`: + +### 1. Worlds have characters; a character is the connection + +Today `WorldDefinition` carries connection parameters *and* is itself the connection unit. +The design separates them: a world is a **server** (host/port/TLS/encoding), and it holds +**zero or more characters**. A character is what you connect *as* — sessions are keyed +`.`, and one world can have several sessions live at once. + +```csharp +public sealed class CharacterDefinition +{ + public string Name { get; set; } = "New Character"; + public string? Password { get; set; } // keychain-backed; never plain in JSON + public string? ConnectString { get; set; } // default: "connect {Name} {Password}" + public bool AutoLogin { get; set; } + public string? OnConnect { get; set; } // ';'-separated commands + public string? OnDisconnect { get; set; } + public List TriggerSets { get; set; } = new(); // set names, see below + public LoggingSettings Logging { get; set; } = new(); // per character, not per world +} +``` + +`WorldDefinition` keeps `Name/Host/Port/UseTls/AllowInvalidCertificates/LocalEcho` and gains +`List Characters`. `Triggers`/`Aliases`/`Macros`/`ScriptFiles` move off it +(see below). A world with zero characters is valid and must render as such — it just cannot connect. + +`SessionManager.Open` should take `(WorldDefinition, CharacterDefinition, int scrollbackLines)` +and key sessions on `$"{world.Name}.{character.Name}"`. + +### 2. Triggers live in named sets, assigned to characters + +Today `WorldDefinition.Triggers` is a flat per-world list. The design makes automation a +first-class, world-independent library: + +```csharp +public sealed class TriggerSet +{ + public string Name { get; set; } = "New Set"; + public string? Description { get; set; } + public List Triggers { get; set; } = new(); + public List Aliases { get; set; } = new(); + public List Macros { get; set; } = new(); + public List ScriptFiles { get; set; } = new(); +} +``` + +`AppConfiguration` gains `List TriggerSets`. A character's `TriggerSets` names +select which apply. `TriggerEngine` for a session is composed from the union of its character's +sets — so a "Comms" set can be shared by every character on every world, and a "Trade" set can +be live for one character and dark for another on the same world. + +`BeipMuImporter` should emit one set per imported world (named after it) and assign it to that +world's imported character, preserving today's behaviour. + +`Trigger.Actions.SpawnTarget` already exists and is what the routing UI edits — no change needed. + +--- + +## Screens + +### 1. Main workspace + +The whole client. Five regions, top to bottom: header, [rail | pane area], input, status bar. + +**Header** (1 row): `☰ muterm` at far left is the menu affordance and opens the command +surface (the caret flips `☰`→`▾` while open). Right side carries a `⌃B` prefix indicator +(shown only while armed), the log indicator (`◉ LOG 1284` / `◉ LOG off`), and a clock. + +**Connection rail** (left, ~204px ≈ 25 cols expanded / 46px ≈ 6 collapsed): a two-level tree. + +``` +┌ CONNECTIONS +▚ Aetherfall + aetherfall.mux:4201 + ▸ ● Corvid 3 + ▪ main p1 + ▪ #public ✎ 3 p2 + ▪ pages p3 + ▪ +who p1 + ○ Rookery +▚ Nightmarket + nightmarket.org:6250 + ○ Sparrow 2 + ▪ main closed + ▪ #trade 2 p2 +``` + +World header carries the world accent as a left spine on the active group. Characters indent +one level with a connected dot (`●`/`○`) and an active marker (`▸`). Windows indent again, +showing unread count, a `✎` if they hold unsent input, and which pane hosts them (or `closed`). +Worlds with no characters print `no characters` rather than rendering empty. + +Collapsed (⌃B b, or click the header) it becomes a 46-col strip: per-world separator glyph, +then character initials with status dot and unread count. Clicking still switches character. + +**Pane area**: a recursive split tree. Each pane is a bordered box containing a tab strip and +an output view; the focused pane's border takes its character's accent colour. + +Tab strip: one tab per window hosted in that pane. Each tab shows a colour dot (its character's +accent), the window name, unread count, `⌁` if the window belongs to a *different* character +than the one currently focused, and `✕` on the active tab only. Tabs keep natural width and the +strip scrolls horizontally when they overflow, with a `»N` counter on the right. Right of that: +`▯▯` split-right, `⌸` split-down, `⤢` zoom. + +Below the strip, spawn windows show their capture pattern as a dim line: `⇱ capture ^\[public\]`. + +Output view: timestamp column (optional), then styled spans. Trigger-highlighted lines get a +2-col left rule in the trigger's colour plus a tinted background. + +Freezing (⌃F) splits the pane horizontally: frozen scrollback above under a +`▲ FROZEN ⌃F` bar, live tail below. + +**Input** (grows, min 3 rows): prompt reads `Corvid@aetherfall ›` — bound to the focused +**character**, not the focused pane. Right gutter shows the destination window (`→ main`), +a `✎ pages #public` list of other windows holding drafts, character count, and spellcheck state. + +**Status bar** (1 row): connection state, `HP ████░░░░ 78`, `EN ███░░░░░ 54`, +`keepalive ▁▃▅▇ ack 41ms`, then host / encoding / `⌃P palette`. + +### 2. Command surface (⌃P, or the header menu) + +One surface for both mouse and keyboard; there is no separate menu. + +Search field on top (`› type to search commands, windows, characters…`) with a match count +(`12 of 41`). Under it a context strip naming the character every command will act on. +Results are grouped `├ GO TO` / `├ WORLD` / `├ TERMINAL` / `├ LAYOUT`, and ↑↓ walks the +flattened list across group boundaries. ⏎ runs, Esc closes. + +The catalog is generated from live state, not static: every non-focused character is a +`Switch to Rookery` entry, every window a `Go to #public` entry subtitled with its owner and +unread count, and stateful commands read their current value (`Pause logging`, +`Unzoom pane`, `Resume scrollback`). + +Ranking: substring match beats fuzzy subsequence; a prefix match on the command name ranks highest. + +On a narrow terminal it docks to the bottom; otherwise it floats near the top. + +### 3. Worlds & Characters (F5) — full screen + +Not a dialog. Worlds list on the left (name, address, character count, live count) with +`[+ world]` / `[- del]`. Right side, top to bottom: + +- **Header**: world name, address, TLS state, encoding. +- **`├ WORLD`**: name, host, port, security, encoding, keepalive — right-aligned labels, + bordered value cells. +- **`├ CHARACTERS`**: a table — name, state (`● connected` / `○ offline`), login mode, + assigned trigger sets — with `[+ add character] [⧉ duplicate] [- remove]`. Empty state: + *"no characters — this world has nothing to connect with."* +- **`└ CHARACTER · `**: two columns. Left: name, password (keychain), on-connect, + auto-login, session state. Right: the trigger-set checklist — each row is + `[x] ▪ Comms — channel + page routing 2 rules`. Toggling assigns/unassigns live. + +Footer: `Cancel` / `Save`. + +### 4. Triggers & spawn routing (F2) + +Two columns. Left: the rule list — enable checkbox, name, pattern, owning set (`▪ Comms`), +and action flags. Right: the editor for the selected rule — pattern field, a **route-to** +list (main inline, or any spawn window), colour swatches, and `[x] highlight line` / +`[x] play sound` / `[ ] gag line`. Editing is live: change a pattern and the next matching +line routes differently. + +### 5. Other dialogs + +`F3` aliases · `F4` keypad & hotkeys (3×3 keypad grid + binding list) · `F6` timers · +`F7` text & ANSI · `F8` input & spellcheck · `F9` logging. All are checkbox-list or +table layouts in the same frame; F7/F8/F9 share one options-list body. + +--- + +## Interactions + +### Pane management (tmux-style prefix) + +`⌃B` arms a prefix — the header shows `⌃B — awaiting | - z o x b m < >` — and the next key acts: + +| Key | Action | +|---|---| +| `\|` | split focused pane vertically, moving its non-active tabs into the new pane | +| `-` | split horizontally, same rule | +| `z` | zoom / unzoom focused pane | +| `o` | cycle pane focus | +| `x` | close focused pane | +| `b` | collapse / expand the connection rail | +| `m` | enter **move mode** | +| `<` `>` | reorder the active tab within its pane | + +Splitting moves the *other* tabs across rather than duplicating the active one — the common +case is "pull #public out into its own pane". + +### Move mode (`⌃B m`) — the keyboard path for window placement + +Drag is an accelerator, not the only route. Move mode: the active window lifts, every pane dims +and shows a large target letter (`a`–`j`), and the status bar becomes the prompt +`MOVE #public → [b] split right · a–j pane · ←↑↓→ edge · ⏎ commit · Esc cancel`. + +- `a`–`j` or Tab picks the destination pane +- arrows or `hjkl` toggle an edge (splits there instead of adding as a tab); pressing the same + edge again clears it +- ⏎ commits, Esc cancels + +The edge preview reuses the same highlight the drag path draws. + +### Mouse + +Drag a tab (or a window from the rail) onto a pane: drop in the middle to add it as a tab, +drop within 25% of an edge to split there. Pane dividers drag to resize (min 14% per side). +This requires SGR mouse reporting (modes 1002/1006) — note it degrades on some SSH stacks, +which is exactly why move mode exists. + +### Per-tab input drafts + +Each tab owns its input buffer. Switching tabs parks the typed text with the tab it was written +in and presents the new tab's buffer; switching back restores it verbatim. Sending clears only +that tab's buffer. Closing a tab keeps its buffer for when the window reopens. + +History recall must not destroy a draft: `↑` stashes the live draft before the first recall, +`↓` past the newest entry restores it, and editing a recalled line re-bases it as the draft. +While recalling, the gutter shows `history · ↓ back to draft`. + +Held drafts are visible, not silent: `✎` on the tab, `✎` in the rail, and a +`✎ pages #public` list in the input gutter. + +### Trigger routing + +A line is matched against the union of the session character's trigger sets. First `Gag` wins +and drops the line. Otherwise highlights accumulate, and the last matching `SpawnTarget` +decides the destination window. A line routed to a non-visible window increments its unread +count on the tab, the rail character, and the rail world. + +### Other keys + +`⌃P` command surface · `⌃F` freeze/resume in focused pane · `⌃L` toggle logging · +`⌃Tab` next tab in pane · `⌃R` reconnect · `↑`/`↓` history · `F2`–`F9` config · `Esc` close overlay · +`Shift+Enter` newline in input. + +--- + +## State + +Per application: + +- `layout` — the pane split tree: `{t:'s', dir:'row'|'col', sizes:[a,b], kids:[…]}` interior + nodes, `{t:'p', id, tabs:[windowId], active}` leaves. Pruning rule: a pane with no tabs is + removed, and a split left with one child collapses into that child. +- `focus` — pane id; `conn` — focused session id (`world.character`) +- `zoom` — pane id or null +- `frozen` — per-pane bool +- `drafts` / `stash` — per-window input buffers and the pre-recall stash +- `hIdx` — history cursor, reset to -1 on any tab or pane switch +- `move` — `{windowId, from, target, edge}` while move mode is active +- `railOpen`, `palette` + query + selection, `dialog` / `screen` + +Per window: `{id, sessionId, name, kind: main|chan|page|spawn, capturePattern, lines[], unread}`. + +## Design tokens + +The mock's palette is a stand-in. Map it through `Theme` rather than copying hexes: + +| Mock hex | Role | Resolve via | +|---|---|---| +| `#0b0e14` | app background | `Theme.Background` | +| `#c8d0dd` | body text | `Theme.Foreground` | +| `#12161f` | status bar bg | `Theme.StatusBackground` | +| `#8b93a5` | status bar text | `Theme.StatusForeground` | +| `#1e2532` / `#2e394d` | pane + dialog borders | `Theme.Border` | +| `#63c8d8` | accent, prompt, focus ring | `Theme.Prompt` | +| `#98c379` | connected, character names, HP | `Theme.SystemMessage`, ANSI 2 | +| `#8b93a5` on echo | local echo | `Theme.LocalEcho` | +| `#e5c07b` | unread, warnings, patterns, move mode | ANSI 3 | +| `#e06c75` | disconnected, errors, destructive | ANSI 1 | +| `#c678dd` | frozen-split chrome, channel captures | ANSI 5 | +| `#e58fb0` | pages / whispers | ANSI 13 | +| `#d19a66` | poses, second world accent | ANSI 11 | +| `#5b6577` / `#404b5e` / `#3f4859` | dim text, section labels, disabled | derive from `Theme.Foreground` | + +Per-world accent colours should be a `WorldDefinition.Accent` field (an ANSI index or `Rgb`), +not hardcoded — the design leans on them to keep windows traceable to their owner once they +scatter across panes. + +**Character cells, not pixels.** All mock dimensions are px against a 13px monospace grid; +divide by ~8 for columns, ~20 for rows. Rail 204px ≈ 25 cols expanded, 46px ≈ 6 collapsed. +Header/status/input rows are 1 row each. Minimum pane after a split ≈ 14% of its parent. + +**Glyphs used:** `▚ ▸ ▪ ● ○ ✎ ⌁ ✕ ⇱ ▯▯ ⌸ ⤢ ⤡ ▲ █ ░ ▁▃▅▇ ┌ ├ └ »`. All are in the common +box-drawing/geometric ranges; `⌁` and `⇱` are the least safe — substitute if MTTS reports a +narrow charset. + +**No rounded corners, gradients, or shadows anywhere.** The mock had them early and they were +removed deliberately: the design must read as a terminal. + +## Assets + +None. No images, no icon fonts — glyphs only. + +## Files + +- `SharpMUTerm-TUI-v3.dc.html` — the interactive design reference (open in a browser) +- `support.js` — runtime for the above; not part of the design + +Repo files each screen maps to are tabulated in `github.md` at the project root. + +## Suggested PR breakdown + +1. **Schema** — `CharacterDefinition`, `TriggerSet`, `AppConfiguration.TriggerSets`, + `SessionManager` keying, `BeipMuImporter` update, migration from v1 config. Tests only. +2. **Pane tree** — split tree model + Terminal.Gui view hosting, dividers, zoom, `⌃B` prefix. +3. **Tab strips & spawn routing** — per-pane tabs, unread, `TriggerEngine` `SpawnTarget` → window. +4. **Rail** — worlds/characters/windows tree, collapse. +5. **Input** — per-tab drafts, draft-safe history, `✎` indicators. +6. **Move mode + mouse drag**. +7. **Command surface**. +8. **Settings screens** — F5 full screen, then F2–F9. + +Steps 1 and 2 are the load-bearing ones; everything after is additive. diff --git a/docs/design/SharpMUTerm-TUI-v3.dc.html b/docs/design/SharpMUTerm-TUI-v3.dc.html new file mode 100644 index 0000000..00cea19 --- /dev/null +++ b/docs/design/SharpMUTerm-TUI-v3.dc.html @@ -0,0 +1,1421 @@ + + + + + + + + + + + + + +
+ +
+
{{ brandCaret }}muterm
+ ⌃B — awaiting | - z o x +
+
+ ⌃B split · drag a tab to move + ◉ {{ logLines }} + {{ clock }} +
+
+ +
+ +
+
+ {{ railIcon }} + CONNECTIONS + {{ railUnread }} +
+ + +
+
+ + {{ w.name }} + {{ w.unread }} +
+
{{ w.addr }}
+ +
no characters
+
+ +
+
+ {{ c.mark }} + + {{ c.char }} + {{ c.unread }} +
+ +
+ + {{ win.label }} + + {{ win.unread }} + {{ win.where }} +
+
+
+
+
+
+
Windows belong to their connection. Drag one into any pane; drop near an edge to split.
+
+ + +
+ +
+ + {{ c.char }} + {{ c.unread }} +
+
+
+
+
+ +
+ +
+
+ +
+
+ +
+ + + {{ t.label }} + {{ t.unread }} + + +
+
+
+
+ »{{ p.hiddenN }} + ▯▯ + + {{ p.zoomIcon }} +
+
+ + +
+
{{ p.frozenTag }}
+
+ +
+ {{ ln.ts }} + {{ g.t }} +
+
+
+
+
+ +
{{ p.capLabel }}
+
+ +
+ {{ ln.ts }} + {{ g.t }} +
+
+
+ + +
+ +
+
+ {{ p.moveLetter }} +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+
+ {{ prompt }} +
+ +
+ {{ routeNote }} + {{ heldDrafts }} + history · ↓ back to draft + {{ inputMeta }} + {{ spellMeta }} +
+
+ +
+ +
{{ connText }}
+
HP{{ hpBar }}{{ hp }}
+
EN{{ enBar }}{{ en }}
+
keepalive{{ kaGlyph }}{{ kaLabel }}
+
+
+ +
+ MOVE + {{ moveWin }} + + [{{ moveTargetLetter }}] + {{ moveEdgeLabel }} + · a–j pane · ←↑↓→ edge +
+ ⏎ commit + Esc cancel +
+
+ +
{{ host }}utf-8⌃P palette
+
+
+ + +
{{ dragLabel }}
+
+ +
+
+ back + Worlds & Characters + F5 +
+ +
+ +
+
┌ WORLDS
+ +
+
{{ w.mark }}{{ w.name }}
+
{{ w.meta }}
+
{{ w.count }}
+
+
+
+ [+ world] + [- del] +
+
+ +
+ +
+
+ {{ wsHead.name }} + {{ wsHead.addr }} + {{ wsHead.tls }} + {{ wsHead.enc }} +
+
+ +
+
├ WORLD
+
+ +
+ {{ fl.k }} + {{ fl.v }} +
+
+
+
+ +
+
+ ├ CHARACTERS + a character is a connection +
+
+
+ namestatelogintrigger sets +
+ +
+ {{ c.mark }} + {{ c.name }} + {{ c.dot }} {{ c.state }} + {{ c.auto }} + {{ c.sets }} +
+
+ +
no characters — this world has nothing to connect with.
+
+
+
+ [+ add character] + [⧉ duplicate] + [- remove] +
+
+ + +
+
└ CHARACTER · {{ wsChar.name }}
+
+
+
name{{ wsChar.name }}
+
password•••••••• keychain
+
on connect{{ wsChar.onConnect }}
+
auto-login{{ wsChar.auto }}
+
session{{ wsChar.state }}
+
+
+
assigned trigger sets
+
+ +
+ {{ t.box }} + + {{ t.name }} — {{ t.note }} + {{ t.n }} +
+
+
+
Trigger sets live in F2, independent of any world. A set only fires on characters it is assigned to.
+
+
+
+
+
+
+ +
+ Cancel + Save +
+
+
+ + +
+
+ +
+ + + {{ cmdCount }} +
+ +
+ {{ cmdCtx }} + context for every command below +
+ +
+ +
no match — Esc to close
+
+ +
+
├ {{ g.label }}
+ +
+ + {{ i.k }} + {{ i.sub }} + + {{ i.hint }} +
+
+
+
+
+ +
+ ↑↓ select⏎ runEsc close + [close] +
+
+
+
+ + +
+
+
+
{{ dialogTitle }}{{ dialogHint }}
+ +
+
+ + +
+
+
onname / pattern→ window
+ +
+ {{ t.on }} + + {{ t.name }} + {{ t.pat }} + ▪ {{ t.set }} · {{ t.flags }} + + {{ t.route }} +
+
+
+
+
+
match pattern (regex)
+ +
+
+
route matching lines to
+
+ +
{{ r.label }}
+
+
+
+
+
highlight colour
+
+ +
+
+
+
+
+ {{ trigHi }} + {{ trigSound }} + {{ trigGag }} +
+
Routing is live: change a pattern and the next matching line lands in the chosen spawn window instead of main.
+
+
+
+ + +
+
keyexpansionname
+ +
+ {{ a.key }}{{ a.exp }}{{ a.name }} +
+
+
+
%1…%9 positional args · %n your name · aliases are per-connection.
+
+ + +
+
+ +
+ {{ k.n }}{{ k.cmd }} +
+
+
+
+ +
{{ h.k }}{{ h.v }}
+
+
+
+
+ + +
+ +
+
{{ c.name }}{{ c.state }}
+
{{ c.host }}
+
character: {{ c.char }}
+
spawn windows: {{ c.spawns }}
+
+
+
+
+ + +
+
oneverycommandname
+ +
{{ t.on }}{{ t.every }}{{ t.cmd }}{{ t.name }}
+
+
+
+ + +
+ +
{{ o.box }}{{ o.k }}
+
+
{{ optsNote }}
+
+
+ +
+
+ Close + Apply +
+
+
+
+
+ +
+ + + diff --git a/docs/design/support.js b/docs/design/support.js new file mode 100644 index 0000000..cb009b6 --- /dev/null +++ b/docs/design/support.js @@ -0,0 +1,1911 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + var SLIDE_ID_VALUE_RE = /^[0-9a-f]{8}$/; + var DECK_CONTROL_FLOW_RE = /^(sc-if|sc-for|sc-else|dc-import|x-import)$/; + var DECK_AUX_RE = /^(template|script|style|sc-helmet|helmet)$/; + function isDeckMountTag(el) { + if (el.localName === "deck-stage") return true; + return el.localName === "x-import" && (el.getAttribute("component-from-global-scope") || "") === "deck-stage"; + } + function walkDeckChildren(el, host) { + const pairs = [...el.childNodes].map((c) => ({ c, b: walk(c, host) })).filter((p) => p.b !== null); + const kids = pairs.map((p) => p.b); + const seen = /* @__PURE__ */ new Set(); + const wsSeen = /* @__PURE__ */ new Map(); + const keys = []; + const nextSlideId = new Array(pairs.length); + { + let upcoming = null; + for (let j = pairs.length - 1; j >= 0; j--) { + const n = pairs[j].c; + if (n.nodeType === Node.ELEMENT_NODE) { + const t = n.localName; + upcoming = !DECK_AUX_RE.test(t) && !DECK_CONTROL_FLOW_RE.test(t) ? n.getAttribute("data-om-slide-id") : null; + } + nextSlideId[j] = upcoming; + } + } + for (let j = 0; j < pairs.length; j++) { + const { c } = pairs[j]; + if (c.nodeType === Node.TEXT_NODE) { + if ((c.nodeValue ?? "").trim() === "") { + const base = nextSlideId[j] ? "omid-ws:" + nextSlideId[j] : "omid-ws:aux"; + const n = wsSeen.get(base) ?? 0; + wsSeen.set(base, n + 1); + keys.push(n === 0 ? base : base + ":" + n); + continue; + } + return { kids, keys: null }; + } + if (c.nodeType !== Node.ELEMENT_NODE) { + keys.push(j); + continue; + } + const child = c; + const tag = child.localName; + if (DECK_AUX_RE.test(tag)) { + keys.push(j); + continue; + } + if (DECK_CONTROL_FLOW_RE.test(tag)) return { kids, keys: null }; + const v = child.getAttribute("data-om-slide-id"); + if (!v || !SLIDE_ID_VALUE_RE.test(v) || seen.has(v)) { + return { kids, keys: null }; + } + seen.add(v); + keys.push("omid:" + v); + } + return { kids, keys }; + } + function renderDeckKids(kids, kidKeys, vals, ctx) { + return kids.map((b, j) => { + const k = kidKeys ? kidKeys[j] : j; + const out = b(vals, ctx, k); + return kidKeys != null && typeof out === "string" ? h(getReact().Fragment, { key: k }, out) : out; + }); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const deckKeyed = hasContent && isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : hasContent ? walkChildren(el, host) : []; + const kidKeys = deckKeyed?.keys ?? null; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) { + props.children = renderDeckKids(kids, kidKeys, vals, ctx); + } + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const deckKeyed = isDeckMountTag(el) ? walkDeckChildren(el, host) : null; + const kids = deckKeyed ? deckKeyed.kids : walkChildren(el, host); + const kidKeys = deckKeyed?.keys ?? null; + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...renderDeckKids(kids, kidKeys, vals, ctx)); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function scanUnquotedUrl(css, i) { + if (css[i] !== "u" && css[i] !== "U" || css.slice(i, i + 4).toLowerCase() !== "url(" || /[a-z0-9_-]/i.test(css[i - 1] ?? "")) { + return -1; + } + let j = i + 4; + while (j < css.length && /\s/.test(css[j])) j++; + if (css[j] === '"' || css[j] === "'") return -1; + while (j < css.length && css[j] !== ")") { + if (css[j] === "\\") j++; + j++; + } + return j < css.length ? j + 1 : css.length; + } + function stripComments(css) { + let out = ""; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") { + out += c + (css[i + 1] ?? ""); + i++; + continue; + } + if (c === quote) quote = ""; + out += c; + } else if (c === "'" || c === '"') { + quote = c; + out += c; + } else if (c === "/" && css[i + 1] === "*") { + const end = css.indexOf("*/", i + 2); + i = end === -1 ? css.length : end + 1; + out += " "; + } else { + const end = scanUnquotedUrl(css, i); + if (end === -1) out += c; + else { + out += css.slice(i, end); + i = end - 1; + } + } + } + return out; + } + function importantify(css) { + css = stripComments(css); + const decls = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let i = 0; i < css.length; i++) { + const c = css[i]; + if (quote) { + if (c === "\\") i++; + else if (c === quote) quote = ""; + } else if (c === "'" || c === '"') quote = c; + else if (c === "(") depth++; + else if (c === ")") depth = Math.max(0, depth - 1); + else if (c === ";" && depth === 0) { + decls.push(css.slice(start, i)); + start = i + 1; + } else { + const end = scanUnquotedUrl(css, i); + if (end !== -1) i = end - 1; + } + } + decls.push(css.slice(start)); + return decls.map((d) => d.trim()).filter(Boolean).map((d) => /!\s*important$/i.test(d) ? d : d + " !important").join(";"); + } + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const isPseudoElement = pseudo === "before" || pseudo === "after"; + const sel = isPseudoElement ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule( + sel + "{" + (isPseudoElement ? css : importantify(css)) + "}", + el.sheet.cssRules.length + ); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: "); + var text = AllText(lines); + await Assert.That(text).Contains("visible"); + await Assert.That(text).DoesNotContain("var x"); + await Assert.That(text).DoesNotContain(".a{}"); + } + + [Test] + public async Task Heading_IsBoldAndSeparated() + { + var lines = Render("

Title

body

"); + var title = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("Title")); + await Assert.That(title.Style.HasAttribute(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task ListItems_GetBullets() + { + var lines = Render("
  • one
  • two
"); + var text = AllText(lines); + await Assert.That(text).Contains("• one"); + await Assert.That(text).Contains("• two"); + } + + [Test] + public async Task Break_ProducesNewLine() + { + var lines = Render("first
second"); + await Assert.That(lines.Count).IsGreaterThanOrEqualTo(2); + await Assert.That(lines.Any(l => l.Text == "first")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "second")).IsTrue(); + } + + [Test] + public async Task WhitespaceIsCollapsed_AcrossInlineElements() + { + var lines = Render("

a b c

"); + await Assert.That(AllText(lines)).Contains("a b c"); + } + + [Test] + public async Task Wrapping_RespectsWidth() + { + var lines = Render("

" + string.Join(' ', Enumerable.Repeat("word", 40)) + "

", width: 20); + await Assert.That(lines.All(l => l.Text.Length <= 20)).IsTrue(); + await Assert.That(lines.Count).IsGreaterThan(1); + } + + [Test] + public async Task Image_BecomesLabelledLink() + { + var lines = Render("\"a"); + var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("image")); + await Assert.That(span.Text).Contains("a cat"); + await Assert.That(span.IsInteractive).IsTrue(); + await Assert.That(span.Interaction!.Kind).IsEqualTo(InteractionKind.Hyperlink); + await Assert.That(span.Interaction!.Target).IsEqualTo("pic.png"); + } + + [Test] + public async Task ManySameStyleSegments_CoalesceCorrectly() + { + // Exercises the StringBuilder coalescing path with many adjacent same-style inline nodes. + var html = "

" + string.Concat(Enumerable.Repeat("a", 500)) + "

"; + var lines = Render(html, width: 10_000); + var text = string.Concat(lines.Select(l => l.Text)); + await Assert.That(text).IsEqualTo(new string('a', 500)); + } + + [Test] + public async Task Preformatted_PreservesLineBreaks() + { + var lines = Render("
line1\nline2
"); + await Assert.That(lines.Any(l => l.Text == "line1")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "line2")).IsTrue(); + } + + [Test] + public async Task GetTitle_ReturnsDocumentTitle() + { + await Assert.That(HtmlStyledRenderer.GetTitle("My Page")) + .IsEqualTo("My Page"); + } + + [Test] + public async Task FontColor_IsApplied() + { + var lines = Render("danger"); + var span = lines.SelectMany(l => l.Spans).First(s => s.Text.Contains("danger")); + await Assert.That(span.Style.Foreground).IsEqualTo(TerminalColor.FromRgb(0xff, 0, 0)); + } +} diff --git a/tests/MuClient.Scripting.Tests/MuClient.Scripting.Tests.csproj b/tests/SharpMUTerm.Web.Tests/SharpMUTerm.Web.Tests.csproj similarity index 58% rename from tests/MuClient.Scripting.Tests/MuClient.Scripting.Tests.csproj rename to tests/SharpMUTerm.Web.Tests/SharpMUTerm.Web.Tests.csproj index dbd8da0..64f338e 100644 --- a/tests/MuClient.Scripting.Tests/MuClient.Scripting.Tests.csproj +++ b/tests/SharpMUTerm.Web.Tests/SharpMUTerm.Web.Tests.csproj @@ -2,8 +2,8 @@ Exe - MuClient.Scripting.Tests - MuClient.Scripting.Tests + SharpMUTerm.Web.Tests + SharpMUTerm.Web.Tests false true @@ -13,7 +13,7 @@
- + diff --git a/tools/ansi_frame_to_image.py b/tools/ansi_frame_to_image.py new file mode 100755 index 0000000..37b9c54 --- /dev/null +++ b/tools/ansi_frame_to_image.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Convert a SharpMUTerm headless snapshot (`sharpmuterm --snapshot`) into an SVG image. + +The headless SharpConsoleUI driver emits a full-screen ANSI frame: per-row cursor +moves (`ESC[row;colH`), truecolor SGR runs (`ESC[...;38;2;r;g;b;48;2;r;g;b;...m`), +and text. This parses that into a character grid with per-cell fg/bg/bold and emits +a self-contained SVG (monospace, background rects + text) — no external deps, so it +runs anywhere and the output embeds directly in Markdown/READMEs. + +Usage: + sharpmuterm --snapshot --size 100x30 | python3 tools/ansi_frame_to_image.py > shot.svg + python3 tools/ansi_frame_to_image.py frame.ansi shot.svg +""" +import base64 +import os +import sys + +CELL_W = 8.4 +CELL_H = 18.0 +FONT_SIZE = 15 +PAD = 12 +# A little extra headroom above the first row so a focused header highlight or a tall Nerd glyph +# isn't clipped against the top edge, and clear breathing room below the last row so previews never +# look cut off against the bottom edge. +PAD_TOP = 20 +PAD_BOTTOM = 28 +DEFAULT_BG = (24, 24, 28) +DEFAULT_FG = (238, 238, 238) + +# A subset JetBrainsMono Nerd Font Mono (OFL), embedded so the Nerd Font icons + box drawing render +# anywhere the SVG is opened — no font install required on the viewer's side. Falls back to generic +# monospace when the file is absent (icons then show as tofu, same as before). +FONT_NAME = "SharpMUTerm Mono Nerd" +FONT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts", "SharpMUTermMonoNerd.woff") +FONT_FAMILY = f"'{FONT_NAME}',ui-monospace,Menlo,Consolas,monospace" + + +def font_face_style(): + """Returns an inline " + ) + + +class Cell: + __slots__ = ("ch", "fg", "bg", "bold") + + def __init__(self): + self.ch = " " + self.fg = DEFAULT_FG + self.bg = DEFAULT_BG + self.bold = False + + +def parse(data): + grid = {} + row = col = 1 + fg, bg, bold = DEFAULT_FG, DEFAULT_BG, False + i, n = 0, len(data) + maxr = maxc = 0 + while i < n: + c = data[i] + if c == "\x1b" and i + 1 < n and data[i + 1] == "[": + j = i + 2 + while j < n and not data[j].isalpha(): + j += 1 + seq, fin = data[i + 2:j], data[j] if j < n else "" + if fin == "H": + parts = seq.split(";") + row = int(parts[0] or 1) + col = int(parts[1] or 1) if len(parts) > 1 else 1 + elif fin == "m": + fg, bg, bold = apply_sgr(seq, fg, bg, bold) + i = j + 1 + continue + if c == "\x1b" and i + 1 < n and data[i + 1] == "]": + j = i + 2 + while j < n and data[j] != "\x07": + j += 1 + i = j + 1 + continue + if c == "\x1b": + i += 2 + continue + if c == "\n": + row += 1 + col = 1 + elif c == "\r": + col = 1 + elif c >= " ": + # Snapshots currently only contain single-width glyphs (Nerd Font icons, box drawing, + # ASCII), so advancing the column by 1 is always correct here. Double-width content + # (East Asian / emoji) would need a `unicodedata.east_asian_width(c) in ("W", "F")` + # check to advance by 2 and keep the grid aligned. + cell = Cell() + cell.ch, cell.fg, cell.bg, cell.bold = c, fg, bg, bold + grid[(row, col)] = cell + maxr, maxc = max(maxr, row), max(maxc, col) + col += 1 + i += 1 + return grid, maxr, maxc + + +def apply_sgr(seq, fg, bg, bold): + codes = [int(x) if x else 0 for x in seq.split(";")] or [0] + k = 0 + while k < len(codes): + c = codes[k] + if c == 0: + fg, bg, bold = DEFAULT_FG, DEFAULT_BG, False + elif c == 1: + bold = True + elif c == 22: + bold = False + elif c == 38 and k + 4 < len(codes) and codes[k + 1] == 2: + fg = (codes[k + 2], codes[k + 3], codes[k + 4]) + k += 4 + elif c == 48 and k + 4 < len(codes) and codes[k + 1] == 2: + bg = (codes[k + 2], codes[k + 3], codes[k + 4]) + k += 4 + elif c == 38 and k + 2 < len(codes) and codes[k + 1] == 5: + k += 2 + elif c == 39: + fg = DEFAULT_FG + elif c == 48 and k + 2 < len(codes) and codes[k + 1] == 5: + k += 2 + elif c == 49: + bg = DEFAULT_BG + k += 1 + return fg, bg, bold + + +def esc(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def hexc(rgb): + return "#%02x%02x%02x" % rgb + + +def to_svg(grid, maxr, maxc): + width = PAD * 2 + maxc * CELL_W + height = PAD_TOP + maxr * CELL_H + PAD_BOTTOM + out = [ + f'', + font_face_style(), + # Square corners — a terminal frame, no rounding. + f'', + ] + # Background rects: merge horizontal runs of equal bg per row. + for r in range(1, maxr + 1): + c = 1 + while c <= maxc: + cell = grid.get((r, c)) + bg = cell.bg if cell else DEFAULT_BG + start = c + while c <= maxc: + nxt = grid.get((r, c)) + if (nxt.bg if nxt else DEFAULT_BG) != bg: + break + c += 1 + if bg != DEFAULT_BG: + x = PAD + (start - 1) * CELL_W + y = PAD_TOP + (r - 1) * CELL_H + out.append( + f'' + ) + # Text: merge horizontal runs of equal fg/bold per row. + for r in range(1, maxr + 1): + y = PAD_TOP + (r - 1) * CELL_H + FONT_SIZE - 1 + c = 1 + while c <= maxc: + cell = grid.get((r, c)) + if not cell or cell.ch == " ": + c += 1 + continue + fg, bold = cell.fg, cell.bold + start, run = c, [] + while c <= maxc: + nxt = grid.get((r, c)) + if not nxt or nxt.ch == " " or nxt.fg != fg or nxt.bold != bold: + break + run.append(nxt.ch) + c += 1 + x = PAD + (start - 1) * CELL_W + weight = ' font-weight="bold"' if bold else "" + out.append( + f'{esc("".join(run))}' + ) + out.append("") + return "\n".join(out) + + +def to_html(grid, maxr, maxc): + """Emit a self-contained HTML page: the grid as a coloured
."""
+    rows = []
+    for r in range(1, maxr + 1):
+        spans, c = [], 1
+        while c <= maxc:
+            cell = grid.get((r, c))
+            fg = cell.fg if cell else DEFAULT_FG
+            bg = cell.bg if cell else DEFAULT_BG
+            bold = cell.bold if cell else False
+            start, run = c, []
+            while c <= maxc:
+                nxt = grid.get((r, c))
+                nfg = nxt.fg if nxt else DEFAULT_FG
+                nbg = nxt.bg if nxt else DEFAULT_BG
+                nbold = nxt.bold if nxt else False
+                if (nfg, nbg, nbold) != (fg, bg, bold):
+                    break
+                run.append(nxt.ch if nxt else " ")
+                c += 1
+            style = f"color:{hexc(fg)}"
+            if bg != DEFAULT_BG:
+                style += f";background:{hexc(bg)}"
+            if bold:
+                style += ";font-weight:bold"
+            spans.append(f'{esc("".join(run))}')
+        rows.append("".join(spans))
+    body = "\n".join(rows)
+    face = font_face_style().replace("", "").replace("", "")
+    return (
+        "SharpMUTerm"
+        f"{face}"
+        f""
+        f"
"
+        f"{body}
" + ) + + +def main(): + argv = sys.argv[1:] + force_html = "--html" in argv + positional = [a for a in argv if not a.startswith("--")] + if positional: + with open(positional[0], encoding="utf-8", errors="replace") as fh: + data = fh.read() + else: + data = sys.stdin.read() + grid, maxr, maxc = parse(data) + out_path = positional[1] if len(positional) > 1 else None + html = force_html or (out_path is not None and out_path.endswith(".html")) + text = to_html(grid, maxr, maxc) if html else to_svg(grid, maxr, maxc) + if out_path: + with open(out_path, "w", encoding="utf-8") as fh: + fh.write(text) + else: + sys.stdout.write(text) + + +if __name__ == "__main__": + main() diff --git a/tools/fonts/LICENSE-NerdFonts.txt b/tools/fonts/LICENSE-NerdFonts.txt new file mode 100644 index 0000000..d163912 --- /dev/null +++ b/tools/fonts/LICENSE-NerdFonts.txt @@ -0,0 +1,126 @@ +# Nerd Fonts Licensing + +There are various sources used under various licenses: + +* Nerd Fonts source fonts, patched fonts, and folders with explict OFL SIL files are licensed under SIL OPEN FONT LICENSE Version 1.1 (see below). +* Nerd Fonts original source code files (such as `.sh`, `.py`, `font-patcher` and others) are licensed under the MIT License (MIT) (see below). +* Many other licenses are present in this project for even more detailed breakdown see: [License Audit](https://github.com/ryanoasis/nerd-fonts/blob/-/license-audit.md). + +## Source files not in folders containing an explicit license are using the MIT License (MIT) + +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Various Fonts, Patched Fonts, SVGs, Glyph Fonts, and any files in a folder with explicit SIL OFL 1.1 License + +Copyright (c) 2014, Ryan L McIntyre (https://ryanlmcintyre.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tools/fonts/OFL.txt b/tools/fonts/OFL.txt new file mode 100644 index 0000000..d307283 --- /dev/null +++ b/tools/fonts/OFL.txt @@ -0,0 +1,107 @@ +This bundled font (SharpMUTermMonoNerd.woff) is a SUBSET of JetBrainsMono Nerd Font +Mono, redistributed here solely as a documentation-screenshot asset. It is not +used by the SharpMUTerm application itself. + +Portions Copyright (c) Ryan L McIntyre and the Nerd Fonts project + (https://github.com/ryanoasis/nerd-fonts) +The Nerd Fonts patch/glyph tooling is licensed under the MIT License; its full +text travels alongside this file in LICENSE-NerdFonts.txt. + +The full, canonical text of the SIL Open Font License 1.1 that governs +JetBrainsMono (and this subset of it) follows. + +=============================================================================== + +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tools/fonts/README.md b/tools/fonts/README.md new file mode 100644 index 0000000..8c12755 --- /dev/null +++ b/tools/fonts/README.md @@ -0,0 +1,29 @@ +# Bundled screenshot font + +`SharpMUTermMonoNerd.woff` is a **subset** of **JetBrainsMono Nerd Font Mono**, reduced to only the +codepoint ranges SharpMUTerm's snapshots use (ASCII, Latin-1, box drawing, block/geometric shapes, +arrows, and the Nerd Font icon PUA ranges). It is embedded as a base64 `@font-face` by +`tools/ansi_frame_to_image.py` so the generated SVG/HTML shows the Nerd Font icons and box drawing +on any viewer, with no font install required. + +It is **not** used by the application itself — only by the documentation screenshot pipeline. + +## Licensing + +- **JetBrainsMono** — SIL Open Font License 1.1 (© JetBrains). +- **Nerd Fonts** patch / glyphs — MIT (© Ryan L McIntyre) with bundled icon sets under their own + permissive licenses. + +The SIL OFL permits bundling and redistribution (including subsets) provided the font is not sold on +its own; this repository redistributes it only as a screenshot asset. + +The complete license texts travel with the font in this directory, as SIL OFL +1.1 requires for any redistributed copy (including subsets): + +- [`OFL.txt`](OFL.txt) — the subset notice plus the full, canonical SIL Open + Font License 1.1 text governing JetBrainsMono. +- [`LICENSE-NerdFonts.txt`](LICENSE-NerdFonts.txt) — the full Nerd Fonts license + (MIT plus the OFL notice) for the patch/glyph contributions. + +See also and +. diff --git a/tools/fonts/SharpMUTermMonoNerd.woff b/tools/fonts/SharpMUTermMonoNerd.woff new file mode 100644 index 0000000..82aec9d Binary files /dev/null and b/tools/fonts/SharpMUTermMonoNerd.woff differ diff --git a/tools/make-screenshots.sh b/tools/make-screenshots.sh new file mode 100755 index 0000000..0e3dbaf --- /dev/null +++ b/tools/make-screenshots.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Generate SharpMUTerm UI screenshots headlessly — no terminal required. +# +# Renders demo frames with `sharpmuterm --snapshot` (SharpConsoleUI's HeadlessConsoleDriver) +# and converts each ANSI frame to an SVG (and HTML) with tools/ansi_frame_to_image.py. +# Runs anywhere the .NET build runs, including CI. +# +# Usage: tools/make-screenshots.sh [output-dir] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${1:-$ROOT/docs/screenshots}" +mkdir -p "$OUT" + +echo "Building sharpmuterm…" +dotnet build "$ROOT/src/SharpMUTerm.Tui/SharpMUTerm.Tui.csproj" -c Release >/dev/null + +DLL="$ROOT/src/SharpMUTerm.Tui/bin/Release/net10.0/sharpmuterm.dll" + +render() { + local name="$1" size="$2" + echo "Rendering $name ($size)…" + dotnet "$DLL" --snapshot --size "$size" --out "$OUT/$name.ansi" + python3 "$ROOT/tools/ansi_frame_to_image.py" "$OUT/$name.ansi" "$OUT/$name.svg" + python3 "$ROOT/tools/ansi_frame_to_image.py" "$OUT/$name.ansi" "$OUT/$name.html" +} + +render sharpmuterm-demo 160x48 + +echo "Done. See $OUT/*.svg"