diff --git a/bun.lock b/bun.lock index 2e03153..2aa2750 100644 --- a/bun.lock +++ b/bun.lock @@ -31,8 +31,9 @@ "@contextbridge/instrumentation": "workspace:*", "@contextbridge/shared": "workspace:*", "@contextbridge/ui": "workspace:*", + "@shikijs/langs": "^4.3.1", + "@shikijs/themes": "^4.3.1", "hast-util-to-string": "^3.0.1", - "highlight.js": "^11.11.1", "lucide-react": "catalog:", "mermaid": "11.15.0", "neverthrow": "catalog:", @@ -40,8 +41,8 @@ "react-dom": "catalog:", "react-hotkeys-hook": "^5.3.2", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "shiki": "^4.3.1", }, "devDependencies": { "@storybook/react-vite": "catalog:", @@ -1002,19 +1003,19 @@ "@sentry/react": ["@sentry/react@10.51.0", "", { "dependencies": { "@sentry/browser": "10.51.0", "@sentry/core": "10.51.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw=="], - "@shikijs/core": ["@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" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + "@shikijs/core": ["@shikijs/core@4.3.1", "", { "dependencies": { "@shikijs/primitive": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg=="], - "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "@shikijs/langs": ["@shikijs/langs@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ=="], - "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "@shikijs/primitive": ["@shikijs/primitive@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A=="], - "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "@shikijs/themes": ["@shikijs/themes@4.3.1", "", { "dependencies": { "@shikijs/types": "4.3.1" } }, "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA=="], - "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "@shikijs/types": ["@shikijs/types@4.3.1", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1964,8 +1965,6 @@ "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], - "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], @@ -2178,8 +2177,6 @@ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], - "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], "lucide-react": ["lucide-react@1.16.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ=="], @@ -2618,8 +2615,6 @@ "rehype-format": ["rehype-format@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-format": "^1.0.0" } }, "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ=="], - "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], - "rehype-minify-whitespace": ["rehype-minify-whitespace@6.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-minify-whitespace": "^1.0.0" } }, "sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw=="], "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], @@ -2724,7 +2719,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["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" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "shiki": ["shiki@4.3.1", "", { "dependencies": { "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/engine-oniguruma": "4.3.1", "@shikijs/langs": "4.3.1", "@shikijs/themes": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], @@ -3062,6 +3057,8 @@ "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/markdown-remark/shiki": ["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" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/react/vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], @@ -3160,6 +3157,8 @@ "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "astro/shiki": ["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" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "astro/vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], "astro-eslint-parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], @@ -3264,6 +3263,18 @@ "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/markdown-remark/shiki/@shikijs/core": ["@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" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + + "@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + + "@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + + "@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "@astrojs/react/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "@astrojs/react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -3370,6 +3381,18 @@ "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "astro/shiki/@shikijs/core": ["@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" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + + "astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + + "astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + + "astro/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + + "astro/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + + "astro/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "astro/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], @@ -3450,6 +3473,8 @@ "yaml-language-server/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@astrojs/markdown-remark/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "@astrojs/react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@astrojs/react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -3534,6 +3559,8 @@ "astro-eslint-parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "astro/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -3560,6 +3587,22 @@ "@contextbridge-ai/eslint-config/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki": ["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" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "@contextbridge-ai/eslint-config/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@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" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + + "@astrojs/starlight/astro-expressive-code/rehype-expressive-code/expressive-code/@expressive-code/plugin-shiki/shiki/@shikijs/core/@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], } } diff --git a/packages/annotation/AGENTS.md b/packages/annotation/AGENTS.md index 7565d2e..9317321 100644 --- a/packages/annotation/AGENTS.md +++ b/packages/annotation/AGENTS.md @@ -26,6 +26,8 @@ Demo-only code lives under `src/demo/` (TerminalWindow, DemoStage, terminalScrip Consumes `@contextbridge/ui` — `styles.css` import in the entry, `cn()` helper, shared components under `@contextbridge/ui/components/*` (e.g. `Header`, `BrandMark`), and shadcn primitives under `@contextbridge/ui/components/ui/*`. See `packages/ui/AGENTS.md` for the wiring steps and the "do not remove" notes on the `@source` directive. +The annotation experience owns the curated Shiki theme catalog in `src/themes.ts`. `ThemeController` keeps localStorage, system-color-scheme observation, and root-element mutation behind the injected app context; components consume only semantic CSS properties. Shiki themes provide both the application palette and fenced-code token colors. Preserve the `System` default and keep explicit selections persistent across review sessions. + ## Design language: utilitarian, not SaaS This UI is a developer tool, not a marketing surface. Before adding visual weight (cards, shadows, large radii, tinted fills, backdrop-blur), read `.claude/rules/plan-review-design.md` — it owns the full set of rules. That file is auto-loaded when editing files in this package. diff --git a/packages/annotation/package.json b/packages/annotation/package.json index eccbee7..169345a 100644 --- a/packages/annotation/package.json +++ b/packages/annotation/package.json @@ -15,8 +15,9 @@ "@contextbridge/instrumentation": "workspace:*", "@contextbridge/shared": "workspace:*", "@contextbridge/ui": "workspace:*", + "@shikijs/langs": "^4.3.1", + "@shikijs/themes": "^4.3.1", "hast-util-to-string": "^3.0.1", - "highlight.js": "^11.11.1", "lucide-react": "catalog:", "mermaid": "11.15.0", "neverthrow": "catalog:", @@ -24,8 +25,8 @@ "react-dom": "catalog:", "react-hotkeys-hook": "^5.3.2", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "shiki": "^4.3.1" }, "devDependencies": { "@storybook/react-vite": "catalog:", diff --git a/packages/annotation/src/AnnotatedMarkdown.test.tsx b/packages/annotation/src/AnnotatedMarkdown.test.tsx index 4c255b5..cf74b36 100644 --- a/packages/annotation/src/AnnotatedMarkdown.test.tsx +++ b/packages/annotation/src/AnnotatedMarkdown.test.tsx @@ -2,7 +2,7 @@ import { asset } from '@contextbridge/shared/testFactories'; import { render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { describe, expect, it } from 'vitest'; -import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx'; +import { AnnotatedMarkdown, annotatedMarkdownTestIds } from './AnnotatedMarkdown.tsx'; describe('AnnotatedMarkdown image rendering', () => { const fixtureAsset = asset.build({ @@ -40,3 +40,35 @@ describe('AnnotatedMarkdown image rendering', () => { expect(img.getAttribute('src')).toBe('/tmp/missing.png'); }); }); + +describe('AnnotatedMarkdown syntax highlighting', () => { + it('renders fenced code with Shiki token spans while preserving the annotatable pre', () => { + const { container: rendered } = render( + ()} + themeId="dracula" + />, + ); + + const container = rendered.querySelector(`[data-testid="${annotatedMarkdownTestIds.container}"]`)!; + const pre = container.querySelector('pre'); + const tokens = container.querySelectorAll('.shiki-token'); + + expect(pre).toHaveAttribute('data-target-kind', 'code-block'); + expect(pre).toHaveAttribute('data-src-start-line', '1'); + expect(pre).toHaveAttribute('data-src-end-line', '3'); + expect(pre).toHaveClass('bg-[var(--code-background)]'); + expect(tokens.length).toBeGreaterThan(0); + expect(Array.from(tokens).every((token) => token.textContent === token.textContent?.trim())).toBe(true); + expect((tokens[0] as HTMLElement).style.color).not.toBe(''); + }); + + it('leaves adapter-owned code blocks un-tokenized', () => { + const { container: rendered } = render( + B\n```'} containerRef={createRef()} />, + ); + + expect(rendered.querySelector('.shiki-token')).toBeNull(); + }); +}); diff --git a/packages/annotation/src/AnnotatedMarkdown.tsx b/packages/annotation/src/AnnotatedMarkdown.tsx index 83a3555..c7818d3 100644 --- a/packages/annotation/src/AnnotatedMarkdown.tsx +++ b/packages/annotation/src/AnnotatedMarkdown.tsx @@ -5,9 +5,11 @@ import { toString as hastToString } from 'hast-util-to-string'; import { createElement } from 'react'; import type { ComponentPropsWithoutRef, ComponentType, JSX, Ref } from 'react'; import ReactMarkdown, { type ExtraProps } from 'react-markdown'; -import rehypeHighlight from 'rehype-highlight'; import remarkGfm from 'remark-gfm'; import { type ElementAdapter, elementAdapterForLanguage, elementAdapterLanguages } from './element/ElementAdapter.ts'; +import { rehypeShiki } from './rehypeShiki.ts'; +import { shikiHighlighter } from './shikiHighlighter.ts'; +import type { ThemeId } from './themes.ts'; export const annotatedMarkdownTestIds = { container: 'plan-review-markdown-plan', @@ -18,9 +20,16 @@ export interface AnnotatedMarkdownProps { containerRef: Ref; onMouseUp?: () => void; assets?: Asset[]; + themeId?: ThemeId; } -export function AnnotatedMarkdown({ content, containerRef, onMouseUp, assets }: AnnotatedMarkdownProps) { +export function AnnotatedMarkdown({ + content, + containerRef, + onMouseUp, + assets, + themeId = 'github-light-default', +}: AnnotatedMarkdownProps) { const assetsByPath = new Map(); for (const asset of assets ?? []) { assetsByPath.set(asset.originalPath, asset); @@ -43,8 +52,9 @@ export function AnnotatedMarkdown({ content, containerRef, onMouseUp, assets }: > {content} @@ -101,7 +111,7 @@ const AnnotatablePre = annotatable('pre', { targetKey: 'code', targetKind: 'code-block', className: - 'overflow-x-auto rounded-md border border-border bg-neutral-900 px-4 py-3 text-sm leading-6 text-neutral-50', + 'overflow-x-auto rounded-md border border-border bg-[var(--code-background)] px-4 py-3 text-sm leading-6 text-[var(--code-foreground)]', }); const markdownComponents = { @@ -210,7 +220,7 @@ const markdownComponents = { const LANGUAGE_PREFIX = 'language-'; // Reads the adapter + raw source of an adapter-claimed fenced code block. The children are -// still plain text nodes because rehype-highlight's plainText option skips these languages. +// still plain text nodes because adapter languages are not loaded into the Shiki highlighter. // Returns undefined for ordinary code blocks, which render normally. function elementBlockFromPre(node: Element | undefined): { adapter: ElementAdapter; source: string } | undefined { const code = node?.children.find((child): child is Element => child.type === 'element' && child.tagName === 'code'); diff --git a/packages/annotation/src/App.test.tsx b/packages/annotation/src/App.test.tsx index a658295..c9d1a06 100644 --- a/packages/annotation/src/App.test.tsx +++ b/packages/annotation/src/App.test.tsx @@ -16,6 +16,7 @@ import { commentsSidebarTestIds } from './CommentsSidebar.tsx'; import { globalCommentComposerTestIds } from './GlobalCommentComposer.tsx'; import { submitBarTestIds } from './SubmitBar.tsx'; import { drag, pressSubmitShortcut, renderApp } from './testHelpers/index.tsx'; +import { themePickerTestIds } from './ThemePicker.tsx'; import { updateNoticeCardTestIds } from './UpdateNoticeCard.tsx'; describe('App', () => { @@ -493,19 +494,16 @@ describe('App', () => { expect(screen.queryByTestId(submitBarTestIds.codexHandoffNotice)).not.toBeInTheDocument(); }); - it('syntax-highlights fenced code blocks with hljs token spans', async () => { + it('syntax-highlights fenced code blocks with Shiki token spans', async () => { renderApp({ initialPayload: { contentKind: 'plan', content: '# Plan\n\n```ts\nconst greeting = "hello";\n```\n' }, }); const pre = await waitForMarkdownElement('pre'); - expect(pre.querySelector('code')?.classList.contains('hljs')).toBe(true); - const stringToken = pre.querySelector('.hljs-string'); - expect(stringToken).not.toBeNull(); - expect(stringToken!.textContent).toBe('"hello"'); - const keyword = pre.querySelector('.hljs-keyword'); - expect(keyword?.textContent).toBe('const'); + expect(pre.querySelector('code')).toHaveClass('shiki'); + expect(getCodeToken('"hello"')).toBeInTheDocument(); + expect(getCodeToken('const')).toBeInTheDocument(); }); it('snaps a drag-selection inside a code token to the full token boundary', async () => { @@ -515,10 +513,10 @@ describe('App', () => { }); await waitFor(() => { - expect(getMarkdownElement('pre code.hljs .hljs-string')).not.toBeNull(); + expect(getCodeToken('"helloWorld"')).toBeInTheDocument(); }); - const stringToken = getMarkdownElement('pre code.hljs .hljs-string'); + const stringToken = getCodeToken('"helloWorld"'); const text = stringToken.firstChild as Text; drag({ target: text, from: 3, to: 5 }); @@ -573,10 +571,10 @@ describe('App', () => { }); await waitFor(() => { - expect(getMarkdownElement('pre code.hljs .hljs-string')).not.toBeNull(); + expect(getCodeToken('"hello"')).toBeInTheDocument(); }); - const stringToken = getMarkdownElement('pre code.hljs .hljs-string'); + const stringToken = getCodeToken('"hello"'); await user.click(stringToken); await screen.findByTestId(annotationDraftCommentComposerTestIds.container); @@ -999,8 +997,50 @@ Run \`${longCode}\` now. expect(feedbackButton).toBeInTheDocument(); }); }); + + describe('theme picker', () => { + it('renders the curated theme grid and persists the selected theme', async () => { + const { themeController } = renderApp({ initialPayload: { contentKind: 'plan', content: '# Ready' } }); + const user = userEvent.setup(); + + await user.click(screen.getByTestId(themePickerTestIds.trigger)); + + expect(await screen.findByTestId(themePickerTestIds.content)).toBeInTheDocument(); + expect(screen.getByTestId(themePickerTestIds.option('system'))).toHaveAttribute('aria-pressed', 'true'); + + await user.click(screen.getByTestId(themePickerTestIds.option('dracula'))); + + expect(themeController.savedPreferences).toEqual(['dracula']); + expect(screen.getByTestId(themePickerTestIds.option('dracula'))).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => { + expect(themeController.appliedThemes.at(-1)?.id).toBe('dracula'); + }); + }); + + it('tracks system color-scheme changes while System is selected', async () => { + const { themeController } = renderApp({ initialPayload: { contentKind: 'plan', content: '# Ready' } }); + + act(() => { + themeController.setSystemColorScheme('dark'); + }); + + await waitFor(() => { + expect(themeController.appliedThemes.at(-1)?.id).toBe('github-dark-default'); + }); + }); + }); }); +function getCodeToken(text: string): HTMLElement { + const token = Array.from( + screen.getByTestId(annotatedMarkdownTestIds.container).querySelectorAll('.shiki-token'), + ).find((candidate) => candidate.textContent === text); + if (!token) { + throw new Error(`Could not find Shiki token containing ${text}`); + } + return token; +} + /** Assert that `child`'s right edge does not extend beyond `parent`'s right border. */ function expectWithinRightBorder(child: Element, parent: Element): void { const childRect = child.getBoundingClientRect(); diff --git a/packages/annotation/src/App.tsx b/packages/annotation/src/App.tsx index b8b7bad..5906bdf 100644 --- a/packages/annotation/src/App.tsx +++ b/packages/annotation/src/App.tsx @@ -12,17 +12,19 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@contextbridge/ui/components/ui/alert-dialog'; -import 'highlight.js/styles/github-dark.css'; import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; import './annotationStyles.css'; import './codeHighlightStyles.css'; import { AnnotatedMarkdown } from './AnnotatedMarkdown.tsx'; import { getAnnotationHighlightWarning } from './annotationHighlights.ts'; import { CommentsSidebar } from './CommentsSidebar.tsx'; +import { ThemePicker } from './ThemePicker.tsx'; import { UpdateNoticeCard } from './UpdateNoticeCard.tsx'; import { useAnnotationInteractions } from './useAnnotationInteractions.ts'; import { useAnnotationState } from './useAnnotationState.ts'; import { useAnnotationAppContext } from './useAppContext.ts'; +import { useTheme } from './useTheme.ts'; export const appTestIds = { container: 'plan-review-app', @@ -49,11 +51,12 @@ export interface AppProps { } export function App({ initialPayload, initialThreads, initialGlobalComment }: AppProps = {}) { - const { fetchPayload, fetchUpdateNotice, analytics, buildInfo } = useAnnotationAppContext(); + const { fetchPayload, fetchUpdateNotice, analytics, buildInfo, themeController } = useAnnotationAppContext(); const [payload, setPayload] = useState(initialPayload ?? null); const [updateNotice, setUpdateNotice] = useState(null); const [updateNoticeDismissed, setUpdateNoticeDismissed] = useState(false); const reviewState = useAnnotationState({ initialThreads, initialGlobalComment }); + const themeState = useTheme(themeController); const annotationInteractions = useAnnotationInteractions({ threads: reviewState.threads, submitted: reviewState.submission.submitted, @@ -64,6 +67,7 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap const highlightWarning = getAnnotationHighlightWarning(); const showCommentNavigation = annotationInteractions.navigation.total > 0; const commentNavigationDisabled = reviewState.draft.active !== null; + const themePicker = ; useEffect(() => { if (initialPayload) { @@ -91,13 +95,14 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap <> {documentTitle} {!payload ? ( - + ) : (
@@ -120,6 +125,7 @@ export function App({ initialPayload, initialThreads, initialGlobalComment }: Ap content={payload.content} assets={payload.assets} onMouseUp={annotationInteractions.handleSelectionCapture} + themeId={themeState.theme.id} /> ) : (
diff --git a/packages/annotation/src/ThemeController.test.ts b/packages/annotation/src/ThemeController.test.ts new file mode 100644 index 0000000..dc63501 --- /dev/null +++ b/packages/annotation/src/ThemeController.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { ThemeControllerImpl } from './ThemeController.ts'; +import { themeById } from './themes.ts'; + +describe('ThemeControllerImpl', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + it('persists and restores an explicit theme preference', () => { + const controller = new ThemeControllerImpl(); + + controller.savePreference('dracula'); + + expect(new ThemeControllerImpl().loadPreference()).toBe('dracula'); + }); + + it('falls back to System when no valid preference is stored', () => { + expect(new ThemeControllerImpl().loadPreference()).toBe('system'); + }); + + it('applies all semantic colors and dark-mode metadata to the root', () => { + const root = document.createElement('div'); + const controller = new ThemeControllerImpl({ root }); + const theme = themeById.get('dracula')!; + + controller.applyTheme(theme); + + expect(root).toHaveAttribute('data-theme', 'dracula'); + expect(root).toHaveClass('dark'); + expect(root.style.colorScheme).toBe('dark'); + expect(root.style.getPropertyValue('--background')).toBe(theme.styles['--background']); + expect(root.style.getPropertyValue('--annotation-background')).toBe(theme.styles['--annotation-background']); + }); +}); diff --git a/packages/annotation/src/ThemeController.ts b/packages/annotation/src/ThemeController.ts new file mode 100644 index 0000000..fd77387 --- /dev/null +++ b/packages/annotation/src/ThemeController.ts @@ -0,0 +1,79 @@ +import { fromThrowable } from 'neverthrow'; +import type { ColorScheme, ThemeDefinition, ThemePreference } from './themes.ts'; +import { isThemePreference, resolveTheme } from './themes.ts'; + +const THEME_STORAGE_KEY = 'contextbridge.theme'; +const SYSTEM_DARK_QUERY = '(prefers-color-scheme: dark)'; + +export interface ThemeController { + loadPreference(): ThemePreference; + savePreference(preference: ThemePreference): void; + getSystemColorScheme(): ColorScheme; + subscribeToSystemColorScheme(listener: (colorScheme: ColorScheme) => void): () => void; + applyTheme(theme: ThemeDefinition): void; +} + +export interface ThemeControllerImplOptions { + readonly storage?: Storage; + readonly root?: HTMLElement; + readonly systemThemeQuery?: MediaQueryList; +} + +export class ThemeControllerImpl implements ThemeController { + readonly #storage: Storage; + readonly #root: HTMLElement; + readonly #systemThemeQuery: MediaQueryList; + + constructor(options: ThemeControllerImplOptions = {}) { + const { + storage = window.localStorage, + root = document.documentElement, + systemThemeQuery = window.matchMedia(SYSTEM_DARK_QUERY), + } = options; + this.#storage = storage; + this.#root = root; + this.#systemThemeQuery = systemThemeQuery; + } + + loadPreference(): ThemePreference { + const stored = readStoredPreference(this.#storage).unwrapOr(null); + return isThemePreference(stored) ? stored : 'system'; + } + + savePreference(preference: ThemePreference): void { + writeStoredPreference(this.#storage, preference); + } + + getSystemColorScheme(): ColorScheme { + return this.#systemThemeQuery.matches ? 'dark' : 'light'; + } + + subscribeToSystemColorScheme(listener: (colorScheme: ColorScheme) => void): () => void { + const handleChange = (event: MediaQueryListEvent) => { + listener(event.matches ? 'dark' : 'light'); + }; + this.#systemThemeQuery.addEventListener('change', handleChange); + return () => { + this.#systemThemeQuery.removeEventListener('change', handleChange); + }; + } + + applyTheme(theme: ThemeDefinition): void { + this.#root.dataset.theme = theme.id; + this.#root.classList.toggle('dark', theme.colorScheme === 'dark'); + this.#root.style.colorScheme = theme.colorScheme; + for (const [property, value] of Object.entries(theme.styles)) { + this.#root.style.setProperty(property, value); + } + } +} + +export function applyInitialTheme(controller: ThemeController): void { + const preference = controller.loadPreference(); + controller.applyTheme(resolveTheme(preference, controller.getSystemColorScheme())); +} + +const readStoredPreference = fromThrowable((storage: Storage) => storage.getItem(THEME_STORAGE_KEY)); +const writeStoredPreference = fromThrowable((storage: Storage, preference: ThemePreference) => { + storage.setItem(THEME_STORAGE_KEY, preference); +}); diff --git a/packages/annotation/src/ThemePicker.tsx b/packages/annotation/src/ThemePicker.tsx new file mode 100644 index 0000000..7a3c167 --- /dev/null +++ b/packages/annotation/src/ThemePicker.tsx @@ -0,0 +1,106 @@ +import { Button } from '@contextbridge/ui/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@contextbridge/ui/components/ui/popover'; +import { cn } from '@contextbridge/ui/lib/utils'; +import { Check, Palette } from 'lucide-react'; +import type { ThemePreference } from './themes.ts'; +import { systemThemeIds, themeById, themes } from './themes.ts'; + +export const themePickerTestIds = { + trigger: 'theme-picker-trigger', + content: 'theme-picker-content', + option: (preference: ThemePreference) => `theme-picker-option-${preference}`, +}; + +export interface ThemePickerProps { + readonly preference: ThemePreference; + readonly onSelect: (preference: ThemePreference) => void; +} + +export function ThemePicker({ preference, onSelect }: ThemePickerProps) { + return ( + + + + + +
+

Theme

+

Choose a palette for the review and its code blocks.

+
+
+ + {themes.map((theme) => ( + + ))} +
+
+
+ ); +} + +interface ThemeOptionProps { + readonly colors: readonly string[]; + readonly label: string; + readonly onSelect: (preference: ThemePreference) => void; + readonly preference: ThemePreference; + readonly selected: boolean; +} + +function ThemeOption({ colors, label, onSelect, preference, selected }: ThemeOptionProps) { + return ( + + ); +} + +const systemLight = themeById.get(systemThemeIds.light)!; +const systemDark = themeById.get(systemThemeIds.dark)!; +const systemPreviewColors = [...systemLight.preview.slice(0, 3), ...systemDark.preview.slice(0, 3)]; diff --git a/packages/annotation/src/annotationStyles.css b/packages/annotation/src/annotationStyles.css index 2077349..896e463 100644 --- a/packages/annotation/src/annotationStyles.css +++ b/packages/annotation/src/annotationStyles.css @@ -1,16 +1,16 @@ ::highlight(cb-plan-annotations) { - background-color: color-mix(in oklch, var(--color-amber-300) 85%, transparent); - color: var(--color-neutral-950); + background-color: var(--annotation-background); + color: var(--annotation-foreground); } ::highlight(cb-plan-annotation-active) { - background-color: var(--color-amber-400); - color: var(--color-neutral-950); + background-color: var(--annotation-active-background); + color: var(--annotation-foreground); } ::highlight(cb-plan-annotation-draft) { - background-color: color-mix(in oklch, var(--color-amber-300) 85%, transparent); - color: var(--color-neutral-950); + background-color: var(--annotation-background); + color: var(--annotation-foreground); } .cb-draft-thread-attention { diff --git a/packages/annotation/src/codeHighlightStyles.css b/packages/annotation/src/codeHighlightStyles.css index 26fac3b..c2bfab1 100644 --- a/packages/annotation/src/codeHighlightStyles.css +++ b/packages/annotation/src/codeHighlightStyles.css @@ -1,13 +1,11 @@ -/* Override highlight.js's github-dark theme for the annotation UI. - The wrapping
 in AnnotatedMarkdown supplies its own chrome (background,
-   padding, rounded corners), so we flatten the .hljs block-level rules and
-   let the per-token color rules pass through unchanged. */
-.cb-plan-content pre code.hljs {
+/* The wrapping 
 in AnnotatedMarkdown owns the code-block chrome. Shiki only
+   supplies token spans and their foreground colors. */
+.cb-plan-content pre code.shiki {
+  display: block;
+  overflow-x: visible;
   background: transparent;
   padding: 0;
   color: inherit;
-  display: block;
-  overflow-x: visible;
 }
 
 .cb-plan-content :not(pre) > code {
@@ -19,12 +17,12 @@
   overflow-wrap: anywhere;
 }
 
-.cb-plan-content pre code [class*='hljs-']:hover {
-  background-color: color-mix(in oklch, var(--color-white) 18%, transparent);
+.cb-plan-content pre code .shiki-token:hover {
+  background-color: color-mix(in oklch, var(--annotation-hover) 22%, transparent);
   cursor: pointer;
 }
 
 /* Innermost-only: suppress an ancestor token's glow while a descendant is hovered. */
-.cb-plan-content pre code [class*='hljs-']:has([class*='hljs-']:hover) {
+.cb-plan-content pre code .shiki-token:has(.shiki-token:hover) {
   background-color: transparent;
 }
diff --git a/packages/annotation/src/codeTokenSnap.test.ts b/packages/annotation/src/codeTokenSnap.test.ts
index a26831d..881eb2c 100644
--- a/packages/annotation/src/codeTokenSnap.test.ts
+++ b/packages/annotation/src/codeTokenSnap.test.ts
@@ -2,20 +2,20 @@ import { describe, expect, it } from 'vitest';
 import { findTokenSpan, snapRangeToTokenBoundaries } from './codeTokenSnap.ts';
 
 describe('findTokenSpan', () => {
-  it('returns the innermost hljs-* ancestor inside a code-block', () => {
+  it('returns the innermost Shiki token ancestor inside a code-block', () => {
     const container = renderContainer(
-      `
const foo = 1
`, + `
const foo = 1
`, ); - const variableSpan = container.querySelector('.hljs-variable')!; + const variableSpan = container.querySelector('.variable')!; const textNode = variableSpan.firstChild!; expect(findTokenSpan(textNode, container)).toBe(variableSpan); }); it('returns the innermost token when tokens are nested', () => { const container = renderContainer( - `
\`hello \${who}\`
`, + `
\`hello \${who}\`
`, ); - const innerVariable = container.querySelector('.hljs-variable')!; + const innerVariable = container.querySelector('.variable')!; expect(findTokenSpan(innerVariable.firstChild!, container)).toBe(innerVariable); }); @@ -27,7 +27,7 @@ describe('findTokenSpan', () => { it('returns null for whitespace text nodes between tokens', () => { const container = renderContainer( - `
const foo
`, + `
const foo
`, ); const code = container.querySelector('code')!; const whitespaceText = code.childNodes[1]!; @@ -35,8 +35,10 @@ describe('findTokenSpan', () => { }); it('returns null for code that is not inside a code-block target', () => { - const container = renderContainer(`
const
`); - const keyword = container.querySelector('.hljs-keyword')!; + const container = renderContainer( + `
const
`, + ); + const keyword = container.querySelector('.keyword')!; expect(findTokenSpan(keyword.firstChild!, container)).toBeNull(); }); }); @@ -44,9 +46,9 @@ describe('findTokenSpan', () => { describe('snapRangeToTokenBoundaries', () => { it('widens a selection starting and ending inside the same token', () => { const container = renderContainer( - `
const foobar
`, + `
const foobar
`, ); - const variable = container.querySelector('.hljs-variable')!; + const variable = container.querySelector('.variable')!; const text = variable.firstChild as Text; const range = document.createRange(); @@ -60,10 +62,10 @@ describe('snapRangeToTokenBoundaries', () => { it('widens both endpoints when a selection spans multiple tokens', () => { const container = renderContainer( - `
const foobar = 42
`, + `
const foobar = 42
`, ); - const keyword = container.querySelector('.hljs-keyword')!; - const variable = container.querySelector('.hljs-variable')!; + const keyword = container.querySelector('.keyword')!; + const variable = container.querySelector('.variable')!; const range = document.createRange(); range.setStart(keyword.firstChild as Text, 2); @@ -87,7 +89,7 @@ describe('snapRangeToTokenBoundaries', () => { it('returns an identical range when no endpoints are in tokens', () => { const container = renderContainer( - `
plain text with no spans
`, + `
plain text with no spans
`, ); const code = container.querySelector('code')!; const text = code.firstChild as Text; @@ -101,10 +103,10 @@ describe('snapRangeToTokenBoundaries', () => { it('only widens the endpoint that sits inside a token when the other does not', () => { const container = renderContainer( - `
lead foobar trail
`, + `
lead foobar trail
`, ); const code = container.querySelector('code')!; - const variable = container.querySelector('.hljs-variable')!; + const variable = container.querySelector('.variable')!; const leadText = code.firstChild as Text; const variableText = variable.firstChild as Text; diff --git a/packages/annotation/src/codeTokenSnap.ts b/packages/annotation/src/codeTokenSnap.ts index ca9284d..2103e77 100644 --- a/packages/annotation/src/codeTokenSnap.ts +++ b/packages/annotation/src/codeTokenSnap.ts @@ -1,5 +1,5 @@ const CODE_BLOCK_SELECTOR = '[data-target-kind="code-block"]'; -const TOKEN_SELECTOR = '[class*="hljs-"]'; +const TOKEN_SELECTOR = '.shiki-token'; export function snapRangeToTokenBoundaries(range: Range, container: HTMLElement): Range { const startToken = findTokenSpan(range.startContainer, container); diff --git a/packages/annotation/src/element/ElementAdapter.ts b/packages/annotation/src/element/ElementAdapter.ts index d9ac22e..6bb1ce4 100644 --- a/packages/annotation/src/element/ElementAdapter.ts +++ b/packages/annotation/src/element/ElementAdapter.ts @@ -19,7 +19,7 @@ export interface ElementAdapter { /** Stable id for this content type, stored verbatim on every anchor as `contentType` and used to route resolve/marker/hover calls back here. e.g. `'mermaid'`. */ readonly contentType: string; - /** The fenced-code info-string languages this adapter renders, e.g. `['mermaid']`. Also fed to rehype-highlight's `plainText` option so claimed blocks keep their raw text. */ + /** The fenced-code info-string languages this adapter renders, e.g. `['mermaid']`. Also excluded from Shiki so claimed blocks keep their raw text. */ readonly languages: string[]; /** React component that renders the source to DOM and tags its annotatable sub-elements. Spreads the shared block-level attrs from `elementBlock.ts` and dispatches `ELEMENT_RENDERED_EVENT` once tagged so markers re-apply. */ @@ -42,7 +42,7 @@ export interface ElementAdapter { /** The registered element adapters. Add a new content type here — nothing else changes. */ export const elementAdapters: ElementAdapter[] = [mermaidAdapter]; -/** Every language a registered element adapter renders; passed to rehype-highlight's `plainText` so those blocks are left as raw text. */ +/** Every language a registered element adapter renders; excluded from Shiki so those blocks are left as raw text. */ export const elementAdapterLanguages = elementAdapters.flatMap((adapter) => adapter.languages); /** The element adapter that renders a given fenced-code language, if any. */ diff --git a/packages/annotation/src/element/mermaid/mermaidAdapter.css b/packages/annotation/src/element/mermaid/mermaidAdapter.css index 8b67114..539ba17 100644 --- a/packages/annotation/src/element/mermaid/mermaidAdapter.css +++ b/packages/annotation/src/element/mermaid/mermaidAdapter.css @@ -1,15 +1,15 @@ /* Mermaid adapter styling — self-contained. Lives next to the adapter (not the shared annotationStyles.css) because every selector targets mermaid's generated SVG structure. Tokens are scoped to the mermaid block container so they don't leak globally; their values - mirror the amber (saved/active) + royal (hover) language used by text highlights. */ + semantic annotation + hover tokens used by text highlights. */ [data-element-content-type='mermaid'] { - --mermaid-mark: color-mix(in oklch, var(--color-amber-300) 85%, transparent); - --mermaid-mark-active: var(--color-amber-400); - --mermaid-mark-border: var(--color-amber-500); - --mermaid-mark-text: var(--color-neutral-950); - --mermaid-hover-stroke: var(--chart-3); - --mermaid-hover-node: color-mix(in oklab, var(--chart-3) 22%, var(--muted)); - --mermaid-hover-edge: color-mix(in oklab, var(--chart-3) 35%, transparent); + --mermaid-mark: var(--annotation-background); + --mermaid-mark-active: var(--annotation-active-background); + --mermaid-mark-border: var(--annotation-border); + --mermaid-mark-text: var(--annotation-foreground); + --mermaid-hover-stroke: var(--annotation-hover); + --mermaid-hover-node: color-mix(in oklab, var(--annotation-hover) 22%, var(--muted)); + --mermaid-hover-edge: color-mix(in oklab, var(--annotation-hover) 35%, transparent); } /* Mermaid injects per-diagram `#mermaid-rN .node rect {…}` rules (ID specificity), so our marker @@ -51,8 +51,8 @@ g.node:not(.cb-mermaid-annotated):hover > :is(rect, circle, ellipse, polygon, pa } /* Edge-label markers — mermaid draws the label chip as `g.edgeLabel > … div.labelBkg /

`, both - background-colored via injected ID rules, so these need !important too. Amber chip + dark label - for a saved/active edge, royal for hover — mirroring the node treatment. */ + background-colored via injected ID rules, so these need !important too. Annotation chip + themed label + for a saved/active edge, hover accent for preview — mirroring the node treatment. */ g.edgeLabel.cb-mermaid-annotated :is(.labelBkg, p) { background-color: var(--mermaid-mark) !important; } diff --git a/packages/annotation/src/main.tsx b/packages/annotation/src/main.tsx index fa7eaa5..763aab1 100644 --- a/packages/annotation/src/main.tsx +++ b/packages/annotation/src/main.tsx @@ -7,6 +7,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; import { fetchUpdateNotice } from './fetchUpdateNotice.ts'; +import { ThemeControllerImpl, applyInitialTheme } from './ThemeController.ts'; import type { AnnotationAppContext as AnnotationAppContextValue } from './useAppContext.ts'; import { AnnotationAppContext } from './useAppContext.ts'; @@ -21,6 +22,9 @@ if (!rootElement) throw new Error('#root not found'); void bootstrap(rootElement); async function bootstrap(target: HTMLElement): Promise { + const themeController = new ThemeControllerImpl(); + applyInitialTheme(themeController); + const config = (await fetchConfig()) ?? FALLBACK_CONFIG; const base = createFrontendContext({ @@ -34,6 +38,7 @@ async function bootstrap(target: HTMLElement): Promise { fetchUpdateNotice: () => fetchUpdateNotice(base.fetcher), submitAnnotation, autoCloseDelaySeconds: 3, + themeController, }; const { ErrorBoundary } = context.telemetry; diff --git a/packages/annotation/src/rehypeShiki.ts b/packages/annotation/src/rehypeShiki.ts new file mode 100644 index 0000000..d32639e --- /dev/null +++ b/packages/annotation/src/rehypeShiki.ts @@ -0,0 +1,80 @@ +import type { Element, Root, RootContent } from 'hast'; +import { toString as hastToString } from 'hast-util-to-string'; +import type { HighlighterCore, ShikiTransformer } from 'shiki/types'; + +export interface RehypeShikiOptions { + readonly highlighter: HighlighterCore; + readonly theme: string; + readonly skipLanguages?: readonly string[]; +} + +export function rehypeShiki({ highlighter, theme, skipLanguages = [] }: RehypeShikiOptions) { + return (tree: Root) => { + visitElements(tree, (node) => { + if (node.tagName !== 'pre') return; + const code = node.children.find(isCodeElement); + const language = code ? languageOf(code) : undefined; + if ( + !code || + !language || + skipLanguages.includes(language) || + !highlighter.getLoadedLanguages().includes(language) + ) { + return; + } + + const source = hastToString(code).replace(/\n$/, ''); + const highlighted = highlighter.codeToHast(source, { + lang: language, + mergeWhitespaces: 'never', + theme, + transformers: [tokenClassTransformer], + }); + const highlightedPre = highlighted.children.find(isPreElement); + const highlightedCode = highlightedPre?.children.find(isCodeElement); + if (!highlightedCode) return; + + code.children = highlightedCode.children; + code.properties = { + ...code.properties, + ...highlightedCode.properties, + className: [...classesOf(code), 'shiki'], + }; + }); + }; +} + +const tokenClassTransformer: ShikiTransformer = { + name: 'contextbridge-token-class', + span(node, _line, _column, _lineElement, token) { + if (token.content.trim().length > 0) { + this.addClassToHast(node, 'shiki-token'); + } + }, +}; + +function visitElements(node: Root | Element, visitor: (element: Element) => void): void { + for (const child of node.children) { + if (child.type !== 'element') continue; + visitor(child); + visitElements(child, visitor); + } +} + +function isCodeElement(node: RootContent): node is Element { + return node.type === 'element' && node.tagName === 'code'; +} + +function isPreElement(node: RootContent): node is Element { + return node.type === 'element' && node.tagName === 'pre'; +} + +function languageOf(code: Element): string | undefined { + const languageClass = classesOf(code).find((className) => className.startsWith('language-')); + return languageClass?.slice('language-'.length); +} + +function classesOf(element: Element): string[] { + const className = element.properties.className ?? element.properties['class']; + return Array.isArray(className) ? className.map(String) : []; +} diff --git a/packages/annotation/src/shikiHighlighter.ts b/packages/annotation/src/shikiHighlighter.ts new file mode 100644 index 0000000..c3df4b7 --- /dev/null +++ b/packages/annotation/src/shikiHighlighter.ts @@ -0,0 +1,60 @@ +import c from '@shikijs/langs/c'; +import cpp from '@shikijs/langs/cpp'; +import css from '@shikijs/langs/css'; +import diff from '@shikijs/langs/diff'; +import docker from '@shikijs/langs/docker'; +import go from '@shikijs/langs/go'; +import graphql from '@shikijs/langs/graphql'; +import html from '@shikijs/langs/html'; +import java from '@shikijs/langs/java'; +import javascript from '@shikijs/langs/javascript'; +import json from '@shikijs/langs/json'; +import jsx from '@shikijs/langs/jsx'; +import kotlin from '@shikijs/langs/kotlin'; +import lua from '@shikijs/langs/lua'; +import markdown from '@shikijs/langs/markdown'; +import php from '@shikijs/langs/php'; +import python from '@shikijs/langs/python'; +import ruby from '@shikijs/langs/ruby'; +import rust from '@shikijs/langs/rust'; +import shellscript from '@shikijs/langs/shellscript'; +import sql from '@shikijs/langs/sql'; +import swift from '@shikijs/langs/swift'; +import tsx from '@shikijs/langs/tsx'; +import typescript from '@shikijs/langs/typescript'; +import yaml from '@shikijs/langs/yaml'; +import { createHighlighterCoreSync } from 'shiki/core'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; +import { shikiThemes } from './themes.ts'; + +export const shikiHighlighter = createHighlighterCoreSync({ + themes: [...shikiThemes], + langs: [ + c, + cpp, + css, + diff, + docker, + go, + graphql, + html, + java, + javascript, + json, + jsx, + kotlin, + lua, + markdown, + php, + python, + ruby, + rust, + shellscript, + sql, + swift, + tsx, + typescript, + yaml, + ], + engine: createJavaScriptRegexEngine(), +}); diff --git a/packages/annotation/src/testHelpers/FakeThemeController.ts b/packages/annotation/src/testHelpers/FakeThemeController.ts new file mode 100644 index 0000000..db7d989 --- /dev/null +++ b/packages/annotation/src/testHelpers/FakeThemeController.ts @@ -0,0 +1,45 @@ +import type { ThemeController } from '../ThemeController.ts'; +import type { ColorScheme, ThemeDefinition, ThemePreference } from '../themes.ts'; + +export class FakeThemeController implements ThemeController { + readonly appliedThemes: ThemeDefinition[] = []; + readonly savedPreferences: ThemePreference[] = []; + + preference: ThemePreference; + systemColorScheme: ColorScheme; + #listeners = new Set<(colorScheme: ColorScheme) => void>(); + + constructor(preference: ThemePreference = 'system', systemColorScheme: ColorScheme = 'light') { + this.preference = preference; + this.systemColorScheme = systemColorScheme; + } + + loadPreference(): ThemePreference { + return this.preference; + } + + savePreference(preference: ThemePreference): void { + this.preference = preference; + this.savedPreferences.push(preference); + } + + getSystemColorScheme(): ColorScheme { + return this.systemColorScheme; + } + + subscribeToSystemColorScheme(listener: (colorScheme: ColorScheme) => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + applyTheme(theme: ThemeDefinition): void { + this.appliedThemes.push(theme); + } + + setSystemColorScheme(colorScheme: ColorScheme): void { + this.systemColorScheme = colorScheme; + for (const listener of this.#listeners) listener(colorScheme); + } +} diff --git a/packages/annotation/src/testHelpers/appContextDecorator.tsx b/packages/annotation/src/testHelpers/appContextDecorator.tsx index 2897e7c..a51045b 100644 --- a/packages/annotation/src/testHelpers/appContextDecorator.tsx +++ b/packages/annotation/src/testHelpers/appContextDecorator.tsx @@ -1,5 +1,6 @@ import { FakeFrontendBrowser, fakeFrontendContext } from '@contextbridge/context/testHelpers'; import type { ComponentType, ReactElement } from 'react'; +import { ThemeControllerImpl } from '../ThemeController.ts'; import type { AnnotationAppContext as AnnotationAppContextValue } from '../useAppContext.ts'; import { AnnotationAppContext } from '../useAppContext.ts'; @@ -12,6 +13,7 @@ export function createStoryAppContext(overrides?: Partial Promise.resolve(null), submitAnnotation: () => Promise.resolve(), autoCloseDelaySeconds: 3, + themeController: new ThemeControllerImpl({ storage: new StoryThemeStorage() }), ...overrides, }; } @@ -26,3 +28,31 @@ export function withAppContext(overrides?: Partial) { ); }; } + +class StoryThemeStorage implements Storage { + readonly #values = new Map(); + + get length(): number { + return this.#values.size; + } + + clear(): void { + this.#values.clear(); + } + + getItem(key: string): string | null { + return this.#values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.#values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.#values.delete(key); + } + + setItem(key: string, value: string): void { + this.#values.set(key, value); + } +} diff --git a/packages/annotation/src/testHelpers/createFakeAppContext.ts b/packages/annotation/src/testHelpers/createFakeAppContext.ts index b5d551f..f80bf53 100644 --- a/packages/annotation/src/testHelpers/createFakeAppContext.ts +++ b/packages/annotation/src/testHelpers/createFakeAppContext.ts @@ -9,6 +9,7 @@ import type { UpdateNotice } from '@contextbridge/shared/updateNoticeSchema'; import type { Mock } from 'vitest'; import { vi } from 'vitest'; import type { AnnotationAppContext } from '../useAppContext.ts'; +import { FakeThemeController } from './FakeThemeController.ts'; export interface FakeAppContextResult { context: AnnotationAppContext; @@ -18,6 +19,7 @@ export interface FakeAppContextResult { fetchUpdateNotice: Mock<() => Promise>; analytics: FakeAnalytics; telemetry: FakeFrontendTelemetry; + themeController: FakeThemeController; } export function createFakeAppContext(overrides?: Partial): FakeAppContextResult { @@ -27,6 +29,7 @@ export function createFakeAppContext(overrides?: Partial): .fn<() => Promise>() .mockResolvedValue({ content: '', contentKind: 'plan' }); const fetchUpdateNotice = vi.fn<() => Promise>().mockResolvedValue(null); + const themeController = new FakeThemeController(); const context: AnnotationAppContext = { ...fakeFrontendContext({ browser, @@ -35,6 +38,7 @@ export function createFakeAppContext(overrides?: Partial): fetchUpdateNotice, submitAnnotation, autoCloseDelaySeconds: 3, + themeController, ...overrides, }; return { @@ -45,5 +49,6 @@ export function createFakeAppContext(overrides?: Partial): fetchUpdateNotice, analytics: context.analytics as FakeAnalytics, telemetry: context.telemetry as FakeFrontendTelemetry, + themeController, }; } diff --git a/packages/annotation/src/themes.ts b/packages/annotation/src/themes.ts new file mode 100644 index 0000000..4817a40 --- /dev/null +++ b/packages/annotation/src/themes.ts @@ -0,0 +1,197 @@ +import ayuDark from '@shikijs/themes/ayu-dark'; +import ayuLight from '@shikijs/themes/ayu-light'; +import catppuccinLatte from '@shikijs/themes/catppuccin-latte'; +import catppuccinMocha from '@shikijs/themes/catppuccin-mocha'; +import dracula from '@shikijs/themes/dracula'; +import everforestDark from '@shikijs/themes/everforest-dark'; +import everforestLight from '@shikijs/themes/everforest-light'; +import githubDarkDefault from '@shikijs/themes/github-dark-default'; +import githubLightDefault from '@shikijs/themes/github-light-default'; +import gruvboxDarkMedium from '@shikijs/themes/gruvbox-dark-medium'; +import nord from '@shikijs/themes/nord'; +import rosePine from '@shikijs/themes/rose-pine'; +import tokyoNight from '@shikijs/themes/tokyo-night'; +import type { ThemeRegistrationAny } from 'shiki/types'; + +export type ColorScheme = 'light' | 'dark'; + +export interface ThemeDefinition { + readonly id: ThemeId; + readonly label: string; + readonly colorScheme: ColorScheme; + readonly preview: readonly string[]; + readonly styles: Readonly>; + readonly shikiTheme: ThemeRegistrationAny; +} + +const themeSources = [ + { id: 'github-light-default', label: 'GitHub Light', shikiTheme: githubLightDefault }, + { id: 'ayu-light', label: 'Ayu Light', shikiTheme: ayuLight }, + { id: 'everforest-light', label: 'Everforest Light', shikiTheme: everforestLight }, + { id: 'catppuccin-latte', label: 'Catppuccin Latte', shikiTheme: catppuccinLatte }, + { id: 'github-dark-default', label: 'GitHub Dark', shikiTheme: githubDarkDefault }, + { id: 'ayu-dark', label: 'Ayu Dark', shikiTheme: ayuDark }, + { id: 'everforest-dark', label: 'Everforest Dark', shikiTheme: everforestDark }, + { id: 'dracula', label: 'Dracula', shikiTheme: dracula }, + { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', shikiTheme: catppuccinMocha }, + { id: 'gruvbox-dark-medium', label: 'Gruvbox Dark', shikiTheme: gruvboxDarkMedium }, + { id: 'nord', label: 'Nord', shikiTheme: nord }, + { id: 'rose-pine', label: 'Rosé Pine', shikiTheme: rosePine }, + { id: 'tokyo-night', label: 'Tokyo Night', shikiTheme: tokyoNight }, +] as const; + +export type ThemeId = (typeof themeSources)[number]['id']; +export type ThemePreference = ThemeId | 'system'; + +export const themes: readonly ThemeDefinition[] = themeSources.map(({ id, label, shikiTheme }) => + createThemeDefinition(id, label, shikiTheme), +); + +export const shikiThemes: readonly ThemeRegistrationAny[] = themes.map(({ shikiTheme }) => shikiTheme); + +export const systemThemeIds = { + light: 'github-light-default', + dark: 'github-dark-default', +} as const satisfies Record; + +export const themeById = new Map(themes.map((theme) => [theme.id, theme])); + +export function resolveTheme(preference: ThemePreference, systemColorScheme: ColorScheme): ThemeDefinition { + const id = preference === 'system' ? systemThemeIds[systemColorScheme] : preference; + return themeById.get(id) ?? themeById.get(systemThemeIds[systemColorScheme])!; +} + +export function isThemePreference(value: string | null): value is ThemePreference { + return value === 'system' || (value !== null && themeById.has(value as ThemeId)); +} + +function createThemeDefinition(id: ThemeId, label: string, shikiTheme: ThemeRegistrationAny): ThemeDefinition { + const colors = shikiTheme.colors ?? {}; + const colorScheme: ColorScheme = shikiTheme.type === 'light' ? 'light' : 'dark'; + const background = pickColor(colors, ['editor.background'], colorScheme === 'light' ? '#ffffff' : '#0d1117'); + const foreground = pickColor(colors, ['editor.foreground'], colorScheme === 'light' ? '#1f2328' : '#f0f6fc'); + const surface = pickColor(colors, ['sideBar.background', 'editorWidget.background', 'panel.background'], background); + const muted = pickColor( + colors, + ['editorGroupHeader.tabsBackground', 'input.background', 'dropdown.background', 'sideBar.background'], + surface, + ); + const mutedForeground = pickColor( + colors, + ['descriptionForeground', 'sideBar.foreground', 'editorLineNumber.foreground'], + foreground, + ); + const border = pickColor( + colors, + ['panel.border', 'sideBar.border', 'editorGroup.border', 'input.border', 'contrastBorder'], + mutedForeground, + ); + const primary = pickColor( + colors, + ['textLink.foreground', 'activityBarBadge.background', 'button.background', 'focusBorder'], + foreground, + ); + const primaryForeground = bestContrastingColor(primary, background, foreground); + const accent = pickColor( + colors, + ['list.hoverBackground', 'list.activeSelectionBackground', 'editor.selectionBackground'], + muted, + ); + const accentForeground = pickColor( + colors, + ['list.hoverForeground', 'list.activeSelectionForeground', 'editor.selectionForeground'], + foreground, + ); + const destructive = pickColor(colors, ['errorForeground', 'editorError.foreground', 'terminal.ansiRed'], '#ef4444'); + const warning = pickColor(colors, ['editorWarning.foreground', 'terminal.ansiYellow'], '#f59e0b'); + const green = pickColor(colors, ['terminal.ansiGreen', 'testing.iconPassed'], primary); + const violet = pickColor(colors, ['terminal.ansiMagenta', 'symbolIcon.typeParameterForeground'], primary); + const cyan = pickColor(colors, ['terminal.ansiCyan', 'terminal.ansiBrightBlue'], primary); + const annotation = pickColor( + colors, + ['editor.findMatchBackground', 'editor.selectionBackground', 'editor.wordHighlightStrongBackground'], + warning, + ); + const annotationActive = pickColor( + colors, + ['editor.findMatchHighlightBackground', 'editor.selectionHighlightBackground'], + annotation, + ); + const annotationForeground = bestContrastingColor(annotation, background, foreground); + + return { + id, + label, + colorScheme, + shikiTheme, + preview: [background, surface, primary, annotation, green, violet], + styles: { + '--background': background, + '--foreground': foreground, + '--muted-foreground': mutedForeground, + '--border': border, + '--card': surface, + '--card-foreground': foreground, + '--primary': primary, + '--primary-foreground': primaryForeground, + '--secondary': muted, + '--secondary-foreground': foreground, + '--accent': accent, + '--accent-foreground': accentForeground, + '--muted': muted, + '--popover': surface, + '--popover-foreground': foreground, + '--destructive': destructive, + '--input': border, + '--ring': primary, + '--chart-1': warning, + '--chart-2': green, + '--chart-3': primary, + '--chart-4': violet, + '--chart-5': destructive, + '--sidebar': surface, + '--sidebar-foreground': foreground, + '--sidebar-primary': primary, + '--sidebar-primary-foreground': primaryForeground, + '--sidebar-accent': accent, + '--sidebar-accent-foreground': accentForeground, + '--sidebar-border': border, + '--sidebar-ring': primary, + '--annotation-background': annotation, + '--annotation-active-background': annotationActive, + '--annotation-foreground': annotationForeground, + '--annotation-border': warning, + '--annotation-hover': cyan, + '--code-background': background, + '--code-foreground': foreground, + }, + }; +} + +function bestContrastingColor(background: string, first: string, second: string): string { + return contrastRatio(background, first) >= contrastRatio(background, second) ? first : second; +} + +function contrastRatio(first: string, second: string): number { + const light = Math.max(relativeLuminance(first), relativeLuminance(second)); + const dark = Math.min(relativeLuminance(first), relativeLuminance(second)); + return (light + 0.05) / (dark + 0.05); +} + +function relativeLuminance(color: string): number { + const hex = color.match(/^#([\da-f]{6})/i)?.[1]; + if (!hex) return 0; + const channels = [0, 2, 4].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255); + const [red = 0, green = 0, blue = 0] = channels.map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function pickColor(colors: Record, keys: readonly string[], fallback: string): string { + for (const key of keys) { + const color = colors[key]; + if (color) return color; + } + return fallback; +} diff --git a/packages/annotation/src/useAppContext.ts b/packages/annotation/src/useAppContext.ts index 81ea60b..0e8835e 100644 --- a/packages/annotation/src/useAppContext.ts +++ b/packages/annotation/src/useAppContext.ts @@ -2,12 +2,14 @@ import type { FrontendContext } from '@contextbridge/context/frontend'; import type { AnnotationPayload, AnnotationSubmission } from '@contextbridge/shared/annotationSchema'; import type { UpdateNotice } from '@contextbridge/shared/updateNoticeSchema'; import { createContext, useContext } from 'react'; +import type { ThemeController } from './ThemeController.ts'; export interface AnnotationAppContext extends FrontendContext { fetchPayload: () => Promise; fetchUpdateNotice: () => Promise; submitAnnotation: (submission: AnnotationSubmission) => Promise; autoCloseDelaySeconds: number; + themeController: ThemeController; } export const AnnotationAppContext = createContext(null); diff --git a/packages/annotation/src/useTheme.ts b/packages/annotation/src/useTheme.ts new file mode 100644 index 0000000..6816a10 --- /dev/null +++ b/packages/annotation/src/useTheme.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import type { ThemeController } from './ThemeController.ts'; +import type { ThemePreference } from './themes.ts'; +import { resolveTheme } from './themes.ts'; + +export function useTheme(controller: ThemeController) { + const [preference, setPreference] = useState(() => controller.loadPreference()); + const [systemColorScheme, setSystemColorScheme] = useState(() => controller.getSystemColorScheme()); + const theme = resolveTheme(preference, systemColorScheme); + + useEffect(() => { + controller.applyTheme(theme); + }, [controller, theme]); + + useEffect(() => { + if (preference !== 'system') return; + return controller.subscribeToSystemColorScheme(setSystemColorScheme); + }, [controller, preference]); + + const selectTheme = (nextPreference: ThemePreference) => { + controller.savePreference(nextPreference); + if (nextPreference === 'system') { + setSystemColorScheme(controller.getSystemColorScheme()); + } + setPreference(nextPreference); + }; + + return { + preference, + selectTheme, + theme, + }; +} diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 2c09b8e..90b334a 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -15,7 +15,7 @@ This package holds the styling foundation for every browser UI in cb-cli (`packa - **IBM Plex Sans** — prose + chrome (including content headings like `h1–h6`). Loaded via `@fontsource/ibm-plex-sans`. Exposed as `var(--cb-font-sans)`; applied to `html, body` so consumers inherit it automatically. - **IBM Plex Mono** — code blocks and inline code. Loaded via `@fontsource/ibm-plex-mono`. Exposed as `var(--cb-font-mono)` and the `.font-mono` utility class. Scope it to `pre`/`code` containers; do not apply to prose. - **Headings inherit Plex Sans by default.** Keep ESBuild opt-in through `.font-brand` only. -- **Dark mode**: `@media (prefers-color-scheme: dark)` only. No `.dark` class, no toggle, no runtime theming JS. Browser UIs are spawned per session, so the OS setting is the contract. +- **Dark mode and themes**: browser UIs default to `prefers-color-scheme`, while experiences may apply a curated runtime theme by setting semantic CSS custom properties and a `data-theme` attribute on ``. Keep theme selection and persistence behind injected browser-side controllers; shared components consume semantic tokens and must not know theme IDs. - **Colors**: OKLCH palette from `contextbridge.github.io`. Brand accents (royal, violet, coral, tangerine, peach) plus the shadcn semantic tokens (primary, secondary, muted, accent, destructive, card, popover, sidebar, chart-1..5). - **Tailwind v4**: CSS-based config via `@theme inline` in `styles.css`. **There is no `tailwind.config.{js,ts}` file and there should never be one.** @@ -55,6 +55,6 @@ After cleanup, run `bun run typecheck` here and `just verify` at the root. ## What NOT to put in this package - **Per-experience components** (plan-specific cards, review-specific widgets). Those live in the consuming package. `@contextbridge/ui` holds only generic, cross-experience primitives. -- **Runtime theming logic** (`useTheme`, ThemeProvider, localStorage persistence). System preference is the contract; no toggles. +- **Experience-specific theme catalogs or persistence logic.** Keep shared semantic tokens and generic picker primitives here, but theme choices, localStorage behavior, and settings surfaces live in the consuming browser-UI package behind an injected controller. - **Marketing-site-only utilities** (`.glass`, `.hero-product-card`, `.section-dark`, MagicUI animations). `contextbridge.github.io` has those — the CLI UIs do not need them. - **A `tailwind.config.{js,ts}` file**. Tailwind v4 is configured entirely via CSS (`@theme inline` in `styles.css`). diff --git a/packages/ui/src/components/Header.tsx b/packages/ui/src/components/Header.tsx index df2cc50..8f33d31 100644 --- a/packages/ui/src/components/Header.tsx +++ b/packages/ui/src/components/Header.tsx @@ -1,4 +1,5 @@ import { CircleHelp, MessageSquare } from 'lucide-react'; +import type { ReactNode } from 'react'; import { BrandMark } from './BrandMark.tsx'; import { Button } from './ui/button.tsx'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu.tsx'; @@ -21,9 +22,10 @@ export interface HeaderProps { feedbackHref: string; githubRepoHref: string; slackHelpHref: string; + settings?: ReactNode; } -export function Header({ version, docsHref, feedbackHref, githubRepoHref, slackHelpHref }: HeaderProps) { +export function Header({ version, docsHref, feedbackHref, githubRepoHref, slackHelpHref, settings }: HeaderProps) { return (

v{version} + {settings}