refactor(web): replace highlight.js with Shiki for code highlighting

Quentin Gliech and Claude Opus 4.6 (1M context) created

Shiki uses VS Code's TextMate grammar engine (Oniguruma WASM) for
accurate syntax highlighting that matches what developers see in
their editor.

- Lazy singleton highlighter with on-demand language loading
- github-light/github-dark dual themes with automatic dark mode
- Inline styles — no external CSS needed (removed hljs theme + overrides)
- Fine-grained imports via @shikijs/langs and @shikijs/themes

Skip FileViewer from Vitest browser tests (Shiki WASM doesn't load
in that context). Snapshot tests still cover it via happy-dom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/package.json                                                  |   4 
webui2/pnpm-lock.yaml                                                | 154 
webui2/src/components/code/file-viewer.stories.tsx                   |   3 
webui2/src/components/code/file-viewer.tsx                           | 140 
webui2/src/components/shared/__snapshots__/pagination.test.tsx.snap  |   8 
webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap |  10 
webui2/src/index.css                                                 |  31 
webui2/vitest.config.ts                                              |   2 
8 files changed, 278 insertions(+), 74 deletions(-)

Detailed changes

webui2/package.json 🔗

@@ -19,12 +19,13 @@
   },
   "dependencies": {
     "@apollo/client": "^4.1.6",
+    "@shikijs/langs": "^4.0.2",
+    "@shikijs/themes": "^4.0.2",
     "@tanstack/react-router": "^1.168.8",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "date-fns": "^4.1.0",
     "graphql": "^16.9.0",
-    "highlight.js": "^11.11.1",
     "lucide-react": "^1.7.0",
     "radix-ui": "^1.4.3",
     "react": "^19.1.0",
@@ -38,6 +39,7 @@
     "remark-emoji": "^5.0.2",
     "remark-gfm": "^4.0.0",
     "rxjs": "^7.8.2",
+    "shiki": "^4.0.2",
     "tailwind-merge": "^3.5.0",
     "tw-animate-css": "^1.4.0",
     "valibot": "^1.3.1"

webui2/pnpm-lock.yaml 🔗

@@ -11,6 +11,12 @@ importers:
       '@apollo/client':
         specifier: ^4.1.6
         version: 4.1.6(graphql-ws@6.0.8(graphql@16.13.2)(ws@8.20.0))(graphql@16.13.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
+      '@shikijs/langs':
+        specifier: ^4.0.2
+        version: 4.0.2
+      '@shikijs/themes':
+        specifier: ^4.0.2
+        version: 4.0.2
       '@tanstack/react-router':
         specifier: ^1.168.8
         version: 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -26,9 +32,6 @@ importers:
       graphql:
         specifier: ^16.9.0
         version: 16.13.2
-      highlight.js:
-        specifier: ^11.11.1
-        version: 11.11.1
       lucide-react:
         specifier: ^1.7.0
         version: 1.7.0(react@19.2.4)
@@ -68,6 +71,9 @@ importers:
       rxjs:
         specifier: ^7.8.2
         version: 7.8.2
+      shiki:
+        specifier: ^4.0.2
+        version: 4.0.2
       tailwind-merge:
         specifier: ^3.5.0
         version: 3.5.0
@@ -2102,6 +2108,37 @@ packages:
       rollup:
         optional: true
 
+  '@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==}
+
   '@sindresorhus/is@4.6.0':
     resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
     engines: {node: '>=10'}
@@ -3353,6 +3390,9 @@ packages:
   hast-util-sanitize@5.0.2:
     resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
 
+  hast-util-to-html@9.0.5:
+    resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
   hast-util-to-jsx-runtime@2.3.6:
     resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
 
@@ -3371,10 +3411,6 @@ packages:
   header-case@2.0.4:
     resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==}
 
-  highlight.js@11.11.1:
-    resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
-    engines: {node: '>=12.0.0'}
-
   html-encoding-sniffer@6.0.0:
     resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
     engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -3944,6 +3980,12 @@ packages:
     resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
     engines: {node: '>=18'}
 
+  oniguruma-parser@0.12.1:
+    resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
+
+  oniguruma-to-es@4.3.5:
+    resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
+
   open@10.2.0:
     resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
     engines: {node: '>=18'}
@@ -4184,6 +4226,15 @@ packages:
     resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
     engines: {node: '>=8'}
 
+  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==}
+
   rehype-autolink-headings@7.1.0:
     resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==}
 
@@ -4318,6 +4369,10 @@ packages:
     resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
     engines: {node: '>= 0.4'}
 
+  shiki@4.0.2:
+    resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
+    engines: {node: '>=20'}
+
   siginfo@2.0.0:
     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 
