feat: Premium design overhaul — typography, atmosphere, animations, component polish

- Add Plus Jakarta Sans as display font for headings
- Add subtle noise texture overlay + indigo radial gradient for depth
- New keyframe animations: glow-pulse, fade-in-up, scale-in, slide-in-right
- Card: interactive hover-lift + selected ring variants
- Button: scale micro-interactions, destructive glow, transition-all
- Header: logo upgrade with wordmark, animated nav indicator bar, glass search button, gradient shadow depth
- StatusDot: glow halos per status variant (active/success/error/warning/urgent)
- HealthDot: glow effects for connected/disconnected/reconnecting states
- Card hover-lift and status glow CSS utilities
This commit is contained in:
Lukas May
2026-03-04 07:30:06 +01:00
parent dd86f12057
commit 7e60cbfff9
10 changed files with 233 additions and 48 deletions

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codewalk District</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap" rel="stylesheet">
<script>
(function() {
var t = localStorage.getItem('cw-theme') || 'system';

View File

@@ -1,32 +1,35 @@
import { trpc } from "@/lib/trpc";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { ConnectionState } from "@/hooks/useConnectionStatus";
export function HealthDot() {
const health = trpc.healthCheck.useQuery(undefined, {
refetchInterval: 30000,
retry: 1,
});
interface HealthDotProps {
connectionState: ConnectionState;
}
const isHealthy = health.data && !health.isError;
const isLoading = health.isLoading;
const healthGlow: Record<ConnectionState, string> = {
connected: "0 0 6px 1px hsl(var(--status-success-dot) / 0.4)",
disconnected: "0 0 6px 1px hsl(var(--status-error-dot) / 0.5)",
reconnecting: "0 0 6px 1px hsl(var(--status-neutral-dot) / 0.3)",
};
export function HealthDot({ connectionState }: HealthDotProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
isLoading && "bg-status-neutral-dot animate-status-pulse",
isHealthy && "bg-status-success-dot",
!isHealthy && !isLoading && "bg-status-error-dot",
connectionState === "reconnecting" && "bg-status-neutral-dot animate-status-pulse",
connectionState === "connected" && "bg-status-success-dot",
connectionState === "disconnected" && "bg-status-error-dot",
)}
style={{ boxShadow: healthGlow[connectionState] }}
/>
</TooltipTrigger>
<TooltipContent>
{isLoading
? "Checking server..."
: isHealthy
{connectionState === "reconnecting"
? "Reconnecting..."
: connectionState === "connected"
? "Server connected"
: "Server disconnected"}
</TooltipContent>

View File

@@ -11,6 +11,15 @@ const dotColors: Record<StatusVariant, string> = {
urgent: "bg-status-urgent-dot",
};
const glowMap: Record<StatusVariant, string | undefined> = {
active: "0 0 6px 1px hsl(var(--status-active-dot) / 0.5)",
success: "0 0 6px 1px hsl(var(--status-success-dot) / 0.4)",
warning: "0 0 6px 1px hsl(var(--status-warning-dot) / 0.4)",
error: "0 0 6px 1px hsl(var(--status-error-dot) / 0.5)",
neutral: undefined,
urgent: "0 0 6px 1px hsl(var(--status-urgent-dot) / 0.4)",
};
/** Maps raw entity status strings to semantic StatusVariant tokens. */
export function mapEntityStatus(rawStatus: string): StatusVariant {
switch (rawStatus) {
@@ -84,6 +93,7 @@ export function StatusDot({
const variant = variantOverride ?? mapEntityStatus(status);
const color = dotColors[variant];
const glow = glowMap[variant];
const displayLabel = label ?? status.replace(/_/g, " ").toLowerCase();
return (
@@ -94,9 +104,10 @@ export function StatusDot({
"inline-block shrink-0 rounded-full",
sizeClasses[size],
color,
pulse && "animate-status-pulse",
pulse && "animate-status-pulse transition-transform",
className,
)}
style={glow ? { boxShadow: glow } : undefined}
/>
);
}

View File

@@ -5,18 +5,18 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-md active:scale-[0.98]",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90 hover:shadow-[0_0_12px_hsl(var(--destructive)/0.3)]",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground active:scale-[0.98]",
link: "text-primary underline-offset-4 hover:underline",
},
size: {

View File

@@ -2,19 +2,25 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
interactive?: boolean;
selected?: boolean;
}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, interactive, selected, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
interactive && "transition-all duration-200 ease-out hover:shadow-md hover:-translate-y-0.5 hover:border-border/80 cursor-pointer",
selected && "ring-1 ring-primary/30 border-primary/20",
className
)}
{...props}
/>
))
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<

View File

@@ -150,6 +150,13 @@
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Spacing tokens */
--space-section: 2rem;
--space-section-lg: 3rem;
/* Background atmosphere */
--bg-gradient: radial-gradient(ellipse 80% 60% at 50% -10%, hsl(239 84% 67% / 0.04), transparent 70%);
/* Z-index scale */
--z-base: 0;
--z-raised: 1;
@@ -259,6 +266,9 @@
--diff-remove-border: 0 84% 25%;
--diff-hunk-bg: 239 40% 16%;
/* Background atmosphere — dark mode */
--bg-gradient: radial-gradient(ellipse 80% 60% at 50% -10%, hsl(239 84% 67% / 0.06), transparent 70%);
/* Shadow tokens — dark mode (inset highlights + ambient glow) */
--shadow-xs: none;
--shadow-sm: inset 0 1px 0 hsl(0 0% 100% / 0.04);
@@ -320,9 +330,28 @@ select:focus-visible {
min-width: 320px;
height: 100vh;
overflow: hidden;
background-image: var(--bg-gradient);
background-attachment: fixed;
}
}
/* Noise texture overlay */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.015;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
.dark body::before {
opacity: 0.03;
}
/* Notion-style page link blocks inside the editor */
.page-link-block {
display: flex;
@@ -426,3 +455,27 @@ select:focus-visible {
.ProseMirror pre code::after {
content: none;
}
@layer utilities {
.card-hover-lift {
transition: transform var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out);
}
.card-hover-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.glow-active {
box-shadow: 0 0 8px hsl(var(--status-active-dot) / 0.4);
}
.glow-success {
box-shadow: 0 0 8px hsl(var(--status-success-dot) / 0.4);
}
.glow-error {
box-shadow: 0 0 8px hsl(var(--status-error-dot) / 0.4);
}
.glow-warning {
box-shadow: 0 0 8px hsl(var(--status-warning-dot) / 0.4);
}
}

View File

@@ -4,6 +4,7 @@ import { ThemeToggle } from '@/components/ThemeToggle'
import { HealthDot } from '@/components/HealthDot'
import { NavBadge } from '@/components/NavBadge'
import { trpc } from '@/lib/trpc'
import type { ConnectionState } from '@/hooks/useConnectionStatus'
const navItems = [
{ label: 'Initiatives', to: '/initiatives', badgeKey: null },
@@ -15,9 +16,10 @@ const navItems = [
interface AppLayoutProps {
children: React.ReactNode
onOpenCommandPalette?: () => void
connectionState: ConnectionState
}
export function AppLayout({ children, onOpenCommandPalette }: AppLayoutProps) {
export function AppLayout({ children, onOpenCommandPalette, connectionState }: AppLayoutProps) {
const agents = trpc.listAgents.useQuery(undefined, {
refetchInterval: 10000,
})
@@ -30,41 +32,51 @@ export function AppLayout({ children, onOpenCommandPalette }: AppLayoutProps) {
return (
<div className="flex h-screen flex-col bg-background">
{/* Single-row 48px header */}
<header className="z-sticky shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-12 items-center justify-between px-4">
<header className="relative z-sticky shrink-0 border-b border-border/50 bg-background/95 shadow-[0_1px_0_0_hsl(var(--border)/0.5),0_1px_3px_-1px_hsl(var(--primary)/0.1)] backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-12 items-center justify-between px-5">
{/* Left: Logo + Nav */}
<div className="flex items-center gap-6">
<Link to="/initiatives" className="flex items-center gap-2 text-sm font-bold tracking-tight">
<span className="inline-flex h-6 w-6 items-center justify-center rounded bg-primary text-[10px] font-bold text-primary-foreground">
<Link to="/initiatives" className="flex items-center gap-2.5">
<span className="inline-flex h-7 w-7 items-center justify-center rounded bg-primary text-[11px] font-bold text-primary-foreground shadow-sm ring-1 ring-primary/20">
CW
</span>
<span className="hidden sm:inline">Codewalk District</span>
<span className="hidden items-baseline gap-1 sm:inline-flex">
<span className="font-display font-bold tracking-tight">Codewalk</span>
<span className="font-display font-medium tracking-tight text-muted-foreground">District</span>
</span>
</Link>
<nav className="flex items-center gap-0.5">
<nav className="flex items-center gap-1">
{navItems.map((item) => (
<Link
key={item.label}
to={item.to}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors duration-fast hover:text-foreground"
className="relative flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors duration-fast hover:text-foreground"
activeProps={{
className: 'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-foreground bg-accent',
className: 'relative flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-semibold text-foreground',
}}
>
{({ isActive }) => (
<>
{item.label}
{item.badgeKey && (
<NavBadge count={badgeCounts[item.badgeKey]} />
)}
{isActive && (
<span className="absolute -bottom-[13px] left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-primary" />
)}
</>
)}
</Link>
))}
</nav>
</div>
{/* Right: Cmd+K, Theme toggle, Health, Workspace */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{onOpenCommandPalette && (
<button
onClick={onOpenCommandPalette}
className="flex items-center gap-1.5 rounded-md border bg-muted/50 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background/80 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm transition-all duration-fast hover:border-border hover:bg-accent/80 hover:text-foreground"
>
<Search className="h-3 w-3" />
<span className="hidden sm:inline">Search</span>
@@ -73,8 +85,9 @@ export function AppLayout({ children, onOpenCommandPalette }: AppLayoutProps) {
</kbd>
</button>
)}
<div className="mx-1 h-4 w-px bg-border/50" />
<ThemeToggle />
<HealthDot />
<HealthDot connectionState={connectionState} />
</div>
</div>
</header>

View File

@@ -18,6 +18,7 @@ export default {
fontFamily: {
sans: ["Geist Sans", ...defaultTheme.fontFamily.sans],
mono: ["Geist Mono", ...defaultTheme.fontFamily.mono],
display: ['"Plus Jakarta Sans"', "var(--font-geist-sans)", "system-ui", "sans-serif"],
},
colors: {
border: "hsl(var(--border))",
@@ -168,12 +169,37 @@ export default {
"0%": { backgroundPosition: "-200% 0" },
"100%": { backgroundPosition: "200% 0" },
},
"glow-pulse": {
"0%, 100%": { boxShadow: "0 0 4px var(--glow-color, hsl(239 84% 67% / 0.3))" },
"50%": { boxShadow: "0 0 12px var(--glow-color, hsl(239 84% 67% / 0.5))" },
},
"fade-in-up": {
"0%": { opacity: "0", transform: "translateY(8px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
"scale-in": {
"0%": { opacity: "0", transform: "scale(0.95)" },
"100%": { opacity: "1", transform: "scale(1)" },
},
"slide-in-right": {
"0%": { opacity: "0", transform: "translateX(16px)" },
"100%": { opacity: "1", transform: "translateX(0)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"status-pulse": "status-pulse 2s ease-in-out infinite",
shimmer: "shimmer 1.5s infinite",
"glow-pulse": "glow-pulse 2s ease-in-out infinite",
"fade-in-up": "fade-in-up 0.4s var(--ease-out) forwards",
"fade-in": "fade-in 0.3s var(--ease-out) forwards",
"scale-in": "scale-in 0.3s var(--ease-out) forwards",
"slide-in-right": "slide-in-right 0.3s var(--ease-out) forwards",
},
},
},

71
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"execa": "^9.5.2",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"motion": "^12.34.5",
"nanoid": "^5.1.6",
"pino": "^10.3.0",
"simple-git": "^3.30.0",
@@ -32,7 +33,7 @@
"zod": "^4.3.6"
},
"bin": {
"cw": "dist/bin/cw.js"
"cw": "apps/server/dist/bin/cw.js"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
@@ -6699,6 +6700,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.34.5",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.5.tgz",
"integrity": "sha512-Z2dQ+o7BsfpJI3+u0SQUNCrN+ajCKJen1blC4rCHx1Ta2EOHs+xKJegLT2aaD9iSMbU3OoX+WabQXkloUbZmJQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.34.5",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -7409,6 +7437,47 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/motion": {
"version": "12.34.5",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.34.5.tgz",
"integrity": "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.34.5",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.34.5",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.5.tgz",
"integrity": "sha512-k33CsnxO2K3gBRMUZT+vPmc4Utlb5menKdG0RyVNLtlqRaaJPRWlE9fXl8NTtfZ5z3G8TDvqSu0MENLqSTaHZA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -41,6 +41,7 @@
"execa": "^9.5.2",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"motion": "^12.34.5",
"nanoid": "^5.1.6",
"pino": "^10.3.0",
"simple-git": "^3.30.0",