From 2f42548e26ffe6bd39957c4239eebc0664b2525f Mon Sep 17 00:00:00 2001 From: Danny Neira <16809145+dannyneira@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:51:02 -0600 Subject: [PATCH 01/10] feat(docs): add Kapa support handoff Co-Authored-By: Warp Agent --- .env.example | 23 +- astro.config.mjs | 36 +- package-lock.json | 34 +- src/components/CustomSidebar.astro | 12 +- src/components/KapaChatLauncher.css | 403 ++++++++++++- src/components/KapaChatLauncher.mount.tsx | 0 src/components/KapaChatLauncher.tsx | 660 +++++++++++++++++++++- src/components/KapaLauncher.astro | 10 + src/pages/api/support-handoff.ts | 231 ++++++++ src/styles/warp-components.css | 12 + vercel.json | 21 +- 11 files changed, 1366 insertions(+), 76 deletions(-) delete mode 100644 src/components/KapaChatLauncher.mount.tsx create mode 100644 src/pages/api/support-handoff.ts diff --git a/.env.example b/.env.example index 02af3a50d..cd78fae27 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= + +# Optional shared secret sent as an authorization bearer token when +# forwarding to SUPPORT_HANDOFF_ENDPOINT_URL. +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/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..d810c5b08 100644
--- a/src/components/KapaChatLauncher.css
+++ b/src/components/KapaChatLauncher.css
@@ -200,6 +200,126 @@
 	padding: 0.1rem 0.3rem;
 }
 
+.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;
 	align-items: center;
