mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
fix: polish sidebar and masthead chrome, scroll setup preflight (#1522)
Remove redundant nav and label dots, move desktop masthead status to animated left rails, and let AuthCanvas scroll long preflight content on small viewports.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { MastheadRail } from '@/components/ui/MastheadRail';
|
||||
import type { Stats, SystemStats, NotificationItem, HealthLevel } from './types';
|
||||
import { deriveHealth } from './deriveHealth';
|
||||
import { countVisibleUnread } from '@/lib/notificationVisibility';
|
||||
@@ -15,24 +16,21 @@ interface HealthStatusBarProps {
|
||||
metricsStale?: boolean;
|
||||
}
|
||||
|
||||
const healthConfig: Record<HealthLevel, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
|
||||
const healthConfig: Record<HealthLevel, { label: string; textClass: string; railClass: string; tintClass: string }> = {
|
||||
healthy: {
|
||||
label: 'Healthy',
|
||||
dotClass: 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]',
|
||||
textClass: 'text-stat-value',
|
||||
railClass: 'bg-brand',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
degraded: {
|
||||
label: 'Degraded',
|
||||
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
|
||||
textClass: 'text-warning',
|
||||
railClass: 'bg-warning',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
|
||||
textClass: 'text-destructive',
|
||||
railClass: 'bg-destructive',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
@@ -92,14 +90,10 @@ export function HealthStatusBar({
|
||||
className={`relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors`}
|
||||
>
|
||||
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-r ${config.tintClass}`} />
|
||||
<div className={`absolute inset-y-0 left-0 w-[3px] ${config.railClass}`} />
|
||||
<MastheadRail variant={level === 'healthy' ? 'shimmer' : 'glow'} className={config.railClass} />
|
||||
<div className="relative grid grid-cols-[auto_1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
||||
{/* State column */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { MastheadRail } from '@/components/ui/MastheadRail';
|
||||
type FleetHealth = 'healthy' | 'degraded' | 'critical';
|
||||
|
||||
interface FleetMastheadProps {
|
||||
@@ -37,24 +38,21 @@ function useTicker(intervalMs: number): number {
|
||||
return now;
|
||||
}
|
||||
|
||||
const healthConfig: Record<FleetHealth, { label: string; dotClass: string; textClass: string; railClass: string; tintClass: string }> = {
|
||||
const healthConfig: Record<FleetHealth, { label: string; textClass: string; railClass: string; tintClass: string }> = {
|
||||
healthy: {
|
||||
label: 'The fleet',
|
||||
dotClass: 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]',
|
||||
textClass: 'text-stat-value',
|
||||
railClass: 'bg-brand',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
degraded: {
|
||||
label: 'The fleet',
|
||||
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
|
||||
textClass: 'text-warning',
|
||||
railClass: 'bg-warning',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
critical: {
|
||||
label: 'The fleet',
|
||||
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
|
||||
textClass: 'text-destructive',
|
||||
railClass: 'bg-destructive',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
@@ -102,13 +100,9 @@ export function FleetMasthead({
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors mb-4">
|
||||
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-r ${config.tintClass}`} />
|
||||
<div className={`absolute inset-y-0 left-0 w-[3px] ${config.railClass}`} />
|
||||
<MastheadRail variant={level === 'healthy' ? 'shimmer' : 'glow'} className={config.railClass} />
|
||||
<div className="relative grid grid-cols-[auto_1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 rounded-full ${config.dotClass} ${level === 'healthy' ? '' : 'animate-[pulse_2.4s_ease-in-out_infinite]'}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`font-heading text-3xl leading-none tracking-tight ${config.textClass}`}>
|
||||
{config.label}
|
||||
|
||||
@@ -93,13 +93,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
className="absolute inset-y-0 left-0 w-[2px] rounded-sm bg-brand"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-1 w-1 shrink-0 rounded-full',
|
||||
isActive ? 'bg-brand' : 'bg-stat-subtitle/40',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 truncate text-left">{item.label}</span>
|
||||
{isDirty && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
|
||||
|
||||
@@ -3,7 +3,6 @@ import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import type { CheckStatus } from '@/types/imageUpdates';
|
||||
import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/components/animate-ui/primitives/animate/cursor';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { LabelDot } from '@/components/LabelPill';
|
||||
import type { Label } from '@/components/label-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sidebarRowActive, sidebarRowBase, sidebarRowCheckboxSlot } from './sidebar-styles';
|
||||
@@ -47,18 +46,13 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_LABELS = 3;
|
||||
|
||||
export function StackRow(props: StackRowProps) {
|
||||
const {
|
||||
file, displayName, status, running, total, isBusy, isActive, labels,
|
||||
file, displayName, status, running, total, isBusy, isActive,
|
||||
hasUpdate, checkStatus, lastError, hasGitPending, onSelect, kebabSlot,
|
||||
bulkMode = false, isSelected = false, onToggleSelect,
|
||||
} = props;
|
||||
|
||||
const visibleLabels = labels.slice(0, MAX_VISIBLE_LABELS);
|
||||
const overflowCount = Math.max(0, labels.length - MAX_VISIBLE_LABELS);
|
||||
|
||||
const handleClick = () => {
|
||||
if (bulkMode) onToggleSelect?.(file);
|
||||
else onSelect(file);
|
||||
@@ -110,16 +104,6 @@ export function StackRow(props: StackRowProps) {
|
||||
{/* Stack name */}
|
||||
<span className="flex-1 truncate font-mono text-sm min-w-0">{displayName}</span>
|
||||
|
||||
{/* Trailing: label dots (max 3 + overflow count) */}
|
||||
{visibleLabels.length > 0 && (
|
||||
<span className="flex items-center gap-0.5 shrink-0">
|
||||
{visibleLabels.map(l => <LabelDot key={l.id} color={l.color} />)}
|
||||
{overflowCount > 0 && (
|
||||
<span className="font-mono text-[10px] leading-3 text-stat-icon">+{overflowCount}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Fixed trailing icon slot: update dot > check-failed > git pending */}
|
||||
<span className="w-3.5 h-3.5 flex items-center justify-center shrink-0">
|
||||
{hasUpdate ? (
|
||||
|
||||
@@ -93,13 +93,13 @@ describe('StackRow', () => {
|
||||
expect(screen.queryByText('UP')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label indicators', () => {
|
||||
it('does not render trailing label color dots when labels are assigned', () => {
|
||||
const labels: Label[] = [
|
||||
{ id: 1, node_id: 0, name: 'prod', color: 'teal' },
|
||||
{ id: 2, node_id: 0, name: 'media', color: 'blue' },
|
||||
];
|
||||
const { container } = render(<StackRow {...base({ labels })} />);
|
||||
expect(container.querySelectorAll('[style*="--label-"]')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('[style*="--label-"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Image-update check status indicator ────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MastheadRailVariant = 'shimmer' | 'glow';
|
||||
|
||||
interface MastheadRailProps {
|
||||
variant: MastheadRailVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MastheadRail({ variant, className }: MastheadRailProps) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn('absolute inset-y-0 left-0 w-[3px] overflow-hidden', className)}
|
||||
>
|
||||
{variant === 'shimmer' ? (
|
||||
<div className="masthead-rail-shimmer absolute inset-x-0 h-1/3 bg-gradient-to-b from-transparent via-white/25 to-transparent" />
|
||||
) : (
|
||||
<div className="masthead-rail-glow absolute inset-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MastheadRail } from '@/components/ui/MastheadRail';
|
||||
|
||||
export type MastheadTone = 'live' | 'idle' | 'warn' | 'error';
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface PageMastheadProps {
|
||||
kicker?: string;
|
||||
state: string;
|
||||
tone: MastheadTone;
|
||||
/** When true with tone `live`, the left rail shimmers; otherwise the rail glows subtly. */
|
||||
pulsing?: boolean;
|
||||
metadata?: MastheadMetadataItem[];
|
||||
/** Optional meta line under the state word (e.g. a one-line posture summary). */
|
||||
@@ -29,27 +31,27 @@ export interface PageMastheadProps {
|
||||
}
|
||||
|
||||
const toneConfig: Record<MastheadTone, {
|
||||
dotClass: string;
|
||||
railClass: string;
|
||||
stateTextClass: string;
|
||||
tintClass: string;
|
||||
}> = {
|
||||
live: {
|
||||
dotClass: 'bg-brand shadow-[0_0_0_3px_color-mix(in_oklch,var(--brand)_22%,transparent)]',
|
||||
railClass: 'bg-brand',
|
||||
stateTextClass: 'text-stat-value',
|
||||
tintClass: 'from-brand/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
idle: {
|
||||
dotClass: 'bg-stat-subtitle',
|
||||
railClass: 'bg-stat-subtitle',
|
||||
stateTextClass: 'text-stat-title',
|
||||
tintClass: 'from-transparent via-transparent to-transparent',
|
||||
},
|
||||
warn: {
|
||||
dotClass: 'bg-warning shadow-[0_0_0_3px_color-mix(in_oklch,var(--warning)_22%,transparent)]',
|
||||
railClass: 'bg-warning',
|
||||
stateTextClass: 'text-warning',
|
||||
tintClass: 'from-warning/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
error: {
|
||||
dotClass: 'bg-destructive shadow-[0_0_0_3px_color-mix(in_oklch,var(--destructive)_24%,transparent)]',
|
||||
railClass: 'bg-destructive',
|
||||
stateTextClass: 'text-destructive',
|
||||
tintClass: 'from-destructive/[0.06] via-transparent to-transparent',
|
||||
},
|
||||
@@ -74,7 +76,7 @@ export function PageMasthead({
|
||||
size = 'default',
|
||||
}: PageMastheadProps) {
|
||||
const config = toneConfig[tone];
|
||||
const shouldPulse = pulsing && (tone === 'live' || tone === 'warn');
|
||||
const railVariant = tone === 'live' && pulsing ? 'shimmer' : 'glow';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -84,17 +86,9 @@ export function PageMasthead({
|
||||
)}
|
||||
>
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r', config.tintClass)} />
|
||||
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||
<MastheadRail variant={railVariant} className={config.railClass} />
|
||||
<div className="relative grid grid-cols-[1fr_auto] items-center gap-6 py-5 pl-7 pr-6">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 shrink-0 rounded-full',
|
||||
config.dotClass,
|
||||
shouldPulse && 'animate-[pulse_2.4s_ease-in-out_infinite]',
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{kicker ? (
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
|
||||
@@ -716,6 +716,10 @@ body {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(200%); }
|
||||
}
|
||||
@keyframes masthead-rail-glow {
|
||||
0%, 100% { opacity: 0.55; box-shadow: inset 0 0 0 0 transparent; }
|
||||
50% { opacity: 1; box-shadow: inset 0 0 10px 0 oklch(1 0 0 / 0.12); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
ANIMATION UTILITIES
|
||||
@@ -738,6 +742,12 @@ body {
|
||||
.animate-shimmer {
|
||||
animation: shimmer 4.5s ease-in-out infinite alternate;
|
||||
}
|
||||
.masthead-rail-shimmer {
|
||||
animation: shimmer 11s ease-in-out infinite alternate;
|
||||
}
|
||||
.masthead-rail-glow {
|
||||
animation: masthead-rail-glow 5.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* Stagger delay helpers */
|
||||
.animate-delay-50 { animation-delay: 50ms; }
|
||||
|
||||
Reference in New Issue
Block a user