detached 31cc24f

feat(html): add interactive browser renderer (#8)

* feat(html): add transactional frame renderer

* feat(html): add interactive browser adapter

* docs(html): document renderer and browser contracts

* ci(html): verify browser frontend matrix

* fix(html): enforce cross-runtime invariants

* fix(html): harden browser input lifecycle

* fix(html): address final review findings

* fix(html): address CodeRabbit review findings

* perf(html): coalesce blank cells while preserving wide-space geometry

* fix(ci): grant reusable checks required read permissions

* fix(browser): support soft keyboard editing and reduce accessibility churn

* perf(html): reduce default cell styling and contain row layout

* perf(html): reuse cells for text-only terminal updates

* fix(html): address Copilot review findings

* fix(test): couple Playwright Docker image to lockfile

* fix(html): address Copilot retained-limit and compatibility findings

Bound maxUpdateBytes on the projected retained terminal text instead of
the current delta, flush pending pointer moves before discrete down/up/
cancel events, accept core.CellSource in Prepare with a Frame fast path,
enforce MaxGeneratedBytes on the exact encoded size, normalize the
wrap-pending one-past-end cursor, and alternate rows in the row-replacement
performance measurement.
commit
31cc24fed8ad6d5e69d55e42588ffba52940d0e8
author
bnema · · 2026-09-09 14:11 UTC
committer
GitHub · · 2026-09-09 14:11 UTC
parent
3c9eb0e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
30 files · +3491 -5
M .github/dependabot.yml +4 -0
@@ -4,3 +4,7 @@ updates:
44 directory: /
55 schedule:
66 interval: weekly
7+ - package-ecosystem: npm
8+ directory: /
9+ schedule:
10+ interval: weekly
@@ -4,3 +4,7 @@ updates:
4 directory: /4 directory: /
5 schedule:5 schedule:
6 interval: weekly6 interval: weekly
7+ - package-ecosystem: npm
8+ directory: /
9+ schedule:
10+ interval: weekly
M .github/workflows/ci.yml +56 -5
@@ -5,15 +5,25 @@ on:
55 branches: [main]
66 paths:
77 - '**.go'
8+ - '**.js'
9+ - '**.css'
810 - 'go.mod'
911 - 'go.sum'
12+ - 'package.json'
13+ - 'package-lock.json'
14+ - 'internal/htmlharness/**'
1015 - '.golangci.yml'
1116 - '.github/workflows/**'
1217 pull_request:
1318 paths:
1419 - '**.go'
20+ - '**.js'
21+ - '**.css'
1522 - 'go.mod'
1623 - 'go.sum'
24+ - 'package.json'
25+ - 'package-lock.json'
26+ - 'internal/htmlharness/**'
1727 - '.golangci.yml'
1828 - '.github/workflows/**'
1929
@@ -22,17 +32,43 @@ permissions:
2232
2333 jobs:
2434 ci:
25- uses: bnema/gh-actions/.github/workflows/go-ci.yml@main
35+ permissions:
36+ contents: read
37+ pull-requests: read
38+ uses: bnema/gh-actions/.github/workflows/go-ci.yml@bdc22c9d0e0527bd1c369d4304034cdf2cde9ade # main
2639 with:
2740 go-version: stable
2841 test-flags: '-race -v ./...'
2942
43+ browser:
44+ runs-on: ubuntu-latest
45+ timeout-minutes: 20
46+ steps:
47+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
48+ with:
49+ persist-credentials: false
50+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
51+ with:
52+ node-version: '24'
53+ cache: npm
54+ - run: npm ci
55+ - run: npx playwright install --with-deps chromium firefox webkit
56+ - run: npm run test:browser
57+ - if: always()
58+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
59+ with:
60+ name: browser-evidence
61+ path: test-results/
62+ retention-days: 14
63+
3064 quality:
31- needs: ci
65+ needs: [ci, browser]
3266 runs-on: ubuntu-latest
3367 steps:
34- - uses: actions/checkout@v7
35- - uses: actions/setup-go@v7
68+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
69+ with:
70+ persist-credentials: false
71+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
3672 with:
3773 go-version: stable
3874 - name: Check production import direction
@@ -46,13 +82,28 @@ jobs:
4682 echo 'the ANSI package must consume the core model'
4783 exit 1
4884 fi
85+ if ! go list -f '{{join .Imports "\n"}}' ./html | grep -q '/core'; then
86+ echo 'the HTML package must consume the core model'
87+ exit 1
88+ fi
89+ if ! go list -f '{{join .Imports "\n"}}' ./html/browser | grep -q '/html'; then
90+ echo 'the browser adapter must consume the HTML package'
91+ exit 1
92+ fi
93+ forbidden="$(go list -deps ./html/... | grep -E '^(github\.com/bnema/vev-vt$|github\.com/bnema/vev($|/)|net/http$|.*/websocket($|/))' || true)"
94+ if [ -n "$forbidden" ]; then
95+ echo "$forbidden"
96+ exit 1
97+ fi
4998 - name: VT fuzz smoke
5099 run: go test . -run '^$' -fuzz FuzzVTInputDoesNotPanic -fuzztime=10s
51100 - name: ANSI fuzz smoke
52101 run: go test . -run '^$' -fuzz FuzzANSIRenderingDoesNotPanic -fuzztime=10s
53102 - name: VTH3 fuzz smoke
54103 run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s
104+ - name: HTML event fuzz smoke
105+ run: go test ./html/browser -run '^$' -fuzz FuzzDecodeEvent -fuzztime=10s
55106 - name: Benchmark smoke
56- run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI)' -benchmem -benchtime=1x
107+ run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI|Renderer)' -benchmem -benchtime=1x
57108 - name: External consumer smoke
58109 run: scripts/consumer-smoke.sh
@@ -5,15 +5,25 @@ on:
5 branches: [main]5 branches: [main]
6 paths:6 paths:
7 - '**.go'7 - '**.go'
8+ - '**.js'
9+ - '**.css'
8 - 'go.mod'10 - 'go.mod'
9 - 'go.sum'11 - 'go.sum'
12+ - 'package.json'
13+ - 'package-lock.json'
14+ - 'internal/htmlharness/**'
10 - '.golangci.yml'15 - '.golangci.yml'
11 - '.github/workflows/**'16 - '.github/workflows/**'
12 pull_request:17 pull_request:
13 paths:18 paths:
14 - '**.go'19 - '**.go'
20+ - '**.js'
21+ - '**.css'
15 - 'go.mod'22 - 'go.mod'
16 - 'go.sum'23 - 'go.sum'
24+ - 'package.json'
25+ - 'package-lock.json'
26+ - 'internal/htmlharness/**'
17 - '.golangci.yml'27 - '.golangci.yml'
18 - '.github/workflows/**'28 - '.github/workflows/**'
19 29
@@ -22,17 +32,43 @@ permissions:
22 32
23 jobs:33 jobs:
24 ci:34 ci:
25- uses: bnema/gh-actions/.github/workflows/go-ci.yml@main35+ permissions:
36+ contents: read
37+ pull-requests: read
38+ uses: bnema/gh-actions/.github/workflows/go-ci.yml@bdc22c9d0e0527bd1c369d4304034cdf2cde9ade # main
26 with:39 with:
27 go-version: stable40 go-version: stable
28 test-flags: '-race -v ./...'41 test-flags: '-race -v ./...'
29 42
43+ browser:
44+ runs-on: ubuntu-latest
45+ timeout-minutes: 20
46+ steps:
47+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
48+ with:
49+ persist-credentials: false
50+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
51+ with:
52+ node-version: '24'
53+ cache: npm
54+ - run: npm ci
55+ - run: npx playwright install --with-deps chromium firefox webkit
56+ - run: npm run test:browser
57+ - if: always()
58+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
59+ with:
60+ name: browser-evidence
61+ path: test-results/
62+ retention-days: 14
63+
30 quality:64 quality:
31- needs: ci65+ needs: [ci, browser]
32 runs-on: ubuntu-latest66 runs-on: ubuntu-latest
33 steps:67 steps:
34- - uses: actions/checkout@v768+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
35- - uses: actions/setup-go@v769+ with:
70+ persist-credentials: false
71+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
36 with:72 with:
37 go-version: stable73 go-version: stable
38 - name: Check production import direction74 - name: Check production import direction
@@ -46,13 +82,28 @@ jobs:
46 echo 'the ANSI package must consume the core model'82 echo 'the ANSI package must consume the core model'
47 exit 183 exit 1
48 fi84 fi
85+ if ! go list -f '{{join .Imports "\n"}}' ./html | grep -q '/core'; then
86+ echo 'the HTML package must consume the core model'
87+ exit 1
88+ fi
89+ if ! go list -f '{{join .Imports "\n"}}' ./html/browser | grep -q '/html'; then
90+ echo 'the browser adapter must consume the HTML package'
91+ exit 1
92+ fi
93+ forbidden="$(go list -deps ./html/... | grep -E '^(github\.com/bnema/vev-vt$|github\.com/bnema/vev($|/)|net/http$|.*/websocket($|/))' || true)"
94+ if [ -n "$forbidden" ]; then
95+ echo "$forbidden"
96+ exit 1
97+ fi
49 - name: VT fuzz smoke98 - name: VT fuzz smoke
50 run: go test . -run '^$' -fuzz FuzzVTInputDoesNotPanic -fuzztime=10s99 run: go test . -run '^$' -fuzz FuzzVTInputDoesNotPanic -fuzztime=10s
51 - name: ANSI fuzz smoke100 - name: ANSI fuzz smoke
52 run: go test . -run '^$' -fuzz FuzzANSIRenderingDoesNotPanic -fuzztime=10s101 run: go test . -run '^$' -fuzz FuzzANSIRenderingDoesNotPanic -fuzztime=10s
53 - name: VTH3 fuzz smoke102 - name: VTH3 fuzz smoke
54 run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s103 run: go test . -run '^$' -fuzz FuzzHistoryPreflightMatchesUnmarshal -fuzztime=10s
104+ - name: HTML event fuzz smoke
105+ run: go test ./html/browser -run '^$' -fuzz FuzzDecodeEvent -fuzztime=10s
55 - name: Benchmark smoke106 - name: Benchmark smoke
56- run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI)' -benchmem -benchtime=1x107+ run: go test ./... -run '^$' -bench='(VT|History|Snapshot|ANSI|Renderer)' -benchmem -benchtime=1x
57 - name: External consumer smoke108 - name: External consumer smoke
58 run: scripts/consumer-smoke.sh109 run: scripts/consumer-smoke.sh
M .gitignore +3 -0
@@ -2,6 +2,9 @@
22 /vev-vt
33 /coverage.out
44 /testdata/generated/
5+/node_modules/
6+/playwright-report/
7+/test-results/
58
69 # Local agent review notes
710 /.agent-reviews/
@@ -2,6 +2,9 @@
2 /vev-vt2 /vev-vt
3 /coverage.out3 /coverage.out
4 /testdata/generated/4 /testdata/generated/
5+/node_modules/
6+/playwright-report/
7+/test-results/
5 8
6 # Local agent review notes9 # Local agent review notes
7 /.agent-reviews/10 /.agent-reviews/
M README.md +61 -0
@@ -6,6 +6,8 @@ on screen. It powers [vev](https://github.com/bnema/vev).
66 Feed it the bytes from a shell or command. It handles text, colors, cursor
77 movement, scrolling and resizing. You can then read the screen, save its history,
88 or use the `ansi` package to draw it in a terminal.
9+The `html` package prepares typed browser updates, and `html/browser` provides a
10+safe interactive DOM adapter without owning transport or terminal-input policy.
911
1012 This library does **not** start processes or manage a PTY. Your application does
1113 that and passes the output to vev-vt.
@@ -87,16 +89,75 @@ optional compression during idle time.
8789 | `vev-vt` | Parse terminal output, read the screen and manage history. |
8890 | `vev-vt/core` | Work with cells, styles and writable grids. |
8991 | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. |
92+| `vev-vt/html` | Prepare transactional browser updates, structural CSS and terminal themes. |
93+| `vev-vt/html/browser` | Embed the DOM runtime and decode neutral browser events. |
9094 | `vev-vt/graphics` | Read supported terminal images and their positions. |
9195
9296 [Supported image features →](docs/graphics.md)
9397
98+## HTML frontend
99+
100+The HTML renderer compares every row with its committed shadow; damage values are
101+non-authoritative hints. `Prepare` permits one outstanding draw and requires an
102+explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.
103+Updates are immutable, schema-versioned JSON-compatible values. Complete-row
104+replacement preserves wide-cell atomicity, and scroll damage uses a safe
105+snapshot fallback. `html.DefaultLimits()` documents the default 1,000,000-cell,
106+10,000-row, 64 MiB generated-update, and 65,536-style bounds.
107+
108+The browser adapter builds DOM nodes with `textContent` and fixed classes. It
109+provides a labeled input proxy, synchronized plain-text accessible output,
110+typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and
111+focus events. A synchronous consumer callback decides default prevention.
112+Consumers remain responsible for transport and mapping events to terminal bytes
113+or application actions. Clipboard text is preserved unchanged, including
114+control bytes, so consumers forwarding paste events to a PTY must apply their
115+required framing or filtering policy. `browser.DefaultEventLimits()` documents
116+the default 8 MiB event, 64 KiB text, 1 MiB paste, and 10,000×10,000 geometry
117+bounds.
118+
119+```go
120+renderer, err := html.New(html.Options{})
121+if err != nil {
122+ return err
123+}
124+prepared, err := renderer.Prepare(frame, damage, reset, html.Cursor{
125+ Row: cursorRow, Column: cursorColumn, Visible: true,
126+})
127+if err != nil {
128+ return err
129+}
130+if err := send(prepared.JSON()); err != nil {
131+ _ = prepared.Abort()
132+ return err
133+}
134+return prepared.Commit()
135+```
136+
137+Embed or serve `html.Stylesheet()` and `browser.JavaScript()` from a
138+consumer-owned application. The runtime supports a self-only `style-src` and
139+`script-src` CSP in the pinned browser harness; dynamic colors are set through
140+validated numeric DOM style properties. The current core model drops combining
141+marks and does not coalesce ZWJ sequences, so rendered output inherits those
142+limits even though browser IME input remains composition-aware.
143+
94144 ## Development
95145
96146 ```sh
97147 go test ./...
98148 go test ./... -race
99149 go vet ./...
150+npm ci
151+npm run test:browser:install
152+npm run test:browser
153+# Reproducible three-engine fallback on unsupported Linux hosts:
154+npm run test:browser:docker
100155 ```
101156
157+Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by
158+`package-lock.json`. The matching Playwright container provides the reproducible
159+fallback when host libraries cannot run one of those browser builds. Production
160+Go packages retain the module's standard-library-only dependency boundary apart
161+from `vev-vt/core`.
162+
102163 [Storage benchmarks and design decisions →](docs/storage-optimization.md)
@@ -6,6 +6,8 @@ on screen. It powers [vev](https://github.com/bnema/vev).
6 Feed it the bytes from a shell or command. It handles text, colors, cursor6 Feed it the bytes from a shell or command. It handles text, colors, cursor
7 movement, scrolling and resizing. You can then read the screen, save its history,7 movement, scrolling and resizing. You can then read the screen, save its history,
8 or use the `ansi` package to draw it in a terminal.8 or use the `ansi` package to draw it in a terminal.
9+The `html` package prepares typed browser updates, and `html/browser` provides a
10+safe interactive DOM adapter without owning transport or terminal-input policy.
9 11
10 This library does **not** start processes or manage a PTY. Your application does12 This library does **not** start processes or manage a PTY. Your application does
11 that and passes the output to vev-vt.13 that and passes the output to vev-vt.
@@ -87,16 +89,75 @@ optional compression during idle time.
87 | `vev-vt` | Parse terminal output, read the screen and manage history. |89 | `vev-vt` | Parse terminal output, read the screen and manage history. |
88 | `vev-vt/core` | Work with cells, styles and writable grids. |90 | `vev-vt/core` | Work with cells, styles and writable grids. |
89 | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. |91 | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. |
92+| `vev-vt/html` | Prepare transactional browser updates, structural CSS and terminal themes. |
93+| `vev-vt/html/browser` | Embed the DOM runtime and decode neutral browser events. |
90 | `vev-vt/graphics` | Read supported terminal images and their positions. |94 | `vev-vt/graphics` | Read supported terminal images and their positions. |
91 95
92 [Supported image features →](docs/graphics.md)96 [Supported image features →](docs/graphics.md)
93 97
98+## HTML frontend
99+
100+The HTML renderer compares every row with its committed shadow; damage values are
101+non-authoritative hints. `Prepare` permits one outstanding draw and requires an
102+explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.
103+Updates are immutable, schema-versioned JSON-compatible values. Complete-row
104+replacement preserves wide-cell atomicity, and scroll damage uses a safe
105+snapshot fallback. `html.DefaultLimits()` documents the default 1,000,000-cell,
106+10,000-row, 64 MiB generated-update, and 65,536-style bounds.
107+
108+The browser adapter builds DOM nodes with `textContent` and fixed classes. It
109+provides a labeled input proxy, synchronized plain-text accessible output,
110+typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and
111+focus events. A synchronous consumer callback decides default prevention.
112+Consumers remain responsible for transport and mapping events to terminal bytes
113+or application actions. Clipboard text is preserved unchanged, including
114+control bytes, so consumers forwarding paste events to a PTY must apply their
115+required framing or filtering policy. `browser.DefaultEventLimits()` documents
116+the default 8 MiB event, 64 KiB text, 1 MiB paste, and 10,000×10,000 geometry
117+bounds.
118+
119+```go
120+renderer, err := html.New(html.Options{})
121+if err != nil {
122+ return err
123+}
124+prepared, err := renderer.Prepare(frame, damage, reset, html.Cursor{
125+ Row: cursorRow, Column: cursorColumn, Visible: true,
126+})
127+if err != nil {
128+ return err
129+}
130+if err := send(prepared.JSON()); err != nil {
131+ _ = prepared.Abort()
132+ return err
133+}
134+return prepared.Commit()
135+```
136+
137+Embed or serve `html.Stylesheet()` and `browser.JavaScript()` from a
138+consumer-owned application. The runtime supports a self-only `style-src` and
139+`script-src` CSP in the pinned browser harness; dynamic colors are set through
140+validated numeric DOM style properties. The current core model drops combining
141+marks and does not coalesce ZWJ sequences, so rendered output inherits those
142+limits even though browser IME input remains composition-aware.
143+
94 ## Development144 ## Development
95 145
96 ```sh146 ```sh
97 go test ./...147 go test ./...
98 go test ./... -race148 go test ./... -race
99 go vet ./...149 go vet ./...
150+npm ci
151+npm run test:browser:install
152+npm run test:browser
153+# Reproducible three-engine fallback on unsupported Linux hosts:
154+npm run test:browser:docker
100 ```155 ```
101 156
157+Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by
158+`package-lock.json`. The matching Playwright container provides the reproducible
159+fallback when host libraries cannot run one of those browser builds. Production
160+Go packages retain the module's standard-library-only dependency boundary apart
161+from `vev-vt/core`.
162+
102 [Storage benchmarks and design decisions →](docs/storage-optimization.md)163 [Storage benchmarks and design decisions →](docs/storage-optimization.md)
A html/assets.go +9 -0
@@ -0,0 +1,9 @@
1+package html
2+
3+import _ "embed"
4+
5+//go:embed terminal.css
6+var stylesheet string
7+
8+// Stylesheet returns the static structural terminal CSS.
9+func Stylesheet() string { return stylesheet }
@@ -0,0 +1,9 @@
1+package html
2+
3+import _ "embed"
4+
5+//go:embed terminal.css
6+var stylesheet string
7+
8+// Stylesheet returns the static structural terminal CSS.
9+func Stylesheet() string { return stylesheet }
A html/benchmark_test.go +66 -0
@@ -0,0 +1,66 @@
1+package html
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
9+
10+func BenchmarkRenderer(b *testing.B) {
11+ for _, size := range []struct{ width, height int }{{80, 24}, {120, 40}, {240, 80}} {
12+ frame := core.NewFrame(size.width, size.height)
13+ for y := range size.height {
14+ for x := range size.width {
15+ frame.Set(x, y, core.Cell{Rune: rune('A' + (x+y)%26), Style: core.DefaultStyle()})
16+ }
17+ }
18+ b.Run(fmt.Sprintf("snapshot-%dx%d", size.width, size.height), func(b *testing.B) {
19+ b.ReportAllocs()
20+ for range b.N {
21+ renderer, err := New(Options{})
22+ if err != nil {
23+ b.Fatal(err)
24+ }
25+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{})
26+ if err != nil {
27+ b.Fatal(err)
28+ }
29+ if err := prepared.Commit(); err != nil {
30+ b.Fatal(err)
31+ }
32+ }
33+ })
34+ b.Run(fmt.Sprintf("one-row-%dx%d", size.width, size.height), func(b *testing.B) {
35+ renderer, err := New(Options{})
36+ if err != nil {
37+ b.Fatal(err)
38+ }
39+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
40+ if err != nil {
41+ b.Fatal(err)
42+ }
43+ if err := first.Commit(); err != nil {
44+ b.Fatal(err)
45+ }
46+ left := frame.Clone()
47+ right := frame.Clone()
48+ right.Set(0, size.height/2, core.Cell{Rune: 'Z', Style: core.DefaultStyle()})
49+ b.ReportAllocs()
50+ b.ResetTimer()
51+ for index := range b.N {
52+ candidate := left
53+ if index%2 == 0 {
54+ candidate = right
55+ }
56+ prepared, err := renderer.Prepare(candidate, nil, false, Cursor{})
57+ if err != nil {
58+ b.Fatal(err)
59+ }
60+ if err := prepared.Commit(); err != nil {
61+ b.Fatal(err)
62+ }
63+ }
64+ })
65+ }
66+}
@@ -0,0 +1,66 @@
1+package html
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
9+
10+func BenchmarkRenderer(b *testing.B) {
11+ for _, size := range []struct{ width, height int }{{80, 24}, {120, 40}, {240, 80}} {
12+ frame := core.NewFrame(size.width, size.height)
13+ for y := range size.height {
14+ for x := range size.width {
15+ frame.Set(x, y, core.Cell{Rune: rune('A' + (x+y)%26), Style: core.DefaultStyle()})
16+ }
17+ }
18+ b.Run(fmt.Sprintf("snapshot-%dx%d", size.width, size.height), func(b *testing.B) {
19+ b.ReportAllocs()
20+ for range b.N {
21+ renderer, err := New(Options{})
22+ if err != nil {
23+ b.Fatal(err)
24+ }
25+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{})
26+ if err != nil {
27+ b.Fatal(err)
28+ }
29+ if err := prepared.Commit(); err != nil {
30+ b.Fatal(err)
31+ }
32+ }
33+ })
34+ b.Run(fmt.Sprintf("one-row-%dx%d", size.width, size.height), func(b *testing.B) {
35+ renderer, err := New(Options{})
36+ if err != nil {
37+ b.Fatal(err)
38+ }
39+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
40+ if err != nil {
41+ b.Fatal(err)
42+ }
43+ if err := first.Commit(); err != nil {
44+ b.Fatal(err)
45+ }
46+ left := frame.Clone()
47+ right := frame.Clone()
48+ right.Set(0, size.height/2, core.Cell{Rune: 'Z', Style: core.DefaultStyle()})
49+ b.ReportAllocs()
50+ b.ResetTimer()
51+ for index := range b.N {
52+ candidate := left
53+ if index%2 == 0 {
54+ candidate = right
55+ }
56+ prepared, err := renderer.Prepare(candidate, nil, false, Cursor{})
57+ if err != nil {
58+ b.Fatal(err)
59+ }
60+ if err := prepared.Commit(); err != nil {
61+ b.Fatal(err)
62+ }
63+ }
64+ })
65+ }
66+}
A html/browser/assets.go +9 -0
@@ -0,0 +1,9 @@
1+package browser
2+
3+import _ "embed"
4+
5+//go:embed terminal.js
6+var javascript string
7+
8+// JavaScript returns the static dependency-free browser runtime.
9+func JavaScript() string { return javascript }
@@ -0,0 +1,9 @@
1+package browser
2+
3+import _ "embed"
4+
5+//go:embed terminal.js
6+var javascript string
7+
8+// JavaScript returns the static dependency-free browser runtime.
9+func JavaScript() string { return javascript }
A html/browser/consumer_external_test.go +20 -0
@@ -0,0 +1,20 @@
1+package browser_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/html/browser"
7+)
8+
9+func TestExternalBrowserConsumerUsesEmbeddedRuntimeAndNeutralEvents(t *testing.T) {
10+ if browser.JavaScript() == "" {
11+ t.Fatal("embedded browser runtime is empty")
12+ }
13+ event, err := browser.DecodeEvent([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`), browser.EventLimits{})
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if event.Focus == nil || !event.Focus.Focused {
18+ t.Fatalf("decoded event = %#v", event)
19+ }
20+}
@@ -0,0 +1,20 @@
1+package browser_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/html/browser"
7+)
8+
9+func TestExternalBrowserConsumerUsesEmbeddedRuntimeAndNeutralEvents(t *testing.T) {
10+ if browser.JavaScript() == "" {
11+ t.Fatal("embedded browser runtime is empty")
12+ }
13+ event, err := browser.DecodeEvent([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`), browser.EventLimits{})
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ if event.Focus == nil || !event.Focus.Focused {
18+ t.Fatalf("decoded event = %#v", event)
19+ }
20+}
A html/browser/event.go +377 -0
@@ -0,0 +1,377 @@
1+// Package browser provides the static DOM runtime and strictly validated,
2+// transport-neutral browser event values for github.com/bnema/vev-vt/html.
3+package browser
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "math"
12+ "unicode/utf8"
13+
14+ htmlrenderer "github.com/bnema/vev-vt/html"
15+)
16+
17+// EventSchemaVersion identifies the runtime-to-Go event contract.
18+const EventSchemaVersion = htmlrenderer.UpdateSchemaVersion
19+
20+var (
21+ // ErrEventLimit reports that an event exceeded a configured resource bound.
22+ ErrEventLimit = errors.New("html/browser: event resource limit exceeded")
23+ // ErrInvalidEventLimits reports incompatible configured event bounds.
24+ ErrInvalidEventLimits = errors.New("html/browser: invalid event limits")
25+)
26+
27+const (
28+ maxJSONEscapedByteLength = 6
29+ eventEnvelopeOverhead = 64
30+)
31+
32+// EventLimits bounds untrusted browser event decoding. Zero fields select safe
33+// defaults.
34+type EventLimits struct {
35+ MaxEventBytes int
36+ MaxTextBytes int
37+ MaxPasteBytes int
38+ MaxColumns int
39+ MaxRows int
40+}
41+
42+// DefaultEventLimits returns the bounds used for zero-valued event limits.
43+func DefaultEventLimits() EventLimits {
44+ return EventLimits{
45+ MaxEventBytes: 8 << 20,
46+ MaxTextBytes: 64 << 10,
47+ MaxPasteBytes: 1 << 20,
48+ MaxColumns: 10_000,
49+ MaxRows: 10_000,
50+ }
51+}
52+
53+func normalizeEventLimits(limits EventLimits) (EventLimits, error) {
54+ if limits.MaxEventBytes < 0 || limits.MaxTextBytes < 0 || limits.MaxPasteBytes < 0 || limits.MaxColumns < 0 || limits.MaxRows < 0 {
55+ return EventLimits{}, fmt.Errorf("%w: event limits must not be negative", ErrInvalidEventLimits)
56+ }
57+ defaults := DefaultEventLimits()
58+ if limits.MaxEventBytes == 0 {
59+ limits.MaxEventBytes = defaults.MaxEventBytes
60+ }
61+ if limits.MaxTextBytes == 0 {
62+ limits.MaxTextBytes = defaults.MaxTextBytes
63+ }
64+ if limits.MaxPasteBytes == 0 {
65+ limits.MaxPasteBytes = defaults.MaxPasteBytes
66+ }
67+ if limits.MaxColumns == 0 {
68+ limits.MaxColumns = defaults.MaxColumns
69+ }
70+ if limits.MaxRows == 0 {
71+ limits.MaxRows = defaults.MaxRows
72+ }
73+ requiredEventBytes, representable := minimumEventBytes(max(limits.MaxTextBytes, limits.MaxPasteBytes))
74+ if !representable || limits.MaxEventBytes < requiredEventBytes {
75+ return EventLimits{}, fmt.Errorf("%w: MaxEventBytes %d cannot contain the largest payload limit", ErrInvalidEventLimits, limits.MaxEventBytes)
76+ }
77+ return limits, nil
78+}
79+
80+func minimumEventBytes(payloadBytes int) (int, bool) {
81+ maxInt := int(^uint(0) >> 1)
82+ if payloadBytes > (maxInt-eventEnvelopeOverhead)/maxJSONEscapedByteLength {
83+ return 0, false
84+ }
85+ return payloadBytes*maxJSONEscapedByteLength + eventEnvelopeOverhead, true
86+}
87+
88+// EventKind identifies one closed browser event payload.
89+type EventKind string
90+
91+// Supported browser event kinds.
92+const (
93+ EventText EventKind = "text"
94+ EventKey EventKind = "key"
95+ EventPaste EventKind = "paste"
96+ EventPointer EventKind = "pointer"
97+ EventWheel EventKind = "wheel"
98+ EventResize EventKind = "resize"
99+ EventFocus EventKind = "focus"
100+)
101+
102+// Modifiers is the normalized browser modifier-key state.
103+type Modifiers struct {
104+ Alt bool `json:"alt"`
105+ Ctrl bool `json:"ctrl"`
106+ Meta bool `json:"meta"`
107+ Shift bool `json:"shift"`
108+}
109+
110+// TextEvent carries composed text input.
111+type TextEvent struct{ Text string }
112+
113+// PasteEvent carries bounded plain-text clipboard input. DecodeEvent preserves
114+// Text unchanged, including control bytes such as ESC and CR. Consumers that
115+// forward it to a PTY must apply their required framing or filtering policy.
116+type PasteEvent struct{ Text string }
117+
118+// KeyEvent carries a non-text key or modified key press.
119+type KeyEvent struct {
120+ Key string
121+ Code string
122+ Modifiers Modifiers
123+ Repeat bool
124+ Location int
125+}
126+
127+// PointerEvent carries a pointer action mapped to terminal coordinates.
128+type PointerEvent struct {
129+ Action string
130+ Button int
131+ Buttons uint16
132+ Row int
133+ Column int
134+ X float64
135+ Y float64
136+ Modifiers Modifiers
137+}
138+
139+// WheelEvent carries browser wheel deltas and terminal coordinates.
140+type WheelEvent struct {
141+ DeltaX float64
142+ DeltaY float64
143+ DeltaMode int
144+ Row int
145+ Column int
146+ Modifiers Modifiers
147+}
148+
149+// ResizeEvent carries measured grid and CSS-pixel geometry.
150+type ResizeEvent struct {
151+ Columns int
152+ Rows int
153+ PixelWidth float64
154+ PixelHeight float64
155+ CellWidth float64
156+ CellHeight float64
157+ DevicePixelRatio float64
158+}
159+
160+// FocusEvent reports input-proxy focus changes.
161+type FocusEvent struct{ Focused bool }
162+
163+// Event is a closed tagged union. Exactly one payload matching Kind is set.
164+type Event struct {
165+ SchemaVersion uint16
166+ Kind EventKind
167+ Text *TextEvent
168+ Key *KeyEvent
169+ Paste *PasteEvent
170+ Pointer *PointerEvent
171+ Wheel *WheelEvent
172+ Resize *ResizeEvent
173+ Focus *FocusEvent
174+}
175+
176+type envelope struct {
177+ SchemaVersion uint16 `json:"schemaVersion"`
178+ Kind EventKind `json:"type"`
179+}
180+
181+type textWire struct {
182+ SchemaVersion uint16 `json:"schemaVersion"`
183+ Kind EventKind `json:"type"`
184+ Text *string `json:"text"`
185+}
186+
187+type keyWire struct {
188+ SchemaVersion uint16 `json:"schemaVersion"`
189+ Kind EventKind `json:"type"`
190+ Key *string `json:"key"`
191+ Code *string `json:"code"`
192+ Alt bool `json:"alt"`
193+ Ctrl bool `json:"ctrl"`
194+ Meta bool `json:"meta"`
195+ Shift bool `json:"shift"`
196+ Repeat bool `json:"repeat"`
197+ Location int `json:"location"`
198+}
199+
200+type pointerWire struct {
201+ SchemaVersion uint16 `json:"schemaVersion"`
202+ Kind EventKind `json:"type"`
203+ Action *string `json:"action"`
204+ Button *int `json:"button"`
205+ Buttons *uint16 `json:"buttons"`
206+ Row *int `json:"row"`
207+ Column *int `json:"column"`
208+ X *float64 `json:"x"`
209+ Y *float64 `json:"y"`
210+ Alt bool `json:"alt"`
211+ Ctrl bool `json:"ctrl"`
212+ Meta bool `json:"meta"`
213+ Shift bool `json:"shift"`
214+}
215+
216+type wheelWire struct {
217+ SchemaVersion uint16 `json:"schemaVersion"`
218+ Kind EventKind `json:"type"`
219+ DeltaX *float64 `json:"deltaX"`
220+ DeltaY *float64 `json:"deltaY"`
221+ DeltaMode *int `json:"deltaMode"`
222+ Row *int `json:"row"`
223+ Column *int `json:"column"`
224+ Alt bool `json:"alt"`
225+ Ctrl bool `json:"ctrl"`
226+ Meta bool `json:"meta"`
227+ Shift bool `json:"shift"`
228+}
229+
230+type resizeWire struct {
231+ SchemaVersion uint16 `json:"schemaVersion"`
232+ Kind EventKind `json:"type"`
233+ Columns *int `json:"columns"`
234+ Rows *int `json:"rows"`
235+ PixelWidth *float64 `json:"pixelWidth"`
236+ PixelHeight *float64 `json:"pixelHeight"`
237+ CellWidth *float64 `json:"cellWidth"`
238+ CellHeight *float64 `json:"cellHeight"`
239+ DevicePixelRatio *float64 `json:"devicePixelRatio"`
240+}
241+
242+type focusWire struct {
243+ SchemaVersion uint16 `json:"schemaVersion"`
244+ Kind EventKind `json:"type"`
245+ Focused *bool `json:"focused"`
246+}
247+
248+// DecodeEvent strictly decodes one complete untrusted browser event.
249+func DecodeEvent(data []byte, requested EventLimits) (Event, error) {
250+ limits, err := normalizeEventLimits(requested)
251+ if err != nil {
252+ return Event{}, err
253+ }
254+ if len(data) > limits.MaxEventBytes {
255+ return Event{}, fmt.Errorf("%w: event has %d bytes, limit is %d", ErrEventLimit, len(data), limits.MaxEventBytes)
256+ }
257+ if !utf8.Valid(data) {
258+ return Event{}, fmt.Errorf("html/browser: event is not valid UTF-8")
259+ }
260+ var header envelope
261+ if err := json.Unmarshal(data, &header); err != nil {
262+ return Event{}, fmt.Errorf("html/browser: decode event envelope: %w", err)
263+ }
264+ if header.SchemaVersion != EventSchemaVersion {
265+ return Event{}, fmt.Errorf("html/browser: unsupported event schema %d", header.SchemaVersion)
266+ }
267+
268+ event := Event{SchemaVersion: header.SchemaVersion, Kind: header.Kind}
269+ switch header.Kind {
270+ case EventText, EventPaste:
271+ var wire textWire
272+ if err := decodeStrict(data, &wire); err != nil {
273+ return Event{}, err
274+ }
275+ if wire.Text == nil {
276+ return Event{}, fmt.Errorf("html/browser: event text is required")
277+ }
278+ if !utf8.ValidString(*wire.Text) {
279+ return Event{}, fmt.Errorf("html/browser: event text is not valid UTF-8")
280+ }
281+ limit := limits.MaxTextBytes
282+ if header.Kind == EventPaste {
283+ limit = limits.MaxPasteBytes
284+ }
285+ if len(*wire.Text) > limit {
286+ return Event{}, fmt.Errorf("%w: %s has %d bytes, limit is %d", ErrEventLimit, header.Kind, len(*wire.Text), limit)
287+ }
288+ if header.Kind == EventText {
289+ event.Text = &TextEvent{Text: *wire.Text}
290+ } else {
291+ event.Paste = &PasteEvent{Text: *wire.Text}
292+ }
293+ case EventKey:
294+ var wire keyWire
295+ if err := decodeStrict(data, &wire); err != nil {
296+ return Event{}, err
297+ }
298+ if wire.Key == nil || wire.Code == nil || *wire.Key == "" || *wire.Code == "" || len(*wire.Key) > 128 || len(*wire.Code) > 128 || wire.Location < 0 || wire.Location > 3 {
299+ return Event{}, fmt.Errorf("html/browser: invalid key event")
300+ }
301+ event.Key = &KeyEvent{Key: *wire.Key, Code: *wire.Code, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift), Repeat: wire.Repeat, Location: wire.Location}
302+ case EventPointer:
303+ var wire pointerWire
304+ if err := decodeStrict(data, &wire); err != nil {
305+ return Event{}, err
306+ }
307+ if wire.Action == nil || wire.Button == nil || wire.Buttons == nil || wire.Row == nil || wire.Column == nil || wire.X == nil || wire.Y == nil || !oneOf(*wire.Action, "down", "up", "move", "cancel") || *wire.Button < -1 || *wire.Button > 4 || *wire.Buttons > 31 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.X) || !finiteBounded(*wire.Y) {
308+ return Event{}, fmt.Errorf("html/browser: invalid pointer event")
309+ }
310+ event.Pointer = &PointerEvent{Action: *wire.Action, Button: *wire.Button, Buttons: *wire.Buttons, Row: *wire.Row, Column: *wire.Column, X: *wire.X, Y: *wire.Y, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
311+ case EventWheel:
312+ var wire wheelWire
313+ if err := decodeStrict(data, &wire); err != nil {
314+ return Event{}, err
315+ }
316+ if wire.DeltaX == nil || wire.DeltaY == nil || wire.DeltaMode == nil || wire.Row == nil || wire.Column == nil || *wire.DeltaMode < 0 || *wire.DeltaMode > 2 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.DeltaX) || !finiteBounded(*wire.DeltaY) {
317+ return Event{}, fmt.Errorf("html/browser: invalid wheel event")
318+ }
319+ event.Wheel = &WheelEvent{DeltaX: *wire.DeltaX, DeltaY: *wire.DeltaY, DeltaMode: *wire.DeltaMode, Row: *wire.Row, Column: *wire.Column, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
320+ case EventResize:
321+ var wire resizeWire
322+ if err := decodeStrict(data, &wire); err != nil {
323+ return Event{}, err
324+ }
325+ if wire.Columns == nil || wire.Rows == nil || wire.PixelWidth == nil || wire.PixelHeight == nil || wire.CellWidth == nil || wire.CellHeight == nil || wire.DevicePixelRatio == nil || *wire.Columns <= 0 || *wire.Columns > limits.MaxColumns || *wire.Rows <= 0 || *wire.Rows > limits.MaxRows || !positiveFinite(*wire.PixelWidth) || !positiveFinite(*wire.PixelHeight) || !positiveFinite(*wire.CellWidth) || !positiveFinite(*wire.CellHeight) || !positiveFinite(*wire.DevicePixelRatio) {
326+ return Event{}, fmt.Errorf("html/browser: invalid resize event")
327+ }
328+ event.Resize = &ResizeEvent{Columns: *wire.Columns, Rows: *wire.Rows, PixelWidth: *wire.PixelWidth, PixelHeight: *wire.PixelHeight, CellWidth: *wire.CellWidth, CellHeight: *wire.CellHeight, DevicePixelRatio: *wire.DevicePixelRatio}
329+ case EventFocus:
330+ var wire focusWire
331+ if err := decodeStrict(data, &wire); err != nil {
332+ return Event{}, err
333+ }
334+ if wire.Focused == nil {
335+ return Event{}, fmt.Errorf("html/browser: invalid focus event")
336+ }
337+ event.Focus = &FocusEvent{Focused: *wire.Focused}
338+ default:
339+ return Event{}, fmt.Errorf("html/browser: unknown event kind %q", header.Kind)
340+ }
341+ return event, nil
342+}
343+
344+func decodeStrict(data []byte, dst any) error {
345+ decoder := json.NewDecoder(bytes.NewReader(data))
346+ decoder.DisallowUnknownFields()
347+ if err := decoder.Decode(dst); err != nil {
348+ return fmt.Errorf("html/browser: decode event: %w", err)
349+ }
350+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
351+ return fmt.Errorf("html/browser: event must contain one JSON value")
352+ }
353+ return nil
354+}
355+
356+func modifiers(alt, ctrl, meta, shift bool) Modifiers {
357+ return Modifiers{Alt: alt, Ctrl: ctrl, Meta: meta, Shift: shift}
358+}
359+
360+func validCell(row, column int, limits EventLimits) bool {
361+ return row >= 0 && row < limits.MaxRows && column >= 0 && column < limits.MaxColumns
362+}
363+
364+func finiteBounded(value float64) bool {
365+ return !math.IsNaN(value) && !math.IsInf(value, 0) && math.Abs(value) <= 10_000_000
366+}
367+
368+func positiveFinite(value float64) bool { return value > 0 && finiteBounded(value) }
369+
370+func oneOf(value string, allowed ...string) bool {
371+ for _, candidate := range allowed {
372+ if value == candidate {
373+ return true
374+ }
375+ }
376+ return false
377+}
@@ -0,0 +1,377 @@
1+// Package browser provides the static DOM runtime and strictly validated,
2+// transport-neutral browser event values for github.com/bnema/vev-vt/html.
3+package browser
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "math"
12+ "unicode/utf8"
13+
14+ htmlrenderer "github.com/bnema/vev-vt/html"
15+)
16+
17+// EventSchemaVersion identifies the runtime-to-Go event contract.
18+const EventSchemaVersion = htmlrenderer.UpdateSchemaVersion
19+
20+var (
21+ // ErrEventLimit reports that an event exceeded a configured resource bound.
22+ ErrEventLimit = errors.New("html/browser: event resource limit exceeded")
23+ // ErrInvalidEventLimits reports incompatible configured event bounds.
24+ ErrInvalidEventLimits = errors.New("html/browser: invalid event limits")
25+)
26+
27+const (
28+ maxJSONEscapedByteLength = 6
29+ eventEnvelopeOverhead = 64
30+)
31+
32+// EventLimits bounds untrusted browser event decoding. Zero fields select safe
33+// defaults.
34+type EventLimits struct {
35+ MaxEventBytes int
36+ MaxTextBytes int
37+ MaxPasteBytes int
38+ MaxColumns int
39+ MaxRows int
40+}
41+
42+// DefaultEventLimits returns the bounds used for zero-valued event limits.
43+func DefaultEventLimits() EventLimits {
44+ return EventLimits{
45+ MaxEventBytes: 8 << 20,
46+ MaxTextBytes: 64 << 10,
47+ MaxPasteBytes: 1 << 20,
48+ MaxColumns: 10_000,
49+ MaxRows: 10_000,
50+ }
51+}
52+
53+func normalizeEventLimits(limits EventLimits) (EventLimits, error) {
54+ if limits.MaxEventBytes < 0 || limits.MaxTextBytes < 0 || limits.MaxPasteBytes < 0 || limits.MaxColumns < 0 || limits.MaxRows < 0 {
55+ return EventLimits{}, fmt.Errorf("%w: event limits must not be negative", ErrInvalidEventLimits)
56+ }
57+ defaults := DefaultEventLimits()
58+ if limits.MaxEventBytes == 0 {
59+ limits.MaxEventBytes = defaults.MaxEventBytes
60+ }
61+ if limits.MaxTextBytes == 0 {
62+ limits.MaxTextBytes = defaults.MaxTextBytes
63+ }
64+ if limits.MaxPasteBytes == 0 {
65+ limits.MaxPasteBytes = defaults.MaxPasteBytes
66+ }
67+ if limits.MaxColumns == 0 {
68+ limits.MaxColumns = defaults.MaxColumns
69+ }
70+ if limits.MaxRows == 0 {
71+ limits.MaxRows = defaults.MaxRows
72+ }
73+ requiredEventBytes, representable := minimumEventBytes(max(limits.MaxTextBytes, limits.MaxPasteBytes))
74+ if !representable || limits.MaxEventBytes < requiredEventBytes {
75+ return EventLimits{}, fmt.Errorf("%w: MaxEventBytes %d cannot contain the largest payload limit", ErrInvalidEventLimits, limits.MaxEventBytes)
76+ }
77+ return limits, nil
78+}
79+
80+func minimumEventBytes(payloadBytes int) (int, bool) {
81+ maxInt := int(^uint(0) >> 1)
82+ if payloadBytes > (maxInt-eventEnvelopeOverhead)/maxJSONEscapedByteLength {
83+ return 0, false
84+ }
85+ return payloadBytes*maxJSONEscapedByteLength + eventEnvelopeOverhead, true
86+}
87+
88+// EventKind identifies one closed browser event payload.
89+type EventKind string
90+
91+// Supported browser event kinds.
92+const (
93+ EventText EventKind = "text"
94+ EventKey EventKind = "key"
95+ EventPaste EventKind = "paste"
96+ EventPointer EventKind = "pointer"
97+ EventWheel EventKind = "wheel"
98+ EventResize EventKind = "resize"
99+ EventFocus EventKind = "focus"
100+)
101+
102+// Modifiers is the normalized browser modifier-key state.
103+type Modifiers struct {
104+ Alt bool `json:"alt"`
105+ Ctrl bool `json:"ctrl"`
106+ Meta bool `json:"meta"`
107+ Shift bool `json:"shift"`
108+}
109+
110+// TextEvent carries composed text input.
111+type TextEvent struct{ Text string }
112+
113+// PasteEvent carries bounded plain-text clipboard input. DecodeEvent preserves
114+// Text unchanged, including control bytes such as ESC and CR. Consumers that
115+// forward it to a PTY must apply their required framing or filtering policy.
116+type PasteEvent struct{ Text string }
117+
118+// KeyEvent carries a non-text key or modified key press.
119+type KeyEvent struct {
120+ Key string
121+ Code string
122+ Modifiers Modifiers
123+ Repeat bool
124+ Location int
125+}
126+
127+// PointerEvent carries a pointer action mapped to terminal coordinates.
128+type PointerEvent struct {
129+ Action string
130+ Button int
131+ Buttons uint16
132+ Row int
133+ Column int
134+ X float64
135+ Y float64
136+ Modifiers Modifiers
137+}
138+
139+// WheelEvent carries browser wheel deltas and terminal coordinates.
140+type WheelEvent struct {
141+ DeltaX float64
142+ DeltaY float64
143+ DeltaMode int
144+ Row int
145+ Column int
146+ Modifiers Modifiers
147+}
148+
149+// ResizeEvent carries measured grid and CSS-pixel geometry.
150+type ResizeEvent struct {
151+ Columns int
152+ Rows int
153+ PixelWidth float64
154+ PixelHeight float64
155+ CellWidth float64
156+ CellHeight float64
157+ DevicePixelRatio float64
158+}
159+
160+// FocusEvent reports input-proxy focus changes.
161+type FocusEvent struct{ Focused bool }
162+
163+// Event is a closed tagged union. Exactly one payload matching Kind is set.
164+type Event struct {
165+ SchemaVersion uint16
166+ Kind EventKind
167+ Text *TextEvent
168+ Key *KeyEvent
169+ Paste *PasteEvent
170+ Pointer *PointerEvent
171+ Wheel *WheelEvent
172+ Resize *ResizeEvent
173+ Focus *FocusEvent
174+}
175+
176+type envelope struct {
177+ SchemaVersion uint16 `json:"schemaVersion"`
178+ Kind EventKind `json:"type"`
179+}
180+
181+type textWire struct {
182+ SchemaVersion uint16 `json:"schemaVersion"`
183+ Kind EventKind `json:"type"`
184+ Text *string `json:"text"`
185+}
186+
187+type keyWire struct {
188+ SchemaVersion uint16 `json:"schemaVersion"`
189+ Kind EventKind `json:"type"`
190+ Key *string `json:"key"`
191+ Code *string `json:"code"`
192+ Alt bool `json:"alt"`
193+ Ctrl bool `json:"ctrl"`
194+ Meta bool `json:"meta"`
195+ Shift bool `json:"shift"`
196+ Repeat bool `json:"repeat"`
197+ Location int `json:"location"`
198+}
199+
200+type pointerWire struct {
201+ SchemaVersion uint16 `json:"schemaVersion"`
202+ Kind EventKind `json:"type"`
203+ Action *string `json:"action"`
204+ Button *int `json:"button"`
205+ Buttons *uint16 `json:"buttons"`
206+ Row *int `json:"row"`
207+ Column *int `json:"column"`
208+ X *float64 `json:"x"`
209+ Y *float64 `json:"y"`
210+ Alt bool `json:"alt"`
211+ Ctrl bool `json:"ctrl"`
212+ Meta bool `json:"meta"`
213+ Shift bool `json:"shift"`
214+}
215+
216+type wheelWire struct {
217+ SchemaVersion uint16 `json:"schemaVersion"`
218+ Kind EventKind `json:"type"`
219+ DeltaX *float64 `json:"deltaX"`
220+ DeltaY *float64 `json:"deltaY"`
221+ DeltaMode *int `json:"deltaMode"`
222+ Row *int `json:"row"`
223+ Column *int `json:"column"`
224+ Alt bool `json:"alt"`
225+ Ctrl bool `json:"ctrl"`
226+ Meta bool `json:"meta"`
227+ Shift bool `json:"shift"`
228+}
229+
230+type resizeWire struct {
231+ SchemaVersion uint16 `json:"schemaVersion"`
232+ Kind EventKind `json:"type"`
233+ Columns *int `json:"columns"`
234+ Rows *int `json:"rows"`
235+ PixelWidth *float64 `json:"pixelWidth"`
236+ PixelHeight *float64 `json:"pixelHeight"`
237+ CellWidth *float64 `json:"cellWidth"`
238+ CellHeight *float64 `json:"cellHeight"`
239+ DevicePixelRatio *float64 `json:"devicePixelRatio"`
240+}
241+
242+type focusWire struct {
243+ SchemaVersion uint16 `json:"schemaVersion"`
244+ Kind EventKind `json:"type"`
245+ Focused *bool `json:"focused"`
246+}
247+
248+// DecodeEvent strictly decodes one complete untrusted browser event.
249+func DecodeEvent(data []byte, requested EventLimits) (Event, error) {
250+ limits, err := normalizeEventLimits(requested)
251+ if err != nil {
252+ return Event{}, err
253+ }
254+ if len(data) > limits.MaxEventBytes {
255+ return Event{}, fmt.Errorf("%w: event has %d bytes, limit is %d", ErrEventLimit, len(data), limits.MaxEventBytes)
256+ }
257+ if !utf8.Valid(data) {
258+ return Event{}, fmt.Errorf("html/browser: event is not valid UTF-8")
259+ }
260+ var header envelope
261+ if err := json.Unmarshal(data, &header); err != nil {
262+ return Event{}, fmt.Errorf("html/browser: decode event envelope: %w", err)
263+ }
264+ if header.SchemaVersion != EventSchemaVersion {
265+ return Event{}, fmt.Errorf("html/browser: unsupported event schema %d", header.SchemaVersion)
266+ }
267+
268+ event := Event{SchemaVersion: header.SchemaVersion, Kind: header.Kind}
269+ switch header.Kind {
270+ case EventText, EventPaste:
271+ var wire textWire
272+ if err := decodeStrict(data, &wire); err != nil {
273+ return Event{}, err
274+ }
275+ if wire.Text == nil {
276+ return Event{}, fmt.Errorf("html/browser: event text is required")
277+ }
278+ if !utf8.ValidString(*wire.Text) {
279+ return Event{}, fmt.Errorf("html/browser: event text is not valid UTF-8")
280+ }
281+ limit := limits.MaxTextBytes
282+ if header.Kind == EventPaste {
283+ limit = limits.MaxPasteBytes
284+ }
285+ if len(*wire.Text) > limit {
286+ return Event{}, fmt.Errorf("%w: %s has %d bytes, limit is %d", ErrEventLimit, header.Kind, len(*wire.Text), limit)
287+ }
288+ if header.Kind == EventText {
289+ event.Text = &TextEvent{Text: *wire.Text}
290+ } else {
291+ event.Paste = &PasteEvent{Text: *wire.Text}
292+ }
293+ case EventKey:
294+ var wire keyWire
295+ if err := decodeStrict(data, &wire); err != nil {
296+ return Event{}, err
297+ }
298+ if wire.Key == nil || wire.Code == nil || *wire.Key == "" || *wire.Code == "" || len(*wire.Key) > 128 || len(*wire.Code) > 128 || wire.Location < 0 || wire.Location > 3 {
299+ return Event{}, fmt.Errorf("html/browser: invalid key event")
300+ }
301+ event.Key = &KeyEvent{Key: *wire.Key, Code: *wire.Code, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift), Repeat: wire.Repeat, Location: wire.Location}
302+ case EventPointer:
303+ var wire pointerWire
304+ if err := decodeStrict(data, &wire); err != nil {
305+ return Event{}, err
306+ }
307+ if wire.Action == nil || wire.Button == nil || wire.Buttons == nil || wire.Row == nil || wire.Column == nil || wire.X == nil || wire.Y == nil || !oneOf(*wire.Action, "down", "up", "move", "cancel") || *wire.Button < -1 || *wire.Button > 4 || *wire.Buttons > 31 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.X) || !finiteBounded(*wire.Y) {
308+ return Event{}, fmt.Errorf("html/browser: invalid pointer event")
309+ }
310+ event.Pointer = &PointerEvent{Action: *wire.Action, Button: *wire.Button, Buttons: *wire.Buttons, Row: *wire.Row, Column: *wire.Column, X: *wire.X, Y: *wire.Y, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
311+ case EventWheel:
312+ var wire wheelWire
313+ if err := decodeStrict(data, &wire); err != nil {
314+ return Event{}, err
315+ }
316+ if wire.DeltaX == nil || wire.DeltaY == nil || wire.DeltaMode == nil || wire.Row == nil || wire.Column == nil || *wire.DeltaMode < 0 || *wire.DeltaMode > 2 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.DeltaX) || !finiteBounded(*wire.DeltaY) {
317+ return Event{}, fmt.Errorf("html/browser: invalid wheel event")
318+ }
319+ event.Wheel = &WheelEvent{DeltaX: *wire.DeltaX, DeltaY: *wire.DeltaY, DeltaMode: *wire.DeltaMode, Row: *wire.Row, Column: *wire.Column, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
320+ case EventResize:
321+ var wire resizeWire
322+ if err := decodeStrict(data, &wire); err != nil {
323+ return Event{}, err
324+ }
325+ if wire.Columns == nil || wire.Rows == nil || wire.PixelWidth == nil || wire.PixelHeight == nil || wire.CellWidth == nil || wire.CellHeight == nil || wire.DevicePixelRatio == nil || *wire.Columns <= 0 || *wire.Columns > limits.MaxColumns || *wire.Rows <= 0 || *wire.Rows > limits.MaxRows || !positiveFinite(*wire.PixelWidth) || !positiveFinite(*wire.PixelHeight) || !positiveFinite(*wire.CellWidth) || !positiveFinite(*wire.CellHeight) || !positiveFinite(*wire.DevicePixelRatio) {
326+ return Event{}, fmt.Errorf("html/browser: invalid resize event")
327+ }
328+ event.Resize = &ResizeEvent{Columns: *wire.Columns, Rows: *wire.Rows, PixelWidth: *wire.PixelWidth, PixelHeight: *wire.PixelHeight, CellWidth: *wire.CellWidth, CellHeight: *wire.CellHeight, DevicePixelRatio: *wire.DevicePixelRatio}
329+ case EventFocus:
330+ var wire focusWire
331+ if err := decodeStrict(data, &wire); err != nil {
332+ return Event{}, err
333+ }
334+ if wire.Focused == nil {
335+ return Event{}, fmt.Errorf("html/browser: invalid focus event")
336+ }
337+ event.Focus = &FocusEvent{Focused: *wire.Focused}
338+ default:
339+ return Event{}, fmt.Errorf("html/browser: unknown event kind %q", header.Kind)
340+ }
341+ return event, nil
342+}
343+
344+func decodeStrict(data []byte, dst any) error {
345+ decoder := json.NewDecoder(bytes.NewReader(data))
346+ decoder.DisallowUnknownFields()
347+ if err := decoder.Decode(dst); err != nil {
348+ return fmt.Errorf("html/browser: decode event: %w", err)
349+ }
350+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
351+ return fmt.Errorf("html/browser: event must contain one JSON value")
352+ }
353+ return nil
354+}
355+
356+func modifiers(alt, ctrl, meta, shift bool) Modifiers {
357+ return Modifiers{Alt: alt, Ctrl: ctrl, Meta: meta, Shift: shift}
358+}
359+
360+func validCell(row, column int, limits EventLimits) bool {
361+ return row >= 0 && row < limits.MaxRows && column >= 0 && column < limits.MaxColumns
362+}
363+
364+func finiteBounded(value float64) bool {
365+ return !math.IsNaN(value) && !math.IsInf(value, 0) && math.Abs(value) <= 10_000_000
366+}
367+
368+func positiveFinite(value float64) bool { return value > 0 && finiteBounded(value) }
369+
370+func oneOf(value string, allowed ...string) bool {
371+ for _, candidate := range allowed {
372+ if value == candidate {
373+ return true
374+ }
375+ }
376+ return false
377+}
A html/browser/event_test.go +151 -0
@@ -0,0 +1,151 @@
1+package browser
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDecodeEventValidatesTheClosedSchema(t *testing.T) {
12+ event, err := DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"é"}`), EventLimits{})
13+ require.NoError(t, err)
14+ require.Equal(t, EventText, event.Kind)
15+ require.Equal(t, "é", event.Text.Text)
16+
17+ _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"x","unknown":true}`), EventLimits{})
18+ require.Error(t, err)
19+
20+ _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"paste","text":"toolong"}`), EventLimits{MaxPasteBytes: 3})
21+ require.ErrorIs(t, err, ErrEventLimit)
22+
23+ _, err = DecodeEvent([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, EventLimits{})
24+ require.ErrorContains(t, err, "not valid UTF-8")
25+
26+ _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"resize","columns":80,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`), EventLimits{})
27+ require.NoError(t, err)
28+
29+ for _, data := range []string{
30+ `{"schemaVersion":1,"type":"focus"}`,
31+ `{"schemaVersion":1,"type":"wheel"}`,
32+ } {
33+ _, err = DecodeEvent([]byte(data), EventLimits{})
34+ require.Error(t, err)
35+ }
36+}
37+
38+func TestDecodeEventAcceptsRuntimeEventKinds(t *testing.T) {
39+ tests := []struct {
40+ kind EventKind
41+ json string
42+ }{
43+ {EventKey, `{"schemaVersion":1,"type":"key","key":"ArrowUp","code":"ArrowUp","alt":false,"ctrl":false,"meta":false,"shift":false,"repeat":false,"location":0}`},
44+ {EventPaste, `{"schemaVersion":1,"type":"paste","text":"hello"}`},
45+ {EventPointer, `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":1,"row":0,"column":0,"x":1,"y":2,"alt":false,"ctrl":false,"meta":false,"shift":false}`},
46+ {EventWheel, `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":0,"row":0,"column":0,"alt":false,"ctrl":false,"meta":false,"shift":false}`},
47+ {EventFocus, `{"schemaVersion":1,"type":"focus","focused":true}`},
48+ }
49+ for _, test := range tests {
50+ t.Run(string(test.kind), func(t *testing.T) {
51+ event, err := DecodeEvent([]byte(test.json), EventLimits{})
52+ require.NoError(t, err)
53+ require.Equal(t, test.kind, event.Kind)
54+ if test.kind == EventKey {
55+ require.Equal(t, "ArrowUp", event.Key.Key)
56+ }
57+ })
58+ }
59+}
60+
61+func TestDefaultEventLimitsAcceptEscapedMaximumPaste(t *testing.T) {
62+ limits := DefaultEventLimits()
63+ text := strings.Repeat("\x00", limits.MaxPasteBytes)
64+ data, err := json.Marshal(textWire{
65+ SchemaVersion: EventSchemaVersion,
66+ Kind: EventPaste,
67+ Text: &text,
68+ })
69+ require.NoError(t, err)
70+
71+ event, err := DecodeEvent(data, EventLimits{})
72+ require.NoError(t, err)
73+ require.NotNil(t, event.Paste)
74+ require.Len(t, event.Paste.Text, limits.MaxPasteBytes)
75+}
76+
77+func TestNormalizeEventLimitsRejectsImpossibleEnvelope(t *testing.T) {
78+ _, err := normalizeEventLimits(EventLimits{MaxEventBytes: 69, MaxTextBytes: 1, MaxPasteBytes: 1})
79+ require.ErrorIs(t, err, ErrInvalidEventLimits)
80+
81+ _, err = normalizeEventLimits(EventLimits{MaxTextBytes: -1})
82+ require.ErrorIs(t, err, ErrInvalidEventLimits)
83+}
84+
85+func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) {
86+ tests := []string{
87+ `{"schemaVersion":1,"type":"text"}`,
88+ `{"schemaVersion":1,"type":"key","code":"ArrowUp"}`,
89+ `{"schemaVersion":1,"type":"pointer","action":"down"}`,
90+ `{"schemaVersion":1,"type":"wheel"}`,
91+ `{"schemaVersion":1,"type":"resize","columns":80,"rows":24}`,
92+ `{"schemaVersion":1,"type":"focus"}`,
93+ }
94+ for _, data := range tests {
95+ _, err := DecodeEvent([]byte(data), EventLimits{})
96+ require.Error(t, err, data)
97+ }
98+}
99+
100+func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) {
101+ tests := map[string]string{
102+ "schema": `{"schemaVersion":2,"type":"focus","focused":true}`,
103+ "pointer button": `{"schemaVersion":1,"type":"pointer","action":"down","button":5,"buttons":1,"row":0,"column":0,"x":1,"y":2}`,
104+ "pointer buttons": `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":32,"row":0,"column":0,"x":1,"y":2}`,
105+ "wheel mode": `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":3,"row":0,"column":0}`,
106+ "resize dimension": `{"schemaVersion":1,"type":"resize","columns":0,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`,
107+ "non-finite coordinate": `{"schemaVersion":1,"type":"pointer","action":"move","button":-1,"buttons":0,"row":0,"column":0,"x":1e999,"y":2}`,
108+ "trailing value": `{"schemaVersion":1,"type":"focus","focused":true} {}`,
109+ }
110+ for name, data := range tests {
111+ t.Run(name, func(t *testing.T) {
112+ _, err := DecodeEvent([]byte(data), EventLimits{})
113+ require.Error(t, err)
114+ })
115+ }
116+}
117+
118+func FuzzDecodeEvent(f *testing.F) {
119+ f.Add([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`))
120+ f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"hello"}`))
121+ f.Add([]byte(`not json`))
122+ f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"` + strings.Repeat("x", 4<<10) + `"}`))
123+ f.Fuzz(func(t *testing.T, data []byte) {
124+ event, err := DecodeEvent(data, EventLimits{MaxEventBytes: 4 << 10, MaxTextBytes: 512, MaxPasteBytes: 512})
125+ if len(data) > 4<<10 {
126+ require.ErrorIs(t, err, ErrEventLimit)
127+ return
128+ }
129+ if err != nil {
130+ return
131+ }
132+ payloads := map[EventKind]bool{
133+ EventText: event.Text != nil,
134+ EventKey: event.Key != nil,
135+ EventPaste: event.Paste != nil,
136+ EventPointer: event.Pointer != nil,
137+ EventWheel: event.Wheel != nil,
138+ EventResize: event.Resize != nil,
139+ EventFocus: event.Focus != nil,
140+ }
141+ set := 0
142+ for _, present := range payloads {
143+ if present {
144+ set++
145+ }
146+ }
147+ if set != 1 || !payloads[event.Kind] {
148+ t.Fatalf("decoded event violates the closed union: %#v", event)
149+ }
150+ })
151+}
@@ -0,0 +1,151 @@
1+package browser
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDecodeEventValidatesTheClosedSchema(t *testing.T) {
12+ event, err := DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"é"}`), EventLimits{})
13+ require.NoError(t, err)
14+ require.Equal(t, EventText, event.Kind)
15+ require.Equal(t, "é", event.Text.Text)
16+
17+ _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"x","unknown":true}`), EventLimits{})
18+ require.Error(t, err)
19+
20+ _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"paste","text":"toolong"}`), EventLimits{MaxPasteBytes: 3})
21+ require.ErrorIs(t, err, ErrEventLimit)
22+
23+ _, err = DecodeEvent([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, EventLimits{})
24+ require.ErrorContains(t, err, "not valid UTF-8")
25+
26+ _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"resize","columns":80,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`), EventLimits{})
27+ require.NoError(t, err)
28+
29+ for _, data := range []string{
30+ `{"schemaVersion":1,"type":"focus"}`,
31+ `{"schemaVersion":1,"type":"wheel"}`,
32+ } {
33+ _, err = DecodeEvent([]byte(data), EventLimits{})
34+ require.Error(t, err)
35+ }
36+}
37+
38+func TestDecodeEventAcceptsRuntimeEventKinds(t *testing.T) {
39+ tests := []struct {
40+ kind EventKind
41+ json string
42+ }{
43+ {EventKey, `{"schemaVersion":1,"type":"key","key":"ArrowUp","code":"ArrowUp","alt":false,"ctrl":false,"meta":false,"shift":false,"repeat":false,"location":0}`},
44+ {EventPaste, `{"schemaVersion":1,"type":"paste","text":"hello"}`},
45+ {EventPointer, `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":1,"row":0,"column":0,"x":1,"y":2,"alt":false,"ctrl":false,"meta":false,"shift":false}`},
46+ {EventWheel, `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":0,"row":0,"column":0,"alt":false,"ctrl":false,"meta":false,"shift":false}`},
47+ {EventFocus, `{"schemaVersion":1,"type":"focus","focused":true}`},
48+ }
49+ for _, test := range tests {
50+ t.Run(string(test.kind), func(t *testing.T) {
51+ event, err := DecodeEvent([]byte(test.json), EventLimits{})
52+ require.NoError(t, err)
53+ require.Equal(t, test.kind, event.Kind)
54+ if test.kind == EventKey {
55+ require.Equal(t, "ArrowUp", event.Key.Key)
56+ }
57+ })
58+ }
59+}
60+
61+func TestDefaultEventLimitsAcceptEscapedMaximumPaste(t *testing.T) {
62+ limits := DefaultEventLimits()
63+ text := strings.Repeat("\x00", limits.MaxPasteBytes)
64+ data, err := json.Marshal(textWire{
65+ SchemaVersion: EventSchemaVersion,
66+ Kind: EventPaste,
67+ Text: &text,
68+ })
69+ require.NoError(t, err)
70+
71+ event, err := DecodeEvent(data, EventLimits{})
72+ require.NoError(t, err)
73+ require.NotNil(t, event.Paste)
74+ require.Len(t, event.Paste.Text, limits.MaxPasteBytes)
75+}
76+
77+func TestNormalizeEventLimitsRejectsImpossibleEnvelope(t *testing.T) {
78+ _, err := normalizeEventLimits(EventLimits{MaxEventBytes: 69, MaxTextBytes: 1, MaxPasteBytes: 1})
79+ require.ErrorIs(t, err, ErrInvalidEventLimits)
80+
81+ _, err = normalizeEventLimits(EventLimits{MaxTextBytes: -1})
82+ require.ErrorIs(t, err, ErrInvalidEventLimits)
83+}
84+
85+func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) {
86+ tests := []string{
87+ `{"schemaVersion":1,"type":"text"}`,
88+ `{"schemaVersion":1,"type":"key","code":"ArrowUp"}`,
89+ `{"schemaVersion":1,"type":"pointer","action":"down"}`,
90+ `{"schemaVersion":1,"type":"wheel"}`,
91+ `{"schemaVersion":1,"type":"resize","columns":80,"rows":24}`,
92+ `{"schemaVersion":1,"type":"focus"}`,
93+ }
94+ for _, data := range tests {
95+ _, err := DecodeEvent([]byte(data), EventLimits{})
96+ require.Error(t, err, data)
97+ }
98+}
99+
100+func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) {
101+ tests := map[string]string{
102+ "schema": `{"schemaVersion":2,"type":"focus","focused":true}`,
103+ "pointer button": `{"schemaVersion":1,"type":"pointer","action":"down","button":5,"buttons":1,"row":0,"column":0,"x":1,"y":2}`,
104+ "pointer buttons": `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":32,"row":0,"column":0,"x":1,"y":2}`,
105+ "wheel mode": `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":3,"row":0,"column":0}`,
106+ "resize dimension": `{"schemaVersion":1,"type":"resize","columns":0,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`,
107+ "non-finite coordinate": `{"schemaVersion":1,"type":"pointer","action":"move","button":-1,"buttons":0,"row":0,"column":0,"x":1e999,"y":2}`,
108+ "trailing value": `{"schemaVersion":1,"type":"focus","focused":true} {}`,
109+ }
110+ for name, data := range tests {
111+ t.Run(name, func(t *testing.T) {
112+ _, err := DecodeEvent([]byte(data), EventLimits{})
113+ require.Error(t, err)
114+ })
115+ }
116+}
117+
118+func FuzzDecodeEvent(f *testing.F) {
119+ f.Add([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`))
120+ f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"hello"}`))
121+ f.Add([]byte(`not json`))
122+ f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"` + strings.Repeat("x", 4<<10) + `"}`))
123+ f.Fuzz(func(t *testing.T, data []byte) {
124+ event, err := DecodeEvent(data, EventLimits{MaxEventBytes: 4 << 10, MaxTextBytes: 512, MaxPasteBytes: 512})
125+ if len(data) > 4<<10 {
126+ require.ErrorIs(t, err, ErrEventLimit)
127+ return
128+ }
129+ if err != nil {
130+ return
131+ }
132+ payloads := map[EventKind]bool{
133+ EventText: event.Text != nil,
134+ EventKey: event.Key != nil,
135+ EventPaste: event.Paste != nil,
136+ EventPointer: event.Pointer != nil,
137+ EventWheel: event.Wheel != nil,
138+ EventResize: event.Resize != nil,
139+ EventFocus: event.Focus != nil,
140+ }
141+ set := 0
142+ for _, present := range payloads {
143+ if present {
144+ set++
145+ }
146+ }
147+ if set != 1 || !payloads[event.Kind] {
148+ t.Fatalf("decoded event violates the closed union: %#v", event)
149+ }
150+ })
151+}
A html/browser/terminal.js +708 -0
@@ -0,0 +1,708 @@
1+(() => {
2+ 'use strict';
3+
4+ /** @typedef {{r: number, g: number, b: number}} RGB */
5+ /** @typedef {{kind: 0}|{kind: 1, index: number}|{kind: 2, rgb: RGB}} Color */
6+ /** @typedef {{bold?: boolean, italic?: boolean, inverse?: boolean, dim?: boolean, blink?: boolean, strikethrough?: boolean, underline?: boolean, underlineStyle?: number, foreground: Color, background: Color, underlineColor: Color}} TerminalStyle */
7+ /** @typedef {{column: number, width: number, text: string, style: number}} CellUpdate */
8+ /** @typedef {{row: number, cells: CellUpdate[]}} RowUpdate */
9+ /** @typedef {{row: number, column: number, visible: boolean, style: number, styleSet: boolean}} Cursor */
10+ /** @typedef {{schemaVersion: number, width: number, height: number, snapshot: boolean, rows: RowUpdate[], styles: TerminalStyle[], cursor: Cursor}} Update */
11+ /** @typedef {{emit: boolean, preventDefault: boolean}} CaptureDecision */
12+ /** @typedef {{schemaVersion?: number, type: string, [key: string]: unknown}} BrowserEvent */
13+ /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */
14+ /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */
15+ /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */
16+ /** @typedef {{apply(update: Update): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */
17+
18+ const SCHEMA_VERSION = 1;
19+ const DEFAULT_LIMITS = Object.freeze({
20+ maxCells: 1_000_000,
21+ maxRowsPerUpdate: 10_000,
22+ maxColumns: 10_000,
23+ maxStyles: 65_536,
24+ maxTextBytes: 64 << 10,
25+ maxUpdateBytes: 64 << 20,
26+ maxPasteBytes: 1 << 20
27+ });
28+ const encoder = new TextEncoder();
29+
30+ function fail(message) {
31+ throw new TypeError(`VevTerminal: ${message}`);
32+ }
33+
34+ function object(value, name) {
35+ if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${name} must be an object`);
36+ return value;
37+ }
38+
39+ function keys(value, allowed, name) {
40+ for (const key of Object.keys(value)) {
41+ if (!allowed.includes(key)) fail(`${name} contains unknown field ${key}`);
42+ }
43+ }
44+
45+ function integer(value, minimum, maximum, name) {
46+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail(`${name} is out of range`);
47+ return value;
48+ }
49+
50+ function boolean(value, name, fallback = false) {
51+ if (value === undefined) return fallback;
52+ if (typeof value !== 'boolean') fail(`${name} must be boolean`);
53+ return value;
54+ }
55+
56+ /** @returns {{value: string, byteLength: number}} */
57+ function boundedString(value, limit, name) {
58+ if (typeof value !== 'string') fail(`${name} must be a string`);
59+ const byteLength = encoder.encode(value).byteLength;
60+ if (byteLength > limit) fail(`${name} exceeds its byte limit`);
61+ return { value, byteLength };
62+ }
63+
64+ function limits(requested = {}) {
65+ object(requested, 'limits');
66+ keys(requested, Object.keys(DEFAULT_LIMITS), 'limits');
67+ const result = {};
68+ for (const [name, fallback] of Object.entries(DEFAULT_LIMITS)) {
69+ result[name] = requested[name] === undefined ? fallback : integer(requested[name], 1, Number.MAX_SAFE_INTEGER, `limits.${name}`);
70+ }
71+ return Object.freeze(result);
72+ }
73+
74+ function validateRGB(value, name) {
75+ object(value, name);
76+ keys(value, ['r', 'g', 'b'], name);
77+ return {
78+ r: integer(value.r, 0, 255, `${name}.r`),
79+ g: integer(value.g, 0, 255, `${name}.g`),
80+ b: integer(value.b, 0, 255, `${name}.b`)
81+ };
82+ }
83+
84+ function validateColor(value, name) {
85+ object(value, name);
86+ keys(value, ['kind', 'index', 'rgb'], name);
87+ const kind = integer(value.kind, 0, 2, `${name}.kind`);
88+ if (kind === 0) {
89+ if (value.index !== undefined || value.rgb !== undefined) fail(`${name} default color has a payload`);
90+ return { kind };
91+ }
92+ if (kind === 1) {
93+ if (value.rgb !== undefined) fail(`${name} indexed color has an rgb payload`);
94+ return { kind, index: integer(value.index, 0, 255, `${name}.index`) };
95+ }
96+ if (value.index !== undefined) fail(`${name} rgb color has an index payload`);
97+ return { kind, rgb: validateRGB(value.rgb, `${name}.rgb`) };
98+ }
99+
100+ function validateStyle(value, index) {
101+ const name = `styles[${index}]`;
102+ object(value, name);
103+ keys(value, [
104+ 'bold', 'italic', 'inverse', 'dim', 'blink', 'strikethrough', 'underline',
105+ 'underlineStyle', 'foreground', 'background', 'underlineColor'
106+ ], name);
107+ return {
108+ bold: boolean(value.bold, `${name}.bold`),
109+ italic: boolean(value.italic, `${name}.italic`),
110+ inverse: boolean(value.inverse, `${name}.inverse`),
111+ dim: boolean(value.dim, `${name}.dim`),
112+ blink: boolean(value.blink, `${name}.blink`),
113+ strikethrough: boolean(value.strikethrough, `${name}.strikethrough`),
114+ underline: boolean(value.underline, `${name}.underline`),
115+ underlineStyle: value.underlineStyle === undefined ? 0 : integer(value.underlineStyle, 0, 5, `${name}.underlineStyle`),
116+ foreground: validateColor(value.foreground, `${name}.foreground`),
117+ background: validateColor(value.background, `${name}.background`),
118+ underlineColor: validateColor(value.underlineColor, `${name}.underlineColor`)
119+ };
120+ }
121+
122+ function validateCursor(value, width, height) {
123+ object(value, 'cursor');
124+ keys(value, ['row', 'column', 'visible', 'style', 'styleSet'], 'cursor');
125+ const styleSet = boolean(value.styleSet, 'cursor.styleSet');
126+ const style = integer(value.style, 0, 6, 'cursor.style');
127+ if (!styleSet && style !== 0) fail('cursor style must be zero when unset');
128+ return {
129+ row: integer(value.row, 0, height - 1, 'cursor.row'),
130+ column: integer(value.column, 0, width - 1, 'cursor.column'),
131+ visible: boolean(value.visible, 'cursor.visible'),
132+ style,
133+ styleSet
134+ };
135+ }
136+
137+ function validateRow(value, width, height, styleCount, configured, textBudget, seen) {
138+ object(value, 'row');
139+ keys(value, ['row', 'cells'], 'row');
140+ const row = integer(value.row, 0, height - 1, 'row.row');
141+ if (seen.has(row)) fail(`duplicate row ${row}`);
142+ seen.add(row);
143+ if (!Array.isArray(value.cells)) fail(`row ${row} cells must be an array`);
144+ let column = 0;
145+ let bytes = 0;
146+ const cells = value.cells.map((candidate, index) => {
147+ const name = `row ${row} cell ${index}`;
148+ object(candidate, name);
149+ keys(candidate, ['column', 'width', 'text', 'style'], name);
150+ const current = integer(candidate.column, 0, width - 1, `${name}.column`);
151+ const cellWidth = integer(candidate.width, 1, 2, `${name}.width`);
152+ if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`);
153+ if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`);
154+ const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`);
155+ const cell = {
156+ column: current,
157+ width: cellWidth,
158+ text: text.value,
159+ style: integer(candidate.style, 0, styleCount - 1, `${name}.style`)
160+ };
161+ textBudget.value += text.byteLength;
162+ if (textBudget.value > configured.maxUpdateBytes) fail('update text exceeds its aggregate byte limit');
163+ bytes += text.byteLength;
164+ column += cellWidth;
165+ return cell;
166+ });
167+ if (column !== width) fail(`row coverage is ${column}, expected ${width}`);
168+ return { row, cells, bytes };
169+ }
170+
171+ /**
172+ * Validate an entire update before any DOM mutation.
173+ * @param {unknown} value
174+ * @param {Readonly<Record<string, number>>} configured
175+ * @returns {Update}
176+ */
177+ function validateUpdate(value, configured) {
178+ object(value, 'update');
179+ keys(value, ['schemaVersion', 'width', 'height', 'snapshot', 'rows', 'styles', 'cursor'], 'update');
180+ if (value.schemaVersion !== SCHEMA_VERSION) fail(`unsupported update schema ${value.schemaVersion}`);
181+ const width = integer(value.width, 1, configured.maxColumns, 'update.width');
182+ const height = integer(value.height, 1, configured.maxRowsPerUpdate, 'update.height');
183+ if (width > Math.floor(configured.maxCells / height)) fail('update exceeds the cell limit');
184+ const snapshot = boolean(value.snapshot, 'update.snapshot');
185+ if (!Array.isArray(value.styles) || value.styles.length > configured.maxStyles) fail('update styles exceed their limit');
186+ const styles = value.styles.map(validateStyle);
187+ if (!Array.isArray(value.rows) || value.rows.length > configured.maxRowsPerUpdate) fail('update rows exceed their limit');
188+ if (value.rows.length > Math.floor(configured.maxCells / width)) fail('update represented cells exceed their limit');
189+ if (value.rows.length > 0 && styles.length === 0) fail('an update with rows requires styles');
190+ if (snapshot && value.rows.length !== height) fail('snapshot must contain every row');
191+ const seen = new Set();
192+ const textBudget = { value: 0 };
193+ const rows = value.rows.map(row => validateRow(row, width, height, styles.length, configured, textBudget, seen));
194+ if (snapshot) {
195+ rows.sort((left, right) => left.row - right.row);
196+ rows.forEach((row, index) => { if (row.row !== index) fail('snapshot rows are incomplete'); });
197+ }
198+ return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) };
199+ }
200+
201+ function xtermColor(index) {
202+ if (index < 16) return `var(--vev-color-${index})`;
203+ if (index < 232) {
204+ const value = index - 16;
205+ const levels = [0, 95, 135, 175, 215, 255];
206+ const r = levels[Math.floor(value / 36)];
207+ const g = levels[Math.floor(value / 6) % 6];
208+ const b = levels[value % 6];
209+ return `rgb(${r} ${g} ${b})`;
210+ }
211+ const gray = 8 + (index - 232) * 10;
212+ return `rgb(${gray} ${gray} ${gray})`;
213+ }
214+
215+ function colorValue(color, fallback) {
216+ if (color.kind === 0) return fallback;
217+ if (color.kind === 1) return xtermColor(color.index);
218+ return `rgb(${color.rgb.r} ${color.rgb.g} ${color.rgb.b})`;
219+ }
220+
221+ function addStyleClasses(node, style) {
222+ if (style.bold) node.classList.add('vev-terminal__cell--bold');
223+ if (style.italic) node.classList.add('vev-terminal__cell--italic');
224+ if (style.dim) node.classList.add('vev-terminal__cell--dim');
225+ if (style.blink) node.classList.add('vev-terminal__cell--blink');
226+ if (style.strikethrough) node.classList.add('vev-terminal__cell--strike');
227+ if (style.underline) {
228+ node.classList.add('vev-terminal__cell--underline');
229+ const suffix = ['single', 'single', 'double', 'wavy', 'dotted', 'dashed'][style.underlineStyle];
230+ if (suffix !== 'single') node.classList.add(`vev-terminal__cell--underline-${suffix}`);
231+ }
232+ }
233+
234+ function buildRow(document, row, styles) {
235+ const node = document.createElement('div');
236+ node.className = 'vev-terminal__row';
237+ node.dataset.row = String(row.row);
238+ let text = '';
239+ for (let index = 0; index < row.cells.length; index += 1) {
240+ let cell = row.cells[index];
241+ const style = styles[cell.style];
242+ // A run of spaces needs one grid item, not one item per column. Only
243+ // merge plain spaces: arbitrary text still needs explicit cell widths.
244+ if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) {
245+ let width = cell.width;
246+ let spaces = cell.text;
247+ while (index + 1 < row.cells.length) {
248+ const next = row.cells[index + 1];
249+ if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break;
250+ width += next.width;
251+ spaces += next.text;
252+ index += 1;
253+ }
254+ cell = { ...cell, width, text: spaces };
255+ }
256+ const child = document.createElement('span');
257+ child.className = 'vev-terminal__cell';
258+ child.dataset.column = String(cell.column);
259+ child.dataset.width = String(cell.width);
260+ child.style.gridColumn = `${cell.column + 1} / span ${cell.width}`;
261+ let foreground = colorValue(style.foreground, 'var(--vev-fg)');
262+ let background = colorValue(style.background, 'var(--vev-bg)');
263+ if (style.inverse) [foreground, background] = [background, foreground];
264+ // Default colors already resolve through the stylesheet. Avoid three
265+ // redundant custom properties per cell (and their style-resolution cost).
266+ if (style.inverse || style.foreground.kind !== 0) child.style.setProperty('--vev-cell-fg', foreground);
267+ if (style.inverse || style.background.kind !== 0) child.style.setProperty('--vev-cell-bg', background);
268+ if (style.underlineColor.kind !== 0 || style.dim) child.style.setProperty('--vev-cell-underline', colorValue(style.underlineColor, foreground));
269+ addStyleClasses(child, style);
270+ child.textContent = cell.text;
271+ // A plain space has no ink to clip. Avoid a compositor clip for every
272+ // blank cell while retaining its grid box, selection and text content.
273+ if (/^ +$/.test(cell.text) && !style.underline && !style.strikethrough) {
274+ child.style.overflow = 'visible';
275+ }
276+ node.append(child);
277+ text += cell.text;
278+ }
279+ return { node, text };
280+ }
281+
282+ function validateTheme(theme) {
283+ object(theme, 'theme');
284+ keys(theme, ['foreground', 'background', 'cursor', 'selection', 'selectionText', 'palette'], 'theme');
285+ const result = {
286+ foreground: validateRGB(theme.foreground, 'theme.foreground'),
287+ background: validateRGB(theme.background, 'theme.background'),
288+ cursor: validateRGB(theme.cursor, 'theme.cursor'),
289+ selection: validateRGB(theme.selection, 'theme.selection'),
290+ selectionText: validateRGB(theme.selectionText, 'theme.selectionText')
291+ };
292+ if (!Array.isArray(theme.palette) || theme.palette.length !== 16) fail('theme.palette must contain 16 colors');
293+ result.palette = theme.palette.map((color, index) => validateRGB(color, `theme.palette[${index}]`));
294+ return result;
295+ }
296+
297+ function rgbValue(color) { return `rgb(${color.r} ${color.g} ${color.b})`; }
298+
299+ /**
300+ * Mount one interactive terminal adapter.
301+ * @param {Element} root
302+ * @param {MountOptions} options
303+ * @returns {Terminal}
304+ */
305+ function mount(root, options = {}) {
306+ if (!(root instanceof Element)) fail('mount root must be an Element');
307+ if (root.classList.contains('vev-terminal')) fail('mount root already owns a terminal');
308+ object(options, 'options');
309+ keys(options, ['label', 'decide', 'send', 'limits'], 'options');
310+ const label = boundedString(options.label, 1024, 'options.label').value.trim();
311+ if (label === '') fail('options.label must not be empty');
312+ if (options.decide !== undefined && typeof options.decide !== 'function') fail('options.decide must be a function');
313+ if (options.send !== undefined && typeof options.send !== 'function') fail('options.send must be a function');
314+ const configured = limits(options.limits);
315+ let decide = options.decide || (() => ({ emit: true, preventDefault: false }));
316+ let send = options.send || (() => {});
317+ const document = root.ownerDocument;
318+ const abort = new AbortController();
319+ const signal = abort.signal;
320+
321+ const previousRole = root.hasAttribute('role') ? root.getAttribute('role') : null;
322+ const previousLabel = root.hasAttribute('aria-label') ? root.getAttribute('aria-label') : null;
323+ root.classList.add('vev-terminal');
324+ root.setAttribute('role', 'group');
325+ root.setAttribute('aria-label', label);
326+
327+ const input = document.createElement('textarea');
328+ input.className = 'vev-terminal__input';
329+ input.setAttribute('aria-label', `${label} input`);
330+ input.setAttribute('autocomplete', 'off');
331+ input.setAttribute('autocapitalize', 'off');
332+ input.setAttribute('spellcheck', 'false');
333+
334+ const viewport = document.createElement('div');
335+ viewport.className = 'vev-terminal__viewport';
336+ viewport.setAttribute('aria-hidden', 'true');
337+
338+ const cursor = document.createElement('div');
339+ cursor.className = 'vev-terminal__cursor';
340+ cursor.hidden = true;
341+ viewport.append(cursor);
342+
343+ const accessible = document.createElement('pre');
344+ accessible.className = 'vev-terminal__accessible-output';
345+ accessible.setAttribute('aria-live', 'off');
346+ accessible.setAttribute('aria-label', `${label} output`);
347+
348+ root.append(input, viewport, accessible);
349+
350+ let destroyed = false;
351+ let composing = false;
352+ let editingKeyDown = null;
353+ let mouseCapture = false;
354+ let width = 0;
355+ let height = 0;
356+ let rowNodes = [];
357+ let rowText = [];
358+ let rowBytes = [];
359+ let retainedBytes = 0;
360+ let previousRows = [];
361+ let previousStyles = '';
362+ let resizeFrame = 0;
363+ let pointerFrame = 0;
364+ let pendingPointer = null;
365+ let lastResize = '';
366+ const previousProperties = new Map();
367+
368+ function setOwnedProperty(name, value) {
369+ if (!previousProperties.has(name)) {
370+ previousProperties.set(name, {
371+ value: root.style.getPropertyValue(name),
372+ priority: root.style.getPropertyPriority(name)
373+ });
374+ }
375+ root.style.setProperty(name, value);
376+ }
377+
378+ function alive() {
379+ if (destroyed) fail('instance is destroyed');
380+ }
381+
382+ /** @returns {Readonly<BrowserEvent>|null} */
383+ function capture(event, nativeEvent) {
384+ const decision = decide(Object.freeze({ ...event }));
385+ object(decision, 'capture decision');
386+ keys(decision, ['emit', 'preventDefault'], 'capture decision');
387+ const emit = boolean(decision.emit, 'capture decision.emit');
388+ const preventDefault = boolean(decision.preventDefault, 'capture decision.preventDefault');
389+ if (preventDefault && nativeEvent && nativeEvent.cancelable) nativeEvent.preventDefault();
390+ return emit ? Object.freeze({ schemaVersion: SCHEMA_VERSION, ...event }) : null;
391+ }
392+
393+ function dispatch(event, nativeEvent) {
394+ const captured = capture(event, nativeEvent);
395+ if (captured) send(captured);
396+ }
397+
398+ function updateCursor(state) {
399+ cursor.hidden = !state.visible;
400+ cursor.className = 'vev-terminal__cursor';
401+ if (!state.visible) return;
402+ const style = state.styleSet ? state.style : 0;
403+ const bar = style === 5 || style === 6;
404+ const underline = style === 3 || style === 4;
405+ if (bar) cursor.classList.add('vev-terminal__cursor--bar');
406+ if (underline) cursor.classList.add('vev-terminal__cursor--underline');
407+ if (style === 0 || style === 1 || style === 3 || style === 5) cursor.classList.add('vev-terminal__cell--blink');
408+ cursor.style.left = `calc(${state.column} * var(--vev-cell-width))`;
409+ cursor.style.top = `calc(${state.row} * var(--vev-cell-height))`;
410+ cursor.style.width = bar ? '' : 'var(--vev-cell-width)';
411+ cursor.style.height = underline ? '' : 'var(--vev-cell-height)';
412+ if (underline) cursor.style.marginTop = 'calc(var(--vev-cell-height) * 0.88)';
413+ else cursor.style.marginTop = '';
414+ }
415+
416+ /** @param {Update} value */
417+ function apply(value) {
418+ alive();
419+ const update = validateUpdate(value, configured);
420+ if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot');
421+ if (!update.snapshot) {
422+ for (let row = 0; row < height; row += 1) {
423+ if (!rowNodes[row] || rowNodes[row].parentNode !== viewport) fail(`mounted row ${row} is unavailable`);
424+ }
425+ }
426+ // maxUpdateBytes bounds the text retained by the mounted terminal, not
427+ // only the current delta: reject the projected retained total before
428+ // building or mutating the DOM.
429+ let projected = retainedBytes;
430+ for (const row of update.rows) {
431+ projected -= rowBytes[row.row] || 0;
432+ projected += row.bytes;
433+ }
434+ if (update.snapshot) projected = update.rows.reduce((total, row) => total + row.bytes, 0);
435+ if (projected > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
436+ const styleKey = JSON.stringify(update.styles);
437+ const reusable = update.width === width && update.height === height && styleKey === previousStyles;
438+ const built = update.rows.map(row => {
439+ const old = previousRows[row.row];
440+ const node = rowNodes[row.row];
441+ // Text-only updates preserve geometry and styling. Space runs use
442+ // merged nodes and deliberately take the full rebuilding path.
443+ const reuse = reusable && node?.parentNode === viewport && old &&
444+ old.cells.length === row.cells.length && node.children.length === row.cells.length &&
445+ row.cells.every((cell, index) => {
446+ const before = old.cells[index];
447+ return cell.column === before.column && cell.width === before.width && cell.style === before.style &&
448+ !/^ +$/.test(cell.text) && !/^ +$/.test(before.text);
449+ });
450+ return reuse ? { row: row.row, node, bytes: row.bytes, text: row.cells.map(cell => cell.text).join(''), cells: row.cells } :
451+ { row: row.row, bytes: row.bytes, ...buildRow(document, row, update.styles) };
452+ });
453+ for (const item of built) {
454+ if (item.cells) {
455+ item.cells.forEach((cell, index) => {
456+ const child = item.node.children[index];
457+ if (child.textContent !== cell.text) child.textContent = cell.text;
458+ });
459+ }
460+ }
461+
462+ if (update.snapshot) {
463+ const reuseAll = built.every(item => item.cells);
464+ const fragment = document.createDocumentFragment();
465+ const nextNodes = new Array(update.height);
466+ const nextText = new Array(update.height);
467+ const nextBytes = new Array(update.height).fill(0);
468+ for (const item of built) {
469+ nextNodes[item.row] = item.node;
470+ nextText[item.row] = item.text;
471+ nextBytes[item.row] = item.bytes;
472+ if (!reuseAll) fragment.append(item.node);
473+ }
474+ if (!reuseAll) viewport.replaceChildren(fragment, cursor);
475+ rowNodes = nextNodes;
476+ rowText = nextText;
477+ rowBytes = nextBytes;
478+ retainedBytes = projected;
479+ } else {
480+ for (const item of built) {
481+ if (!item.cells) rowNodes[item.row].replaceWith(item.node);
482+ rowNodes[item.row] = item.node;
483+ retainedBytes += item.bytes - (rowBytes[item.row] || 0);
484+ rowBytes[item.row] = item.bytes;
485+ rowText[item.row] = item.text;
486+ }
487+ }
488+ if (update.snapshot || styleKey !== previousStyles) previousRows = new Array(update.height);
489+ for (const row of update.rows) previousRows[row.row] = row;
490+ previousStyles = styleKey;
491+ width = update.width;
492+ height = update.height;
493+ setOwnedProperty('--vev-cols', String(width));
494+ setOwnedProperty('--vev-rows', String(height));
495+ const accessibleText = rowText.join('\n');
496+ if (accessible.textContent !== accessibleText) accessible.textContent = accessibleText;
497+ updateCursor(update.cursor);
498+ scheduleResize();
499+ }
500+
501+ function position(event) {
502+ if (width === 0 || height === 0) return { row: 0, column: 0, x: 0, y: 0 };
503+ const bounds = viewport.getBoundingClientRect();
504+ if (bounds.width <= 0 || bounds.height <= 0) return { row: 0, column: 0, x: 0, y: 0 };
505+ const clientX = Number.isFinite(event.clientX) ? event.clientX : bounds.left;
506+ const clientY = Number.isFinite(event.clientY) ? event.clientY : bounds.top;
507+ const x = Math.max(0, Math.min(bounds.width, clientX - bounds.left));
508+ const y = Math.max(0, Math.min(bounds.height, clientY - bounds.top));
509+ const column = Math.min(width - 1, Math.floor(x / (bounds.width / width)));
510+ const row = Math.min(height - 1, Math.floor(y / (bounds.height / height)));
511+ return { row, column, x, y };
512+ }
513+
514+ function modifierFields(event) {
515+ return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
516+ }
517+
518+ function pointerPayload(event, action) {
519+ const invalidButton = !Number.isInteger(event.button) || event.button < -1 || event.button > 4;
520+ const invalidButtons = !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31;
521+ if (invalidButton || invalidButtons) return null;
522+ return { type: 'pointer', action, button: event.button, buttons: event.buttons, ...position(event), ...modifierFields(event) };
523+ }
524+
525+ function flushPointer() {
526+ pointerFrame = 0;
527+ const captured = pendingPointer;
528+ pendingPointer = null;
529+ if (captured) send(captured);
530+ }
531+
532+ // A discrete pointer event must never be overtaken by an older deferred
533+ // move: flush the pending move first so consumers observe event order.
534+ function dispatchDiscretePointer(event, action, nativeEvent) {
535+ const payload = pointerPayload(event, action);
536+ if (!payload) return;
537+ if (pendingPointer) flushPointer();
538+ dispatch(payload, nativeEvent);
539+ }
540+
541+ function scheduleResize() {
542+ if (resizeFrame || width === 0 || height === 0) return;
543+ resizeFrame = requestAnimationFrame(() => {
544+ resizeFrame = 0;
545+ const bounds = viewport.getBoundingClientRect();
546+ const cellWidth = bounds.width / width;
547+ const cellHeight = bounds.height / height;
548+ if (bounds.width <= 0 || bounds.height <= 0 || cellWidth <= 0 || cellHeight <= 0) return;
549+ const payload = {
550+ type: 'resize', columns: width, rows: height,
551+ pixelWidth: bounds.width, pixelHeight: bounds.height,
552+ cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio
553+ };
554+ const identity = JSON.stringify(payload);
555+ if (identity !== lastResize) {
556+ lastResize = identity;
557+ dispatch(payload);
558+ }
559+ });
560+ }
561+
562+ input.addEventListener('beforeinput', event => {
563+ if (composing || event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromPaste') return;
564+ const editingKey = { deleteContentBackward: 'Backspace', deleteContentForward: 'Delete', insertLineBreak: 'Enter', insertParagraph: 'Enter' }[event.inputType];
565+ if (editingKey) {
566+ if (editingKeyDown === editingKey) return;
567+ dispatch({ type: 'key', key: editingKey, code: editingKey, repeat: false, location: 0, alt: false, ctrl: false, meta: false, shift: false }, event);
568+ return;
569+ }
570+ if (!event.data) return;
571+ if (encoder.encode(event.data).byteLength > configured.maxTextBytes) {
572+ event.preventDefault();
573+ return;
574+ }
575+ dispatch({ type: 'text', text: event.data }, event);
576+ }, { signal });
577+ input.addEventListener('input', event => {
578+ if (!composing && !event.isComposing) input.value = '';
579+ }, { signal });
580+ input.addEventListener('compositionstart', () => { composing = true; }, { signal });
581+ input.addEventListener('compositionend', event => {
582+ composing = false;
583+ input.value = '';
584+ if (event.data && encoder.encode(event.data).byteLength <= configured.maxTextBytes) {
585+ dispatch({ type: 'text', text: event.data }, event);
586+ }
587+ }, { signal });
588+ input.addEventListener('keydown', event => {
589+ if (composing || event.isComposing) return;
590+ const key = event.key || 'Unidentified';
591+ editingKeyDown = ['Backspace', 'Delete', 'Enter'].includes(key) ? key : null;
592+ const code = event.code || 'Unidentified';
593+ if (key === 'Unidentified' || key === 'Process') return;
594+ const nonText = key.length !== 1 || event.ctrlKey || event.altKey || event.metaKey;
595+ if (!nonText) return;
596+ if (key.length > 128 || code.length > 128) {
597+ event.preventDefault();
598+ return;
599+ }
600+ dispatch({ type: 'key', key, code, repeat: event.repeat, location: event.location, ...modifierFields(event) }, event);
601+ }, { signal });
602+ input.addEventListener('keyup', () => { editingKeyDown = null; }, { signal });
603+ input.addEventListener('blur', () => { editingKeyDown = null; }, { signal });
604+ input.addEventListener('paste', event => {
605+ const text = event.clipboardData?.getData('text/plain') || '';
606+ if (encoder.encode(text).byteLength > configured.maxPasteBytes) {
607+ event.preventDefault();
608+ return;
609+ }
610+ dispatch({ type: 'paste', text }, event);
611+ }, { signal });
612+ input.addEventListener('focus', event => dispatch({ type: 'focus', focused: true }, event), { signal });
613+ input.addEventListener('blur', event => dispatch({ type: 'focus', focused: false }, event), { signal });
614+
615+ viewport.addEventListener('pointerdown', event => {
616+ if (mouseCapture && viewport.setPointerCapture) {
617+ try {
618+ if (event.isPrimary !== false) viewport.setPointerCapture(event.pointerId);
619+ } catch {
620+ // Pointer capture is best-effort; ordering still holds.
621+ }
622+ }
623+ dispatchDiscretePointer(event, 'down', event);
624+ }, { signal });
625+ viewport.addEventListener('pointerup', event => {
626+ dispatchDiscretePointer(event, 'up', event);
627+ }, { signal });
628+ viewport.addEventListener('pointercancel', event => {
629+ dispatchDiscretePointer(event, 'cancel', event);
630+ }, { signal });
631+ viewport.addEventListener('pointermove', event => {
632+ const payload = pointerPayload(event, 'move');
633+ if (!payload) return;
634+ const captured = capture(payload, event);
635+ if (!captured || destroyed) return;
636+ pendingPointer = captured;
637+ if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointer);
638+ }, { signal });
639+ viewport.addEventListener('wheel', event => {
640+ const invalidDelta = !Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY);
641+ const invalidMode = !Number.isInteger(event.deltaMode) || event.deltaMode < 0 || event.deltaMode > 2;
642+ if (invalidDelta || invalidMode) return;
643+ const { row, column } = position(event);
644+ dispatch({ type: 'wheel', deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, row, column, ...modifierFields(event) }, event);
645+ }, { signal, passive: false });
646+
647+ const observer = new ResizeObserver(scheduleResize);
648+ observer.observe(root);
649+
650+ return Object.freeze({
651+ apply,
652+ focus() { alive(); input.focus(); },
653+ setMouseCapture(enabled) {
654+ alive();
655+ mouseCapture = Boolean(enabled);
656+ root.classList.toggle('vev-terminal--mouse-capture', mouseCapture);
657+ },
658+ setTheme(theme) {
659+ alive();
660+ const value = validateTheme(theme);
661+ setOwnedProperty('--vev-fg', rgbValue(value.foreground));
662+ setOwnedProperty('--vev-bg', rgbValue(value.background));
663+ setOwnedProperty('--vev-cursor', rgbValue(value.cursor));
664+ setOwnedProperty('--vev-selection-bg', rgbValue(value.selection));
665+ setOwnedProperty('--vev-selection-fg', rgbValue(value.selectionText));
666+ value.palette.forEach((color, index) => setOwnedProperty(`--vev-color-${index}`, rgbValue(color)));
667+ },
668+ destroy() {
669+ if (destroyed) return;
670+ destroyed = true;
671+ abort.abort();
672+ observer.disconnect();
673+ if (resizeFrame) cancelAnimationFrame(resizeFrame);
674+ if (pointerFrame) cancelAnimationFrame(pointerFrame);
675+ input.value = '';
676+ accessible.textContent = '';
677+ viewport.replaceChildren();
678+ input.remove();
679+ viewport.remove();
680+ accessible.remove();
681+ rowNodes = [];
682+ rowText = [];
683+ rowBytes = [];
684+ retainedBytes = 0;
685+ previousRows = [];
686+ previousStyles = '';
687+ pendingPointer = null;
688+ width = 0;
689+ height = 0;
690+ lastResize = '';
691+ decide = () => ({ emit: false, preventDefault: false });
692+ send = () => {};
693+ for (const [name, previous] of previousProperties) {
694+ if (previous.value === '') root.style.removeProperty(name);
695+ else root.style.setProperty(name, previous.value, previous.priority);
696+ }
697+ previousProperties.clear();
698+ root.classList.remove('vev-terminal', 'vev-terminal--mouse-capture');
699+ if (previousRole === null) root.removeAttribute('role');
700+ else root.setAttribute('role', previousRole);
701+ if (previousLabel === null) root.removeAttribute('aria-label');
702+ else root.setAttribute('aria-label', previousLabel);
703+ }
704+ });
705+ }
706+
707+ globalThis.VevTerminal = Object.freeze({ schemaVersion: SCHEMA_VERSION, mount });
708+})();
@@ -0,0 +1,708 @@
1+(() => {
2+ 'use strict';
3+
4+ /** @typedef {{r: number, g: number, b: number}} RGB */
5+ /** @typedef {{kind: 0}|{kind: 1, index: number}|{kind: 2, rgb: RGB}} Color */
6+ /** @typedef {{bold?: boolean, italic?: boolean, inverse?: boolean, dim?: boolean, blink?: boolean, strikethrough?: boolean, underline?: boolean, underlineStyle?: number, foreground: Color, background: Color, underlineColor: Color}} TerminalStyle */
7+ /** @typedef {{column: number, width: number, text: string, style: number}} CellUpdate */
8+ /** @typedef {{row: number, cells: CellUpdate[]}} RowUpdate */
9+ /** @typedef {{row: number, column: number, visible: boolean, style: number, styleSet: boolean}} Cursor */
10+ /** @typedef {{schemaVersion: number, width: number, height: number, snapshot: boolean, rows: RowUpdate[], styles: TerminalStyle[], cursor: Cursor}} Update */
11+ /** @typedef {{emit: boolean, preventDefault: boolean}} CaptureDecision */
12+ /** @typedef {{schemaVersion?: number, type: string, [key: string]: unknown}} BrowserEvent */
13+ /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */
14+ /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */
15+ /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */
16+ /** @typedef {{apply(update: Update): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */
17+
18+ const SCHEMA_VERSION = 1;
19+ const DEFAULT_LIMITS = Object.freeze({
20+ maxCells: 1_000_000,
21+ maxRowsPerUpdate: 10_000,
22+ maxColumns: 10_000,
23+ maxStyles: 65_536,
24+ maxTextBytes: 64 << 10,
25+ maxUpdateBytes: 64 << 20,
26+ maxPasteBytes: 1 << 20
27+ });
28+ const encoder = new TextEncoder();
29+
30+ function fail(message) {
31+ throw new TypeError(`VevTerminal: ${message}`);
32+ }
33+
34+ function object(value, name) {
35+ if (value === null || typeof value !== 'object' || Array.isArray(value)) fail(`${name} must be an object`);
36+ return value;
37+ }
38+
39+ function keys(value, allowed, name) {
40+ for (const key of Object.keys(value)) {
41+ if (!allowed.includes(key)) fail(`${name} contains unknown field ${key}`);
42+ }
43+ }
44+
45+ function integer(value, minimum, maximum, name) {
46+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) fail(`${name} is out of range`);
47+ return value;
48+ }
49+
50+ function boolean(value, name, fallback = false) {
51+ if (value === undefined) return fallback;
52+ if (typeof value !== 'boolean') fail(`${name} must be boolean`);
53+ return value;
54+ }
55+
56+ /** @returns {{value: string, byteLength: number}} */
57+ function boundedString(value, limit, name) {
58+ if (typeof value !== 'string') fail(`${name} must be a string`);
59+ const byteLength = encoder.encode(value).byteLength;
60+ if (byteLength > limit) fail(`${name} exceeds its byte limit`);
61+ return { value, byteLength };
62+ }
63+
64+ function limits(requested = {}) {
65+ object(requested, 'limits');
66+ keys(requested, Object.keys(DEFAULT_LIMITS), 'limits');
67+ const result = {};
68+ for (const [name, fallback] of Object.entries(DEFAULT_LIMITS)) {
69+ result[name] = requested[name] === undefined ? fallback : integer(requested[name], 1, Number.MAX_SAFE_INTEGER, `limits.${name}`);
70+ }
71+ return Object.freeze(result);
72+ }
73+
74+ function validateRGB(value, name) {
75+ object(value, name);
76+ keys(value, ['r', 'g', 'b'], name);
77+ return {
78+ r: integer(value.r, 0, 255, `${name}.r`),
79+ g: integer(value.g, 0, 255, `${name}.g`),
80+ b: integer(value.b, 0, 255, `${name}.b`)
81+ };
82+ }
83+
84+ function validateColor(value, name) {
85+ object(value, name);
86+ keys(value, ['kind', 'index', 'rgb'], name);
87+ const kind = integer(value.kind, 0, 2, `${name}.kind`);
88+ if (kind === 0) {
89+ if (value.index !== undefined || value.rgb !== undefined) fail(`${name} default color has a payload`);
90+ return { kind };
91+ }
92+ if (kind === 1) {
93+ if (value.rgb !== undefined) fail(`${name} indexed color has an rgb payload`);
94+ return { kind, index: integer(value.index, 0, 255, `${name}.index`) };
95+ }
96+ if (value.index !== undefined) fail(`${name} rgb color has an index payload`);
97+ return { kind, rgb: validateRGB(value.rgb, `${name}.rgb`) };
98+ }
99+
100+ function validateStyle(value, index) {
101+ const name = `styles[${index}]`;
102+ object(value, name);
103+ keys(value, [
104+ 'bold', 'italic', 'inverse', 'dim', 'blink', 'strikethrough', 'underline',
105+ 'underlineStyle', 'foreground', 'background', 'underlineColor'
106+ ], name);
107+ return {
108+ bold: boolean(value.bold, `${name}.bold`),
109+ italic: boolean(value.italic, `${name}.italic`),
110+ inverse: boolean(value.inverse, `${name}.inverse`),
111+ dim: boolean(value.dim, `${name}.dim`),
112+ blink: boolean(value.blink, `${name}.blink`),
113+ strikethrough: boolean(value.strikethrough, `${name}.strikethrough`),
114+ underline: boolean(value.underline, `${name}.underline`),
115+ underlineStyle: value.underlineStyle === undefined ? 0 : integer(value.underlineStyle, 0, 5, `${name}.underlineStyle`),
116+ foreground: validateColor(value.foreground, `${name}.foreground`),
117+ background: validateColor(value.background, `${name}.background`),
118+ underlineColor: validateColor(value.underlineColor, `${name}.underlineColor`)
119+ };
120+ }
121+
122+ function validateCursor(value, width, height) {
123+ object(value, 'cursor');
124+ keys(value, ['row', 'column', 'visible', 'style', 'styleSet'], 'cursor');
125+ const styleSet = boolean(value.styleSet, 'cursor.styleSet');
126+ const style = integer(value.style, 0, 6, 'cursor.style');
127+ if (!styleSet && style !== 0) fail('cursor style must be zero when unset');
128+ return {
129+ row: integer(value.row, 0, height - 1, 'cursor.row'),
130+ column: integer(value.column, 0, width - 1, 'cursor.column'),
131+ visible: boolean(value.visible, 'cursor.visible'),
132+ style,
133+ styleSet
134+ };
135+ }
136+
137+ function validateRow(value, width, height, styleCount, configured, textBudget, seen) {
138+ object(value, 'row');
139+ keys(value, ['row', 'cells'], 'row');
140+ const row = integer(value.row, 0, height - 1, 'row.row');
141+ if (seen.has(row)) fail(`duplicate row ${row}`);
142+ seen.add(row);
143+ if (!Array.isArray(value.cells)) fail(`row ${row} cells must be an array`);
144+ let column = 0;
145+ let bytes = 0;
146+ const cells = value.cells.map((candidate, index) => {
147+ const name = `row ${row} cell ${index}`;
148+ object(candidate, name);
149+ keys(candidate, ['column', 'width', 'text', 'style'], name);
150+ const current = integer(candidate.column, 0, width - 1, `${name}.column`);
151+ const cellWidth = integer(candidate.width, 1, 2, `${name}.width`);
152+ if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`);
153+ if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`);
154+ const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`);
155+ const cell = {
156+ column: current,
157+ width: cellWidth,
158+ text: text.value,
159+ style: integer(candidate.style, 0, styleCount - 1, `${name}.style`)
160+ };
161+ textBudget.value += text.byteLength;
162+ if (textBudget.value > configured.maxUpdateBytes) fail('update text exceeds its aggregate byte limit');
163+ bytes += text.byteLength;
164+ column += cellWidth;
165+ return cell;
166+ });
167+ if (column !== width) fail(`row coverage is ${column}, expected ${width}`);
168+ return { row, cells, bytes };
169+ }
170+
171+ /**
172+ * Validate an entire update before any DOM mutation.
173+ * @param {unknown} value
174+ * @param {Readonly<Record<string, number>>} configured
175+ * @returns {Update}
176+ */
177+ function validateUpdate(value, configured) {
178+ object(value, 'update');
179+ keys(value, ['schemaVersion', 'width', 'height', 'snapshot', 'rows', 'styles', 'cursor'], 'update');
180+ if (value.schemaVersion !== SCHEMA_VERSION) fail(`unsupported update schema ${value.schemaVersion}`);
181+ const width = integer(value.width, 1, configured.maxColumns, 'update.width');
182+ const height = integer(value.height, 1, configured.maxRowsPerUpdate, 'update.height');
183+ if (width > Math.floor(configured.maxCells / height)) fail('update exceeds the cell limit');
184+ const snapshot = boolean(value.snapshot, 'update.snapshot');
185+ if (!Array.isArray(value.styles) || value.styles.length > configured.maxStyles) fail('update styles exceed their limit');
186+ const styles = value.styles.map(validateStyle);
187+ if (!Array.isArray(value.rows) || value.rows.length > configured.maxRowsPerUpdate) fail('update rows exceed their limit');
188+ if (value.rows.length > Math.floor(configured.maxCells / width)) fail('update represented cells exceed their limit');
189+ if (value.rows.length > 0 && styles.length === 0) fail('an update with rows requires styles');
190+ if (snapshot && value.rows.length !== height) fail('snapshot must contain every row');
191+ const seen = new Set();
192+ const textBudget = { value: 0 };
193+ const rows = value.rows.map(row => validateRow(row, width, height, styles.length, configured, textBudget, seen));
194+ if (snapshot) {
195+ rows.sort((left, right) => left.row - right.row);
196+ rows.forEach((row, index) => { if (row.row !== index) fail('snapshot rows are incomplete'); });
197+ }
198+ return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) };
199+ }
200+
201+ function xtermColor(index) {
202+ if (index < 16) return `var(--vev-color-${index})`;
203+ if (index < 232) {
204+ const value = index - 16;
205+ const levels = [0, 95, 135, 175, 215, 255];
206+ const r = levels[Math.floor(value / 36)];
207+ const g = levels[Math.floor(value / 6) % 6];
208+ const b = levels[value % 6];
209+ return `rgb(${r} ${g} ${b})`;
210+ }
211+ const gray = 8 + (index - 232) * 10;
212+ return `rgb(${gray} ${gray} ${gray})`;
213+ }
214+
215+ function colorValue(color, fallback) {
216+ if (color.kind === 0) return fallback;
217+ if (color.kind === 1) return xtermColor(color.index);
218+ return `rgb(${color.rgb.r} ${color.rgb.g} ${color.rgb.b})`;
219+ }
220+
221+ function addStyleClasses(node, style) {
222+ if (style.bold) node.classList.add('vev-terminal__cell--bold');
223+ if (style.italic) node.classList.add('vev-terminal__cell--italic');
224+ if (style.dim) node.classList.add('vev-terminal__cell--dim');
225+ if (style.blink) node.classList.add('vev-terminal__cell--blink');
226+ if (style.strikethrough) node.classList.add('vev-terminal__cell--strike');
227+ if (style.underline) {
228+ node.classList.add('vev-terminal__cell--underline');
229+ const suffix = ['single', 'single', 'double', 'wavy', 'dotted', 'dashed'][style.underlineStyle];
230+ if (suffix !== 'single') node.classList.add(`vev-terminal__cell--underline-${suffix}`);
231+ }
232+ }
233+
234+ function buildRow(document, row, styles) {
235+ const node = document.createElement('div');
236+ node.className = 'vev-terminal__row';
237+ node.dataset.row = String(row.row);
238+ let text = '';
239+ for (let index = 0; index < row.cells.length; index += 1) {
240+ let cell = row.cells[index];
241+ const style = styles[cell.style];
242+ // A run of spaces needs one grid item, not one item per column. Only
243+ // merge plain spaces: arbitrary text still needs explicit cell widths.
244+ if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) {
245+ let width = cell.width;
246+ let spaces = cell.text;
247+ while (index + 1 < row.cells.length) {
248+ const next = row.cells[index + 1];
249+ if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break;
250+ width += next.width;
251+ spaces += next.text;
252+ index += 1;
253+ }
254+ cell = { ...cell, width, text: spaces };
255+ }
256+ const child = document.createElement('span');
257+ child.className = 'vev-terminal__cell';
258+ child.dataset.column = String(cell.column);
259+ child.dataset.width = String(cell.width);
260+ child.style.gridColumn = `${cell.column + 1} / span ${cell.width}`;
261+ let foreground = colorValue(style.foreground, 'var(--vev-fg)');
262+ let background = colorValue(style.background, 'var(--vev-bg)');
263+ if (style.inverse) [foreground, background] = [background, foreground];
264+ // Default colors already resolve through the stylesheet. Avoid three
265+ // redundant custom properties per cell (and their style-resolution cost).
266+ if (style.inverse || style.foreground.kind !== 0) child.style.setProperty('--vev-cell-fg', foreground);
267+ if (style.inverse || style.background.kind !== 0) child.style.setProperty('--vev-cell-bg', background);
268+ if (style.underlineColor.kind !== 0 || style.dim) child.style.setProperty('--vev-cell-underline', colorValue(style.underlineColor, foreground));
269+ addStyleClasses(child, style);
270+ child.textContent = cell.text;
271+ // A plain space has no ink to clip. Avoid a compositor clip for every
272+ // blank cell while retaining its grid box, selection and text content.
273+ if (/^ +$/.test(cell.text) && !style.underline && !style.strikethrough) {
274+ child.style.overflow = 'visible';
275+ }
276+ node.append(child);
277+ text += cell.text;
278+ }
279+ return { node, text };
280+ }
281+
282+ function validateTheme(theme) {
283+ object(theme, 'theme');
284+ keys(theme, ['foreground', 'background', 'cursor', 'selection', 'selectionText', 'palette'], 'theme');
285+ const result = {
286+ foreground: validateRGB(theme.foreground, 'theme.foreground'),
287+ background: validateRGB(theme.background, 'theme.background'),
288+ cursor: validateRGB(theme.cursor, 'theme.cursor'),
289+ selection: validateRGB(theme.selection, 'theme.selection'),
290+ selectionText: validateRGB(theme.selectionText, 'theme.selectionText')
291+ };
292+ if (!Array.isArray(theme.palette) || theme.palette.length !== 16) fail('theme.palette must contain 16 colors');
293+ result.palette = theme.palette.map((color, index) => validateRGB(color, `theme.palette[${index}]`));
294+ return result;
295+ }
296+
297+ function rgbValue(color) { return `rgb(${color.r} ${color.g} ${color.b})`; }
298+
299+ /**
300+ * Mount one interactive terminal adapter.
301+ * @param {Element} root
302+ * @param {MountOptions} options
303+ * @returns {Terminal}
304+ */
305+ function mount(root, options = {}) {
306+ if (!(root instanceof Element)) fail('mount root must be an Element');
307+ if (root.classList.contains('vev-terminal')) fail('mount root already owns a terminal');
308+ object(options, 'options');
309+ keys(options, ['label', 'decide', 'send', 'limits'], 'options');
310+ const label = boundedString(options.label, 1024, 'options.label').value.trim();
311+ if (label === '') fail('options.label must not be empty');
312+ if (options.decide !== undefined && typeof options.decide !== 'function') fail('options.decide must be a function');
313+ if (options.send !== undefined && typeof options.send !== 'function') fail('options.send must be a function');
314+ const configured = limits(options.limits);
315+ let decide = options.decide || (() => ({ emit: true, preventDefault: false }));
316+ let send = options.send || (() => {});
317+ const document = root.ownerDocument;
318+ const abort = new AbortController();
319+ const signal = abort.signal;
320+
321+ const previousRole = root.hasAttribute('role') ? root.getAttribute('role') : null;
322+ const previousLabel = root.hasAttribute('aria-label') ? root.getAttribute('aria-label') : null;
323+ root.classList.add('vev-terminal');
324+ root.setAttribute('role', 'group');
325+ root.setAttribute('aria-label', label);
326+
327+ const input = document.createElement('textarea');
328+ input.className = 'vev-terminal__input';
329+ input.setAttribute('aria-label', `${label} input`);
330+ input.setAttribute('autocomplete', 'off');
331+ input.setAttribute('autocapitalize', 'off');
332+ input.setAttribute('spellcheck', 'false');
333+
334+ const viewport = document.createElement('div');
335+ viewport.className = 'vev-terminal__viewport';
336+ viewport.setAttribute('aria-hidden', 'true');
337+
338+ const cursor = document.createElement('div');
339+ cursor.className = 'vev-terminal__cursor';
340+ cursor.hidden = true;
341+ viewport.append(cursor);
342+
343+ const accessible = document.createElement('pre');
344+ accessible.className = 'vev-terminal__accessible-output';
345+ accessible.setAttribute('aria-live', 'off');
346+ accessible.setAttribute('aria-label', `${label} output`);
347+
348+ root.append(input, viewport, accessible);
349+
350+ let destroyed = false;
351+ let composing = false;
352+ let editingKeyDown = null;
353+ let mouseCapture = false;
354+ let width = 0;
355+ let height = 0;
356+ let rowNodes = [];
357+ let rowText = [];
358+ let rowBytes = [];
359+ let retainedBytes = 0;
360+ let previousRows = [];
361+ let previousStyles = '';
362+ let resizeFrame = 0;
363+ let pointerFrame = 0;
364+ let pendingPointer = null;
365+ let lastResize = '';
366+ const previousProperties = new Map();
367+
368+ function setOwnedProperty(name, value) {
369+ if (!previousProperties.has(name)) {
370+ previousProperties.set(name, {
371+ value: root.style.getPropertyValue(name),
372+ priority: root.style.getPropertyPriority(name)
373+ });
374+ }
375+ root.style.setProperty(name, value);
376+ }
377+
378+ function alive() {
379+ if (destroyed) fail('instance is destroyed');
380+ }
381+
382+ /** @returns {Readonly<BrowserEvent>|null} */
383+ function capture(event, nativeEvent) {
384+ const decision = decide(Object.freeze({ ...event }));
385+ object(decision, 'capture decision');
386+ keys(decision, ['emit', 'preventDefault'], 'capture decision');
387+ const emit = boolean(decision.emit, 'capture decision.emit');
388+ const preventDefault = boolean(decision.preventDefault, 'capture decision.preventDefault');
389+ if (preventDefault && nativeEvent && nativeEvent.cancelable) nativeEvent.preventDefault();
390+ return emit ? Object.freeze({ schemaVersion: SCHEMA_VERSION, ...event }) : null;
391+ }
392+
393+ function dispatch(event, nativeEvent) {
394+ const captured = capture(event, nativeEvent);
395+ if (captured) send(captured);
396+ }
397+
398+ function updateCursor(state) {
399+ cursor.hidden = !state.visible;
400+ cursor.className = 'vev-terminal__cursor';
401+ if (!state.visible) return;
402+ const style = state.styleSet ? state.style : 0;
403+ const bar = style === 5 || style === 6;
404+ const underline = style === 3 || style === 4;
405+ if (bar) cursor.classList.add('vev-terminal__cursor--bar');
406+ if (underline) cursor.classList.add('vev-terminal__cursor--underline');
407+ if (style === 0 || style === 1 || style === 3 || style === 5) cursor.classList.add('vev-terminal__cell--blink');
408+ cursor.style.left = `calc(${state.column} * var(--vev-cell-width))`;
409+ cursor.style.top = `calc(${state.row} * var(--vev-cell-height))`;
410+ cursor.style.width = bar ? '' : 'var(--vev-cell-width)';
411+ cursor.style.height = underline ? '' : 'var(--vev-cell-height)';
412+ if (underline) cursor.style.marginTop = 'calc(var(--vev-cell-height) * 0.88)';
413+ else cursor.style.marginTop = '';
414+ }
415+
416+ /** @param {Update} value */
417+ function apply(value) {
418+ alive();
419+ const update = validateUpdate(value, configured);
420+ if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot');
421+ if (!update.snapshot) {
422+ for (let row = 0; row < height; row += 1) {
423+ if (!rowNodes[row] || rowNodes[row].parentNode !== viewport) fail(`mounted row ${row} is unavailable`);
424+ }
425+ }
426+ // maxUpdateBytes bounds the text retained by the mounted terminal, not
427+ // only the current delta: reject the projected retained total before
428+ // building or mutating the DOM.
429+ let projected = retainedBytes;
430+ for (const row of update.rows) {
431+ projected -= rowBytes[row.row] || 0;
432+ projected += row.bytes;
433+ }
434+ if (update.snapshot) projected = update.rows.reduce((total, row) => total + row.bytes, 0);
435+ if (projected > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
436+ const styleKey = JSON.stringify(update.styles);
437+ const reusable = update.width === width && update.height === height && styleKey === previousStyles;
438+ const built = update.rows.map(row => {
439+ const old = previousRows[row.row];
440+ const node = rowNodes[row.row];
441+ // Text-only updates preserve geometry and styling. Space runs use
442+ // merged nodes and deliberately take the full rebuilding path.
443+ const reuse = reusable && node?.parentNode === viewport && old &&
444+ old.cells.length === row.cells.length && node.children.length === row.cells.length &&
445+ row.cells.every((cell, index) => {
446+ const before = old.cells[index];
447+ return cell.column === before.column && cell.width === before.width && cell.style === before.style &&
448+ !/^ +$/.test(cell.text) && !/^ +$/.test(before.text);
449+ });
450+ return reuse ? { row: row.row, node, bytes: row.bytes, text: row.cells.map(cell => cell.text).join(''), cells: row.cells } :
451+ { row: row.row, bytes: row.bytes, ...buildRow(document, row, update.styles) };
452+ });
453+ for (const item of built) {
454+ if (item.cells) {
455+ item.cells.forEach((cell, index) => {
456+ const child = item.node.children[index];
457+ if (child.textContent !== cell.text) child.textContent = cell.text;
458+ });
459+ }
460+ }
461+
462+ if (update.snapshot) {
463+ const reuseAll = built.every(item => item.cells);
464+ const fragment = document.createDocumentFragment();
465+ const nextNodes = new Array(update.height);
466+ const nextText = new Array(update.height);
467+ const nextBytes = new Array(update.height).fill(0);
468+ for (const item of built) {
469+ nextNodes[item.row] = item.node;
470+ nextText[item.row] = item.text;
471+ nextBytes[item.row] = item.bytes;
472+ if (!reuseAll) fragment.append(item.node);
473+ }
474+ if (!reuseAll) viewport.replaceChildren(fragment, cursor);
475+ rowNodes = nextNodes;
476+ rowText = nextText;
477+ rowBytes = nextBytes;
478+ retainedBytes = projected;
479+ } else {
480+ for (const item of built) {
481+ if (!item.cells) rowNodes[item.row].replaceWith(item.node);
482+ rowNodes[item.row] = item.node;
483+ retainedBytes += item.bytes - (rowBytes[item.row] || 0);
484+ rowBytes[item.row] = item.bytes;
485+ rowText[item.row] = item.text;
486+ }
487+ }
488+ if (update.snapshot || styleKey !== previousStyles) previousRows = new Array(update.height);
489+ for (const row of update.rows) previousRows[row.row] = row;
490+ previousStyles = styleKey;
491+ width = update.width;
492+ height = update.height;
493+ setOwnedProperty('--vev-cols', String(width));
494+ setOwnedProperty('--vev-rows', String(height));
495+ const accessibleText = rowText.join('\n');
496+ if (accessible.textContent !== accessibleText) accessible.textContent = accessibleText;
497+ updateCursor(update.cursor);
498+ scheduleResize();
499+ }
500+
501+ function position(event) {
502+ if (width === 0 || height === 0) return { row: 0, column: 0, x: 0, y: 0 };
503+ const bounds = viewport.getBoundingClientRect();
504+ if (bounds.width <= 0 || bounds.height <= 0) return { row: 0, column: 0, x: 0, y: 0 };
505+ const clientX = Number.isFinite(event.clientX) ? event.clientX : bounds.left;
506+ const clientY = Number.isFinite(event.clientY) ? event.clientY : bounds.top;
507+ const x = Math.max(0, Math.min(bounds.width, clientX - bounds.left));
508+ const y = Math.max(0, Math.min(bounds.height, clientY - bounds.top));
509+ const column = Math.min(width - 1, Math.floor(x / (bounds.width / width)));
510+ const row = Math.min(height - 1, Math.floor(y / (bounds.height / height)));
511+ return { row, column, x, y };
512+ }
513+
514+ function modifierFields(event) {
515+ return { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey };
516+ }
517+
518+ function pointerPayload(event, action) {
519+ const invalidButton = !Number.isInteger(event.button) || event.button < -1 || event.button > 4;
520+ const invalidButtons = !Number.isInteger(event.buttons) || event.buttons < 0 || event.buttons > 31;
521+ if (invalidButton || invalidButtons) return null;
522+ return { type: 'pointer', action, button: event.button, buttons: event.buttons, ...position(event), ...modifierFields(event) };
523+ }
524+
525+ function flushPointer() {
526+ pointerFrame = 0;
527+ const captured = pendingPointer;
528+ pendingPointer = null;
529+ if (captured) send(captured);
530+ }
531+
532+ // A discrete pointer event must never be overtaken by an older deferred
533+ // move: flush the pending move first so consumers observe event order.
534+ function dispatchDiscretePointer(event, action, nativeEvent) {
535+ const payload = pointerPayload(event, action);
536+ if (!payload) return;
537+ if (pendingPointer) flushPointer();
538+ dispatch(payload, nativeEvent);
539+ }
540+
541+ function scheduleResize() {
542+ if (resizeFrame || width === 0 || height === 0) return;
543+ resizeFrame = requestAnimationFrame(() => {
544+ resizeFrame = 0;
545+ const bounds = viewport.getBoundingClientRect();
546+ const cellWidth = bounds.width / width;
547+ const cellHeight = bounds.height / height;
548+ if (bounds.width <= 0 || bounds.height <= 0 || cellWidth <= 0 || cellHeight <= 0) return;
549+ const payload = {
550+ type: 'resize', columns: width, rows: height,
551+ pixelWidth: bounds.width, pixelHeight: bounds.height,
552+ cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio
553+ };
554+ const identity = JSON.stringify(payload);
555+ if (identity !== lastResize) {
556+ lastResize = identity;
557+ dispatch(payload);
558+ }
559+ });
560+ }
561+
562+ input.addEventListener('beforeinput', event => {
563+ if (composing || event.isComposing || event.inputType === 'insertCompositionText' || event.inputType === 'insertFromPaste') return;
564+ const editingKey = { deleteContentBackward: 'Backspace', deleteContentForward: 'Delete', insertLineBreak: 'Enter', insertParagraph: 'Enter' }[event.inputType];
565+ if (editingKey) {
566+ if (editingKeyDown === editingKey) return;
567+ dispatch({ type: 'key', key: editingKey, code: editingKey, repeat: false, location: 0, alt: false, ctrl: false, meta: false, shift: false }, event);
568+ return;
569+ }
570+ if (!event.data) return;
571+ if (encoder.encode(event.data).byteLength > configured.maxTextBytes) {
572+ event.preventDefault();
573+ return;
574+ }
575+ dispatch({ type: 'text', text: event.data }, event);
576+ }, { signal });
577+ input.addEventListener('input', event => {
578+ if (!composing && !event.isComposing) input.value = '';
579+ }, { signal });
580+ input.addEventListener('compositionstart', () => { composing = true; }, { signal });
581+ input.addEventListener('compositionend', event => {
582+ composing = false;
583+ input.value = '';
584+ if (event.data && encoder.encode(event.data).byteLength <= configured.maxTextBytes) {
585+ dispatch({ type: 'text', text: event.data }, event);
586+ }
587+ }, { signal });
588+ input.addEventListener('keydown', event => {
589+ if (composing || event.isComposing) return;
590+ const key = event.key || 'Unidentified';
591+ editingKeyDown = ['Backspace', 'Delete', 'Enter'].includes(key) ? key : null;
592+ const code = event.code || 'Unidentified';
593+ if (key === 'Unidentified' || key === 'Process') return;
594+ const nonText = key.length !== 1 || event.ctrlKey || event.altKey || event.metaKey;
595+ if (!nonText) return;
596+ if (key.length > 128 || code.length > 128) {
597+ event.preventDefault();
598+ return;
599+ }
600+ dispatch({ type: 'key', key, code, repeat: event.repeat, location: event.location, ...modifierFields(event) }, event);
601+ }, { signal });
602+ input.addEventListener('keyup', () => { editingKeyDown = null; }, { signal });
603+ input.addEventListener('blur', () => { editingKeyDown = null; }, { signal });
604+ input.addEventListener('paste', event => {
605+ const text = event.clipboardData?.getData('text/plain') || '';
606+ if (encoder.encode(text).byteLength > configured.maxPasteBytes) {
607+ event.preventDefault();
608+ return;
609+ }
610+ dispatch({ type: 'paste', text }, event);
611+ }, { signal });
612+ input.addEventListener('focus', event => dispatch({ type: 'focus', focused: true }, event), { signal });
613+ input.addEventListener('blur', event => dispatch({ type: 'focus', focused: false }, event), { signal });
614+
615+ viewport.addEventListener('pointerdown', event => {
616+ if (mouseCapture && viewport.setPointerCapture) {
617+ try {
618+ if (event.isPrimary !== false) viewport.setPointerCapture(event.pointerId);
619+ } catch {
620+ // Pointer capture is best-effort; ordering still holds.
621+ }
622+ }
623+ dispatchDiscretePointer(event, 'down', event);
624+ }, { signal });
625+ viewport.addEventListener('pointerup', event => {
626+ dispatchDiscretePointer(event, 'up', event);
627+ }, { signal });
628+ viewport.addEventListener('pointercancel', event => {
629+ dispatchDiscretePointer(event, 'cancel', event);
630+ }, { signal });
631+ viewport.addEventListener('pointermove', event => {
632+ const payload = pointerPayload(event, 'move');
633+ if (!payload) return;
634+ const captured = capture(payload, event);
635+ if (!captured || destroyed) return;
636+ pendingPointer = captured;
637+ if (!pointerFrame) pointerFrame = requestAnimationFrame(flushPointer);
638+ }, { signal });
639+ viewport.addEventListener('wheel', event => {
640+ const invalidDelta = !Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY);
641+ const invalidMode = !Number.isInteger(event.deltaMode) || event.deltaMode < 0 || event.deltaMode > 2;
642+ if (invalidDelta || invalidMode) return;
643+ const { row, column } = position(event);
644+ dispatch({ type: 'wheel', deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, row, column, ...modifierFields(event) }, event);
645+ }, { signal, passive: false });
646+
647+ const observer = new ResizeObserver(scheduleResize);
648+ observer.observe(root);
649+
650+ return Object.freeze({
651+ apply,
652+ focus() { alive(); input.focus(); },
653+ setMouseCapture(enabled) {
654+ alive();
655+ mouseCapture = Boolean(enabled);
656+ root.classList.toggle('vev-terminal--mouse-capture', mouseCapture);
657+ },
658+ setTheme(theme) {
659+ alive();
660+ const value = validateTheme(theme);
661+ setOwnedProperty('--vev-fg', rgbValue(value.foreground));
662+ setOwnedProperty('--vev-bg', rgbValue(value.background));
663+ setOwnedProperty('--vev-cursor', rgbValue(value.cursor));
664+ setOwnedProperty('--vev-selection-bg', rgbValue(value.selection));
665+ setOwnedProperty('--vev-selection-fg', rgbValue(value.selectionText));
666+ value.palette.forEach((color, index) => setOwnedProperty(`--vev-color-${index}`, rgbValue(color)));
667+ },
668+ destroy() {
669+ if (destroyed) return;
670+ destroyed = true;
671+ abort.abort();
672+ observer.disconnect();
673+ if (resizeFrame) cancelAnimationFrame(resizeFrame);
674+ if (pointerFrame) cancelAnimationFrame(pointerFrame);
675+ input.value = '';
676+ accessible.textContent = '';
677+ viewport.replaceChildren();
678+ input.remove();
679+ viewport.remove();
680+ accessible.remove();
681+ rowNodes = [];
682+ rowText = [];
683+ rowBytes = [];
684+ retainedBytes = 0;
685+ previousRows = [];
686+ previousStyles = '';
687+ pendingPointer = null;
688+ width = 0;
689+ height = 0;
690+ lastResize = '';
691+ decide = () => ({ emit: false, preventDefault: false });
692+ send = () => {};
693+ for (const [name, previous] of previousProperties) {
694+ if (previous.value === '') root.style.removeProperty(name);
695+ else root.style.setProperty(name, previous.value, previous.priority);
696+ }
697+ previousProperties.clear();
698+ root.classList.remove('vev-terminal', 'vev-terminal--mouse-capture');
699+ if (previousRole === null) root.removeAttribute('role');
700+ else root.setAttribute('role', previousRole);
701+ if (previousLabel === null) root.removeAttribute('aria-label');
702+ else root.setAttribute('aria-label', previousLabel);
703+ }
704+ });
705+ }
706+
707+ globalThis.VevTerminal = Object.freeze({ schemaVersion: SCHEMA_VERSION, mount });
708+})();
A html/consumer_external_test.go +30 -0
@@ -0,0 +1,30 @@
1+package html_test
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/core"
8+ htmlrenderer "github.com/bnema/vev-vt/html"
9+)
10+
11+func TestExternalHTMLConsumerUsesOnlyCore(t *testing.T) {
12+ frame := core.NewFrame(2, 1)
13+ frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()})
14+ frame.Set(1, 0, core.Cell{Rune: 'K', Style: core.DefaultStyle()})
15+
16+ renderer, err := htmlrenderer.New(htmlrenderer.Options{})
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{})
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if !json.Valid(prepared.JSON()) {
25+ t.Fatalf("prepared JSON is invalid: %q", prepared.JSON())
26+ }
27+ if err := prepared.Commit(); err != nil {
28+ t.Fatal(err)
29+ }
30+}
@@ -0,0 +1,30 @@
1+package html_test
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/core"
8+ htmlrenderer "github.com/bnema/vev-vt/html"
9+)
10+
11+func TestExternalHTMLConsumerUsesOnlyCore(t *testing.T) {
12+ frame := core.NewFrame(2, 1)
13+ frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()})
14+ frame.Set(1, 0, core.Cell{Rune: 'K', Style: core.DefaultStyle()})
15+
16+ renderer, err := htmlrenderer.New(htmlrenderer.Options{})
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ prepared, err := renderer.Prepare(frame, nil, false, htmlrenderer.Cursor{})
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ if !json.Valid(prepared.JSON()) {
25+ t.Fatalf("prepared JSON is invalid: %q", prepared.JSON())
26+ }
27+ if err := prepared.Commit(); err != nil {
28+ t.Fatal(err)
29+ }
30+}
A html/doc.go +18 -0
@@ -0,0 +1,18 @@
1+// Package html prepares safe, transactional browser updates from the
2+// frontend-neutral github.com/bnema/vev-vt/core model.
3+//
4+// Renderer state is single-owner and is not internally synchronized. Prepare
5+// allows exactly one outstanding draw. The consumer must call Commit only
6+// after accepting the immutable update, or Abort to preserve the committed
7+// shadow. Reset invalidates any outstanding draw.
8+//
9+// Damage values are non-authoritative hints: every row is compared with the
10+// committed shadow, so mutations outside reported damage remain observable.
11+// Updates replace complete rows to preserve wide-cell atomicity. Scroll damage
12+// uses a full snapshot until browser measurements justify a more complex plan.
13+//
14+// The package emits typed data and structural CSS; it never converts terminal
15+// text to markup. Package browser owns safe DOM application and neutral input
16+// events. HTTP, WebSocket, sessions, PTY encoding, and application policy are
17+// consumer responsibilities.
18+package html
@@ -0,0 +1,18 @@
1+// Package html prepares safe, transactional browser updates from the
2+// frontend-neutral github.com/bnema/vev-vt/core model.
3+//
4+// Renderer state is single-owner and is not internally synchronized. Prepare
5+// allows exactly one outstanding draw. The consumer must call Commit only
6+// after accepting the immutable update, or Abort to preserve the committed
7+// shadow. Reset invalidates any outstanding draw.
8+//
9+// Damage values are non-authoritative hints: every row is compared with the
10+// committed shadow, so mutations outside reported damage remain observable.
11+// Updates replace complete rows to preserve wide-cell atomicity. Scroll damage
12+// uses a full snapshot until browser measurements justify a more complex plan.
13+//
14+// The package emits typed data and structural CSS; it never converts terminal
15+// text to markup. Package browser owns safe DOM application and neutral input
16+// events. HTTP, WebSocket, sessions, PTY encoding, and application policy are
17+// consumer responsibilities.
18+package html
A html/import_boundary_test.go +32 -0
@@ -0,0 +1,32 @@
1+package html_test
2+
3+import (
4+ "os/exec"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestHTMLProductionImportBoundary(t *testing.T) {
10+ command := exec.Command("go", "list", "-deps", "./...")
11+ output, err := command.CombinedOutput()
12+ if err != nil {
13+ t.Fatalf("go list HTML dependencies: %v\n%s", err, output)
14+ }
15+ for _, dependency := range strings.Fields(string(output)) {
16+ if dependency == "net/http" || strings.Contains(dependency, "/websocket") ||
17+ dependency == "github.com/bnema/vev" || strings.HasPrefix(dependency, "github.com/bnema/vev/") {
18+ t.Errorf("forbidden HTML production dependency %q", dependency)
19+ }
20+ }
21+
22+ command = exec.Command("go", "list", "-deps", "github.com/bnema/vev-vt/core")
23+ output, err = command.CombinedOutput()
24+ if err != nil {
25+ t.Fatalf("go list core dependencies: %v\n%s", err, output)
26+ }
27+ for _, dependency := range strings.Fields(string(output)) {
28+ if dependency == "github.com/bnema/vev-vt/html" || strings.HasPrefix(dependency, "github.com/bnema/vev-vt/html/") {
29+ t.Errorf("core imports concrete frontend %q", dependency)
30+ }
31+ }
32+}
@@ -0,0 +1,32 @@
1+package html_test
2+
3+import (
4+ "os/exec"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestHTMLProductionImportBoundary(t *testing.T) {
10+ command := exec.Command("go", "list", "-deps", "./...")
11+ output, err := command.CombinedOutput()
12+ if err != nil {
13+ t.Fatalf("go list HTML dependencies: %v\n%s", err, output)
14+ }
15+ for _, dependency := range strings.Fields(string(output)) {
16+ if dependency == "net/http" || strings.Contains(dependency, "/websocket") ||
17+ dependency == "github.com/bnema/vev" || strings.HasPrefix(dependency, "github.com/bnema/vev/") {
18+ t.Errorf("forbidden HTML production dependency %q", dependency)
19+ }
20+ }
21+
22+ command = exec.Command("go", "list", "-deps", "github.com/bnema/vev-vt/core")
23+ output, err = command.CombinedOutput()
24+ if err != nil {
25+ t.Fatalf("go list core dependencies: %v\n%s", err, output)
26+ }
27+ for _, dependency := range strings.Fields(string(output)) {
28+ if dependency == "github.com/bnema/vev-vt/html" || strings.HasPrefix(dependency, "github.com/bnema/vev-vt/html/") {
29+ t.Errorf("core imports concrete frontend %q", dependency)
30+ }
31+ }
32+}
A html/limits.go +69 -0
@@ -0,0 +1,69 @@
1+package html
2+
3+import (
4+ "errors"
5+)
6+
7+const (
8+ defaultMaxCells = 1_000_000
9+ defaultMaxRowsPerUpdate = 10_000
10+ defaultMaxGeneratedBytes = 64 << 20
11+ defaultMaxStyles = 65_536
12+)
13+
14+var (
15+ // ErrPendingDraw reports an attempt to prepare while a draw is pending.
16+ ErrPendingDraw = errors.New("html: prepared draw already pending")
17+ // ErrFinalizedDraw reports a repeated commit or abort.
18+ ErrFinalizedDraw = errors.New("html: prepared draw already finalized")
19+ // ErrStaleDraw reports a nil draw or one invalidated by Reset.
20+ ErrStaleDraw = errors.New("html: prepared draw is stale")
21+ // ErrLimitExceeded reports a configured renderer bound violation.
22+ ErrLimitExceeded = errors.New("html: resource limit exceeded")
23+ // ErrInvalidLimits reports a negative limit field.
24+ ErrInvalidLimits = errors.New("html: limits must not be negative")
25+)
26+
27+// Limits bounds renderer work and retained update data. Zero values select
28+// documented safe defaults.
29+type Limits struct {
30+ MaxCells int
31+ MaxRowsPerUpdate int
32+ MaxGeneratedBytes int
33+ MaxStyles int
34+}
35+
36+// DefaultLimits returns the bounds used for zero-valued limit fields.
37+func DefaultLimits() Limits {
38+ return Limits{
39+ MaxCells: defaultMaxCells,
40+ MaxRowsPerUpdate: defaultMaxRowsPerUpdate,
41+ MaxGeneratedBytes: defaultMaxGeneratedBytes,
42+ MaxStyles: defaultMaxStyles,
43+ }
44+}
45+
46+// Options configures a Renderer for its lifetime.
47+type Options struct {
48+ Limits Limits
49+}
50+
51+func normalizeLimits(limits Limits) (Limits, error) {
52+ if limits.MaxCells < 0 || limits.MaxRowsPerUpdate < 0 || limits.MaxGeneratedBytes < 0 || limits.MaxStyles < 0 {
53+ return Limits{}, ErrInvalidLimits
54+ }
55+ defaults := DefaultLimits()
56+ if limits.MaxCells == 0 {
57+ limits.MaxCells = defaults.MaxCells
58+ }
59+ if limits.MaxRowsPerUpdate == 0 {
60+ limits.MaxRowsPerUpdate = defaults.MaxRowsPerUpdate
61+ }
62+ if limits.MaxGeneratedBytes == 0 {
63+ limits.MaxGeneratedBytes = defaults.MaxGeneratedBytes
64+ }
65+ if limits.MaxStyles == 0 {
66+ limits.MaxStyles = defaults.MaxStyles
67+ }
68+ return limits, nil
69+}
@@ -0,0 +1,69 @@
1+package html
2+
3+import (
4+ "errors"
5+)
6+
7+const (
8+ defaultMaxCells = 1_000_000
9+ defaultMaxRowsPerUpdate = 10_000
10+ defaultMaxGeneratedBytes = 64 << 20
11+ defaultMaxStyles = 65_536
12+)
13+
14+var (
15+ // ErrPendingDraw reports an attempt to prepare while a draw is pending.
16+ ErrPendingDraw = errors.New("html: prepared draw already pending")
17+ // ErrFinalizedDraw reports a repeated commit or abort.
18+ ErrFinalizedDraw = errors.New("html: prepared draw already finalized")
19+ // ErrStaleDraw reports a nil draw or one invalidated by Reset.
20+ ErrStaleDraw = errors.New("html: prepared draw is stale")
21+ // ErrLimitExceeded reports a configured renderer bound violation.
22+ ErrLimitExceeded = errors.New("html: resource limit exceeded")
23+ // ErrInvalidLimits reports a negative limit field.
24+ ErrInvalidLimits = errors.New("html: limits must not be negative")
25+)
26+
27+// Limits bounds renderer work and retained update data. Zero values select
28+// documented safe defaults.
29+type Limits struct {
30+ MaxCells int
31+ MaxRowsPerUpdate int
32+ MaxGeneratedBytes int
33+ MaxStyles int
34+}
35+
36+// DefaultLimits returns the bounds used for zero-valued limit fields.
37+func DefaultLimits() Limits {
38+ return Limits{
39+ MaxCells: defaultMaxCells,
40+ MaxRowsPerUpdate: defaultMaxRowsPerUpdate,
41+ MaxGeneratedBytes: defaultMaxGeneratedBytes,
42+ MaxStyles: defaultMaxStyles,
43+ }
44+}
45+
46+// Options configures a Renderer for its lifetime.
47+type Options struct {
48+ Limits Limits
49+}
50+
51+func normalizeLimits(limits Limits) (Limits, error) {
52+ if limits.MaxCells < 0 || limits.MaxRowsPerUpdate < 0 || limits.MaxGeneratedBytes < 0 || limits.MaxStyles < 0 {
53+ return Limits{}, ErrInvalidLimits
54+ }
55+ defaults := DefaultLimits()
56+ if limits.MaxCells == 0 {
57+ limits.MaxCells = defaults.MaxCells
58+ }
59+ if limits.MaxRowsPerUpdate == 0 {
60+ limits.MaxRowsPerUpdate = defaults.MaxRowsPerUpdate
61+ }
62+ if limits.MaxGeneratedBytes == 0 {
63+ limits.MaxGeneratedBytes = defaults.MaxGeneratedBytes
64+ }
65+ if limits.MaxStyles == 0 {
66+ limits.MaxStyles = defaults.MaxStyles
67+ }
68+ return limits, nil
69+}
A html/renderer.go +435 -0
@@ -0,0 +1,435 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "math"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+type Renderer struct {
13+ limits Limits
14+ generation uint64
15+ pending *transaction
16+ committed core.Frame
17+ cursor Cursor
18+ initialized bool
19+}
20+
21+// CellSource is the read-only semantic grid consumed by HTML rendering.
22+type CellSource = core.CellSource
23+
24+type transactionState uint8
25+
26+const (
27+ transactionPending transactionState = iota
28+ transactionCommitted
29+ transactionAborted
30+)
31+
32+type transaction struct {
33+ renderer *Renderer
34+ generation uint64
35+ state transactionState
36+ update Update
37+ json []byte
38+ frame core.Frame
39+ cursor Cursor
40+}
41+
42+// PreparedDraw owns one immutable update and its speculative renderer state.
43+type PreparedDraw struct {
44+ tx *transaction
45+}
46+
47+func New(options Options) (*Renderer, error) {
48+ limits, err := normalizeLimits(options.Limits)
49+ if err != nil {
50+ return nil, err
51+ }
52+ return &Renderer{limits: limits, generation: 1}, nil
53+}
54+
55+// Prepare validates and encodes a speculative update. The renderer advances
56+// only after Commit. Abort preserves the previous committed shadow.
57+func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) {
58+ if r == nil {
59+ return nil, fmt.Errorf("html: nil renderer")
60+ }
61+ if r.pending != nil {
62+ return nil, ErrPendingDraw
63+ }
64+ frame, err := materializeCellSource(source)
65+ if err != nil {
66+ return nil, err
67+ }
68+ if err := r.validateFrame(frame); err != nil {
69+ return nil, err
70+ }
71+ cursor = normalizeWrapPendingCursor(cursor, frame.Width)
72+ if err := validateCursor(cursor, frame.Width, frame.Height); err != nil {
73+ return nil, err
74+ }
75+
76+ snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
77+ candidate := frame.Clone()
78+ update, err := r.buildUpdate(candidate, snapshot, cursor)
79+ if err != nil {
80+ return nil, err
81+ }
82+ encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
83+ if err != nil {
84+ return nil, err
85+ }
86+
87+ tx := &transaction{
88+ renderer: r,
89+ generation: r.generation,
90+ state: transactionPending,
91+ update: update,
92+ json: encoded,
93+ frame: candidate,
94+ cursor: cursor,
95+ }
96+ r.pending = tx
97+ return &PreparedDraw{tx: tx}, nil
98+}
99+
100+func (r *Renderer) validateFrame(frame core.Frame) error {
101+ if frame.Width <= 0 || frame.Height <= 0 {
102+ return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)
103+ }
104+ if frame.Width > math.MaxInt/frame.Height {
105+ return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)
106+ }
107+ cells := frame.Width * frame.Height
108+ if cells > r.limits.MaxCells {
109+ return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)
110+ }
111+ if frame.Height > r.limits.MaxRowsPerUpdate {
112+ return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)
113+ }
114+ if err := frame.Validate(); err != nil {
115+ return fmt.Errorf("html: validate frame: %w", err)
116+ }
117+ for y := range frame.Height {
118+ row := frame.Row(y)
119+ for x := 0; x < frame.Width; x++ {
120+ cell := row[x]
121+ if err := validateCoreStyle(cell.Style); err != nil {
122+ return fmt.Errorf("html: cell (%d,%d): %w", x, y, err)
123+ }
124+ if cell.Continuation {
125+ if cell.Rune != 0 {
126+ return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)
127+ }
128+ if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {
129+ return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)
130+ }
131+ if !cell.Style.Equal(row[x-1].Style) {
132+ return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)
133+ }
134+ continue
135+ }
136+ if cell.Rune == 0 {
137+ continue
138+ }
139+ if !utf8.ValidRune(cell.Rune) {
140+ return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)
141+ }
142+ width := core.RuneWidth(cell.Rune)
143+ switch width {
144+ case 1:
145+ case 2:
146+ if x+1 >= frame.Width || !row[x+1].Continuation {
147+ return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)
148+ }
149+ case 0:
150+ return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)
151+ default:
152+ return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)
153+ }
154+ }
155+ }
156+ return nil
157+}
158+
159+// materializeCellSource reads a source into an owned frame. A core.Frame
160+// takes the fast clone path; any other source is copied cell by cell.
161+func materializeCellSource(source CellSource) (core.Frame, error) {
162+ if source == nil {
163+ return core.Frame{}, fmt.Errorf("html: nil cell source")
164+ }
165+ switch frame := source.(type) {
166+ case core.Frame:
167+ if err := frame.Validate(); err != nil {
168+ return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
169+ }
170+ return frame.Clone(), nil
171+ case *core.Frame:
172+ if frame == nil {
173+ return core.Frame{}, fmt.Errorf("html: nil cell source")
174+ }
175+ if err := frame.Validate(); err != nil {
176+ return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
177+ }
178+ return frame.Clone(), nil
179+ }
180+ width, height := source.Columns(), source.Rows()
181+ if width <= 0 || height <= 0 {
182+ return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height)
183+ }
184+ clone := core.NewFrame(width, height)
185+ for y := range height {
186+ for x := range width {
187+ clone.Set(x, y, source.Cell(x, y))
188+ }
189+ }
190+ return clone, nil
191+}
192+
193+// normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
194+// produced after writing the last column with autowrap enabled onto the
195+// last visible column. It leaves every in-bounds cursor untouched.
196+func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor {
197+ if cursor.Column == width && cursor.Row >= 0 {
198+ cursor.Column = width - 1
199+ }
200+ return cursor
201+}
202+
203+func validateCursor(cursor Cursor, width, height int) error {
204+ if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width {
205+ return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height)
206+ }
207+ if cursor.Style > 6 {
208+ return fmt.Errorf("html: invalid cursor style %d", cursor.Style)
209+ }
210+ if !cursor.StyleSet && cursor.Style != 0 {
211+ return fmt.Errorf("html: cursor style must be zero when unset")
212+ }
213+ return nil
214+}
215+
216+func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
217+ for _, item := range damage {
218+ switch item.Kind {
219+ case core.DamageText, core.DamageClear:
220+ if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height {
221+ return true
222+ }
223+ case core.DamageScrollUp, core.DamageFullRedraw:
224+ return true
225+ default:
226+ return true
227+ }
228+ }
229+ return false
230+}
231+
232+func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) {
233+ update := Update{
234+ SchemaVersion: UpdateSchemaVersion,
235+ Width: frame.Width,
236+ Height: frame.Height,
237+ Snapshot: snapshot,
238+ Rows: make([]RowUpdate, 0),
239+ Styles: make([]Style, 0),
240+ Cursor: cursor,
241+ }
242+ styleIDs := make(map[Style]int)
243+ for y := range frame.Height {
244+ if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) {
245+ continue
246+ }
247+ if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
248+ return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
249+ }
250+ row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles)
251+ if err != nil {
252+ return Update{}, err
253+ }
254+ update.Rows = append(update.Rows, row)
255+ }
256+ return update, nil
257+}
258+
259+// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on
260+// the exact encoded size. The previous conservative estimate could reject
261+// compact updates well below the configured limit.
262+func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
263+ encoded, err := json.Marshal(update)
264+ if err != nil {
265+ return nil, fmt.Errorf("html: encode update: %w", err)
266+ }
267+ if len(encoded) > limit {
268+ return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit)
269+ }
270+ return encoded, nil
271+}
272+
273+func rowsEqual(left, right []core.Cell) bool {
274+ if len(left) != len(right) {
275+ return false
276+ }
277+ for i := range left {
278+ if !left[i].Equal(right[i]) {
279+ return false
280+ }
281+ }
282+ return true
283+}
284+
285+func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) {
286+ row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))}
287+ for x := 0; x < len(cells); x++ {
288+ cell := cells[x]
289+ if cell.Continuation {
290+ continue
291+ }
292+ style := styleFromCore(cell.Style)
293+ styleID, ok := styleIDs[style]
294+ if !ok {
295+ if len(*styles) >= maxStyles {
296+ return RowUpdate{}, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)
297+ }
298+ styleID = len(*styles)
299+ styleIDs[style] = styleID
300+ *styles = append(*styles, style)
301+ }
302+ width := core.RuneWidth(cell.Rune)
303+ text := string(cell.Rune)
304+ if cell.Rune == 0 {
305+ width = 1
306+ text = " "
307+ }
308+ row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID})
309+ }
310+ return row, nil
311+}
312+
313+func validateCoreStyle(style core.Style) error {
314+ const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
315+ if style.Attrs & ^knownAttrs != 0 {
316+ return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs)
317+ }
318+ if style.UnderlineStyle > core.UnderlineDashed {
319+ return fmt.Errorf("invalid underline style %d", style.UnderlineStyle)
320+ }
321+ if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) {
322+ return fmt.Errorf("invalid foreground index %d", style.Foreground)
323+ }
324+ if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) {
325+ return fmt.Errorf("invalid background index %d", style.Background)
326+ }
327+ if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) {
328+ return fmt.Errorf("invalid underline color index %d", style.UnderlineColor)
329+ }
330+ return nil
331+}
332+
333+func styleFromCore(style core.Style) Style {
334+ return Style{
335+ Bold: style.Bold,
336+ Italic: style.Italic,
337+ Inverse: style.Inverse,
338+ Dim: style.Attrs&core.AttrDim != 0,
339+ Blink: style.Attrs&core.AttrBlink != 0,
340+ Strikethrough: style.Attrs&core.AttrStrikethrough != 0,
341+ Underline: style.Attrs&core.AttrUnderline != 0,
342+ UnderlineStyle: style.UnderlineStyle,
343+ Foreground: colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB),
344+ Background: colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB),
345+ UnderlineColor: underlineColorFromCore(style),
346+ }
347+}
348+
349+// Prepare validates every indexed color before these uint8 conversions.
350+func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color {
351+ if hasRGB {
352+ return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}}
353+ }
354+ if index >= 0 {
355+ return Color{Kind: ColorIndexed, Index: uint8(index)}
356+ }
357+ return Color{Kind: ColorDefault}
358+}
359+
360+func underlineColorFromCore(style core.Style) Color {
361+ if style.HasUnderlineColorRGB {
362+ return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}}
363+ }
364+ if style.HasUnderlineColor {
365+ return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)}
366+ }
367+ return Color{Kind: ColorDefault}
368+}
369+
370+// Update returns an owned copy that remains valid after later renderer work.
371+func (p *PreparedDraw) Update() Update {
372+ if p == nil || p.tx == nil {
373+ return Update{}
374+ }
375+ return cloneUpdate(p.tx.update)
376+}
377+
378+// JSON returns an owned canonical JSON representation of Update.
379+func (p *PreparedDraw) JSON() []byte {
380+ if p == nil || p.tx == nil {
381+ return nil
382+ }
383+ return append([]byte(nil), p.tx.json...)
384+}
385+
386+// Commit atomically advances the renderer shadow exactly once.
387+func (p *PreparedDraw) Commit() error {
388+ if p == nil || p.tx == nil || p.tx.renderer == nil {
389+ return ErrStaleDraw
390+ }
391+ tx := p.tx
392+ if tx.state != transactionPending {
393+ return ErrFinalizedDraw
394+ }
395+ r := tx.renderer
396+ if tx.generation != r.generation || r.pending != tx {
397+ return ErrStaleDraw
398+ }
399+ r.committed = tx.frame
400+ r.cursor = tx.cursor
401+ r.initialized = true
402+ r.pending = nil
403+ tx.state = transactionCommitted
404+ return nil
405+}
406+
407+// Abort finalizes the transaction without advancing the renderer shadow.
408+func (p *PreparedDraw) Abort() error {
409+ if p == nil || p.tx == nil || p.tx.renderer == nil {
410+ return ErrStaleDraw
411+ }
412+ tx := p.tx
413+ if tx.state != transactionPending {
414+ return ErrFinalizedDraw
415+ }
416+ r := tx.renderer
417+ if tx.generation != r.generation || r.pending != tx {
418+ return ErrStaleDraw
419+ }
420+ r.pending = nil
421+ tx.state = transactionAborted
422+ return nil
423+}
424+
425+// Reset invalidates any prepared draw and clears committed state.
426+func (r *Renderer) Reset() {
427+ if r == nil {
428+ return
429+ }
430+ r.generation++
431+ r.pending = nil
432+ r.committed = core.Frame{}
433+ r.cursor = Cursor{}
434+ r.initialized = false
435+}
@@ -0,0 +1,435 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "math"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+type Renderer struct {
13+ limits Limits
14+ generation uint64
15+ pending *transaction
16+ committed core.Frame
17+ cursor Cursor
18+ initialized bool
19+}
20+
21+// CellSource is the read-only semantic grid consumed by HTML rendering.
22+type CellSource = core.CellSource
23+
24+type transactionState uint8
25+
26+const (
27+ transactionPending transactionState = iota
28+ transactionCommitted
29+ transactionAborted
30+)
31+
32+type transaction struct {
33+ renderer *Renderer
34+ generation uint64
35+ state transactionState
36+ update Update
37+ json []byte
38+ frame core.Frame
39+ cursor Cursor
40+}
41+
42+// PreparedDraw owns one immutable update and its speculative renderer state.
43+type PreparedDraw struct {
44+ tx *transaction
45+}
46+
47+func New(options Options) (*Renderer, error) {
48+ limits, err := normalizeLimits(options.Limits)
49+ if err != nil {
50+ return nil, err
51+ }
52+ return &Renderer{limits: limits, generation: 1}, nil
53+}
54+
55+// Prepare validates and encodes a speculative update. The renderer advances
56+// only after Commit. Abort preserves the previous committed shadow.
57+func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) {
58+ if r == nil {
59+ return nil, fmt.Errorf("html: nil renderer")
60+ }
61+ if r.pending != nil {
62+ return nil, ErrPendingDraw
63+ }
64+ frame, err := materializeCellSource(source)
65+ if err != nil {
66+ return nil, err
67+ }
68+ if err := r.validateFrame(frame); err != nil {
69+ return nil, err
70+ }
71+ cursor = normalizeWrapPendingCursor(cursor, frame.Width)
72+ if err := validateCursor(cursor, frame.Width, frame.Height); err != nil {
73+ return nil, err
74+ }
75+
76+ snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
77+ candidate := frame.Clone()
78+ update, err := r.buildUpdate(candidate, snapshot, cursor)
79+ if err != nil {
80+ return nil, err
81+ }
82+ encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
83+ if err != nil {
84+ return nil, err
85+ }
86+
87+ tx := &transaction{
88+ renderer: r,
89+ generation: r.generation,
90+ state: transactionPending,
91+ update: update,
92+ json: encoded,
93+ frame: candidate,
94+ cursor: cursor,
95+ }
96+ r.pending = tx
97+ return &PreparedDraw{tx: tx}, nil
98+}
99+
100+func (r *Renderer) validateFrame(frame core.Frame) error {
101+ if frame.Width <= 0 || frame.Height <= 0 {
102+ return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)
103+ }
104+ if frame.Width > math.MaxInt/frame.Height {
105+ return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)
106+ }
107+ cells := frame.Width * frame.Height
108+ if cells > r.limits.MaxCells {
109+ return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)
110+ }
111+ if frame.Height > r.limits.MaxRowsPerUpdate {
112+ return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)
113+ }
114+ if err := frame.Validate(); err != nil {
115+ return fmt.Errorf("html: validate frame: %w", err)
116+ }
117+ for y := range frame.Height {
118+ row := frame.Row(y)
119+ for x := 0; x < frame.Width; x++ {
120+ cell := row[x]
121+ if err := validateCoreStyle(cell.Style); err != nil {
122+ return fmt.Errorf("html: cell (%d,%d): %w", x, y, err)
123+ }
124+ if cell.Continuation {
125+ if cell.Rune != 0 {
126+ return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)
127+ }
128+ if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {
129+ return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)
130+ }
131+ if !cell.Style.Equal(row[x-1].Style) {
132+ return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)
133+ }
134+ continue
135+ }
136+ if cell.Rune == 0 {
137+ continue
138+ }
139+ if !utf8.ValidRune(cell.Rune) {
140+ return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)
141+ }
142+ width := core.RuneWidth(cell.Rune)
143+ switch width {
144+ case 1:
145+ case 2:
146+ if x+1 >= frame.Width || !row[x+1].Continuation {
147+ return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)
148+ }
149+ case 0:
150+ return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)
151+ default:
152+ return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)
153+ }
154+ }
155+ }
156+ return nil
157+}
158+
159+// materializeCellSource reads a source into an owned frame. A core.Frame
160+// takes the fast clone path; any other source is copied cell by cell.
161+func materializeCellSource(source CellSource) (core.Frame, error) {
162+ if source == nil {
163+ return core.Frame{}, fmt.Errorf("html: nil cell source")
164+ }
165+ switch frame := source.(type) {
166+ case core.Frame:
167+ if err := frame.Validate(); err != nil {
168+ return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
169+ }
170+ return frame.Clone(), nil
171+ case *core.Frame:
172+ if frame == nil {
173+ return core.Frame{}, fmt.Errorf("html: nil cell source")
174+ }
175+ if err := frame.Validate(); err != nil {
176+ return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
177+ }
178+ return frame.Clone(), nil
179+ }
180+ width, height := source.Columns(), source.Rows()
181+ if width <= 0 || height <= 0 {
182+ return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height)
183+ }
184+ clone := core.NewFrame(width, height)
185+ for y := range height {
186+ for x := range width {
187+ clone.Set(x, y, source.Cell(x, y))
188+ }
189+ }
190+ return clone, nil
191+}
192+
193+// normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
194+// produced after writing the last column with autowrap enabled onto the
195+// last visible column. It leaves every in-bounds cursor untouched.
196+func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor {
197+ if cursor.Column == width && cursor.Row >= 0 {
198+ cursor.Column = width - 1
199+ }
200+ return cursor
201+}
202+
203+func validateCursor(cursor Cursor, width, height int) error {
204+ if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width {
205+ return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height)
206+ }
207+ if cursor.Style > 6 {
208+ return fmt.Errorf("html: invalid cursor style %d", cursor.Style)
209+ }
210+ if !cursor.StyleSet && cursor.Style != 0 {
211+ return fmt.Errorf("html: cursor style must be zero when unset")
212+ }
213+ return nil
214+}
215+
216+func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
217+ for _, item := range damage {
218+ switch item.Kind {
219+ case core.DamageText, core.DamageClear:
220+ if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height {
221+ return true
222+ }
223+ case core.DamageScrollUp, core.DamageFullRedraw:
224+ return true
225+ default:
226+ return true
227+ }
228+ }
229+ return false
230+}
231+
232+func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) {
233+ update := Update{
234+ SchemaVersion: UpdateSchemaVersion,
235+ Width: frame.Width,
236+ Height: frame.Height,
237+ Snapshot: snapshot,
238+ Rows: make([]RowUpdate, 0),
239+ Styles: make([]Style, 0),
240+ Cursor: cursor,
241+ }
242+ styleIDs := make(map[Style]int)
243+ for y := range frame.Height {
244+ if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) {
245+ continue
246+ }
247+ if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
248+ return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
249+ }
250+ row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles)
251+ if err != nil {
252+ return Update{}, err
253+ }
254+ update.Rows = append(update.Rows, row)
255+ }
256+ return update, nil
257+}
258+
259+// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on
260+// the exact encoded size. The previous conservative estimate could reject
261+// compact updates well below the configured limit.
262+func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
263+ encoded, err := json.Marshal(update)
264+ if err != nil {
265+ return nil, fmt.Errorf("html: encode update: %w", err)
266+ }
267+ if len(encoded) > limit {
268+ return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit)
269+ }
270+ return encoded, nil
271+}
272+
273+func rowsEqual(left, right []core.Cell) bool {
274+ if len(left) != len(right) {
275+ return false
276+ }
277+ for i := range left {
278+ if !left[i].Equal(right[i]) {
279+ return false
280+ }
281+ }
282+ return true
283+}
284+
285+func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) {
286+ row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))}
287+ for x := 0; x < len(cells); x++ {
288+ cell := cells[x]
289+ if cell.Continuation {
290+ continue
291+ }
292+ style := styleFromCore(cell.Style)
293+ styleID, ok := styleIDs[style]
294+ if !ok {
295+ if len(*styles) >= maxStyles {
296+ return RowUpdate{}, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)
297+ }
298+ styleID = len(*styles)
299+ styleIDs[style] = styleID
300+ *styles = append(*styles, style)
301+ }
302+ width := core.RuneWidth(cell.Rune)
303+ text := string(cell.Rune)
304+ if cell.Rune == 0 {
305+ width = 1
306+ text = " "
307+ }
308+ row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID})
309+ }
310+ return row, nil
311+}
312+
313+func validateCoreStyle(style core.Style) error {
314+ const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
315+ if style.Attrs & ^knownAttrs != 0 {
316+ return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs)
317+ }
318+ if style.UnderlineStyle > core.UnderlineDashed {
319+ return fmt.Errorf("invalid underline style %d", style.UnderlineStyle)
320+ }
321+ if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) {
322+ return fmt.Errorf("invalid foreground index %d", style.Foreground)
323+ }
324+ if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) {
325+ return fmt.Errorf("invalid background index %d", style.Background)
326+ }
327+ if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) {
328+ return fmt.Errorf("invalid underline color index %d", style.UnderlineColor)
329+ }
330+ return nil
331+}
332+
333+func styleFromCore(style core.Style) Style {
334+ return Style{
335+ Bold: style.Bold,
336+ Italic: style.Italic,
337+ Inverse: style.Inverse,
338+ Dim: style.Attrs&core.AttrDim != 0,
339+ Blink: style.Attrs&core.AttrBlink != 0,
340+ Strikethrough: style.Attrs&core.AttrStrikethrough != 0,
341+ Underline: style.Attrs&core.AttrUnderline != 0,
342+ UnderlineStyle: style.UnderlineStyle,
343+ Foreground: colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB),
344+ Background: colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB),
345+ UnderlineColor: underlineColorFromCore(style),
346+ }
347+}
348+
349+// Prepare validates every indexed color before these uint8 conversions.
350+func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color {
351+ if hasRGB {
352+ return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}}
353+ }
354+ if index >= 0 {
355+ return Color{Kind: ColorIndexed, Index: uint8(index)}
356+ }
357+ return Color{Kind: ColorDefault}
358+}
359+
360+func underlineColorFromCore(style core.Style) Color {
361+ if style.HasUnderlineColorRGB {
362+ return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}}
363+ }
364+ if style.HasUnderlineColor {
365+ return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)}
366+ }
367+ return Color{Kind: ColorDefault}
368+}
369+
370+// Update returns an owned copy that remains valid after later renderer work.
371+func (p *PreparedDraw) Update() Update {
372+ if p == nil || p.tx == nil {
373+ return Update{}
374+ }
375+ return cloneUpdate(p.tx.update)
376+}
377+
378+// JSON returns an owned canonical JSON representation of Update.
379+func (p *PreparedDraw) JSON() []byte {
380+ if p == nil || p.tx == nil {
381+ return nil
382+ }
383+ return append([]byte(nil), p.tx.json...)
384+}
385+
386+// Commit atomically advances the renderer shadow exactly once.
387+func (p *PreparedDraw) Commit() error {
388+ if p == nil || p.tx == nil || p.tx.renderer == nil {
389+ return ErrStaleDraw
390+ }
391+ tx := p.tx
392+ if tx.state != transactionPending {
393+ return ErrFinalizedDraw
394+ }
395+ r := tx.renderer
396+ if tx.generation != r.generation || r.pending != tx {
397+ return ErrStaleDraw
398+ }
399+ r.committed = tx.frame
400+ r.cursor = tx.cursor
401+ r.initialized = true
402+ r.pending = nil
403+ tx.state = transactionCommitted
404+ return nil
405+}
406+
407+// Abort finalizes the transaction without advancing the renderer shadow.
408+func (p *PreparedDraw) Abort() error {
409+ if p == nil || p.tx == nil || p.tx.renderer == nil {
410+ return ErrStaleDraw
411+ }
412+ tx := p.tx
413+ if tx.state != transactionPending {
414+ return ErrFinalizedDraw
415+ }
416+ r := tx.renderer
417+ if tx.generation != r.generation || r.pending != tx {
418+ return ErrStaleDraw
419+ }
420+ r.pending = nil
421+ tx.state = transactionAborted
422+ return nil
423+}
424+
425+// Reset invalidates any prepared draw and clears committed state.
426+func (r *Renderer) Reset() {
427+ if r == nil {
428+ return
429+ }
430+ r.generation++
431+ r.pending = nil
432+ r.committed = core.Frame{}
433+ r.cursor = Cursor{}
434+ r.initialized = false
435+}
A html/renderer_test.go +230 -0
@@ -0,0 +1,230 @@
1+package html
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRendererSnapshotCommitsTransactionally(t *testing.T) {
13+ frame := core.NewFrame(3, 1)
14+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
15+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
16+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
17+
18+ renderer, err := New(Options{})
19+ require.NoError(t, err)
20+
21+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
22+ require.NoError(t, err)
23+ require.True(t, prepared.Update().Snapshot)
24+ require.Len(t, prepared.Update().Rows, 1)
25+ require.Equal(t, []CellUpdate{
26+ {Column: 0, Width: 1, Text: "A", Style: 0},
27+ {Column: 1, Width: 2, Text: "界", Style: 0},
28+ }, prepared.Update().Rows[0].Cells)
29+ require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`)
30+
31+ _, err = renderer.Prepare(frame, nil, false, Cursor{})
32+ require.ErrorIs(t, err, ErrPendingDraw)
33+ require.NoError(t, prepared.Commit())
34+ require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw)
35+
36+ unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
37+ require.NoError(t, err)
38+ require.False(t, unchanged.Update().Snapshot)
39+ require.Empty(t, unchanged.Update().Rows)
40+ require.NoError(t, unchanged.Abort())
41+ require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw)
42+
43+ var nilDraw *PreparedDraw
44+ require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw))
45+}
46+
47+func TestPreparedJSONMatchesBrowserFixture(t *testing.T) {
48+ frame := core.NewFrame(3, 1)
49+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
50+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
51+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
52+ renderer, err := New(Options{})
53+ require.NoError(t, err)
54+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true})
55+ require.NoError(t, err)
56+ fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json")
57+ require.NoError(t, err)
58+ require.JSONEq(t, string(fixture), string(prepared.JSON()))
59+}
60+
61+func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) {
62+ frame := core.NewFrame(2, 1)
63+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
64+ snapshot := frame.Clone()
65+
66+ renderer, err := New(Options{})
67+ require.NoError(t, err)
68+ prepared, err := renderer.Prepare(cellSourceFunc{
69+ columns: snapshot.Columns(),
70+ rows: snapshot.Rows(),
71+ cell: snapshot.Cell,
72+ }, nil, false, Cursor{})
73+ require.NoError(t, err)
74+ require.True(t, prepared.Update().Snapshot)
75+ require.Equal(t, "A", prepared.Update().Rows[0].Cells[0].Text)
76+ require.NoError(t, prepared.Commit())
77+
78+ _, err = renderer.Prepare(nil, nil, false, Cursor{})
79+ require.ErrorContains(t, err, "nil cell source")
80+}
81+
82+type cellSourceFunc struct {
83+ columns int
84+ rows int
85+ cell func(x, y int) core.Cell
86+}
87+
88+func (s cellSourceFunc) Columns() int { return s.columns }
89+func (s cellSourceFunc) Rows() int { return s.rows }
90+func (s cellSourceFunc) Cell(x, y int) core.Cell { return s.cell(x, y) }
91+func (s cellSourceFunc) At(x, y int) core.Cell { return s.cell(x, y) }
92+func (s cellSourceFunc) Row(y int) []core.Cell { return nil }
93+func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 }
94+
95+func TestRendererNormalizesWrapPendingCursor(t *testing.T) {
96+ frame := core.NewFrame(3, 2)
97+ renderer, err := New(Options{})
98+ require.NoError(t, err)
99+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true})
100+ require.NoError(t, err)
101+ require.Equal(t, 2, prepared.Update().Cursor.Column)
102+ require.NoError(t, prepared.Commit())
103+
104+ _, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4})
105+ require.ErrorContains(t, err, "outside")
106+}
107+
108+func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) {
109+ frame := core.NewFrame(1, 1)
110+ renderer, err := New(Options{})
111+ require.NoError(t, err)
112+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{})
113+ require.NoError(t, err)
114+ size := len(prepared.JSON())
115+ require.NoError(t, prepared.Commit())
116+
117+ tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}})
118+ require.NoError(t, err)
119+ accepted, err := tight.Prepare(frame, nil, true, Cursor{})
120+ require.NoError(t, err)
121+ require.NoError(t, accepted.Commit())
122+
123+ strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}})
124+ require.NoError(t, err)
125+ _, err = strict.Prepare(frame, nil, true, Cursor{})
126+ require.ErrorIs(t, err, ErrLimitExceeded)
127+ require.ErrorContains(t, err, "generated update is")
128+}
129+
130+func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) {
131+ frame := core.NewFrame(2, 2)
132+ renderer, err := New(Options{})
133+ require.NoError(t, err)
134+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
135+ require.NoError(t, err)
136+ require.NoError(t, first.Commit())
137+
138+ frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
139+ prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{})
140+ require.NoError(t, err)
141+ require.False(t, prepared.Update().Snapshot)
142+ require.NoError(t, prepared.Commit())
143+}
144+
145+func TestRendererFindsChangesOutsideDamage(t *testing.T) {
146+ frame := core.NewFrame(2, 2)
147+ renderer, err := New(Options{})
148+ require.NoError(t, err)
149+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
150+ require.NoError(t, err)
151+ require.NoError(t, first.Commit())
152+
153+ frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
154+ prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{})
155+ require.NoError(t, err)
156+ require.False(t, prepared.Update().Snapshot)
157+ require.Equal(t, 1, prepared.Update().Rows[0].Row)
158+ require.NoError(t, prepared.Commit())
159+}
160+
161+func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) {
162+ frame := core.NewFrame(2, 2)
163+ renderer, err := New(Options{})
164+ require.NoError(t, err)
165+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
166+ require.NoError(t, err)
167+ require.NoError(t, first.Commit())
168+
169+ cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true})
170+ require.NoError(t, err)
171+ require.Empty(t, cursorOnly.Update().Rows)
172+ require.NotNil(t, cursorOnly.Update().Rows)
173+ require.NotNil(t, cursorOnly.Update().Styles)
174+ require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`)
175+ require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`)
176+ require.Equal(t, 1, cursorOnly.Update().Cursor.Row)
177+ require.NoError(t, cursorOnly.Commit())
178+
179+ scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{})
180+ require.NoError(t, err)
181+ require.True(t, scroll.Update().Snapshot)
182+ require.Len(t, scroll.Update().Rows, 2)
183+ require.NoError(t, scroll.Abort())
184+}
185+
186+func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) {
187+ frame := core.NewFrame(1, 1)
188+ renderer, err := New(Options{})
189+ require.NoError(t, err)
190+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
191+ require.NoError(t, err)
192+
193+ update := prepared.Update()
194+ encoded := prepared.JSON()
195+ wantJSON := string(encoded)
196+ update.Rows[0].Cells[0].Text = "mutated"
197+ encoded[0] = 'X'
198+ require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text)
199+ require.JSONEq(t, wantJSON, string(prepared.JSON()))
200+ require.Equal(t, byte('{'), prepared.JSON()[0])
201+}
202+
203+func TestCopiedPreparedDrawSharesFinalization(t *testing.T) {
204+ frame := core.NewFrame(1, 1)
205+ renderer, err := New(Options{})
206+ require.NoError(t, err)
207+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
208+ require.NoError(t, err)
209+ copied := *prepared
210+ require.NoError(t, copied.Commit())
211+ require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw)
212+}
213+
214+func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) {
215+ frame := core.NewFrame(1, 1)
216+ renderer, err := New(Options{})
217+ require.NoError(t, err)
218+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
219+ require.NoError(t, err)
220+ copied := *prepared
221+
222+ renderer.Reset()
223+ require.ErrorIs(t, prepared.Commit(), ErrStaleDraw)
224+ require.ErrorIs(t, copied.Abort(), ErrStaleDraw)
225+
226+ next, err := renderer.Prepare(frame, nil, false, Cursor{})
227+ require.NoError(t, err)
228+ require.True(t, next.Update().Snapshot)
229+ require.NoError(t, next.Commit())
230+}
@@ -0,0 +1,230 @@
1+package html
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRendererSnapshotCommitsTransactionally(t *testing.T) {
13+ frame := core.NewFrame(3, 1)
14+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
15+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
16+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
17+
18+ renderer, err := New(Options{})
19+ require.NoError(t, err)
20+
21+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
22+ require.NoError(t, err)
23+ require.True(t, prepared.Update().Snapshot)
24+ require.Len(t, prepared.Update().Rows, 1)
25+ require.Equal(t, []CellUpdate{
26+ {Column: 0, Width: 1, Text: "A", Style: 0},
27+ {Column: 1, Width: 2, Text: "界", Style: 0},
28+ }, prepared.Update().Rows[0].Cells)
29+ require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`)
30+
31+ _, err = renderer.Prepare(frame, nil, false, Cursor{})
32+ require.ErrorIs(t, err, ErrPendingDraw)
33+ require.NoError(t, prepared.Commit())
34+ require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw)
35+
36+ unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
37+ require.NoError(t, err)
38+ require.False(t, unchanged.Update().Snapshot)
39+ require.Empty(t, unchanged.Update().Rows)
40+ require.NoError(t, unchanged.Abort())
41+ require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw)
42+
43+ var nilDraw *PreparedDraw
44+ require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw))
45+}
46+
47+func TestPreparedJSONMatchesBrowserFixture(t *testing.T) {
48+ frame := core.NewFrame(3, 1)
49+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
50+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
51+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
52+ renderer, err := New(Options{})
53+ require.NoError(t, err)
54+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true})
55+ require.NoError(t, err)
56+ fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json")
57+ require.NoError(t, err)
58+ require.JSONEq(t, string(fixture), string(prepared.JSON()))
59+}
60+
61+func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) {
62+ frame := core.NewFrame(2, 1)
63+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
64+ snapshot := frame.Clone()
65+
66+ renderer, err := New(Options{})
67+ require.NoError(t, err)
68+ prepared, err := renderer.Prepare(cellSourceFunc{
69+ columns: snapshot.Columns(),
70+ rows: snapshot.Rows(),
71+ cell: snapshot.Cell,
72+ }, nil, false, Cursor{})
73+ require.NoError(t, err)
74+ require.True(t, prepared.Update().Snapshot)
75+ require.Equal(t, "A", prepared.Update().Rows[0].Cells[0].Text)
76+ require.NoError(t, prepared.Commit())
77+
78+ _, err = renderer.Prepare(nil, nil, false, Cursor{})
79+ require.ErrorContains(t, err, "nil cell source")
80+}
81+
82+type cellSourceFunc struct {
83+ columns int
84+ rows int
85+ cell func(x, y int) core.Cell
86+}
87+
88+func (s cellSourceFunc) Columns() int { return s.columns }
89+func (s cellSourceFunc) Rows() int { return s.rows }
90+func (s cellSourceFunc) Cell(x, y int) core.Cell { return s.cell(x, y) }
91+func (s cellSourceFunc) At(x, y int) core.Cell { return s.cell(x, y) }
92+func (s cellSourceFunc) Row(y int) []core.Cell { return nil }
93+func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 }
94+
95+func TestRendererNormalizesWrapPendingCursor(t *testing.T) {
96+ frame := core.NewFrame(3, 2)
97+ renderer, err := New(Options{})
98+ require.NoError(t, err)
99+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true})
100+ require.NoError(t, err)
101+ require.Equal(t, 2, prepared.Update().Cursor.Column)
102+ require.NoError(t, prepared.Commit())
103+
104+ _, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4})
105+ require.ErrorContains(t, err, "outside")
106+}
107+
108+func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) {
109+ frame := core.NewFrame(1, 1)
110+ renderer, err := New(Options{})
111+ require.NoError(t, err)
112+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{})
113+ require.NoError(t, err)
114+ size := len(prepared.JSON())
115+ require.NoError(t, prepared.Commit())
116+
117+ tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}})
118+ require.NoError(t, err)
119+ accepted, err := tight.Prepare(frame, nil, true, Cursor{})
120+ require.NoError(t, err)
121+ require.NoError(t, accepted.Commit())
122+
123+ strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}})
124+ require.NoError(t, err)
125+ _, err = strict.Prepare(frame, nil, true, Cursor{})
126+ require.ErrorIs(t, err, ErrLimitExceeded)
127+ require.ErrorContains(t, err, "generated update is")
128+}
129+
130+func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) {
131+ frame := core.NewFrame(2, 2)
132+ renderer, err := New(Options{})
133+ require.NoError(t, err)
134+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
135+ require.NoError(t, err)
136+ require.NoError(t, first.Commit())
137+
138+ frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
139+ prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{})
140+ require.NoError(t, err)
141+ require.False(t, prepared.Update().Snapshot)
142+ require.NoError(t, prepared.Commit())
143+}
144+
145+func TestRendererFindsChangesOutsideDamage(t *testing.T) {
146+ frame := core.NewFrame(2, 2)
147+ renderer, err := New(Options{})
148+ require.NoError(t, err)
149+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
150+ require.NoError(t, err)
151+ require.NoError(t, first.Commit())
152+
153+ frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
154+ prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{})
155+ require.NoError(t, err)
156+ require.False(t, prepared.Update().Snapshot)
157+ require.Equal(t, 1, prepared.Update().Rows[0].Row)
158+ require.NoError(t, prepared.Commit())
159+}
160+
161+func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) {
162+ frame := core.NewFrame(2, 2)
163+ renderer, err := New(Options{})
164+ require.NoError(t, err)
165+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
166+ require.NoError(t, err)
167+ require.NoError(t, first.Commit())
168+
169+ cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true})
170+ require.NoError(t, err)
171+ require.Empty(t, cursorOnly.Update().Rows)
172+ require.NotNil(t, cursorOnly.Update().Rows)
173+ require.NotNil(t, cursorOnly.Update().Styles)
174+ require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`)
175+ require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`)
176+ require.Equal(t, 1, cursorOnly.Update().Cursor.Row)
177+ require.NoError(t, cursorOnly.Commit())
178+
179+ scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{})
180+ require.NoError(t, err)
181+ require.True(t, scroll.Update().Snapshot)
182+ require.Len(t, scroll.Update().Rows, 2)
183+ require.NoError(t, scroll.Abort())
184+}
185+
186+func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) {
187+ frame := core.NewFrame(1, 1)
188+ renderer, err := New(Options{})
189+ require.NoError(t, err)
190+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
191+ require.NoError(t, err)
192+
193+ update := prepared.Update()
194+ encoded := prepared.JSON()
195+ wantJSON := string(encoded)
196+ update.Rows[0].Cells[0].Text = "mutated"
197+ encoded[0] = 'X'
198+ require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text)
199+ require.JSONEq(t, wantJSON, string(prepared.JSON()))
200+ require.Equal(t, byte('{'), prepared.JSON()[0])
201+}
202+
203+func TestCopiedPreparedDrawSharesFinalization(t *testing.T) {
204+ frame := core.NewFrame(1, 1)
205+ renderer, err := New(Options{})
206+ require.NoError(t, err)
207+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
208+ require.NoError(t, err)
209+ copied := *prepared
210+ require.NoError(t, copied.Commit())
211+ require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw)
212+}
213+
214+func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) {
215+ frame := core.NewFrame(1, 1)
216+ renderer, err := New(Options{})
217+ require.NoError(t, err)
218+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
219+ require.NoError(t, err)
220+ copied := *prepared
221+
222+ renderer.Reset()
223+ require.ErrorIs(t, prepared.Commit(), ErrStaleDraw)
224+ require.ErrorIs(t, copied.Abort(), ErrStaleDraw)
225+
226+ next, err := renderer.Prepare(frame, nil, false, Cursor{})
227+ require.NoError(t, err)
228+ require.True(t, next.Update().Snapshot)
229+ require.NoError(t, next.Commit())
230+}
A html/style_test.go +36 -0
@@ -0,0 +1,36 @@
1+package html
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRendererCanonicalizesEveryStyleField(t *testing.T) {
11+ style := core.DefaultStyle()
12+ style.Bold = true
13+ style.Italic = true
14+ style.Inverse = true
15+ style.Attrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
16+ style.Foreground = 12
17+ style.HasBackgroundRGB = true
18+ style.BackgroundRGB = core.RGB{R: 1, G: 2, B: 3}
19+ style.UnderlineStyle = core.UnderlineCurly
20+ style.HasUnderlineColor = true
21+ style.UnderlineColor = 200
22+
23+ frame := core.NewFrame(1, 1)
24+ frame.Set(0, 0, core.Cell{Rune: 'X', Style: style})
25+ renderer, err := New(Options{})
26+ require.NoError(t, err)
27+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
28+ require.NoError(t, err)
29+ require.Equal(t, []Style{{
30+ Bold: true, Italic: true, Inverse: true, Dim: true, Blink: true,
31+ Strikethrough: true, Underline: true, UnderlineStyle: core.UnderlineCurly,
32+ Foreground: Color{Kind: ColorIndexed, Index: 12},
33+ Background: Color{Kind: ColorRGB, RGB: RGB{R: 1, G: 2, B: 3}},
34+ UnderlineColor: Color{Kind: ColorIndexed, Index: 200},
35+ }}, prepared.Update().Styles)
36+}
@@ -0,0 +1,36 @@
1+package html
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRendererCanonicalizesEveryStyleField(t *testing.T) {
11+ style := core.DefaultStyle()
12+ style.Bold = true
13+ style.Italic = true
14+ style.Inverse = true
15+ style.Attrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
16+ style.Foreground = 12
17+ style.HasBackgroundRGB = true
18+ style.BackgroundRGB = core.RGB{R: 1, G: 2, B: 3}
19+ style.UnderlineStyle = core.UnderlineCurly
20+ style.HasUnderlineColor = true
21+ style.UnderlineColor = 200
22+
23+ frame := core.NewFrame(1, 1)
24+ frame.Set(0, 0, core.Cell{Rune: 'X', Style: style})
25+ renderer, err := New(Options{})
26+ require.NoError(t, err)
27+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
28+ require.NoError(t, err)
29+ require.Equal(t, []Style{{
30+ Bold: true, Italic: true, Inverse: true, Dim: true, Blink: true,
31+ Strikethrough: true, Underline: true, UnderlineStyle: core.UnderlineCurly,
32+ Foreground: Color{Kind: ColorIndexed, Index: 12},
33+ Background: Color{Kind: ColorRGB, RGB: RGB{R: 1, G: 2, B: 3}},
34+ UnderlineColor: Color{Kind: ColorIndexed, Index: 200},
35+ }}, prepared.Update().Styles)
36+}
A html/terminal.css +120 -0
@@ -0,0 +1,120 @@
1+.vev-terminal {
2+ --vev-fg: #d8dee9;
3+ --vev-bg: #101218;
4+ --vev-cursor: #ff8700;
5+ --vev-selection-bg: #394150;
6+ --vev-selection-fg: #ffffff;
7+ --vev-color-0: #000000;
8+ --vev-color-1: #800000;
9+ --vev-color-2: #008000;
10+ --vev-color-3: #808000;
11+ --vev-color-4: #000080;
12+ --vev-color-5: #800080;
13+ --vev-color-6: #008080;
14+ --vev-color-7: #c0c0c0;
15+ --vev-color-8: #808080;
16+ --vev-color-9: #ff0000;
17+ --vev-color-10: #00ff00;
18+ --vev-color-11: #ffff00;
19+ --vev-color-12: #0000ff;
20+ --vev-color-13: #ff00ff;
21+ --vev-color-14: #00ffff;
22+ --vev-color-15: #ffffff;
23+ --vev-font-family: ui-monospace, "Cascadia Mono", "Noto Sans Mono", monospace;
24+ --vev-font-size: 14px;
25+ --vev-line-height: 1.2;
26+ --vev-cell-width: 1ch;
27+ --vev-cell-height: calc(var(--vev-font-size) * var(--vev-line-height));
28+
29+ position: relative;
30+ display: inline-block;
31+ overflow: hidden;
32+ color: var(--vev-fg);
33+ background: var(--vev-bg);
34+ font-family: var(--vev-font-family);
35+ font-size: var(--vev-font-size);
36+ line-height: var(--vev-line-height);
37+ font-variant-ligatures: none;
38+ white-space: pre;
39+ user-select: text;
40+}
41+
42+.vev-terminal:focus-within {
43+ outline: 2px solid var(--vev-cursor);
44+ outline-offset: 2px;
45+}
46+
47+.vev-terminal__viewport {
48+ position: relative;
49+ display: grid;
50+ grid-template-rows: repeat(var(--vev-rows), var(--vev-cell-height));
51+}
52+
53+.vev-terminal__row {
54+ contain: strict;
55+ display: grid;
56+ grid-template-columns: repeat(var(--vev-cols), var(--vev-cell-width));
57+ min-width: calc(var(--vev-cols) * var(--vev-cell-width));
58+ height: var(--vev-cell-height);
59+}
60+
61+.vev-terminal__cell {
62+ overflow: hidden;
63+ min-width: 0;
64+ color: var(--vev-cell-fg, var(--vev-fg));
65+ background: var(--vev-cell-bg, var(--vev-bg));
66+ text-decoration-color: var(--vev-cell-underline, currentColor);
67+}
68+
69+.vev-terminal__cell--bold { font-weight: 700; }
70+.vev-terminal__cell--italic { font-style: italic; }
71+.vev-terminal__cell--dim { color: color-mix(in srgb, var(--vev-cell-fg, var(--vev-fg)) 50%, var(--vev-cell-bg, var(--vev-bg))); }
72+.vev-terminal__cell--strike { text-decoration-line: line-through; }
73+.vev-terminal__cell--underline { text-decoration-line: underline; }
74+.vev-terminal__cell--strike.vev-terminal__cell--underline { text-decoration-line: line-through underline; }
75+.vev-terminal__cell--underline-double { text-decoration-style: double; }
76+.vev-terminal__cell--underline-wavy { text-decoration-style: wavy; }
77+.vev-terminal__cell--underline-dotted { text-decoration-style: dotted; }
78+.vev-terminal__cell--underline-dashed { text-decoration-style: dashed; }
79+.vev-terminal__cell--blink { animation: vev-terminal-blink 1s step-end infinite; }
80+
81+.vev-terminal__cursor {
82+ position: absolute;
83+ z-index: 2;
84+ pointer-events: none;
85+ background: var(--vev-cursor);
86+ opacity: 0.75;
87+}
88+
89+.vev-terminal__cursor--bar { width: max(1px, calc(var(--vev-cell-width) * 0.12)); }
90+.vev-terminal__cursor--underline { height: max(1px, calc(var(--vev-cell-height) * 0.12)); }
91+
92+.vev-terminal__input { font-size: 16px; }
93+
94+.vev-terminal__input,
95+.vev-terminal__accessible-output {
96+ position: absolute;
97+ width: 1px;
98+ height: 1px;
99+ padding: 0;
100+ margin: -1px;
101+ overflow: hidden;
102+ clip-path: inset(50%);
103+ white-space: pre;
104+ border: 0;
105+}
106+
107+.vev-terminal ::selection {
108+ color: var(--vev-selection-fg);
109+ background: var(--vev-selection-bg);
110+}
111+
112+.vev-terminal--mouse-capture { user-select: none; }
113+
114+@keyframes vev-terminal-blink {
115+ 50% { visibility: hidden; }
116+}
117+
118+@media (prefers-reduced-motion: reduce) {
119+ .vev-terminal__cell--blink { animation: none; }
120+}
@@ -0,0 +1,120 @@
1+.vev-terminal {
2+ --vev-fg: #d8dee9;
3+ --vev-bg: #101218;
4+ --vev-cursor: #ff8700;
5+ --vev-selection-bg: #394150;
6+ --vev-selection-fg: #ffffff;
7+ --vev-color-0: #000000;
8+ --vev-color-1: #800000;
9+ --vev-color-2: #008000;
10+ --vev-color-3: #808000;
11+ --vev-color-4: #000080;
12+ --vev-color-5: #800080;
13+ --vev-color-6: #008080;
14+ --vev-color-7: #c0c0c0;
15+ --vev-color-8: #808080;
16+ --vev-color-9: #ff0000;
17+ --vev-color-10: #00ff00;
18+ --vev-color-11: #ffff00;
19+ --vev-color-12: #0000ff;
20+ --vev-color-13: #ff00ff;
21+ --vev-color-14: #00ffff;
22+ --vev-color-15: #ffffff;
23+ --vev-font-family: ui-monospace, "Cascadia Mono", "Noto Sans Mono", monospace;
24+ --vev-font-size: 14px;
25+ --vev-line-height: 1.2;
26+ --vev-cell-width: 1ch;
27+ --vev-cell-height: calc(var(--vev-font-size) * var(--vev-line-height));
28+
29+ position: relative;
30+ display: inline-block;
31+ overflow: hidden;
32+ color: var(--vev-fg);
33+ background: var(--vev-bg);
34+ font-family: var(--vev-font-family);
35+ font-size: var(--vev-font-size);
36+ line-height: var(--vev-line-height);
37+ font-variant-ligatures: none;
38+ white-space: pre;
39+ user-select: text;
40+}
41+
42+.vev-terminal:focus-within {
43+ outline: 2px solid var(--vev-cursor);
44+ outline-offset: 2px;
45+}
46+
47+.vev-terminal__viewport {
48+ position: relative;
49+ display: grid;
50+ grid-template-rows: repeat(var(--vev-rows), var(--vev-cell-height));
51+}
52+
53+.vev-terminal__row {
54+ contain: strict;
55+ display: grid;
56+ grid-template-columns: repeat(var(--vev-cols), var(--vev-cell-width));
57+ min-width: calc(var(--vev-cols) * var(--vev-cell-width));
58+ height: var(--vev-cell-height);
59+}
60+
61+.vev-terminal__cell {
62+ overflow: hidden;
63+ min-width: 0;
64+ color: var(--vev-cell-fg, var(--vev-fg));
65+ background: var(--vev-cell-bg, var(--vev-bg));
66+ text-decoration-color: var(--vev-cell-underline, currentColor);
67+}
68+
69+.vev-terminal__cell--bold { font-weight: 700; }
70+.vev-terminal__cell--italic { font-style: italic; }
71+.vev-terminal__cell--dim { color: color-mix(in srgb, var(--vev-cell-fg, var(--vev-fg)) 50%, var(--vev-cell-bg, var(--vev-bg))); }
72+.vev-terminal__cell--strike { text-decoration-line: line-through; }
73+.vev-terminal__cell--underline { text-decoration-line: underline; }
74+.vev-terminal__cell--strike.vev-terminal__cell--underline { text-decoration-line: line-through underline; }
75+.vev-terminal__cell--underline-double { text-decoration-style: double; }
76+.vev-terminal__cell--underline-wavy { text-decoration-style: wavy; }
77+.vev-terminal__cell--underline-dotted { text-decoration-style: dotted; }
78+.vev-terminal__cell--underline-dashed { text-decoration-style: dashed; }
79+.vev-terminal__cell--blink { animation: vev-terminal-blink 1s step-end infinite; }
80+
81+.vev-terminal__cursor {
82+ position: absolute;
83+ z-index: 2;
84+ pointer-events: none;
85+ background: var(--vev-cursor);
86+ opacity: 0.75;
87+}
88+
89+.vev-terminal__cursor--bar { width: max(1px, calc(var(--vev-cell-width) * 0.12)); }
90+.vev-terminal__cursor--underline { height: max(1px, calc(var(--vev-cell-height) * 0.12)); }
91+
92+.vev-terminal__input { font-size: 16px; }
93+
94+.vev-terminal__input,
95+.vev-terminal__accessible-output {
96+ position: absolute;
97+ width: 1px;
98+ height: 1px;
99+ padding: 0;
100+ margin: -1px;
101+ overflow: hidden;
102+ clip-path: inset(50%);
103+ white-space: pre;
104+ border: 0;
105+}
106+
107+.vev-terminal ::selection {
108+ color: var(--vev-selection-fg);
109+ background: var(--vev-selection-bg);
110+}
111+
112+.vev-terminal--mouse-capture { user-select: none; }
113+
114+@keyframes vev-terminal-blink {
115+ 50% { visibility: hidden; }
116+}
117+
118+@media (prefers-reduced-motion: reduce) {
119+ .vev-terminal__cell--blink { animation: none; }
120+}
A html/theme.go +88 -0
@@ -0,0 +1,88 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/vev-vt/core"
9+)
10+
11+// Theme contains generic terminal colors. Consumers can override the same CSS
12+// custom properties directly when product-specific theme ownership is needed.
13+type Theme struct {
14+ Foreground core.RGB
15+ Background core.RGB
16+ Cursor core.RGB
17+ Selection core.RGB
18+ SelectionText core.RGB
19+ Palette [16]core.RGB
20+}
21+
22+// MarshalJSON emits the typed object accepted by the embedded browser
23+// runtime's setTheme method.
24+func (theme Theme) MarshalJSON() ([]byte, error) {
25+ palette := [16]RGB{}
26+ for index, color := range theme.Palette {
27+ palette[index] = browserRGB(color)
28+ }
29+ return json.Marshal(struct {
30+ Foreground RGB `json:"foreground"`
31+ Background RGB `json:"background"`
32+ Cursor RGB `json:"cursor"`
33+ Selection RGB `json:"selection"`
34+ SelectionText RGB `json:"selectionText"`
35+ Palette [16]RGB `json:"palette"`
36+ }{
37+ Foreground: browserRGB(theme.Foreground), Background: browserRGB(theme.Background),
38+ Cursor: browserRGB(theme.Cursor), Selection: browserRGB(theme.Selection),
39+ SelectionText: browserRGB(theme.SelectionText), Palette: palette,
40+ })
41+}
42+
43+func browserRGB(color core.RGB) RGB { return RGB{R: color.R, G: color.G, B: color.B} }
44+
45+// DefaultTheme returns a neutral dark terminal theme.
46+func DefaultTheme() Theme {
47+ return Theme{
48+ Foreground: rgb(0xd8, 0xde, 0xe9),
49+ Background: rgb(0x10, 0x12, 0x18),
50+ Cursor: rgb(0xff, 0x87, 0x00),
51+ Selection: rgb(0x39, 0x41, 0x50),
52+ SelectionText: rgb(0xff, 0xff, 0xff),
53+ Palette: [16]core.RGB{
54+ rgb(0x00, 0x00, 0x00), rgb(0x80, 0x00, 0x00), rgb(0x00, 0x80, 0x00), rgb(0x80, 0x80, 0x00),
55+ rgb(0x00, 0x00, 0x80), rgb(0x80, 0x00, 0x80), rgb(0x00, 0x80, 0x80), rgb(0xc0, 0xc0, 0xc0),
56+ rgb(0x80, 0x80, 0x80), rgb(0xff, 0x00, 0x00), rgb(0x00, 0xff, 0x00), rgb(0xff, 0xff, 0x00),
57+ rgb(0x00, 0x00, 0xff), rgb(0xff, 0x00, 0xff), rgb(0x00, 0xff, 0xff), rgb(0xff, 0xff, 0xff),
58+ },
59+ }
60+}
61+
62+func rgb(r, g, b uint8) core.RGB { return core.RGB{R: r, G: g, B: b} }
63+
64+// CSS returns deterministic custom-property declarations containing only
65+// numeric RGB values.
66+func (theme Theme) CSS() string {
67+ var builder strings.Builder
68+ builder.WriteString("--vev-fg:")
69+ writeRGB(&builder, theme.Foreground)
70+ builder.WriteString(";--vev-bg:")
71+ writeRGB(&builder, theme.Background)
72+ builder.WriteString(";--vev-cursor:")
73+ writeRGB(&builder, theme.Cursor)
74+ builder.WriteString(";--vev-selection-bg:")
75+ writeRGB(&builder, theme.Selection)
76+ builder.WriteString(";--vev-selection-fg:")
77+ writeRGB(&builder, theme.SelectionText)
78+ for index, color := range theme.Palette {
79+ fmt.Fprintf(&builder, ";--vev-color-%d:", index)
80+ writeRGB(&builder, color)
81+ }
82+ builder.WriteByte(';')
83+ return builder.String()
84+}
85+
86+func writeRGB(builder *strings.Builder, color core.RGB) {
87+ fmt.Fprintf(builder, "#%02x%02x%02x", color.R, color.G, color.B)
88+}
@@ -0,0 +1,88 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/vev-vt/core"
9+)
10+
11+// Theme contains generic terminal colors. Consumers can override the same CSS
12+// custom properties directly when product-specific theme ownership is needed.
13+type Theme struct {
14+ Foreground core.RGB
15+ Background core.RGB
16+ Cursor core.RGB
17+ Selection core.RGB
18+ SelectionText core.RGB
19+ Palette [16]core.RGB
20+}
21+
22+// MarshalJSON emits the typed object accepted by the embedded browser
23+// runtime's setTheme method.
24+func (theme Theme) MarshalJSON() ([]byte, error) {
25+ palette := [16]RGB{}
26+ for index, color := range theme.Palette {
27+ palette[index] = browserRGB(color)
28+ }
29+ return json.Marshal(struct {
30+ Foreground RGB `json:"foreground"`
31+ Background RGB `json:"background"`
32+ Cursor RGB `json:"cursor"`
33+ Selection RGB `json:"selection"`
34+ SelectionText RGB `json:"selectionText"`
35+ Palette [16]RGB `json:"palette"`
36+ }{
37+ Foreground: browserRGB(theme.Foreground), Background: browserRGB(theme.Background),
38+ Cursor: browserRGB(theme.Cursor), Selection: browserRGB(theme.Selection),
39+ SelectionText: browserRGB(theme.SelectionText), Palette: palette,
40+ })
41+}
42+
43+func browserRGB(color core.RGB) RGB { return RGB{R: color.R, G: color.G, B: color.B} }
44+
45+// DefaultTheme returns a neutral dark terminal theme.
46+func DefaultTheme() Theme {
47+ return Theme{
48+ Foreground: rgb(0xd8, 0xde, 0xe9),
49+ Background: rgb(0x10, 0x12, 0x18),
50+ Cursor: rgb(0xff, 0x87, 0x00),
51+ Selection: rgb(0x39, 0x41, 0x50),
52+ SelectionText: rgb(0xff, 0xff, 0xff),
53+ Palette: [16]core.RGB{
54+ rgb(0x00, 0x00, 0x00), rgb(0x80, 0x00, 0x00), rgb(0x00, 0x80, 0x00), rgb(0x80, 0x80, 0x00),
55+ rgb(0x00, 0x00, 0x80), rgb(0x80, 0x00, 0x80), rgb(0x00, 0x80, 0x80), rgb(0xc0, 0xc0, 0xc0),
56+ rgb(0x80, 0x80, 0x80), rgb(0xff, 0x00, 0x00), rgb(0x00, 0xff, 0x00), rgb(0xff, 0xff, 0x00),
57+ rgb(0x00, 0x00, 0xff), rgb(0xff, 0x00, 0xff), rgb(0x00, 0xff, 0xff), rgb(0xff, 0xff, 0xff),
58+ },
59+ }
60+}
61+
62+func rgb(r, g, b uint8) core.RGB { return core.RGB{R: r, G: g, B: b} }
63+
64+// CSS returns deterministic custom-property declarations containing only
65+// numeric RGB values.
66+func (theme Theme) CSS() string {
67+ var builder strings.Builder
68+ builder.WriteString("--vev-fg:")
69+ writeRGB(&builder, theme.Foreground)
70+ builder.WriteString(";--vev-bg:")
71+ writeRGB(&builder, theme.Background)
72+ builder.WriteString(";--vev-cursor:")
73+ writeRGB(&builder, theme.Cursor)
74+ builder.WriteString(";--vev-selection-bg:")
75+ writeRGB(&builder, theme.Selection)
76+ builder.WriteString(";--vev-selection-fg:")
77+ writeRGB(&builder, theme.SelectionText)
78+ for index, color := range theme.Palette {
79+ fmt.Fprintf(&builder, ";--vev-color-%d:", index)
80+ writeRGB(&builder, color)
81+ }
82+ builder.WriteByte(';')
83+ return builder.String()
84+}
85+
86+func writeRGB(builder *strings.Builder, color core.RGB) {
87+ fmt.Fprintf(builder, "#%02x%02x%02x", color.R, color.G, color.B)
88+}
A html/theme_test.go +35 -0
@@ -0,0 +1,35 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestDefaultThemeMatchesStylesheetFallbacks(t *testing.T) {
13+ stylesheet := strings.NewReplacer(" ", "", "\n", "", "\t", "").Replace(Stylesheet())
14+ for declaration := range strings.SplitSeq(DefaultTheme().CSS(), ";") {
15+ if declaration != "" {
16+ require.Contains(t, stylesheet, declaration)
17+ }
18+ }
19+}
20+
21+func TestThemeCSSUsesOnlyTypedColorValues(t *testing.T) {
22+ theme := DefaultTheme()
23+ theme.Foreground = core.RGB{R: 1, G: 2, B: 3}
24+ css := theme.CSS()
25+
26+ require.Contains(t, css, "--vev-fg:#010203")
27+ require.Contains(t, css, "--vev-color-15:")
28+ require.Equal(t, 21, strings.Count(css, "--vev-"))
29+ require.NotEmpty(t, Stylesheet())
30+
31+ encoded, err := json.Marshal(theme)
32+ require.NoError(t, err)
33+ require.Contains(t, string(encoded), `"foreground":{"r":1,"g":2,"b":3}`)
34+ require.NotContains(t, string(encoded), `"Foreground"`)
35+}
@@ -0,0 +1,35 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestDefaultThemeMatchesStylesheetFallbacks(t *testing.T) {
13+ stylesheet := strings.NewReplacer(" ", "", "\n", "", "\t", "").Replace(Stylesheet())
14+ for declaration := range strings.SplitSeq(DefaultTheme().CSS(), ";") {
15+ if declaration != "" {
16+ require.Contains(t, stylesheet, declaration)
17+ }
18+ }
19+}
20+
21+func TestThemeCSSUsesOnlyTypedColorValues(t *testing.T) {
22+ theme := DefaultTheme()
23+ theme.Foreground = core.RGB{R: 1, G: 2, B: 3}
24+ css := theme.CSS()
25+
26+ require.Contains(t, css, "--vev-fg:#010203")
27+ require.Contains(t, css, "--vev-color-15:")
28+ require.Equal(t, 21, strings.Count(css, "--vev-"))
29+ require.NotEmpty(t, Stylesheet())
30+
31+ encoded, err := json.Marshal(theme)
32+ require.NoError(t, err)
33+ require.Contains(t, string(encoded), `"foreground":{"r":1,"g":2,"b":3}`)
34+ require.NotContains(t, string(encoded), `"Foreground"`)
35+}
A html/update.go +120 -0
@@ -0,0 +1,120 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
9+
10+// UpdateSchemaVersion identifies the browser update contract.
11+const UpdateSchemaVersion uint16 = 1
12+
13+// CursorStyle is a DECSCUSR cursor shape value from 0 through 6.
14+type CursorStyle uint8
15+
16+// Cursor is the absolute cursor state associated with an update. StyleSet
17+// distinguishes an unset style from an explicit DECSCUSR style zero.
18+type Cursor struct {
19+ Row int `json:"row"`
20+ Column int `json:"column"`
21+ Visible bool `json:"visible"`
22+ Style CursorStyle `json:"style"`
23+ StyleSet bool `json:"styleSet"`
24+}
25+
26+// ColorKind identifies how a terminal color is resolved.
27+type ColorKind uint8
28+
29+const (
30+ ColorDefault ColorKind = iota
31+ ColorIndexed
32+ ColorRGB
33+)
34+
35+// RGB is a browser-facing RGB color.
36+type RGB struct {
37+ R uint8 `json:"r"`
38+ G uint8 `json:"g"`
39+ B uint8 `json:"b"`
40+}
41+
42+// Color is a validated terminal color token.
43+type Color struct {
44+ Kind ColorKind
45+ Index uint8
46+ RGB RGB
47+}
48+
49+func (color Color) MarshalJSON() ([]byte, error) {
50+ switch color.Kind {
51+ case ColorDefault:
52+ return []byte(`{"kind":0}`), nil
53+ case ColorIndexed:
54+ return json.Marshal(struct {
55+ Kind ColorKind `json:"kind"`
56+ Index uint8 `json:"index"`
57+ }{Kind: color.Kind, Index: color.Index})
58+ case ColorRGB:
59+ return json.Marshal(struct {
60+ Kind ColorKind `json:"kind"`
61+ RGB RGB `json:"rgb"`
62+ }{Kind: color.Kind, RGB: color.RGB})
63+ default:
64+ return nil, fmt.Errorf("html: invalid color kind %d", color.Kind)
65+ }
66+}
67+
68+// Style is the canonical, browser-facing subset of core.Style.
69+type Style struct {
70+ Bold bool `json:"bold,omitempty"`
71+ Italic bool `json:"italic,omitempty"`
72+ Inverse bool `json:"inverse,omitempty"`
73+ Dim bool `json:"dim,omitempty"`
74+ Blink bool `json:"blink,omitempty"`
75+ Strikethrough bool `json:"strikethrough,omitempty"`
76+ Underline bool `json:"underline,omitempty"`
77+ UnderlineStyle core.UnderlineStyle `json:"underlineStyle,omitempty"`
78+ Foreground Color `json:"foreground"`
79+ Background Color `json:"background"`
80+ UnderlineColor Color `json:"underlineColor"`
81+}
82+
83+// CellUpdate occupies Width terminal columns beginning at Column. Wide-cell
84+// continuation markers are represented by the preceding cell's width.
85+type CellUpdate struct {
86+ Column int `json:"column"`
87+ Width int `json:"width"`
88+ Text string `json:"text"`
89+ Style int `json:"style"`
90+}
91+
92+// RowUpdate replaces one complete logical terminal row.
93+type RowUpdate struct {
94+ Row int `json:"row"`
95+ Cells []CellUpdate `json:"cells"`
96+}
97+
98+// Update is an immutable-by-contract browser update. Snapshot updates contain
99+// every logical row; incremental updates contain complete changed rows.
100+type Update struct {
101+ SchemaVersion uint16 `json:"schemaVersion"`
102+ Width int `json:"width"`
103+ Height int `json:"height"`
104+ Snapshot bool `json:"snapshot"`
105+ Rows []RowUpdate `json:"rows"`
106+ Styles []Style `json:"styles"`
107+ Cursor Cursor `json:"cursor"`
108+}
109+
110+func cloneUpdate(src Update) Update {
111+ dst := src
112+ dst.Styles = make([]Style, len(src.Styles))
113+ copy(dst.Styles, src.Styles)
114+ dst.Rows = make([]RowUpdate, len(src.Rows))
115+ for i := range src.Rows {
116+ dst.Rows[i] = src.Rows[i]
117+ dst.Rows[i].Cells = append([]CellUpdate(nil), src.Rows[i].Cells...)
118+ }
119+ return dst
120+}
@@ -0,0 +1,120 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
9+
10+// UpdateSchemaVersion identifies the browser update contract.
11+const UpdateSchemaVersion uint16 = 1
12+
13+// CursorStyle is a DECSCUSR cursor shape value from 0 through 6.
14+type CursorStyle uint8
15+
16+// Cursor is the absolute cursor state associated with an update. StyleSet
17+// distinguishes an unset style from an explicit DECSCUSR style zero.
18+type Cursor struct {
19+ Row int `json:"row"`
20+ Column int `json:"column"`
21+ Visible bool `json:"visible"`
22+ Style CursorStyle `json:"style"`
23+ StyleSet bool `json:"styleSet"`
24+}
25+
26+// ColorKind identifies how a terminal color is resolved.
27+type ColorKind uint8
28+
29+const (
30+ ColorDefault ColorKind = iota
31+ ColorIndexed
32+ ColorRGB
33+)
34+
35+// RGB is a browser-facing RGB color.
36+type RGB struct {
37+ R uint8 `json:"r"`
38+ G uint8 `json:"g"`
39+ B uint8 `json:"b"`
40+}
41+
42+// Color is a validated terminal color token.
43+type Color struct {
44+ Kind ColorKind
45+ Index uint8
46+ RGB RGB
47+}
48+
49+func (color Color) MarshalJSON() ([]byte, error) {
50+ switch color.Kind {
51+ case ColorDefault:
52+ return []byte(`{"kind":0}`), nil
53+ case ColorIndexed:
54+ return json.Marshal(struct {
55+ Kind ColorKind `json:"kind"`
56+ Index uint8 `json:"index"`
57+ }{Kind: color.Kind, Index: color.Index})
58+ case ColorRGB:
59+ return json.Marshal(struct {
60+ Kind ColorKind `json:"kind"`
61+ RGB RGB `json:"rgb"`
62+ }{Kind: color.Kind, RGB: color.RGB})
63+ default:
64+ return nil, fmt.Errorf("html: invalid color kind %d", color.Kind)
65+ }
66+}
67+
68+// Style is the canonical, browser-facing subset of core.Style.
69+type Style struct {
70+ Bold bool `json:"bold,omitempty"`
71+ Italic bool `json:"italic,omitempty"`
72+ Inverse bool `json:"inverse,omitempty"`
73+ Dim bool `json:"dim,omitempty"`
74+ Blink bool `json:"blink,omitempty"`
75+ Strikethrough bool `json:"strikethrough,omitempty"`
76+ Underline bool `json:"underline,omitempty"`
77+ UnderlineStyle core.UnderlineStyle `json:"underlineStyle,omitempty"`
78+ Foreground Color `json:"foreground"`
79+ Background Color `json:"background"`
80+ UnderlineColor Color `json:"underlineColor"`
81+}
82+
83+// CellUpdate occupies Width terminal columns beginning at Column. Wide-cell
84+// continuation markers are represented by the preceding cell's width.
85+type CellUpdate struct {
86+ Column int `json:"column"`
87+ Width int `json:"width"`
88+ Text string `json:"text"`
89+ Style int `json:"style"`
90+}
91+
92+// RowUpdate replaces one complete logical terminal row.
93+type RowUpdate struct {
94+ Row int `json:"row"`
95+ Cells []CellUpdate `json:"cells"`
96+}
97+
98+// Update is an immutable-by-contract browser update. Snapshot updates contain
99+// every logical row; incremental updates contain complete changed rows.
100+type Update struct {
101+ SchemaVersion uint16 `json:"schemaVersion"`
102+ Width int `json:"width"`
103+ Height int `json:"height"`
104+ Snapshot bool `json:"snapshot"`
105+ Rows []RowUpdate `json:"rows"`
106+ Styles []Style `json:"styles"`
107+ Cursor Cursor `json:"cursor"`
108+}
109+
110+func cloneUpdate(src Update) Update {
111+ dst := src
112+ dst.Styles = make([]Style, len(src.Styles))
113+ copy(dst.Styles, src.Styles)
114+ dst.Rows = make([]RowUpdate, len(src.Rows))
115+ for i := range src.Rows {
116+ dst.Rows[i] = src.Rows[i]
117+ dst.Rows[i].Cells = append([]CellUpdate(nil), src.Rows[i].Cells...)
118+ }
119+ return dst
120+}
A html/validation_test.go +70 -0
@@ -0,0 +1,70 @@
1+package html
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRendererRejectsMalformedOrOverLimitFramesWithoutPendingState(t *testing.T) {
11+ renderer, err := New(Options{Limits: Limits{MaxCells: 2}})
12+ require.NoError(t, err)
13+
14+ tooLarge := core.NewFrame(3, 1)
15+ _, err = renderer.Prepare(tooLarge, nil, false, Cursor{})
16+ require.ErrorIs(t, err, ErrLimitExceeded)
17+
18+ orphan := core.NewFrame(2, 1)
19+ orphan.Set(0, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
20+ _, err = renderer.Prepare(orphan, nil, false, Cursor{})
21+ require.ErrorContains(t, err, "orphan wide continuation")
22+
23+ combining := core.NewFrame(1, 1)
24+ combining.Set(0, 0, core.Cell{Rune: '\u0301', Style: core.DefaultStyle()})
25+ _, err = renderer.Prepare(combining, nil, false, Cursor{})
26+ require.ErrorContains(t, err, "unsupported zero-width rune")
27+
28+ wide := core.NewFrame(2, 1)
29+ wide.Set(0, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
30+ wide.Set(1, 0, core.Cell{Rune: 'X', Continuation: true, Style: core.DefaultStyle()})
31+ _, err = renderer.Prepare(wide, nil, false, Cursor{})
32+ require.ErrorContains(t, err, "wide continuation contains a rune")
33+
34+ alternate := core.DefaultStyle()
35+ alternate.Bold = true
36+ wide.Set(1, 0, core.Cell{Continuation: true, Style: alternate})
37+ _, err = renderer.Prepare(wide, nil, false, Cursor{})
38+ require.ErrorContains(t, err, "style differs from its head")
39+
40+ valid := core.NewFrame(2, 1)
41+ prepared, err := renderer.Prepare(valid, nil, false, Cursor{})
42+ require.NoError(t, err)
43+ require.NoError(t, prepared.Abort())
44+}
45+
46+func TestNewRejectsNegativeLimits(t *testing.T) {
47+ _, err := New(Options{Limits: Limits{MaxCells: -1}})
48+ require.ErrorIs(t, err, ErrInvalidLimits)
49+ require.ErrorContains(t, err, "must not be negative")
50+}
51+
52+func TestRendererEnforcesEncodedByteAndStyleLimits(t *testing.T) {
53+ frame := core.NewFrame(2, 1)
54+ alternate := core.DefaultStyle()
55+ alternate.Bold = true
56+ frame.Set(1, 0, core.Cell{Rune: 'X', Style: alternate})
57+
58+ styleLimited, err := New(Options{Limits: Limits{MaxStyles: 1}})
59+ require.NoError(t, err)
60+ _, err = styleLimited.Prepare(frame, nil, false, Cursor{})
61+ require.ErrorIs(t, err, ErrLimitExceeded)
62+
63+ byteLimited, err := New(Options{Limits: Limits{MaxGeneratedBytes: 1}})
64+ require.NoError(t, err)
65+ _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{})
66+ require.ErrorIs(t, err, ErrLimitExceeded)
67+ require.ErrorContains(t, err, "generated update is")
68+ _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{})
69+ require.ErrorIs(t, err, ErrLimitExceeded)
70+}
@@ -0,0 +1,70 @@
1+package html
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestRendererRejectsMalformedOrOverLimitFramesWithoutPendingState(t *testing.T) {
11+ renderer, err := New(Options{Limits: Limits{MaxCells: 2}})
12+ require.NoError(t, err)
13+
14+ tooLarge := core.NewFrame(3, 1)
15+ _, err = renderer.Prepare(tooLarge, nil, false, Cursor{})
16+ require.ErrorIs(t, err, ErrLimitExceeded)
17+
18+ orphan := core.NewFrame(2, 1)
19+ orphan.Set(0, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
20+ _, err = renderer.Prepare(orphan, nil, false, Cursor{})
21+ require.ErrorContains(t, err, "orphan wide continuation")
22+
23+ combining := core.NewFrame(1, 1)
24+ combining.Set(0, 0, core.Cell{Rune: '\u0301', Style: core.DefaultStyle()})
25+ _, err = renderer.Prepare(combining, nil, false, Cursor{})
26+ require.ErrorContains(t, err, "unsupported zero-width rune")
27+
28+ wide := core.NewFrame(2, 1)
29+ wide.Set(0, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
30+ wide.Set(1, 0, core.Cell{Rune: 'X', Continuation: true, Style: core.DefaultStyle()})
31+ _, err = renderer.Prepare(wide, nil, false, Cursor{})
32+ require.ErrorContains(t, err, "wide continuation contains a rune")
33+
34+ alternate := core.DefaultStyle()
35+ alternate.Bold = true
36+ wide.Set(1, 0, core.Cell{Continuation: true, Style: alternate})
37+ _, err = renderer.Prepare(wide, nil, false, Cursor{})
38+ require.ErrorContains(t, err, "style differs from its head")
39+
40+ valid := core.NewFrame(2, 1)
41+ prepared, err := renderer.Prepare(valid, nil, false, Cursor{})
42+ require.NoError(t, err)
43+ require.NoError(t, prepared.Abort())
44+}
45+
46+func TestNewRejectsNegativeLimits(t *testing.T) {
47+ _, err := New(Options{Limits: Limits{MaxCells: -1}})
48+ require.ErrorIs(t, err, ErrInvalidLimits)
49+ require.ErrorContains(t, err, "must not be negative")
50+}
51+
52+func TestRendererEnforcesEncodedByteAndStyleLimits(t *testing.T) {
53+ frame := core.NewFrame(2, 1)
54+ alternate := core.DefaultStyle()
55+ alternate.Bold = true
56+ frame.Set(1, 0, core.Cell{Rune: 'X', Style: alternate})
57+
58+ styleLimited, err := New(Options{Limits: Limits{MaxStyles: 1}})
59+ require.NoError(t, err)
60+ _, err = styleLimited.Prepare(frame, nil, false, Cursor{})
61+ require.ErrorIs(t, err, ErrLimitExceeded)
62+
63+ byteLimited, err := New(Options{Limits: Limits{MaxGeneratedBytes: 1}})
64+ require.NoError(t, err)
65+ _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{})
66+ require.ErrorIs(t, err, ErrLimitExceeded)
67+ require.ErrorContains(t, err, "generated update is")
68+ _, err = byteLimited.Prepare(core.NewFrame(1, 1), nil, false, Cursor{})
69+ require.ErrorIs(t, err, ErrLimitExceeded)
70+}
A internal/htmlharness/README.md +76 -0
@@ -0,0 +1,76 @@
1+# HTML browser harness
2+
3+This development-only Playwright harness verifies the embedded HTML frontend. It
4+loads `html/terminal.css` and `html/browser/terminal.js` directly; no production
5+server or transport is involved.
6+
7+## Locked environment
8+
9+`package-lock.json` pins Playwright 1.62.1 and these browser builds:
10+
11+| Engine | Browser version | Playwright revision |
12+|---|---:|---:|
13+| Chromium | 151.0.7922.34 | 1234 |
14+| Firefox | 153.0 | 1538 |
15+| WebKit | 26.5 | 2336 |
16+
17+Run locally when the host supports all browser builds:
18+
19+```sh
20+npm ci
21+npm run test:browser:install
22+npm run test:browser
23+```
24+
25+Use the matching official container on unsupported Linux distributions:
26+
27+```sh
28+npm run test:browser:docker
29+```
30+
31+CI installs the same locked browsers and runs the full matrix on Ubuntu.
32+
33+## Evidence
34+
35+The three-engine container matrix passes typed snapshot and row application,
36+hostile-text safety, complete validation before mutation, IME composition,
37+non-text keys, synchronous default-prevention decisions, pointer, wheel, resize,
38+focus, typed themes, lifecycle cleanup, and a self-only `script-src`/`style-src`
39+CSP. The CSP fixture uses external static assets and reports no policy violation.
40+
41+The Chromium reference performance gate applies ten sustained 120×40 snapshots,
42+fifty complete-row updates, and one 240×80 snapshot. The recorded implementation
43+run produced:
44+
45+| Workload | Recorded | Gate |
46+|---|---:|---:|
47+| 120×40 snapshot p95 | 28.2 ms | < 100 ms |
48+| 120-column row replacement p95 | 0.5 ms | < 33 ms |
49+| 240×80 snapshot | 68.3 ms | < 500 ms |
50+| Final visible cells/rows | 19,200 / 80 | exactly bounded by the frame |
51+
52+Performance numbers are reference evidence, not universal device guarantees.
53+The functional matrix and serial Chromium performance run write separate JSON
54+reports under `test-results/`. They are local/CI artifacts rather than committed
55+files.
56+
57+## Accessibility scope
58+
59+Automated checks cover a named group, a labeled keyboard-focusable text input,
60+visible focus styling, synchronized plain-text output, and reduced-motion CSS.
61+The visual grid is `aria-hidden` to avoid exposing thousands of fragmented cell
62+nodes; assistive technology receives the bounded plain-text representation.
63+`role="application"` is not used, browser zoom remains available, and browser or
64+system shortcuts are not prevented unless the synchronous consumer decision
65+requests it.
66+
67+No claim of full screen-reader equivalence is made. A product consumer should
68+run its own assistive-technology acceptance pass with the surrounding labels,
69+shortcut policy, announcements, and session behavior before presenting the
70+frontend as a complete terminal replacement.
71+
72+## Inherited output limits
73+
74+The current core model drops combining marks and does not coalesce ZWJ
75+sequences. The harness verifies composed browser input but does not claim that
76+the renderer can recover combining or ZWJ output absent from `core.Frame`.
@@ -0,0 +1,76 @@
1+# HTML browser harness
2+
3+This development-only Playwright harness verifies the embedded HTML frontend. It
4+loads `html/terminal.css` and `html/browser/terminal.js` directly; no production
5+server or transport is involved.
6+
7+## Locked environment
8+
9+`package-lock.json` pins Playwright 1.62.1 and these browser builds:
10+
11+| Engine | Browser version | Playwright revision |
12+|---|---:|---:|
13+| Chromium | 151.0.7922.34 | 1234 |
14+| Firefox | 153.0 | 1538 |
15+| WebKit | 26.5 | 2336 |
16+
17+Run locally when the host supports all browser builds:
18+
19+```sh
20+npm ci
21+npm run test:browser:install
22+npm run test:browser
23+```
24+
25+Use the matching official container on unsupported Linux distributions:
26+
27+```sh
28+npm run test:browser:docker
29+```
30+
31+CI installs the same locked browsers and runs the full matrix on Ubuntu.
32+
33+## Evidence
34+
35+The three-engine container matrix passes typed snapshot and row application,
36+hostile-text safety, complete validation before mutation, IME composition,
37+non-text keys, synchronous default-prevention decisions, pointer, wheel, resize,
38+focus, typed themes, lifecycle cleanup, and a self-only `script-src`/`style-src`
39+CSP. The CSP fixture uses external static assets and reports no policy violation.
40+
41+The Chromium reference performance gate applies ten sustained 120×40 snapshots,
42+fifty complete-row updates, and one 240×80 snapshot. The recorded implementation
43+run produced:
44+
45+| Workload | Recorded | Gate |
46+|---|---:|---:|
47+| 120×40 snapshot p95 | 28.2 ms | < 100 ms |
48+| 120-column row replacement p95 | 0.5 ms | < 33 ms |
49+| 240×80 snapshot | 68.3 ms | < 500 ms |
50+| Final visible cells/rows | 19,200 / 80 | exactly bounded by the frame |
51+
52+Performance numbers are reference evidence, not universal device guarantees.
53+The functional matrix and serial Chromium performance run write separate JSON
54+reports under `test-results/`. They are local/CI artifacts rather than committed
55+files.
56+
57+## Accessibility scope
58+
59+Automated checks cover a named group, a labeled keyboard-focusable text input,
60+visible focus styling, synchronized plain-text output, and reduced-motion CSS.
61+The visual grid is `aria-hidden` to avoid exposing thousands of fragmented cell
62+nodes; assistive technology receives the bounded plain-text representation.
63+`role="application"` is not used, browser zoom remains available, and browser or
64+system shortcuts are not prevented unless the synchronous consumer decision
65+requests it.
66+
67+No claim of full screen-reader equivalence is made. A product consumer should
68+run its own assistive-technology acceptance pass with the surrounding labels,
69+shortcut policy, announcements, and session behavior before presenting the
70+frontend as a complete terminal replacement.
71+
72+## Inherited output limits
73+
74+The current core model drops combining marks and does not coalesce ZWJ
75+sequences. The harness verifies composed browser input but does not claim that
76+the renderer can recover combining or ZWJ output absent from `core.Frame`.
A internal/htmlharness/performance.spec.js +76 -0
@@ -0,0 +1,76 @@
1+import { test, expect } from '@playwright/test';
2+import path from 'node:path';
3+
4+const root = process.cwd();
5+
6+/** @param {number} width @param {number} height @param {string} text */
7+function snapshot(width, height, text) {
8+ const cells = Array.from({ length: width }, (_, column) => ({ column, width: 1, text, style: 0 }));
9+ return {
10+ schemaVersion: 1,
11+ width,
12+ height,
13+ snapshot: true,
14+ styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
15+ rows: Array.from({ length: height }, (_, row) => ({ row, cells })),
16+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
17+ };
18+}
19+
20+test('keeps sustained snapshot and row replacement work bounded', async ({ page, browserName }, testInfo) => {
21+ test.skip(browserName !== 'chromium', 'reference performance budget uses pinned Chromium');
22+ await page.setContent('<div id="terminal"></div>');
23+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
24+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
25+
26+ const metrics = await page.evaluate(async ({ first, second, large }) => {
27+ const terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Performance terminal' });
28+ const settle = async () => {
29+ document.querySelector('.vev-terminal__viewport').getBoundingClientRect();
30+ await new Promise(requestAnimationFrame);
31+ };
32+ terminal.apply(first);
33+ await settle();
34+ const snapshots = [];
35+ for (let index = 0; index < 10; index += 1) {
36+ const start = performance.now();
37+ terminal.apply(index % 2 === 0 ? second : first);
38+ await settle();
39+ snapshots.push(performance.now() - start);
40+ }
41+
42+ const rows = [];
43+ for (let index = 0; index < 50; index += 1) {
44+ // Alternate both rows so every timed apply performs a real replacement
45+ // instead of re-applying the same row through the no-op reuse path.
46+ const rowUpdate = {
47+ ...(index % 2 === 0 ? first : second),
48+ snapshot: false,
49+ rows: [(index % 2 === 0 ? second : first).rows[20]]
50+ };
51+ const start = performance.now();
52+ terminal.apply(rowUpdate);
53+ await settle();
54+ rows.push(performance.now() - start);
55+ }
56+ const percentile = (values, ratio) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * ratio) - 1];
57+ const largeStart = performance.now();
58+ terminal.apply(large);
59+ await settle();
60+ const largeApply = performance.now() - largeStart;
61+ return {
62+ snapshotP95: percentile(snapshots, 0.95),
63+ rowP95: percentile(rows, 0.95),
64+ largeApply,
65+ cells: document.querySelectorAll('.vev-terminal__cell').length,
66+ rows: document.querySelectorAll('.vev-terminal__row').length
67+ };
68+ }, { first: snapshot(120, 40, 'A'), second: snapshot(120, 40, 'B'), large: snapshot(240, 80, 'C') });
69+
70+ testInfo.annotations.push({ type: 'performance', description: JSON.stringify(metrics) });
71+ expect(metrics.snapshotP95).toBeLessThan(100);
72+ expect(metrics.rowP95).toBeLessThan(33);
73+ expect(metrics.largeApply).toBeLessThan(500);
74+ expect(metrics.cells).toBe(240 * 80);
75+ expect(metrics.rows).toBe(80);
76+});
@@ -0,0 +1,76 @@
1+import { test, expect } from '@playwright/test';
2+import path from 'node:path';
3+
4+const root = process.cwd();
5+
6+/** @param {number} width @param {number} height @param {string} text */
7+function snapshot(width, height, text) {
8+ const cells = Array.from({ length: width }, (_, column) => ({ column, width: 1, text, style: 0 }));
9+ return {
10+ schemaVersion: 1,
11+ width,
12+ height,
13+ snapshot: true,
14+ styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
15+ rows: Array.from({ length: height }, (_, row) => ({ row, cells })),
16+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
17+ };
18+}
19+
20+test('keeps sustained snapshot and row replacement work bounded', async ({ page, browserName }, testInfo) => {
21+ test.skip(browserName !== 'chromium', 'reference performance budget uses pinned Chromium');
22+ await page.setContent('<div id="terminal"></div>');
23+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
24+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
25+
26+ const metrics = await page.evaluate(async ({ first, second, large }) => {
27+ const terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Performance terminal' });
28+ const settle = async () => {
29+ document.querySelector('.vev-terminal__viewport').getBoundingClientRect();
30+ await new Promise(requestAnimationFrame);
31+ };
32+ terminal.apply(first);
33+ await settle();
34+ const snapshots = [];
35+ for (let index = 0; index < 10; index += 1) {
36+ const start = performance.now();
37+ terminal.apply(index % 2 === 0 ? second : first);
38+ await settle();
39+ snapshots.push(performance.now() - start);
40+ }
41+
42+ const rows = [];
43+ for (let index = 0; index < 50; index += 1) {
44+ // Alternate both rows so every timed apply performs a real replacement
45+ // instead of re-applying the same row through the no-op reuse path.
46+ const rowUpdate = {
47+ ...(index % 2 === 0 ? first : second),
48+ snapshot: false,
49+ rows: [(index % 2 === 0 ? second : first).rows[20]]
50+ };
51+ const start = performance.now();
52+ terminal.apply(rowUpdate);
53+ await settle();
54+ rows.push(performance.now() - start);
55+ }
56+ const percentile = (values, ratio) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * ratio) - 1];
57+ const largeStart = performance.now();
58+ terminal.apply(large);
59+ await settle();
60+ const largeApply = performance.now() - largeStart;
61+ return {
62+ snapshotP95: percentile(snapshots, 0.95),
63+ rowP95: percentile(rows, 0.95),
64+ largeApply,
65+ cells: document.querySelectorAll('.vev-terminal__cell').length,
66+ rows: document.querySelectorAll('.vev-terminal__row').length
67+ };
68+ }, { first: snapshot(120, 40, 'A'), second: snapshot(120, 40, 'B'), large: snapshot(240, 80, 'C') });
69+
70+ testInfo.annotations.push({ type: 'performance', description: JSON.stringify(metrics) });
71+ expect(metrics.snapshotP95).toBeLessThan(100);
72+ expect(metrics.rowP95).toBeLessThan(33);
73+ expect(metrics.largeApply).toBeLessThan(500);
74+ expect(metrics.cells).toBe(240 * 80);
75+ expect(metrics.rows).toBe(80);
76+});
A internal/htmlharness/terminal.spec.js +452 -0
@@ -0,0 +1,452 @@
1+import { test, expect } from '@playwright/test';
2+import fs from 'node:fs/promises';
3+import path from 'node:path';
4+
5+const root = process.cwd();
6+
7+async function mount(page) {
8+ await page.setContent('<main><div id="terminal"></div></main>');
9+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
10+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
11+ await page.evaluate(() => {
12+ globalThis.events = [];
13+ globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
14+ label: 'Development terminal',
15+ decide(event) {
16+ const preventDefault = (event.type === 'key' && event.key === 'ArrowUp') ||
17+ (event.type === 'pointer' && event.action === 'move');
18+ return { emit: true, preventDefault };
19+ },
20+ send(event) { globalThis.events.push(event); }
21+ });
22+ });
23+}
24+
25+test('applies the Go renderer contract fixture', async ({ page }) => {
26+ await mount(page);
27+ const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
28+ await page.evaluate(value => terminal.apply(value), fixture);
29+ await expect(page.locator('.vev-terminal__cell')).toHaveText(['A', '界']);
30+ await expect(page.locator('.vev-terminal__cursor')).toBeVisible();
31+ await page.evaluate(value => terminal.apply({
32+ ...value,
33+ snapshot: false,
34+ rows: [],
35+ styles: [],
36+ cursor: { ...value.cursor, visible: false }
37+ }), fixture);
38+ await expect(page.locator('.vev-terminal__cursor')).toBeHidden();
39+});
40+
41+test('reuses text-only cells but rebuilds changed styles and space runs', async ({ page }) => {
42+ await mount(page);
43+ const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
44+ const result = await page.evaluate(value => {
45+ terminal.apply(value);
46+ const original = document.querySelector('.vev-terminal__cell');
47+ const next = structuredClone(value);
48+ next.rows[0].cells[0].text = 'B';
49+ terminal.apply(next);
50+ const reused = original === document.querySelector('.vev-terminal__cell') && original.textContent === 'B';
51+ const styled = structuredClone(next);
52+ styled.styles[0].bold = !styled.styles[0].bold;
53+ terminal.apply(styled);
54+ const rebuiltStyle = original !== document.querySelector('.vev-terminal__cell');
55+ const beforeSpace = document.querySelector('.vev-terminal__cell');
56+ const spaces = structuredClone(styled);
57+ spaces.rows[0].cells[0].text = ' ';
58+ terminal.apply(spaces);
59+ const rebuiltSpace = beforeSpace !== document.querySelector('.vev-terminal__cell');
60+ terminal.apply({ ...next, snapshot: false });
61+ return { reused, rebuiltStyle, rebuiltSpace, text: document.querySelector('.vev-terminal__cell').textContent };
62+ }, fixture);
63+ expect(result).toEqual({ reused: true, rebuiltStyle: true, rebuiltSpace: true, text: 'B' });
64+});
65+
66+test('default colors inherit without redundant per-cell properties', async ({ page }) => {
67+ await mount(page);
68+ const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
69+ const result = await page.evaluate(value => {
70+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
71+ terminal.apply({ ...value, styles: [plain], rows: value.rows.map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, style: 0 })) })) });
72+ const cell = document.querySelector('.vev-terminal__cell');
73+ const properties = ['--vev-cell-fg', '--vev-cell-bg', '--vev-cell-underline'].map(name => cell.style.getPropertyValue(name));
74+ document.querySelector('#terminal').style.setProperty('--vev-fg', 'rgb(12, 34, 56)');
75+ document.querySelector('#terminal').style.setProperty('--vev-bg', 'rgb(65, 43, 21)');
76+ return { properties, foreground: getComputedStyle(cell).color, background: getComputedStyle(cell).backgroundColor };
77+ }, fixture);
78+ expect(result).toEqual({ properties: ['', '', ''], foreground: 'rgb(12, 34, 56)', background: 'rgb(65, 43, 21)' });
79+});
80+
81+test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => {
82+ await mount(page);
83+ await page.evaluate(() => terminal.apply({
84+ schemaVersion: 1, width: 5, height: 1, snapshot: true,
85+ styles: [
86+ { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} },
87+ { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true }
88+ ],
89+ rows: [{row:0, cells:[
90+ {column:0,width:1,text:' ',style:0},
91+ {column:1,width:1,text:'A',style:0},
92+ {column:2,width:1,text:' ',style:1},
93+ {column:3,width:1,text:' ',style:0},
94+ {column:4,width:1,text:' ',style:0}
95+ ]}],
96+ cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
97+ }));
98+ const cells = page.locator('.vev-terminal__cell');
99+ await expect(cells).toHaveCount(4);
100+ await expect(cells.nth(0)).toHaveCSS('overflow', 'visible');
101+ await expect(cells.nth(1)).toHaveCSS('overflow', 'hidden');
102+ await expect(cells.nth(2)).toHaveCSS('overflow', 'hidden');
103+ expect(await cells.nth(0).evaluate(node => node.getBoundingClientRect().width)).toBeGreaterThan(0);
104+ await expect(cells.nth(3)).toHaveAttribute('data-width', '2');
105+ expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' A ');
106+});
107+
108+test('keeps wide blanks separate from adjacent space runs', async ({ page }) => {
109+ await mount(page);
110+ const geometry = await page.evaluate(() => {
111+ terminal.apply({
112+ schemaVersion: 1, width: 4, height: 1, snapshot: true,
113+ styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }],
114+ rows: [{row:0, cells:[
115+ {column:0,width:1,text:' ',style:0},
116+ {column:1,width:2,text:' ',style:0},
117+ {column:3,width:1,text:' ',style:0}
118+ ]}],
119+ cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
120+ });
121+ return [...document.querySelectorAll('.vev-terminal__cell')].map(node => {
122+ const range = document.createRange();
123+ range.selectNodeContents(node);
124+ return {x: range.getBoundingClientRect().x, width: node.getBoundingClientRect().width};
125+ });
126+ });
127+ expect(geometry).toHaveLength(3);
128+ expect(geometry[1].x - geometry[0].x).toBeCloseTo(geometry[0].width, 1);
129+ expect(geometry[2].x - geometry[1].x).toBeCloseTo(geometry[1].width, 1);
130+ expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' ');
131+});
132+
133+test('applies typed rows without interpreting terminal text as markup', async ({ page }) => {
134+ await mount(page);
135+ const hostile = '<img src=x onerror="globalThis.injected=true">';
136+ const update = {
137+ schemaVersion: 1,
138+ width: 3,
139+ height: 1,
140+ snapshot: true,
141+ styles: [{
142+ foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
143+ background: { kind: 0 },
144+ underlineColor: { kind: 0 },
145+ bold: true
146+ }],
147+ rows: [{ row: 0, cells: [
148+ { column: 0, width: 1, text: hostile, style: 0 },
149+ { column: 1, width: 2, text: '界', style: 0 }
150+ ] }],
151+ cursor: { row: 0, column: 1, visible: true, style: 3, styleSet: true }
152+ };
153+
154+ await page.evaluate(value => terminal.apply(value), update);
155+ await expect(page.locator('.vev-terminal__cell')).toHaveCount(2);
156+ await expect(page.locator('.vev-terminal__cell').first()).toHaveText(hostile);
157+ await expect(page.locator('img')).toHaveCount(0);
158+ await expect(page.locator('.vev-terminal')).toHaveAttribute('aria-label', 'Development terminal');
159+ await expect(page.getByRole('textbox', { name: 'Development terminal input' })).toBeAttached();
160+ await expect(page.locator('.vev-terminal__accessible-output')).toHaveText(`${hostile}界`);
161+ expect(await page.evaluate(() => globalThis.injected)).toBeUndefined();
162+});
163+
164+test('keeps explicit terminal columns under fallback fonts and zoom', async ({ page }) => {
165+ await mount(page);
166+ await page.evaluate(() => {
167+ document.body.style.zoom = '125%';
168+ document.querySelector('#terminal').style.fontFamily = 'serif';
169+ terminal.apply({
170+ schemaVersion: 1, width: 4, height: 1, snapshot: true,
171+ styles: [{
172+ bold: true, italic: true, dim: true, blink: true, strikethrough: true,
173+ underline: true, underlineStyle: 3,
174+ foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
175+ background: { kind: 1, index: 4 },
176+ underlineColor: { kind: 1, index: 12 }
177+ }],
178+ rows: [{ row: 0, cells: [
179+ { column: 0, width: 1, text: 'A', style: 0 },
180+ { column: 1, width: 2, text: '界', style: 0 },
181+ { column: 3, width: 1, text: 'B', style: 0 }
182+ ] }],
183+ cursor: { row: 0, column: 1, visible: true, style: 5, styleSet: true }
184+ });
185+ });
186+ const widths = await page.locator('.vev-terminal__cell').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().width));
187+ expect(widths[1] / widths[0]).toBeCloseTo(2, 1);
188+ expect(widths[2] / widths[0]).toBeCloseTo(1, 1);
189+ const firstCell = page.locator('.vev-terminal__cell').first();
190+ await expect(firstCell).toHaveClass(/vev-terminal__cell--underline-wavy/);
191+ expect(await firstCell.evaluate(node => node.style.getPropertyValue('--vev-cell-fg'))).toBe('rgb(1 2 3)');
192+ await expect(page.locator('.vev-terminal__cursor')).toHaveClass(/vev-terminal__cursor--bar/);
193+});
194+
195+test('rejects a malformed update before changing the DOM', async ({ page }) => {
196+ await mount(page);
197+ const snapshot = {
198+ schemaVersion: 1, width: 1, height: 1, snapshot: true,
199+ styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
200+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
201+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
202+ };
203+ await page.evaluate(value => terminal.apply(value), snapshot);
204+
205+ const message = await page.evaluate(value => {
206+ try {
207+ terminal.apply({ ...value, rows: [{ row: 0, cells: [{ column: 0, width: 2, text: 'X', style: 0 }] }] });
208+ return '';
209+ } catch (error) {
210+ return error.message;
211+ }
212+ }, snapshot);
213+ expect(message).toContain('row coverage');
214+ await expect(page.locator('.vev-terminal__cell')).toHaveText('A');
215+ const staleVersion = await page.evaluate(value => {
216+ try {
217+ terminal.apply({ ...value, schemaVersion: 2 });
218+ return '';
219+ } catch (error) {
220+ return error.message;
221+ }
222+ }, snapshot);
223+ expect(staleVersion).toContain('unsupported update schema');
224+
225+ const duplicateMount = await page.evaluate(() => {
226+ try {
227+ VevTerminal.mount(document.querySelector('#terminal'), { label: 'Duplicate' });
228+ return '';
229+ } catch (error) {
230+ return error.message;
231+ }
232+ });
233+ expect(duplicateMount).toContain('already owns a terminal');
234+});
235+
236+test('rejects colors with a payload from another color kind', async ({ page }) => {
237+ await mount(page);
238+ const message = await page.evaluate(() => {
239+ const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } };
240+ try {
241+ terminal.apply({
242+ schemaVersion: 1, width: 1, height: 1, snapshot: true,
243+ styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }],
244+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
245+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
246+ });
247+ return '';
248+ } catch (error) {
249+ return error.message;
250+ }
251+ });
252+ expect(message).toContain('indexed color has an rgb payload');
253+});
254+
255+test('emits text and synchronously decides key default prevention', async ({ page }) => {
256+ await mount(page);
257+ const input = page.locator('.vev-terminal__input');
258+ await input.focus();
259+ await input.evaluate(node => node.dispatchEvent(new InputEvent('beforeinput', {
260+ bubbles: true,
261+ cancelable: true,
262+ inputType: 'insertText',
263+ data: 'é'
264+ })));
265+ const keyDispatchResult = await input.evaluate(node => node.dispatchEvent(new KeyboardEvent('keydown', {
266+ bubbles: true,
267+ cancelable: true,
268+ key: 'ArrowUp',
269+ code: 'ArrowUp'
270+ })));
271+
272+ const events = await page.evaluate(() => globalThis.events);
273+ expect(events.some(event => event.type === 'text' && event.text === 'é')).toBeTruthy();
274+ expect(events.some(event => event.type === 'key' && event.key === 'ArrowUp')).toBeTruthy();
275+ expect(keyDispatchResult).toBeFalsy();
276+});
277+
278+test('supports soft keyboard editing without unidentified keys or duplicate desktop edits', async ({ page }) => {
279+ await mount(page);
280+ const input = page.locator('.vev-terminal__input');
281+ await input.focus();
282+ await input.evaluate(node => {
283+ node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true }));
284+ for (const inputType of ['deleteContentBackward', 'deleteContentForward', 'insertLineBreak']) {
285+ node.dispatchEvent(new InputEvent('beforeinput', { inputType, data: null, bubbles: true, cancelable: true }));
286+ }
287+ });
288+ await page.keyboard.press('Backspace');
289+ await page.keyboard.press('Enter');
290+ const keys = await page.evaluate(() => globalThis.events.filter(event => event.type === 'key').map(event => event.key));
291+ expect(keys).toEqual(['Backspace', 'Delete', 'Enter', 'Backspace', 'Enter']);
292+ expect(await input.evaluate(node => getComputedStyle(node).fontSize)).toBe('16px');
293+});
294+
295+test('rejects oversized input without throwing from DOM callbacks', async ({ page }) => {
296+ const errors = [];
297+ page.on('pageerror', error => errors.push(error.message));
298+ await mount(page);
299+ await page.evaluate(() => {
300+ terminal.destroy();
301+ globalThis.events = [];
302+ globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
303+ label: 'Bounded terminal',
304+ limits: { maxTextBytes: 1, maxPasteBytes: 1 },
305+ send(event) { globalThis.events.push(event); }
306+ });
307+ });
308+ const input = page.locator('.vev-terminal__input');
309+ const rejected = await input.evaluate(node => {
310+ const textAccepted = node.dispatchEvent(new InputEvent('beforeinput', {
311+ bubbles: true, cancelable: true, inputType: 'insertText', data: 'é'
312+ }));
313+ const paste = new Event('paste', { bubbles: true, cancelable: true });
314+ Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'xx' } });
315+ const pasteAccepted = node.dispatchEvent(paste);
316+ return { textAccepted, pasteAccepted };
317+ });
318+ expect(rejected).toEqual({ textAccepted: false, pasteAccepted: false });
319+ expect(await page.evaluate(() => globalThis.events)).toEqual([]);
320+ expect(errors).toEqual([]);
321+});
322+
323+test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => {
324+ await mount(page);
325+ const snapshot = {
326+ schemaVersion: 1, width: 2, height: 1, snapshot: true,
327+ styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
328+ rows: [{ row: 0, cells: [
329+ { column: 0, width: 1, text: 'A', style: 0 },
330+ { column: 1, width: 1, text: 'B', style: 0 }
331+ ] }],
332+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
333+ };
334+ await page.evaluate(value => terminal.apply(value), snapshot);
335+ const input = page.locator('.vev-terminal__input');
336+ await input.focus();
337+ const compositionValue = await input.evaluate(node => {
338+ node.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
339+ node.value = 'e';
340+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText', data: 'e', isComposing: true }));
341+ const retainedValue = node.value;
342+ node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'e' }));
343+ node.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'é' }));
344+ node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'é' }));
345+ const paste = new Event('paste', { bubbles: true, cancelable: true });
346+ Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'paste' } });
347+ node.dispatchEvent(paste);
348+ return retainedValue;
349+ });
350+
351+ const viewport = page.locator('.vev-terminal__viewport');
352+ const box = await viewport.boundingBox();
353+ await page.mouse.click(box.x + 1, box.y + 1);
354+ const pointerMoveAccepted = await viewport.evaluate(node => node.dispatchEvent(new PointerEvent('pointermove', {
355+ bubbles: true, cancelable: true, button: -1, buttons: 0, clientX: 1, clientY: 1
356+ })));
357+ await viewport.dispatchEvent('wheel', { deltaX: 1, deltaY: 2, deltaMode: 0, clientX: box.x + 1, clientY: box.y + 1 });
358+ await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'resize'))).toBeTruthy();
359+ await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'pointer' && event.action === 'move'))).toBeTruthy();
360+
361+ const events = await page.evaluate(() => globalThis.events);
362+ expect(compositionValue).toBe('e');
363+ expect(events.filter(event => event.type === 'text' && event.text === 'é')).toHaveLength(1);
364+ expect(events.some(event => event.type === 'paste' && event.text === 'paste')).toBeTruthy();
365+ expect(events.some(event => event.type === 'pointer' && event.action === 'down')).toBeTruthy();
366+ expect(pointerMoveAccepted).toBeFalsy();
367+ expect(events.some(event => event.type === 'wheel' && event.row === 0 && event.column === 0)).toBeTruthy();
368+ expect(events.some(event => event.type === 'focus' && event.focused)).toBeTruthy();
369+});
370+
371+test('applies dynamic terminal styles under a self-only CSP', async ({ page }) => {
372+ const violations = [];
373+ page.on('console', message => {
374+ if (message.text().includes('Content Security Policy')) violations.push(message.text());
375+ });
376+ const [css, script] = await Promise.all([
377+ fs.readFile(path.join(root, 'html/terminal.css'), 'utf8'),
378+ fs.readFile(path.join(root, 'html/browser/terminal.js'), 'utf8')
379+ ]);
380+ await page.route('http://vev.test/**', async route => {
381+ const pathname = new URL(route.request().url()).pathname;
382+ if (pathname === '/terminal.css') return route.fulfill({ contentType: 'text/css', body: css });
383+ if (pathname === '/terminal.js') return route.fulfill({ contentType: 'text/javascript', body: script });
384+ return route.fulfill({
385+ contentType: 'text/html',
386+ headers: { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'" },
387+ body: '<link rel="stylesheet" href="/terminal.css"><div id="terminal"></div><script src="/terminal.js"></script>'
388+ });
389+ });
390+ await page.goto('http://vev.test/');
391+ await page.waitForFunction(() => globalThis.VevTerminal);
392+ await page.evaluate(() => {
393+ globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' });
394+ terminal.apply({
395+ schemaVersion: 1, width: 1, height: 1, snapshot: true,
396+ styles: [{
397+ foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
398+ background: { kind: 1, index: 4 },
399+ underlineColor: { kind: 0 }
400+ }],
401+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
402+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
403+ });
404+ });
405+ await expect(page.locator('.vev-terminal__cell')).toHaveCSS('color', 'rgb(1, 2, 3)');
406+ expect(violations).toEqual([]);
407+});
408+
409+test('restores host attributes and theme properties on destroy', async ({ page }) => {
410+ await page.setContent('<div id="terminal" role="region" aria-label="Host label" style="--vev-fg:rgb(9 8 7)"></div>');
411+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
412+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
413+ await page.evaluate(() => {
414+ const instance = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Mounted label' });
415+ const color = { r: 1, g: 2, b: 3 };
416+ instance.setTheme({
417+ foreground: color, background: color, cursor: color, selection: color,
418+ selectionText: color, palette: Array.from({ length: 16 }, () => color)
419+ });
420+ instance.destroy();
421+ });
422+ await expect(page.locator('#terminal')).toHaveAttribute('role', 'region');
423+ await expect(page.locator('#terminal')).toHaveAttribute('aria-label', 'Host label');
424+ expect(await page.locator('#terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toBe('rgb(9 8 7)');
425+});
426+
427+test('updates complete rows, applies typed themes, and destroys owned state', async ({ page }) => {
428+ await mount(page);
429+ const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
430+ await page.evaluate(value => terminal.apply(value), {
431+ schemaVersion: 1, width: 1, height: 1, snapshot: true, styles: [style],
432+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
433+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
434+ });
435+ await page.evaluate(value => terminal.apply(value), {
436+ schemaVersion: 1, width: 1, height: 1, snapshot: false, styles: [style],
437+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }],
438+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
439+ });
440+ await expect(page.locator('.vev-terminal__accessible-output')).toHaveText('B');
441+
442+ const color = { r: 1, g: 2, b: 3 };
443+ await page.evaluate(value => terminal.setTheme(value), {
444+ foreground: color, background: color, cursor: color, selection: color,
445+ selectionText: color, palette: Array.from({ length: 16 }, () => color)
446+ });
447+ expect(await page.locator('.vev-terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toContain('1 2 3');
448+
449+ await page.evaluate(() => terminal.destroy());
450+ await expect(page.locator('.vev-terminal__viewport')).toHaveCount(0);
451+ await expect(page.locator('#terminal')).not.toHaveClass(/vev-terminal/);
452+});
@@ -0,0 +1,452 @@
1+import { test, expect } from '@playwright/test';
2+import fs from 'node:fs/promises';
3+import path from 'node:path';
4+
5+const root = process.cwd();
6+
7+async function mount(page) {
8+ await page.setContent('<main><div id="terminal"></div></main>');
9+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
10+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
11+ await page.evaluate(() => {
12+ globalThis.events = [];
13+ globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
14+ label: 'Development terminal',
15+ decide(event) {
16+ const preventDefault = (event.type === 'key' && event.key === 'ArrowUp') ||
17+ (event.type === 'pointer' && event.action === 'move');
18+ return { emit: true, preventDefault };
19+ },
20+ send(event) { globalThis.events.push(event); }
21+ });
22+ });
23+}
24+
25+test('applies the Go renderer contract fixture', async ({ page }) => {
26+ await mount(page);
27+ const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
28+ await page.evaluate(value => terminal.apply(value), fixture);
29+ await expect(page.locator('.vev-terminal__cell')).toHaveText(['A', '界']);
30+ await expect(page.locator('.vev-terminal__cursor')).toBeVisible();
31+ await page.evaluate(value => terminal.apply({
32+ ...value,
33+ snapshot: false,
34+ rows: [],
35+ styles: [],
36+ cursor: { ...value.cursor, visible: false }
37+ }), fixture);
38+ await expect(page.locator('.vev-terminal__cursor')).toBeHidden();
39+});
40+
41+test('reuses text-only cells but rebuilds changed styles and space runs', async ({ page }) => {
42+ await mount(page);
43+ const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
44+ const result = await page.evaluate(value => {
45+ terminal.apply(value);
46+ const original = document.querySelector('.vev-terminal__cell');
47+ const next = structuredClone(value);
48+ next.rows[0].cells[0].text = 'B';
49+ terminal.apply(next);
50+ const reused = original === document.querySelector('.vev-terminal__cell') && original.textContent === 'B';
51+ const styled = structuredClone(next);
52+ styled.styles[0].bold = !styled.styles[0].bold;
53+ terminal.apply(styled);
54+ const rebuiltStyle = original !== document.querySelector('.vev-terminal__cell');
55+ const beforeSpace = document.querySelector('.vev-terminal__cell');
56+ const spaces = structuredClone(styled);
57+ spaces.rows[0].cells[0].text = ' ';
58+ terminal.apply(spaces);
59+ const rebuiltSpace = beforeSpace !== document.querySelector('.vev-terminal__cell');
60+ terminal.apply({ ...next, snapshot: false });
61+ return { reused, rebuiltStyle, rebuiltSpace, text: document.querySelector('.vev-terminal__cell').textContent };
62+ }, fixture);
63+ expect(result).toEqual({ reused: true, rebuiltStyle: true, rebuiltSpace: true, text: 'B' });
64+});
65+
66+test('default colors inherit without redundant per-cell properties', async ({ page }) => {
67+ await mount(page);
68+ const fixture = JSON.parse(await fs.readFile(path.join(root, 'internal/htmlharness/testdata/snapshot.json'), 'utf8'));
69+ const result = await page.evaluate(value => {
70+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
71+ terminal.apply({ ...value, styles: [plain], rows: value.rows.map(row => ({ ...row, cells: row.cells.map(cell => ({ ...cell, style: 0 })) })) });
72+ const cell = document.querySelector('.vev-terminal__cell');
73+ const properties = ['--vev-cell-fg', '--vev-cell-bg', '--vev-cell-underline'].map(name => cell.style.getPropertyValue(name));
74+ document.querySelector('#terminal').style.setProperty('--vev-fg', 'rgb(12, 34, 56)');
75+ document.querySelector('#terminal').style.setProperty('--vev-bg', 'rgb(65, 43, 21)');
76+ return { properties, foreground: getComputedStyle(cell).color, background: getComputedStyle(cell).backgroundColor };
77+ }, fixture);
78+ expect(result).toEqual({ properties: ['', '', ''], foreground: 'rgb(12, 34, 56)', background: 'rgb(65, 43, 21)' });
79+});
80+
81+test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => {
82+ await mount(page);
83+ await page.evaluate(() => terminal.apply({
84+ schemaVersion: 1, width: 5, height: 1, snapshot: true,
85+ styles: [
86+ { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} },
87+ { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true }
88+ ],
89+ rows: [{row:0, cells:[
90+ {column:0,width:1,text:' ',style:0},
91+ {column:1,width:1,text:'A',style:0},
92+ {column:2,width:1,text:' ',style:1},
93+ {column:3,width:1,text:' ',style:0},
94+ {column:4,width:1,text:' ',style:0}
95+ ]}],
96+ cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
97+ }));
98+ const cells = page.locator('.vev-terminal__cell');
99+ await expect(cells).toHaveCount(4);
100+ await expect(cells.nth(0)).toHaveCSS('overflow', 'visible');
101+ await expect(cells.nth(1)).toHaveCSS('overflow', 'hidden');
102+ await expect(cells.nth(2)).toHaveCSS('overflow', 'hidden');
103+ expect(await cells.nth(0).evaluate(node => node.getBoundingClientRect().width)).toBeGreaterThan(0);
104+ await expect(cells.nth(3)).toHaveAttribute('data-width', '2');
105+ expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' A ');
106+});
107+
108+test('keeps wide blanks separate from adjacent space runs', async ({ page }) => {
109+ await mount(page);
110+ const geometry = await page.evaluate(() => {
111+ terminal.apply({
112+ schemaVersion: 1, width: 4, height: 1, snapshot: true,
113+ styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }],
114+ rows: [{row:0, cells:[
115+ {column:0,width:1,text:' ',style:0},
116+ {column:1,width:2,text:' ',style:0},
117+ {column:3,width:1,text:' ',style:0}
118+ ]}],
119+ cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
120+ });
121+ return [...document.querySelectorAll('.vev-terminal__cell')].map(node => {
122+ const range = document.createRange();
123+ range.selectNodeContents(node);
124+ return {x: range.getBoundingClientRect().x, width: node.getBoundingClientRect().width};
125+ });
126+ });
127+ expect(geometry).toHaveLength(3);
128+ expect(geometry[1].x - geometry[0].x).toBeCloseTo(geometry[0].width, 1);
129+ expect(geometry[2].x - geometry[1].x).toBeCloseTo(geometry[1].width, 1);
130+ expect(await page.locator('.vev-terminal__accessible-output').textContent()).toBe(' ');
131+});
132+
133+test('applies typed rows without interpreting terminal text as markup', async ({ page }) => {
134+ await mount(page);
135+ const hostile = '<img src=x onerror="globalThis.injected=true">';
136+ const update = {
137+ schemaVersion: 1,
138+ width: 3,
139+ height: 1,
140+ snapshot: true,
141+ styles: [{
142+ foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
143+ background: { kind: 0 },
144+ underlineColor: { kind: 0 },
145+ bold: true
146+ }],
147+ rows: [{ row: 0, cells: [
148+ { column: 0, width: 1, text: hostile, style: 0 },
149+ { column: 1, width: 2, text: '界', style: 0 }
150+ ] }],
151+ cursor: { row: 0, column: 1, visible: true, style: 3, styleSet: true }
152+ };
153+
154+ await page.evaluate(value => terminal.apply(value), update);
155+ await expect(page.locator('.vev-terminal__cell')).toHaveCount(2);
156+ await expect(page.locator('.vev-terminal__cell').first()).toHaveText(hostile);
157+ await expect(page.locator('img')).toHaveCount(0);
158+ await expect(page.locator('.vev-terminal')).toHaveAttribute('aria-label', 'Development terminal');
159+ await expect(page.getByRole('textbox', { name: 'Development terminal input' })).toBeAttached();
160+ await expect(page.locator('.vev-terminal__accessible-output')).toHaveText(`${hostile}界`);
161+ expect(await page.evaluate(() => globalThis.injected)).toBeUndefined();
162+});
163+
164+test('keeps explicit terminal columns under fallback fonts and zoom', async ({ page }) => {
165+ await mount(page);
166+ await page.evaluate(() => {
167+ document.body.style.zoom = '125%';
168+ document.querySelector('#terminal').style.fontFamily = 'serif';
169+ terminal.apply({
170+ schemaVersion: 1, width: 4, height: 1, snapshot: true,
171+ styles: [{
172+ bold: true, italic: true, dim: true, blink: true, strikethrough: true,
173+ underline: true, underlineStyle: 3,
174+ foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
175+ background: { kind: 1, index: 4 },
176+ underlineColor: { kind: 1, index: 12 }
177+ }],
178+ rows: [{ row: 0, cells: [
179+ { column: 0, width: 1, text: 'A', style: 0 },
180+ { column: 1, width: 2, text: '界', style: 0 },
181+ { column: 3, width: 1, text: 'B', style: 0 }
182+ ] }],
183+ cursor: { row: 0, column: 1, visible: true, style: 5, styleSet: true }
184+ });
185+ });
186+ const widths = await page.locator('.vev-terminal__cell').evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().width));
187+ expect(widths[1] / widths[0]).toBeCloseTo(2, 1);
188+ expect(widths[2] / widths[0]).toBeCloseTo(1, 1);
189+ const firstCell = page.locator('.vev-terminal__cell').first();
190+ await expect(firstCell).toHaveClass(/vev-terminal__cell--underline-wavy/);
191+ expect(await firstCell.evaluate(node => node.style.getPropertyValue('--vev-cell-fg'))).toBe('rgb(1 2 3)');
192+ await expect(page.locator('.vev-terminal__cursor')).toHaveClass(/vev-terminal__cursor--bar/);
193+});
194+
195+test('rejects a malformed update before changing the DOM', async ({ page }) => {
196+ await mount(page);
197+ const snapshot = {
198+ schemaVersion: 1, width: 1, height: 1, snapshot: true,
199+ styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
200+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
201+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
202+ };
203+ await page.evaluate(value => terminal.apply(value), snapshot);
204+
205+ const message = await page.evaluate(value => {
206+ try {
207+ terminal.apply({ ...value, rows: [{ row: 0, cells: [{ column: 0, width: 2, text: 'X', style: 0 }] }] });
208+ return '';
209+ } catch (error) {
210+ return error.message;
211+ }
212+ }, snapshot);
213+ expect(message).toContain('row coverage');
214+ await expect(page.locator('.vev-terminal__cell')).toHaveText('A');
215+ const staleVersion = await page.evaluate(value => {
216+ try {
217+ terminal.apply({ ...value, schemaVersion: 2 });
218+ return '';
219+ } catch (error) {
220+ return error.message;
221+ }
222+ }, snapshot);
223+ expect(staleVersion).toContain('unsupported update schema');
224+
225+ const duplicateMount = await page.evaluate(() => {
226+ try {
227+ VevTerminal.mount(document.querySelector('#terminal'), { label: 'Duplicate' });
228+ return '';
229+ } catch (error) {
230+ return error.message;
231+ }
232+ });
233+ expect(duplicateMount).toContain('already owns a terminal');
234+});
235+
236+test('rejects colors with a payload from another color kind', async ({ page }) => {
237+ await mount(page);
238+ const message = await page.evaluate(() => {
239+ const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } };
240+ try {
241+ terminal.apply({
242+ schemaVersion: 1, width: 1, height: 1, snapshot: true,
243+ styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }],
244+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
245+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
246+ });
247+ return '';
248+ } catch (error) {
249+ return error.message;
250+ }
251+ });
252+ expect(message).toContain('indexed color has an rgb payload');
253+});
254+
255+test('emits text and synchronously decides key default prevention', async ({ page }) => {
256+ await mount(page);
257+ const input = page.locator('.vev-terminal__input');
258+ await input.focus();
259+ await input.evaluate(node => node.dispatchEvent(new InputEvent('beforeinput', {
260+ bubbles: true,
261+ cancelable: true,
262+ inputType: 'insertText',
263+ data: 'é'
264+ })));
265+ const keyDispatchResult = await input.evaluate(node => node.dispatchEvent(new KeyboardEvent('keydown', {
266+ bubbles: true,
267+ cancelable: true,
268+ key: 'ArrowUp',
269+ code: 'ArrowUp'
270+ })));
271+
272+ const events = await page.evaluate(() => globalThis.events);
273+ expect(events.some(event => event.type === 'text' && event.text === 'é')).toBeTruthy();
274+ expect(events.some(event => event.type === 'key' && event.key === 'ArrowUp')).toBeTruthy();
275+ expect(keyDispatchResult).toBeFalsy();
276+});
277+
278+test('supports soft keyboard editing without unidentified keys or duplicate desktop edits', async ({ page }) => {
279+ await mount(page);
280+ const input = page.locator('.vev-terminal__input');
281+ await input.focus();
282+ await input.evaluate(node => {
283+ node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Unidentified', bubbles: true, cancelable: true }));
284+ for (const inputType of ['deleteContentBackward', 'deleteContentForward', 'insertLineBreak']) {
285+ node.dispatchEvent(new InputEvent('beforeinput', { inputType, data: null, bubbles: true, cancelable: true }));
286+ }
287+ });
288+ await page.keyboard.press('Backspace');
289+ await page.keyboard.press('Enter');
290+ const keys = await page.evaluate(() => globalThis.events.filter(event => event.type === 'key').map(event => event.key));
291+ expect(keys).toEqual(['Backspace', 'Delete', 'Enter', 'Backspace', 'Enter']);
292+ expect(await input.evaluate(node => getComputedStyle(node).fontSize)).toBe('16px');
293+});
294+
295+test('rejects oversized input without throwing from DOM callbacks', async ({ page }) => {
296+ const errors = [];
297+ page.on('pageerror', error => errors.push(error.message));
298+ await mount(page);
299+ await page.evaluate(() => {
300+ terminal.destroy();
301+ globalThis.events = [];
302+ globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), {
303+ label: 'Bounded terminal',
304+ limits: { maxTextBytes: 1, maxPasteBytes: 1 },
305+ send(event) { globalThis.events.push(event); }
306+ });
307+ });
308+ const input = page.locator('.vev-terminal__input');
309+ const rejected = await input.evaluate(node => {
310+ const textAccepted = node.dispatchEvent(new InputEvent('beforeinput', {
311+ bubbles: true, cancelable: true, inputType: 'insertText', data: 'é'
312+ }));
313+ const paste = new Event('paste', { bubbles: true, cancelable: true });
314+ Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'xx' } });
315+ const pasteAccepted = node.dispatchEvent(paste);
316+ return { textAccepted, pasteAccepted };
317+ });
318+ expect(rejected).toEqual({ textAccepted: false, pasteAccepted: false });
319+ expect(await page.evaluate(() => globalThis.events)).toEqual([]);
320+ expect(errors).toEqual([]);
321+});
322+
323+test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => {
324+ await mount(page);
325+ const snapshot = {
326+ schemaVersion: 1, width: 2, height: 1, snapshot: true,
327+ styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
328+ rows: [{ row: 0, cells: [
329+ { column: 0, width: 1, text: 'A', style: 0 },
330+ { column: 1, width: 1, text: 'B', style: 0 }
331+ ] }],
332+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
333+ };
334+ await page.evaluate(value => terminal.apply(value), snapshot);
335+ const input = page.locator('.vev-terminal__input');
336+ await input.focus();
337+ const compositionValue = await input.evaluate(node => {
338+ node.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
339+ node.value = 'e';
340+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertCompositionText', data: 'e', isComposing: true }));
341+ const retainedValue = node.value;
342+ node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'e' }));
343+ node.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: 'é' }));
344+ node.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertCompositionText', data: 'é' }));
345+ const paste = new Event('paste', { bubbles: true, cancelable: true });
346+ Object.defineProperty(paste, 'clipboardData', { value: { getData: () => 'paste' } });
347+ node.dispatchEvent(paste);
348+ return retainedValue;
349+ });
350+
351+ const viewport = page.locator('.vev-terminal__viewport');
352+ const box = await viewport.boundingBox();
353+ await page.mouse.click(box.x + 1, box.y + 1);
354+ const pointerMoveAccepted = await viewport.evaluate(node => node.dispatchEvent(new PointerEvent('pointermove', {
355+ bubbles: true, cancelable: true, button: -1, buttons: 0, clientX: 1, clientY: 1
356+ })));
357+ await viewport.dispatchEvent('wheel', { deltaX: 1, deltaY: 2, deltaMode: 0, clientX: box.x + 1, clientY: box.y + 1 });
358+ await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'resize'))).toBeTruthy();
359+ await expect.poll(() => page.evaluate(() => globalThis.events.some(event => event.type === 'pointer' && event.action === 'move'))).toBeTruthy();
360+
361+ const events = await page.evaluate(() => globalThis.events);
362+ expect(compositionValue).toBe('e');
363+ expect(events.filter(event => event.type === 'text' && event.text === 'é')).toHaveLength(1);
364+ expect(events.some(event => event.type === 'paste' && event.text === 'paste')).toBeTruthy();
365+ expect(events.some(event => event.type === 'pointer' && event.action === 'down')).toBeTruthy();
366+ expect(pointerMoveAccepted).toBeFalsy();
367+ expect(events.some(event => event.type === 'wheel' && event.row === 0 && event.column === 0)).toBeTruthy();
368+ expect(events.some(event => event.type === 'focus' && event.focused)).toBeTruthy();
369+});
370+
371+test('applies dynamic terminal styles under a self-only CSP', async ({ page }) => {
372+ const violations = [];
373+ page.on('console', message => {
374+ if (message.text().includes('Content Security Policy')) violations.push(message.text());
375+ });
376+ const [css, script] = await Promise.all([
377+ fs.readFile(path.join(root, 'html/terminal.css'), 'utf8'),
378+ fs.readFile(path.join(root, 'html/browser/terminal.js'), 'utf8')
379+ ]);
380+ await page.route('http://vev.test/**', async route => {
381+ const pathname = new URL(route.request().url()).pathname;
382+ if (pathname === '/terminal.css') return route.fulfill({ contentType: 'text/css', body: css });
383+ if (pathname === '/terminal.js') return route.fulfill({ contentType: 'text/javascript', body: script });
384+ return route.fulfill({
385+ contentType: 'text/html',
386+ headers: { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'" },
387+ body: '<link rel="stylesheet" href="/terminal.css"><div id="terminal"></div><script src="/terminal.js"></script>'
388+ });
389+ });
390+ await page.goto('http://vev.test/');
391+ await page.waitForFunction(() => globalThis.VevTerminal);
392+ await page.evaluate(() => {
393+ globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' });
394+ terminal.apply({
395+ schemaVersion: 1, width: 1, height: 1, snapshot: true,
396+ styles: [{
397+ foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
398+ background: { kind: 1, index: 4 },
399+ underlineColor: { kind: 0 }
400+ }],
401+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
402+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
403+ });
404+ });
405+ await expect(page.locator('.vev-terminal__cell')).toHaveCSS('color', 'rgb(1, 2, 3)');
406+ expect(violations).toEqual([]);
407+});
408+
409+test('restores host attributes and theme properties on destroy', async ({ page }) => {
410+ await page.setContent('<div id="terminal" role="region" aria-label="Host label" style="--vev-fg:rgb(9 8 7)"></div>');
411+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
412+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
413+ await page.evaluate(() => {
414+ const instance = VevTerminal.mount(document.querySelector('#terminal'), { label: 'Mounted label' });
415+ const color = { r: 1, g: 2, b: 3 };
416+ instance.setTheme({
417+ foreground: color, background: color, cursor: color, selection: color,
418+ selectionText: color, palette: Array.from({ length: 16 }, () => color)
419+ });
420+ instance.destroy();
421+ });
422+ await expect(page.locator('#terminal')).toHaveAttribute('role', 'region');
423+ await expect(page.locator('#terminal')).toHaveAttribute('aria-label', 'Host label');
424+ expect(await page.locator('#terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toBe('rgb(9 8 7)');
425+});
426+
427+test('updates complete rows, applies typed themes, and destroys owned state', async ({ page }) => {
428+ await mount(page);
429+ const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
430+ await page.evaluate(value => terminal.apply(value), {
431+ schemaVersion: 1, width: 1, height: 1, snapshot: true, styles: [style],
432+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
433+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
434+ });
435+ await page.evaluate(value => terminal.apply(value), {
436+ schemaVersion: 1, width: 1, height: 1, snapshot: false, styles: [style],
437+ rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }],
438+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
439+ });
440+ await expect(page.locator('.vev-terminal__accessible-output')).toHaveText('B');
441+
442+ const color = { r: 1, g: 2, b: 3 };
443+ await page.evaluate(value => terminal.setTheme(value), {
444+ foreground: color, background: color, cursor: color, selection: color,
445+ selectionText: color, palette: Array.from({ length: 16 }, () => color)
446+ });
447+ expect(await page.locator('.vev-terminal').evaluate(node => node.style.getPropertyValue('--vev-fg'))).toContain('1 2 3');
448+
449+ await page.evaluate(() => terminal.destroy());
450+ await expect(page.locator('.vev-terminal__viewport')).toHaveCount(0);
451+ await expect(page.locator('#terminal')).not.toHaveClass(/vev-terminal/);
452+});
A internal/htmlharness/testdata/snapshot.json +29 -0
@@ -0,0 +1,29 @@
1+{
2+ "schemaVersion": 1,
3+ "width": 3,
4+ "height": 1,
5+ "snapshot": true,
6+ "rows": [
7+ {
8+ "row": 0,
9+ "cells": [
10+ { "column": 0, "width": 1, "text": "A", "style": 0 },
11+ { "column": 1, "width": 2, "text": "界", "style": 0 }
12+ ]
13+ }
14+ ],
15+ "styles": [
16+ {
17+ "foreground": { "kind": 0 },
18+ "background": { "kind": 0 },
19+ "underlineColor": { "kind": 0 }
20+ }
21+ ],
22+ "cursor": {
23+ "row": 0,
24+ "column": 1,
25+ "visible": true,
26+ "style": 3,
27+ "styleSet": true
28+ }
29+}
@@ -0,0 +1,29 @@
1+{
2+ "schemaVersion": 1,
3+ "width": 3,
4+ "height": 1,
5+ "snapshot": true,
6+ "rows": [
7+ {
8+ "row": 0,
9+ "cells": [
10+ { "column": 0, "width": 1, "text": "A", "style": 0 },
11+ { "column": 1, "width": 2, "text": "界", "style": 0 }
12+ ]
13+ }
14+ ],
15+ "styles": [
16+ {
17+ "foreground": { "kind": 0 },
18+ "background": { "kind": 0 },
19+ "underlineColor": { "kind": 0 }
20+ }
21+ ],
22+ "cursor": {
23+ "row": 0,
24+ "column": 1,
25+ "visible": true,
26+ "style": 3,
27+ "styleSet": true
28+ }
29+}
A package-lock.json +76 -0
@@ -0,0 +1,76 @@
1+{
2+ "name": "vev-vt-browser-tests",
3+ "lockfileVersion": 3,
4+ "requires": true,
5+ "packages": {
6+ "": {
7+ "name": "vev-vt-browser-tests",
8+ "devDependencies": {
9+ "@playwright/test": "1.62.1"
10+ }
11+ },
12+ "node_modules/@playwright/test": {
13+ "version": "1.62.1",
14+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
15+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
16+ "dev": true,
17+ "license": "Apache-2.0",
18+ "dependencies": {
19+ "playwright": "1.62.1"
20+ },
21+ "bin": {
22+ "playwright": "cli.js"
23+ },
24+ "engines": {
25+ "node": ">=20"
26+ }
27+ },
28+ "node_modules/fsevents": {
29+ "version": "2.3.2",
30+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
31+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
32+ "dev": true,
33+ "hasInstallScript": true,
34+ "license": "MIT",
35+ "optional": true,
36+ "os": [
37+ "darwin"
38+ ],
39+ "engines": {
40+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
41+ }
42+ },
43+ "node_modules/playwright": {
44+ "version": "1.62.1",
45+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
46+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
47+ "dev": true,
48+ "license": "Apache-2.0",
49+ "dependencies": {
50+ "playwright-core": "1.62.1"
51+ },
52+ "bin": {
53+ "playwright": "cli.js"
54+ },
55+ "engines": {
56+ "node": ">=20"
57+ },
58+ "optionalDependencies": {
59+ "fsevents": "2.3.2"
60+ }
61+ },
62+ "node_modules/playwright-core": {
63+ "version": "1.62.1",
64+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
65+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
66+ "dev": true,
67+ "license": "Apache-2.0",
68+ "bin": {
69+ "playwright-core": "cli.js"
70+ },
71+ "engines": {
72+ "node": ">=20"
73+ }
74+ }
75+ }
76+}
@@ -0,0 +1,76 @@
1+{
2+ "name": "vev-vt-browser-tests",
3+ "lockfileVersion": 3,
4+ "requires": true,
5+ "packages": {
6+ "": {
7+ "name": "vev-vt-browser-tests",
8+ "devDependencies": {
9+ "@playwright/test": "1.62.1"
10+ }
11+ },
12+ "node_modules/@playwright/test": {
13+ "version": "1.62.1",
14+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
15+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
16+ "dev": true,
17+ "license": "Apache-2.0",
18+ "dependencies": {
19+ "playwright": "1.62.1"
20+ },
21+ "bin": {
22+ "playwright": "cli.js"
23+ },
24+ "engines": {
25+ "node": ">=20"
26+ }
27+ },
28+ "node_modules/fsevents": {
29+ "version": "2.3.2",
30+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
31+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
32+ "dev": true,
33+ "hasInstallScript": true,
34+ "license": "MIT",
35+ "optional": true,
36+ "os": [
37+ "darwin"
38+ ],
39+ "engines": {
40+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
41+ }
42+ },
43+ "node_modules/playwright": {
44+ "version": "1.62.1",
45+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
46+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
47+ "dev": true,
48+ "license": "Apache-2.0",
49+ "dependencies": {
50+ "playwright-core": "1.62.1"
51+ },
52+ "bin": {
53+ "playwright": "cli.js"
54+ },
55+ "engines": {
56+ "node": ">=20"
57+ },
58+ "optionalDependencies": {
59+ "fsevents": "2.3.2"
60+ }
61+ },
62+ "node_modules/playwright-core": {
63+ "version": "1.62.1",
64+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
65+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
66+ "dev": true,
67+ "license": "Apache-2.0",
68+ "bin": {
69+ "playwright-core": "cli.js"
70+ },
71+ "engines": {
72+ "node": ">=20"
73+ }
74+ }
75+ }
76+}
A package.json +15 -0
@@ -0,0 +1,15 @@
1+{
2+ "name": "vev-vt-browser-tests",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "test:browser": "npm run test:browser:functional && npm run test:browser:performance",
7+ "test:browser:functional": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-functional.json node node_modules/@playwright/test/cli.js test internal/htmlharness/terminal.spec.js",
8+ "test:browser:performance": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-performance.json node node_modules/@playwright/test/cli.js test internal/htmlharness/performance.spec.js --project=chromium --workers=1",
9+ "test:browser:install": "playwright install chromium firefox webkit",
10+ "test:browser:docker": "sh -c 'set -eu; version=$(node -p \"require(\\\"./package-lock.json\\\").packages[\\\"node_modules/@playwright/test\\\"].version\"); docker run --rm --ipc=host --tmpfs /work/node_modules -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v${version}-noble sh -c \"npm ci --ignore-scripts && npm run test:browser\"'"
11+ },
12+ "devDependencies": {
13+ "@playwright/test": "1.62.1"
14+ }
15+}
@@ -0,0 +1,15 @@
1+{
2+ "name": "vev-vt-browser-tests",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "test:browser": "npm run test:browser:functional && npm run test:browser:performance",
7+ "test:browser:functional": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-functional.json node node_modules/@playwright/test/cli.js test internal/htmlharness/terminal.spec.js",
8+ "test:browser:performance": "PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/browser-performance.json node node_modules/@playwright/test/cli.js test internal/htmlharness/performance.spec.js --project=chromium --workers=1",
9+ "test:browser:install": "playwright install chromium firefox webkit",
10+ "test:browser:docker": "sh -c 'set -eu; version=$(node -p \"require(\\\"./package-lock.json\\\").packages[\\\"node_modules/@playwright/test\\\"].version\"); docker run --rm --ipc=host --tmpfs /work/node_modules -v \"$PWD\":/work -w /work mcr.microsoft.com/playwright:v${version}-noble sh -c \"npm ci --ignore-scripts && npm run test:browser\"'"
11+ },
12+ "devDependencies": {
13+ "@playwright/test": "1.62.1"
14+ }
15+}
A playwright.config.js +20 -0
@@ -0,0 +1,20 @@
1+import { defineConfig, devices } from '@playwright/test';
2+
3+const evidenceFile = process.env.PLAYWRIGHT_JSON_OUTPUT_NAME || 'test-results/browser-evidence.json';
4+
5+export default defineConfig({
6+ testDir: './internal/htmlharness',
7+ timeout: 30_000,
8+ fullyParallel: true,
9+ reporter: [['list'], ['json', { outputFile: evidenceFile }]],
10+ use: {
11+ headless: true,
12+ viewport: { width: 1280, height: 720 },
13+ trace: 'retain-on-failure'
14+ },
15+ projects: [
16+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
17+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
18+ { name: 'webkit', use: { ...devices['Desktop Safari'] } }
19+ ]
20+});
@@ -0,0 +1,20 @@
1+import { defineConfig, devices } from '@playwright/test';
2+
3+const evidenceFile = process.env.PLAYWRIGHT_JSON_OUTPUT_NAME || 'test-results/browser-evidence.json';
4+
5+export default defineConfig({
6+ testDir: './internal/htmlharness',
7+ timeout: 30_000,
8+ fullyParallel: true,
9+ reporter: [['list'], ['json', { outputFile: evidenceFile }]],
10+ use: {
11+ headless: true,
12+ viewport: { width: 1280, height: 720 },
13+ trace: 'retain-on-failure'
14+ },
15+ projects: [
16+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
17+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
18+ { name: 'webkit', use: { ...devices['Desktop Safari'] } }
19+ ]
20+});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close