diff --git a/apps/design-system/components/code-block-wrapper.tsx b/apps/design-system/components/code-block-wrapper.tsx index c8b1e43dc14..dbafd40241d 100644 --- a/apps/design-system/components/code-block-wrapper.tsx +++ b/apps/design-system/components/code-block-wrapper.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' +import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, FloatingPlate } from 'ui' interface CodeBlockProps extends React.HTMLAttributes { expandButtonTitle?: string @@ -34,11 +34,13 @@ export function CodeBlockWrapper({ isOpened ? 'inset-x-0 bottom-0 h-12' : 'inset-0' )} > - - - + + + + + diff --git a/apps/design-system/components/component-preview.tsx b/apps/design-system/components/component-preview.tsx index 4e4913d29bc..1381d131a38 100644 --- a/apps/design-system/components/component-preview.tsx +++ b/apps/design-system/components/component-preview.tsx @@ -2,7 +2,7 @@ import { ChevronRight, Expand } from 'lucide-react' import * as React from 'react' -import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' +import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, FloatingPlate } from 'ui' import { Index } from '@/__registry__' import { useConfig } from '@/hooks/use-config' @@ -122,13 +122,15 @@ export function ComponentPreview({ > {Code}
- + + +
diff --git a/apps/design-system/content/docs/components/button.mdx b/apps/design-system/content/docs/components/button.mdx index 3af2a64e17f..d4535fc407f 100644 --- a/apps/design-system/content/docs/components/button.mdx +++ b/apps/design-system/content/docs/components/button.mdx @@ -133,6 +133,40 @@ Ensure the middle border is shared rather than doubled-up. Do not use `border-l- Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions when `layout="responsive"`: also use `flex w-full @lg:w-auto` with `flex-1 @lg:flex-none` on the primary action. +## Default fill and floating buttons + +The default variant is meant to read as raised chrome on whatever surface it sits on. + +- **Light:** opaque `bg-card` (a solid elevated plate). Opaque on purpose so the button can cover busy content underneath. +- **Dark:** translucent `bg-muted` (a foreground wash). That adapts to the local surface, but content can show through the fill. + +Hover: light uses `hover:bg-muted`; dark uses `dark:hover:bg-accent`. + +### Floating over content + +If a default button is absolutely or sticky-positioned over code, tables, maps, or other busy UI, wrap it in [`FloatingPlate`](#floating-plate) so nothing bleeds through: + + + +Keep positioning, z-index, and hover/focus reveal on the plate's `className`. Use `rounded="full"` when the child is a pill. For clusters (split toggles, parallel actions), wrap the group once. + +Do not override the button fill with `bg-popover` at the callsite. The plate owns occlusion; the button stays a normal default control. + +## Floating plate + +`FloatingPlate` is an opaque `bg-popover` shell for floating default buttons (and small clusters). + +```tsx +import { Button, FloatingPlate } from 'ui' +``` + +| Prop | Default | Notes | +| ----------- | ------- | --------------------------------------------------------------- | +| `rounded` | `lg` | `md`, `lg`, or `full`. Match or exceed the child button radius. | +| `className` | — | Positioning, opacity, gaps for clusters. | + +See [Floating over content](#floating-over-content) for when to use it. + ## Accessibility [Keyboard focus](../accessibility#focus-management) is automatically handled: diff --git a/apps/design-system/registry/default/example/button-floating-plate.tsx b/apps/design-system/registry/default/example/button-floating-plate.tsx new file mode 100644 index 00000000000..21772cd988f --- /dev/null +++ b/apps/design-system/registry/default/example/button-floating-plate.tsx @@ -0,0 +1,21 @@ +import { Copy } from 'lucide-react' +import { Button, FloatingPlate } from 'ui' + +export default function ButtonFloatingPlate() { + return ( +
+
+        {`select *
+from projects
+where status = 'ACTIVE_HEALTHY'
+order by created_at desc
+limit 20;`}
+      
+ + + +
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 349b1b3d191..e1baf7235da 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -199,6 +199,12 @@ export const examples: Registry = [ registryDependencies: ['button'], files: ['example/button-default.tsx'], }, + { + name: 'button-floating-plate', + type: 'components:example', + registryDependencies: ['button'], + files: ['example/button-floating-plate.tsx'], + }, { name: 'button-warning', type: 'components:example', diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 3d60d0c78b0..1c06cc8b765 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -75,7 +75,7 @@ const TopNavBar: FC = () => { title="Menu dropdown button" className={cn( buttonVariants({ variant: 'default' }), - 'flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] data-open:bg-overlay-hover/30' + 'flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] px-0 data-open:bg-overlay-hover/30' )} onClick={() => setMobileMenuOpen(!mobileMenuOpen)} > diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx index ea6c1873de0..fd54b92fdbc 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx @@ -71,7 +71,7 @@ const TopNavDropdown = () => { title="Menu dropdown button" className={cn( buttonVariants({ variant: 'default' }), - 'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] data-open:bg-overlay-hover/30 hover:border-strong data-open:border-stronger hover:bg-overlay-hover/50! bg-transparent' + 'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] px-0 data-open:bg-overlay-hover/30 hover:border-strong data-open:border-stronger hover:bg-overlay-hover/50! bg-transparent' )} > diff --git a/apps/docs/features/directives/Show.test.ts b/apps/docs/features/directives/Show.test.ts index 1a60f1bb086..fc7bfdbfb4c 100644 --- a/apps/docs/features/directives/Show.test.ts +++ b/apps/docs/features/directives/Show.test.ts @@ -1,8 +1,8 @@ +import { type Feature } from 'common' import { mdxToMarkdown } from 'mdast-util-mdx' import { toMarkdown } from 'mdast-util-to-markdown' import { afterEach, describe, expect, it, vi } from 'vitest' -import { type Feature } from 'common' import { showRemark } from './Show' import { fromDocsMarkdown } from './utils.server' diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx index 9c48dcf2a57..7b54e31d441 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx @@ -325,7 +325,7 @@ describe('ReplicationPipelineLayout', () => { renderLayout() const updateButton = await screen.findByRole('button', { name: 'Update available' }) - expect(updateButton).toHaveClass('bg-brand-400') + expect(updateButton).toHaveClass('bg-primary') await userEvent.click(updateButton) // The trigger button shares this name, so match the dialog's heading specifically expect(await screen.findByRole('heading', { name: 'Update available' })).toBeVisible() diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx index 69448573b80..0e9025e3fbe 100644 --- a/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx +++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsTable/QueryInsightsTable.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import DataGrid, { DataGridHandle, Row } from 'react-data-grid' -import { Button, cn, Tabs, TabsList, TabsTrigger } from 'ui' +import { Button, cn, FloatingPlate, Tabs, TabsList, TabsTrigger } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' @@ -422,13 +422,15 @@ export const QueryInsightsTable = ({ : 'opacity-0 translate-y-4 pointer-events-none', ].join(' ')} > - + + + {isLoading ? (
diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx index f7b96f5a4f7..664d5bb0612 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryDetail.tsx @@ -1,7 +1,7 @@ import { ChevronsUpDown, Lightbulb } from 'lucide-react' import dynamic from 'next/dynamic' import { useEffect, useState } from 'react' -import { Alert, AlertDescription, AlertTitle, Button, cn } from 'ui' +import { Alert, AlertDescription, AlertTitle, Button, cn, FloatingPlate } from 'ui' import { QueryPanelContainer, QueryPanelSection } from './QueryPanel' import { buildQueryExplanationPrompt } from './QueryPerformance.ai' @@ -92,6 +92,7 @@ export const QueryDetail = ({ selectedRow, onClickViewSuggestion, onClose }: Que />
- + + +
diff --git a/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx b/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx index 952213ee6dd..d62214a3fd6 100644 --- a/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/SqlMonacoBlock.tsx @@ -1,6 +1,6 @@ import { Check, Copy } from 'lucide-react' import { useMemo, useState } from 'react' -import { Button, cn, copyToClipboard } from 'ui' +import { Button, cn, copyToClipboard, FloatingPlate } from 'ui' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' @@ -36,7 +36,7 @@ export const SqlMonacoBlock = ({ value, wrapperClassName }: SqlMonacoBlockProps) options={{ padding: { top: 12, bottom: 12 } }} /> -
+ -
+
) } diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 9870641d046..11c57cebee4 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { partition } from 'lodash' import { Globe2, Loader2, Network } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { Button } from 'ui' +import { Button, FloatingPlate } from 'ui' import { DiagramFlow } from './DiagramFlow' import { SmoothstepEdge } from './Edge' @@ -177,7 +177,7 @@ const InstanceConfigurationUI = () => { {isSuccessReplicas && !isLoadingProject && ( <> {infrastructureReadReplicas && ( -
+ {isAws && (
)} -
+ )} {view === 'flow' ? ( { return ( <>
- + + +
diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx index e895170417c..d3d1c9c5ec6 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlowPanel.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react' import { Button, copyToClipboard, + FloatingPlate, ResizableHandle, ResizablePanel, Skeleton, @@ -246,18 +247,20 @@ export function ServiceFlowPanel({ )}
- + + +
, 'children'> & { @@ -60,14 +60,16 @@ export const ConversationScrollButton = ({ return ( !isAtBottom && ( - + + + ) ) } diff --git a/apps/studio/components/ui/DataGridResults/ResultCell.tsx b/apps/studio/components/ui/DataGridResults/ResultCell.tsx index 9abe2b1e8a0..03ced03814d 100644 --- a/apps/studio/components/ui/DataGridResults/ResultCell.tsx +++ b/apps/studio/components/ui/DataGridResults/ResultCell.tsx @@ -1,5 +1,5 @@ import { Expand } from 'lucide-react' -import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Button, FloatingPlate, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { formatCellValue, isLargeValue } from './DataGridResults.utils' import { NullValue } from '@/components/grid/components/common/NullValue' @@ -25,18 +25,20 @@ export const ResultCell = ({ column, value, onContextMenu, onExpand }: ResultCel {value === null ? : formatCellValue(value)} {showExpand && ( - - ) : ( <> - - - + + + + + Copy to clipboard )} diff --git a/apps/www/styles/globals.css b/apps/www/styles/globals.css index d7d730b0642..441b4c82e2b 100644 --- a/apps/www/styles/globals.css +++ b/apps/www/styles/globals.css @@ -46,6 +46,17 @@ } @theme { + /* Scale Tailwind's default radius tokens by 4/3 to match the shared Studio curve. */ + --radius: 0.333333rem; + --radius-xs: 0.166667rem; + --radius-sm: 0.333333rem; + --radius-md: 0.5rem; + --radius-lg: 0.666667rem; + --radius-xl: 1rem; + --radius-2xl: 1.333333rem; + --radius-3xl: 2rem; + --radius-4xl: 2.666667rem; + --color-purple-sos-100: hsl(293 80% 97%); --color-purple-sos-200: hsl(293 85% 91%); --color-purple-sos-300: hsl(293 90% 80%); diff --git a/packages/ui-patterns/src/AuthenticatedDropdownMenu/index.tsx b/packages/ui-patterns/src/AuthenticatedDropdownMenu/index.tsx index 447c282197b..3a4817d4086 100644 --- a/packages/ui-patterns/src/AuthenticatedDropdownMenu/index.tsx +++ b/packages/ui-patterns/src/AuthenticatedDropdownMenu/index.tsx @@ -53,7 +53,7 @@ export const AuthenticatedDropdownMenu = ({ user, menu, site }: Props) => { title="Menu dropdown button" className={cn( buttonVariants({ variant: 'default' }), - 'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30 hover:border-strong data-[state=open]:border-stronger hover:!bg-overlay-hover/50 bg-transparent', + 'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] px-0 data-[state=open]:bg-overlay-hover/30 hover:border-strong data-[state=open]:border-stronger hover:!bg-overlay-hover/50 bg-transparent', 'rounded-full overflow-hidden opacity-0 transition-opacity animate-fade-in' )} > diff --git a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx index ed2b57644e5..536f4b65fa9 100644 --- a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx +++ b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx @@ -28,7 +28,7 @@ import swift from 'react-syntax-highlighter/dist/cjs/languages/hljs/swift' import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript' import xml from 'react-syntax-highlighter/dist/cjs/languages/hljs/xml' import yaml from 'react-syntax-highlighter/dist/cjs/languages/hljs/yaml' -import { Button, cn, copyToClipboard } from 'ui' +import { Button, cn, copyToClipboard, FloatingPlate } from 'ui' import { monokaiCustomTheme } from './CodeBlock.utils' @@ -266,12 +266,12 @@ export const CodeBlock = ({ {codeValue} {!hideCopy && (value || children) && className ? ( -
-
+ ) : null} ) : ( diff --git a/packages/ui-patterns/src/Row/index.tsx b/packages/ui-patterns/src/Row/index.tsx index 7069d9302d0..3f2ef226e98 100644 --- a/packages/ui-patterns/src/Row/index.tsx +++ b/packages/ui-patterns/src/Row/index.tsx @@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react' import type React from 'react' import type { ReactNode } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' -import { Button, cn } from 'ui' +import { Button, cn, FloatingPlate } from 'ui' import { useMeasuredWidth } from './Row.utils' @@ -146,23 +146,27 @@ export const Row = forwardRef(function Row( return (
{showArrows && canScrollLeft && ( - + + + )} {showArrows && canScrollRight && hasContentToScroll && ( - + + + )}
{showCopy && ( -
+ -
+ )}
) diff --git a/packages/ui-patterns/src/SkipToContent/SkipToContent.test.tsx b/packages/ui-patterns/src/SkipToContent/SkipToContent.test.tsx index 9c5082795d5..0f4ff2b68e1 100644 --- a/packages/ui-patterns/src/SkipToContent/SkipToContent.test.tsx +++ b/packages/ui-patterns/src/SkipToContent/SkipToContent.test.tsx @@ -34,7 +34,8 @@ describe('SkipToContent', () => { render() const link = screen.getByRole('link', { name: 'Skip to content' }) - expect(link.className).toContain('hover:bg-popover') + expect(link.className).toContain('hover:bg-muted') + expect(link.className).toContain('dark:hover:bg-accent') expect(link.className).not.toContain('bg-surface-300') expect(link.className).not.toContain('hover:bg-secondary') }) diff --git a/packages/ui-patterns/src/SkipToContent/SkipToContent.tsx b/packages/ui-patterns/src/SkipToContent/SkipToContent.tsx index 654f9cb1ce7..c7c19527f5f 100644 --- a/packages/ui-patterns/src/SkipToContent/SkipToContent.tsx +++ b/packages/ui-patterns/src/SkipToContent/SkipToContent.tsx @@ -1,5 +1,5 @@ import { type ReactNode } from 'react' -import { Button, cn } from 'ui' +import { Button, cn, FloatingPlate } from 'ui' export interface SkipToContentProps { /** Hash href to the main content landmark, e.g. `#main`. */ @@ -17,9 +17,8 @@ export interface SkipToContentProps { */ function SkipToContent({ href, children = 'Skip to content', className }: SkipToContentProps) { return ( - + ) } diff --git a/packages/ui/build/css/source/semantic.css b/packages/ui/build/css/source/semantic.css index fd148cce386..4542dbb13c9 100644 --- a/packages/ui/build/css/source/semantic.css +++ b/packages/ui/build/css/source/semantic.css @@ -138,7 +138,7 @@ */ --expressive-chroma: 0.14; --warning-lightness: 0.8; - --destructive-lightness: 0.75; + --destructive-lightness: 0.55; --info-lightness: 0.7; /* shadcn semantic pairs */ @@ -150,7 +150,7 @@ ); /* Inverse of --foreground: canvas-coloured text for use on inverted fills (bg-foreground, bg-black / dark:bg-white, etc). Not the same as - --primary-foreground, which stays dark for the vivid primary fill. */ + --primary-foreground, which stays light for the dark primary fill. */ --foreground-contrast: oklch(var(--surface) calc(var(--chroma) * 0.55) var(--surface-hue)); --card: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-1)) c h); @@ -158,9 +158,24 @@ --popover: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-2)) c h); --popover-foreground: var(--foreground); - /* Primary is the accessible functional green (text, controls). Bright - expressive fills use brand-default. Light theme overrides --primary for AA text. */ + /* + * Primary is the accessible functional green (text, controls). Bright + * expressive fills use brand-default. Light theme overrides --primary for AA text. + * + * Hover shifts lightness only (preserves hue/chroma). Mixing with --foreground + * pulls light-mode surface-hue (warm) into the green and reads as yellow. + * Sign of --tone-span: darken in light, lighten in dark. + */ --primary: oklch(0.76 0.15 var(--primary-hue)); + --button-fill-hover-delta: 0.04; + --primary-hover: oklch( + from var(--primary) + calc( + l + var(--button-fill-hover-delta) * var(--tone-span) / + max(var(--tone-span), calc(var(--tone-span) * -1)) + ) + c h + ); --primary-foreground: oklch( min(var(--surface), var(--foreground-lightness)) calc(var(--chroma) * 0.45) var(--primary-hue) ); @@ -217,9 +232,25 @@ --control: var(--control-raised); --warning: oklch(var(--warning-lightness) var(--expressive-chroma) var(--warning-hue)); + --warning-hover: oklch( + from var(--warning) + calc( + l + var(--button-fill-hover-delta) * var(--tone-span) / + max(var(--tone-span), calc(var(--tone-span) * -1)) + ) + c h + ); --destructive: oklch( var(--destructive-lightness) var(--expressive-chroma) var(--destructive-hue) ); + --destructive-hover: oklch( + from var(--destructive) + calc( + l + var(--button-fill-hover-delta) * var(--tone-span) / + max(var(--tone-span), calc(var(--tone-span) * -1)) + ) + c h + ); /* * Text that sits on the solid fill: dark ink on light fills (amber), white on * dark fills (red). The clamp turns the fill's own lightness into a hard flip diff --git a/packages/ui/build/css/themes/light.css b/packages/ui/build/css/themes/light.css index 69c1e35af88..7a5770def36 100644 --- a/packages/ui/build/css/themes/light.css +++ b/packages/ui/build/css/themes/light.css @@ -24,6 +24,7 @@ * against the near-white surface (a light amber on white disappears). Lightness * is mode-aware; chroma is the shared --expressive-chroma from semantic.css. */ + --warning-hue: 58; --warning-lightness: 0.68; --destructive-lightness: 0.52; --info-lightness: 0.54; diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 0beb615dcdb..2d7de7f58c6 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -1,6 +1,7 @@ // GENERAL export * from './src/components/Button' +export * from './src/components/FloatingPlate' export * from './src/components/Icon/IconBackground' // NAV diff --git a/packages/ui/src/components/Button/Button.test.tsx b/packages/ui/src/components/Button/Button.test.tsx index ac5596a9bc3..029449293fc 100644 --- a/packages/ui/src/components/Button/Button.test.tsx +++ b/packages/ui/src/components/Button/Button.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import React from 'react' import { describe, expect, it, vi } from 'vitest' -import { Button } from './Button' +import { Button, buttonVariants } from './Button' const SIZES = ['tiny', 'small', 'medium', 'large', 'xlarge'] as const const TYPES = [ @@ -30,16 +30,22 @@ describe('#Button', () => { render() const button = screen.getByRole('button', { name: 'Neutral' }) - expect(button.className).toContain('bg-background') - expect(button.className).toContain('hover:bg-popover') - expect(button.className).not.toContain('bg-brand-400') + expect(button.className).toContain('bg-card') + expect(button.className).toContain('hover:bg-muted') + expect(button.className).not.toContain('bg-primary') + }) + + it('should apply tiny size classes when buttonVariants omits size', () => { + const className = buttonVariants({ variant: 'default' }) + expect(className).toContain('h-[26px]') + expect(className).toContain('px-2.5') }) it('should allow an explicit primary variant override', () => { render() const button = screen.getByRole('button', { name: 'Primary' }) - expect(button.className).toContain('bg-brand-400') + expect(button.className).toContain('bg-primary') }) it('should render different text', () => { diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index 90d76a47d68..df2c182c84f 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -5,49 +5,61 @@ import { Loader2 } from 'lucide-react' import { Slot } from 'radix-ui' import { cloneElement, forwardRef, isValidElement, ReactNode } from 'react' -import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from '../../lib/constants' +import { SIZE_VARIANTS } from '../../lib/constants' import { cn } from '../../lib/utils/cn' import { getExplicitTabIndex } from '../../lib/utils/getExplicitTabIndex' export type ButtonVariantProps = VariantProps +// Normalize the shared border curve at contrast 0.5: (0.05 + 0.95 * 0.5)² = 0.275625. const buttonVariants = cva( `relative inline-flex items-center justify-center cursor-pointer space-x-2 text-center - font-regular - ease-out + font-medium + ease-[cubic-bezier(0.22,1,0.36,1)] duration-200 rounded-md transition-[background-color,border-color,color,scale] [&:not([aria-haspopup])]:motion-safe:active:scale-[0.97] focus-ring border + [--button-shadow-opacity:0.04] dark:[--button-shadow-opacity:0.2] + [--button-edge-strength:calc(var(--contrast-border,0.275625)/0.275625*0.6)] + dark:[--button-edge-strength:calc(var(--contrast-border,0.275625)/0.275625)] + [--button-edge-color:var(--colors-black)] dark:[--button-edge-color:var(--colors-white)] + [--button-shadow-drop:0_1px_3px_0_hsl(var(--colors-black)/var(--button-shadow-opacity))] + [--button-shadow-raised:var(--button-shadow-drop),inset_0_1px_0_0_hsl(var(--button-edge-color)/calc(0.04*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--button-edge-color)/calc(0.06*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--button-edge-color)/calc(0.1*var(--button-edge-strength)))] + [--button-shadow-default:var(--button-shadow-drop),inset_0_1px_0_0_hsl(var(--button-edge-color)/calc(0.04*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--colors-black)/calc(0.06*var(--button-edge-strength))),inset_0_-1px_0_0_hsl(var(--colors-black)/calc(0.06*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--button-edge-color)/calc(0.1*var(--button-edge-strength)))] `, { variants: { variant: { primary: ` - bg-brand-400 dark:bg-brand-500 - hover:bg-brand-default/80 dark:hover:bg-brand-default/50 - text-foreground - border-brand-500/75 dark:border-brand-default/30 - hover:border-brand-600 dark:hover:border-brand-default - data-[state=open]:bg-brand-400/80 dark:data-[state=open]:bg-brand-500/80 + border-0 + bg-primary + bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))] + text-primary-foreground + shadow-[var(--button-shadow-raised)] + hover:bg-[var(--primary-hover)] + data-[state=open]:bg-[var(--primary-hover)] `, default: ` text-foreground - bg-background dark:bg-card hover:bg-popover - border-strong hover:border-control-hover - data-[state=open]:bg-popover - data-[state=open]:border-control-hover + border-0 + bg-card hover:bg-muted dark:bg-muted dark:hover:bg-accent + dark:bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))] + shadow-[var(--button-shadow-default)] + data-[state=open]:bg-muted dark:data-[state=open]:bg-accent `, secondary: ` bg-foreground - text-background hover:text-background/80 - border-foreground-light hover:border-foreground-lighter - data-[state=open]:border-foreground-lighter + text-background + border-0 + shadow-[var(--button-shadow-drop)] + hover:bg-foreground/90 + data-[state=open]:bg-foreground/90 `, outline: ` text-foreground @@ -79,27 +91,35 @@ const buttonVariants = cva( border-transparent `, danger: ` - text-foreground - bg-destructive-300 dark:bg-destructive-400 hover:bg-destructive-400 dark:hover:bg-destructive/50 - border-border-destructive hover:border-destructive - hover:text-hi-contrast - data-[state=open]:border-destructive - data-[state=open]:bg-destructive-400 dark:data-[state=open]:bg-destructive/50 + border-0 + bg-destructive + bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))] + text-destructive-foreground + shadow-[var(--button-shadow-raised)] + hover:bg-[var(--destructive-hover)] + data-[state=open]:bg-[var(--destructive-hover)] `, warning: ` - text-foreground - bg-warning-300 dark:bg-warning-400 hover:bg-warning-400 dark:hover:bg-warning/50 - border-border-warning hover:border-warning - hover:text-hi-contrast - data-[state=open]:border-warning - data-[state=open]:bg-warning-400 dark:data-[state=open]:bg-warning/50 + border-0 + bg-warning + bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))] + text-warning-foreground + shadow-[var(--button-shadow-raised)] + hover:bg-[var(--warning-hover)] + data-[state=open]:bg-[var(--warning-hover)] `, }, block: { true: 'w-full flex items-center justify-center', }, size: { - ...SIZE_VARIANTS, + // Larger sizes soften the curve; cn() merges these over base rounded-md. + // Radius stays on Button (not SIZE_VARIANTS) because that map is shared with Input/Select. + tiny: `${SIZE_VARIANTS.tiny} rounded-md`, + small: `${SIZE_VARIANTS.small} rounded-[calc(var(--radius-md)*(1+(34/26-1)*0.35))]`, + medium: `${SIZE_VARIANTS.medium} rounded-[calc(var(--radius-md)*(1+(38/26-1)*0.35))]`, + large: `${SIZE_VARIANTS.large} rounded-[calc(var(--radius-md)*(1+(42/26-1)*0.35))]`, + xlarge: `${SIZE_VARIANTS.xlarge} rounded-[calc(var(--radius-md)*(1+(50/26-1)*0.35))]`, }, overlay: { base: `absolute inset-0 bg-background opacity-50`, @@ -114,13 +134,11 @@ const buttonVariants = cva( rounded: { true: 'rounded-full', }, - defaultVariants: { - // variant: 'default', - // size: 'default', - size: { - SIZE_VARIANTS_DEFAULT, - }, - }, + }, + // Match + + ) + + const plate = screen.getByRole('button', { name: 'Copy' }).parentElement + expect(plate?.className).toContain('inline-flex') + expect(plate?.className).toContain('bg-popover') + expect(plate?.className).toContain('rounded-lg') + }) + + it('supports pill radius for rounded-full controls', () => { + const { container } = render( + + + + ) + + expect(container.firstElementChild?.className).toContain('rounded-full') + expect(container.firstElementChild?.className).not.toContain('rounded-lg') + }) + + it('merges layout and reveal classes from the callsite', () => { + const { container } = render( + + + + ) + + const plate = container.firstElementChild as HTMLElement + expect(plate.className).toContain('absolute') + expect(plate.className).toContain('right-2') + expect(plate.className).toContain('opacity-0') + expect(plate.className).toContain('group-hover:opacity-100') + }) +}) diff --git a/packages/ui/src/components/FloatingPlate/FloatingPlate.tsx b/packages/ui/src/components/FloatingPlate/FloatingPlate.tsx new file mode 100644 index 00000000000..e4de1c182f9 --- /dev/null +++ b/packages/ui/src/components/FloatingPlate/FloatingPlate.tsx @@ -0,0 +1,42 @@ +import { type HTMLAttributes, type ReactNode } from 'react' + +import { cn } from '../../lib/utils/cn' + +const ROUNDED = { + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full', +} as const + +export type FloatingPlateRounded = keyof typeof ROUNDED + +export type FloatingPlateProps = HTMLAttributes & { + children: ReactNode + /** + * Plate corner radius. Match or exceed the child control radius + * (`full` for pill buttons, `lg` for most default buttons). + * @default 'lg' + */ + rounded?: FloatingPlateRounded +} + +/** + * Opaque plate behind default Buttons that float over busy content. + * + * Default buttons use a translucent fill in dark themes, so code, tables, and + * gradients show through. Wrap those controls (or clusters) in FloatingPlate + * so the plate occludes; keep positioning, z-index, and reveal opacity on + * `className`. + */ +export function FloatingPlate({ + children, + className, + rounded = 'lg', + ...props +}: FloatingPlateProps) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/ui/src/components/FloatingPlate/index.tsx b/packages/ui/src/components/FloatingPlate/index.tsx new file mode 100644 index 00000000000..dcb1ca96278 --- /dev/null +++ b/packages/ui/src/components/FloatingPlate/index.tsx @@ -0,0 +1 @@ +export * from './FloatingPlate' diff --git a/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx b/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx index dbf9e1216ce..2733cf2b0d6 100644 --- a/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx +++ b/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx @@ -57,7 +57,7 @@ export const KeyboardShortcut = ({ keys, variant = 'pill', className }: Keyboard 'inline-flex whitespace-nowrap shrink-0', variant === 'pill' ? 'items-center text-[11px] leading-none tracking-[-0.025em] text-foreground-light bg-surface-200/50 dark:bg-surface-100/50 rounded px-[5px] py-[3px] border border-border-muted' - : 'items-baseline text-[11px] leading-[inherit] text-foreground/40', + : 'items-baseline text-[11px] leading-[inherit]', className )} > diff --git a/packages/ui/src/components/shadcn/ui/button.tsx b/packages/ui/src/components/shadcn/ui/button.tsx index ce40d2f00db..960c86492e5 100644 --- a/packages/ui/src/components/shadcn/ui/button.tsx +++ b/packages/ui/src/components/shadcn/ui/button.tsx @@ -12,7 +12,7 @@ const buttonVariants = cva( { variants: { variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', + default: 'bg-primary text-primary-foreground hover:bg-[var(--primary-hover)]', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', outline: 'border border-control bg-background hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',