chore(ui): refresh shared button styles (#50197)

## 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 |
| --- | --- |
| <img width="1074" height="438" alt="CleanShot 2026-09-18 at 15 52
51@2x"
src="https://github.com/user-attachments/assets/ef43da21-b053-4b7e-9ac4-ab8b428228ab"
/> | <img width="1090" height="464" alt="CleanShot 2026-09-18 at 15 50
59@2x"
src="https://github.com/user-attachments/assets/2ddc55fc-4c8d-499c-a280-f3db3d99023c"
/> |
| <img width="1082" height="446" alt="CleanShot 2026-09-18 at 15 52
35@2x"
src="https://github.com/user-attachments/assets/3dd5452d-325a-4e4a-a79d-26c6c6950a31"
/> | <img width="1078" height="446" alt="CleanShot 2026-09-18 at 15 51
13@2x"
src="https://github.com/user-attachments/assets/93666385-3e6e-42e0-9891-9cd6bb935b67"
/> |

## 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>
This commit is contained in:
Saxon FletcherandDanny White authored and GitHub committed 2026-09-23 03:43:00 +00:00
1 parent 59e2122833
commit cd77bebafd
37 files changed
+437 -183

No files matched your search

@@ -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<HTMLDivElement> {
expandButtonTitle?: string
@@ -34,11 +34,13 @@ export function CodeBlockWrapper({
isOpened ? 'inset-x-0 bottom-0 h-12' : 'inset-0'
)}
>
<CollapsibleTrigger asChild>
<Button variant="secondary" className="h-8 text-xs">
{isOpened ? 'Collapse' : expandButtonTitle}
</Button>
</CollapsibleTrigger>
<FloatingPlate>
<CollapsibleTrigger asChild>
<Button variant="secondary" className="h-8 text-xs">
{isOpened ? 'Collapse' : expandButtonTitle}
</Button>
</CollapsibleTrigger>
</FloatingPlate>
</div>
</div>
</Collapsible>
@@ -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}
<div className="absolute bottom-0 w-full flex justify-center mb-4">
<Button
className="rounded-full"
onClick={() => setExpandState(!expand)}
icon={<Expand className="text-foreground-lighter" />}
>
{expand ? 'Collapse code' : 'Expand code'}
</Button>
<FloatingPlate rounded="full">
<Button
className="rounded-full"
onClick={() => setExpandState(!expand)}
icon={<Expand className="text-foreground-lighter" />}
>
{expand ? 'Collapse code' : 'Expand code'}
</Button>
</FloatingPlate>
</div>
</div>
</div>
@@ -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:
<ComponentPreview name="button-floating-plate" peekCode />
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:
@@ -0,0 +1,21 @@
import { Copy } from 'lucide-react'
import { Button, FloatingPlate } from 'ui'
export default function ButtonFloatingPlate() {
return (
<div className="relative w-full max-w-md overflow-hidden rounded-md border border-border">
<pre className="bg-surface-100 p-4 pr-16 font-mono text-xs text-foreground-light leading-relaxed">
{`select *
from projects
where status = 'ACTIVE_HEALTHY'
order by created_at desc
limit 20;`}
</pre>
<FloatingPlate className="absolute right-2 top-2">
<Button size="tiny" icon={<Copy />}>
Copy
</Button>
</FloatingPlate>
</div>
)
}
+6
View File
@@ -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',
@@ -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)}
>
@@ -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'
)}
>
<Menu size={18} strokeWidth={1} />
+1 -1
View File
@@ -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'
@@ -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()
@@ -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(' ')}
>
<Button
size="tiny"
className="rounded-full shadow-md"
onClick={() => onCurrentSelectQuery?.(null)}
>
Clear query
</Button>
<FloatingPlate rounded="full">
<Button
size="tiny"
className="rounded-full shadow-md"
onClick={() => onCurrentSelectQuery?.(null)}
>
Clear query
</Button>
</FloatingPlate>
</div>
{isLoading ? (
<div className="px-6 py-4">
@@ -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
/>
</div>
<div
id="query-detail-sql-panel"
className={cn(
'overflow-hidden pb-0 z-0 relative transition-all duration-300',
isExpanded ? 'h-[348px]' : 'h-[120px]'
@@ -126,13 +127,18 @@ export const QueryDetail = ({ selectedRow, onClickViewSuggestion, onClose }: Que
)}
/>
<div className="absolute bottom-[-13px] left-0 right-0 w-full flex items-center justify-center z-10">
<Button
className="rounded-full"
icon={<ChevronsUpDown />}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Collapse' : 'Expand'}
</Button>
<FloatingPlate rounded="full">
<Button
type="button"
className="rounded-full"
icon={<ChevronsUpDown />}
aria-expanded={isExpanded}
aria-controls="query-detail-sql-panel"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Collapse' : 'Expand'}
</Button>
</FloatingPlate>
</div>
</QueryPanelSection>
<QueryPanelSection className="pb-3 pt-6">
@@ -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 } }}
/>
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
<FloatingPlate className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<Button
className="px-1.5"
icon={copied ? <Check /> : <Copy />}
@@ -44,7 +44,7 @@ export const SqlMonacoBlock = ({ value, wrapperClassName }: SqlMonacoBlockProps)
>
{copied ? 'Copied' : ''}
</Button>
</div>
</FloatingPlate>
</div>
)
}
@@ -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 && (
<div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
<FloatingPlate className="z-10 absolute top-4 right-4 items-center justify-center gap-x-2">
{isAws && (
<div className="flex items-center justify-center">
<Button
@@ -196,7 +196,7 @@ const InstanceConfigurationUI = () => {
/>
</div>
)}
</div>
</FloatingPlate>
)}
{view === 'flow' ? (
<DiagramFlow
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import Link from 'next/link'
import { useMemo } from 'react'
import { Button } from 'ui'
import { Button, FloatingPlate } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { Footer } from '@/components/grid/components/footer/Footer'
@@ -66,15 +66,17 @@ export const TableDefinition = ({ entity }: TableDefinitionProps) => {
return (
<>
<div className="grow overflow-y-auto border-t border-muted relative">
<Button asChild className="absolute top-2 right-5 z-10">
<Link
href={`/project/${ref}/sql/new?content=${encodeURIComponent(
formattedDefinition ?? ''
)}`}
>
Open in SQL Editor
</Link>
</Button>
<FloatingPlate className="absolute top-2 right-5 z-10">
<Button asChild>
<Link
href={`/project/${ref}/sql/new?content=${encodeURIComponent(
formattedDefinition ?? ''
)}`}
>
Open in SQL Editor
</Link>
</Button>
</FloatingPlate>
<CodeEditor isReadOnly language="pgsql" value={formattedDefinition} />
</div>
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
import {
Button,
copyToClipboard,
FloatingPlate,
ResizableHandle,
ResizablePanel,
Skeleton,
@@ -246,18 +247,20 @@ export function ServiceFlowPanel({
</div>
)}
<div className="sticky top-2 z-10 flex justify-end px-2 -mb-9 pointer-events-none">
<Button
size="tiny"
className="pointer-events-auto px-1.5"
icon={jsonCopied ? <Check size={12} /> : <Copy size={12} />}
onClick={() => {
copyToClipboard(JSON.stringify(formattedJsonData, null, 2))
setJsonCopied(true)
setTimeout(() => setJsonCopied(false), 1000)
}}
>
{jsonCopied ? 'Copied' : ''}
</Button>
<FloatingPlate className="pointer-events-auto">
<Button
size="tiny"
className="px-1.5"
icon={jsonCopied ? <Check size={12} /> : <Copy size={12} />}
onClick={() => {
copyToClipboard(JSON.stringify(formattedJsonData, null, 2))
setJsonCopied(true)
setTimeout(() => setJsonCopied(false), 1000)
}}
>
{jsonCopied ? 'Copied' : ''}
</Button>
</FloatingPlate>
</div>
<CodeBlock
language="json"
@@ -1,7 +1,7 @@
import { ArrowDownIcon } from 'lucide-react'
import type { ComponentProps, ReactNode } from 'react'
import { useCallback } from 'react'
import { Button, cn } from 'ui'
import { Button, cn, FloatingPlate } from 'ui'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
type ConversationProps = Omit<ComponentProps<typeof StickToBottom>, 'children'> & {
@@ -60,14 +60,16 @@ export const ConversationScrollButton = ({
return (
!isAtBottom && (
<Button
className={cn('absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full', className)}
onClick={handleScrollToBottom}
size="tiny"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
<FloatingPlate rounded="full" className="absolute bottom-4 left-[50%] translate-x-[-50%]">
<Button
className={cn('rounded-full', className)}
onClick={handleScrollToBottom}
size="tiny"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
</FloatingPlate>
)
)
}
@@ -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 ? <NullValue /> : formatCellValue(value)}
{showExpand && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="tiny"
className="absolute right-1 top-1/2 -translate-y-1/2 px-1 opacity-0 group-hover/cell:opacity-100 focus-visible:opacity-100"
icon={<Expand size={10} />}
aria-label="View full cell content"
onClick={(e) => {
e.stopPropagation()
onExpand(column, value)
}}
/>
</TooltipTrigger>
<FloatingPlate className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 focus-within:opacity-100">
<TooltipTrigger asChild>
<Button
size="tiny"
className="px-1"
icon={<Expand size={10} />}
aria-label="View full cell content"
onClick={(e) => {
e.stopPropagation()
onExpand(column, value)
}}
/>
</TooltipTrigger>
</FloatingPlate>
<TooltipContent side="left">View full cell content</TooltipContent>
</Tooltip>
)}
+11
View File
@@ -11,6 +11,17 @@
}
@theme {
/* Scale Tailwind's default radius tokens by 4/3 to match the 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;
--text-grid: 13px;
--color-typography-body-light: var(--foreground-light);
+3 -3
View File
@@ -217,14 +217,14 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
<GitHubButton />
{isLoggedIn ? (
<>
<Button className="hidden lg:block" asChild>
<Button className="hidden lg:inline-flex" asChild>
<Link href="/dashboard/projects">Dashboard</Link>
</Button>
<AuthenticatedDropdownMenu menu={userMenu} user={user} site="www" />
</>
) : (
<>
<Button className="hidden lg:block" asChild>
<Button className="hidden lg:inline-flex" asChild>
<Link
href="https://supabase.com/dashboard"
onClick={() =>
@@ -237,7 +237,7 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
Sign in
</Link>
</Button>
<Button variant="primary" className="hidden lg:block" asChild>
<Button variant="primary" className="hidden lg:inline-flex" asChild>
<Link
href="https://supabase.com/dashboard/sign-up"
onClick={() =>
@@ -1,7 +1,7 @@
import { ArrowUpRight, Check, Copy } from 'lucide-react'
import Link from 'next/link'
import React, { useState } from 'react'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Button, cn, FloatingPlate, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { TextLink } from 'ui-patterns/TextLink'
import SectionContainer from '@/components/Layouts/SectionContainer'
@@ -52,17 +52,19 @@ const CodeSnippet = ({ prompt }: { prompt: AIPrompt }) => {
<div className="p-4 relative">
{prompt.copyable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="text-foreground-light hover:text-foreground absolute top-2 right-2 w-7 h-7 p-1 shadow-lg"
onClick={async () => {
await navigator.clipboard.writeText(prompt.code)
updateCopyStatus()
}}
>
{text === 'copy' ? <Copy className="w-3 h-3" /> : <Check className="w-3 h-3" />}
</Button>
</TooltipTrigger>
<FloatingPlate className="absolute top-2 right-2">
<TooltipTrigger asChild>
<Button
className="text-foreground-light hover:text-foreground w-7 h-7 p-1 shadow-lg"
onClick={async () => {
await navigator.clipboard.writeText(prompt.code)
updateCopyStatus()
}}
>
{text === 'copy' ? <Copy className="w-3 h-3" /> : <Check className="w-3 h-3" />}
</Button>
</TooltipTrigger>
</FloatingPlate>
<TooltipContent>Copy to clipboard</TooltipContent>
</Tooltip>
)}
+11
View File
@@ -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%);
@@ -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'
)}
>
@@ -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}
</SyntaxHighlighter>
{!hideCopy && (value || children) && className ? (
<div
className={[
'absolute right-2 top-2',
<FloatingPlate
className={cn(
'absolute right-2 top-2 bg-surface-100 dark:bg-200',
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition',
`${isDarkTheme ? 'dark' : ''}`,
].join(' ')}
isDarkTheme && 'dark'
)}
>
<Button
className="px-1.5 dark:bg-200! dark:hover:bg-button! hover:bg-alternative!"
@@ -281,7 +281,7 @@ export const CodeBlock = ({
>
{copied ? 'Copied' : ''}
</Button>
</div>
</FloatingPlate>
) : null}
</div>
) : (
+19 -15
View File
@@ -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<HTMLDivElement, RowProps>(function Row(
return (
<div ref={ref} className={cn('relative w-full', className)} {...rest}>
{showArrows && canScrollLeft && (
<Button
onClick={scrollLeft}
className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
aria-label="Scroll left"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<FloatingPlate rounded="full" className="absolute left-0 top-1/2 z-10 -translate-y-1/2">
<Button
onClick={scrollLeft}
className="w-8 h-8 rounded-full p-2"
aria-label="Scroll left"
>
<ChevronLeft className="w-4 h-4" />
</Button>
</FloatingPlate>
)}
{showArrows && canScrollRight && hasContentToScroll && (
<Button
onClick={scrollRight}
className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
aria-label="Scroll right"
>
<ChevronRight className="w-4 h-4" />
</Button>
<FloatingPlate rounded="full" className="absolute right-0 top-1/2 z-10 -translate-y-1/2">
<Button
onClick={scrollRight}
className="w-8 h-8 rounded-full p-2"
aria-label="Scroll right"
>
<ChevronRight className="w-4 h-4" />
</Button>
</FloatingPlate>
)}
<div
@@ -9,7 +9,7 @@
import { useTheme } from 'next-themes'
import { Highlight, Language, Prism, themes } from 'prism-react-renderer'
import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'
import { Button, cn, copyToClipboard } from 'ui'
import { Button, cn, copyToClipboard, FloatingPlate } from 'ui'
import { dart } from './prism'
@@ -98,11 +98,11 @@ export const SimpleCodeBlock = ({
})}
</pre>
{showCopy && (
<div className="invisible absolute right-0 top-0 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100">
<FloatingPlate className="invisible absolute right-0 top-0 bg-surface-100 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
<Button size="tiny" onClick={() => handleCopyCode(children)}>
{showCopied ? 'Copied' : 'Copy'}
</Button>
</div>
</FloatingPlate>
)}
</div>
)
@@ -34,7 +34,8 @@ describe('SkipToContent', () => {
render(<SkipToContent href="#main" />)
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')
})
@@ -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 (
<div
<FloatingPlate
className={cn(
// w-fit: wrapper is a block div by default and would otherwise span the full content column.
'fixed top-0 left-[10px] z-[100] w-fit',
'-translate-y-full focus-within:translate-y-[10px]',
'transition-transform duration-200 ease-out',
@@ -29,7 +28,7 @@ function SkipToContent({ href, children = 'Skip to content', className }: SkipTo
<Button size="tiny" asChild>
<a href={href}>{children}</a>
</Button>
</div>
</FloatingPlate>
)
}
+35 -4
View File
@@ -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
+1
View File
@@ -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;
+1
View File
@@ -1,6 +1,7 @@
// GENERAL
export * from './src/components/Button'
export * from './src/components/FloatingPlate'
export * from './src/components/Icon/IconBackground'
// NAV
@@ -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(<Button>Neutral</Button>)
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(<Button variant="primary">Primary</Button>)
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', () => {
+60 -42
View File
@@ -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<typeof buttonVariants>
// 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 <Button size="tiny"> so raw buttonVariants({ variant }) keeps sizing.
// Fixed icon shells that omit size must override padding (e.g. px-0 with h/w-[30px]).
defaultVariants: {
size: 'tiny',
},
}
)
@@ -137,7 +155,7 @@ const IconContainerVariants = cva('inline-flex items-center justify-center shrin
xxxlarge: '[&_svg]:h-[42px] [&_svg]:w-[42px]',
},
variant: {
primary: 'text-brand-600',
primary: 'text-primary-foreground/50',
default: 'text-foreground-lighter',
secondary: 'text-background',
alternative: 'text-foreground-lighter',
@@ -145,8 +163,8 @@ const IconContainerVariants = cva('inline-flex items-center justify-center shrin
dashed: 'text-foreground-lighter',
link: 'text-brand-600',
text: 'text-foreground-lighter',
danger: 'text-destructive',
warning: 'text-warning',
danger: 'text-destructive-foreground/50',
warning: 'text-warning-foreground/50',
},
},
})
@@ -155,7 +173,7 @@ export type LoadingVariantProps = VariantProps<typeof loadingVariants>
const loadingVariants = cva('', {
variants: {
variant: {
primary: 'text-brand-600',
primary: 'text-primary-foreground/50',
default: 'text-foreground-lighter',
secondary: 'text-background',
alternative: 'text-foreground-lighter',
@@ -163,8 +181,8 @@ const loadingVariants = cva('', {
dashed: 'text-foreground-lighter',
link: 'text-brand-600',
text: 'text-foreground-muted',
danger: 'text-destructive',
warning: 'text-warning',
danger: 'text-destructive-foreground/50',
warning: 'text-warning-foreground/50',
},
loading: {
default: '',
@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { FloatingPlate } from './FloatingPlate'
describe('FloatingPlate', () => {
it('renders children inside an opaque plate', () => {
render(
<FloatingPlate>
<button type="button">Copy</button>
</FloatingPlate>
)
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(
<FloatingPlate rounded="full">
<button type="button">Clear</button>
</FloatingPlate>
)
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(
<FloatingPlate className="absolute right-2 top-2 opacity-0 group-hover:opacity-100">
<button type="button">Copy</button>
</FloatingPlate>
)
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')
})
})
@@ -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<HTMLDivElement> & {
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 (
<div className={cn('inline-flex bg-popover', ROUNDED[rounded], className)} {...props}>
{children}
</div>
)
}
@@ -0,0 +1 @@
export * from './FloatingPlate'
@@ -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
)}
>
@@ -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',