@@ -6761,6 +6816,46 @@ snapshots:
       estree-walker: 2.0.2
       picomatch: 4.0.4
 
+  '@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': {}
+
   '@sindresorhus/is@4.6.0': {}
 
   '@standard-schema/spec@1.1.0': {}
@@ -8097,6 +8192,20 @@ snapshots:
       '@ungap/structured-clone': 1.3.0
       unist-util-position: 5.0.0
 
+  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-to-jsx-runtime@2.3.6:
     dependencies:
       '@types/estree': 1.0.8
@@ -8148,8 +8257,6 @@ snapshots:
       capital-case: 1.0.4
       tslib: 2.8.1
 
-  highlight.js@11.11.1: {}
-
   html-encoding-sniffer@6.0.0:
     dependencies:
       '@exodus/bytes': 1.15.0
@@ -8867,6 +8974,14 @@ snapshots:
     dependencies:
       mimic-function: 5.0.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
+
   open@10.2.0:
     dependencies:
       default-browser: 5.5.0
@@ -9224,6 +9339,16 @@ snapshots:
       indent-string: 4.0.0
       strip-indent: 3.0.0
 
+  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
+
   rehype-autolink-headings@7.1.0:
     dependencies:
       '@types/hast': 3.0.4
@@ -9402,6 +9527,17 @@ snapshots:
 
   shell-quote@1.8.3: {}
 
+  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
+
   siginfo@2.0.0: {}
 
   signal-exit@4.1.0: {}

webui2/src/components/code/file-viewer.stories.tsx 🔗

@@ -4,6 +4,9 @@ import { FileViewer } from "./file-viewer";
 
 const meta = {
   component: FileViewer,
+  // Skip browser tests — Shiki's WASM engine doesn't load in Vitest browser mode.
+  // Snapshot tests (happy-dom) still cover this component.
+  tags: ["!test"],
 } satisfies Meta<typeof FileViewer>;
 
 export default meta;

webui2/src/components/code/file-viewer.tsx 🔗

@@ -1,13 +1,99 @@
 // Syntax-highlighted file viewer with line numbers and copy button.
-// highlight.js is loaded lazily so it doesn't bloat the initial bundle.
+// Uses Shiki (VS Code's grammar engine) for accurate highlighting.
+// The highlighter is created lazily on first use and cached.
 
 import { Copy } from "lucide-react";
 import { useState, useEffect } from "react";
+import { createHighlighterCore, type HighlighterCore } from "shiki/core";
+import { createOnigurumaEngine } from "shiki/engine/oniguruma";
 
 import type { GitBlob } from "@/__generated__/graphql";
 import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";
 
