diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/create.json b/dogfood/20260326-lazyvim-nerd-font-check-2/create.json new file mode 100644 index 0000000..9728e78 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-26T00:22:36.918Z", + "result": { + "sessionId": "01KMKRB2D1DN8PHRGN992DD06C", + "createdAt": "2026-03-26T00:22:36.195Z", + "cols": 120, + "rows": 40, + "shell": "/bin/bash" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/lazyvim-a-reference-dark.png b/dogfood/20260326-lazyvim-nerd-font-check-2/lazyvim-a-reference-dark.png new file mode 100644 index 0000000..4226132 Binary files /dev/null and b/dogfood/20260326-lazyvim-nerd-font-check-2/lazyvim-a-reference-dark.png differ diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/lazyvim-space-reference-dark.png b/dogfood/20260326-lazyvim-nerd-font-check-2/lazyvim-space-reference-dark.png new file mode 100644 index 0000000..e05229a Binary files /dev/null and b/dogfood/20260326-lazyvim-nerd-font-check-2/lazyvim-space-reference-dark.png differ diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/screenshot-a.json b/dogfood/20260326-lazyvim-nerd-font-check-2/screenshot-a.json new file mode 100644 index 0000000..8d67fff --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/screenshot-a.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-26T00:23:00.904Z", + "result": { + "sessionId": "01KMKRB2D1DN8PHRGN992DD06C", + "capturedAtSeq": 32, + "profileName": "reference-dark", + "cols": 120, + "rows": 40, + "artifactPath": "/tmp/agent-terminal-lazyvim-home-2/sessions/01KMKRB2D1DN8PHRGN992DD06C/artifacts/screenshot-32-reference-dark.png", + "pngSizeBytes": 43638, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 960, + "pixelHeight": 640, + "sha256": "bdb9113a11fe0af5ce1978a674b7e6ad6fbd31f1515ff6104b69baf0bf428849", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/screenshot-space.json b/dogfood/20260326-lazyvim-nerd-font-check-2/screenshot-space.json new file mode 100644 index 0000000..16b0895 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/screenshot-space.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-26T00:22:54.066Z", + "result": { + "sessionId": "01KMKRB2D1DN8PHRGN992DD06C", + "capturedAtSeq": 29, + "profileName": "reference-dark", + "cols": 120, + "rows": 40, + "artifactPath": "/tmp/agent-terminal-lazyvim-home-2/sessions/01KMKRB2D1DN8PHRGN992DD06C/artifacts/screenshot-29-reference-dark.png", + "pngSizeBytes": 84582, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 960, + "pixelHeight": 640, + "sha256": "8dbb5adf887071e4b13d1d6062733a9935b746d5820fd7e9f2ec7649bafd2198", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/send-a.json b/dogfood/20260326-lazyvim-nerd-font-check-2/send-a.json new file mode 100644 index 0000000..3edc585 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/send-a.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-26T00:22:55.098Z", + "result": { + "accepted": ["a"], + "bytesWritten": 1, + "seq": 30 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/send-space.json b/dogfood/20260326-lazyvim-nerd-font-check-2/send-space.json new file mode 100644 index 0000000..11b4b7c --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/send-space.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-26T00:22:48.139Z", + "result": { + "accepted": ["Space"], + "bytesWritten": 1, + "seq": 26 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/snapshot-a.json b/dogfood/20260326-lazyvim-nerd-font-check-2/snapshot-a.json new file mode 100644 index 0000000..776f0ff --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/snapshot-a.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-26T00:22:59.686Z", + "result": { + "format": "text", + "sessionId": "01KMKRB2D1DN8PHRGN992DD06C", + "capturedAtSeq": 32, + "cols": 120, + "rows": 40, + "cursorRow": 14, + "cursorCol": 33, + "text": "\n\n\n\n\n ██╗ █████╗ ███████╗██╗ ██╗██╗ ██╗██╗███╗ ███╗ Z\n ██║ ██╔══██╗╚══███╔╝╚██╗ ██╔╝██║ ██║██║████╗ ████║ Z\n ██║ ███████║ ███╔╝ ╚████╔╝ ██║ ██║██║██╔████╔██║ z\n ██║ ██╔══██║ ███╔╝ ╚██╔╝ ╚██╗ ██╔╝██║██║╚██╔╝██║ z\n ███████╗██║ ██║███████╗ ██║ ╚████╔╝ ██║██║ ╚═╝ ██║\n ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═╝\n\n\n\n  Find File f\n\n  New File n\n\n  Projects p\n\n  Find Text g\n\n  Recent Files r\n\n  Config c\n\n  Restore Session s\n\n  Lazy Extras x\n ╭ 󱁐 » +ai ──────────────────╮\n 󰒲 Lazy │ a ➜  Accept diff │\n │ b ➜ 󰈔 Add current buffer │\n  Quit │ c ➜  Toggle Claude │\n │ C ➜  Continue Claude │\n ⚡ Neovim loaded 4/33 plugins in 32.69ms │ d ➜  Deny diff │\n │ f ➜  Focus Claude │\n │ r ➜  Resume Claude │\n │ 󱊷 close 󰁮 back │\n ╰────────────────────────────╯\n" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/snapshot-space.json b/dogfood/20260326-lazyvim-nerd-font-check-2/snapshot-space.json new file mode 100644 index 0000000..9ca9482 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/snapshot-space.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-26T00:22:52.799Z", + "result": { + "format": "text", + "sessionId": "01KMKRB2D1DN8PHRGN992DD06C", + "capturedAtSeq": 29, + "cols": 120, + "rows": 40, + "cursorRow": 14, + "cursorCol": 33, + "text": "\n\n\n\n\n ██╗ █████╗ ███████╗██╗ ██╗██╗ ██╗██╗███╗ ███╗ Z\n ██║ ██╔══██╗╚══███╔╝╚██╗ ██╔╝██║ ██║██║████╗ ████║ Z\n ██║ ███████║ ███╔╝ ╚████╔╝ ██║ ██║██║██╔████╔██║ z\n ██║ ██╔══██║ ███╔╝ ╚██╔╝ ╚██╗ ██╔╝██║██║╚██╭ 󱁐 ──────────────────────────────╮\n ███████╗██║ ██║███████╗ ██║ ╚████╔╝ ██║██║ ╚═│ e ➜ 󱥰 Explorer Snacks (root dir) │\n ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ │ E ➜ 󱥰 Explorer Snacks (cwd) │\n │ K ➜ Keywordprg │\n │ l ➜ 󰒲 Lazy │\n │ L ➜ 󰒲 LazyVim Changelog │\n  Find File │ n ➜ 󱥰 Notification History │\n │ S ➜ 󱥰 Select Scratch Buffer │\n  New File │ , ➜ 󱥰 Buffers │\n │ - ➜  Split Window Below │\n  Projects │ . ➜ 󱥰 Toggle Scratch Buffer │\n │ / ➜ 󱥰 Grep (Root Dir) │\n  Find Text │ : ➜ 󱥰 Command History │\n │ ? ➜ 󰈔 Buffer Keymaps (which-key) │\n  Recent Files │ ` ➜ 󰈔 Switch to Other Buffer │\n │ | ➜  Split Window Right │\n  Config │ 󱁐 ➜ 󱥰 Find Files (Root Dir) │\n │ a ➜  +ai │\n  Restore Session │ b ➜ 󰈔 +buffer │\n │ c ➜  +code │\n  Lazy Extras │ d ➜ 󰃤 +debug │\n │ f ➜  +file/find │\n 󰒲 Lazy │ g ➜ 󰊢 +git │\n │ q ➜  +quit/session │\n  Quit │ s ➜  +search │\n │ u ➜ 󰙵 +ui │\n ⚡ Neovim loaded 4/33 plugins in 32.69ms │ w ➜  +windows │\n │ x ➜ 󱖫 +diagnostics/quickfix │\n │ 󰌒 ➜ 󰓩 +tabs │\n │ 󱊷 close 󰁮 back │\n ╰──────────────────────────────────╯\n" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/wait-a.json b/dogfood/20260326-lazyvim-nerd-font-check-2/wait-a.json new file mode 100644 index 0000000..9e165f9 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/wait-a.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:22:58.534Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 14, + "cursorCol": 33, + "capturedAtSeq": 32 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/wait-dashboard.json b/dogfood/20260326-lazyvim-nerd-font-check-2/wait-dashboard.json new file mode 100644 index 0000000..4b1d1f8 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/wait-dashboard.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:22:46.978Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 14, + "cursorCol": 33, + "capturedAtSeq": 25 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check-2/wait-space.json b/dogfood/20260326-lazyvim-nerd-font-check-2/wait-space.json new file mode 100644 index 0000000..a303267 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check-2/wait-space.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:22:51.678Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 14, + "cursorCol": 33, + "capturedAtSeq": 29 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/create.json b/dogfood/20260326-lazyvim-nerd-font-check/create.json new file mode 100644 index 0000000..e332872 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-26T00:21:38.428Z", + "result": { + "sessionId": "01KMKR9996ZV74MZ8ZAZ9A9H1W", + "createdAt": "2026-03-26T00:21:37.705Z", + "cols": 120, + "rows": 40, + "shell": "/bin/bash" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/destroy.json b/dogfood/20260326-lazyvim-nerd-font-check/destroy.json new file mode 100644 index 0000000..5b4d525 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-26T00:22:03.133Z", + "result": { + "sessionId": "01KMKR9996ZV74MZ8ZAZ9A9H1W", + "destroyed": true + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/lazyvim-dashboard-reference-dark.png b/dogfood/20260326-lazyvim-nerd-font-check/lazyvim-dashboard-reference-dark.png new file mode 100644 index 0000000..dc51656 Binary files /dev/null and b/dogfood/20260326-lazyvim-nerd-font-check/lazyvim-dashboard-reference-dark.png differ diff --git a/dogfood/20260326-lazyvim-nerd-font-check/lazyvim-leader-a-reference-dark.png b/dogfood/20260326-lazyvim-nerd-font-check/lazyvim-leader-a-reference-dark.png new file mode 100644 index 0000000..dc51656 Binary files /dev/null and b/dogfood/20260326-lazyvim-nerd-font-check/lazyvim-leader-a-reference-dark.png differ diff --git a/dogfood/20260326-lazyvim-nerd-font-check/screenshot-dashboard.json b/dogfood/20260326-lazyvim-nerd-font-check/screenshot-dashboard.json new file mode 100644 index 0000000..39ffdd9 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/screenshot-dashboard.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-26T00:21:51.375Z", + "result": { + "sessionId": "01KMKR9996ZV74MZ8ZAZ9A9H1W", + "capturedAtSeq": 26, + "profileName": "reference-dark", + "cols": 120, + "rows": 40, + "artifactPath": "/tmp/agent-terminal-lazyvim-home/sessions/01KMKR9996ZV74MZ8ZAZ9A9H1W/artifacts/screenshot-26-reference-dark.png", + "pngSizeBytes": 27279, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 960, + "pixelHeight": 640, + "sha256": "4c95a45b58d394b3e36aaa73679d453c729ded977274952e447df3710c879626", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/screenshot-leader-a.json b/dogfood/20260326-lazyvim-nerd-font-check/screenshot-leader-a.json new file mode 100644 index 0000000..6d348b8 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/screenshot-leader-a.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-26T00:21:58.763Z", + "result": { + "sessionId": "01KMKR9996ZV74MZ8ZAZ9A9H1W", + "capturedAtSeq": 27, + "profileName": "reference-dark", + "cols": 120, + "rows": 40, + "artifactPath": "/tmp/agent-terminal-lazyvim-home/sessions/01KMKR9996ZV74MZ8ZAZ9A9H1W/artifacts/screenshot-27-reference-dark.png", + "pngSizeBytes": 27279, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 960, + "pixelHeight": 640, + "sha256": "4c95a45b58d394b3e36aaa73679d453c729ded977274952e447df3710c879626", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/send-keys-enter.json b/dogfood/20260326-lazyvim-nerd-font-check/send-keys-enter.json new file mode 100644 index 0000000..e2e7198 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/send-keys-enter.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-26T00:22:00.881Z", + "result": { + "accepted": ["Enter"], + "bytesWritten": 1, + "seq": 34 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/send-keys-leader-a.json b/dogfood/20260326-lazyvim-nerd-font-check/send-keys-leader-a.json new file mode 100644 index 0000000..e9ca7d7 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/send-keys-leader-a.json @@ -0,0 +1,10 @@ +{ + "ok": true, + "command": "send-keys", + "timestamp": "2026-03-26T00:21:52.501Z", + "result": { + "accepted": ["Space", "a"], + "bytesWritten": 2, + "seq": 27 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/session-id.txt b/dogfood/20260326-lazyvim-nerd-font-check/session-id.txt new file mode 100644 index 0000000..a7198e3 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/session-id.txt @@ -0,0 +1 @@ +01KMKR9996ZV74MZ8ZAZ9A9H1W diff --git a/dogfood/20260326-lazyvim-nerd-font-check/snapshot-dashboard.json b/dogfood/20260326-lazyvim-nerd-font-check/snapshot-dashboard.json new file mode 100644 index 0000000..42514a8 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/snapshot-dashboard.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-26T00:21:50.113Z", + "result": { + "format": "text", + "sessionId": "01KMKR9996ZV74MZ8ZAZ9A9H1W", + "capturedAtSeq": 26, + "cols": 120, + "rows": 40, + "cursorRow": 14, + "cursorCol": 33, + "text": "\n\n\n\n\n ██╗ █████╗ ███████╗██╗ ██╗██╗ ██╗██╗███╗ ███╗ Z\n ██║ ██╔══██╗╚══███╔╝╚██╗ ██╔╝██║ ██║██║████╗ ████║ Z\n ██║ ███████║ ███╔╝ ╚████╔╝ ██║ ██║██║██╔████╔██║ z\n ██║ ██╔══██║ ███╔╝ ╚██╔╝ ╚██╗ ██╔╝██║██║╚██╔╝██║ z\n ███████╗██║ ██║███████╗ ██║ ╚████╔╝ ██║██║ ╚═╝ ██║\n ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═╝\n\n\n\n  Find File f\n\n  New File n\n\n  Projects p\n\n  Find Text g\n\n  Recent Files r\n\n  Config c\n\n  Restore Session s\n\n  Lazy Extras x\n\n 󰒲 Lazy l\n\n  Quit q\n\n ⚡ Neovim loaded 4/33 plugins in 30.98ms\n\n\n\n\n" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/snapshot-leader-a.json b/dogfood/20260326-lazyvim-nerd-font-check/snapshot-leader-a.json new file mode 100644 index 0000000..1994e7a --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/snapshot-leader-a.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-26T00:21:57.480Z", + "result": { + "format": "text", + "sessionId": "01KMKR9996ZV74MZ8ZAZ9A9H1W", + "capturedAtSeq": 27, + "cols": 120, + "rows": 40, + "cursorRow": 14, + "cursorCol": 33, + "text": "\n\n\n\n\n ██╗ █████╗ ███████╗██╗ ██╗██╗ ██╗██╗███╗ ███╗ Z\n ██║ ██╔══██╗╚══███╔╝╚██╗ ██╔╝██║ ██║██║████╗ ████║ Z\n ██║ ███████║ ███╔╝ ╚████╔╝ ██║ ██║██║██╔████╔██║ z\n ██║ ██╔══██║ ███╔╝ ╚██╔╝ ╚██╗ ██╔╝██║██║╚██╔╝██║ z\n ███████╗██║ ██║███████╗ ██║ ╚████╔╝ ██║██║ ╚═╝ ██║\n ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═╝\n\n\n\n  Find File f\n\n  New File n\n\n  Projects p\n\n  Find Text g\n\n  Recent Files r\n\n  Config c\n\n  Restore Session s\n\n  Lazy Extras x\n\n 󰒲 Lazy l\n\n  Quit q\n\n ⚡ Neovim loaded 4/33 plugins in 30.98ms\n\n\n\n\n" + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/type-quit.json b/dogfood/20260326-lazyvim-nerd-font-check/type-quit.json new file mode 100644 index 0000000..63b5d24 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/type-quit.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-03-26T00:21:59.880Z", + "result": {} +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/wait-dashboard.json b/dogfood/20260326-lazyvim-nerd-font-check/wait-dashboard.json new file mode 100644 index 0000000..3e6244b --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/wait-dashboard.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:21:49.004Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 14, + "cursorCol": 33, + "capturedAtSeq": 26 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/wait-exit.json b/dogfood/20260326-lazyvim-nerd-font-check/wait-exit.json new file mode 100644 index 0000000..f5690e7 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/wait-exit.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:22:02.075Z", + "result": { + "timedOut": false, + "exitCode": 0 + } +} diff --git a/dogfood/20260326-lazyvim-nerd-font-check/wait-leader-a.json b/dogfood/20260326-lazyvim-nerd-font-check/wait-leader-a.json new file mode 100644 index 0000000..f44e898 --- /dev/null +++ b/dogfood/20260326-lazyvim-nerd-font-check/wait-leader-a.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:21:56.450Z", + "result": { + "matched": true, + "timedOut": false, + "cursorRow": 14, + "cursorCol": 33, + "capturedAtSeq": 27 + } +} diff --git a/dogfood/20260326-nerd-font-fallback/create.json b/dogfood/20260326-nerd-font-fallback/create.json new file mode 100644 index 0000000..e0535a0 --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/create.json @@ -0,0 +1,12 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-26T00:17:56.657Z", + "result": { + "sessionId": "01KMKR2GPS70FX8B8BTDGSZHJD", + "createdAt": "2026-03-26T00:17:55.932Z", + "cols": 80, + "rows": 24, + "shell": "/bin/bash" + } +} diff --git a/dogfood/20260326-nerd-font-fallback/destroy.json b/dogfood/20260326-nerd-font-fallback/destroy.json new file mode 100644 index 0000000..3eb3edd --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/destroy.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-26T00:18:13.724Z", + "result": { + "sessionId": "01KMKR2GPS70FX8B8BTDGSZHJD", + "destroyed": true + } +} diff --git a/dogfood/20260326-nerd-font-fallback/export-asciicast.json b/dogfood/20260326-nerd-font-fallback/export-asciicast.json new file mode 100644 index 0000000..c845eeb --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/export-asciicast.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-03-26T00:18:11.524Z", + "result": { + "sessionId": "01KMKR2GPS70FX8B8BTDGSZHJD", + "format": "asciicast", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-terminal-v12b-1/dogfood/20260326-nerd-font-fallback/unicode-grid.cast", + "bytes": 799, + "sha256": "8b49f61b2f316d301f824829000a844c02b6a8ad3639ecc2a920ca411b9a5ec1", + "capturedAtSeq": 1, + "durationMs": 1216, + "metadata": { + "width": 80, + "height": 24, + "title": "01KMKR2GPS70FX8B8BTDGSZHJD", + "timestamp": 1774484276, + "outputEventCount": 1, + "resizeEventCount": 0, + "markerCount": 0 + } + } +} diff --git a/dogfood/20260326-nerd-font-fallback/export-webm.json b/dogfood/20260326-nerd-font-fallback/export-webm.json new file mode 100644 index 0000000..7c5ba3b --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/export-webm.json @@ -0,0 +1,23 @@ +{ + "ok": true, + "command": "record export", + "timestamp": "2026-03-26T00:18:10.433Z", + "result": { + "sessionId": "01KMKR2GPS70FX8B8BTDGSZHJD", + "format": "webm", + "artifactPath": "/home/coder/.mux/src/agent-terminal/agent-terminal-v12b-1/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.webm", + "bytes": 34623, + "sha256": "62a0c16411970e23f075f4c5aceceb86c040ed04e0d25e9eee029e694c90ea71", + "capturedAtSeq": 1, + "durationMs": 1216, + "metadata": { + "width": 80, + "height": 24, + "profileName": "reference-dark", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d", + "timingMode": "recorded", + "outputEventCount": 1, + "resizeEventCount": 0 + } + } +} diff --git a/dogfood/20260326-nerd-font-fallback/screenshot.json b/dogfood/20260326-nerd-font-fallback/screenshot.json new file mode 100644 index 0000000..25e0dde --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/screenshot.json @@ -0,0 +1,20 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-26T00:18:01.709Z", + "result": { + "sessionId": "01KMKR2GPS70FX8B8BTDGSZHJD", + "capturedAtSeq": 1, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-terminal-nerd-font-home/sessions/01KMKR2GPS70FX8B8BTDGSZHJD/artifacts/screenshot-1-reference-dark.png", + "pngSizeBytes": 20702, + "cursorVisible": false, + "rendererBackend": "ghostty-web", + "pixelWidth": 640, + "pixelHeight": 384, + "sha256": "a03d10008897b206ebce3d0f9e912b967937c36cd958aeecb5bc1b33454baddb", + "renderProfileHash": "8ffed6af301ec7c0e6b69599c3be0d1d12096f9fcdfc59d0bbb4cc474d64c53d" + } +} diff --git a/dogfood/20260326-nerd-font-fallback/snapshot-text.json b/dogfood/20260326-nerd-font-fallback/snapshot-text.json new file mode 100644 index 0000000..5936968 --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/snapshot-text.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-26T00:18:00.057Z", + "result": { + "format": "text", + "sessionId": "01KMKR2GPS70FX8B8BTDGSZHJD", + "capturedAtSeq": 1, + "cols": 80, + "rows": 24, + "cursorRow": 10, + "cursorCol": 0, + "text": "UNICODE GRID FIXTURE\n\n| LABEL | SAMPLE |\n| ASCII | Hello, World! 0123456789 |\n| BOX | ┌─┐│└┘├┤┬┴┼═║╔╗╚╝ |\n| CJK | 漢字テスト中文日本 |\n| EMOJI | ✓✗★♠♣♥♦⚡☀☁ |\n| AMBIG | αβγδ∑∏∫∂√∞ |\n| NERD |   󰊢 󰈙  |\nUNICODE GRID COMPLETE\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} diff --git a/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.png b/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.png new file mode 100644 index 0000000..be3ba44 Binary files /dev/null and b/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.png differ diff --git a/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.webm b/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.webm new file mode 100644 index 0000000..4ce8de7 Binary files /dev/null and b/dogfood/20260326-nerd-font-fallback/unicode-grid-reference-dark.webm differ diff --git a/dogfood/20260326-nerd-font-fallback/unicode-grid.cast b/dogfood/20260326-nerd-font-fallback/unicode-grid.cast new file mode 100644 index 0000000..ffc279f --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/unicode-grid.cast @@ -0,0 +1,2 @@ +{"version":2,"width":80,"height":24,"timestamp":1774484276,"title":"01KMKR2GPS70FX8B8BTDGSZHJD","sessionId":"01KMKR2GPS70FX8B8BTDGSZHJD","env":{"TERM":"xterm-256color"},"toolVersion":"0.1.0"} +[0,"o","\u001b[0m\u001b[2J\u001b[H\u001b[1;1HUNICODE GRID FIXTURE\u001b[3;1H| LABEL | SAMPLE |\u001b[4;1H| ASCII | Hello, World! 0123456789 |\u001b[5;1H| BOX | ┌─┐│└┘├┤┬┴┼═║╔╗╚╝ |\u001b[6;1H| CJK | 漢字テスト中文日本 |\u001b[7;1H| EMOJI | ✓✗★♠♣♥♦⚡☀☁ |\u001b[8;1H| AMBIG | αβγδ∑∏∫∂√∞ |\u001b[9;1H| NERD |   󰊢 󰈙  |\u001b[10;1H\u001b[0mUNICODE GRID COMPLETE\u001b[11;1H\u001b[0m"] diff --git a/dogfood/20260326-nerd-font-fallback/wait-exit.json b/dogfood/20260326-nerd-font-fallback/wait-exit.json new file mode 100644 index 0000000..64478fa --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/wait-exit.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:18:12.641Z", + "result": { + "timedOut": false, + "exitCode": 0 + } +} diff --git a/dogfood/20260326-nerd-font-fallback/wait-render.json b/dogfood/20260326-nerd-font-fallback/wait-render.json new file mode 100644 index 0000000..2b6ad34 --- /dev/null +++ b/dogfood/20260326-nerd-font-fallback/wait-render.json @@ -0,0 +1,13 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-26T00:17:58.564Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "UNICODE GRID COMPLETE", + "cursorRow": 10, + "cursorCol": 0, + "capturedAtSeq": 1 + } +} diff --git a/src/renderer/bundledFont.ts b/src/renderer/bundledFont.ts index 986025c..f5c4e42 100644 --- a/src/renderer/bundledFont.ts +++ b/src/renderer/bundledFont.ts @@ -3,30 +3,129 @@ import { readFileSync } from 'node:fs'; import { invariant } from '../util/assert.js'; -const BUNDLED_FONT_ASSET_FILENAME = 'JetBrainsMono-Regular-latin.woff2'; -const BUNDLED_FONT_FAMILY = 'JetBrains Mono'; -const BUNDLED_FONT_CONTENT_TYPE = 'font/woff2'; -const BUNDLED_FONT_ROUTE = `/assets/fonts/${BUNDLED_FONT_ASSET_FILENAME}`; +const BUNDLED_FONT_PATH_PREFIX = 'ghosttyWeb/assets/'; +const SHA_256_HEX_PATTERN = /^[a-f0-9]{64}$/u; -const fontAssetPath = new URL( - `ghosttyWeb/assets/${BUNDLED_FONT_ASSET_FILENAME}`, - import.meta.url, +interface BundledFontAssetSource { + assetKey: string; + contentType: string; + family: string; + filename: string; + style: 'italic' | 'normal' | 'oblique'; + weight: string; +} + +interface BundledFontAsset extends BundledFontAssetSource { + assetIdentity: string; + buffer: Buffer; + route: string; +} + +const BUNDLED_FONT_ASSET_SOURCES = [ + { + assetKey: 'jetbrains-mono-regular-latin', + contentType: 'font/woff2', + family: 'JetBrains Mono', + filename: 'JetBrainsMono-Regular-latin.woff2', + style: 'normal', + weight: '400', + }, + { + assetKey: 'symbols-nerd-font-mono-regular', + contentType: 'font/ttf', + family: 'Symbols Nerd Font Mono', + filename: 'SymbolsNerdFontMono-Regular.ttf', + style: 'normal', + weight: '400', + }, +] as const satisfies readonly BundledFontAssetSource[]; + +function loadBundledFontAsset( + source: BundledFontAssetSource, +): Readonly { + const assetPath = new URL( + `${BUNDLED_FONT_PATH_PREFIX}${source.filename}`, + import.meta.url, + ); + const buffer = readFileSync(assetPath); + invariant( + buffer.byteLength > 0, + `bundled font asset ${source.filename} must not be empty`, + ); + + const assetIdentity = createHash('sha256').update(buffer).digest('hex'); + invariant( + SHA_256_HEX_PATTERN.test(assetIdentity), + `bundled font asset ${source.filename} identity must be a valid SHA-256 hex string`, + ); + + return Object.freeze({ + ...source, + assetIdentity, + buffer, + route: `/assets/fonts/${source.filename}`, + }); +} + +const bundledFontAssets = BUNDLED_FONT_ASSET_SOURCES.map(loadBundledFontAsset); +invariant( + bundledFontAssets.length > 0, + 'bundled font registry must not be empty', +); + +const bundledFontAssetsByIdentity = new Map( + bundledFontAssets.map((asset) => [asset.assetIdentity, asset] as const), +); +invariant( + bundledFontAssetsByIdentity.size === bundledFontAssets.length, + 'bundled font registry must not contain duplicate asset identities', +); + +const bundledFontAssetsByKey = new Map( + bundledFontAssets.map((asset) => [asset.assetKey, asset] as const), +); +invariant( + bundledFontAssetsByKey.size === bundledFontAssets.length, + 'bundled font registry must not contain duplicate asset keys', ); -const fontBuffer = readFileSync(fontAssetPath); -invariant(fontBuffer.byteLength > 0, 'bundled font asset must not be empty'); +export const BUNDLED_FONT_ASSETS = Object.freeze(bundledFontAssets); +export const BUNDLED_FONT_FAMILY = 'JetBrains Mono'; +export const BUNDLED_SYMBOLS_FONT_FAMILY = 'Symbols Nerd Font Mono'; +export const BUNDLED_FONT_ASSET_FILENAME = 'JetBrainsMono-Regular-latin.woff2'; +export const BUNDLED_FONT_CONTENT_TYPE = 'font/woff2'; +export const BUNDLED_FONT_ROUTE = `/assets/fonts/${BUNDLED_FONT_ASSET_FILENAME}`; + +const primaryBundledFont = bundledFontAssetsByKey.get( + 'jetbrains-mono-regular-latin', +); +invariant( + primaryBundledFont !== undefined, + 'JetBrains Mono bundled font asset must exist in the registry', +); -const fontAssetIdentity = createHash('sha256').update(fontBuffer).digest('hex'); +const symbolsBundledFont = bundledFontAssetsByKey.get( + 'symbols-nerd-font-mono-regular', +); invariant( - /^[a-f0-9]{64}$/u.test(fontAssetIdentity), - 'bundled font asset identity must be a valid SHA-256 hex string', -); - -export { - BUNDLED_FONT_ASSET_FILENAME, - BUNDLED_FONT_CONTENT_TYPE, - BUNDLED_FONT_FAMILY, - BUNDLED_FONT_ROUTE, - fontAssetIdentity as BUNDLED_FONT_ASSET_IDENTITY, - fontBuffer as BUNDLED_FONT_BUFFER, -}; + symbolsBundledFont !== undefined, + 'Symbols Nerd Font Mono bundled font asset must exist in the registry', +); + +export const BUNDLED_PRIMARY_FONT_ASSET = primaryBundledFont; +export const BUNDLED_SYMBOLS_FONT_ASSET = symbolsBundledFont; +export const BUNDLED_FONT_ASSET_IDENTITY = + BUNDLED_PRIMARY_FONT_ASSET.assetIdentity; +export const BUNDLED_FONT_BUFFER = BUNDLED_PRIMARY_FONT_ASSET.buffer; + +export function getBundledFontAssetByIdentity( + assetIdentity: string, +): Readonly | undefined { + invariant( + SHA_256_HEX_PATTERN.test(assetIdentity), + 'bundled font asset identity lookup requires a SHA-256 hex string', + ); + return bundledFontAssetsByIdentity.get(assetIdentity); +} + +export type { BundledFontAsset }; diff --git a/src/renderer/ghosttyWeb/assets/FONT-LICENSE.txt b/src/renderer/ghosttyWeb/assets/FONT-LICENSE.txt index f0ac4b0..95a8cf4 100644 --- a/src/renderer/ghosttyWeb/assets/FONT-LICENSE.txt +++ b/src/renderer/ghosttyWeb/assets/FONT-LICENSE.txt @@ -2,3 +2,8 @@ Font: JetBrains Mono by JetBrains s.r.o. License: SIL Open Font License 1.1 Source: https://github.com/JetBrains/JetBrainsMono Subset: Latin (from Google Fonts CDN) + +Font: Symbols Nerd Font Mono by the Nerd Fonts contributors +License: SIL Open Font License 1.1 +Source: https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/NerdFontsSymbolsOnly.zip +Asset: SymbolsNerdFontMono-Regular.ttf diff --git a/src/renderer/ghosttyWeb/assets/SymbolsNerdFontMono-Regular.ttf b/src/renderer/ghosttyWeb/assets/SymbolsNerdFontMono-Regular.ttf new file mode 100644 index 0000000..d898eea Binary files /dev/null and b/src/renderer/ghosttyWeb/assets/SymbolsNerdFontMono-Regular.ttf differ diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index bf5d548..ba8ac80 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -37,11 +37,7 @@ import type { ScreenshotResult, SemanticSnapshot, } from '../types.js'; -import { - BUNDLED_FONT_BUFFER, - BUNDLED_FONT_CONTENT_TYPE, - BUNDLED_FONT_ROUTE, -} from '../bundledFont.js'; +import { BUNDLED_FONT_ASSETS } from '../bundledFont.js'; import { hashProfile } from '../profiles.js'; interface GhosttyHarnessVisibleLine { @@ -307,6 +303,7 @@ const EMBEDDED_HARNESS_HTML = ` /^#[0-9a-fA-F]{6}$/u.test(profile.foregroundColor), 'profile.foregroundColor must be a hex color', ); + let validatedFontAssets; if ( profile.fontAssetIdentity !== undefined && profile.fontAssetIdentity !== null @@ -317,8 +314,71 @@ const EMBEDDED_HARNESS_HTML = ` 'profile.fontAssetIdentity must be a 64-character lowercase SHA-256 hex string', ); } + if (profile.fontAssets !== undefined) { + invariant( + Array.isArray(profile.fontAssets), + 'profile.fontAssets must be an array when provided', + ); + invariant( + profile.fontAssets.length > 0, + 'profile.fontAssets must be non-empty when provided', + ); + + const seenAssetIdentities = new Set(); + const seenRoutes = new Set(); + validatedFontAssets = profile.fontAssets.map((fontAsset, index) => { + invariant( + fontAsset !== null && typeof fontAsset === 'object', + \`profile.fontAssets[\${index}] must be an object\`, + ); + assertNonEmptyString( + fontAsset.family, + \`profile.fontAssets[\${index}].family must be a non-empty string\`, + ); + invariant( + typeof fontAsset.assetIdentity === 'string' && + /^[a-f0-9]{64}$/u.test(fontAsset.assetIdentity), + \`profile.fontAssets[\${index}].assetIdentity must be a 64-character lowercase SHA-256 hex string\`, + ); + assertNonEmptyString( + fontAsset.route, + \`profile.fontAssets[\${index}].route must be a non-empty string\`, + ); + invariant( + fontAsset.route.startsWith('/'), + \`profile.fontAssets[\${index}].route must be an absolute route path\`, + ); + assertNonEmptyString( + fontAsset.weight, + \`profile.fontAssets[\${index}].weight must be a non-empty string\`, + ); + invariant( + fontAsset.style === 'normal' || + fontAsset.style === 'italic' || + fontAsset.style === 'oblique', + \`profile.fontAssets[\${index}].style must be normal, italic, or oblique\`, + ); + invariant( + !seenAssetIdentities.has(fontAsset.assetIdentity), + \`profile.fontAssets[\${index}].assetIdentity must be unique\`, + ); + invariant( + !seenRoutes.has(fontAsset.route), + \`profile.fontAssets[\${index}].route must be unique\`, + ); + seenAssetIdentities.add(fontAsset.assetIdentity); + seenRoutes.add(fontAsset.route); + + return Object.freeze({ ...fontAsset }); + }); + } - return Object.freeze({ ...profile }); + return Object.freeze({ + ...profile, + ...(validatedFontAssets !== undefined && { + fontAssets: Object.freeze(validatedFontAssets), + }), + }); } const profile = parseProfileFromLocation(); @@ -662,24 +722,65 @@ const EMBEDDED_HARNESS_HTML = ` }, }; - async function loadBundledFont() { + function getBundledFontAssets() { + if (Array.isArray(profile.fontAssets) && profile.fontAssets.length > 0) { + return profile.fontAssets; + } if (!profile.fontAssetIdentity) { - return; + return []; } - const fontFaceRule = new FontFace( - profile.fontFamily, - 'url(/assets/fonts/JetBrainsMono-Regular-latin.woff2)', - { style: 'normal', weight: '400' }, - ); + return [ + Object.freeze({ + family: 'JetBrains Mono', + assetIdentity: profile.fontAssetIdentity, + route: '/assets/fonts/JetBrainsMono-Regular-latin.woff2', + style: 'normal', + weight: '400', + }), + ]; + } + + async function sha256Hex(buffer) { + const digest = await crypto.subtle.digest('SHA-256', buffer); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + } + + async function loadBundledFonts() { + const bundledFontAssets = getBundledFontAssets(); + for (const fontAsset of bundledFontAssets) { + const response = await fetch(fontAsset.route, { cache: 'no-store' }); + invariant( + response.ok, + \`bundled font asset \${fontAsset.route} failed to load (\${response.status})\`, + ); + + const fontBuffer = await response.arrayBuffer(); + invariant( + fontBuffer.byteLength > 0, + \`bundled font asset \${fontAsset.route} must not be empty\`, + ); - const loadedFace = await fontFaceRule.load(); - document.fonts.add(loadedFace); + const assetIdentity = await sha256Hex(fontBuffer); + invariant( + assetIdentity === fontAsset.assetIdentity, + \`bundled font asset \${fontAsset.route} identity did not match the profile descriptor\`, + ); + + const fontFaceRule = new FontFace(fontAsset.family, fontBuffer, { + style: fontAsset.style, + weight: fontAsset.weight, + }); + const loadedFace = await fontFaceRule.load(); + document.fonts.add(loadedFace); + } await document.fonts.ready; } async function boot() { - await loadBundledFont(); + await loadBundledFonts(); await init(); const terminal = new Terminal({ @@ -875,14 +976,16 @@ async function loadServedAssets(): Promise< }); } - invariant( - BUNDLED_FONT_BUFFER.byteLength > 0, - 'bundled font buffer must not be empty', - ); - assetEntries.set(BUNDLED_FONT_ROUTE, { - body: BUNDLED_FONT_BUFFER, - contentType: BUNDLED_FONT_CONTENT_TYPE, - }); + for (const bundledFontAsset of BUNDLED_FONT_ASSETS) { + invariant( + bundledFontAsset.buffer.byteLength > 0, + `bundled font asset ${bundledFontAsset.route} must not be empty`, + ); + assetEntries.set(bundledFontAsset.route, { + body: bundledFontAsset.buffer, + contentType: bundledFontAsset.contentType, + }); + } return assetEntries; } diff --git a/src/renderer/ghosttyWeb/harness.html b/src/renderer/ghosttyWeb/harness.html index bcc9b09..f870fa2 100644 --- a/src/renderer/ghosttyWeb/harness.html +++ b/src/renderer/ghosttyWeb/harness.html @@ -137,6 +137,7 @@ /^#[0-9a-fA-F]{6}$/u.test(profile.foregroundColor), 'profile.foregroundColor must be a hex color', ); + let validatedFontAssets; if ( profile.fontAssetIdentity !== undefined && profile.fontAssetIdentity !== null @@ -147,8 +148,71 @@ 'profile.fontAssetIdentity must be a 64-character lowercase SHA-256 hex string', ); } + if (profile.fontAssets !== undefined) { + invariant( + Array.isArray(profile.fontAssets), + 'profile.fontAssets must be an array when provided', + ); + invariant( + profile.fontAssets.length > 0, + 'profile.fontAssets must be non-empty when provided', + ); - return Object.freeze({ ...profile }); + const seenAssetIdentities = new Set(); + const seenRoutes = new Set(); + validatedFontAssets = profile.fontAssets.map((fontAsset, index) => { + invariant( + fontAsset !== null && typeof fontAsset === 'object', + `profile.fontAssets[${index}] must be an object`, + ); + assertNonEmptyString( + fontAsset.family, + `profile.fontAssets[${index}].family must be a non-empty string`, + ); + invariant( + typeof fontAsset.assetIdentity === 'string' && + /^[a-f0-9]{64}$/u.test(fontAsset.assetIdentity), + `profile.fontAssets[${index}].assetIdentity must be a 64-character lowercase SHA-256 hex string`, + ); + assertNonEmptyString( + fontAsset.route, + `profile.fontAssets[${index}].route must be a non-empty string`, + ); + invariant( + fontAsset.route.startsWith('/'), + `profile.fontAssets[${index}].route must be an absolute route path`, + ); + assertNonEmptyString( + fontAsset.weight, + `profile.fontAssets[${index}].weight must be a non-empty string`, + ); + invariant( + fontAsset.style === 'normal' || + fontAsset.style === 'italic' || + fontAsset.style === 'oblique', + `profile.fontAssets[${index}].style must be normal, italic, or oblique`, + ); + invariant( + !seenAssetIdentities.has(fontAsset.assetIdentity), + `profile.fontAssets[${index}].assetIdentity must be unique`, + ); + invariant( + !seenRoutes.has(fontAsset.route), + `profile.fontAssets[${index}].route must be unique`, + ); + seenAssetIdentities.add(fontAsset.assetIdentity); + seenRoutes.add(fontAsset.route); + + return Object.freeze({ ...fontAsset }); + }); + } + + return Object.freeze({ + ...profile, + ...(validatedFontAssets !== undefined && { + fontAssets: Object.freeze(validatedFontAssets), + }), + }); } const profile = parseProfileFromLocation(); @@ -463,24 +527,68 @@ }, }; - async function loadBundledFont() { + function getBundledFontAssets() { + if ( + Array.isArray(profile.fontAssets) && + profile.fontAssets.length > 0 + ) { + return profile.fontAssets; + } if (!profile.fontAssetIdentity) { - return; + return []; } - const fontFaceRule = new FontFace( - profile.fontFamily, - 'url(/assets/fonts/JetBrainsMono-Regular-latin.woff2)', - { style: 'normal', weight: '400' }, - ); + return [ + Object.freeze({ + family: 'JetBrains Mono', + assetIdentity: profile.fontAssetIdentity, + route: '/assets/fonts/JetBrainsMono-Regular-latin.woff2', + style: 'normal', + weight: '400', + }), + ]; + } + + async function sha256Hex(buffer) { + const digest = await crypto.subtle.digest('SHA-256', buffer); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + } + + async function loadBundledFonts() { + const bundledFontAssets = getBundledFontAssets(); + for (const fontAsset of bundledFontAssets) { + const response = await fetch(fontAsset.route, { cache: 'no-store' }); + invariant( + response.ok, + `bundled font asset ${fontAsset.route} failed to load (${response.status})`, + ); + + const fontBuffer = await response.arrayBuffer(); + invariant( + fontBuffer.byteLength > 0, + `bundled font asset ${fontAsset.route} must not be empty`, + ); - const loadedFace = await fontFaceRule.load(); - document.fonts.add(loadedFace); + const assetIdentity = await sha256Hex(fontBuffer); + invariant( + assetIdentity === fontAsset.assetIdentity, + `bundled font asset ${fontAsset.route} identity did not match the profile descriptor`, + ); + + const fontFaceRule = new FontFace(fontAsset.family, fontBuffer, { + style: fontAsset.style, + weight: fontAsset.weight, + }); + const loadedFace = await fontFaceRule.load(); + document.fonts.add(loadedFace); + } await document.fonts.ready; } async function boot() { - await loadBundledFont(); + await loadBundledFonts(); await init(); const terminal = new Terminal({ diff --git a/src/renderer/profiles.ts b/src/renderer/profiles.ts index 939b231..ab55148 100644 --- a/src/renderer/profiles.ts +++ b/src/renderer/profiles.ts @@ -6,9 +6,14 @@ import { invariant } from '../util/assert.js'; import { BUNDLED_FONT_ASSET_IDENTITY, BUNDLED_FONT_FAMILY, + BUNDLED_PRIMARY_FONT_ASSET, + BUNDLED_SYMBOLS_FONT_ASSET, + BUNDLED_SYMBOLS_FONT_FAMILY, + getBundledFontAssetByIdentity, } from './bundledFont.js'; import { RenderProfileConfigSchema, + type RenderProfileBundledFont, type RenderProfileConfig, } from './types.js'; @@ -17,14 +22,50 @@ export const BUILTIN_PROFILE_NAMES = Object.freeze([ 'reference-light', ] as const); +export const REFERENCE_PROFILE_FONT_STACK = `"${BUNDLED_FONT_FAMILY}", "${BUNDLED_SYMBOLS_FONT_FAMILY}", monospace`; + type BuiltinProfileName = (typeof BUILTIN_PROFILE_NAMES)[number]; +function createBundledFontDescriptor( + family: string, + assetIdentity: string, + route: string, + weight: string, + style: 'italic' | 'normal' | 'oblique', +): RenderProfileBundledFont { + return Object.freeze({ + assetIdentity, + family, + route, + style, + weight, + }); +} + +const BUILTIN_PROFILE_FONT_ASSETS = Object.freeze([ + createBundledFontDescriptor( + BUNDLED_PRIMARY_FONT_ASSET.family, + BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + BUNDLED_PRIMARY_FONT_ASSET.route, + BUNDLED_PRIMARY_FONT_ASSET.weight, + BUNDLED_PRIMARY_FONT_ASSET.style, + ), + createBundledFontDescriptor( + BUNDLED_SYMBOLS_FONT_ASSET.family, + BUNDLED_SYMBOLS_FONT_ASSET.assetIdentity, + BUNDLED_SYMBOLS_FONT_ASSET.route, + BUNDLED_SYMBOLS_FONT_ASSET.weight, + BUNDLED_SYMBOLS_FONT_ASSET.style, + ), +] as const satisfies readonly RenderProfileBundledFont[]); + const BUILTIN_PROFILES: Record = { 'reference-dark': { name: 'reference-dark', theme: 'dark', - fontFamily: BUNDLED_FONT_FAMILY, + fontFamily: REFERENCE_PROFILE_FONT_STACK, fontAssetIdentity: BUNDLED_FONT_ASSET_IDENTITY, + fontAssets: [...BUILTIN_PROFILE_FONT_ASSETS], fontSize: 14, cursorStyle: 'block', backgroundColor: '#1e1e2e', @@ -33,8 +74,9 @@ const BUILTIN_PROFILES: Record = { 'reference-light': { name: 'reference-light', theme: 'light', - fontFamily: BUNDLED_FONT_FAMILY, + fontFamily: REFERENCE_PROFILE_FONT_STACK, fontAssetIdentity: BUNDLED_FONT_ASSET_IDENTITY, + fontAssets: [...BUILTIN_PROFILE_FONT_ASSETS], fontSize: 14, cursorStyle: 'block', backgroundColor: '#eff1f5', @@ -51,6 +93,21 @@ function formatSchemaIssues(error: ZodError): string { .join('; '); } +function assertBundledFontDescriptor( + fontAsset: RenderProfileBundledFont, + label: string, +): void { + const bundledAsset = getBundledFontAssetByIdentity(fontAsset.assetIdentity); + invariant( + bundledAsset !== undefined, + `${label} assetIdentity must reference a bundled font asset`, + ); + invariant( + bundledAsset.route === fontAsset.route, + `${label} route must match the bundled font asset route`, + ); +} + function assertRenderProfileConfig( config: unknown, ): asserts config is RenderProfileConfig { @@ -76,6 +133,21 @@ function assertRenderProfileConfig( /^#[0-9a-fA-F]{6}$/u.test(validatedConfig.foregroundColor), 'render profile foregroundColor must be a hex color', ); + if (validatedConfig.fontAssetIdentity !== undefined) { + invariant( + getBundledFontAssetByIdentity(validatedConfig.fontAssetIdentity) !== + undefined, + 'render profile fontAssetIdentity must reference a bundled font asset', + ); + } + for (const [index, fontAsset] of ( + validatedConfig.fontAssets ?? [] + ).entries()) { + assertBundledFontDescriptor( + fontAsset, + `render profile fontAssets.${String(index)}`, + ); + } } function isBuiltinProfileName(name: string): name is BuiltinProfileName { @@ -90,8 +162,19 @@ for (const profileName of BUILTIN_PROFILE_NAMES) { assertRenderProfileConfig(BUILTIN_PROFILES[profileName]); } +function cloneBundledFontDescriptor( + fontAsset: RenderProfileBundledFont, +): RenderProfileBundledFont { + return { ...fontAsset }; +} + function cloneProfile(profile: RenderProfileConfig): RenderProfileConfig { - return { ...profile }; + return { + ...profile, + ...(profile.fontAssets !== undefined + ? { fontAssets: profile.fontAssets.map(cloneBundledFontDescriptor) } + : {}), + }; } export function getBuiltinProfile( @@ -113,6 +196,14 @@ export function hashProfile(config: RenderProfileConfig): string { theme: config.theme, fontFamily: config.fontFamily, fontAssetIdentity: config.fontAssetIdentity ?? null, + fontAssets: + config.fontAssets?.map((fontAsset) => ({ + assetIdentity: fontAsset.assetIdentity, + family: fontAsset.family, + route: fontAsset.route, + style: fontAsset.style, + weight: fontAsset.weight, + })) ?? null, fontSize: config.fontSize, cursorStyle: config.cursorStyle, backgroundColor: config.backgroundColor, diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 7db16a3..fa7128f 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -22,6 +22,11 @@ const Sha256HexSchema = z /^[a-f0-9]{64}$/u, 'must be a 64-character lowercase SHA-256 hex string', ); +const BundledFontStyleSchema = z.enum(['normal', 'italic', 'oblique']); +const RoutePathSchema = z + .string() + .min(1) + .refine((value) => value.startsWith('/'), 'must be an absolute route path'); const OutputReplayEventSchema = z .object({ @@ -225,16 +230,57 @@ export const ScreenshotResultSchema = z .strict(); export type ScreenshotResult = z.infer; +export const RenderProfileBundledFontSchema = z + .object({ + family: NonEmptyStringSchema, + assetIdentity: Sha256HexSchema, + route: RoutePathSchema, + weight: NonEmptyStringSchema, + style: BundledFontStyleSchema, + }) + .strict(); +export type RenderProfileBundledFont = z.infer< + typeof RenderProfileBundledFontSchema +>; + export const RenderProfileConfigSchema = z .object({ name: NonEmptyStringSchema, theme: ThemeSchema, fontFamily: NonEmptyStringSchema, fontAssetIdentity: Sha256HexSchema.optional(), + fontAssets: z.array(RenderProfileBundledFontSchema).min(1).optional(), fontSize: PositiveNumberSchema, cursorStyle: CursorStyleSchema, backgroundColor: HexColorSchema, foregroundColor: HexColorSchema, }) - .strict(); + .strict() + .superRefine(({ fontAssets }, context) => { + if (fontAssets === undefined) { + return; + } + + const seenAssetIdentities = new Set(); + const seenRoutes = new Set(); + for (const [index, fontAsset] of fontAssets.entries()) { + if (seenAssetIdentities.has(fontAsset.assetIdentity)) { + context.addIssue({ + code: 'custom', + path: ['fontAssets', index, 'assetIdentity'], + message: 'fontAssets must not repeat asset identities', + }); + } + seenAssetIdentities.add(fontAsset.assetIdentity); + + if (seenRoutes.has(fontAsset.route)) { + context.addIssue({ + code: 'custom', + path: ['fontAssets', index, 'route'], + message: 'fontAssets must not repeat route paths', + }); + } + seenRoutes.add(fontAsset.route); + } + }); export type RenderProfileConfig = z.infer; diff --git a/test/e2e/unicode-grid.test.ts b/test/e2e/unicode-grid.test.ts index 7a28c83..33d988f 100644 --- a/test/e2e/unicode-grid.test.ts +++ b/test/e2e/unicode-grid.test.ts @@ -96,7 +96,7 @@ describe('unicode-grid e2e', { timeout: 60_000 }, () => { const output = normalizeTerminalOutput( await readOutput(testHome, sessionId), ); - for (const label of ['ASCII', 'BOX', 'CJK', 'EMOJI', 'AMBIG']) { + for (const label of ['ASCII', 'BOX', 'CJK', 'EMOJI', 'AMBIG', 'NERD']) { expect(output).toContain(label); } expect(output).toContain('UNICODE GRID COMPLETE'); @@ -109,9 +109,11 @@ describe('unicode-grid e2e', { timeout: 60_000 }, () => { expect(snapshotEnvelope.command).toBe('snapshot'); expectTextSnapshot(snapshotEnvelope.result); expect(snapshotEnvelope.result.sessionId).toBe(sessionId); - for (const label of ['ASCII', 'BOX', 'CJK', 'EMOJI', 'AMBIG']) { + for (const label of ['ASCII', 'BOX', 'CJK', 'EMOJI', 'AMBIG', 'NERD']) { expect(snapshotEnvelope.result.text).toContain(label); } + expect(snapshotEnvelope.result.text).toContain(''); + expect(snapshotEnvelope.result.text).toContain(''); expect(snapshotEnvelope.result.text).toContain('UNICODE GRID COMPLETE'); const screenshotEnvelope = runCliJson>( diff --git a/test/fixtures/apps/unicode-grid/main.ts b/test/fixtures/apps/unicode-grid/main.ts index c082be0..6092340 100644 --- a/test/fixtures/apps/unicode-grid/main.ts +++ b/test/fixtures/apps/unicode-grid/main.ts @@ -26,6 +26,7 @@ const rows = [ { row: 6, label: 'CJK', sample: '漢字テスト中文日本' }, { row: 7, label: 'EMOJI', sample: '✓✗★♠♣♥♦⚡☀☁' }, { row: 8, label: 'AMBIG', sample: 'αβγδ∑∏∫∂√∞' }, + { row: 9, label: 'NERD', sample: '  󰊢 󰈙 ' }, ] as const; for (const { label, sample } of rows) { diff --git a/test/unit/renderer/ghosttyWebBackend.test.ts b/test/unit/renderer/ghosttyWebBackend.test.ts index f1f5f79..3163b1d 100644 --- a/test/unit/renderer/ghosttyWebBackend.test.ts +++ b/test/unit/renderer/ghosttyWebBackend.test.ts @@ -5,11 +5,7 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { - BUNDLED_FONT_BUFFER, - BUNDLED_FONT_CONTENT_TYPE, - BUNDLED_FONT_ROUTE, -} from '../../../src/renderer/bundledFont.js'; +import { BUNDLED_FONT_ASSETS } from '../../../src/renderer/bundledFont.js'; import { hashProfile, resolveProfile } from '../../../src/renderer/profiles.js'; import { GhosttyWebBackend } from '../../../src/renderer/ghosttyWeb/index.js'; @@ -212,7 +208,7 @@ describe('GhosttyWebBackend unit guards', () => { ); }); - it('serves the bundled font asset over the backend HTTP server', async () => { + it('serves all bundled font assets over the backend HTTP server', async () => { const backend = createBackend(); try { @@ -226,17 +222,19 @@ describe('GhosttyWebBackend unit guards', () => { throw new Error('expected ghostty-web backend server origin'); } - const response = await fetch( - new URL(BUNDLED_FONT_ROUTE, serverOrigin).toString(), - ); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain( - BUNDLED_FONT_CONTENT_TYPE, - ); - - const fontBody = Buffer.from(await response.arrayBuffer()); - expect(fontBody.byteLength).toBeGreaterThan(0); - expect(fontBody.equals(BUNDLED_FONT_BUFFER)).toBe(true); + for (const bundledFontAsset of BUNDLED_FONT_ASSETS) { + const response = await fetch( + new URL(bundledFontAsset.route, serverOrigin).toString(), + ); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain( + bundledFontAsset.contentType, + ); + + const fontBody = Buffer.from(await response.arrayBuffer()); + expect(fontBody.byteLength).toBeGreaterThan(0); + expect(fontBody.equals(bundledFontAsset.buffer)).toBe(true); + } } finally { await backend.dispose(); } diff --git a/test/unit/renderer/profiles.test.ts b/test/unit/renderer/profiles.test.ts index 030ceba..cf55ef6 100644 --- a/test/unit/renderer/profiles.test.ts +++ b/test/unit/renderer/profiles.test.ts @@ -2,9 +2,14 @@ import { AssertionError } from 'node:assert'; import { describe, expect, it } from 'vitest'; -import { BUNDLED_FONT_FAMILY } from '../../../src/renderer/bundledFont.js'; +import { + BUNDLED_FONT_FAMILY, + BUNDLED_PRIMARY_FONT_ASSET, + BUNDLED_SYMBOLS_FONT_ASSET, +} from '../../../src/renderer/bundledFont.js'; import { BUILTIN_PROFILE_NAMES, + REFERENCE_PROFILE_FONT_STACK, getBuiltinProfile, hashProfile, resolveProfile, @@ -20,57 +25,82 @@ describe('renderer profiles', () => { it('returns cloned built-in profiles by name', () => { const profile = getBuiltinProfile('reference-dark'); + const secondRead = getBuiltinProfile('reference-dark'); + + expect(profile).not.toBeUndefined(); + expect(secondRead).not.toBeUndefined(); + if (profile === undefined || secondRead === undefined) { + throw new Error('expected reference-dark to be available'); + } expect(profile).toMatchObject({ name: 'reference-dark', theme: 'dark', - fontFamily: BUNDLED_FONT_FAMILY, + fontFamily: REFERENCE_PROFILE_FONT_STACK, + fontAssetIdentity: BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, fontSize: 14, cursorStyle: 'block', backgroundColor: '#1e1e2e', foregroundColor: '#cdd6f4', }); - expect(profile?.fontAssetIdentity).toMatch(/^[a-f0-9]{64}$/u); - - expect(profile).not.toBeUndefined(); - - const secondRead = getBuiltinProfile('reference-dark'); - expect(secondRead).not.toBeUndefined(); - - if (profile === undefined || secondRead === undefined) { - throw new Error('expected reference-dark to be available'); - } + expect(profile.fontAssets).toEqual([ + { + family: BUNDLED_PRIMARY_FONT_ASSET.family, + assetIdentity: BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + route: BUNDLED_PRIMARY_FONT_ASSET.route, + weight: BUNDLED_PRIMARY_FONT_ASSET.weight, + style: BUNDLED_PRIMARY_FONT_ASSET.style, + }, + { + family: BUNDLED_SYMBOLS_FONT_ASSET.family, + assetIdentity: BUNDLED_SYMBOLS_FONT_ASSET.assetIdentity, + route: BUNDLED_SYMBOLS_FONT_ASSET.route, + weight: BUNDLED_SYMBOLS_FONT_ASSET.weight, + style: BUNDLED_SYMBOLS_FONT_ASSET.style, + }, + ]); profile.fontFamily = 'mutated'; - profile.fontAssetIdentity = 'a'.repeat(64); + profile.fontAssetIdentity = BUNDLED_SYMBOLS_FONT_ASSET.assetIdentity; + if (profile.fontAssets?.[0] !== undefined) { + profile.fontAssets[0].family = 'mutated primary'; + } - expect(secondRead.fontFamily).toBe(BUNDLED_FONT_FAMILY); - expect(secondRead.fontAssetIdentity).toMatch(/^[a-f0-9]{64}$/u); - expect(secondRead.fontAssetIdentity).not.toBe('a'.repeat(64)); + expect(secondRead.fontFamily).toBe(REFERENCE_PROFILE_FONT_STACK); + expect(secondRead.fontAssetIdentity).toBe( + BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + ); + expect(secondRead.fontAssets?.[0]?.family).toBe(BUNDLED_FONT_FAMILY); }); - it('uses the bundled JetBrains Mono font for built-in profiles', () => { + it('uses the bundled font stack for built-in profiles', () => { for (const profileName of BUILTIN_PROFILE_NAMES) { const profile = resolveProfile(profileName); - expect(profile.fontFamily).toBe(BUNDLED_FONT_FAMILY); - expect(profile.fontAssetIdentity).toMatch(/^[a-f0-9]{64}$/u); + expect(profile.fontFamily).toBe(REFERENCE_PROFILE_FONT_STACK); + expect(profile.fontAssetIdentity).toBe( + BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + ); + expect( + profile.fontAssets?.map((fontAsset) => fontAsset.assetIdentity), + ).toEqual([ + BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + BUNDLED_SYMBOLS_FONT_ASSET.assetIdentity, + ]); } }); - it('resolves built-in and custom profiles without a font asset identity', () => { + it('resolves built-in and custom profiles without bundled font metadata', () => { expect(resolveProfile('reference-dark')).toMatchObject({ name: 'reference-dark', theme: 'dark', - fontFamily: BUNDLED_FONT_FAMILY, + fontFamily: REFERENCE_PROFILE_FONT_STACK, fontSize: 14, cursorStyle: 'block', backgroundColor: '#1e1e2e', foregroundColor: '#cdd6f4', }); - expect(resolveProfile('reference-dark').fontAssetIdentity).toMatch( - /^[a-f0-9]{64}$/u, - ); + expect(resolveProfile('reference-dark').fontAssets).toHaveLength(2); const customProfile = resolveProfile({ name: 'custom', @@ -92,6 +122,7 @@ describe('renderer profiles', () => { foregroundColor: '#000000', }); expect(customProfile).not.toHaveProperty('fontAssetIdentity'); + expect(customProfile).not.toHaveProperty('fontAssets'); }); it('hashes profiles deterministically as lowercase SHA-256 hex', () => { @@ -103,37 +134,60 @@ describe('renderer profiles', () => { expect(firstHash).toMatch(/^[a-f0-9]{64}$/u); }); - it('changes the hash when only fontAssetIdentity changes', () => { - const baseProfile = { + it('changes the hash when bundled font ordering changes', () => { + const baseProfile: Parameters[0] = { name: 'custom', theme: 'dark', - fontFamily: BUNDLED_FONT_FAMILY, - fontAssetIdentity: '1'.repeat(64), + fontFamily: REFERENCE_PROFILE_FONT_STACK, + fontAssets: [ + { + family: BUNDLED_PRIMARY_FONT_ASSET.family, + assetIdentity: BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + route: BUNDLED_PRIMARY_FONT_ASSET.route, + style: BUNDLED_PRIMARY_FONT_ASSET.style, + weight: BUNDLED_PRIMARY_FONT_ASSET.weight, + }, + { + family: BUNDLED_SYMBOLS_FONT_ASSET.family, + assetIdentity: BUNDLED_SYMBOLS_FONT_ASSET.assetIdentity, + route: BUNDLED_SYMBOLS_FONT_ASSET.route, + style: BUNDLED_SYMBOLS_FONT_ASSET.style, + weight: BUNDLED_SYMBOLS_FONT_ASSET.weight, + }, + ], fontSize: 14, cursorStyle: 'block', backgroundColor: '#1e1e2e', foregroundColor: '#cdd6f4', - } as const; + }; expect(hashProfile(baseProfile)).not.toBe( hashProfile({ ...baseProfile, - fontAssetIdentity: '2'.repeat(64), + fontAssets: [...(baseProfile.fontAssets ?? [])].reverse(), }), ); }); - it('keeps the hash stable when fontAssetIdentity is unchanged', () => { - const profile = { + it('keeps the hash stable when bundled font metadata is unchanged', () => { + const profile: Parameters[0] = { name: 'custom', theme: 'dark', - fontFamily: BUNDLED_FONT_FAMILY, - fontAssetIdentity: '3'.repeat(64), + fontFamily: REFERENCE_PROFILE_FONT_STACK, + fontAssets: [ + { + family: BUNDLED_PRIMARY_FONT_ASSET.family, + assetIdentity: BUNDLED_PRIMARY_FONT_ASSET.assetIdentity, + route: BUNDLED_PRIMARY_FONT_ASSET.route, + style: BUNDLED_PRIMARY_FONT_ASSET.style, + weight: BUNDLED_PRIMARY_FONT_ASSET.weight, + }, + ], fontSize: 14, cursorStyle: 'block', backgroundColor: '#1e1e2e', foregroundColor: '#cdd6f4', - } as const; + }; expect(hashProfile(profile)).toBe(hashProfile({ ...profile })); }); @@ -160,6 +214,29 @@ describe('renderer profiles', () => { expect(() => hashProfile(invalidProfile)).toThrow(/Too small/u); }); + it('rejects unbundled font asset identities in profile metadata', () => { + expect(() => + resolveProfile({ + name: 'broken-font-assets', + theme: 'dark', + fontFamily: REFERENCE_PROFILE_FONT_STACK, + fontAssets: [ + { + family: BUNDLED_FONT_FAMILY, + assetIdentity: 'a'.repeat(64), + route: BUNDLED_PRIMARY_FONT_ASSET.route, + style: 'normal', + weight: '400', + }, + ], + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }), + ).toThrow(/assetIdentity must reference a bundled font asset/u); + }); + it('throws clearly for unknown or invalid profiles', () => { expect(() => resolveProfile('nonexistent')).toThrow( /unknown render profile: nonexistent/u, diff --git a/test/unit/renderer/types.test.ts b/test/unit/renderer/types.test.ts index d9c9d83..7d0962e 100644 --- a/test/unit/renderer/types.test.ts +++ b/test/unit/renderer/types.test.ts @@ -201,6 +201,15 @@ describe('renderer schemas', () => { name: 'custom-profile', theme: 'dark', fontFamily: 'monospace', + fontAssets: [ + { + family: 'JetBrains Mono', + assetIdentity: 'a'.repeat(64), + route: '/assets/fonts/JetBrainsMono-Regular-latin.woff2', + weight: '400', + style: 'normal', + }, + ], fontSize: 14, cursorStyle: 'block', backgroundColor: '#1e1e2e', @@ -241,6 +250,36 @@ describe('renderer schemas', () => { ).toBe(true); }); + it('rejects render profiles with duplicate bundled font descriptors', () => { + const result = RenderProfileConfigSchema.safeParse({ + name: 'duplicate-font-assets', + theme: 'dark', + fontFamily: 'monospace', + fontAssets: [ + { + family: 'JetBrains Mono', + assetIdentity: 'a'.repeat(64), + route: '/assets/fonts/JetBrainsMono-Regular-latin.woff2', + weight: '400', + style: 'normal', + }, + { + family: 'Symbols Nerd Font Mono', + assetIdentity: 'a'.repeat(64), + route: '/assets/fonts/SymbolsNerdFontMono-Regular.ttf', + weight: '400', + style: 'normal', + }, + ], + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }); + + expect(result.success).toBe(false); + }); + it('rejects invalid render profile colors', () => { const result = RenderProfileConfigSchema.safeParse({ name: 'broken-profile',