feat(dashboard): premium icons, rail stretch, attention as own row
Iteration 008 per Eric's iteration-007 feedback. == Premium header icons == Replaced emoji glyphs in the action row with Hugeicons stroke icons + better button chrome: - New Chat: BubbleChatAddIcon on a real accent gradient button (was translucent tinted background) with inset highlight, soft drop shadow, and an animated overlay sheen on hover. - Terminal: ConsoleIcon - Skills: PuzzleIcon - Edit: Edit02Icon \u2192 CheckmarkCircle02Icon when active - Settings: Settings02Icon SecondaryAction component now takes a Hugeicons icon (not an emoji string), uses a subtle card gradient background, accent-colored icon on hover, and matching uppercase tracking for visual unity with the primary New Chat button. Edit + Settings icon-only buttons get the same treatment so the whole right-hand cluster reads as one premium control group. == Side rail height/balance == - Achievements: query bumped to `achievements=5` (was 3) and the card renders every unlock the aggregator returns. Switched from compact \u2192 full-detail rows so the card has body. Together this fills the gap between Top Models and the rest of the rail. - Side rail container: `min-h-full` so the column stretches to Sessions Intelligence height instead of collapsing to content. - Mix & rhythm card: `flex-1 h-full` + `justify-between` so it consumes the remaining vertical space at the bottom of the rail and aligns flush with Sessions Intelligence's lower edge. == Attention bar separated == Eric: 'is it cluttered or should be higher or lower / separate?' - Lifted the AttentionMarquee out of OpsStrip into its own dedicated row above the gateway strip. Now a self-contained warning-tinted chamber with its own border + gradient. Clearly reads as 'things to look at' separate from 'gateway is up'. - OpsStrip reverted to its single-row layout (no more nested vertical stack). - When there are no incidents, the row simply doesn't render \u2014 no empty frame. Build/tests: - pnpm exec vitest run src/server/dashboard-aggregator.test.ts (12/12) - pnpm build (passes) - tsc clean for src/screens/dashboard/* and src/server/dashboard*
This commit is contained in:
@@ -166,7 +166,7 @@ export function AchievementsCard({
|
||||
{achievements.totalUnlocked} unlocked · view all →
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{achievements.recentUnlocks.length === 0 ? (
|
||||
<div
|
||||
className="py-3 text-center text-[11px]"
|
||||
@@ -175,8 +175,11 @@ export function AchievementsCard({
|
||||
No unlocks yet — keep working.
|
||||
</div>
|
||||
) : (
|
||||
// Render every unlock the aggregator returns so the card
|
||||
// grows to consume vertical space (Eric's iter-007 ask).
|
||||
// Default count is now 5 so the rail has more presence.
|
||||
achievements.recentUnlocks.map((unlock) => (
|
||||
<AchievementRow key={unlock.id} unlock={unlock} compact />
|
||||
<AchievementRow key={unlock.id} unlock={unlock} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DashboardOverview } from '@/server/dashboard-aggregator'
|
||||
import { AttentionMarquee } from './attention-marquee'
|
||||
|
||||
function formatPulse(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
@@ -86,17 +85,10 @@ export function OpsStrip({
|
||||
status,
|
||||
cron,
|
||||
platforms,
|
||||
overview,
|
||||
}: {
|
||||
status: DashboardOverview['status']
|
||||
cron: DashboardOverview['cron']
|
||||
platforms: DashboardOverview['platforms']
|
||||
/**
|
||||
* Full overview is needed for the embedded attention marquee.
|
||||
* Optional so the strip remains usable as a standalone component
|
||||
* elsewhere (e.g. in tests or smaller screens that omit incidents).
|
||||
*/
|
||||
overview?: DashboardOverview | null
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
if (!status) return null
|
||||
@@ -115,18 +107,11 @@ export function OpsStrip({
|
||||
|
||||
const next = cron ? formatNextRun(cron.nextRunAt) : null
|
||||
|
||||
const incidentCount = overview?.incidents?.length ?? 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-md border bg-[var(--theme-card)]/50 px-3 py-2"
|
||||
className="flex flex-col gap-2 rounded-md border bg-[var(--theme-card)]/50 px-3 py-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4"
|
||||
style={{ borderColor: 'var(--theme-border)' }}
|
||||
>
|
||||
{/* Top row: gateway state on the left, attention marquee in the
|
||||
middle (only when there are incidents), platforms+cron on
|
||||
the right. Falls back to a single-column stack on narrow
|
||||
viewports. */}
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between lg:gap-4">
|
||||
{/* Gateway block: state + version + active agents */}
|
||||
<div className="flex items-center gap-3 text-[11px]">
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -274,13 +259,6 @@ export function OpsStrip({
|
||||
)
|
||||
})() : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Attention marquee row — dedicated row when present so the
|
||||
ticker has full width to breathe; collapses to nothing when
|
||||
the gateway is all-clear. */}
|
||||
{incidentCount > 0 && overview ? (
|
||||
<AttentionMarquee overview={overview} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function TokenMixHourCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col gap-3 overflow-hidden rounded-xl border p-3"
|
||||
className="relative flex h-full flex-1 flex-col justify-between gap-3 overflow-hidden rounded-xl border p-3"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(150deg, color-mix(in srgb, var(--theme-card) 96%, transparent), color-mix(in srgb, var(--theme-card) 92%, transparent))',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { SkillsUsageCard } from './components/skills-usage-card'
|
||||
import { TokenMixHourCard } from './components/token-mix-hour-card'
|
||||
import { ActiveModelKpi } from './components/active-model-kpi'
|
||||
import { AttentionMarquee } from './components/attention-marquee'
|
||||
import { WidgetShell } from './components/widget-shell'
|
||||
import { EditModePanel } from './components/edit-mode-panel'
|
||||
import { useDashboardLayout } from './lib/use-dashboard-layout'
|
||||
@@ -38,7 +39,20 @@ import { cn } from '@/lib/utils'
|
||||
import { openHamburgerMenu } from '@/components/mobile-hamburger-menu'
|
||||
import { applyTheme, useSettingsStore } from '@/hooks/use-settings'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Moon02Icon, Sun02Icon } from '@hugeicons/core-free-icons'
|
||||
import {
|
||||
Moon02Icon,
|
||||
Sun02Icon,
|
||||
BubbleChatAddIcon,
|
||||
ConsoleIcon,
|
||||
PuzzleIcon,
|
||||
Edit02Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
Settings02Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
|
||||
// `IconSvgObject` isn't exported from @hugeicons/react; reuse the
|
||||
// inferred type from a real icon import for prop typing.
|
||||
type HugeIcon = typeof Settings02Icon
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -470,7 +484,7 @@ function SecondaryAction({
|
||||
disabled,
|
||||
}: {
|
||||
label: string
|
||||
icon: string
|
||||
icon: HugeIcon
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
@@ -479,13 +493,20 @@ function SecondaryAction({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-xs font-medium transition-colors hover:bg-[var(--theme-card)]/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="group inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-semibold uppercase tracking-[0.05em] transition-all hover:scale-[1.015] hover:bg-[var(--theme-card)]/70 hover:text-[var(--theme-text)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--theme-border)',
|
||||
color: 'var(--theme-muted)',
|
||||
background:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--theme-card) 80%, transparent), transparent)',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{icon}</span>
|
||||
<HugeiconsIcon
|
||||
icon={icon}
|
||||
size={14}
|
||||
strokeWidth={1.6}
|
||||
className="transition-colors group-hover:text-[var(--theme-accent)]"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
@@ -783,8 +804,10 @@ export function DashboardScreen() {
|
||||
const overviewQuery = useQuery<DashboardOverview>({
|
||||
queryKey: ['dashboard', 'overview', period],
|
||||
queryFn: async () => {
|
||||
// achievements=5 (instead of 3) gives the Achievements rail
|
||||
// card enough vertical mass to fill the gap below Top Models.
|
||||
const res = await fetch(
|
||||
`/api/dashboard/overview?days=${period}`,
|
||||
`/api/dashboard/overview?days=${period}&achievements=5`,
|
||||
)
|
||||
if (!res.ok) throw new Error(`overview ${res.status}`)
|
||||
return (await res.json()) as DashboardOverview
|
||||
@@ -882,24 +905,36 @@ export function DashboardScreen() {
|
||||
params: { sessionKey: 'new' },
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-semibold transition-all hover:scale-[1.02] active:scale-[0.99]"
|
||||
className="group relative inline-flex items-center gap-2 overflow-hidden rounded-lg px-3.5 py-2 text-sm font-semibold uppercase tracking-[0.05em] transition-all hover:scale-[1.02] active:scale-[0.99]"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${palette.accent}22, ${palette.accentSecondary}22)`,
|
||||
borderColor: 'color-mix(in srgb, var(--theme-accent) 40%, transparent)',
|
||||
color: 'var(--theme-text)',
|
||||
background: `linear-gradient(135deg, ${palette.accent}, ${palette.accentSecondary})`,
|
||||
color: 'var(--theme-on-accent, white)',
|
||||
boxShadow: `0 6px 18px -8px ${palette.accent}aa, inset 0 1px 0 0 rgba(255,255,255,0.18)`,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>💬</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.15), transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
<HugeiconsIcon
|
||||
icon={BubbleChatAddIcon}
|
||||
size={16}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
<SecondaryAction
|
||||
label="Terminal"
|
||||
icon="💻"
|
||||
icon={ConsoleIcon}
|
||||
onClick={() => navigate({ to: '/terminal' })}
|
||||
/>
|
||||
<SecondaryAction
|
||||
label="Skills"
|
||||
icon="🧩"
|
||||
icon={PuzzleIcon}
|
||||
onClick={() => navigate({ to: '/skills' })}
|
||||
disabled={!skillsAvailable}
|
||||
/>
|
||||
@@ -911,46 +946,62 @@ export function DashboardScreen() {
|
||||
aria-label={layout.editMode ? 'Done editing layout' : 'Edit layout'}
|
||||
title={layout.editMode ? 'Done editing layout' : 'Edit layout'}
|
||||
onClick={layout.toggleEdit}
|
||||
className="flex size-9 items-center justify-center rounded-lg border transition-colors hover:bg-[var(--theme-card)]/80"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg border transition-all hover:scale-[1.05] hover:bg-[var(--theme-card)]/70"
|
||||
style={{
|
||||
borderColor: layout.editMode
|
||||
? 'var(--theme-accent)'
|
||||
: 'var(--theme-border)',
|
||||
background: layout.editMode
|
||||
? 'color-mix(in srgb, var(--theme-accent) 14%, transparent)'
|
||||
: 'transparent',
|
||||
: 'linear-gradient(135deg, color-mix(in srgb, var(--theme-card) 80%, transparent), transparent)',
|
||||
color: layout.editMode
|
||||
? 'var(--theme-accent)'
|
||||
: 'var(--theme-muted)',
|
||||
}}
|
||||
>
|
||||
{layout.editMode ? '✓' : '✏️'}
|
||||
<HugeiconsIcon
|
||||
icon={layout.editMode ? CheckmarkCircle02Icon : Edit02Icon}
|
||||
size={15}
|
||||
strokeWidth={1.7}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
onClick={() => navigate({ to: '/settings', search: {} })}
|
||||
className="flex size-9 items-center justify-center rounded-lg border transition-colors hover:bg-[var(--theme-card)]/80"
|
||||
className="inline-flex size-9 items-center justify-center rounded-lg border transition-all hover:scale-[1.05] hover:bg-[var(--theme-card)]/70 hover:text-[var(--theme-text)]"
|
||||
style={{
|
||||
borderColor: 'var(--theme-border)',
|
||||
color: 'var(--theme-muted)',
|
||||
background:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--theme-card) 80%, transparent), transparent)',
|
||||
}}
|
||||
>
|
||||
⚙️
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
size={15}
|
||||
strokeWidth={1.7}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Ops strip (gateway + version drift + platforms + cron pulse).
|
||||
Now also hosts the right-to-left Attention marquee per
|
||||
Eric's iteration-006 ask, replacing the standalone
|
||||
AttentionCard in the side rail. ── */}
|
||||
{/* ── Attention marquee ──
|
||||
Iteration 008: lifted *out* of the OpsStrip into its own
|
||||
dedicated row above it. Fixed Eric's 'feels cluttered'
|
||||
concern by giving the ticker its own visual chamber
|
||||
(warning gradient, separated border) so it doesn't blend
|
||||
into the gateway/version/cron line below it. */}
|
||||
{(overview?.incidents?.length ?? 0) > 0 ? (
|
||||
<AttentionMarquee overview={overview ?? null} />
|
||||
) : null}
|
||||
|
||||
{/* ── Ops strip (gateway + version drift + platforms + cron pulse). ── */}
|
||||
<OpsStrip
|
||||
status={overview?.status ?? null}
|
||||
cron={overview?.cron ?? null}
|
||||
platforms={overview?.platforms ?? []}
|
||||
overview={overview ?? null}
|
||||
/>
|
||||
|
||||
{/* ── Hero Metrics: 3 analytics tiles + Active Model KPI in slot 4 ── */}
|
||||
@@ -1033,8 +1084,11 @@ export function DashboardScreen() {
|
||||
{/* Side rail. Achievements is now first (sits beside Top Models
|
||||
visually since the rail is right of the chart row + sessions),
|
||||
then Skills, then the rhythm card. Mix & rhythm is the unique
|
||||
chart in this column — keeping it. */}
|
||||
<div className="flex flex-col gap-3 lg:col-span-4">
|
||||
chart in this column — keeping it.
|
||||
`min-h-full` + the trailing `flex-1` rhythm card together
|
||||
stretch the rail to match Sessions Intelligence height so
|
||||
we don't get the dangling gap Eric flagged in iter 007. */}
|
||||
<div className="flex min-h-full flex-col gap-3 lg:col-span-4">
|
||||
<WidgetShell id="achievements" layout={layout}>
|
||||
<AchievementsCard
|
||||
achievements={overview?.achievements ?? null}
|
||||
@@ -1047,12 +1101,18 @@ export function DashboardScreen() {
|
||||
onOpen={() => navigate({ to: '/skills' })}
|
||||
/>
|
||||
</WidgetShell>
|
||||
<WidgetShell id="mix_rhythm" layout={layout}>
|
||||
<TokenMixHourCard
|
||||
analytics={overview?.analytics ?? null}
|
||||
sessions={sessionRows}
|
||||
/>
|
||||
</WidgetShell>
|
||||
{/* `flex-1` here pushes the rhythm card to consume any
|
||||
remaining vertical space so the rail's bottom aligns
|
||||
with Sessions Intelligence. The card itself uses
|
||||
h-full + flex-1 to honor the stretch. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<WidgetShell id="mix_rhythm" layout={layout}>
|
||||
<TokenMixHourCard
|
||||
analytics={overview?.analytics ?? null}
|
||||
sessions={sessionRows}
|
||||
/>
|
||||
</WidgetShell>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user