feat(theme): add browser-local light and dark mode

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 12:20:37 +08:00
parent ac42c2346c
commit ebac94a043
24 changed files with 332 additions and 34 deletions

View file

@ -0,0 +1,48 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
test.describe('Light and dark theme', () => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({ 'X-Mock-User-Id': 'local-user' })
await page.addInitScript(() => {
if (!window.sessionStorage.getItem('theme-test-initialized')) {
window.localStorage.removeItem('skillhub-theme')
window.sessionStorage.setItem('theme-test-initialized', 'true')
}
})
})
test('switches themes and restores only the browser-local selection', async ({ page }, testInfo) => {
const consoleErrors: string[] = []
const pageErrors: string[] = []
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message))
await page.goto('/')
await expect(page.locator('html')).not.toHaveClass(/dark/)
await page.getByRole('button', { name: 'Switch to dark theme' }).click()
await expect(page.locator('html')).toHaveClass(/dark/)
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('skillhub-theme'))).toBe('dark')
await page.reload()
await expect(page.locator('html')).toHaveClass(/dark/)
await expect(page.getByRole('button', { name: 'Switch to light theme' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('dark-desktop.png'), fullPage: true })
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.getByRole('button', { name: 'Switch to light theme' })).toBeVisible()
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('dark-mobile.png'), fullPage: true })
const unexpectedConsoleErrors = consoleErrors.filter((message) => (
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
))
expect(unexpectedConsoleErrors).toEqual([])
expect(pageErrors).toEqual([])
})
})

View file

