Add syntax highlighting to file browser, harden capsules list

File browser:
- Add shiki-based syntax highlighting (lazy-loaded, zero initial bundle
  impact) with support for 30+ languages
- Cap highlighting at 2000 lines to avoid freezing on large files
- Pre-compute preview lines as derived state instead of re-splitting
  on every render
- Add content-visibility: auto on code lines for off-screen skip
- Remove per-line CSS transitions (unnecessary paint on 5000 elements)
- Cap row entrance animations to first 30 entries

Capsules list:
- Pause auto-refresh polling when browser tab is hidden
- Add empty state for search with no results
- Fix error state not clearing on successful refresh
- Fix action menu positioning near viewport edges
- Disable create button when no template selected
This commit is contained in:
2026-04-11 07:49:11 +06:00
parent 430fb9e70e
commit 26917d432d
6 changed files with 633 additions and 23 deletions

View File

@ -31,6 +31,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"chart.js": "^4.5.1"
"chart.js": "^4.5.1",
"shiki": "^4.0.2"
}
}

331
frontend/pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
chart.js:
specifier: ^4.5.1
version: 4.5.1
shiki:
specifier: ^4.0.2
version: 4.0.2
devDependencies:
'@fontsource-variable/jetbrains-mono':
specifier: ^5.2.8
@ -393,6 +396,37 @@ packages:
cpu: [x64]
os: [win32]
'@shikijs/core@4.0.2':
resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.0.2':
resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.0.2':
resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
engines: {node: '>=20'}
'@shikijs/langs@4.0.2':
resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
engines: {node: '>=20'}
'@shikijs/primitive@4.0.2':
resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
engines: {node: '>=20'}
'@shikijs/themes@4.0.2':
resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
engines: {node: '>=20'}
'@shikijs/types@4.0.2':
resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -536,13 +570,25 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@typescript-eslint/types@8.57.1':
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
@ -572,6 +618,15 @@ packages:
'@internationalized/date': ^3.8.1
svelte: ^5.33.0
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
@ -584,6 +639,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
@ -603,6 +661,9 @@ packages:
devalue@5.6.4:
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
enhanced-resolve@5.20.1:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
@ -635,6 +696,15 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@ -729,6 +799,24 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
micromark-util-character@2.1.1:
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
micromark-util-encode@2.0.1:
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
micromark-util-sanitize-uri@2.0.1:
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
micromark-util-symbol@2.0.1:
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@ -745,6 +833,12 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.5:
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -756,10 +850,22 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -781,6 +887,10 @@ packages:
set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
shiki@4.0.2:
resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
engines: {node: '>=20'}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
@ -789,6 +899,12 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
@ -828,6 +944,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -836,6 +955,27 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
unist-util-position@5.0.0:
resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
unist-util-stringify-position@4.0.0:
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
unist-util-visit-parents@6.0.2:
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -887,6 +1027,9 @@ packages:
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
snapshots:
'@esbuild/aix-ppc64@0.27.4':
@ -1088,6 +1231,46 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@shikijs/core@4.0.2':
dependencies:
'@shikijs/primitive': 4.0.2
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.5
'@shikijs/engine-oniguruma@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/primitive@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/themes@4.0.2':
dependencies:
'@shikijs/types': 4.0.2
'@shikijs/types@4.0.2':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@standard-schema/spec@1.1.0': {}
'@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)':
@ -1211,10 +1394,22 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/trusted-types@2.0.7': {}
'@types/unist@3.0.3': {}
'@typescript-eslint/types@8.57.1': {}
'@ungap/structured-clone@1.3.0': {}
'@xterm/addon-fit@0.11.0': {}
'@xterm/addon-web-links@0.12.0': {}
@ -1240,6 +1435,12 @@ snapshots:
transitivePeerDependencies:
- '@sveltejs/kit'
ccount@2.0.1: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
@ -1250,6 +1451,8 @@ snapshots:
clsx@2.1.1: {}
comma-separated-tokens@2.0.3: {}
cookie@0.6.0: {}
deepmerge@4.3.1: {}
@ -1260,6 +1463,10 @@ snapshots:
devalue@5.6.4: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
enhanced-resolve@5.20.1:
dependencies:
graceful-fs: 4.2.11
@ -1310,6 +1517,26 @@ snapshots:
graceful-fs@4.2.11: {}
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
html-void-elements@3.0.0: {}
inline-style-parser@0.2.7: {}
is-reference@3.0.3:
@ -1377,6 +1604,35 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mdast-util-to-hast@13.2.1:
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@ungap/structured-clone': 1.3.0
devlop: 1.1.0
micromark-util-sanitize-uri: 2.0.1
trim-lines: 3.0.1
unist-util-position: 5.0.0
unist-util-visit: 5.1.0
vfile: 6.0.3
micromark-util-character@2.1.1:
dependencies:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-util-encode@2.0.1: {}
micromark-util-sanitize-uri@2.0.1:
dependencies:
micromark-util-character: 2.1.1
micromark-util-encode: 2.0.1
micromark-util-symbol: 2.0.1
micromark-util-symbol@2.0.1: {}
micromark-util-types@2.0.2: {}
mri@1.2.0: {}
mrmime@2.0.1: {}
@ -1385,6 +1641,14 @@ snapshots:
obug@2.1.1: {}
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.5:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@ -1395,8 +1659,20 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
property-information@7.1.0: {}
readdirp@4.1.2: {}
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
@ -1443,6 +1719,17 @@ snapshots:
set-cookie-parser@3.0.1: {}
shiki@4.0.2:
dependencies:
'@shikijs/core': 4.0.2
'@shikijs/engine-javascript': 4.0.2
'@shikijs/engine-oniguruma': 4.0.2
'@shikijs/langs': 4.0.2
'@shikijs/themes': 4.0.2
'@shikijs/types': 4.0.2
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
@ -1451,6 +1738,13 @@ snapshots:
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
@ -1508,10 +1802,45 @@ snapshots:
totalist@3.0.1: {}
trim-lines@3.0.1: {}
tslib@2.8.1: {}
typescript@5.9.3: {}
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3
unist-util-position@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position@4.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-visit-parents@6.0.2:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit@5.1.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
unist-util-stringify-position: 4.0.0
vfile@6.0.3:
dependencies:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.4
@ -1530,3 +1859,5 @@ snapshots:
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1)
zimmerframe@1.1.4: {}
zwitch@2.0.4: {}

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createCapsule, listSnapshots, type Capsule, type CreateCapsuleParams, type Snapshot } from '$lib/api/capsules';
type Props = {
@ -292,7 +291,7 @@
</button>
<button
onclick={handleCreate}
disabled={creating}
disabled={creating || !templateQuery.trim()}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if creating}

