From cd77bebafd939b1d08e5edf339c84ce7715bdd6f Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 23 Sep 2026 13:43:00 +1000 Subject: [PATCH] chore(ui): refresh shared button styles (#50197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? UI polish / design system: refreshed button styles, related token tweaks, and a shared floating-button plate. Resolves DEPR-652. ## What is the current behavior? Default, primary, and secondary buttons use older fills, borders, and hover treatments. Primary still leans on brand scale utilities. Default fills don’t always read as raised chrome across surfaces, and floating copy / expand / scroll controls can let busy content show through translucent fills. Call sites hand-roll `rounded-* bg-background` wrappers for that. ## What is the new behavior? Refreshes primary, default, and secondary buttons with medium-weight labels, subtle shadows and inset edges, and smoother transitions. Light-mode default buttons use a raised fill with an accent hover state, primary text is brighter, and inline keyboard shortcuts inherit the button’s colour. Adds `FloatingPlate`: an opaque `bg-popover` shell for floating default buttons (and small clusters). Migrates Studio, Docs-related patterns, www, and `ui-patterns` floaters onto it so busy content no longer shows through translucent fills. Positioning, z-index, and hover/focus reveal stay on the plate’s `className`. Use `rounded="full"` for pills. Also: - Moves primary onto semantic `--primary` / `--primary-hover` (with a light-theme override) instead of brand utility fills - Tokenises button shadows as `--button-shadow-drop` / `--button-shadow-raised` / `--button-shadow-default` on the Button base - Aligns hover direction: darken on light mode, lighten on dark mode for both default and primary - Default fill stays opaque `bg-card` in light (occlusion) and translucent `bg-muted` in dark (adapts to the local surface) - Documents fills and `FloatingPlate` on the design-system Button page (with a live example) - Scales shared radius tokens in Studio and www; medium+ Button sizes use a proportionally softer radius - Fixes www nav CTA centering (`lg:inline-flex` instead of `lg:block`) - Query detail Expand/Collapse wires `aria-expanded` / `aria-controls` | Before | After | | --- | --- | | CleanShot 2026-09-18 at 15 52
51@2x | CleanShot 2026-09-18 at 15 50
59@2x | | CleanShot 2026-09-18 at 15 52
35@2x | CleanShot 2026-09-18 at 15 51
13@2x | ## To test ### Design system - [Button page](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button): default / primary in light and dark; hover should darken on light, lighten on dark - Same page: [Floating over content](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button#floating-over-content) / [Floating plate](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button#floating-plate) example; Copy over SQL should stay opaque - Spot-check hover on a code preview Copy control ### Docs [Docs deploy preview](https://docs-git-chore-button-styles-supabase.vercel.app/docs): - [Docs homepage](https://docs-git-chore-button-styles-supabase.vercel.app/docs): top-right **Sign up** / **Dashboard** primary; menu icon beside it (default icon button) - Shrink below `lg` and open the hamburger drawer: bottom **Sign in** (default) + **Start your project** (primary) medium block buttons - Tab once for **Skip to content** (FloatingPlate) - [MCP guide](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/ai-tools/mcp): project picker - [Apple login](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/auth/social-login/auth-apple): **Generate Secret Key** button in the Apple Secret Generator - Optional opacity check: any guide code block Copy control (e.g. at the bottom of [Import data into Supabase](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/database/import-data)) ### Studio [Studio deploy preview](https://studio-staging-git-chore-button-styles-supabase.vercel.app/): - **Observability → Query Performance**: open a query detail → Expand/Collapse pill + SQL Copy chip (dark: no bleed-through) - **Observability → Query Insights**: select a query → Clear query pill - **Table Editor → any table → Definition** → floating **Open in SQL Editor** - **Connect → Framework → Add files**: Copy on the code tabs (FloatingPlate; light hover follow-up is DEPR-694) - Tab once for **Skip to content** ### WWW - [www deploy preview](https://zone-www-dot-com-git-chore-button-styles-supabase.vercel.app/): nav Sign in / Start your project vertical centering; hero medium CTAs radius --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> --- .../components/code-block-wrapper.tsx | 14 +-- .../components/component-preview.tsx | 18 ++-- .../content/docs/components/button.mdx | 34 ++++++ .../default/example/button-floating-plate.tsx | 21 ++++ apps/design-system/registry/examples.ts | 6 ++ .../Navigation/NavigationMenu/TopNavBar.tsx | 2 +- .../NavigationMenu/TopNavDropdown.tsx | 2 +- apps/docs/features/directives/Show.test.ts | 2 +- .../ReplicationPipelineLayout.test.tsx | 2 +- .../QueryInsightsTable/QueryInsightsTable.tsx | 18 ++-- .../QueryPerformance/QueryDetail.tsx | 22 ++-- .../QueryPerformance/SqlMonacoBlock.tsx | 6 +- .../InstanceConfiguration.tsx | 6 +- .../TableGridEditor/TableDefinition.tsx | 22 ++-- .../UnifiedLogs/ServiceFlowPanel.tsx | 27 ++--- .../elements/Conversation.tsx | 20 ++-- .../ui/DataGridResults/ResultCell.tsx | 28 ++--- apps/studio/styles/globals.css | 11 ++ apps/www/components/Nav/index.tsx | 6 +- .../Solutions/TwoColumnsSection.tsx | 26 ++--- apps/www/styles/globals.css | 11 ++ .../src/AuthenticatedDropdownMenu/index.tsx | 2 +- .../ui-patterns/src/CodeBlock/CodeBlock.tsx | 14 +-- packages/ui-patterns/src/Row/index.tsx | 34 +++--- .../src/SimpleCodeBlock/SimpleCodeBlock.tsx | 6 +- .../src/SkipToContent/SkipToContent.test.tsx | 3 +- .../src/SkipToContent/SkipToContent.tsx | 7 +- packages/ui/build/css/source/semantic.css | 39 ++++++- packages/ui/build/css/themes/light.css | 1 + packages/ui/index.tsx | 1 + .../ui/src/components/Button/Button.test.tsx | 16 ++- packages/ui/src/components/Button/Button.tsx | 102 ++++++++++-------- .../FloatingPlate/FloatingPlate.test.tsx | 44 ++++++++ .../FloatingPlate/FloatingPlate.tsx | 42 ++++++++ .../ui/src/components/FloatingPlate/index.tsx | 1 + .../KeyboardShortcut/KeyboardShortcut.tsx | 2 +- .../ui/src/components/shadcn/ui/button.tsx | 2 +- 37 files changed, 437 insertions(+), 183 deletions(-) create mode 100644 apps/design-system/registry/default/example/button-floating-plate.tsx create mode 100644 packages/ui/src/components/FloatingPlate/FloatingPlate.test.tsx create mode 100644 packages/ui/src/components/FloatingPlate/FloatingPlate.tsx create mode 100644 packages/ui/src/components/FloatingPlate/index.tsx 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',