fix(ui): refine dashboard components for Warm Precision tone
- KpiCards: remove gradient/glow, flatten to data-oriented panels - BandwidthChart: replace hardcoded blue (#38BDF8) with accent token, use token colors for axis text and cursor - QuickActions: replace icon grid with command-style list rows with left-border hover interaction - EventsTimeline: remove timeline/skeleton, tighten to log-stream layout with divide separators and monospace timestamps - Light mode: bump border-default opacity 0.12→0.14, darken text-secondary for dense readability Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,7 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
|
|||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
tickFormatter={formatAxisTick}
|
tickFormatter={formatAxisTick}
|
||||||
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
tick={{ fontSize: 10, fill: 'hsl(var(--text-muted))' }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
@@ -86,19 +86,19 @@ export function BandwidthChart({ devices }: BandwidthChartProps) {
|
|||||||
type="category"
|
type="category"
|
||||||
dataKey="hostname"
|
dataKey="hostname"
|
||||||
width={120}
|
width={120}
|
||||||
tick={{ fontSize: 11, fill: '#cbd5e1' }}
|
tick={{ fontSize: 10, fill: 'hsl(var(--text-secondary))' }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<BwTooltip />}
|
content={<BwTooltip />}
|
||||||
cursor={{ fill: '#334155', opacity: 0.5 }}
|
cursor={{ fill: 'hsl(var(--elevated))', opacity: 0.5 }}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="bandwidthBps"
|
dataKey="bandwidthBps"
|
||||||
fill="#38BDF8"
|
fill="hsl(var(--accent))"
|
||||||
radius={[0, 4, 4, 0]}
|
radius={[0, 2, 2, 0]}
|
||||||
maxBarSize={24}
|
maxBarSize={20}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Bell, Server, HardDrive } from 'lucide-react'
|
import { Bell, Server, HardDrive } from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { eventsApi, type DashboardEvent, type EventsParams } from '@/lib/eventsApi'
|
import { eventsApi, type DashboardEvent, type EventsParams } from '@/lib/eventsApi'
|
||||||
import { DeviceLink } from '@/components/ui/device-link'
|
import { DeviceLink } from '@/components/ui/device-link'
|
||||||
@@ -83,22 +82,6 @@ function EventIcon({ event }: { event: DashboardEvent }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3 pl-3">
|
|
||||||
<Skeleton className="h-4 w-4 rounded-full shrink-0 mt-0.5" />
|
|
||||||
<div className="flex-1 space-y-1.5">
|
|
||||||
<Skeleton className="h-3.5 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-1/2" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-3 w-12 shrink-0" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps) {
|
export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps) {
|
||||||
const [filterType, setFilterType] = useState<EventFilter>(undefined)
|
const [filterType, setFilterType] = useState<EventFilter>(undefined)
|
||||||
@@ -115,86 +98,79 @@ export function EventsTimeline({ tenantId, isSuperAdmin }: EventsTimelineProps)
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-panel border-border">
|
<div className="bg-panel border border-border rounded-sm">
|
||||||
<CardHeader className="pb-2">
|
<div className="px-3 py-2 border-b border-border-subtle bg-elevated flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
|
||||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
Recent Events
|
||||||
Recent Events
|
</span>
|
||||||
</CardTitle>
|
<div className="flex gap-0.5">
|
||||||
<div className="flex gap-1">
|
{FILTERS.map((f) => (
|
||||||
{FILTERS.map((f) => (
|
<button
|
||||||
<button
|
key={f.label}
|
||||||
key={f.label}
|
onClick={() => setFilterType(f.value)}
|
||||||
onClick={() => setFilterType(f.value)}
|
className={cn(
|
||||||
className={cn(
|
'px-1.5 py-0.5 rounded-sm text-[10px] font-medium transition-[background-color,color] duration-[50ms]',
|
||||||
'px-2 py-0.5 rounded text-xs font-medium transition-colors',
|
filterType === f.value
|
||||||
filterType === f.value
|
? 'bg-accent-soft text-accent'
|
||||||
? 'bg-accent/15 text-accent'
|
: 'text-text-muted hover:text-text-secondary',
|
||||||
: 'text-text-muted hover:text-text-secondary hover:bg-elevated/50',
|
)}
|
||||||
)}
|
>
|
||||||
>
|
{f.label}
|
||||||
{f.label}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div>
|
||||||
{isSuperAdmin && !tenantId ? (
|
{isSuperAdmin && !tenantId ? (
|
||||||
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
<div className="py-5 text-center text-[9px] text-text-muted">
|
||||||
Select a tenant to view events
|
Select a tenant to view events
|
||||||
</div>
|
</div>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<TimelineSkeleton />
|
<div className="py-5 text-center text-[9px] text-text-muted">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
) : !events || events.length === 0 ? (
|
) : !events || events.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
<div className="py-5 text-center text-[9px] text-text-muted">
|
||||||
No recent events
|
No recent events
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[400px] overflow-y-auto pr-1">
|
<div className="max-h-[400px] overflow-y-auto divide-y divide-border-subtle">
|
||||||
<div className="relative border-l-2 border-border ml-2">
|
{events.map((event) => (
|
||||||
{events.map((event) => (
|
<div
|
||||||
<div
|
key={event.id}
|
||||||
key={event.id}
|
className="flex items-center gap-2.5 px-3 py-1.5"
|
||||||
className="relative flex items-start gap-3 pb-3 pl-4 last:pb-0"
|
>
|
||||||
>
|
<div className="shrink-0">
|
||||||
{/* Icon positioned over the timeline line */}
|
<EventIcon event={event} />
|
||||||
<div className="absolute -left-[9px] top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-panel">
|
</div>
|
||||||
<EventIcon event={event} />
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<span className="text-xs text-text-primary truncate block">
|
||||||
|
{event.title}
|
||||||
{/* Content */}
|
</span>
|
||||||
<div className="flex-1 min-w-0 ml-1">
|
<span className="text-[10px] text-text-muted truncate block">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">
|
{event.description}
|
||||||
{event.title}
|
{event.device_hostname && (
|
||||||
</p>
|
<span className="ml-1 text-text-secondary">
|
||||||
<p className="text-xs text-text-muted truncate">
|
—{' '}
|
||||||
{event.description}
|
{event.device_id ? (
|
||||||
{event.device_hostname && (
|
<DeviceLink tenantId={tenantId} deviceId={event.device_id}>
|
||||||
<span className="ml-1 text-text-secondary">
|
{event.device_hostname}
|
||||||
—{' '}
|
</DeviceLink>
|
||||||
{event.device_id ? (
|
) : (
|
||||||
<DeviceLink tenantId={tenantId} deviceId={event.device_id}>
|
event.device_hostname
|
||||||
{event.device_hostname}
|
)}
|
||||||
</DeviceLink>
|
</span>
|
||||||
) : (
|
)}
|
||||||
event.device_hostname
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<span className="text-xs text-text-muted whitespace-nowrap shrink-0 mt-0.5">
|
|
||||||
{formatRelativeTime(event.timestamp)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="text-[10px] font-mono text-text-muted whitespace-nowrap shrink-0">
|
||||||
</div>
|
{formatRelativeTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,45 +44,31 @@ function KpiCard({
|
|||||||
const animatedValue = useAnimatedCounter(value, 800, decimals)
|
const animatedValue = useAnimatedCounter(value, 800, decimals)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-gradient-to-br from-[#f8f8ff] to-elevated dark:from-elevated dark:to-[#16162a] border-border transition-colors',
|
'bg-panel border border-border px-3 py-2.5 rounded-sm',
|
||||||
highlight && 'border-warning/30',
|
highlight && 'border-l-2 border-l-warning',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="p-4">
|
<div className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px] mb-1">
|
||||||
<div className="flex items-center justify-between">
|
{label}
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<span className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
<div className="flex items-baseline gap-1">
|
||||||
{label}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
<div className="flex items-baseline gap-1">
|
'text-lg font-medium tabular-nums font-mono',
|
||||||
<span
|
colorClass,
|
||||||
className={cn(
|
)}
|
||||||
'text-2xl font-medium tabular-nums font-mono',
|
>
|
||||||
colorClass,
|
{decimals > 0 ? animatedValue.toFixed(decimals) : animatedValue}
|
||||||
)}
|
</span>
|
||||||
>
|
{suffix && (
|
||||||
{decimals > 0 ? animatedValue.toFixed(decimals) : animatedValue}
|
<span className="text-xs text-text-muted">
|
||||||
</span>
|
{suffix}
|
||||||
{suffix && (
|
</span>
|
||||||
<span className="text-sm font-medium text-text-muted">
|
)}
|
||||||
{suffix}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-elevated/50',
|
|
||||||
colorClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,34 +72,38 @@ export function QuickActions({ tenantId, isSuperAdmin }: QuickActionsProps) {
|
|||||||
const actions = getActions(tenantId, isSuperAdmin)
|
const actions = getActions(tenantId, isSuperAdmin)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-panel border-border">
|
<div className="bg-panel border border-border rounded-sm">
|
||||||
<CardHeader className="pb-2">
|
<div className="px-3 py-2 border-b border-border-subtle bg-elevated">
|
||||||
<CardTitle className="text-sm font-medium text-text-secondary">
|
<span className="text-[7px] font-medium text-text-muted uppercase tracking-[1.5px]">
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</CardTitle>
|
</span>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="divide-y divide-border-subtle">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{actions.map((action) => (
|
||||||
{actions.map((action) => (
|
<Link
|
||||||
<Link
|
key={action.label}
|
||||||
key={action.label}
|
to={action.to}
|
||||||
to={action.to}
|
className={cn(
|
||||||
className={cn(
|
'flex items-center gap-2.5 px-3 py-2',
|
||||||
'flex flex-col items-center gap-1.5 rounded-lg px-3 py-3',
|
'text-text-secondary hover:text-text-primary',
|
||||||
'text-text-secondary hover:bg-elevated/50 hover:text-text-primary',
|
'border-l-2 border-transparent hover:border-accent',
|
||||||
'transition-colors text-center',
|
'transition-[border-color,color] duration-[50ms]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-elevated/50">
|
<div className="text-text-muted">
|
||||||
{action.icon}
|
{action.icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium leading-tight">
|
<div className="min-w-0">
|
||||||
|
<span className="text-xs font-medium block">
|
||||||
{action.label}
|
{action.label}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
<span className="text-[10px] text-text-muted block">
|
||||||
))}
|
{action.description}
|
||||||
</div>
|
</span>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,10 +35,10 @@
|
|||||||
--elevated: 39 22% 92%; /* #f0ede4 */
|
--elevated: 39 22% 92%; /* #f0ede4 */
|
||||||
|
|
||||||
--border-subtle: 30 18% 13% / 0.06;
|
--border-subtle: 30 18% 13% / 0.06;
|
||||||
--border-default: 30 18% 13% / 0.12;
|
--border-default: 30 18% 13% / 0.14;
|
||||||
|
|
||||||
--text-primary: 43 27% 8%; /* #1a1810 */
|
--text-primary: 43 27% 8%; /* #1a1810 */
|
||||||
--text-secondary: 40 10% 33%; /* #5e5a4e */
|
--text-secondary: 40 12% 30%; /* #564e42 — slightly darker for dense readability */
|
||||||
--text-muted: 36 7% 50%; /* #8a8578 */
|
--text-muted: 36 7% 50%; /* #8a8578 */
|
||||||
|
|
||||||
--accent: 43 32% 40%; /* #8a7a48 */
|
--accent: 43 32% 40%; /* #8a7a48 */
|
||||||
|
|||||||
152
web_redesign.md
Normal file
152
web_redesign.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
We need to redesign the website to remove all startup / SaaS / marketing patterns.
|
||||||
|
|
||||||
|
Current issue:
|
||||||
|
It feels too “salesy” and corporate — like a product trying to convince people to use it.
|
||||||
|
|
||||||
|
That is the wrong tone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core direction
|
||||||
|
|
||||||
|
This should feel like:
|
||||||
|
- a tool
|
||||||
|
- a system
|
||||||
|
- a project that exists because it needed to exist
|
||||||
|
|
||||||
|
NOT:
|
||||||
|
- a startup landing page
|
||||||
|
- a marketing funnel
|
||||||
|
- a conversion-optimized site
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tone
|
||||||
|
|
||||||
|
- Direct
|
||||||
|
- Honest
|
||||||
|
- Slightly indifferent to whether the user signs up
|
||||||
|
- No hype language
|
||||||
|
- No promises
|
||||||
|
- No “transform your workflow” nonsense
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remove immediately
|
||||||
|
|
||||||
|
- Any “hero pitch” language
|
||||||
|
- Any “why choose us” sections
|
||||||
|
- Any fake trust signals (logos, testimonials, “companies use this”)
|
||||||
|
- Any email capture / gated content
|
||||||
|
- Any “Get Started Now” pressure CTAs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Replace with
|
||||||
|
|
||||||
|
### 1. Simple top section
|
||||||
|
|
||||||
|
- Project name
|
||||||
|
- One blunt sentence describing what it is
|
||||||
|
- Links:
|
||||||
|
- GitHub
|
||||||
|
- Docs
|
||||||
|
- Download / Self-host
|
||||||
|
- SaaS (optional, not emphasized)
|
||||||
|
|
||||||
|
Example tone:
|
||||||
|
“This is a network control system. It’s in active development. Things may break.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. “What it does” (not marketing)
|
||||||
|
|
||||||
|
- Bullet points
|
||||||
|
- No adjectives
|
||||||
|
- No hype
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
“Powerful, scalable, next-gen network automation”
|
||||||
|
|
||||||
|
Good:
|
||||||
|
- Monitor device state
|
||||||
|
- Push configuration
|
||||||
|
- Track changes
|
||||||
|
- Run commands across devices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. “What it is not”
|
||||||
|
|
||||||
|
This is important for your voice.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Not finished
|
||||||
|
- Not stable
|
||||||
|
- Not for everyone
|
||||||
|
- Not trying to replace everything
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Screenshots
|
||||||
|
|
||||||
|
- Real UI
|
||||||
|
- No mockups
|
||||||
|
- No gradients / marketing framing
|
||||||
|
- No captions trying to sell
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Development status
|
||||||
|
|
||||||
|
- Be explicit:
|
||||||
|
- versioning
|
||||||
|
- breaking changes
|
||||||
|
- expectations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Pricing (if present)
|
||||||
|
|
||||||
|
- Simple
|
||||||
|
- No anchoring tricks
|
||||||
|
- No “save 20%”
|
||||||
|
- No fake urgency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual direction
|
||||||
|
|
||||||
|
- Use the same Warm Precision system
|
||||||
|
- No big hero sections
|
||||||
|
- No oversized typography
|
||||||
|
- No soft gradients
|
||||||
|
- No “landing page layout”
|
||||||
|
|
||||||
|
Layout should feel like:
|
||||||
|
- documentation
|
||||||
|
- system panel
|
||||||
|
- terminal-adjacent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-pattern check
|
||||||
|
|
||||||
|
If any section feels like:
|
||||||
|
- it’s trying to convince the user
|
||||||
|
- it’s trying to impress the user
|
||||||
|
- it’s trying to capture the user
|
||||||
|
|
||||||
|
Remove or rewrite it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The site should feel like:
|
||||||
|
|
||||||
|
“This exists. If you need it, you’ll understand it.”
|
||||||
|
|
||||||
|
Not:
|
||||||
|
|
||||||
|
“Please use this product.”
|
||||||
Reference in New Issue
Block a user