From 47ddbdc9ee27aef81bf374c68d6157a9ee0c38f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 10:00:25 +0000 Subject: [PATCH 1/6] test(web): reproduce #26383 Ctrl+layout "+" claimed by physical-key bindings These tests reproduce #26383: on German/Nordic-style layouts an unshifted "+" is remapped to its physical key, so Ctrl+"+" runs navigation.forward (BracketRight) or preview.zoomOut (Minus) instead of zooming in. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/keybindings.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a0ba7fa7ee043..d4d4d2687f79e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -659,6 +659,24 @@ describe("resolveShortcutCommand", () => { ); }); + it("treats mod and an unshifted layout + as zoom, not its physical key", () => { + assert.isNull( + resolveShortcutCommand( + event({ key: "+", code: "BracketRight", ctrlKey: true }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform: "Linux", context: { terminalFocus: false } }, + ), + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "+", code: "Minus", ctrlKey: true }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform: "Linux", context: { previewFocus: true } }, + ), + "preview.zoomIn", + ); + }); + it("does not let a punctuation position shadow a Latin layout key", () => { const keybindings = compile([ { shortcut: modShortcut("m"), command: "diff.toggle" }, From c3f9a9272d70c08e550fcd6f6c48a44d2259616d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 10:03:00 +0000 Subject: [PATCH 2/6] fix(web): Ctrl + layout "+" zooms instead of triggering its physical-key binding On German, Spanish and Nordic layouts "+" has its own key, so Ctrl+"+" arrives as {key: "+", code: "BracketRight" | "Minus"} without Shift. shortcutKeyFromEvent mapped it to the physical position, so mod+] (navigation.forward) swallowed the desktop zoom accelerator and the preview zoomed out instead of in. An unshifted "+" now keeps its layout identity; Shift+"=" on US layouts still maps to "=". Fixes pingdotgg/t3code#26383 Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/keybindings.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 32292c0f9ef21..04d1eed79929f 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -87,9 +87,13 @@ export function normalizeEventKey(key: string): string { return normalized; } -export function shortcutKeyFromEvent(event: Pick): string { +export function shortcutKeyFromEvent( + event: Pick, +): string { const layoutKey = normalizeEventKey(event.key); if (/^[a-z]$/.test(layoutKey)) return layoutKey; + // An unshifted "+" is a dedicated zoom key (German, Spanish, Nordic), not its physical position. + if (layoutKey === "+" && !event.shiftKey) return layoutKey; const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; return physicalKey ?? layoutKey; } From a0fbcd8bd518e7b92695212aa98d903ac2ba3d82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 10:14:53 +0000 Subject: [PATCH 3/6] fix(web): keep digit positions when a layout types + on the number row Czech and Slovak type an unshifted + on Digit1; mod+1 must still match. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/keybindings.test.ts | 8 ++++++++ packages/shared/src/keybindings.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d4d4d2687f79e..36f726d85ae5d 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -675,6 +675,14 @@ describe("resolveShortcutCommand", () => { ), "preview.zoomIn", ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "+", code: "Digit1", ctrlKey: true }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform: "Linux", context: { isDesktop: true } }, + ), + "thread.jump.1", + ); }); it("does not let a punctuation position shadow a Latin layout key", () => { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 04d1eed79929f..09b40192a9ce8 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -92,8 +92,8 @@ export function shortcutKeyFromEvent( ): string { const layoutKey = normalizeEventKey(event.key); if (/^[a-z]$/.test(layoutKey)) return layoutKey; - // An unshifted "+" is a dedicated zoom key (German, Spanish, Nordic), not its physical position. - if (layoutKey === "+" && !event.shiftKey) return layoutKey; + // An unshifted "+" is a dedicated zoom key (German, Spanish, Nordic); Czech types it on Digit1. + if (layoutKey === "+" && !event.shiftKey && !event.code?.startsWith("Digit")) return layoutKey; const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; return physicalKey ?? layoutKey; } From c7480cb31b3928b0811ff0089dc36665765083f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 10:16:14 +0000 Subject: [PATCH 4/6] fix(web): keep physical matching for Alt chords on a layout + A German alt+] binding recorded on the + key must still match. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/keybindings.test.ts | 15 ++++++++++++++- packages/shared/src/keybindings.ts | 5 +++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 36f726d85ae5d..8ab515ce29d49 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -659,7 +659,7 @@ describe("resolveShortcutCommand", () => { ); }); - it("treats mod and an unshifted layout + as zoom, not its physical key", () => { + it('leaves Ctrl+layout "+" to zoom, except on the number row or with Alt', () => { assert.isNull( resolveShortcutCommand( event({ key: "+", code: "BracketRight", ctrlKey: true }), @@ -683,6 +683,19 @@ describe("resolveShortcutCommand", () => { ), "thread.jump.1", ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "+", code: "BracketRight", altKey: true }), + compile([ + { + shortcut: modShortcut("]", { modKey: false, altKey: true }), + command: "terminal.toggle", + }, + ]), + { platform: "Linux" }, + ), + "terminal.toggle", + ); }); it("does not let a punctuation position shadow a Latin layout key", () => { diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 09b40192a9ce8..6821806af1b78 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -88,12 +88,13 @@ export function normalizeEventKey(key: string): string { } export function shortcutKeyFromEvent( - event: Pick, + event: Pick, ): string { const layoutKey = normalizeEventKey(event.key); if (/^[a-z]$/.test(layoutKey)) return layoutKey; // An unshifted "+" is a dedicated zoom key (German, Spanish, Nordic); Czech types it on Digit1. - if (layoutKey === "+" && !event.shiftKey && !event.code?.startsWith("Digit")) return layoutKey; + const isZoomPlus = layoutKey === "+" && !event.shiftKey && !event.altKey; + if (isZoomPlus && !event.code?.startsWith("Digit")) return layoutKey; const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; return physicalKey ?? layoutKey; } From 09f9cc5e6afb95f773578754d11dc7bd3afa945e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 10:26:22 +0000 Subject: [PATCH 5/6] fix(web): HTML renders match a layout + the same way as the app The frame bootstrap keeps its own copy of the key matcher; without this it forwards a German Ctrl/Cmd+"+" as "]" and the host then rejects it. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/htmlRender.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 572441ffe5366..3146e8ff2a051 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -368,7 +368,7 @@ function rootRule(theme: HtmlRenderTheme): string { // Apply overridable theme CSS before paint and strip the fragment before routing; framed navigation uses the client. // Forward shortcuts after page handlers; text fields retain their keys. -const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);window.addEventListener("click",function(e){var t=e.composedPath()[0],u;if(!e.isTrusted||e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||!t||t.tagName!=="IMG"||t.closest("a[href],button"))return;u=t.currentSrc||t.src;if(u)setTimeout(function(){if(!e.defaultPrevented)window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(OPEN_IMAGE_METHOD)},params:{src:u,name:t.alt||t.title||""}},"*");});});var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`; +const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);window.addEventListener("click",function(e){var t=e.composedPath()[0],u;if(!e.isTrusted||e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||!t||t.tagName!=="IMG"||t.closest("a[href],button"))return;u=t.currentSrc||t.src;if(u)setTimeout(function(){if(!e.defaultPrevented)window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(OPEN_IMAGE_METHOD)},params:{src:u,name:t.alt||t.title||""}},"*");});});var K=[],B=[],C=${JSON.stringify(EVENT_CODE_SHORTCUT_KEYS)};window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(e.source===window.parent&&d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(SHORTCUTS_METHOD)}&&p&&Array.isArray(p.shortcuts)&&Array.isArray(p.blocked)){K=p.shortcuts;B=p.blocked;}});window.addEventListener("keydown",function(e){var t=e.composedPath()[0],c=e.code||"",l=e.key.toLowerCase(),x=/^[a-z]$/.test(l)||l==="+"&&!e.shiftKey&&!e.altKey&&!/^Digit/.test(c)?[l]:[l,/^Key[A-Z]$/.test(c)?c.slice(3).toLowerCase():"",C.hasOwnProperty(c)?C[c]:""],f,m=function(s){return s&&s.metaKey===e.metaKey&&s.ctrlKey===e.ctrlKey&&s.shiftKey===e.shiftKey&&s.altKey===e.altKey&&x.indexOf(s.key)>-1;};if(!e.isTrusted||e.isComposing||e.getModifierState("AltGraph")&&!/^[a-z0-9]$/i.test(e.key)||t&&(t.isContentEditable||/^(?:INPUT|TEXTAREA|SELECT)$/.test(t.tagName))||!K.some(m)||B.some(m))return;f=function(v){window.removeEventListener("keydown",f);if(v!==e||e.defaultPrevented)return;e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(KEY_DOWN_METHOD)},params:{key:e.key,code:e.code,metaKey:e.metaKey,ctrlKey:e.ctrlKey,shiftKey:e.shiftKey,altKey:e.altKey,repeat:e.repeat}},"*");};window.addEventListener("keydown",f);setTimeout(function(){window.removeEventListener("keydown",f);});},true);}})();`; function bootstrapMarkup(markup: string): string { const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); From 67ebcbe57021f09b4eec153153002e15c91d53c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 10:35:52 +0000 Subject: [PATCH 6/6] test(web): cover the Shift guard for a layout + chord Co-Authored-By: Claude Opus 5.5 --- apps/web/src/keybindings.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8ab515ce29d49..95c0795c496a6 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -659,7 +659,7 @@ describe("resolveShortcutCommand", () => { ); }); - it('leaves Ctrl+layout "+" to zoom, except on the number row or with Alt', () => { + it('matches Ctrl+layout "+" as itself unless Shift, Alt or the number row is involved', () => { assert.isNull( resolveShortcutCommand( event({ key: "+", code: "BracketRight", ctrlKey: true }), @@ -696,6 +696,14 @@ describe("resolveShortcutCommand", () => { ), "terminal.toggle", ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "+", code: "Equal", ctrlKey: true, shiftKey: true }), + compile([{ shortcut: modShortcut("=", { shiftKey: true }), command: "terminal.toggle" }]), + { platform: "Linux" }, + ), + "terminal.toggle", + ); }); it("does not let a punctuation position shadow a Latin layout key", () => {