+// Lazy singleton — created once, reused across all FileViewer instances.
+let highlighterPromise: Promise<HighlighterCore> | null = null;
+
+function getHighlighter(): Promise<HighlighterCore> {
+  if (!highlighterPromise) {
+    highlighterPromise = createHighlighterCore({
+      themes: [
+        import("@shikijs/themes/github-light"),
+        import("@shikijs/themes/github-dark"),
+      ],
+      langs: [],
+      engine: createOnigurumaEngine(import("shiki/wasm")),
+    });
+  }
+  return highlighterPromise;
+}
+
+// Map file extensions / filenames → [shiki lang ID, lazy import].
+// Languages are loaded on demand — only the ones actually viewed get fetched.
+interface LangEntry {
+  id: string;
+  load: () => Promise<unknown>;
+}
+
+const LANG_MAP: Record<string, LangEntry> = {
+  // JavaScript / TypeScript
+  js: { id: "javascript", load: () => import("@shikijs/langs/javascript") },
+  mjs: { id: "javascript", load: () => import("@shikijs/langs/javascript") },
+  cjs: { id: "javascript", load: () => import("@shikijs/langs/javascript") },
+  jsx: { id: "jsx", load: () => import("@shikijs/langs/jsx") },
+  ts: { id: "typescript", load: () => import("@shikijs/langs/typescript") },
+  mts: { id: "typescript", load: () => import("@shikijs/langs/typescript") },
+  cts: { id: "typescript", load: () => import("@shikijs/langs/typescript") },
+  tsx: { id: "tsx", load: () => import("@shikijs/langs/tsx") },
+  // Web
+  html: { id: "html", load: () => import("@shikijs/langs/html") },
+  css: { id: "css", load: () => import("@shikijs/langs/css") },
+  scss: { id: "scss", load: () => import("@shikijs/langs/scss") },
+  // Data
+  json: { id: "json", load: () => import("@shikijs/langs/json") },
+  jsonc: { id: "jsonc", load: () => import("@shikijs/langs/jsonc") },
+  yaml: { id: "yaml", load: () => import("@shikijs/langs/yaml") },
+  yml: { id: "yaml", load: () => import("@shikijs/langs/yaml") },
+  toml: { id: "toml", load: () => import("@shikijs/langs/toml") },
+  xml: { id: "xml", load: () => import("@shikijs/langs/xml") },
+  svg: { id: "xml", load: () => import("@shikijs/langs/xml") },
+  graphql: { id: "graphql", load: () => import("@shikijs/langs/graphql") },
+  sql: { id: "sql", load: () => import("@shikijs/langs/sql") },
+  // Docs
+  md: { id: "markdown", load: () => import("@shikijs/langs/markdown") },
+  mdx: { id: "mdx", load: () => import("@shikijs/langs/mdx") },
+  // Shell
+  sh: { id: "bash", load: () => import("@shikijs/langs/bash") },
+  bash: { id: "bash", load: () => import("@shikijs/langs/bash") },
+  zsh: { id: "bash", load: () => import("@shikijs/langs/bash") },
+  // Systems
+  go: { id: "go", load: () => import("@shikijs/langs/go") },
+  rs: { id: "rust", load: () => import("@shikijs/langs/rust") },
+  c: { id: "c", load: () => import("@shikijs/langs/c") },
+  h: { id: "c", load: () => import("@shikijs/langs/c") },
+  cpp: { id: "cpp", load: () => import("@shikijs/langs/cpp") },
+  hpp: { id: "cpp", load: () => import("@shikijs/langs/cpp") },
+  // Scripting
+  py: { id: "python", load: () => import("@shikijs/langs/python") },
+  rb: { id: "ruby", load: () => import("@shikijs/langs/ruby") },
+  lua: { id: "lua", load: () => import("@shikijs/langs/lua") },
+  // JVM / Mobile
+  java: { id: "java", load: () => import("@shikijs/langs/java") },
+  kt: { id: "kotlin", load: () => import("@shikijs/langs/kotlin") },
+  swift: { id: "swift", load: () => import("@shikijs/langs/swift") },
+  // Infra
+  nix: { id: "nix", load: () => import("@shikijs/langs/nix") },
+  // Filenames
+  Dockerfile: { id: "dockerfile", load: () => import("@shikijs/langs/dockerfile") },
+  Makefile: { id: "makefile", load: () => import("@shikijs/langs/makefile") },
+};
+
+function getLangEntry(path: string): LangEntry | undefined {
+  const filename = path.split("/").pop() ?? "";
+  const ext = filename.split(".").pop() ?? "";
+  return LANG_MAP[ext] ?? LANG_MAP[filename];
+}
+
 interface FileViewerProps {
   blob: GitBlob | null;
 }
@@ -34,17 +120,35 @@ export function FileViewer({ blob }: FileViewerProps) {
     }
     setHighlighted(null);
     let cancelled = false;
-    void import("highlight.js").then(({ default: hljs }) => {
+
+    void (async () => {
+      const highlighter = await getHighlighter();
+      const entry = getLangEntry(blob.path);
+
+      let lang = "text";
+      if (entry) {
+        try {
+          const langModule = await entry.load();
+          await highlighter.loadLanguage(langModule as Parameters<typeof highlighter.loadLanguage>[0]);
+          lang = entry.id;
+        } catch {
+          // Language not available — fall back to plain text
+        }
+      }
+
       if (cancelled) return;
-      const ext = blob.path.split(".").pop() ?? "";
-      const result = hljs.getLanguage(ext)
-        ? hljs.highlight(blob.text!, { language: ext })
-        : hljs.highlightAuto(blob.text!);
+
+      const html = highlighter.codeToHtml(blob.text!, {
+        lang,
+        themes: { light: "github-light", dark: "github-dark" },
+      });
+
       setHighlighted({
-        html: result.value,
+        html,
         lineCount: blob.text!.split("\n").length,
       });
-    });
+    })();
+
     return () => {
       cancelled = true;
     };
@@ -80,22 +184,10 @@ export function FileViewer({ blob }: FileViewerProps) {
           Binary file — {formatBytes(blob.size)}
         </div>
       ) : (
-        <div className="flex overflow-x-auto font-mono text-xs leading-5">
-          <div
-            className="border-border bg-muted/20 text-muted-foreground/50 border-r px-4 py-4 text-right select-none"
-            aria-hidden
-          >
-            {Array.from({ length: lineCount }, (_, i) => (
-              <div key={i}>{i + 1}</div>
-            ))}
-          </div>
-          <pre className="flex-1 overflow-visible px-4 py-4">
-            <code
-              className="hljs !bg-transparent !p-0"
-              dangerouslySetInnerHTML={{ __html: html }}
-            />
-          </pre>
-        </div>
+        <div
+          className="overflow-x-auto font-mono text-xs leading-5 [&_.shiki]:!bg-transparent [&_pre]:px-4 [&_pre]:py-4"
+          dangerouslySetInnerHTML={{ __html: html }}
+        />
       )}
     </div>
   );

