diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f788263fac71..bd55691ed566 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -62,6 +62,9 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, legacySidebarScale: 100, + largerScrollbarsEnabled: true, + scrollbarWidth: 10, + scrollbarMargin: 4, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5730d1fcb606..f002dcb8de58 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1918,7 +1918,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ > {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */} -
+
{targetPath}
diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx index a7ef6bc7d5cb..c7b52943addf 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx @@ -27,8 +27,8 @@ describe("ComposerPendingApprovalPanel", () => { expect(markup).toContain("max-h-20"); expect(markup).toContain("overflow-auto"); expect(markup).toContain("whitespace-pre"); - expect(markup).toContain("[scrollbar-width:thin]"); - expect(markup).toContain("[&::-webkit-scrollbar]:h-1.5"); + expect(markup).toContain("[scrollbar-width:var(--app-native-scrollbar-width)]"); + expect(markup).toContain("[&::-webkit-scrollbar]:h-[var(--app-compact-scrollbar-height)]"); expect(markup).not.toContain("truncate"); expect(markup).not.toContain("line-clamp"); expect(markup).toContain("min-w-0"); diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx index deb5ba54c53c..8dc851bac66f 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx @@ -43,7 +43,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova ) : null} diff --git a/apps/web/src/components/settings/LastCodeSettings.tsx b/apps/web/src/components/settings/LastCodeSettings.tsx index edb4ccfe70fb..9eba085335f1 100644 --- a/apps/web/src/components/settings/LastCodeSettings.tsx +++ b/apps/web/src/components/settings/LastCodeSettings.tsx @@ -5,9 +5,15 @@ import { } from "@t3tools/contracts"; import { DEFAULT_LEGACY_SIDEBAR_SCALE, + DEFAULT_SCROLLBAR_MARGIN, + DEFAULT_SCROLLBAR_WIDTH, LEGACY_SIDEBAR_SCALE_REFERENCE, MAX_LEGACY_SIDEBAR_SCALE, + MAX_SCROLLBAR_MARGIN, + MAX_SCROLLBAR_WIDTH, MIN_LEGACY_SIDEBAR_SCALE, + MIN_SCROLLBAR_MARGIN, + MIN_SCROLLBAR_WIDTH, } from "@t3tools/contracts/settings"; import { useAtomValue } from "@effect/atom-react"; import { DownloadIcon, MoonStarIcon, PaletteIcon, ServerIcon } from "lucide-react"; @@ -46,6 +52,56 @@ const WORKTREE_INDICATOR_PREVIEW_THREAD = { worktreePath: "/example/worktrees/example", }; +function PixelSlider({ + id, + label, + min, + max, + value, + onChange, +}: { + id: string; + label: string; + min: number; + max: number; + value: number; + onChange: (value: number) => void; +}) { + const ratio = (value - min) / (max - min); + const sliderStyle = { + "--settings-slider-progress": `${ratio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - ratio}rem`, + } as CSSProperties; + + return ( +
+ + {value}px + + { + const nextValue = Number(event.currentTarget.value); + if (Number.isInteger(nextValue) && nextValue >= min && nextValue <= max) { + onChange(nextValue); + } + }} + step={1} + style={sliderStyle} + type="range" + value={value} + /> +
+ ); +} + export function LastCodeSettingsPanel() { const updateState = useDesktopUpdateState(); const clientSettings = usePrimarySettings(); @@ -182,6 +238,72 @@ export function LastCodeSettingsPanel() { /> }> + + updateClientSettings({ largerScrollbarsEnabled: Boolean(checked) }) + } + aria-label="Larger scrollbars" + /> + } + /> + {clientSettings.largerScrollbarsEnabled ? ( + <> + + updateClientSettings({ scrollbarWidth: DEFAULT_SCROLLBAR_WIDTH }) + } + /> + ) : null + } + control={ + updateClientSettings({ scrollbarWidth })} + /> + } + /> + + updateClientSettings({ scrollbarMargin: DEFAULT_SCROLLBAR_MARGIN }) + } + /> + ) : null + } + control={ + updateClientSettings({ scrollbarMargin })} + /> + } + /> + + ) : null} { }); }); + it("routes scrollbar sizing to LastCode settings", () => { + expect(searchSettings("scrollbar margin")[0]).toMatchObject({ + id: "larger-scrollbars", + to: "/settings/lastcode", + }); + }); + it("routes project icon rounding to LastCode settings", () => { expect(searchSettings("rounded project icons")[0]).toMatchObject({ id: "rounded-project-icons", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 13e2a7ddff97..805217d7fad7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -493,6 +493,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Show and install local nightlies", to: "/settings/lastcode", }, + { + id: "larger-scrollbars", + title: "Larger scrollbars", + to: "/settings/lastcode", + searchTerms: ["width margin resize handle pane drag thumb"], + }, { id: "scale-legacy-sidebar", title: "Scale legacy sidebar", diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index bfc10825b460..4dad6bd3c084 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -73,7 +73,7 @@ function ScrollBar({ return ( + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} @@ -204,6 +206,30 @@ function ProjectIconAppearanceSync() { return null; } +function ScrollbarAppearanceSync() { + const largerScrollbarsEnabled = useClientSettings((settings) => settings.largerScrollbarsEnabled); + const scrollbarWidth = useClientSettings((settings) => settings.scrollbarWidth); + const scrollbarMargin = useClientSettings((settings) => settings.scrollbarMargin); + + useEffect(() => { + const root = document.documentElement; + applyScrollbarAppearance(root, { + enabled: largerScrollbarsEnabled, + width: scrollbarWidth, + margin: scrollbarMargin, + }); + return () => { + applyScrollbarAppearance(root, { + enabled: false, + width: scrollbarWidth, + margin: scrollbarMargin, + }); + }; + }, [largerScrollbarsEnabled, scrollbarMargin, scrollbarWidth]); + + return null; +} + function FontAppearanceSync() { const fontFamilySans = useClientSettings((settings) => settings.fontFamilySans); const fontFamilyCode = useClientSettings((settings) => settings.fontFamilyCode); diff --git a/apps/web/src/scrollbarAppearance.test.ts b/apps/web/src/scrollbarAppearance.test.ts new file mode 100644 index 000000000000..08288492acad --- /dev/null +++ b/apps/web/src/scrollbarAppearance.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyScrollbarAppearance } from "./scrollbarAppearance"; + +describe("applyScrollbarAppearance", () => { + it("sets the thumb, margin, and total hit-lane sizes", () => { + const { root, setProperty, toggleAttribute } = makeRoot(); + + applyScrollbarAppearance(root, { enabled: true, width: 9, margin: 5 }); + + expect(toggleAttribute).toHaveBeenCalledWith("data-larger-scrollbars", true); + expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-width", "9px"); + expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-margin", "5px"); + expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-lane-width", "14px"); + expect(setProperty).toHaveBeenCalledWith("--app-native-scrollbar-margin", "5px"); + expect(setProperty).toHaveBeenCalledWith("--app-native-scrollbar-width", "auto"); + expect(setProperty).toHaveBeenCalledWith("--app-compact-scrollbar-height", "14px"); + expect(setProperty).toHaveBeenCalledWith("--app-code-scrollbar-height", "14px"); + expect(setProperty).toHaveBeenCalledWith("--app-scrollbar-thumb-inset", "0px"); + }); + + it("restores the stylesheet defaults when disabled", () => { + const { root, removeProperty, toggleAttribute } = makeRoot(); + + applyScrollbarAppearance(root, { enabled: false, width: 12, margin: 6 }); + + expect(toggleAttribute).toHaveBeenCalledWith("data-larger-scrollbars", false); + expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-width"); + expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-margin"); + expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-lane-width"); + expect(removeProperty).toHaveBeenCalledWith("--app-native-scrollbar-margin"); + expect(removeProperty).toHaveBeenCalledWith("--app-native-scrollbar-width"); + expect(removeProperty).toHaveBeenCalledWith("--app-compact-scrollbar-height"); + expect(removeProperty).toHaveBeenCalledWith("--app-code-scrollbar-height"); + expect(removeProperty).toHaveBeenCalledWith("--app-scrollbar-thumb-inset"); + }); +}); + +function makeRoot() { + const setProperty = vi.fn(); + const removeProperty = vi.fn(); + const toggleAttribute = vi.fn(); + return { + root: { + style: { setProperty, removeProperty }, + toggleAttribute, + } as unknown as HTMLElement, + setProperty, + removeProperty, + toggleAttribute, + }; +} diff --git a/apps/web/src/scrollbarAppearance.ts b/apps/web/src/scrollbarAppearance.ts new file mode 100644 index 000000000000..31c98da0b88a --- /dev/null +++ b/apps/web/src/scrollbarAppearance.ts @@ -0,0 +1,33 @@ +import type { ScrollbarMargin, ScrollbarWidth } from "@t3tools/contracts/settings"; + +export function applyScrollbarAppearance( + root: HTMLElement, + options: { + enabled: boolean; + width: ScrollbarWidth; + margin: ScrollbarMargin; + }, +): void { + root.toggleAttribute("data-larger-scrollbars", options.enabled); + + if (!options.enabled) { + root.style.removeProperty("--app-scrollbar-width"); + root.style.removeProperty("--app-scrollbar-margin"); + root.style.removeProperty("--app-scrollbar-lane-width"); + root.style.removeProperty("--app-native-scrollbar-margin"); + root.style.removeProperty("--app-native-scrollbar-width"); + root.style.removeProperty("--app-compact-scrollbar-height"); + root.style.removeProperty("--app-code-scrollbar-height"); + root.style.removeProperty("--app-scrollbar-thumb-inset"); + return; + } + + root.style.setProperty("--app-scrollbar-width", `${options.width}px`); + root.style.setProperty("--app-scrollbar-margin", `${options.margin}px`); + root.style.setProperty("--app-scrollbar-lane-width", `${options.width + options.margin}px`); + root.style.setProperty("--app-native-scrollbar-margin", `${options.margin}px`); + root.style.setProperty("--app-native-scrollbar-width", "auto"); + root.style.setProperty("--app-compact-scrollbar-height", `${options.width + options.margin}px`); + root.style.setProperty("--app-code-scrollbar-height", `${options.width + options.margin}px`); + root.style.setProperty("--app-scrollbar-thumb-inset", "0px"); +} diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 84e59e10f509..05fee03fbd06 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -674,7 +674,7 @@ export class GhosttyTerminalSurface { const scrollbar = document.createElement("div"); scrollbar.className = - "group absolute top-1 right-px bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none"; + "group absolute top-1 right-[var(--app-scrollbar-margin)] bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none"; scrollbar.setAttribute("role", "scrollbar"); scrollbar.setAttribute("aria-label", "Terminal scrollback"); scrollbar.setAttribute("aria-orientation", "vertical"); @@ -682,7 +682,7 @@ export class GhosttyTerminalSurface { scrollbar.hidden = true; const scrollbarThumb = document.createElement("div"); scrollbarThumb.className = - "absolute inset-x-px top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]"; + "absolute inset-x-[var(--app-scrollbar-thumb-inset)] top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]"; scrollbar.append(scrollbarThumb); mount.replaceChildren(canvas, input, scrollbar); diff --git a/docs/README.md b/docs/README.md index 9d1e3156ee53..22294cfa3a01 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [LastCode appearance preferences](./user/lastcode-appearance.md) - [Resumable Project Actions in LastCode](./user/resumable-project-actions.md) - [Use an agent to add resumable Project Actions](./user/resumable-project-actions-for-agents.md) - [Review usage](./user/usage.md) diff --git a/docs/user/lastcode-appearance.md b/docs/user/lastcode-appearance.md new file mode 100644 index 000000000000..013b29d17180 --- /dev/null +++ b/docs/user/lastcode-appearance.md @@ -0,0 +1,19 @@ +# LastCode appearance preferences + +## Make scrollbars easier to grab + +Open **Settings → LastCode → Appearance** and enable **Larger scrollbars**. Two controls appear: + +- **Scrollbar width** sets the visible thumb from 1 px through 12 px in one-pixel increments. +- **Scrollbar margin** adds 0 px through 6 px of clear space between the thumb and the pane edge. + +The default larger-scrollbar profile uses a 10 px thumb and a 4 px margin. That margin keeps the +thumb clear of the resize handle between adjacent panes. Changes apply immediately to native app +scrollbars, styled scroll areas, and terminal scrollback, and are stored in the current LastCode +profile. Turning the setting off restores the standard scrollbar appearance without discarding the +chosen width and margin. + +LastCode desktop and Chromium-based web browsers apply the exact pixel values to native +scrollbars. Firefox exposes only system scrollbar sizes: enabling the setting selects its larger +system scrollbar, while the width and margin sliders continue to apply exactly to LastCode's +styled scroll areas. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index fd00e068bf21..306329928f88 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -76,6 +76,26 @@ describe("ClientSettings proactive panels", () => { }); }); +describe("ClientSettings larger scrollbars", () => { + it("is opt-in with defaults that clear the pane resize target", () => { + const settings = decodeClientSettings({}); + + expect(settings.largerScrollbarsEnabled).toBe(false); + expect(settings.scrollbarWidth).toBe(10); + expect(settings.scrollbarMargin).toBe(4); + }); + + it.each([ + ["scrollbarWidth", 1, 12], + ["scrollbarMargin", 0, 6], + ] as const)("accepts the inclusive %s range", (key, minimum, maximum) => { + expect(decodeClientSettingsPatch({ [key]: minimum })).toEqual({ [key]: minimum }); + expect(decodeClientSettingsPatch({ [key]: maximum })).toEqual({ [key]: maximum }); + expect(() => decodeClientSettingsPatch({ [key]: minimum - 1 })).toThrow(); + expect(() => decodeClientSettingsPatch({ [key]: maximum + 1 })).toThrow(); + }); +}); + describe("ClientSettings quit confirmation", () => { it("defaults to hold", () => { expect(decodeClientSettings({}).confirmQuit).toBe("hold"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b93bc4743501..4dd899a371aa 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -104,6 +104,22 @@ export const AppearanceContrast = Schema.Int.check( ); export type AppearanceContrast = typeof AppearanceContrast.Type; export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; +export const MIN_SCROLLBAR_WIDTH = 1; +export const MAX_SCROLLBAR_WIDTH = 12; +export const ScrollbarWidth = Schema.Int.check( + Schema.isBetween({ minimum: MIN_SCROLLBAR_WIDTH, maximum: MAX_SCROLLBAR_WIDTH }), +); +export type ScrollbarWidth = typeof ScrollbarWidth.Type; +export const DEFAULT_SCROLLBAR_WIDTH: ScrollbarWidth = 10; +export const MIN_SCROLLBAR_MARGIN = 0; +export const MAX_SCROLLBAR_MARGIN = 6; +export const ScrollbarMargin = Schema.Int.check( + Schema.isBetween({ minimum: MIN_SCROLLBAR_MARGIN, maximum: MAX_SCROLLBAR_MARGIN }), +); +export type ScrollbarMargin = typeof ScrollbarMargin.Type; +// The inline preview resize handle reaches four pixels into its neighboring +// pane, so this default keeps the whole scrollbar thumb clear of that target. +export const DEFAULT_SCROLLBAR_MARGIN: ScrollbarMargin = 4; export const MIN_PANEL_ANIMATION_DURATION_MS = 0; export const MAX_PANEL_ANIMATION_DURATION_MS = 400; export const PanelAnimationDurationMs = Schema.Int.check( @@ -357,6 +373,13 @@ export const ClientSettingsSchema = Schema.Struct({ legacySidebarScale: LegacySidebarScale.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_LEGACY_SIDEBAR_SCALE)), ), + largerScrollbarsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + scrollbarWidth: ScrollbarWidth.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SCROLLBAR_WIDTH)), + ), + scrollbarMargin: ScrollbarMargin.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SCROLLBAR_MARGIN)), + ), roundedProjectIcons: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showLocalEnvironmentIcon: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( @@ -1205,6 +1228,9 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarScale: Schema.optionalKey(LegacySidebarScale), + largerScrollbarsEnabled: Schema.optionalKey(Schema.Boolean), + scrollbarWidth: Schema.optionalKey(ScrollbarWidth), + scrollbarMargin: Schema.optionalKey(ScrollbarMargin), roundedProjectIcons: Schema.optionalKey(Schema.Boolean), showLocalEnvironmentIcon: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode),