feat(manager-web): align login + header with Heicode brand violet

Theme tokens (theme.css)
- --primary retuned to the brand violet oklch(0.58 0.17 278) ≈ #7B6BE3
  (was a saturated pink oklch ... 286 that read off-brand against the H
  glass logo)
- New tokens: --brand-from #B888E5, --brand #7B6BE3, --brand-to #6B7CE0,
  --gradient-brand (135deg three-stop), --gradient-brand-btn for primary
  CTAs. Both light and dark modes hold the same brand identity.

Login page (auth-layout.tsx + sign-in/index.tsx + user-auth-form.tsx)
- Inlined H glass mark SVG replaces the abstract ShieldCheck pictogram
- Brand wordmark uses gradient text-fill so the word "Heicode" reads as
  the same gradient as the logo
- "Tenant access" pill and h2 heading both pick up brand violet via
  border / bg / gradient text
- Sign-in button switches from solid var(--primary) to the three-stop
  --gradient-brand-btn with violet drop shadow + lift-on-hover

Header user info (profile-dropdown.tsx)
- The right-side trigger used to be a bare 36px avatar — invisible user
  identity unless you click. Now it is a pill: gradient-filled avatar
  initials + display name + email + role badge, always visible on >=sm
- Avatar fallback fills with --gradient-brand so even pre-image, the
  user pill carries Heicode color identity

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 15:24:04 +08:00
co-authored by Claude Opus 4.7
parent b55d103ea3
commit 88c5b4a285
5 changed files with 208 additions and 66 deletions
+43 -3
View File
@@ -30,11 +30,51 @@ export function ProfileDropdown() {
<> <>
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant='ghost' className='relative h-9 w-9 rounded-full p-0'> {/* Inline user pill: avatar + display name + email + role.
<Avatar className='h-9 w-9'> Always visible (no hover/click required) per docs §10
"用户登录信息要看得到". Click expands the existing sheet. */}
<Button
variant='ghost'
className='relative flex h-10 items-center gap-2.5 rounded-full border px-1.5 pe-3 ps-1 transition hover:bg-[color-mix(in_oklch,var(--primary)_10%,transparent)]'
style={{
borderColor: 'rgba(123,107,227,0.28)',
backgroundColor: 'rgba(123,107,227,0.06)',
}}
>
<Avatar className='h-7 w-7'>
<AvatarImage src='/avatars/01.png' alt={`@${displayName}`} /> <AvatarImage src='/avatars/01.png' alt={`@${displayName}`} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback
className='text-[10px] font-semibold text-white'
style={{ backgroundImage: 'var(--gradient-brand)' }}
>
{initials}
</AvatarFallback>
</Avatar> </Avatar>
<span className='hidden flex-col items-start leading-tight sm:flex'>
<span className='max-w-[140px] truncate text-xs font-semibold text-foreground'>
{displayName}
</span>
{user?.email ? (
<span className='max-w-[140px] truncate text-[10px] text-muted-foreground'>
{user.email}
</span>
) : roleLabel ? (
<span className='text-[10px] text-muted-foreground'>
{roleLabel}
</span>
) : null}
</span>
{roleLabel && user?.email && (
<span
className='hidden rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.12em] md:inline'
style={{
backgroundColor: 'rgba(123,107,227,0.14)',
color: 'var(--brand)',
}}
>
{roleLabel}
</span>
)}
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent <SheetContent
+67 -9
View File
@@ -1,7 +1,53 @@
import { ShieldCheck, Workflow, Layers, Activity } from 'lucide-react' import { Workflow, Layers, Activity } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { BRAND_NAME, BRAND_TAGLINE } from '@/lib/brand' import { BRAND_NAME, BRAND_TAGLINE } from '@/lib/brand'
// Heicode H glass mark, inlined so the login page never depends on a network
// fetch and the SVG renders before any JS hydrates. Gradient stops match
// docs/product-package brand reference and theme.css --brand* tokens.
function HeicodeMark({ className = 'h-10 w-10' }: { className?: string }) {
return (
<svg
viewBox='0 0 256 256'
xmlns='http://www.w3.org/2000/svg'
className={className}
aria-hidden='true'
>
<defs>
<linearGradient
id='login-mark-bg'
x1='0'
y1='0'
x2='256'
y2='256'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#B888E5' />
<stop offset='0.55' stopColor='#7B6BE3' />
<stop offset='1' stopColor='#6B7CE0' />
</linearGradient>
<linearGradient
id='login-mark-glass'
x1='0'
y1='0'
x2='0'
y2='256'
gradientUnits='userSpaceOnUse'
>
<stop offset='0' stopColor='#FFFFFF' stopOpacity='0.32' />
<stop offset='0.6' stopColor='#FFFFFF' stopOpacity='0' />
</linearGradient>
</defs>
<rect x='8' y='8' width='240' height='240' rx='56' fill='url(#login-mark-bg)' />
<rect x='8' y='8' width='240' height='240' rx='56' fill='url(#login-mark-glass)' />
<path
d='M74 64h28v54h52V64h28v128h-28v-54h-52v54H74z'
fill='#FFFFFF'
/>
</svg>
)
}
type AuthLayoutProps = { type AuthLayoutProps = {
children: React.ReactNode children: React.ReactNode
} }
@@ -34,11 +80,17 @@ export function AuthLayout({ children }: AuthLayoutProps) {
<div className='pointer-events-none absolute inset-0 [mask-image:radial-gradient(circle_at_center,black_55%,transparent_85%)] bg-[linear-gradient(transparent_95%,color-mix(in_oklch,var(--primary)_30%,transparent)_95%),linear-gradient(90deg,transparent_95%,color-mix(in_oklch,var(--primary)_30%,transparent)_95%)] bg-[size:42px_42px]' /> <div className='pointer-events-none absolute inset-0 [mask-image:radial-gradient(circle_at_center,black_55%,transparent_85%)] bg-[linear-gradient(transparent_95%,color-mix(in_oklch,var(--primary)_30%,transparent)_95%),linear-gradient(90deg,transparent_95%,color-mix(in_oklch,var(--primary)_30%,transparent)_95%)] bg-[size:42px_42px]' />
<div className='relative z-10 flex items-center gap-3'> <div className='relative z-10 flex items-center gap-3'>
<span className='inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-[color-mix(in_oklch,var(--primary)_25%,transparent)] text-primary ring-1 ring-[color-mix(in_oklch,var(--primary)_45%,var(--border))] backdrop-blur-xl'> <HeicodeMark className='h-12 w-12 drop-shadow-[0_8px_24px_rgba(123,107,227,0.45)]' />
<ShieldCheck className='h-5 w-5' />
</span>
<div className='leading-tight'> <div className='leading-tight'>
<p className='text-[11px] font-semibold tracking-[0.22em] text-primary uppercase'> <p
className='text-2xl font-semibold tracking-tight'
style={{
backgroundImage: 'var(--gradient-brand)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
color: 'transparent',
}}
>
{BRAND_NAME} {BRAND_NAME}
</p> </p>
<p className='text-sm text-muted-foreground'>{t(BRAND_TAGLINE)}</p> <p className='text-sm text-muted-foreground'>{t(BRAND_TAGLINE)}</p>
@@ -86,11 +138,17 @@ export function AuthLayout({ children }: AuthLayoutProps) {
<div className='absolute inset-0 -z-10 lg:hidden bg-[radial-gradient(circle_at_0%_0%,color-mix(in_oklch,var(--primary)_25%,transparent)_0,transparent_45%),linear-gradient(160deg,color-mix(in_oklch,var(--background)_94%,black),color-mix(in_oklch,var(--background)_98%,var(--primary)))]' /> <div className='absolute inset-0 -z-10 lg:hidden bg-[radial-gradient(circle_at_0%_0%,color-mix(in_oklch,var(--primary)_25%,transparent)_0,transparent_45%),linear-gradient(160deg,color-mix(in_oklch,var(--background)_94%,black),color-mix(in_oklch,var(--background)_98%,var(--primary)))]' />
<div className='w-full max-w-md'> <div className='w-full max-w-md'>
<div className='mb-8 flex items-center gap-2 lg:hidden'> <div className='mb-8 flex items-center gap-2 lg:hidden'>
<span className='inline-flex h-9 w-9 items-center justify-center rounded-xl bg-[color-mix(in_oklch,var(--primary)_25%,transparent)] text-primary ring-1 ring-[color-mix(in_oklch,var(--primary)_40%,var(--border))]'> <HeicodeMark className='h-10 w-10' />
<ShieldCheck className='h-4 w-4' />
</span>
<div className='leading-tight'> <div className='leading-tight'>
<p className='text-[10px] font-semibold tracking-[0.2em] text-primary uppercase'> <p
className='text-lg font-semibold tracking-tight'
style={{
backgroundImage: 'var(--gradient-brand)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
color: 'transparent',
}}
>
{BRAND_NAME} {BRAND_NAME}
</p> </p>
<p className='text-xs text-muted-foreground'>{t(BRAND_TAGLINE)}</p> <p className='text-xs text-muted-foreground'>{t(BRAND_TAGLINE)}</p>
@@ -154,7 +154,12 @@ export function UserAuthForm({
<Button <Button
type='submit' type='submit'
className='h-12 w-full justify-center gap-2 rounded-xl text-sm font-semibold shadow-[0_18px_48px_-22px_color-mix(in_oklch,var(--primary)_75%,black)]' className='h-12 w-full justify-center gap-2 rounded-xl text-sm font-semibold text-white shadow-[0_18px_48px_-18px_rgba(123,107,227,0.65)] transition-transform hover:translate-y-[-1px] hover:shadow-[0_22px_56px_-18px_rgba(123,107,227,0.75)] active:translate-y-0 active:scale-[0.99]'
style={{
backgroundImage: 'var(--gradient-brand-btn)',
backgroundColor: 'var(--brand)',
border: '1px solid rgba(255,255,255,0.16)',
}}
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
+21 -3
View File
@@ -12,11 +12,29 @@ export function SignIn() {
<AuthLayout> <AuthLayout>
<div className='space-y-8'> <div className='space-y-8'>
<div className='space-y-3'> <div className='space-y-3'>
<span className='inline-flex items-center gap-2 rounded-full border border-[color-mix(in_oklch,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklch,var(--primary)_12%,transparent)] px-3 py-1 text-[11px] font-semibold tracking-[0.18em] text-primary uppercase'> <span
<span className='inline-block h-1.5 w-1.5 rounded-full bg-primary' /> className='inline-flex items-center gap-2 rounded-full px-3 py-1 text-[11px] font-semibold tracking-[0.18em] uppercase'
style={{
border: '1px solid rgba(123,107,227,0.35)',
backgroundColor: 'rgba(123,107,227,0.10)',
color: 'var(--brand)',
}}
>
<span
className='inline-block h-1.5 w-1.5 rounded-full'
style={{ backgroundColor: 'var(--brand)' }}
/>
{t('Tenant access')} {t('Tenant access')}
</span> </span>
<h2 className='text-3xl font-semibold tracking-tight sm:text-4xl'> <h2
className='text-3xl font-semibold tracking-tight sm:text-4xl'
style={{
backgroundImage: 'var(--gradient-brand)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
color: 'transparent',
}}
>
{t('Sign in to your workspace')} {t('Sign in to your workspace')}
</h2> </h2>
<p className='text-sm text-muted-foreground sm:text-base'> <p className='text-sm text-muted-foreground sm:text-base'>
+71 -50
View File
@@ -1,76 +1,97 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* Brand reference — Heicode H glass mark.
* --brand-from ≈ #B888E5 (pink-violet highlight)
* --brand ≈ #7B6BE3 (primary violet)
* --brand-to ≈ #6B7CE0 (blue-violet)
* All --primary / --ring / chart-1 tokens are tuned around the brand main so
* buttons, focus rings, badges and active states read as the same color you
* see in the logo and login wordmark. Don't re-introduce the saturated pink
* oklch(... 286) values that existed previously — they drift off-brand. */
:root { :root {
--background: oklch(0.97 0.012 236); --background: oklch(0.97 0.012 268);
--foreground: oklch(0.23 0.03 252); --foreground: oklch(0.23 0.03 270);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.22 0.03 252); --card-foreground: oklch(0.22 0.03 270);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.03 252); --popover-foreground: oklch(0.22 0.03 270);
--primary: oklch(0.59 0.24 286); --primary: oklch(0.58 0.17 278); /* #7B6BE3 brand violet */
--primary-foreground: oklch(0.985 0.003 247); --primary-foreground: oklch(0.985 0.003 270);
--secondary: oklch(0.93 0.045 222); --secondary: oklch(0.93 0.045 270);
--secondary-foreground: oklch(0.28 0.06 254); --secondary-foreground: oklch(0.28 0.06 278);
--muted: oklch(0.95 0.02 240); --muted: oklch(0.95 0.02 270);
--muted-foreground: oklch(0.5 0.035 252); --muted-foreground: oklch(0.5 0.035 270);
--accent: oklch(0.9 0.08 174); --accent: oklch(0.74 0.12 269); /* #6B7CE0 secondary blue */
--accent-foreground: oklch(0.25 0.05 197); --accent-foreground: oklch(0.22 0.05 269);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.88 0.035 254); --border: oklch(0.88 0.035 270);
--input: oklch(0.88 0.035 254); --input: oklch(0.88 0.035 270);
--ring: oklch(0.62 0.24 286); --ring: oklch(0.62 0.17 278);
--chart-1: oklch(0.63 0.23 286); --chart-1: oklch(0.66 0.17 280);
--chart-2: oklch(0.67 0.14 193); --chart-2: oklch(0.67 0.14 269);
--chart-3: oklch(0.68 0.2 338); --chart-3: oklch(0.72 0.15 300);
--chart-4: oklch(0.76 0.17 130); --chart-4: oklch(0.76 0.17 130);
--chart-5: oklch(0.72 0.17 52); --chart-5: oklch(0.72 0.17 52);
--radius: 0.95rem; --radius: 0.95rem;
--sidebar: oklch(0.985 0.02 244); --sidebar: oklch(0.985 0.02 270);
--sidebar-foreground: var(--foreground); --sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary); --sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground); --sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.94 0.05 286); --sidebar-accent: oklch(0.94 0.05 278);
--sidebar-accent-foreground: oklch(0.3 0.06 286); --sidebar-accent-foreground: oklch(0.3 0.06 278);
--sidebar-border: oklch(0.87 0.05 254); --sidebar-border: oklch(0.88 0.04 270);
--sidebar-ring: var(--ring); --sidebar-ring: var(--ring);
--skeleton-base: oklch(0.948 0.004 264); --skeleton-base: oklch(0.948 0.004 270);
--skeleton-highlight: oklch(0.988 0.002 264); --skeleton-highlight: oklch(0.988 0.002 270);
/* Brand tokens — used by login wordmark, primary CTA gradient, brand chips. */
--brand-from: #b888e5;
--brand: #7b6be3;
--brand-to: #6b7ce0;
--gradient-brand: linear-gradient(135deg, #b888e5 0%, #7b6be3 50%, #6b7ce0 100%);
--gradient-brand-btn: linear-gradient(135deg, #8a78ea 0%, #7b6be3 55%, #6b7ce0 100%);
} }
.dark { .dark {
--background: oklch(0.16 0.03 282); --background: oklch(0.16 0.03 278);
--foreground: oklch(0.93 0.02 252); --foreground: oklch(0.93 0.02 270);
--card: oklch(0.21 0.03 281); --card: oklch(0.21 0.03 278);
--card-foreground: oklch(0.93 0.02 252); --card-foreground: oklch(0.93 0.02 270);
--popover: oklch(0.225 0.03 281); --popover: oklch(0.225 0.03 278);
--popover-foreground: oklch(0.93 0.02 252); --popover-foreground: oklch(0.93 0.02 270);
--primary: oklch(0.74 0.23 300); --primary: oklch(0.66 0.17 280); /* slightly brighter on dark bg */
--primary-foreground: oklch(0.18 0.03 282); --primary-foreground: oklch(0.16 0.03 278);
--secondary: oklch(0.29 0.05 238); --secondary: oklch(0.29 0.05 270);
--secondary-foreground: oklch(0.93 0.02 252); --secondary-foreground: oklch(0.93 0.02 270);
--muted: oklch(0.26 0.03 278); --muted: oklch(0.26 0.03 278);
--muted-foreground: oklch(0.76 0.025 252); --muted-foreground: oklch(0.76 0.025 270);
--accent: oklch(0.35 0.08 176); --accent: oklch(0.55 0.13 269);
--accent-foreground: oklch(0.93 0.03 176); --accent-foreground: oklch(0.95 0.02 270);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.35 0.06 286); --border: oklch(0.35 0.06 278);
--input: oklch(0.35 0.06 286); --input: oklch(0.35 0.06 278);
--ring: oklch(0.74 0.23 300); --ring: oklch(0.66 0.17 280);
--chart-1: oklch(0.74 0.23 300); --chart-1: oklch(0.7 0.17 280);
--chart-2: oklch(0.72 0.16 193); --chart-2: oklch(0.72 0.16 269);
--chart-3: oklch(0.7 0.2 338); --chart-3: oklch(0.7 0.2 300);
--chart-4: oklch(0.75 0.17 133); --chart-4: oklch(0.75 0.17 133);
--chart-5: oklch(0.75 0.17 52); --chart-5: oklch(0.75 0.17 52);
--sidebar: oklch(0.2 0.03 282); --sidebar: oklch(0.2 0.03 278);
--sidebar-foreground: oklch(0.92 0.02 252); --sidebar-foreground: oklch(0.92 0.02 270);
--sidebar-primary: var(--primary); --sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground); --sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.31 0.08 300); --sidebar-accent: oklch(0.31 0.08 280);
--sidebar-accent-foreground: oklch(0.93 0.02 252); --sidebar-accent-foreground: oklch(0.93 0.02 270);
--sidebar-border: oklch(0.35 0.06 286); --sidebar-border: oklch(0.35 0.06 278);
--sidebar-ring: var(--ring); --sidebar-ring: var(--ring);
--skeleton-base: oklch(0.31 0.014 265); --skeleton-base: oklch(0.31 0.014 270);
--skeleton-highlight: oklch(0.39 0.018 265); --skeleton-highlight: oklch(0.39 0.018 270);
--brand-from: #b888e5;
--brand: #7b6be3;
--brand-to: #6b7ce0;
--gradient-brand: linear-gradient(135deg, #b888e5 0%, #7b6be3 50%, #6b7ce0 100%);
--gradient-brand-btn: linear-gradient(135deg, #8a78ea 0%, #7b6be3 55%, #6b7ce0 100%);
} }
@theme inline { @theme inline {