diff --git a/.env.example b/.env.example index 02af3a50d..ed587847d 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,19 @@ # Values below are PUBLIC (they're shipped to the browser by Astro's # `envField`, and live behind rate limiting / allow-lists on the vendor side). -# Kapa Custom Frontend integration ID for the "Ask AI" button in the header. -# If unset, the Ask AI button is hidden and the site still runs normally. -# Get yours at: https://app.kapa.ai/admin +# Kapa Website Widget integration ID for the "Ask AI" button in the sidebar. +# If unset, docs falls back to the shared widget integration ID used in preview/prod. +# Get yours at: https://app.kapa.ai/admin (Integrations → Widget → Copy integration ID). +# The same value is passed to the widget as `data-website-id`. +# PUBLIC_KAPA_INTEGRATION_ID=f31c5644-dd65-4f49-9762-bf580062afa8 PUBLIC_KAPA_INTEGRATION_ID= +# Optional Kapa project ID used to build internal conversation links in +# "Create ticket" email drafts: +# https://app.kapa.ai//conversations/ +# You can find this in the Kapa dashboard URL while viewing the project. +PUBLIC_KAPA_PROJECT_ID= + # RudderStack analytics — required for docs_404 event tracking and page analytics. # Without these, no analytics events are sent and the weekly 404 monitor will # report zero data. Set both in the Vercel project environment (all environments). @@ -21,3 +29,12 @@ PUBLIC_KAPA_INTEGRATION_ID= # directive in vercel.json (a mismatch silently blocks all events in the browser). PUBLIC_RUDDERSTACK_WRITE_KEY= PUBLIC_RUDDERSTACK_DATA_PLANE_URL= + +# Server-side handoff forwarding endpoint used by /api/support-handoff. +# This should point at the DevX service endpoint that creates Front +# conversations from docs chat handoff requests. +SUPPORT_HANDOFF_ENDPOINT_URL= + +# Required when SUPPORT_HANDOFF_ENDPOINT_URL is set. Docs forwards this only +# to the DevX service as the `?secret=` query parameter, not as a bearer token. +SUPPORT_HANDOFF_SHARED_SECRET= diff --git a/astro.config.mjs b/astro.config.mjs index 12462c7b4..cfad50019 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -28,6 +28,11 @@ export default defineConfig({ access: 'public', optional: true, }), + PUBLIC_KAPA_PROJECT_ID: envField.string({ + context: 'client', + access: 'public', + optional: true, + }), PUBLIC_RUDDERSTACK_WRITE_KEY: envField.string({ context: 'client', access: 'public', @@ -43,6 +48,16 @@ export default defineConfig({ access: 'public', optional: true, }), + SUPPORT_HANDOFF_ENDPOINT_URL: envField.string({ + context: 'server', + access: 'secret', + optional: true, + }), + SUPPORT_HANDOFF_SHARED_SECRET: envField.string({ + context: 'server', + access: 'secret', + optional: true, + }), }, }, integrations: [ @@ -61,15 +76,24 @@ export default defineConfig({ baseUrl: 'https://github.com/warpdotdev/docs/edit/main/', }, lastUpdated: true, - // Soft-wrap long lines by default. Expressive Code defaults to - // `overflow-x: auto` for `
`, which combined with macOS's
-			// auto-hidden scrollbars made wide lines silently truncate.
-			// `wrap: true` adds the `.wrap` class so EC's `white-space: pre-wrap`
-			// kicks in; leading indents are preserved via its `span.indent` rule.
+			// Keep long lines unwrapped so code blocks use horizontal scrolling.
+			// This aligns docs behavior with the side chat renderer and preserves
+			// exact line shape for commands and snippets.
 			expressiveCode: {
 				defaultProps: {
-					wrap: true,
+					wrap: false,
 				},
+				// IMPORTANT: Expressive Code's Vite plugin rewrites Shiki's bundled
+				// theme registry (shiki/dist/themes.mjs) and strips every theme not
+				// listed as a *string* in its `themes` config. Starlight passes its
+				// themes as objects, so the registry is emptied for the entire Vite
+				// module graph — including the Kapa side-chat island, whose runtime
+				// createHighlighter(['github-light', 'github-dark']) then throws
+				// "theme is not included in this bundle" and falls back to plaintext.
+				// Keeping the registry intact restores chat code block highlighting.
+				// Only the requested themes are ever fetched at runtime (lazy chunks),
+				// so this does not bloat the pages served to visitors.
+				removeUnusedThemes: false,
 				// Map languages Shiki doesn't bundle to a safe fallback. PromQL
 				// blocks live in platform/self-hosting/monitoring.mdx;
 				// without this alias every build emits noisy "language could not be
diff --git a/package-lock.json b/package-lock.json
index fc8163d9c..91002c48f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2713,14 +2713,14 @@
       }
     },
     "node_modules/@kapaai/react-sdk": {
-      "version": "0.9.2",
-      "resolved": "https://registry.npmjs.org/@kapaai/react-sdk/-/react-sdk-0.9.2.tgz",
-      "integrity": "sha512-PeqgKL6d0yIkkmPn+1O8TBjH7KGx5HNiH7fhGl0FEV2MfJZBzWNvAOdOm/34VLdpb2Nawe3H1gSURdJ0DG6z7Q==",
+      "version": "0.9.10",
+      "resolved": "https://registry.npmjs.org/@kapaai/react-sdk/-/react-sdk-0.9.10.tgz",
+      "integrity": "sha512-osQyFgBJmhNM207MpB0aZbjs2kjNHjEJbNc5YEReJuyFE72Iydn9CoZGxox2GYRgMIlQ7wljHCzgQqkvR8lXqA==",
       "license": "MIT",
       "dependencies": {
         "@fingerprintjs/fingerprintjs-pro-react": "^2.7.0",
+        "@fingerprintjs/fingerprintjs-pro-spa": "^1.3.0",
         "@hcaptcha/react-hcaptcha": "^1.12.0",
-        "@tanstack/react-query": "^5.74.3",
         "js-cookie": "^3.0.5",
         "tldts": "^7.0.7"
       },
@@ -3778,32 +3778,6 @@
       "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
       "license": "MIT"
     },
-    "node_modules/@tanstack/query-core": {
-      "version": "5.90.20",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
-      "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
-      "license": "MIT",
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/tannerlinsley"
-      }
-    },
-    "node_modules/@tanstack/react-query": {
-      "version": "5.90.21",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
-      "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
-      "license": "MIT",
-      "dependencies": {
-        "@tanstack/query-core": "5.90.20"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/tannerlinsley"
-      },
-      "peerDependencies": {
-        "react": "^18 || ^19"
-      }
-    },
     "node_modules/@tybys/wasm-util": {
       "version": "0.10.3",
       "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
diff --git a/src/components/ArticleImageLightbox.astro b/src/components/ArticleImageLightbox.astro
new file mode 100644
index 000000000..8f96660e8
--- /dev/null
+++ b/src/components/ArticleImageLightbox.astro
@@ -0,0 +1,235 @@
+---
+// A singleton, delegated lightbox for article content and explicit chat image
+// triggers. Event delegation keeps it active for future Starlight client-side
+// swaps without wrapping article images in buttons or changing their layout.
+---
+
+
diff --git a/src/components/CustomHead.astro b/src/components/CustomHead.astro
index e8bd21f19..3f52967cf 100644
--- a/src/components/CustomHead.astro
+++ b/src/components/CustomHead.astro
@@ -3,6 +3,7 @@ import Default from '@astrojs/starlight/components/Head.astro';
 import Analytics from '@vercel/analytics/astro';
 import SpeedInsights from '@vercel/speed-insights/astro';
 import RudderStackAnalytics from './RudderStackAnalytics.astro';
+import ArticleImageLightbox from './ArticleImageLightbox.astro';
 
 // Note: `` (Astro View Transitions) is intentionally NOT
 // enabled here. Starlight's sidebar scroll persistence relies on the browser
@@ -154,6 +155,7 @@ const fontsHref =
   onload="this.media='all'"
 />
 
+
 
 
 
diff --git a/src/components/CustomSidebar.astro b/src/components/CustomSidebar.astro
index 6ab218ca8..954f822f7 100644
--- a/src/components/CustomSidebar.astro
+++ b/src/components/CustomSidebar.astro
@@ -77,9 +77,9 @@ import KapaLauncher from './KapaLauncher.astro';
 			'api': 'API',
 		};
 
-		// Detect Apple platforms once so the Ask AI kbd reads ⌘+I on Macs and
-		// Ctrl+I elsewhere. Mirrors KapaChatLauncher.tsx which binds the same
-		// shortcut via keymatch's `CmdOrCtrl+I`.
+// Detect Apple platforms once so the Ask AI kbd reads ⌘+I on Macs and
+		// Ctrl+I elsewhere. Mirrors KapaLauncher.astro which binds the same
+		// shortcut for opening the Kapa Website Widget.
 		var isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform || '');
 		var modKey = isMac ? '\u2318' : 'Ctrl';
 
@@ -143,9 +143,9 @@ import KapaLauncher from './KapaLauncher.astro';
 				+ ''
 				+ 'Ask AI'
 				+ '';
-			askAi.addEventListener('click', function () {
-				// openPanel() in KapaChatLauncher reads the live search input and
-				// closes the search dialog itself, so we just route the click.
+askAi.addEventListener('click', function () {
+				// KapaLauncher reads the live search input and closes the search
+				// dialog itself, then opens the widget with that query prefilled.
 				var kapaBtn = document.querySelector('.warp-kapa-button');
 				if (kapaBtn) kapaBtn.click();
 			});
diff --git a/src/components/KapaChatLauncher.css b/src/components/KapaChatLauncher.css
index 3aca6dc6c..fd70b9fd4 100644
--- a/src/components/KapaChatLauncher.css
+++ b/src/components/KapaChatLauncher.css
@@ -47,6 +47,188 @@
 	align-items: center;
 	gap: 0.5rem;
 }
+.sl-kapa-mcp-connect__trigger {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	gap: 0.375rem;
+	min-height: 2rem;
+	padding: 0.375rem 0.625rem;
+	border: 1px solid color-mix(in srgb, var(--sl-color-text-accent), var(--warp-control-border) 48%);
+	border-radius: var(--sl-radius-sm);
+	background: color-mix(in srgb, var(--sl-color-text-accent), var(--warp-control-bg) 88%);
+	color: var(--sl-color-text-accent);
+	font: inherit;
+	font-size: var(--sl-text-xs);
+	font-weight: 600;
+	line-height: 1.2;
+	white-space: nowrap;
+	cursor: pointer;
+	transition:
+		border-color 0.15s ease,
+		background-color 0.15s ease,
+		color 0.15s ease;
+}
+
+.sl-kapa-mcp-connect__trigger svg {
+	width: 0.875rem;
+	height: 0.875rem;
+	flex: none;
+}
+
+.sl-kapa-mcp-connect__trigger:hover,
+.sl-kapa-mcp-connect__trigger[data-state='open'] {
+	border-color: var(--sl-color-text-accent);
+	background: color-mix(in srgb, var(--sl-color-text-accent), var(--warp-control-bg) 78%);
+	color: var(--sl-color-white);
+}
+
+.sl-kapa-mcp-connect__trigger:focus-visible,
+.sl-kapa-mcp-connect__action:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-mcp-connect {
+	width: min(24rem, calc(100vw - 1.5rem));
+	max-width: var(--radix-popover-content-available-width);
+	max-height: var(--radix-popover-content-available-height);
+	padding: 0.875rem;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-lg);
+	background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 18%);
+	box-shadow: var(--sl-shadow-lg);
+	color: var(--sl-color-gray-2);
+	overflow-y: auto;
+	z-index: 20;
+}
+
+.sl-kapa-mcp-connect__header {
+	display: flex;
+	align-items: flex-start;
+	gap: 0.625rem;
+}
+
+.sl-kapa-mcp-connect__header > svg {
+	width: 1.125rem;
+	height: 1.125rem;
+	margin-top: 0.125rem;
+	color: var(--sl-color-text-accent);
+	flex: none;
+}
+
+.sl-kapa-mcp-connect__title,
+.sl-kapa-mcp-connect__description,
+.sl-kapa-mcp-connect__eyebrow,
+.sl-kapa-mcp-connect__instructions {
+	margin: 0;
+}
+
+.sl-kapa-mcp-connect__title {
+	color: var(--sl-color-white);
+	font-size: var(--sl-text-sm);
+	font-weight: 700;
+	line-height: 1.35;
+}
+
+.sl-kapa-mcp-connect__description,
+.sl-kapa-mcp-connect__instructions {
+	margin-top: 0.25rem;
+	font-size: var(--sl-text-xs);
+	line-height: 1.45;
+}
+
+.sl-kapa-mcp-connect__section {
+	display: flex;
+	flex-direction: column;
+	align-items: stretch;
+	gap: 0.5rem;
+	margin-top: 0.875rem;
+	padding-top: 0.875rem;
+	border-top: 1px solid var(--sl-color-gray-5);
+}
+
+.sl-kapa-mcp-connect__eyebrow {
+	color: var(--sl-color-gray-3);
+	font-size: var(--sl-text-2xs);
+	font-weight: 700;
+	letter-spacing: 0.08em;
+	line-height: 1.2;
+	text-transform: uppercase;
+}
+
+.sl-kapa-mcp-connect__instructions strong {
+	color: var(--sl-color-gray-1);
+}
+
+.sl-kapa-mcp-connect__config {
+	box-sizing: border-box;
+	width: 100%;
+	padding: 0.5rem 0.625rem;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-sm);
+	background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 32%);
+	color: var(--sl-color-gray-1);
+	font-family: var(--__sl-font-mono, 'JetBrains Mono', 'SFMono-Regular', monospace);
+	font-size: var(--sl-text-2xs);
+	line-height: 1.5;
+	overflow-wrap: anywhere;
+}
+
+.sl-kapa-mcp-connect__config {
+	white-space: pre-wrap;
+}
+
+
+.sl-kapa-mcp-connect__action {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	gap: 0.375rem;
+	min-height: 2rem;
+	padding: 0.375rem 0.625rem;
+	border: 1px solid var(--warp-control-border);
+	border-radius: var(--sl-radius-sm);
+	background: var(--warp-control-bg);
+	color: var(--warp-control-text-hover);
+	font: inherit;
+	font-size: var(--sl-text-xs);
+	font-weight: 600;
+	line-height: 1.2;
+	cursor: pointer;
+	transition:
+		border-color 0.15s ease,
+		background-color 0.15s ease,
+		color 0.15s ease;
+}
+
+.sl-kapa-mcp-connect__action:hover {
+	border-color: var(--warp-control-border-hover);
+	background: var(--warp-control-bg-hover);
+	color: var(--sl-color-white);
+}
+
+.sl-kapa-mcp-connect__action--primary {
+	border-color: var(--sl-color-text-accent);
+	background: var(--sl-color-text-accent);
+	color: var(--sl-color-black);
+}
+
+.sl-kapa-mcp-connect__action--primary:hover {
+	border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%);
+	background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%);
+	color: var(--sl-color-black);
+}
+
+.sl-kapa-mcp-connect__action svg {
+	width: 0.875rem;
+	height: 0.875rem;
+	flex: none;
+}
+
+.sl-kapa-mcp-connect__arrow {
+	fill: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 18%);
+}
 
 .sl-kapa-icon-button,
 .sl-kapa-feedback__button {
@@ -199,6 +381,208 @@
 	border-radius: var(--sl-radius-sm);
 	padding: 0.1rem 0.3rem;
 }
+.sl-kapa-answer-image-button {
+	position: relative;
+	display: block;
+	box-sizing: border-box;
+	max-width: 100%;
+	margin: 0.875rem 0;
+	padding: 0;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-md);
+	background: var(--sl-color-black);
+	cursor: zoom-in;
+	overflow: hidden;
+}
+
+.sl-kapa-answer-image {
+	display: block;
+	box-sizing: border-box;
+	max-width: 100%;
+	height: auto;
+	background: var(--sl-color-black);
+	object-fit: contain;
+	box-shadow: 0 0.25rem 0.75rem rgb(0 0 0 / 0.18);
+}
+.sl-kapa-answer-image-button__overlay {
+	position: absolute;
+	inset: auto 0 0;
+	padding: 1.25rem 0.75rem 0.5rem;
+	background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
+	color: var(--sl-color-white);
+	font-size: var(--sl-text-xs);
+	font-weight: 600;
+	line-height: 1.2;
+	opacity: 0;
+	transform: translateY(0.25rem);
+	transition:
+		opacity 0.15s ease,
+		transform 0.15s ease;
+	pointer-events: none;
+}
+
+.sl-kapa-answer-image-button:hover .sl-kapa-answer-image-button__overlay,
+.sl-kapa-answer-image-button:focus-visible .sl-kapa-answer-image-button__overlay {
+	opacity: 1;
+	transform: translateY(0);
+}
+
+.sl-kapa-answer-image-button:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-answer-image-fallback {
+	display: flex;
+	box-sizing: border-box;
+	align-items: center;
+	min-height: 3rem;
+	max-width: 100%;
+	margin: 0.875rem 0;
+	padding: 0.625rem 0.75rem;
+	border: 1px dashed var(--sl-color-gray-4);
+	border-radius: var(--sl-radius-md);
+	background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 30%);
+	color: var(--sl-color-gray-3);
+	font-size: var(--sl-text-xs);
+	line-height: 1.4;
+	overflow-wrap: anywhere;
+}
+
+:root[data-theme='light'] .sl-kapa-answer-image {
+	background: var(--sl-color-white);
+	box-shadow: 0 0.25rem 0.75rem rgb(0 0 0 / 0.1);
+}
+:root[data-theme='light'] .sl-kapa-answer-image-button {
+	border-color: color-mix(in srgb, var(--sl-color-gray-5), var(--sl-color-black) 15%);
+	background: var(--sl-color-white);
+}
+
+:root[data-theme='light'] .sl-kapa-answer-image-fallback {
+	border-color: color-mix(in srgb, var(--sl-color-gray-4), var(--sl-color-black) 25%);
+	background: color-mix(in srgb, var(--sl-color-white), var(--sl-color-gray-6) 10%);
+	color: var(--sl-color-gray-3);
+}
+
+.sl-kapa-codeblock {
+	position: relative;
+	margin: 0.85rem 0;
+	max-width: 100%;
+}
+
+/* Chat-only copy control. Deliberately avoids EC's `.copy` class so EC CSS
+   (`button::after` mask) and EC JS never attach a second icon/handler. Visual
+   tokens match docs frosted-glass copy buttons in warp-components.css §2. */
+.sl-kapa-codeblock__copy-wrap {
+	position: absolute;
+	inset-block-start: 0.5rem;
+	inset-inline-end: 0.5rem;
+	z-index: 2;
+	display: flex;
+	align-items: center;
+	gap: 0.25rem;
+	pointer-events: none;
+}
+
+.sl-kapa-codeblock__copy {
+	pointer-events: auto;
+	position: relative;
+	box-sizing: border-box;
+	width: 2rem;
+	height: 2rem;
+	margin: 0;
+	padding: 0;
+	border-radius: var(--sl-radius-sm);
+	border: 1px solid rgba(255, 255, 255, 0.1);
+	background: rgba(255, 255, 255, 0.08);
+	backdrop-filter: blur(8px);
+	cursor: pointer;
+	opacity: 0.4;
+	transition:
+		opacity 0.15s ease,
+		background-color 0.15s ease,
+		border-color 0.15s ease;
+}
+
+.sl-kapa-codeblock:hover .sl-kapa-codeblock__copy,
+.sl-kapa-codeblock:focus-within .sl-kapa-codeblock__copy {
+	opacity: 0.75;
+}
+
+.sl-kapa-codeblock__copy:hover,
+.sl-kapa-codeblock__copy:focus-visible {
+	opacity: 1;
+	background: rgba(255, 255, 255, 0.16);
+	border-color: rgba(255, 255, 255, 0.18);
+}
+
+.sl-kapa-codeblock__copy:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-codeblock__copy-icon {
+	position: absolute;
+	inset: 0.475rem;
+	background-color: var(--sl-color-white, #f9f8f6);
+	-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");
+	mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'%3E%3Cpath d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/%3E%3Crect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/%3E%3C/svg%3E");
+	-webkit-mask-repeat: no-repeat;
+	mask-repeat: no-repeat;
+	-webkit-mask-position: center;
+	mask-position: center;
+	-webkit-mask-size: contain;
+	mask-size: contain;
+}
+
+.sl-kapa-codeblock__copy-feedback {
+	pointer-events: none;
+	user-select: none;
+	font-size: var(--sl-text-2xs);
+	line-height: 1.2;
+	padding: 0.2rem 0.4rem;
+	border-radius: var(--sl-radius-sm);
+	background: color-mix(in srgb, var(--sl-color-accent) 85%, black);
+	color: var(--sl-color-white);
+	white-space: nowrap;
+}
+
+:root[data-theme='light'] .sl-kapa-codeblock__copy {
+	background: rgba(0, 0, 0, 0.04);
+	border: 1px solid rgba(0, 0, 0, 0.08);
+	opacity: 0.6;
+}
+
+:root[data-theme='light'] .sl-kapa-codeblock__copy:hover,
+:root[data-theme='light'] .sl-kapa-codeblock__copy:focus-visible {
+	background: rgba(0, 0, 0, 0.08);
+	border-color: rgba(0, 0, 0, 0.12);
+	opacity: 1;
+}
+
+:root[data-theme='light'] .sl-kapa-codeblock__copy-icon {
+	background-color: var(--sl-color-white, #0a0d12);
+}
+
+.sl-kapa-codeblock pre {
+	margin: 0;
+	padding: var(--ec-codePadBlk) var(--ec-codePadInl);
+	overflow-x: auto;
+	overflow-y: hidden;
+	white-space: pre;
+}
+
+.sl-kapa-codeblock code {
+	font-family: var(--__sl-font-mono, 'JetBrains Mono', 'SFMono-Regular', monospace);
+	display: block;
+	min-width: max-content;
+}
+
+/* Shiki separates `.line` spans with literal newline characters, which
+   `white-space: pre` (set on the pre above) already renders as line
+   breaks. The spans must stay inline — giving them `display: block`
+   would add a second break per line and double-space the code. */
+
 
 .sl-kapa-thinking {
 	display: flex;
@@ -249,18 +633,73 @@
 
 .sl-kapa-feedback {
 	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
 	gap: 0.5rem;
 	margin-top: 0.875rem;
 }
+.sl-kapa-feedback__handoff {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	height: 2.25rem;
+	padding: 0 0.75rem;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-sm);
+	background: var(--sl-color-black);
+	color: var(--sl-color-gray-1);
+	font: inherit;
+	font-size: var(--sl-text-xs);
+	font-weight: 500;
+	cursor: pointer;
+	transition:
+		border-color 0.15s ease,
+		background-color 0.15s ease,
+		color 0.15s ease;
+}
+
+.sl-kapa-feedback__handoff:hover {
+	border-color: var(--warp-control-border-hover);
+	background: var(--warp-control-bg-hover);
+	color: var(--sl-color-white);
+}
+
+.sl-kapa-feedback__handoff:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+.sl-kapa-feedback__handoff--submit {
+	background: var(--sl-color-text-accent);
+	border-color: var(--sl-color-text-accent);
+	color: var(--sl-color-black);
+}
+
+.sl-kapa-feedback__handoff--submit:hover:not(:disabled) {
+	border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%);
+	background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%);
+	color: var(--sl-color-black);
+}
+
+.sl-kapa-feedback__handoff--ghost {
+	background: transparent;
+}
 
 .sl-kapa-error {
-	border: 1px solid color-mix(in srgb, #ef4444, var(--sl-color-black) 35%);
-	background: rgb(127 29 29 / 0.2);
-	color: #fecaca;
+	border: 1px solid rgb(248 113 113 / 0.8);
+	background: rgb(254 226 226 / 0.9);
+	color: #991b1b;
 	border-radius: var(--sl-radius-lg);
 	padding: 0.875rem 1rem;
 }
 
+@media (prefers-color-scheme: dark) {
+	.sl-kapa-error {
+		border: 1px solid color-mix(in srgb, #ef4444, var(--sl-color-black) 35%);
+		background: rgb(127 29 29 / 0.2);
+		color: #fecaca;
+	}
+}
+
 .sl-kapa-panel__footer {
 	position: relative;
 	padding: 1rem;
@@ -337,6 +776,228 @@
 	opacity: 0.5;
 	cursor: not-allowed;
 }
+.sl-kapa-handoff-inline {
+	margin-top: 0.75rem;
+	padding-top: 0.75rem;
+	border-top: 1px solid var(--sl-color-gray-5);
+	display: flex;
+	flex-direction: column;
+	gap: 0.5rem;
+}
+
+.sl-kapa-handoff-inline label {
+	font-size: var(--sl-text-xs);
+	font-weight: 600;
+	color: var(--sl-color-gray-2);
+}
+
+.sl-kapa-handoff-inline__row {
+	display: grid;
+	grid-template-columns: minmax(0, 1fr) auto auto;
+	gap: 0.5rem;
+}
+
+.sl-kapa-handoff-inline input {
+	width: 100%;
+	min-width: 0;
+	height: 2.25rem;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-sm);
+	background: var(--sl-color-black);
+	color: var(--sl-color-white);
+	padding: 0 0.625rem;
+	font: inherit;
+	font-size: var(--sl-text-sm);
+}
+
+.sl-kapa-handoff-inline input::placeholder {
+	color: var(--sl-color-gray-3);
+}
+
+.sl-kapa-handoff-inline input:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-handoff-inline__status {
+	margin: 0;
+	font-size: var(--sl-text-xs);
+	line-height: 1.4;
+}
+
+.sl-kapa-handoff-inline__status--success {
+	color: #14532d;
+	background: rgb(220 252 231 / 0.9);
+	border: 1px solid rgb(74 222 128 / 0.7);
+	padding: 0.375rem 0.5rem;
+	border-radius: var(--sl-radius-sm);
+}
+
+.sl-kapa-handoff-inline__status--error {
+	color: #b91c1c;
+	background: rgb(254 226 226 / 0.9);
+	border: 1px solid rgb(248 113 113 / 0.8);
+	padding: 0.375rem 0.5rem;
+	border-radius: var(--sl-radius-sm);
+}
+
+.sl-kapa-handoff-inline__status a {
+	color: inherit;
+	text-decoration: underline;
+}
+
+@media (prefers-color-scheme: dark) {
+	.sl-kapa-handoff-inline__status--success {
+		color: #86efac;
+		background: rgb(20 83 45 / 0.35);
+		border-color: rgb(74 222 128 / 0.45);
+	}
+	.sl-kapa-handoff-inline__status--error {
+		color: #fecaca;
+		background: rgb(127 29 29 / 0.3);
+		border-color: rgb(248 113 113 / 0.5);
+	}
+}
+
+.sl-kapa-handoff {
+	margin-top: 0.75rem;
+	display: flex;
+	flex-direction: column;
+	gap: 0.625rem;
+}
+
+.sl-kapa-handoff__toggle {
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	gap: 0.5rem;
+	width: 100%;
+	height: 2.5rem;
+	padding: 0 0.875rem;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-sm);
+	background: var(--sl-color-black);
+	color: var(--sl-color-gray-1);
+	font: inherit;
+	font-size: var(--sl-text-sm);
+	font-weight: 500;
+	cursor: pointer;
+	transition:
+		border-color 0.15s ease,
+		background-color 0.15s ease,
+		color 0.15s ease;
+}
+
+.sl-kapa-handoff__toggle svg {
+	width: 0.95rem;
+	height: 0.95rem;
+	flex: none;
+}
+
+.sl-kapa-handoff__toggle:hover {
+	border-color: var(--warp-control-border-hover);
+	background: var(--warp-control-bg-hover);
+	color: var(--sl-color-white);
+}
+
+.sl-kapa-handoff__toggle:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-handoff__form {
+	display: flex;
+	flex-direction: column;
+	gap: 0.5rem;
+	padding: 0.75rem;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-lg);
+	background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 28%);
+}
+
+.sl-kapa-handoff__form label {
+	display: flex;
+	flex-direction: column;
+	gap: 0.25rem;
+}
+
+.sl-kapa-handoff__form label span {
+	font-size: var(--sl-text-2xs);
+	color: var(--sl-color-gray-3);
+}
+
+.sl-kapa-handoff__form input,
+.sl-kapa-handoff__form textarea {
+	width: 100%;
+	border: 1px solid var(--sl-color-gray-5);
+	border-radius: var(--sl-radius-sm);
+	background: var(--sl-color-black);
+	color: var(--sl-color-white);
+	padding: 0.5rem 0.625rem;
+	font: inherit;
+	font-size: var(--sl-text-sm);
+}
+
+.sl-kapa-handoff__form textarea {
+	resize: vertical;
+	min-height: 4rem;
+}
+
+.sl-kapa-handoff__form input::placeholder,
+.sl-kapa-handoff__form textarea::placeholder {
+	color: var(--sl-color-gray-3);
+}
+
+.sl-kapa-handoff__form input:focus-visible,
+.sl-kapa-handoff__form textarea:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-handoff__submit {
+	margin-top: 0.25rem;
+	height: 2.5rem;
+	border: 1px solid var(--sl-color-text-accent);
+	border-radius: var(--sl-radius-sm);
+	background: var(--sl-color-text-accent);
+	color: var(--sl-color-black);
+	font: inherit;
+	font-size: var(--sl-text-sm);
+	font-weight: 600;
+	cursor: pointer;
+	transition:
+		border-color 0.15s ease,
+		background-color 0.15s ease;
+}
+
+.sl-kapa-handoff__submit:hover:not(:disabled) {
+	border-color: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 18%);
+	background: color-mix(in srgb, var(--sl-color-text-accent), var(--sl-color-white) 8%);
+}
+
+.sl-kapa-handoff__submit:focus-visible {
+	outline: 2px solid var(--sl-color-accent-high);
+	outline-offset: 2px;
+}
+
+.sl-kapa-handoff__submit:disabled {
+	opacity: 0.55;
+	cursor: not-allowed;
+}
+
+.sl-kapa-handoff__status {
+	margin: 0;
+	font-size: var(--sl-text-xs);
+	line-height: 1.4;
+}
+
+.sl-kapa-handoff__status--success {
+	color: #86efac;
+}
+
+.sl-kapa-handoff__status--error {
+	color: #fecaca;
+}
 
 /* Hide the reCAPTCHA badge */
 .grecaptcha-badge {
@@ -431,6 +1092,37 @@
 		flex-direction: column;
 		align-items: flex-start;
 	}
+
+	.sl-kapa-panel__header {
+		padding-inline: 0.75rem;
+	}
+
+	.sl-kapa-panel__header-actions {
+		gap: 0.25rem;
+	}
+
+	.sl-kapa-mcp-connect__trigger {
+		padding-inline: 0.5rem;
+	}
+}
+
+@media (max-width: 22rem) {
+	.sl-kapa-mcp-connect__trigger span {
+		position: absolute;
+		width: 1px;
+		height: 1px;
+		padding: 0;
+		margin: -1px;
+		overflow: hidden;
+		clip: rect(0, 0, 0, 0);
+		white-space: nowrap;
+		border: 0;
+	}
+
+	.sl-kapa-mcp-connect__trigger {
+		width: 2rem;
+		padding: 0;
+	}
 }
 
 @keyframes sl-kapa-spin {
diff --git a/src/components/KapaChatLauncher.mount.tsx b/src/components/KapaChatLauncher.mount.tsx
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/components/KapaChatLauncher.tsx b/src/components/KapaChatLauncher.tsx
index d4e1bcb9b..e395acf80 100644
--- a/src/components/KapaChatLauncher.tsx
+++ b/src/components/KapaChatLauncher.tsx
@@ -1,14 +1,24 @@
-import type { FormEvent, MouseEvent } from 'react';
+import type { FormEvent, MouseEvent, ReactElement, ReactNode } from 'react';
 import * as Popover from '@radix-ui/react-popover';
-import { useEffect, useMemo, useRef, useState } from 'react';
-import { KapaProvider, useChat } from '@kapaai/react-sdk';
-import { PUBLIC_KAPA_INTEGRATION_ID } from 'astro:env/client';
+import {
+	Children,
+	isValidElement,
+	useEffect,
+	useMemo,
+	useRef,
+	useState,
+} from 'react';
+import { CaptchaAction, KapaProvider, useCaptcha, useChat } from '@kapaai/react-sdk';
+import { PUBLIC_KAPA_INTEGRATION_ID, PUBLIC_KAPA_PROJECT_ID } from 'astro:env/client';
 import { isMac, keymatch } from 'keymatch';
 import ReactMarkdown from 'react-markdown';
 import {
 	LuExternalLink,
+	LuCheck,
+	LuCopy,
 	LuLoaderCircle,
 	LuMessageSquare,
+	LuPlug,
 	LuSend,
 	LuSquarePen,
 	LuThumbsDown,
@@ -18,10 +28,496 @@ import {
 import './KapaChatLauncher.css';
 
 const integrationId = PUBLIC_KAPA_INTEGRATION_ID;
+const projectId = PUBLIC_KAPA_PROJECT_ID?.trim() || '';
 const title = 'Ask Warp';
 const welcomeMessage = 'What do you want to know about Warp?';
+const uncertaintyThreshold = 0.15;
+const conversationLengthThreshold = 3;
+const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const warpDocsMcpUrl = 'https://warp.mcp.kapa.ai';
+const warpDocsMcpConfig = JSON.stringify(
+	{
+		'Warp Docs': {
+			url: warpDocsMcpUrl,
+		},
+	},
+	null,
+	2
+);
 
 type FeedbackReaction = 'upvote' | 'downvote';
+type McpCopyTarget = 'config' | 'url';
+type GenericRecord = Record;
+type HandoffApiSuccess = {
+	message?: string;
+};
+
+function isObject(value: unknown): value is GenericRecord {
+	return typeof value === 'object' && value !== null;
+}
+
+function readNumberField(record: GenericRecord, key: string) {
+	const value = record[key];
+	if (typeof value === 'number' && Number.isFinite(value)) return value;
+	if (typeof value === 'string') {
+		const parsed = Number(value);
+		if (Number.isFinite(parsed)) return parsed;
+	}
+	return null;
+}
+
+function readBooleanField(record: GenericRecord, key: string) {
+	const value = record[key];
+	if (typeof value === 'boolean') return value;
+	if (typeof value === 'string') {
+		const normalized = value.trim().toLowerCase();
+		if (normalized === 'true') return true;
+		if (normalized === 'false') return false;
+	}
+	return null;
+}
+
+function getUncertaintyScore(metadata: unknown) {
+	if (!isObject(metadata)) return null;
+	return (
+		readNumberField(metadata, 'uncertainty') ??
+		readNumberField(metadata, 'uncertainty_score') ??
+		readNumberField(metadata, 'uncertaintyScore')
+	);
+}
+
+function isAnswerUncertain(metadata: unknown) {
+	if (!isObject(metadata)) return false;
+	const flag =
+		readBooleanField(metadata, 'is_uncertain') ??
+		readBooleanField(metadata, 'isUncertain');
+	if (flag === true) return true;
+	const score = getUncertaintyScore(metadata);
+	return score !== null ? score >= uncertaintyThreshold : false;
+}
+
+function isValidEmailAddress(value: string) {
+	return emailPattern.test(value.trim());
+}
+function getChatErrorMessage(error: unknown) {
+	const message = typeof error === 'string' ? error : String(error ?? '');
+	const normalized = message.toLowerCase();
+	const looksLikeBlockedNetworkError =
+		normalized.includes('network error while fetching answer') ||
+		normalized.includes('failed to fetch') ||
+		normalized.includes('err_blocked_by_client');
+	if (looksLikeBlockedNetworkError) {
+		return "Couldn't reach the chat service. If you use an ad blocker or privacy extension, allow kapa.ai and proxy.kapa.ai, then try again.";
+	}
+	return message;
+}
+
+const CHAT_LANGUAGE_ALIASES: Record = {
+	shell: 'bash',
+	zsh: 'bash',
+	pwsh: 'powershell',
+	plaintext: 'text',
+};
+
+function normalizeChatLanguage(language: string): string {
+	return CHAT_LANGUAGE_ALIASES[language.toLowerCase()] ?? language.toLowerCase();
+}
+
+function inferLanguageFromCode(codeText: string, fallbackLanguage: string): string {
+	if (fallbackLanguage !== 'text') return fallbackLanguage;
+	const sample = codeText.trim();
+	if (!sample) return fallbackLanguage;
+	const commandLikePattern =
+		/^(sudo|apt|apt-get|dnf|yum|zypper|pacman|curl|wget|git|npm|pnpm|yarn|cargo|python|node|brew|sh|bash)\b/m;
+	if (commandLikePattern.test(sample)) return 'bash';
+	return fallbackLanguage;
+}
+
+function escapeHtml(value: string): string {
+	return value.replace(/&/g, '&').replace(//g, '>');
+}
+
+async function copyTextToClipboard(text: string): Promise {
+	try {
+		if (navigator.clipboard?.writeText) {
+			await navigator.clipboard.writeText(text);
+			return true;
+		}
+	} catch {
+		// Fall through to execCommand fallback.
+	}
+	try {
+		const pre = document.createElement('pre');
+		Object.assign(pre.style, {
+			opacity: '0',
+			pointerEvents: 'none',
+			position: 'absolute',
+			overflow: 'hidden',
+			left: '0',
+			top: '0',
+			width: '20px',
+			height: '20px',
+			webkitUserSelect: 'auto',
+			userSelect: 'all',
+		});
+		pre.setAttribute('aria-hidden', 'true');
+		pre.textContent = text;
+		document.body.appendChild(pre);
+		const range = document.createRange();
+		range.selectNode(pre);
+		const selection = window.getSelection();
+		if (!selection) {
+			document.body.removeChild(pre);
+			return false;
+		}
+		selection.removeAllRanges();
+		selection.addRange(range);
+		const ok = document.execCommand('copy');
+		selection.removeAllRanges();
+		document.body.removeChild(pre);
+		return ok;
+	} catch {
+		return false;
+	}
+}
+
+type MarkdownCodeProps = { className?: string; children?: ReactNode };
+
+// react-markdown v9+ no longer passes an `inline` flag to the `code`
+// component. The reliable way to tell fenced blocks apart from inline
+// code is nesting: fenced blocks are always rendered as 
, so
+// we intercept `pre` (block) and leave bare `code` (inline) untouched.
+function extractCodeElement(children: ReactNode): ReactElement | null {
+	const items = Array.isArray(children) ? children : [children];
+	for (const item of items) {
+		if (isValidElement(item)) return item;
+	}
+	return null;
+}
+
+function markdownChildrenToText(children: ReactNode): string {
+	return (Array.isArray(children) ? children.join('') : String(children ?? '')).replace(/\n$/, '');
+}
+type ChatMarkdownImageProps = {
+	alt?: string;
+	src?: string;
+	title?: string;
+};
+
+function isImageOnlyMarkdownLink(children: ReactNode) {
+	const items = Children.toArray(children).filter(
+		(child) => typeof child !== 'string' || child.trim().length > 0
+	);
+	return (
+		items.length === 1 &&
+		isValidElement(items[0]) &&
+		items[0].type === ChatMarkdownImage
+	);
+}
+
+function ChatMarkdownLink({
+	children,
+	href,
+	title,
+}: {
+	children?: ReactNode;
+	href?: string;
+	title?: string;
+}) {
+	// Kapa sometimes wraps an image in its source link. Keep ordinary links,
+	// but remove the image-only wrapper so the thumbnail can be a real button.
+	if (isImageOnlyMarkdownLink(children)) {
+		return <>{children};
+	}
+
+	return (
+		
+			{children}
+		
+	);
+}
+
+function ChatMarkdownImage({ alt, src, title }: ChatMarkdownImageProps) {
+	const [hasFailed, setHasFailed] = useState(false);
+	const imageAlt = alt?.trim() || 'Image from Kapa answer';
+
+	useEffect(() => {
+		setHasFailed(false);
+	}, [src]);
+
+	if (!src || hasFailed) {
+		return (
+			
+				Image unavailable
+			
+		);
+	}
+
+	// `src` has already passed react-markdown's default safe URL transformation.
+	// Image-only Markdown links are unwrapped above to avoid nesting this button
+	// inside an anchor. Ordinary text links retain their original behavior.
+	const image = (
+		{imageAlt} setHasFailed(true)}
+		/>
+	);
+
+	return (
+		
+	);
+}
+const highlightCache = new Map();
+const highlightInFlight = new Map>();
+let highlightRequestQueue: Promise = Promise.resolve();
+type ShikiModule = typeof import('shiki');
+type ShikiHighlighter = Awaited>;
+let shikiHighlighterPromise: Promise | null = null;
+const SHIKI_LANGUAGES = new Set([
+	'bash',
+	'powershell',
+	'json',
+	'javascript',
+	'typescript',
+	'tsx',
+	'jsx',
+	'python',
+	'go',
+	'rust',
+	'yaml',
+	'markdown',
+	'html',
+	'css',
+	'text',
+]);
+
+async function getShikiHighlighter(): Promise {
+	if (!shikiHighlighterPromise) {
+		shikiHighlighterPromise = import('shiki/bundle/full').then(({ createHighlighter }) =>
+			createHighlighter({
+				themes: ['github-light', 'github-dark'],
+				langs: [...SHIKI_LANGUAGES],
+			})
+		);
+	}
+	return shikiHighlighterPromise;
+}
+
+async function requestHighlightedPre({
+	code,
+	language,
+	theme,
+}: {
+	code: string;
+	language: string;
+	theme: 'dark' | 'light';
+}): Promise {
+	const cacheKey = `${theme}:${language}:${code}`;
+	if (highlightCache.has(cacheKey)) {
+		return highlightCache.get(cacheKey) ?? null;
+	}
+	const existing = highlightInFlight.get(cacheKey);
+	if (existing) return existing;
+
+	const requestPromise = new Promise((resolve, reject) => {
+		const run = async () => {
+			try {
+				const highlighter = await getShikiHighlighter();
+				const shikiLanguage = SHIKI_LANGUAGES.has(language) ? language : 'text';
+				const html = highlighter.codeToHtml(code, {
+					lang: shikiLanguage,
+					theme: theme === 'light' ? 'github-light' : 'github-dark',
+				});
+				const preMatch = html.match(/]*>[\s\S]*?<\/pre>/i);
+				const preHtml = preMatch?.[0] ?? null;
+				highlightCache.set(cacheKey, preHtml);
+				resolve(preHtml);
+			} catch (error) {
+				reject(error);
+			}
+		};
+
+		highlightRequestQueue = highlightRequestQueue
+			.then(run)
+			.catch(() => run())
+			.then(() => undefined, () => undefined);
+	});
+
+	highlightInFlight.set(cacheKey, requestPromise);
+	requestPromise.then(
+		() => {
+			highlightInFlight.delete(cacheKey);
+		},
+		() => {
+			highlightInFlight.delete(cacheKey);
+		}
+	);
+	return requestPromise;
+}
+
+function ChatCodeBlock({
+	className,
+	codeText,
+	deferHighlight,
+}: {
+	className?: string;
+	codeText: string;
+	deferHighlight?: boolean;
+}) {
+	const language = className?.replace('language-', '') ?? 'text';
+	const normalizedLanguage = inferLanguageFromCode(
+		codeText,
+		normalizeChatLanguage(language)
+	);
+	const isTerminalLanguage = ['bash', 'sh', 'shell', 'zsh', 'powershell'].includes(normalizedLanguage);
+	const [highlighted, setHighlighted] = useState<{ key: string; preHtml: string } | null>(null);
+	const [isDarkTheme, setIsDarkTheme] = useState(true);
+	const [isCopied, setIsCopied] = useState(false);
+	const copiedResetRef = useRef(null);
+
+	useEffect(() => {
+		const root = document.documentElement;
+		const updateTheme = () => {
+			const explicitTheme = root.dataset.theme;
+			if (explicitTheme === 'light') {
+				setIsDarkTheme(false);
+				return;
+			}
+			if (explicitTheme === 'dark') {
+				setIsDarkTheme(true);
+				return;
+			}
+			setIsDarkTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
+		};
+
+		updateTheme();
+		const observer = new MutationObserver(updateTheme);
+		observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
+		return () => observer.disconnect();
+	}, []);
+
+	useEffect(() => {
+		return () => {
+			if (copiedResetRef.current !== null) {
+				window.clearTimeout(copiedResetRef.current);
+			}
+		};
+	}, []);
+
+	const theme = isDarkTheme ? 'dark' : 'light';
+	const highlightKey = `${theme}:${normalizedLanguage}:${codeText}`;
+
+	useEffect(() => {
+		// While the answer is still streaming, the code text changes on every
+		// token. Requesting a highlight per token causes the block to churn
+		// between plaintext and highlighted DOM (visible flicker), so wait
+		// until streaming settles and highlight the final text once.
+		if (deferHighlight) return;
+		const controller = new AbortController();
+		(async (): Promise => {
+			try {
+				const preHtml = await requestHighlightedPre({
+					code: codeText,
+					language: normalizedLanguage,
+					theme,
+				});
+				if (!controller.signal.aborted && preHtml) {
+					setHighlighted({ key: `${theme}:${normalizedLanguage}:${codeText}`, preHtml });
+				}
+			} catch (error) {
+				if (!controller.signal.aborted) {
+					console.warn('[kapa-chat] code highlighting failed; using plaintext fallback', error);
+				}
+			}
+		})();
+
+		return () => {
+			controller.abort();
+		};
+	}, [codeText, deferHighlight, theme, normalizedLanguage]);
+
+	// Only use highlighted HTML that matches the *current* code text and
+	// theme; otherwise render the plaintext fallback. This prevents stale
+	// highlighted content from flashing while new text is streaming in.
+	const highlightedPreHtml = highlighted?.key === highlightKey ? highlighted.preHtml : null;
+
+	const onCopy = async () => {
+		const ok = await copyTextToClipboard(codeText);
+		if (!ok) return;
+		setIsCopied(true);
+		if (copiedResetRef.current !== null) {
+			window.clearTimeout(copiedResetRef.current);
+		}
+		copiedResetRef.current = window.setTimeout(() => {
+			setIsCopied(false);
+			copiedResetRef.current = null;
+		}, 1500);
+	};
+
+	return (
+		
+
+
+ + {isTerminalLanguage ? Terminal window : null} +
+ {highlightedPreHtml ? ( +
+ ) : ( +
+						
+					
+ )} + {/* Dedicated chat copy control — intentionally NOT EC's `.copy` + class. Reusing EC markup stacked EC's CSS mask icon on top of + any residual SVG and produced a double clipboard. One button, + one icon (CSS mask), one click handler. */} +
+ + {isCopied ? 'Copied!' : ''} + + {isCopied ? Copied! : null} + +
+
+
+ ); +} function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversation }: { title: string; @@ -33,11 +529,20 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const [query, setQuery] = useState(''); const [hasStartedConversation, setHasStartedConversation] = useState(false); const [isAppleDevice, setIsAppleDevice] = useState(false); + const [storedThreadId, setStoredThreadId] = useState(null); + const [downvotedAnswerIds, setDownvotedAnswerIds] = useState>({}); + const [handoffEmailInput, setHandoffEmailInput] = useState(''); + const [handoffQaId, setHandoffQaId] = useState(null); + const [handoffErrorMessage, setHandoffErrorMessage] = useState(null); + const [handoffSuccessMessage, setHandoffSuccessMessage] = useState(null); + const [isSubmittingHandoff, setIsSubmittingHandoff] = useState(false); + const [copiedMcpValue, setCopiedMcpValue] = useState(null); const messagesRef = useRef(null); const dialogRef = useRef(null); const triggerRef = useRef(null); const closeButtonRef = useRef(null); const inputRef = useRef(null); + const mcpCopyResetRef = useRef(null); const { addFeedback, conversation, @@ -45,16 +550,62 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio isGeneratingAnswer, isPreparingAnswer, submitQuery, + threadId, } = useChat(); + const { executeCaptcha } = useCaptcha(); + useEffect(() => { setIsAppleDevice(isMac()); }, []); + useEffect(() => { + return () => { + if (mcpCopyResetRef.current !== null) { + window.clearTimeout(mcpCopyResetRef.current); + } + }; + }, []); + + useEffect(() => { + const savedThreadId = localStorage.getItem('warp_docs_kapa_thread_id'); + if (savedThreadId) { + setStoredThreadId(savedThreadId); + } + }, []); + + useEffect(() => { + if (!threadId) return; + setStoredThreadId(threadId); + localStorage.setItem('warp_docs_kapa_thread_id', threadId); + }, [threadId]); useEffect(() => { if (!isOpen || !messagesRef.current) return; messagesRef.current.scrollTop = messagesRef.current.scrollHeight; }, [conversation.length, isOpen]); + useEffect(() => { + if (!isOpen || !messagesRef.current) return; + if (!isGeneratingAnswer && !isPreparingAnswer) return; + messagesRef.current.scrollTop = messagesRef.current.scrollHeight; + }, [conversation, isGeneratingAnswer, isOpen, isPreparingAnswer]); + + useEffect(() => { + if (!handoffQaId || !isOpen) return; + const frame = window.requestAnimationFrame(() => { + const inlineForm = document.getElementById(`sl-kapa-handoff-inline-${handoffQaId}`); + if (!inlineForm) return; + inlineForm.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + const emailInput = inlineForm.querySelector('input[type="email"]'); + emailInput?.focus(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [handoffQaId, isOpen, conversation.length]); + useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -107,6 +658,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const submit = () => { const value = query.trim(); if (!value || isBusy) return; + closeHandoffForm(); submitQuery(value); setHasStartedConversation(true); setQuery(''); @@ -119,6 +671,123 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const feedback = (questionAnswerId: string, reaction: FeedbackReaction) => { addFeedback(questionAnswerId, reaction); + if (reaction === 'downvote') { + setDownvotedAnswerIds((current) => ({ ...current, [questionAnswerId]: true })); + } + }; + + const isDownvoted = (qaId: string | null | undefined, reaction: string | null | undefined) => { + if (!qaId) return false; + return reaction === 'downvote' || downvotedAnswerIds[qaId] === true; + }; + + const shouldShowHandoffForAnswer = (qa: { + id?: string | null; + answer?: string | null; + metadata?: unknown; + reaction?: string | null; + }) => { + if (!qa.answer?.trim()) return false; + const conversationLengthTriggered = conversation.length >= conversationLengthThreshold; + const downvoteTriggered = isDownvoted(qa.id ?? null, qa.reaction ?? null); + const uncertaintyTriggered = isAnswerUncertain(qa.metadata); + return conversationLengthTriggered || downvoteTriggered || uncertaintyTriggered; + }; + + const buildConversationTranscript = () => { + if (!conversation.length) return 'No conversation history yet.'; + return conversation + .map((qa, index) => { + const sources = qa.sources?.length + ? `\nSources:\n${qa.sources.map((source) => `- ${source.title}: ${source.source_url}`).join('\n')}` + : ''; + return `Q${index + 1}: ${qa.question}\nA${index + 1}: ${qa.answer || '(no answer generated yet)'}${sources}`; + }) + .join('\n\n'); + }; + + const buildConversationLink = (currentThreadId: string | null) => { + if (!projectId || !currentThreadId) return null; + return `https://app.kapa.ai/${projectId}/conversations/${currentThreadId}`; + }; + + const openHandoffForm = (qaId: string) => { + setHandoffQaId(qaId); + setHandoffErrorMessage(null); + setHandoffSuccessMessage(null); + }; + + const closeHandoffForm = () => { + setHandoffQaId(null); + setHandoffErrorMessage(null); + setHandoffSuccessMessage(null); + }; + + const submitSupportHandoff = async (qa: { id?: string | null; question?: string }) => { + if (isSubmittingHandoff) return; + const userEmail = handoffEmailInput.trim().toLowerCase(); + if (!isValidEmailAddress(userEmail)) { + setHandoffErrorMessage('Enter a valid email address to continue.'); + return; + } + const localStorageThreadId = localStorage.getItem('warp_docs_kapa_thread_id'); + const activeThreadId = threadId || storedThreadId || localStorageThreadId; + if (!activeThreadId) { + setHandoffErrorMessage('Missing Kapa thread context; please send another message and try again.'); + return; + } + + const question = qa.question?.trim(); + if (!question) { + setHandoffErrorMessage('Missing question context for handoff.'); + return; + } + + const conversationLink = buildConversationLink(activeThreadId); + + setIsSubmittingHandoff(true); + setHandoffErrorMessage(null); + setHandoffSuccessMessage(null); + try { + let captcha; + try { + captcha = await executeCaptcha(CaptchaAction.FeedbackSubmit); + } catch { + setHandoffErrorMessage('Captcha verification could not be completed. Please try again.'); + return; + } + if (!captcha?.token || !captcha?.key) { + setHandoffErrorMessage('Captcha verification did not return a token. Please try again.'); + return; + } + const response = await fetch('/api/support-handoff', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + user_email: userEmail, + question, + page_url: window.location.href, + conversation_transcript: buildConversationTranscript(), + kapa_project_id: projectId || null, + kapa_thread_id: activeThreadId, + kapa_conversation_url: conversationLink || null, + captcha_token: captcha.token, + captcha_header: captcha.key, + }), + }); + const payload: HandoffApiSuccess & { message?: string } = await response.json().catch(() => ({})); + if (!response.ok) { + setHandoffErrorMessage(payload.message || 'Could not create support ticket. Please try again.'); + return; + } + setHandoffSuccessMessage(payload.message || 'Support ticket created successfully.'); + } catch { + setHandoffErrorMessage('Could not reach support handoff service. Please try again.'); + } finally { + setIsSubmittingHandoff(false); + } }; const openPanel = () => { @@ -137,9 +806,10 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio // Legacy fallback: earlier callers stashed the query on window before // triggering us. Kept as a belt-and-braces guard in case any path // still uses it. - if (!pendingQuery && (window as any).__warpAskAiQuery) { - pendingQuery = (window as any).__warpAskAiQuery; - delete (window as any).__warpAskAiQuery; + const windowWithAskQuery = window as Window & { __warpAskAiQuery?: string }; + if (!pendingQuery && windowWithAskQuery.__warpAskAiQuery) { + pendingQuery = windowWithAskQuery.__warpAskAiQuery; + delete windowWithAskQuery.__warpAskAiQuery; } setIsOpen(true); @@ -155,6 +825,19 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const closePanel = () => { dialogRef.current?.close(); }; + const copyMcpValue = async (target: McpCopyTarget) => { + const value = target === 'config' ? warpDocsMcpConfig : warpDocsMcpUrl; + const copied = await copyTextToClipboard(value); + if (!copied) return; + setCopiedMcpValue(target); + if (mcpCopyResetRef.current !== null) { + window.clearTimeout(mcpCopyResetRef.current); + } + mcpCopyResetRef.current = window.setTimeout(() => { + setCopiedMcpValue(null); + mcpCopyResetRef.current = null; + }, 1500); + }; const restoreFocus = () => { window.requestAnimationFrame(() => { @@ -198,8 +881,8 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio onClose={onDialogClose} onClick={onDialogClick} > -
-
+
+
+ + + + + +
+
+
+