@@ -249,18 +369,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 +512,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 {
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..dcc0b2e55 100644
--- a/src/components/KapaChatLauncher.tsx
+++ b/src/components/KapaChatLauncher.tsx
@@ -1,8 +1,8 @@
-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 { 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 {
@@ -18,10 +18,395 @@ 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@]+$/;
 
 type FeedbackReaction = 'upvote' | 'downvote';
+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$/, '');
+}
+
+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,6 +418,13 @@ 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 messagesRef = useRef(null); const dialogRef = useRef(null); const triggerRef = useRef(null); @@ -45,16 +437,55 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio isGeneratingAnswer, isPreparingAnswer, submitQuery, + threadId, } = useChat(); + const { executeCaptcha } = useCaptcha(); + useEffect(() => { setIsAppleDevice(isMac()); }, []); + 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 +538,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 +551,116 @@ 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 }) => { + 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); + const captcha = await executeCaptcha(CaptchaAction.FeedbackSubmit); + if (!captcha?.token || !captcha?.key) { + setHandoffErrorMessage('Captcha verification failed. Please try again.'); + return; + } + + setIsSubmittingHandoff(true); + setHandoffErrorMessage(null); + setHandoffSuccessMessage(null); + try { + 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 +679,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); @@ -236,7 +779,38 @@ 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 +987,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..31f981eaf --- /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 ALLOWED_PAGE_URL_SUFFIXES = ['.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; + const hostname = parsed.hostname.toLowerCase(); + if (ALLOWED_PAGE_URL_HOSTS.has(hostname)) return true; + return ALLOWED_PAGE_URL_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); + } catch { + return false; + } +} + +function isAllowedHostname(hostname: string) { + const lowercasedHostname = hostname.toLowerCase(); + if (ALLOWED_PAGE_URL_HOSTS.has(lowercasedHostname)) return true; + return ALLOWED_PAGE_URL_SUFFIXES.some((suffix) => lowercasedHostname.endsWith(suffix)); +} + +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) { + 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/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..b3462d7b6 100644 --- a/vercel.json +++ b/vercel.json @@ -29,7 +29,7 @@ }, { "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'" } ] }, @@ -38,7 +38,7 @@ "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'" } ] }, @@ -47,7 +47,7 @@ "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'" } ] }, @@ -390,11 +390,6 @@ "destination": "/guides/devops/how-to-analyze-cloud-run-logs-gcloud/", "statusCode": 308 }, - { - "source": "/knowledge-and-collaboration/session-sharingentials1", - "destination": "/knowledge-and-collaboration/session-sharing/", - "statusCode": 308 - }, { "source": "/advanced/command-line-flags", "destination": "/terminal/sessions/launch-configurations/", @@ -1390,11 +1385,6 @@ "destination": "/agent-platform/local-agents/interacting-with-agents/", "statusCode": 308 }, - { - "source": "/agent-platform/features/warp-ai/agent-modea", - "destination": "/agent-platform/local-agents/interacting-with-agents/", - "statusCode": 308 - }, { "source": "/agent-platform/features/warp-ai/ai-command-search", "destination": "/agent-platform/local-agents/overview/", @@ -5680,11 +5670,6 @@ "destination": "/agent-platform/local-agents/interacting-with-agents/", "statusCode": 308 }, - { - "source": "/agent-platform/features/warp-ai/agent-modea/", - "destination": "/agent-platform/local-agents/interacting-with-agents/", - "statusCode": 308 - }, { "source": "/agent-platform/features/warp-ai/ai-command-search/", "destination": "/agent-platform/local-agents/overview/", From 86f1d61bb7d35e8d270d14f82fbec1f49c7d58d0 Mon Sep 17 00:00:00 2001 From: Danny Neira <16809145+dannyneira@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:31:04 -0600 Subject: [PATCH 02/10] fix(docs): harden support handoff Co-Authored-By: Warp Agent --- .env.example | 4 ++-- src/components/KapaChatLauncher.tsx | 17 ++++++++++++----- src/pages/api/support-handoff.ts | 10 +++++----- vercel.json | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index cd78fae27..ed587847d 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,6 @@ PUBLIC_RUDDERSTACK_DATA_PLANE_URL= # conversations from docs chat handoff requests. SUPPORT_HANDOFF_ENDPOINT_URL= -# Optional shared secret sent as an authorization bearer token when -# forwarding to 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/src/components/KapaChatLauncher.tsx b/src/components/KapaChatLauncher.tsx index dcc0b2e55..d1ccc5314 100644 --- a/src/components/KapaChatLauncher.tsx +++ b/src/components/KapaChatLauncher.tsx @@ -604,6 +604,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio }; 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.'); @@ -623,16 +624,22 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio } const conversationLink = buildConversationLink(activeThreadId); - const captcha = await executeCaptcha(CaptchaAction.FeedbackSubmit); - if (!captcha?.token || !captcha?.key) { - setHandoffErrorMessage('Captcha verification failed. Please try again.'); - return; - } 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: { diff --git a/src/pages/api/support-handoff.ts b/src/pages/api/support-handoff.ts index 31f981eaf..89ecba92a 100644 --- a/src/pages/api/support-handoff.ts +++ b/src/pages/api/support-handoff.ts @@ -22,7 +22,7 @@ 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 ALLOWED_PAGE_URL_SUFFIXES = ['.vercel.app']; +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; @@ -48,9 +48,7 @@ function isAllowedPageUrl(url: string) { try { const parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol)) return false; - const hostname = parsed.hostname.toLowerCase(); - if (ALLOWED_PAGE_URL_HOSTS.has(hostname)) return true; - return ALLOWED_PAGE_URL_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); + return isAllowedHostname(parsed.hostname); } catch { return false; } @@ -59,7 +57,7 @@ function isAllowedPageUrl(url: string) { function isAllowedHostname(hostname: string) { const lowercasedHostname = hostname.toLowerCase(); if (ALLOWED_PAGE_URL_HOSTS.has(lowercasedHostname)) return true; - return ALLOWED_PAGE_URL_SUFFIXES.some((suffix) => lowercasedHostname.endsWith(suffix)); + return DOCS_PREVIEW_HOSTNAME_PATTERN.test(lowercasedHostname); } function isAllowedRequestOrigin(request: Request) { @@ -75,6 +73,8 @@ function isAllowedRequestOrigin(request: Request) { } 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(); diff --git a/vercel.json b/vercel.json index b3462d7b6..ea16be4aa 100644 --- a/vercel.json +++ b/vercel.json @@ -390,6 +390,11 @@ "destination": "/guides/devops/how-to-analyze-cloud-run-logs-gcloud/", "statusCode": 308 }, + { + "source": "/knowledge-and-collaboration/session-sharingentials1", + "destination": "/knowledge-and-collaboration/session-sharing/", + "statusCode": 308 + }, { "source": "/advanced/command-line-flags", "destination": "/terminal/sessions/launch-configurations/", @@ -1385,6 +1390,11 @@ "destination": "/agent-platform/local-agents/interacting-with-agents/", "statusCode": 308 }, + { + "source": "/agent-platform/features/warp-ai/agent-modea", + "destination": "/agent-platform/local-agents/interacting-with-agents/", + "statusCode": 308 + }, { "source": "/agent-platform/features/warp-ai/ai-command-search", "destination": "/agent-platform/local-agents/overview/", @@ -5670,6 +5680,11 @@ "destination": "/agent-platform/local-agents/interacting-with-agents/", "statusCode": 308 }, + { + "source": "/agent-platform/features/warp-ai/agent-modea/", + "destination": "/agent-platform/local-agents/interacting-with-agents/", + "statusCode": 308 + }, { "source": "/agent-platform/features/warp-ai/ai-command-search/", "destination": "/agent-platform/local-agents/overview/", From 991c864c64d75aaa6145cb4db4b9ddab92f9a18c Mon Sep 17 00:00:00 2001 From: Danny Neira <16809145+dannyneira@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:31:05 -0600 Subject: [PATCH 03/10] fix(docs): consolidate Vercel header routes Co-Authored-By: Warp Agent --- vercel.json | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/vercel.json b/vercel.json index ea16be4aa..fbfc579ee 100644 --- a/vercel.json +++ b/vercel.json @@ -34,7 +34,7 @@ ] }, { - "source": "/api", + "source": "/api/:path*", "headers": [ { "key": "Content-Security-Policy", @@ -43,25 +43,7 @@ ] }, { - "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 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": "/_astro/(.*)", - "headers": [ - { - "key": "Cache-Control", - "value": "public, max-age=31536000, immutable" - } - ] - }, - { - "source": "/assets/(.*)", + "source": "/:directory(_astro|assets)/:path(.*)", "headers": [ { "key": "Cache-Control", From b5f8ba2b10caf8bd4f2929b3774409b7a797d745 Mon Sep 17 00:00:00 2001 From: Danny Neira <16809145+dannyneira@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:19:35 -0600 Subject: [PATCH 04/10] feat(docs): render Kapa answer images Co-Authored-By: Warp Agent --- src/components/KapaChatLauncher.css | 41 +++++++++++++++++++++++++++ src/components/KapaChatLauncher.tsx | 43 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/components/KapaChatLauncher.css b/src/components/KapaChatLauncher.css index d810c5b08..c79899bd0 100644 --- a/src/components/KapaChatLauncher.css +++ b/src/components/KapaChatLauncher.css @@ -199,6 +199,47 @@ border-radius: var(--sl-radius-sm); padding: 0.1rem 0.3rem; } +.sl-kapa-answer-image { + display: block; + box-sizing: border-box; + max-width: 100%; + height: auto; + margin: 0.875rem 0; + border: 1px solid var(--sl-color-gray-5); + border-radius: var(--sl-radius-md); + 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-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 { + border-color: color-mix(in srgb, var(--sl-color-gray-5), var(--sl-color-black) 15%); + 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-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; diff --git a/src/components/KapaChatLauncher.tsx b/src/components/KapaChatLauncher.tsx index d1ccc5314..1eac0896c 100644 --- a/src/components/KapaChatLauncher.tsx +++ b/src/components/KapaChatLauncher.tsx @@ -177,6 +177,48 @@ function extractCodeElement(children: ReactNode): ReactElement { + setHasFailed(false); + }, [src]); + + if (!src || hasFailed) { + return ( + + Image unavailable + + ); + } + + // `src` has already passed react-markdown's default safe URL transformation. + // Do not wrap this image in an anchor because Markdown links provide that wrapper. + return ( + {imageAlt} setHasFailed(true)} + /> + ); +} const highlightCache = new Map(); const highlightInFlight = new Map>(); @@ -788,6 +830,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio {qa.answer ? ( ; inline code // arrives as a bare . react-markdown v9+ removed the // `inline` prop, so nesting is the only reliable signal. From e73373a028cb93e87a60e2d55bb129f4c71f08fb Mon Sep 17 00:00:00 2001 From: Danny Neira <16809145+dannyneira@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:01:21 -0600 Subject: [PATCH 05/10] feat(docs): add accessible image lightboxes Co-Authored-By: Warp Agent --- src/components/ArticleImageLightbox.astro | 215 +++++++++++++++++ src/components/CustomHead.astro | 2 + src/components/KapaChatLauncher.css | 150 +++++++++++- src/components/KapaChatLauncher.tsx | 280 ++++++++++++++++++++-- src/styles/custom.css | 130 ++++++++++ 5 files changed, 752 insertions(+), 25 deletions(-) create mode 100644 src/components/ArticleImageLightbox.astro diff --git a/src/components/ArticleImageLightbox.astro b/src/components/ArticleImageLightbox.astro new file mode 100644 index 000000000..63f97dbe7 --- /dev/null +++ b/src/components/ArticleImageLightbox.astro @@ -0,0 +1,215 @@ +--- +// A singleton, delegated article-image lightbox. Event delegation keeps it +// active for future Starlight client-side swaps without wrapping images in +// buttons or changing their documentation 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/KapaChatLauncher.css b/src/components/KapaChatLauncher.css index c79899bd0..f7287c8ca 100644 --- a/src/components/KapaChatLauncher.css +++ b/src/components/KapaChatLauncher.css @@ -23,6 +23,7 @@ .sl-kapa-panel { margin-left: auto; + position: relative; display: flex; flex-direction: column; width: min(28rem, 100vw); @@ -30,6 +31,7 @@ background: var(--sl-color-black); border-left: 1px solid var(--sl-color-gray-5); box-shadow: var(--sl-shadow-xl); + overflow: hidden; } @@ -199,18 +201,56 @@ border-radius: var(--sl-radius-sm); padding: 0.1rem 0.3rem; } -.sl-kapa-answer-image { +.sl-kapa-answer-image-button { + position: relative; display: block; box-sizing: border-box; max-width: 100%; - height: auto; 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; @@ -230,16 +270,120 @@ } :root[data-theme='light'] .sl-kapa-answer-image { - border-color: color-mix(in srgb, var(--sl-color-gray-5), var(--sl-color-black) 15%); 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-image-lightbox { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 1rem; + background: rgb(0 0 0 / 0.72); + backdrop-filter: blur(8px); + overscroll-behavior: contain; +} + +.sl-kapa-image-lightbox__content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + padding: 2.75rem 0 0; +} + +.sl-kapa-image-lightbox__image { + display: block; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + border: 1px solid rgb(255 255 255 / 0.2); + 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.4); +} + +.sl-kapa-image-lightbox__close { + position: absolute; + top: 0; + right: 0; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + 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; + transition: + border-color 0.15s ease, + background-color 0.15s ease; +} + +.sl-kapa-image-lightbox__close:hover { + border-color: rgb(255 255 255 / 0.5); + background: rgb(0 0 0 / 0.7); +} + +.sl-kapa-image-lightbox__close:focus-visible { + outline: 2px solid var(--sl-color-accent-high); + outline-offset: 2px; +} + +.sl-kapa-image-lightbox__close svg { + width: 1.125rem; + height: 1.125rem; +} + +.sl-kapa-image-lightbox__fallback { + width: min(100%, 20rem); + margin: 0; +} + +:root[data-theme='light'] .sl-kapa-image-lightbox { + background: rgb(241 245 249 / 0.78); +} + +:root[data-theme='light'] .sl-kapa-image-lightbox__image { + border-color: rgb(15 23 42 / 0.2); + background: var(--sl-color-white); + box-shadow: 0 1rem 2.5rem rgb(15 23 42 / 0.22); +} + +:root[data-theme='light'] .sl-kapa-image-lightbox__close { + border-color: rgb(15 23 42 / 0.2); + background: rgb(255 255 255 / 0.78); + color: var(--sl-color-black); +} + +:root[data-theme='light'] .sl-kapa-image-lightbox__close:hover { + border-color: rgb(15 23 42 / 0.4); + background: var(--sl-color-white); +} .sl-kapa-codeblock { position: relative; diff --git a/src/components/KapaChatLauncher.tsx b/src/components/KapaChatLauncher.tsx index 1eac0896c..839abe307 100644 --- a/src/components/KapaChatLauncher.tsx +++ b/src/components/KapaChatLauncher.tsx @@ -1,6 +1,16 @@ import type { FormEvent, MouseEvent, ReactElement, ReactNode } from 'react'; import * as Popover from '@radix-ui/react-popover'; -import { isValidElement, useEffect, useMemo, useRef, useState } from 'react'; +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + 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'; @@ -182,9 +192,55 @@ type ChatMarkdownImageProps = { src?: string; title?: string; }; +type ChatImageLightboxImage = { + alt: string; + src: string; + title?: string; +}; + +type ChatImageLightboxController = { + openImageLightbox: (image: ChatImageLightboxImage, trigger: HTMLButtonElement) => void; +}; + +const ChatImageLightboxContext = createContext(null); + +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 lightbox = useContext(ChatImageLightboxContext); + const triggerRef = useRef(null); const imageAlt = alt?.trim() || 'Image from Kapa answer'; useEffect(() => { @@ -204,8 +260,9 @@ function ChatMarkdownImage({ alt, src, title }: ChatMarkdownImageProps) { } // `src` has already passed react-markdown's default safe URL transformation. - // Do not wrap this image in an anchor because Markdown links provide that wrapper. - return ( + // Image-only Markdown links are unwrapped above to avoid nesting this button + // inside an anchor. Ordinary text links retain their original behavior. + const image = ( setHasFailed(true)} /> ); + + if (!lightbox) { + return image; + } + return ( + + ); } +function ChatImageLightbox({ + image, + onClose, + closeButtonRef, + lightboxRef, +}: { + image: ChatImageLightboxImage; + onClose: () => void; + closeButtonRef: React.RefObject; + lightboxRef: React.RefObject; +}) { + const [hasFailed, setHasFailed] = useState(false); + + useEffect(() => { + setHasFailed(false); + }, [image.src]); + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+ + Expanded image: {image.alt} + + + {hasFailed ? ( + + Image unavailable + + ) : ( + {image.alt} setHasFailed(true)} + /> + )} +
+
+ ); +} const highlightCache = new Map(); const highlightInFlight = new Map>(); let highlightRequestQueue: Promise = Promise.resolve(); @@ -467,11 +613,15 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio const [handoffErrorMessage, setHandoffErrorMessage] = useState(null); const [handoffSuccessMessage, setHandoffSuccessMessage] = useState(null); const [isSubmittingHandoff, setIsSubmittingHandoff] = useState(false); + const [imageLightbox, setImageLightbox] = useState(null); const messagesRef = useRef(null); const dialogRef = useRef(null); const triggerRef = useRef(null); const closeButtonRef = useRef(null); const inputRef = useRef(null); + const imageLightboxRef = useRef(null); + const imageLightboxCloseButtonRef = useRef(null); + const imageLightboxTriggerRef = useRef(null); const { addFeedback, conversation, @@ -555,12 +705,76 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio dialog.close(); } }, [isOpen]); + const closeImageLightbox = useCallback(() => { + setImageLightbox(null); + window.requestAnimationFrame(() => { + imageLightboxTriggerRef.current?.focus(); + }); + }, []); + + const openImageLightbox = useCallback( + (image: ChatImageLightboxImage, trigger: HTMLButtonElement) => { + imageLightboxTriggerRef.current = trigger; + setImageLightbox(image); + }, + [] + ); + + const imageLightboxController = useMemo( + () => ({ openImageLightbox }), + [openImageLightbox] + ); + + useEffect(() => { + if (!imageLightbox) return; + + const frame = window.requestAnimationFrame(() => { + imageLightboxCloseButtonRef.current?.focus(); + }); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + closeImageLightbox(); + return; + } + if (event.key !== 'Tab') return; + + const focusable = imageLightboxRef.current?.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (!focusable?.length) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener('keydown', onKeyDown, true); + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('keydown', onKeyDown, true); + }; + }, [closeImageLightbox, imageLightbox]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (keymatch(event, 'CmdOrCtrl+I')) { if (dialogRef.current?.open) { - closePanel(); + if (imageLightbox) { + closeImageLightbox(); + } else { + closePanel(); + } } else { openPanel(); } @@ -572,7 +786,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio return () => { window.removeEventListener('keydown', onKeyDown); }; - }, []); + }, [closeImageLightbox, imageLightbox]); const hasConversation = conversation.length > 0; const isBusy = isGeneratingAnswer || isPreparingAnswer; @@ -755,6 +969,7 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio }; const onDialogClose = () => { + setImageLightbox(null); setIsOpen(false); restoreFocus(); }; @@ -782,16 +997,23 @@ function ChatSurface({ title, welcomeMessage, autoOpen = false, onNewConversatio
- - + + {imageLightbox ? ( + + ) : null} + + + ); } diff --git a/src/styles/custom.css b/src/styles/custom.css index 8afdcb559..645dd1ecd 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -315,6 +315,136 @@ 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-docs-image-lightbox-trigger]:focus-visible, +.sl-markdown-content a[data-docs-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; + padding: 1.5rem; + background: rgb(0 0 0 / 0.76); + backdrop-filter: blur(8px); +} + +.docs-image-lightbox[hidden] { + display: none; +} + +.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; From 39203501b94309e12cd66d8914f1094ed9a26cd5 Mon Sep 17 00:00:00 2001 From: Danny Neira <16809145+dannyneira@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:41:02 -0600 Subject: [PATCH 06/10] refactor(docs): share image lightbox Co-Authored-By: Warp Agent --- src/components/ArticleImageLightbox.astro | 55 +++--- src/components/KapaChatLauncher.css | 103 ----------- src/components/KapaChatLauncher.tsx | 210 ++-------------------- src/styles/custom.css | 14 +- 4 files changed, 60 insertions(+), 322 deletions(-) diff --git a/src/components/ArticleImageLightbox.astro b/src/components/ArticleImageLightbox.astro index 63f97dbe7..596861616 100644 --- a/src/components/ArticleImageLightbox.astro +++ b/src/components/ArticleImageLightbox.astro @@ -1,7 +1,7 @@ --- -// A singleton, delegated article-image lightbox. Event delegation keeps it -// active for future Starlight client-side swaps without wrapping images in -// buttons or changing their documentation layout. +// 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. ---