@ -3,6 +3,7 @@ import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/features/auth/use-auth'
import { LanguageSwitcher } from '@/shared/components/language-switcher'
import { ThemeToggle } from '@/shared/components/theme-toggle'
import { UserMenu } from '@/shared/components/user-menu'
import { NotificationBell } from '@/features/notification/notification-bell'
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
@ -115,7 +116,8 @@ export function Layout() {
})}
</nav>
<div className="flex items-center gap-6 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
<div className="flex items-center gap-3 text-[15px] font-normal sm:gap-4" style={{ color: 'hsl(var(--text-secondary))' }}>
<ThemeToggle />
<LanguageSwitcher />
{user && <NotificationBell />}
{isLoading ? null : user ? (
@ -149,7 +151,7 @@ export function Layout() {
</main>
{/* Footer */}
<footer className="relative z-10 border-t rounded-t-2xl mt-auto" style={{ background: '#F1F5F9', borderColor: 'hsl(var(--border))' }}>
<footer className="relative z-10 mt-auto rounded-t-2xl border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
<div className="max-w-6xl mx-auto px-6 md:px-12 py-10">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-10 md:gap-12">
<div className="flex-shrink-0">

View file

@ -5,6 +5,10 @@
* that config or a safe fallback object before importing the main entry.
*/
import './legacy-polyfills'
import { initializeTheme } from './shared/lib/theme'
initializeTheme()
async function loadRuntimeConfig() {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')

View file

@ -37,7 +37,7 @@ export function NotificationDropdown({ onClose }: Props) {
return (
<div
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-white shadow-lg"
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-popover text-popover-foreground shadow-lg"
style={{ borderColor: 'hsl(var(--border))' }}
>
{/* Header */}

View file

@ -27,7 +27,10 @@ export function createNotificationSseConnection(
url: string,
eventSourceFactory: EventSourceFactory = (targetUrl) =>
new EventSource(targetUrl, { withCredentials: true }),
timerApi: TimerApi = { setTimeout, clearTimeout },
timerApi: TimerApi = {
setTimeout: (...args) => globalThis.setTimeout(...args),
clearTimeout: (timer) => globalThis.clearTimeout(timer),
},
): NotificationSseConnection {
return new ManagedNotificationSseConnection(url, eventSourceFactory, timerApi)
}

View file

@ -46,7 +46,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
return (
<Card
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
className="group relative h-full cursor-pointer overflow-hidden border bg-card p-5 text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border-card))' }}
onClick={onClick}
onKeyDown={(event) => {

View file

@ -147,8 +147,8 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium',
label.type === 'PRIVILEGED'
? 'border-amber-500/40 bg-amber-100 text-amber-900'
: 'border-slate-300 bg-slate-100 text-slate-800',
? 'border-amber-500/40 bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300'
: 'border-border bg-secondary text-secondary-foreground',
)}
>
{label.displayName}

View file

@ -487,6 +487,10 @@
"pageTitle": "Token Management",
"pageSubtitle": "Manage access credentials for CLI and API"
},
"theme": {
"switchToLight": "Switch to light theme",
"switchToDark": "Switch to dark theme"
},
"reviews": {
"title": "Review Center",
"subtitle": "Manage platform review tasks",

View file

@ -487,6 +487,10 @@
"pageTitle": "Управление токенами",
"pageSubtitle": "Учётные данные доступа для CLI и API"
},
"theme": {
"switchToLight": "Переключить на светлую тему",
"switchToDark": "Переключить на тёмную тему"
},
"reviews": {
"title": "Центр рецензирования",
"subtitle": "Задачи рецензирования на платформе",

View file

@ -487,6 +487,10 @@
"pageTitle": "Token 管理",
"pageSubtitle": "管理 CLI 和 API 使用的访问凭证"
},
"theme": {
"switchToLight": "切换到浅色主题",
"switchToDark": "切换到深色主题"
},
"reviews": {
"title": "审核中心",
"subtitle": "管理平台审核事务",

View file

@ -74,6 +74,10 @@
--surface-glass: 222 35% 11%;
--glow-primary: 239 100% 75%;
--glow-accent: 271 100% 72%;
--text-secondary: 215 20% 72%;
--text-muted: 215 16% 63%;
--text-placeholder: 215 14% 48%;
--border-card: 222 20% 18%;
}
}
@ -323,9 +327,9 @@
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
color: #0369a1;
background: #e0f2fe;
border: 1px solid #7dd3fc;
color: hsl(199 76% 34%);
background: hsl(199 89% 94%);
border: 1px solid hsl(199 93% 74%);
border-radius: 9999px;
}
@ -342,7 +346,7 @@
}
.status-pill--published { background: #16a34a; }
.status-pill--review { background: #ea580c; }
.status-pill--review { background: #c2410c; }
.status-pill--archived { background: #6b7280; }
.status-pill--rejected { background: #dc2626; }
@ -378,10 +382,26 @@
color: #1d4ed8;
}
.dark .handle-tag {
color: hsl(196 80% 72%);
background: hsl(199 70% 15%);
border-color: hsl(199 60% 30%);
}
.dark .badge-soft-green {
background: hsl(142 55% 16%);
color: hsl(142 70% 72%);
}
.dark .badge-soft-blue {
background: hsl(221 55% 18%);
color: hsl(213 90% 75%);
}
/* ─── Tab active ─── */
.tab-active {
border-bottom: 2px solid #3b82f6;
color: #1d4ed8;
border-bottom: 2px solid hsl(var(--primary));
color: hsl(var(--primary));
}
/* ─── Code block ─── */

View file

@ -110,7 +110,7 @@ export function LandingPage() {
{/* Search box */}
<div className="w-full max-w-2xl mb-8">
<div
className="flex items-center bg-white rounded-xl border shadow-sm px-5 py-3.5"
className="flex items-center rounded-xl border bg-card px-5 py-3.5 text-card-foreground shadow-sm"
style={{ borderColor: 'hsl(var(--border))' }}
>
<SearchIcon className="w-5 h-5 flex-shrink-0 mr-3" style={{ color: 'hsl(var(--text-placeholder))' }} strokeWidth={1.5} />
@ -181,7 +181,7 @@ export function LandingPage() {
{features.map((feature) => (
<div
key={feature.title}
className="bg-white rounded-xl p-8 border shadow-sm transition-shadow hover:shadow-md"
className="rounded-xl border bg-card p-8 text-card-foreground shadow-sm transition-shadow hover:shadow-md"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div className="feature-icon w-12 h-12 rounded-2xl flex items-center justify-center mb-6 mx-auto bg-brand-gradient">

View file

@ -168,7 +168,7 @@ export function RegisterPage() {
return (
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<Card className="w-full border-border bg-card/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle>{t('register.title')}</CardTitle>
<CardDescription>{t('register.subtitle')}</CardDescription>

View file

@ -89,7 +89,7 @@ export function ResetPasswordPage() {
return (
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
<Card className="w-full border-border bg-card/95 shadow-xl">
<CardHeader className="space-y-3 text-center">
<CardTitle>{t('resetPassword.title')}</CardTitle>
<CardDescription>{t('resetPassword.subtitle')}</CardDescription>

View file

@ -827,12 +827,12 @@ export function SkillDetailPage() {
</span>
)}
{isReviewFlowPending && (
<span className="badge-soft" style={{ background: '#fef3c7', color: '#92400e' }}>
<span className="badge-soft bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300">
{t('skillDetail.versionStatusPendingReview')}
</span>
)}
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && (
<span className="badge-soft" style={{ background: '#fee2e2', color: '#991b1b' }}>
<span className="badge-soft bg-red-100 text-red-900 dark:bg-red-950/60 dark:text-red-300">
{t('skillDetail.rejectedBadge')}
</span>
)}
@ -861,8 +861,8 @@ export function SkillDetailPage() {
className={cn(
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2',
label.type === 'PRIVILEGED'
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80'
: 'border-slate-300 bg-slate-100 text-slate-800 hover:bg-slate-200/80',
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80 dark:bg-amber-950/60 dark:text-amber-300 dark:hover:bg-amber-900/70'
: 'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
)}
>
{label.displayName}

View file

@ -56,7 +56,7 @@ function CompactCopyButton({ text }: { text: string }) {
onClick={handleCopy}
aria-label={label}
title={label}
className="absolute right-2.5 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-xl border bg-white transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-xl border bg-card text-card-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
@ -111,12 +111,11 @@ export function LandingQuickStartSection() {
</div>
<div
className="mx-auto max-w-2xl rounded-[28px] border bg-white p-3 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.25)]"
className="mx-auto max-w-2xl rounded-[28px] border bg-card p-3 text-card-foreground shadow-[0_24px_60px_-28px_hsl(var(--foreground)/0.18)]"
style={{ borderColor: 'hsl(var(--border-card))' }}
>
<div
className="grid grid-cols-1 gap-2 rounded-2xl p-1.5 md:grid-cols-3"
style={{ background: 'linear-gradient(180deg, rgba(248,250,252,0.98) 0%, rgba(241,245,249,0.92) 100%)' }}
className="grid grid-cols-1 gap-2 rounded-2xl bg-muted/70 p-1.5 md:grid-cols-3"
>
{tabs.map((tab) => {
const isActive = tab.id === currentTab.id
@ -130,9 +129,9 @@ export function LandingQuickStartSection() {
aria-pressed={isActive}
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
style={{
background: isActive ? 'rgba(255,255,255,0.96)' : 'transparent',
background: isActive ? 'hsl(var(--card))' : 'transparent',
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
boxShadow: isActive ? '0 6px 18px rgba(15, 23, 42, 0.08)' : 'none',
boxShadow: isActive ? '0 6px 18px hsl(var(--foreground) / 0.08)' : 'none',
}}
>
<Icon className="h-4 w-4" strokeWidth={1.75} />
@ -151,13 +150,12 @@ export function LandingQuickStartSection() {
</p>
<div
className="relative rounded-2xl border bg-slate-50/90 px-4 py-3 pr-14 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]"
className="relative rounded-2xl border bg-muted/65 px-4 py-3 pr-16"
style={{ borderColor: 'hsl(var(--border))' }}
>
<div className="overflow-x-auto whitespace-nowrap">
<code
className="font-mono text-sm md:text-base"
style={{ color: currentTab.id === 'agent' ? '#16A34A' : '#0F172A' }}
className={`font-mono text-sm md:text-base ${currentTab.id === 'agent' ? 'text-emerald-700 dark:text-emerald-400' : 'text-foreground'}`}
>
{currentTab.command}
</code>

View file

@ -65,8 +65,8 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
onClick={() => setOpen((current) => !current)}
>
<Globe className="h-4 w-4" />
<span className="text-sm text-inherit">{currentLanguage.name}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
<span className="hidden text-sm text-inherit sm:inline">{currentLanguage.name}</span>
<ChevronDown className="hidden h-3.5 w-3.5 opacity-70 sm:block" />
</Button>
{open ? (
<div className="absolute right-0 top-full z-50 pt-2">

View file

@ -0,0 +1,34 @@
/** @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { THEME_STORAGE_KEY } from '@/shared/lib/theme'
import { ThemeToggle } from './theme-toggle'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ThemeToggle', () => {
beforeEach(() => {
window.localStorage.clear()
document.documentElement.classList.remove('dark')
document.documentElement.dataset.theme = 'light'
document.documentElement.style.colorScheme = 'light'
})
it('switches theme and keeps the selection in browser-local storage', () => {
render(<ThemeToggle />)
const toggle = screen.getByRole('button', { name: 'theme.switchToDark' })
expect(toggle.getAttribute('aria-pressed')).toBe('false')
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: 'theme.switchToLight' }).getAttribute('aria-pressed')).toBe('true')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
})
})

View file

@ -0,0 +1,44 @@
import { Moon, Sun } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useTheme } from '@/shared/hooks/use-theme'
import { cn } from '@/shared/lib/utils'
interface ThemeToggleProps {
className?: string
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { t } = useTranslation()
const { theme, toggleTheme } = useTheme()
const isDark = theme === 'dark'
const label = isDark ? t('theme.switchToLight') : t('theme.switchToDark')
return (
<button
type="button"
aria-label={label}
aria-pressed={isDark}
title={label}
onClick={toggleTheme}
className={cn(
'group relative inline-flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/70 bg-card/80 text-muted-foreground shadow-sm transition-colors duration-200 hover:border-primary/35 hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
>
<Sun
aria-hidden="true"
className={cn(
'absolute h-[18px] w-[18px] transition-[opacity,transform] duration-200 motion-reduce:transition-none',
isDark ? 'rotate-90 scale-75 opacity-0' : 'rotate-0 scale-100 opacity-100',
)}
/>
<Moon
aria-hidden="true"
className={cn(
'absolute h-[18px] w-[18px] transition-[opacity,transform] duration-200 motion-reduce:transition-none',
isDark ? 'rotate-0 scale-100 opacity-100' : '-rotate-90 scale-75 opacity-0',
)}
/>
</button>
)
}

View file

@ -130,7 +130,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
className="w-8 h-8 rounded-full border border-border/60"
/>
)}
<span className="text-sm font-medium text-inherit">
<span className="hidden text-sm font-medium text-inherit lg:inline">
{user.displayName}
</span>
</button>

View file

@ -0,0 +1,24 @@
import { useCallback, useState } from 'react'
import {
applyTheme,
DEFAULT_THEME,
readStoredTheme,
saveTheme,
type Theme,
} from '@/shared/lib/theme'
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme() ?? DEFAULT_THEME)
const setTheme = useCallback((nextTheme: Theme) => {
applyTheme(nextTheme)
saveTheme(nextTheme)
setThemeState(nextTheme)
}, [])
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : 'light')
}, [setTheme, theme])
return { theme, setTheme, toggleTheme }
}

View file

@ -0,0 +1,53 @@
/** @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from 'vitest'
import {
applyTheme,
DEFAULT_THEME,
initializeTheme,
readStoredTheme,
saveTheme,
THEME_STORAGE_KEY,
} from './theme'
describe('theme preference', () => {
beforeEach(() => {
window.localStorage.clear()
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('data-theme')
document.documentElement.style.colorScheme = ''
})
it('uses light when the browser has no saved preference', () => {
expect(initializeTheme()).toBe(DEFAULT_THEME)
expect(document.documentElement.classList.contains('dark')).toBe(false)
expect(document.documentElement.dataset.theme).toBe('light')
expect(document.documentElement.style.colorScheme).toBe('light')
})
it('restores a valid browser-local preference before render', () => {
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark')
expect(initializeTheme()).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(document.documentElement.dataset.theme).toBe('dark')
})
it('ignores invalid and inaccessible storage values', () => {
expect(readStoredTheme({ getItem: () => 'system', setItem: () => undefined })).toBeNull()
expect(readStoredTheme({
getItem: () => {
throw new Error('blocked')
},
setItem: () => undefined,
})).toBeNull()
})
it('applies and saves only the selected frontend theme', () => {
applyTheme('dark')
saveTheme('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
})
})

View file

@ -0,0 +1,56 @@
export type Theme = 'light' | 'dark'
export const THEME_STORAGE_KEY = 'skillhub-theme'
export const DEFAULT_THEME: Theme = 'light'
interface ThemeStorage {
getItem(key: string): string | null
setItem(key: string, value: string): void
}
export function isTheme(value: unknown): value is Theme {
return value === 'light' || value === 'dark'
}
export function readStoredTheme(storage: ThemeStorage | undefined = getBrowserStorage()): Theme | null {
if (!storage) return null
try {
const value = storage.getItem(THEME_STORAGE_KEY)
return isTheme(value) ? value : null
} catch {
return null
}
}
export function applyTheme(theme: Theme, root: HTMLElement = document.documentElement) {
root.classList.toggle('dark', theme === 'dark')
root.dataset.theme = theme
root.style.colorScheme = theme
}
export function initializeTheme(root: HTMLElement = document.documentElement): Theme {
const theme = readStoredTheme() ?? DEFAULT_THEME
applyTheme(theme, root)
return theme
}
export function saveTheme(theme: Theme, storage: ThemeStorage | undefined = getBrowserStorage()) {
if (!storage) return
try {
storage.setItem(THEME_STORAGE_KEY, theme)
} catch {
// Storage can be unavailable in private browsing or hardened browser contexts.
}
}
function getBrowserStorage(): Storage | undefined {
if (typeof window === 'undefined') return undefined
try {
return window.localStorage
} catch {
return undefined
}
}

View file

@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const INPUT_BASE_CLASS_NAME =
'flex h-11 w-full rounded-lg border bg-white px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
'flex h-11 w-full rounded-lg border bg-background px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, style, ...props }, ref) => {