feat(deps): Tailwind 3 → 4 + swap tailwindcss-animate for tw-animate-css

Ran the official @tailwindcss/upgrade tool:
- tailwind.config.ts → @theme directive in globals.css
- @tailwind base/components/utilities → @import 'tailwindcss'
- postcss.config switched from tailwindcss + autoprefixer to
  @tailwindcss/postcss (autoprefixer baked in)
- focus-visible:outline-none → focus-visible:outline-hidden (the v3
  utility was a footgun — outline still showed in forced-colors mode)

Reverted the migration tool's over-zealous variant="outline" →
variant="outline-solid" rename on CVA prop values; that rename was
meant for the Tailwind `outline:` utility, not our Button/Badge
component variants.

Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css
(v4-native @import). Same utility surface (animate-spin, animate-in,
etc.), one fewer JS plugin in the bundle.

Fixed the upgrade tool's malformed dark variant
(@custom-variant dark (&:is(class *)) — `class` was being parsed as
a tag) to canonical &:where(.dark, .dark *).

Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings),
vitest 1315/1315, next build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:14:38 +02:00
parent 3147923d91
commit 0ab96d74a8
69 changed files with 561 additions and 753 deletions

View File

@@ -14,7 +14,7 @@ export function ActionRow({ children, className }: { children: ReactNode; classN
'flex gap-2',
'overflow-x-auto snap-x snap-mandatory scroll-smooth -mx-3 px-3 sm:mx-0 sm:px-0',
'sm:flex-wrap sm:overflow-visible',
'[&>*]:snap-start [&>*]:shrink-0 sm:[&>*]:snap-none',
'*:snap-start *:shrink-0 sm:*:snap-none',
className,
)}
>

View File

@@ -32,7 +32,7 @@ export function BrandedAuthShell({ children, branding }: BrandedAuthShellProps)
const altText = branding?.appName || 'Port Nimara';
// fixed inset-0 anchors the auth surface to the viewport directly —
// iOS Safari ignores overflow-hidden on inner divs for body-level
// scrolling, so a regular `h-[100dvh] overflow-hidden` wrapper doesn't
// scrolling, so a regular `h-dvh overflow-hidden` wrapper doesn't
// stop the rubber-band bounce. Pinning to the viewport via position
// fixed does. The fixed-position shell then uses flex to center the
// card within the visible area.

View File

@@ -122,10 +122,7 @@ export function CountryCombobox({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0"
align="start"
>
<PopoverContent className="w-(--radix-popper-anchor-width) min-w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="Search country or code…" />
<CommandList>

View File

@@ -408,7 +408,7 @@ export function DataTable<TData>({
const next = e.target.value === 'all' ? 1000 : Number(e.target.value);
onPaginationChange?.(1, next);
}}
className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="25">25</option>
<option value="50">50</option>

View File

@@ -42,7 +42,7 @@ export function DetailPageShell({
return (
<div className={cn('flex flex-col min-h-full', className)}>
{/* Desktop-only sticky header - mobile topbar covers this on small viewports. */}
<div className="hidden sm:block sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 sm:px-6">
<div className="hidden sm:block sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-border px-4 py-3 sm:px-6">
<div className="flex items-center gap-3 min-w-0">
<h2 className="truncate text-lg font-semibold text-foreground">{entityName}</h2>
{status ? <div className="ml-auto shrink-0">{status}</div> : null}
@@ -65,7 +65,7 @@ export function DetailPageShell({
className={cn(
'sm:hidden',
'fixed inset-x-0 bottom-[calc(56px+env(safe-area-inset-bottom))] z-30',
'border-t border-border bg-background/95 backdrop-blur px-4 py-3',
'border-t border-border bg-background/95 backdrop-blur-sm px-4 py-3',
'flex items-center gap-2',
)}
>

View File

@@ -119,7 +119,7 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
const actor = row.actor?.name || row.actor?.email || 'System';
return (
<li key={row.id} className="relative">
<span className="absolute -left-[31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
<div className="text-sm">
<span className="font-medium">{summarize(row)}</span>
<span className="text-muted-foreground"> · {actor}</span>

View File

@@ -89,7 +89,7 @@ export function InlineCountryField({
data-testid={testId}
className={cn(
'group inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-left text-sm',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}

View File

@@ -293,7 +293,7 @@ function ReadButton({
className={cn(
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring',
// Select-kind buttons get a faint border so they read as a
// distinct interactive element rather than text-with-an-icon.
kind === 'select' && 'border border-border bg-background hover:bg-accent',

View File

@@ -137,7 +137,7 @@ export function InlinePhoneField({
data-testid={testId}
className={cn(
'group inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-left text-sm',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}

View File

@@ -86,7 +86,7 @@ export function InlineTimezoneField({
data-testid={testId}
className={cn(
'group inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-left text-sm',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}

View File

@@ -45,7 +45,7 @@ export function ListCard({
const innerClassName = cn(
'block p-3',
accentClassName && 'pl-4',
'rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
);
return (

View File

@@ -48,7 +48,7 @@ export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsPro
slides under the wrapper. */}
<div
ref={listRef}
className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className="overflow-x-auto -mx-2 px-2 scrollbar-none [&::-webkit-scrollbar]:hidden"
>
<TabsList className="inline-flex w-max">
{tabs.map((tab) => (

View File

@@ -77,7 +77,7 @@ export function TagPicker({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
<Command>
<CommandInput placeholder="Search tags..." />
<CommandList>

View File

@@ -97,10 +97,7 @@ export function TimezoneCombobox({
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popper-anchor-width)] min-w-[360px] p-0"
align="start"
>
<PopoverContent className="w-(--radix-popper-anchor-width) min-w-[360px] p-0" align="start">
<Command>
<CommandInput placeholder="Search timezones…" />
<CommandList>