diff --git a/apps/web/src/components/ProjectScriptsControl.test.tsx b/apps/web/src/components/ProjectScriptsControl.test.tsx new file mode 100644 index 00000000000..d9f3e7e69f0 --- /dev/null +++ b/apps/web/src/components/ProjectScriptsControl.test.tsx @@ -0,0 +1,65 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import ProjectScriptsControl from "./ProjectScriptsControl"; + +const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const PRIMARY_SCRIPT: ProjectScript = { + id: "dev", + name: "Dev", + command: "vp dev", + icon: "play", + runOnWorktreeCreate: false, +}; + +function renderControl(scripts: ReadonlyArray) { + return renderToStaticMarkup( + {}} + onAddScript={async () => undefined as never} + onUpdateScript={async () => undefined as never} + onDeleteScript={async () => undefined as never} + />, + ); +} + +function buttonTag(html: string, ariaLabel: string) { + return html.match(new RegExp(`]*aria-label="${ariaLabel}"[^>]*>`))?.[0]; +} + +function expectResponsiveXsControl(markup: string | undefined) { + expect(markup).toBeDefined(); + expect(markup).toContain("h-7"); + expect(markup).toContain("gap-1"); + expect(markup).toContain("text-sm"); + expect(markup).toContain("sm:h-6"); + expect(markup).toContain("sm:text-xs"); + expect(markup).toContain("w-7"); + expect(markup).toContain("px-0"); + expect(markup).toContain("sm:w-6"); + expect(markup).toContain("@3xl/header-actions:w-auto!"); + expect(markup).toContain("@3xl/header-actions:px-[calc(--spacing(2)-1px)]"); +} + +describe("ProjectScriptsControl compact controls", () => { + it("keeps the primary Run control compact and expands it with its label", () => { + const html = renderControl([PRIMARY_SCRIPT]); + + expectResponsiveXsControl(buttonTag(html, "Run Dev")); + expect(html).toContain( + 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', + ); + }); + + it("keeps the standalone Add control compact and expands it with its label", () => { + const html = renderControl([]); + + expectResponsiveXsControl(buttonTag(html, "Add action")); + expect(html).toContain( + 'class="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"', + ); + }); +}); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a4e59edbe8..3a1d7115098 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -341,6 +341,7 @@ export default function ProjectScriptsControl({ , + ); + + expect(html).toContain("rounded-[var(--control-radius)]"); + expect(html).toContain("[--control-icon-color:var(--muted-foreground)]"); + expect(html).toContain("text-[var(--control-icon-color)]"); + expect(html).not.toContain("opacity-80"); + }); + + it("keeps compact icon buttons square at every breakpoint", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("size-7"); + expect(html).toContain("sm:size-6"); + }); +}); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index f6d09f7799a..778574ba010 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -8,7 +8,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; const buttonVariants = cva( - "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "[--control-icon-color:currentColor] [&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-[var(--control-radius)] border font-medium text-base outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 sm:text-sm [&_svg:not([class*='text-'])]:text-[var(--control-icon-color)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { defaultVariants: { size: "default", @@ -23,11 +23,11 @@ const buttonVariants = cva( "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", "icon-xs": - "size-7 rounded-md before:rounded-[calc(var(--radius-md)-1px)] sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5", + "size-7 sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5", lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9", sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7", xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", - xs: "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-sm before:rounded-[calc(var(--radius-md)-1px)] sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", + xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5", }, variant: { default: @@ -37,10 +37,10 @@ const buttonVariants = cva( "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", ghost: - "border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [&_svg:not([class*='text-'])]:text-muted-foreground", + "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: - "border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64 [&_svg:not([class*='text-'])]:text-muted-foreground", + "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", secondary: "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", }, diff --git a/apps/web/src/components/ui/command.test.tsx b/apps/web/src/components/ui/command.test.tsx new file mode 100644 index 00000000000..bd03b2712d3 --- /dev/null +++ b/apps/web/src/components/ui/command.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { Command, CommandFooter, CommandInput } from "./command"; + +describe("command compact geometry", () => { + it("keeps shell selectors on the wrapper and direct-input padding on AutocompleteInput", () => { + const html = renderToStaticMarkup( + + + , + ); + const shellClass = html.match(/class="([^"]*px-\[var\(--command-shell-inset\)[^"]*)"/)?.[1]; + const inputClass = html.match(/class="([^"]*has-focus-visible:ring-0[^"]*)"/)?.[1]; + + expect(shellClass).toContain( + "[&_[data-slot=autocomplete-start-addon]]:ps-[calc(var(--command-shell-inset)+0.0625rem)]", + ); + expect(shellClass).not.toContain("sm:*:data-[slot=autocomplete-input]"); + expect(inputClass).toContain( + "sm:*:data-[slot=autocomplete-input]:ps-[calc(var(--command-shell-inset)+1.5rem)]!", + ); + }); + + it("uses the semantic footer inset without changing compact vertical padding", () => { + const html = renderToStaticMarkup(Shortcuts); + + expect(html).toContain("px-[var(--command-content-inset)]"); + expect(html).toContain("py-2.5"); + }); +}); diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 53999c20f0d..0a713b769ec 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -103,11 +103,16 @@ function CommandInput({ wrapperClassName?: string | undefined; }) { return ( -
+
) { return (
{ expect(html).toContain('data-slot="sidebar-menu-button"'); expect(html).toContain("h-8"); - expect(html).toContain("rounded-md"); - expect(html).toContain("px-2"); + expect(html).toContain("rounded-[var(--control-radius)]"); + expect(html).toContain("px-[var(--sidebar-row-content-inset)]"); expect(html).toContain("py-1.5"); expect(html).toContain("]:size-4"); expect(html).toContain("]:shrink-0"); expect(html).toContain("cursor-pointer"); + expect(html).toContain("gap-[var(--sidebar-control-gap)]"); + expect(html).toContain("text-[var(--sidebar-icon-color)]"); + expect(html).not.toContain("[&>svg]:opacity-60"); }); it("applies the shared default treatment to icon-only menu buttons", () => { diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index f9a00437825..ddc7310430f 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -712,7 +712,10 @@ function SidebarContent({ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { return (
) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-2 overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground [&>svg]:opacity-60 hover:[&>svg]:text-sidebar-foreground hover:[&>svg]:opacity-100 active:[&>svg]:text-sidebar-foreground active:[&>svg]:opacity-100 data-[active=true]:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:opacity-100", + "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", { defaultVariants: { size: "default", @@ -800,8 +803,9 @@ const sidebarMenuButtonVariants = cva( }, variants: { size: { - default: "h-8 rounded-md px-2.5 py-1.5 text-sm", - icon: "size-8 justify-center rounded-md p-0", + default: + "h-8 rounded-[var(--control-radius)] px-[var(--sidebar-row-content-inset)] py-1.5 text-sm", + icon: "size-8 justify-center rounded-[var(--control-radius)] p-0", lg: "h-12 rounded-lg p-2 text-sm group-data-[collapsible=icon]:p-0!", sm: "h-7 rounded-lg p-2 text-xs", }, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 296944e113a..ccc3d5b3dc2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -79,6 +79,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --app-scrollbar-width: 6px; --app-scrollbar-thumb: rgb(217 217 217); --app-scrollbar-thumb-hover: rgb(191 191 191); + /* + * Compact UI geometry. Keep these values semantic so sidebar, palette, + * tooltip, and toolbar controls cannot quietly drift apart. + */ + --control-radius: 0.5rem; + --sidebar-content-inset: 0.5rem; + --sidebar-control-gap: 0.5rem; + --sidebar-icon-color: color-mix(in srgb, var(--sidebar-muted-foreground) 60%, var(--sidebar)); + --sidebar-row-content-inset: 0.625rem; + --command-shell-inset: 0.5rem; + --command-content-inset: 1rem; + --floating-content-inset: 0.75rem; --glass-blur: 12px; --glass-opacity: 80%; --glass-saturation: 1.14;