diff --git a/apps/app/src/app-selection.test.ts b/apps/app/src/app-selection.test.ts new file mode 100644 index 0000000000..1bf71559ca --- /dev/null +++ b/apps/app/src/app-selection.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const css = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "app.css"), + "utf8", +); +const compactCss = css.replace(/\s+/g, " "); + +describe("app text selection policy", () => { + it("disables native text selection across the app shell and its portals", () => { + expect(css).toMatch(/body\.bb-app-shell\s*\{\s*user-select:\s*none;\s*\}/); + }); + + it("preserves native selection in editable controls", () => { + expect(compactCss).toContain( + 'body.bb-app-shell :where(input, textarea, [contenteditable]:not([contenteditable="false"])) { user-select: text; }', + ); + }); + + it("keeps nested controls out of selectable content", () => { + expect(compactCss).toContain( + 'body.bb-app-shell .select-text :where( button, label, select, summary, [role="button"], [role="checkbox"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="radio"], [role="switch"], [role="tab"] ):not(.select-text) { user-select: none; }', + ); + }); +}); diff --git a/apps/app/src/app.css b/apps/app/src/app.css index cbd8480927..c415b551a4 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -39,6 +39,46 @@ height: 100%; } + /* + * App chrome is interface, not document content. Keep drag gestures + * and Select All from sweeping through navigation, toolbars, and empty shell + * space; content components opt back in with the `select-text` utility. + */ + body.bb-app-shell { + user-select: none; + } + + /* Chromium inherits `user-select` into form controls despite the property + * being non-inherited by specification. Restore native selection explicitly + * for every editing surface inside the shell. */ + body.bb-app-shell + :where(input, textarea, [contenteditable]:not([contenteditable="false"])) { + user-select: text; + } + + /* A selectable content boundary may contain controls (for example, a + * Markdown action or a diff loader). Keep their labels out of document + * selections unless the control deliberately opts itself back in. */ + body.bb-app-shell + .select-text + :where( + button, + label, + select, + summary, + [role="button"], + [role="checkbox"], + [role="menuitem"], + [role="menuitemcheckbox"], + [role="menuitemradio"], + [role="option"], + [role="radio"], + [role="switch"], + [role="tab"] + ):not(.select-text) { + user-select: none; + } + @media (display-mode: standalone) { :root { --bb-shell-height: 100lvh; diff --git a/apps/app/src/components/AppErrorBoundary.test.tsx b/apps/app/src/components/AppErrorBoundary.test.tsx index c10230cede..0d4f690fd1 100644 --- a/apps/app/src/components/AppErrorBoundary.test.tsx +++ b/apps/app/src/components/AppErrorBoundary.test.tsx @@ -50,6 +50,7 @@ describe("AppErrorBoundary", () => { expect(container.querySelector("button")?.textContent).toBe("Reload bb"); // The message stays reachable so a report can carry it. expect(container.textContent).toContain("render exploded"); + expect(container.querySelector("pre")?.classList).toContain("select-text"); dispose(); }); diff --git a/apps/app/src/components/AppErrorBoundary.tsx b/apps/app/src/components/AppErrorBoundary.tsx index 0d92ba9610..0f8672447f 100644 --- a/apps/app/src/components/AppErrorBoundary.tsx +++ b/apps/app/src/components/AppErrorBoundary.tsx @@ -57,7 +57,7 @@ export class AppErrorBoundary extends Component< Error details -
+            
               {error.stack ?? error.message}
             
diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx index cf1e121f93..ddf8c447ca 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx @@ -85,6 +85,7 @@ describe("AddMachineDialog", () => { ); const command = await screen.findByText(/--join-code jc_test123/); + expect(command.classList.contains("select-text")).toBe(true); expect(sdk.plugins.callRpc).toHaveBeenCalledWith( expect.objectContaining({ pluginId: "connect", diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx index 2aaed3a8db..626157daeb 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx @@ -212,7 +212,7 @@ function AddMachineDialogContent({ ) : command !== null ? (
-
+            
               {command}
             
diff --git a/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx b/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx index e01233be5d..9890f1ab5b 100644 --- a/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx +++ b/apps/app/src/components/dialogs/ProviderCliInstallLogDialog.tsx @@ -74,7 +74,7 @@ export function ProviderCliInstallLogDialogContent({ className="absolute right-2 top-2 z-10 opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100" iconClassName="size-3" /> -
+        
           {state.log}
         
diff --git a/apps/app/src/components/git-diff/GitDiffCardBody.tsx b/apps/app/src/components/git-diff/GitDiffCardBody.tsx index 0de3430501..d11eaeef4d 100644 --- a/apps/app/src/components/git-diff/GitDiffCardBody.tsx +++ b/apps/app/src/components/git-diff/GitDiffCardBody.tsx @@ -1272,7 +1272,7 @@ export function GitDiffCardBody({ return (
{shouldGateDeletedDiff ? ( diff --git a/apps/app/src/components/onboarding/OnboardingFlow.tsx b/apps/app/src/components/onboarding/OnboardingFlow.tsx index ee3140bdc8..fcc61057d1 100644 --- a/apps/app/src/components/onboarding/OnboardingFlow.tsx +++ b/apps/app/src/components/onboarding/OnboardingFlow.tsx @@ -234,7 +234,7 @@ function AgentRows({ Run this in a terminal, then come back:

- + {agent.loginCommand}