View File

@ -8,6 +8,7 @@
formatFileSize,
type FileEntry,
} from '$lib/api/files';
import { tokenize, type ThemedToken } from '$lib/highlight';
type Props = {
sandboxId: string;
@ -29,17 +30,33 @@
let fileError = $state<string | null>(null);
let downloading = $state(false);
// Syntax highlighting (lazy — loaded on first use)
let highlightedTokens = $state<ThemedToken[][] | null>(null);
// Request generation counters — discard stale responses from rapid clicks
let dirGeneration = 0;
let fileGeneration = 0;
const MAX_PREVIEW_LINES = 5000;
const MAX_HIGHLIGHT_LINES = 2000; // Don't tokenize huge files — diminishing returns
// Path input
let pathInput = $state('~');
let pathInputFocused = $state(false);
let pathInputEl = $state<HTMLInputElement | undefined>(undefined);
// Pre-computed preview lines — avoids re-splitting on every render
const previewLines = $derived.by(() => {
if (!fileContent) return { lines: [] as string[], truncated: false, totalLines: 0 };
const allLines = fileContent.split('\n');
const truncated = allLines.length > MAX_PREVIEW_LINES;
return {
lines: truncated ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines,
truncated,
totalLines: allLines.length,
};
});
// Sorted entries: directories first, then files, alphabetical within each group
const sortedEntries = $derived(
[...entries].sort((a, b) => {
@ -71,6 +88,7 @@
selectedFile = null;
fileContent = null;
fileError = null;
highlightedTokens = null;
await loadDir();
}
@ -131,6 +149,7 @@
selectedFile = entry;
fileContent = null;
fileError = null;
highlightedTokens = null;
// Check if we should preview or prompt download
if (isBinaryFile(entry.name) || isFileTooLarge(entry.size)) {
@ -147,6 +166,14 @@
fileContent = null;
} else {
fileContent = result.data;
// Kick off highlighting in the background — preview shows plain text immediately.
// Only tokenize up to MAX_HIGHLIGHT_LINES to avoid freezing on large files.
const linesToHighlight = result.data.split('\n').length > MAX_HIGHLIGHT_LINES
? result.data.split('\n').slice(0, MAX_HIGHLIGHT_LINES).join('\n')
: result.data;
tokenize(linesToHighlight, entry.name).then((tokens) => {
if (gen === fileGeneration) highlightedTokens = tokens;
});
}
} else {
fileError = result.error;
@ -252,6 +279,71 @@
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
}
// Extension → color mapping for file icons and badges
function extColor(name: string): string {
const ext = fileExt(name);
switch (ext) {
case 'go': case 'mod': case 'sum':
return '#5a9fd4'; // blue — Go
case 'py': case 'pyi': case 'pyx':
return '#d4a73c'; // amber — Python
case 'js': case 'mjs': case 'cjs':
return '#d4a73c'; // amber — JavaScript
case 'ts': case 'mts': case 'cts': case 'tsx': case 'jsx':
return '#5a9fd4'; // blue — TypeScript/React
case 'rs':
return '#cf8172'; // red — Rust
case 'sh': case 'bash': case 'zsh': case 'fish':
return '#5e8c58'; // accent — shell
case 'json': case 'yaml': case 'yml': case 'toml': case 'ini': case 'env':
return '#8b7ec8'; // purple — config
case 'md': case 'mdx': case 'txt': case 'rst':
return 'var(--color-text-secondary)'; // neutral — docs
case 'sql':
return '#5a9fd4'; // blue — SQL
case 'proto':
return '#5e8c58'; // accent — protobuf
case 'svelte': case 'vue':
return '#cf8172'; // red — Svelte/Vue
case 'css': case 'scss': case 'less':
return '#5a9fd4'; // blue — styles
case 'html': case 'htm':
return '#cf8172'; // red — HTML
case 'dockerfile': case 'makefile':
return '#5e8c58'; // accent — build
default:
return 'var(--color-text-muted)';
}
}
// Descriptive label for file type badge in preview header
function extLabel(name: string): string {
const ext = fileExt(name);
const lower = name.toLowerCase();
if (lower === 'makefile') return 'Make';
if (lower === 'dockerfile') return 'Docker';
switch (ext) {
case 'go': return 'Go';
case 'py': return 'Python';
case 'js': case 'mjs': case 'cjs': return 'JS';
case 'ts': case 'mts': case 'cts': return 'TS';
case 'tsx': return 'TSX';
case 'jsx': return 'JSX';
case 'rs': return 'Rust';
case 'sh': case 'bash': return 'Shell';
case 'json': return 'JSON';
case 'yaml': case 'yml': return 'YAML';
case 'toml': return 'TOML';
case 'sql': return 'SQL';
case 'proto': return 'Proto';
case 'svelte': return 'Svelte';
case 'css': return 'CSS';
case 'html': case 'htm': return 'HTML';
case 'md': case 'mdx': return 'Markdown';
default: return ext ? ext.toUpperCase() : '';
}
}
// Load initial directory on mount, falling back to / if home can't be resolved
let hasInitiallyLoaded = false;
$effect(() => {
@ -277,10 +369,11 @@
}
.file-row.active {
background-color: var(--color-accent-glow);
border-left: 2px solid var(--color-accent);
border-left: 3px solid var(--color-accent);
box-shadow: inset 0 0 20px rgba(94, 140, 88, 0.06);
}
.file-row:not(.active) {
border-left: 2px solid transparent;
border-left: 3px solid transparent;
}
.preview-code {
@ -288,6 +381,12 @@
-moz-tab-size: 4;
}
/* Let the browser skip rendering off-screen lines in long files */
.code-line {
content-visibility: auto;
contain-intrinsic-size: auto 1.65rem;
}
/* Staggered row entrance */
@keyframes rowSlideIn {
from { opacity: 0; transform: translateX(-4px); }
@ -436,9 +535,10 @@
{#each sortedEntries as entry, idx (entry.path)}
<button
onclick={() => selectFile(entry)}
class="file-row row-enter flex w-full items-center gap-3 px-4 py-[7px] text-left
{selectedFile?.path === entry.path ? 'active' : ''}"
style="animation-delay: {Math.min(idx * 12, 200)}ms"
class="file-row flex w-full items-center gap-3 px-4 py-[7px] text-left
{selectedFile?.path === entry.path ? 'active' : ''}
{idx < 30 ? 'row-enter' : ''}"
style={idx < 30 ? `animation-delay: ${idx * 12}ms` : undefined}
>
<!-- Icon -->
{#if fileIcon(entry) === 'dir'}
@ -451,7 +551,7 @@
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{:else}
<svg class="shrink-0 text-[var(--color-text-muted)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="shrink-0" style="color: {extColor(entry.name)}" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
@ -461,7 +561,7 @@
<div class="flex flex-1 items-center gap-2 overflow-hidden">
<span class="truncate font-mono text-meta
{entry.type === 'directory'
? 'text-[var(--color-text-primary)]'
? 'text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)]'}">
{entry.name}
</span>
@ -472,8 +572,13 @@
{/if}
</div>
<!-- Size (files only) -->
<!-- Size + extension hint (files only) -->
{#if entry.type === 'file'}
{#if fileExt(entry.name)}
<span class="shrink-0 font-mono text-[9px] uppercase tracking-[0.05em]" style="color: {extColor(entry.name)}; opacity: 0.7">
{fileExt(entry.name)}
</span>
{/if}
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)]">
{formatFileSize(entry.size)}
</span>
@ -533,15 +638,18 @@
<polyline points="14 2 14 8 20 8" />
</svg>
{:else}
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="shrink-0" style="color: {extColor(selectedFile.name)}" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{/if}
<span class="truncate font-mono text-meta text-[var(--color-text-primary)]">{selectedFile.path}</span>
{#if fileExt(selectedFile.name)}
<span class="shrink-0 rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-badge uppercase text-[var(--color-text-muted)]">
{fileExt(selectedFile.name)}
{#if extLabel(selectedFile.name)}
<span
class="shrink-0 rounded-[3px] border px-1.5 py-0.5 font-mono text-badge font-semibold uppercase tracking-[0.03em]"
style="color: {extColor(selectedFile.name)}; border-color: color-mix(in srgb, {extColor(selectedFile.name)} 25%, transparent); background: color-mix(in srgb, {extColor(selectedFile.name)} 8%, transparent)"
>
{extLabel(selectedFile.name)}
</span>
{/if}
</div>
@ -632,16 +740,13 @@
</div>
{:else if fileContent !== null}
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
{@const allLines = fileContent.split('\n')}
{@const lines = allLines.length > MAX_PREVIEW_LINES ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines}
{@const truncated = allLines.length > MAX_PREVIEW_LINES}
<div style="animation: fadeUp 0.15s ease both">
<pre class="preview-code p-0 m-0"><code class="block">{#each lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
<pre class="preview-code p-0 m-0"><code class="block">{#each previewLines.lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)]">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem]">{#if highlightedTokens && highlightedTokens[i]}{#each highlightedTokens[i] as token}<span style="color: {token.color ?? 'var(--color-text-secondary)'}">{token.content}</span>{/each}{:else}<span class="text-[var(--color-text-secondary)]">{line || ' '}</span>{/if}</span></div>{/each}</code></pre>
</div>
{#if truncated}
{#if previewLines.truncated}
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
<span class="text-meta text-[var(--color-text-tertiary)]">
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {previewLines.totalLines.toLocaleString()} lines
</span>
<button
onclick={handleDownload}

View File

@ -0,0 +1,128 @@
/**
* Lazy syntax highlighting via shiki.
*
* The highlighter WASM engine + theme are loaded on first use.
* Language grammars load on-demand per extension.
* All imports are dynamic so nothing touches the main bundle.
*/
import type { HighlighterGeneric, ThemedToken } from 'shiki';
export type { ThemedToken };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let highlighter: HighlighterGeneric<any, any> | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let loadingPromise: Promise<HighlighterGeneric<any, any>> | null = null;
const THEME = 'vesper';
// Extensions → shiki language IDs.
// Only map what we expect users to encounter in sandboxes.
const EXT_TO_LANG: Record<string, string> = {
// Go
go: 'go', mod: 'go', sum: 'go',
// Python
py: 'python', pyi: 'python', pyx: 'python',
// JavaScript / TypeScript
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx',
// Rust
rs: 'rust',
// Shell
sh: 'shellscript', bash: 'shellscript', zsh: 'shellscript',
// Config
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
env: 'shellscript',
// Markup / docs
md: 'markdown', mdx: 'mdx', html: 'html', htm: 'html', xml: 'xml',
// CSS
css: 'css', scss: 'scss', less: 'less',
// SQL
sql: 'sql',
// Svelte / Vue
svelte: 'svelte', vue: 'vue',
// Docker / Make
dockerfile: 'dockerfile',
makefile: 'makefile',
// Proto
proto: 'protobuf',
// C / C++
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp',
// Java / Kotlin
java: 'java', kt: 'kotlin', kts: 'kotlin',
// Ruby
rb: 'ruby',
// PHP
php: 'php',
// Lua
lua: 'lua',
// Misc
txt: 'plaintext',
};
// Filenames without extensions
const NAME_TO_LANG: Record<string, string> = {
Dockerfile: 'dockerfile',
Makefile: 'makefile',
Containerfile: 'dockerfile',
Vagrantfile: 'ruby',
};
/** Resolve a filename to a shiki language ID, or null if unknown. */
export function langFromFilename(name: string): string | null {
// Check full filename first (Dockerfile, Makefile, etc.)
const basename = name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name;
if (NAME_TO_LANG[basename]) return NAME_TO_LANG[basename];
const dot = basename.lastIndexOf('.');
if (dot <= 0) return null;
const ext = basename.slice(dot + 1).toLowerCase();
return EXT_TO_LANG[ext] ?? null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getHighlighter(): Promise<HighlighterGeneric<any, any>> {
if (highlighter) return highlighter;
if (loadingPromise) return loadingPromise;
loadingPromise = (async () => {
const { createHighlighter } = await import('shiki');
const h = await createHighlighter({
themes: [THEME],
langs: [], // load languages on demand
});
highlighter = h;
return h;
})();
return loadingPromise;
}
/**
* Tokenize code for a given language.
* Returns an array of lines, each containing themed tokens with `color` and `content`.
* Returns null if the language is unknown or highlighting fails.
*/
export async function tokenize(
code: string,
filename: string,
): Promise<ThemedToken[][] | null> {
const lang = langFromFilename(filename);
if (!lang || lang === 'plaintext') return null;
try {
const h = await getHighlighter();
// Load grammar on demand if not yet loaded
const loaded = h.getLoadedLanguages();
if (!loaded.includes(lang)) {
await h.loadLanguage(lang);
}
return h.codeToTokensBase(code, { lang, theme: THEME });
} catch {
// Grammar not available or other error — fall back to plain text
return null;
}
}

View File

@ -132,6 +132,9 @@
const result = await listCapsules();
if (result.ok) {
capsules = result.data;
error = null;
} else {
error = result.error;
}
loading = false;
@ -236,10 +239,23 @@
}
}
function handleVisibility() {
if (document.hidden) {
stopAutoRefresh();
} else if (autoRefresh) {
fetchCapsules();
startAutoRefresh();
}
}
onMount(() => {
fetchCapsules();
startAutoRefresh();
return () => stopAutoRefresh();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stopAutoRefresh();
document.removeEventListener('visibilitychange', handleVisibility);
};
});
</script>
@ -376,7 +392,31 @@
Loading capsules...
</div>
</div>
{:else if filteredCapsules.length === 0 && searchQuery}
<!-- No search results -->
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="relative mb-5">
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-3)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
</div>
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
No matching capsules
</p>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
No capsules match "<span class="font-mono text-[var(--color-text-secondary)]">{searchQuery}</span>". Try a different ID.
</p>
<button
onclick={() => { searchQuery = ''; }}
class="mt-4 rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)]"
>
Clear search
</button>
</div>
{:else if filteredCapsules.length === 0}
<!-- No capsules at all -->
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="relative mb-5">
<div class="absolute inset-0 -m-4 rounded-full" style="background: radial-gradient(circle, rgba(94,140,88,0.08) 0%, transparent 70%)"></div>
@ -480,7 +520,13 @@
openMenuId = null;
} else {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
menuPos = { top: rect.bottom + 4, left: rect.right - 180 };
const menuW = 180;
const menuH = 140; // approximate max menu height
const top = rect.bottom + 4 + menuH > window.innerHeight
? rect.top - menuH - 4
: rect.bottom + 4;
const left = Math.max(8, Math.min(rect.right - menuW, window.innerWidth - menuW - 8));
menuPos = { top, left };
openMenuId = capsule.id;
}
}}