webui2/src/components/shared/__snapshots__/pagination.test.tsx.snap 🔗

@@ -16,10 +16,10 @@ exports[`Pagination/Default matches snapshot 1`] = `
       <a
         aria-current="page"
         aria-disabled="true"
-        class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+        class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 pointer-events-none opacity-50 active"
         data-status="active"
-        disabled=""
         role="link"
+        tabindex="-1"
       >
         <svg
           aria-hidden="true"
@@ -113,10 +113,10 @@ exports[`Pagination/LastPage matches snapshot 1`] = `
     <a
       aria-current="page"
       aria-disabled="true"
-      class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+      class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 pointer-events-none opacity-50 active"
       data-status="active"
-      disabled=""
       role="link"
+      tabindex="-1"
     >
       Next
       <svg

webui2/src/components/shared/__snapshots__/query-input.test.tsx.snap 🔗

@@ -6,7 +6,7 @@ exports[`QueryInput/AsyncCompletions matches snapshot 1`] = `
     class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
   >
     <div
-      class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+      class="text-muted-foreground pointer-events-none absolute left-3 flex size-4 shrink-0 items-center justify-center [&>svg]:size-4"
     >
       <svg
         aria-hidden="true"
@@ -53,7 +53,7 @@ exports[`QueryInput/AutocompleteInteraction matches snapshot 1`] = `
     class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
   >
     <div
-      class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+      class="text-muted-foreground pointer-events-none absolute left-3 flex size-4 shrink-0 items-center justify-center [&>svg]:size-4"
     >
       <svg
         aria-hidden="true"
@@ -114,7 +114,7 @@ exports[`QueryInput/Default matches snapshot 1`] = `
     class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
   >
     <div
-      class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+      class="text-muted-foreground pointer-events-none absolute left-3 flex size-4 shrink-0 items-center justify-center [&>svg]:size-4"
     >
       <svg
         aria-hidden="true"
@@ -172,7 +172,7 @@ exports[`QueryInput/SyntaxOnly matches snapshot 1`] = `
     class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
   >
     <div
-      class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+      class="text-muted-foreground pointer-events-none absolute left-3 flex size-4 shrink-0 items-center justify-center [&>svg]:size-4"
     >
       <svg
         aria-hidden="true"
@@ -236,7 +236,7 @@ exports[`QueryInput/WithFilters matches snapshot 1`] = `
     class="relative flex flex-1 items-center rounded-md border border-input bg-background ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
   >
     <div
-      class="text-muted-foreground pointer-events-none absolute left-3 size-4 shrink-0"
+      class="text-muted-foreground pointer-events-none absolute left-3 flex size-4 shrink-0 items-center justify-center [&>svg]:size-4"
     >
       <svg
         aria-hidden="true"

webui2/src/index.css 🔗

@@ -1,6 +1,3 @@
-/* highlight.js theme must be imported before tailwind */
-@import "highlight.js/styles/github.css";
-
 @import "tailwindcss";
 @import "tw-animate-css";
 @plugin "@tailwindcss/typography";
@@ -114,31 +111,3 @@ body {
   color: var(--foreground);
 }
 
-/* ── Dark-mode overrides for highlight.js (imported above) ──────────────── */
-.dark .hljs {
-  background: hsl(220, 13%, 16%);
-  color: hsl(220, 10%, 85%);
-}
-.dark .hljs-keyword,
-.dark .hljs-selector-tag,
-.dark .hljs-built_in {
-  color: #ff7b72;
-}
-.dark .hljs-string,
-.dark .hljs-attr {
-  color: #a5d6ff;
-}
-.dark .hljs-comment {
-  color: hsl(220, 8%, 50%);
-}
-.dark .hljs-number,
-.dark .hljs-literal {
-  color: #79c0ff;
-}
-.dark .hljs-title,
-.dark .hljs-name {
-  color: #d2a8ff;
-}
-.dark .hljs-type {
-  color: #ffa657;
-}

webui2/vitest.config.ts 🔗

@@ -31,6 +31,8 @@ export default mergeConfig(
               instances: [{ browser: "chromium" }],
             },
             setupFiles: ["./.storybook/vitest.setup.ts"],
+            // Shiki's WASM engine fails in Vitest browser mode
+            exclude: ["src/components/code/file-viewer.stories.tsx"],
           },
         },
         // Snapshot tests (happy-dom, fast)