Use in Warp

+

+ In Warp, go to Settings > Agents >{' '} + MCP servers, click + Add, and paste the + configuration. +

+ {warpDocsMcpConfig} + +
+
+

Use in another MCP client

+

+ Copy the official MCP URL for Claude, ChatGPT, and other compatible clients. +

+ +
+ + {copiedMcpValue === 'config' + ? 'Warp Docs MCP configuration copied to clipboard.' + : copiedMcpValue === 'url' + ? 'Warp Docs MCP URL copied to clipboard.' + : ''} + + +
+
-
+
-
+
{!hasConversation && (

Ask a question

@@ -236,7 +1009,40 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
{qa.question}
{qa.answer ? ( - {qa.answer} + ; inline code + // arrives as a bare . react-markdown v9+ removed the + // `inline` prop, so nesting is the only reliable signal. + pre({ children }) { + const codeElement = extractCodeElement(children); + if (!codeElement) { + return
{children}
; + } + const codeClassName = + typeof codeElement.props.className === 'string' + ? codeElement.props.className + : undefined; + const codeText = markdownChildrenToText(codeElement.props.children); + const isStreamingAnswer = + isBusy && conversation[conversation.length - 1]?.id === qa.id; + return ( + + ); + }, + code({ className, children }) { + return {children}; + }, + }} + > + {qa.answer} +
) : (
+ ) : null} + {handoffQaId === qa.id && !isBusy && shouldShowHandoffForAnswer(qa) ? ( +
+ +
{ + event.preventDefault(); + void submitSupportHandoff(qa); + }} + > + setHandoffEmailInput(event.target.value)} + required + /> + + +
+ {handoffErrorMessage ? ( +

+ {handoffErrorMessage} +

+ ) : null} + {handoffSuccessMessage ? ( +

+ {handoffSuccessMessage} +

+ ) : null}
) : null}
))} - {error ?
{error}
: null} -
-
+ {error ?
{getChatErrorMessage(error)}
: null} +
+
- - + + ); @@ -353,7 +1219,13 @@ export default function KapaChatLauncher({ autoOpen = false }: { autoOpen?: bool const [sessionAutoOpen, setSessionAutoOpen] = useState(autoOpen); const callbacks = useMemo( () => ({ - askAI: {}, + askAI: { + onAnswerGenerationCompleted: (data: { threadId?: string | null }) => { + if (data.threadId) { + localStorage.setItem('warp_docs_kapa_thread_id', data.threadId); + } + }, + }, }), [] ); diff --git a/src/components/KapaLauncher.astro b/src/components/KapaLauncher.astro index 028bc96f5..a978012c4 100644 --- a/src/components/KapaLauncher.astro +++ b/src/components/KapaLauncher.astro @@ -2,7 +2,17 @@ // The button sits to the right of the sidebar search pill (see // CustomSidebar.astro), rendering as `[icon] Ask` at one type-scale step // smaller than search so it reads as the secondary affordance. +import { Code } from 'astro-expressive-code/components'; import KapaChatLauncher from './KapaChatLauncher'; --- + +{/* + Hidden Expressive Code block so EC frame CSS variables/assets exist on + every page. Chat copy buttons are owned by KapaChatLauncher (not EC's + `.copy` markup) to avoid double icons. +*/} + diff --git a/src/pages/api/support-handoff.ts b/src/pages/api/support-handoff.ts new file mode 100644 index 000000000..89ecba92a --- /dev/null +++ b/src/pages/api/support-handoff.ts @@ -0,0 +1,231 @@ +import type { APIRoute } from 'astro'; +import { SUPPORT_HANDOFF_ENDPOINT_URL, SUPPORT_HANDOFF_SHARED_SECRET } from 'astro:env/server'; + +export const prerender = false; + +type HandoffPayload = { + user_email?: unknown; + question?: unknown; + page_url?: unknown; + conversation_transcript?: unknown; + kapa_project_id?: unknown; + kapa_thread_id?: unknown; + kapa_conversation_url?: unknown; + captcha_token?: unknown; + captcha_header?: unknown; +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const KAPA_ID_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/; +const MAX_QUESTION_LENGTH = 4_000; +const MAX_TRANSCRIPT_LENGTH = 50_000; +const MAX_PAGE_URL_LENGTH = 2_048; +const MAX_CAPTCHA_TOKEN_LENGTH = 4_096; +const ALLOWED_PAGE_URL_HOSTS = new Set(['docs.warp.dev', 'localhost']); +const DOCS_PREVIEW_HOSTNAME_PATTERN = /^docs-[a-z0-9]+(?:-[a-z0-9]+)*-warpdotdev\.vercel\.app$/; +const ALLOWED_CAPTCHA_HEADERS = new Set(['X-RECAPTCHA-ENTERPRISE-TOKEN', 'X-HCAPTCHA-TOKEN']); +const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; +const RATE_LIMIT_MAX_REQUESTS = 5; +const rateLimitStore = new Map(); + +function asTrimmedString(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function jsonError(message: string, status = 400) { + return new Response(JSON.stringify({ message }), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function buildKapaConversationUrl(kapaProjectId: string, kapaThreadId: string) { + return `https://app.kapa.ai/${kapaProjectId}/conversations/${kapaThreadId}`; +} + +function isAllowedPageUrl(url: string) { + if (url.length > MAX_PAGE_URL_LENGTH) return false; + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) return false; + return isAllowedHostname(parsed.hostname); + } catch { + return false; + } +} + +function isAllowedHostname(hostname: string) { + const lowercasedHostname = hostname.toLowerCase(); + if (ALLOWED_PAGE_URL_HOSTS.has(lowercasedHostname)) return true; + return DOCS_PREVIEW_HOSTNAME_PATTERN.test(lowercasedHostname); +} + +function isAllowedRequestOrigin(request: Request) { + const originHeader = request.headers.get('origin'); + if (!originHeader) return false; + try { + const parsedOrigin = new URL(originHeader); + if (!['http:', 'https:'].includes(parsedOrigin.protocol)) return false; + return isAllowedHostname(parsedOrigin.hostname); + } catch { + return false; + } +} + +function getClientIp(request: Request) { + // Vercel overwrites `x-forwarded-for` and strips external values to prevent + // spoofing: https://vercel.com/docs/headers/request-headers#x-forwarded-for + const forwardedFor = request.headers.get('x-forwarded-for'); + if (forwardedFor) { + const firstIp = forwardedFor.split(',')[0]?.trim(); + if (firstIp) return firstIp; + } + const connectingIp = request.headers.get('cf-connecting-ip')?.trim(); + if (connectingIp) return connectingIp; + const realIp = request.headers.get('x-real-ip')?.trim(); + if (realIp) return realIp; + return 'unknown'; +} + +function isRateLimited(clientIp: string) { + const now = Date.now(); + const existing = rateLimitStore.get(clientIp); + if (!existing || existing.resetAt <= now) { + rateLimitStore.set(clientIp, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); + return false; + } + existing.count += 1; + if (existing.count > RATE_LIMIT_MAX_REQUESTS) { + return true; + } + rateLimitStore.set(clientIp, existing); + return false; +} + +export const POST: APIRoute = async ({ request }) => { + const supportHandoffEndpointUrl = SUPPORT_HANDOFF_ENDPOINT_URL?.trim() || ''; + const supportHandoffSharedSecret = SUPPORT_HANDOFF_SHARED_SECRET?.trim() || ''; + if (!supportHandoffEndpointUrl || !supportHandoffSharedSecret) { + return jsonError('Support handoff is not configured.', 503); + } + if (!isAllowedRequestOrigin(request)) { + return jsonError('Request origin is not allowed.', 403); + } + const clientIp = getClientIp(request); + if (isRateLimited(clientIp)) { + return jsonError('Too many support handoff attempts. Please try again later.', 429); + } + + let payload: HandoffPayload; + try { + payload = (await request.json()) as HandoffPayload; + } catch { + return jsonError('Invalid JSON payload.'); + } + + const userEmail = asTrimmedString(payload.user_email).toLowerCase(); + if (!EMAIL_PATTERN.test(userEmail)) { + return jsonError('Please enter a valid email address.'); + } + + const question = asTrimmedString(payload.question); + const pageUrl = asTrimmedString(payload.page_url); + const conversationTranscript = asTrimmedString(payload.conversation_transcript); + const kapaProjectId = asTrimmedString(payload.kapa_project_id); + const kapaThreadId = asTrimmedString(payload.kapa_thread_id); + const suppliedKapaConversationUrl = asTrimmedString(payload.kapa_conversation_url); + const captchaToken = asTrimmedString(payload.captcha_token); + const captchaHeader = asTrimmedString(payload.captcha_header); + + if (!question) return jsonError('A question is required.'); + if (question.length > MAX_QUESTION_LENGTH) return jsonError('Question is too long.'); + if (!pageUrl) return jsonError('The current page URL is required.'); + if (!isAllowedPageUrl(pageUrl)) return jsonError('Page URL is invalid or not allowed.'); + if (!conversationTranscript) return jsonError('Conversation transcript is required.'); + if (conversationTranscript.length > MAX_TRANSCRIPT_LENGTH) { + return jsonError('Conversation transcript is too long.'); + } + if (!kapaThreadId) return jsonError('Kapa thread ID is required.'); + if (!KAPA_ID_PATTERN.test(kapaThreadId)) return jsonError('Kapa thread ID is invalid.'); + if (!captchaToken) return jsonError('Captcha verification is required.'); + if (captchaToken.length > MAX_CAPTCHA_TOKEN_LENGTH) return jsonError('Captcha token is invalid.'); + if (!ALLOWED_CAPTCHA_HEADERS.has(captchaHeader)) return jsonError('Captcha token header is invalid.'); + if (kapaProjectId && !KAPA_ID_PATTERN.test(kapaProjectId)) { + return jsonError('Kapa project ID is invalid.'); + } + const derivedKapaConversationUrl = + kapaProjectId && kapaThreadId + ? buildKapaConversationUrl(kapaProjectId, kapaThreadId) + : ''; + if (kapaProjectId) { + if ( + suppliedKapaConversationUrl && + suppliedKapaConversationUrl !== derivedKapaConversationUrl + ) { + return jsonError('Kapa conversation URL does not match project/thread values.'); + } + } else if (suppliedKapaConversationUrl) { + return jsonError('Kapa project ID is required when a Kapa conversation URL is supplied.'); + } + + const forwardPayload = { + user_email: userEmail, + question, + page_url: pageUrl, + conversation_transcript: conversationTranscript, + kapa_project_id: kapaProjectId || null, + kapa_thread_id: kapaThreadId, + kapa_conversation_url: derivedKapaConversationUrl || null, + source: 'docs-kapa-custom-chat', + }; + + // DevX `gcp_front_docs_handoff` validates the shared secret the same way + // Front webhooks do: as a `?secret=` query param (see + // helper_flask.validate_front_webhook_secret). Bearer alone is ignored. + const upstreamUrl = new URL(supportHandoffEndpointUrl); + upstreamUrl.searchParams.set('secret', supportHandoffSharedSecret); + + const forwardHeaders: HeadersInit = { + 'content-type': 'application/json', + [captchaHeader]: captchaToken, + }; + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(upstreamUrl.toString(), { + method: 'POST', + headers: forwardHeaders, + body: JSON.stringify(forwardPayload), + }); + } catch { + return jsonError('Failed to reach support handoff service.', 502); + } + + let upstreamData: unknown = null; + try { + upstreamData = await upstreamResponse.json(); + } catch { + upstreamData = null; + } + + if (!upstreamResponse.ok) { + const upstreamMessage = + upstreamData && + typeof upstreamData === 'object' && + 'message' in upstreamData && + typeof upstreamData.message === 'string' + ? upstreamData.message + : 'Support handoff failed.'; + return jsonError(upstreamMessage, upstreamResponse.status); + } + + return new Response( + JSON.stringify({ + message: 'Message sent to Warp Support.', + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ); +}; diff --git a/src/styles/custom.css b/src/styles/custom.css index 8afdcb559..bafecc856 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -315,6 +315,146 @@ body { margin-left: auto; margin-right: auto; } +/* Article images are enhanced by ArticleImageLightbox.astro with keyboard + dialog controls while this class adds a layout-neutral zoom affordance. + The script scopes enhancement to main docs content and honors + `data-no-lightbox` on the image or any ancestor. */ +.sl-markdown-content img[data-docs-image-lightbox-image] { + cursor: zoom-in; + transition: box-shadow 0.15s ease; +} + +@media (hover: hover) { + .sl-markdown-content img[data-docs-image-lightbox-image]:hover { + box-shadow: 0 0 0 2px var(--sl-color-accent-high); + } +} +.sl-markdown-content img[data-warp-image-lightbox-trigger]:focus-visible, +.sl-markdown-content a[data-warp-image-lightbox-trigger]:focus-visible img { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 3px; +} + +html.docs-image-lightbox-open, +body.docs-image-lightbox-open { + overflow: hidden; +} + +.docs-image-lightbox { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + padding: 1.5rem; + border: 0; + background: rgb(0 0 0 / 0.76); + backdrop-filter: blur(8px); + overflow: hidden; +} + +.docs-image-lightbox[hidden] { + display: none; +} +.docs-image-lightbox::backdrop { + background: transparent; +} + +.docs-image-lightbox__content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: min(100%, 90rem); + height: 100%; + min-width: 0; + min-height: 0; + padding: 3.25rem 0 0; +} + +.docs-image-lightbox__image { + display: block; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + border: 1px solid rgb(255 255 255 / 0.22); + border-radius: var(--sl-radius-md); + background: var(--sl-color-black); + object-fit: contain; + box-shadow: 0 1rem 2.5rem rgb(0 0 0 / 0.45); +} + +.docs-image-lightbox__close { + position: absolute; + top: 0; + right: 0; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + padding: 0; + border: 1px solid rgb(255 255 255 / 0.25); + border-radius: var(--sl-radius-sm); + background: rgb(0 0 0 / 0.45); + color: var(--sl-color-white); + cursor: pointer; +} + +.docs-image-lightbox__close:hover { + border-color: rgb(255 255 255 / 0.5); + background: rgb(0 0 0 / 0.7); +} + +.docs-image-lightbox__close:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 2px; +} + +.docs-image-lightbox__close svg { + width: 1.25rem; + height: 1.25rem; +} + +.docs-image-lightbox__fallback { + max-width: min(100%, 24rem); + padding: 0.75rem 1rem; + border: 1px dashed var(--sl-color-gray-4); + border-radius: var(--sl-radius-md); + background: color-mix(in srgb, var(--sl-color-black), var(--sl-color-gray-6) 30%); + color: var(--sl-color-gray-3); +} + +:root[data-theme='light'] .docs-image-lightbox { + background: rgb(241 245 249 / 0.8); +} + +:root[data-theme='light'] .docs-image-lightbox__image { + border-color: rgb(15 23 42 / 0.2); + background: var(--sl-color-black); + box-shadow: 0 1rem 2.5rem rgb(15 23 42 / 0.24); +} + +:root[data-theme='light'] .docs-image-lightbox__close { + border-color: rgb(15 23 42 / 0.2); + background: rgb(255 255 255 / 0.8); + color: var(--sl-color-white); +} + +:root[data-theme='light'] .docs-image-lightbox__close:hover { + border-color: rgb(15 23 42 / 0.4); + background: var(--sl-color-black); +} .sl-markdown-content figcaption { margin-top: 0.5rem; diff --git a/src/styles/warp-components.css b/src/styles/warp-components.css index 38e3ce3e5..2169c61c0 100644 --- a/src/styles/warp-components.css +++ b/src/styles/warp-components.css @@ -179,6 +179,18 @@ pre.astro-code, .sl-markdown-content pre { overflow-x: auto; + overflow-y: hidden; + white-space: pre; +} + +.expressive-code pre code, +.sl-kapa-codeblock pre code { + display: block; + min-width: max-content; +} + +.expressive-code pre { + scrollbar-width: thin; } /* Code block copy button — frosted glass effect (Scalar pattern) */ diff --git a/vercel.json b/vercel.json index e8c2eeefd..fbfc579ee 100644 --- a/vercel.json +++ b/vercel.json @@ -29,39 +29,21 @@ }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://cdn.rudderlabs.com https://polyfill-fastly.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; frame-src https://www.youtube-nocookie.com https://www.loom.com https://drive.google.com https://www.google.com; connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://*.kapa.ai https://www.google.com https://app.warp.dev https://api.rudderstack.com; frame-ancestors 'none'" + "value": "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://cdn.rudderlabs.com https://polyfill-fastly.io https://widget.kapa.ai https://hcaptcha.com https://*.hcaptcha.com https://js.hcaptcha.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; frame-src https://www.youtube-nocookie.com https://www.loom.com https://drive.google.com https://www.google.com https://hcaptcha.com https://*.hcaptcha.com; connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://*.kapa.ai https://kapa-widget-proxy-la7dkmplpq-uc.a.run.app https://www.google.com https://app.warp.dev https://api.rudderstack.com https://cdn.rudderlabs.com https://hcaptcha.com https://*.hcaptcha.com; frame-ancestors 'none'" } ] }, { - "source": "/api", + "source": "/api/:path*", "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://cdn.rudderlabs.com https://polyfill-fastly.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; frame-src https://www.youtube-nocookie.com https://www.loom.com https://drive.google.com https://www.google.com; connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://*.kapa.ai https://www.google.com https://app.warp.dev https://api.rudderstack.com; frame-ancestors 'none'" + "value": "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://cdn.rudderlabs.com https://polyfill-fastly.io https://widget.kapa.ai https://hcaptcha.com https://*.hcaptcha.com https://js.hcaptcha.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; frame-src https://www.youtube-nocookie.com https://www.loom.com https://drive.google.com https://www.google.com https://hcaptcha.com https://*.hcaptcha.com; connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://*.kapa.ai https://kapa-widget-proxy-la7dkmplpq-uc.a.run.app https://www.google.com https://app.warp.dev https://api.rudderstack.com https://cdn.rudderlabs.com https://hcaptcha.com https://*.hcaptcha.com; frame-ancestors 'none'" } ] }, { - "source": "/api/(.*)", - "headers": [ - { - "key": "Content-Security-Policy", - "value": "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://www.google.com https://www.gstatic.com https://cdn.jsdelivr.net https://cdn.rudderlabs.com https://polyfill-fastly.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; img-src 'self' data: https:; frame-src https://www.youtube-nocookie.com https://www.loom.com https://drive.google.com https://www.google.com; connect-src 'self' https://va.vercel-scripts.com https://vitals.vercel-insights.com https://*.kapa.ai https://www.google.com https://app.warp.dev https://api.rudderstack.com; frame-ancestors 'none'" - } - ] - }, - { - "source": "/_astro/(.*)", - "headers": [ - { - "key": "Cache-Control", - "value": "public, max-age=31536000, immutable" - } - ] - }, - { - "source": "/assets/(.*)", + "source": "/:directory(_astro|assets)/:path(.*)", "headers": [ { "key": "Cache-Control",