redesign: bento home + semantic tokens + solid cards
All checks were successful
Deploy / deploy (push) Successful in 2m43s

- introduces semantic CSS tokens (--surface-1/2/3, --border-subtle/strong,
  --hairline, --shadow-sm/md/lg/xl) with distinct dark and light values;
  fixes broken light theme caused by hardcoded rgba(255,255,255,X)
- drops glassmorphism on cards — solid var(--surface-1) with 1px border
  and layered shadows; glass kept only for aurora page background
- introduces .card/.card-raised/.card-hero utility classes
- Home page restructured into a bento grid:
  * greeting row with inline day/date
  * hero weather (64px number, large icon, ощущается/влажность/ветер)
    next to the tram widget (1fr 1.1fr)
  * forecast as a single hairline-separated band (no per-day cards)
  * events+notes in a 2-column grid; events card combines today and
    tomorrow with a divider; notes card styled via surface tokens
- TransportWidget repainted to use tokens, larger numbers (32px for the
  next arrival), imminent highlight uses color-mix against surface-2
This commit is contained in:
Cosmo
2026-04-23 08:30:03 +00:00
parent 9ad758174d
commit 121bf30ab1
3 changed files with 461 additions and 375 deletions

View File

@@ -419,94 +419,199 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
return (
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, padding: '20px 24px 28px', display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Greeting + hint */}
<div>
<h1 style={{ fontSize: 26, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.5px', margin: 0 }}>
{greeting} 👋
<div style={{
flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any,
padding: '18px 22px 24px',
display: 'flex', flexDirection: 'column', gap: 14,
}}>
{/* ───── Greeting row ───── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<h1 style={{
fontSize: 28, fontWeight: 800, color: 'var(--text-primary)',
letterSpacing: '-0.6px', margin: 0, lineHeight: 1.1,
}}>
{greeting} <span style={{ fontSize: 26 }}>👋</span>
</h1>
<div style={{
fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600,
textTransform: 'capitalize', textAlign: 'right',
letterSpacing: '-0.1px',
}}>
{new Date().toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
</div>
{/* Weather — full width compact */}
{weather && (
<div style={{
background: 'linear-gradient(135deg, rgba(99,102,241,0.1), rgba(139,92,246,0.05))',
backdropFilter: 'blur(20px)', border: '1px solid rgba(129,140,248,0.1)',
borderRadius: 20, padding: '18px 22px',
display: 'flex', alignItems: 'center', gap: 20,
position: 'relative', overflow: 'hidden',
{/* ───── Bento row: Hero weather + Tram ───── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 14, minHeight: 230 }}>
{/* Hero weather card */}
{weather ? (
<div
className="card-hero"
onClick={() => weather?.forecast?.[0] && setSelectedDay(weather.forecast[0])}
style={{
padding: '22px 24px',
display: 'flex', flexDirection: 'column',
position: 'relative', overflow: 'hidden',
cursor: 'pointer',
}}
>
{/* Decorative animation, large, behind */}
<div style={{
position: 'absolute', top: -24, right: -12,
opacity: 0.14, pointerEvents: 'none',
}}>
<WeatherAnimation condition={weather.desc} size={160} />
</div>
<div style={{
fontSize: 11, color: 'var(--text-tertiary)', fontWeight: 700,
textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 8,
position: 'relative', zIndex: 1,
}}>
Сейчас
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
position: 'relative', zIndex: 1, marginBottom: 6,
}}>
<div style={{
fontSize: 64, fontWeight: 800, letterSpacing: '-3px',
color: 'var(--text-primary)', lineHeight: 0.9,
fontVariantNumeric: 'tabular-nums',
}}>
{weather.temp}°
</div>
<WeatherAnimation condition={weather.desc} size={52} />
</div>
<div style={{
fontSize: 16, color: 'var(--text-primary)', fontWeight: 600,
position: 'relative', zIndex: 1, marginBottom: 12,
}}>
{weather.desc}
</div>
<div style={{
display: 'flex', gap: 16, flexWrap: 'wrap',
marginTop: 'auto', position: 'relative', zIndex: 1,
}}>
{weather.feelsLike && (
<div>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em' }}>Ощущается</div>
<div style={{ fontSize: 15, color: 'var(--text-primary)', fontWeight: 700, marginTop: 2 }}>{weather.feelsLike}°</div>
</div>
)}
{weather.humidity && (
<div>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em' }}>Влажность</div>
<div style={{ fontSize: 15, color: 'var(--text-primary)', fontWeight: 700, marginTop: 2 }}>{weather.humidity}</div>
</div>
)}
{weather.windSpeed && (
<div>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em' }}>Ветер</div>
<div style={{ fontSize: 15, color: 'var(--text-primary)', fontWeight: 700, marginTop: 2 }}>{weather.windSpeed}</div>
</div>
)}
</div>
</div>
) : (
<div className="card-hero" style={{ padding: '22px 24px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-tertiary)', fontSize: 13 }}>
Загрузка погоды...
</div>
)}
{/* Tram */}
<TransportWidget />
</div>
{/* ───── Forecast band (no cards, hairline-separated) ───── */}
{weather?.forecast && (
<div className="card" style={{
padding: '12px 6px',
display: 'flex', alignItems: 'stretch',
}}>
<div style={{ position: 'absolute', top: -15, right: 5, opacity: 0.1, pointerEvents: 'none' }}>
<WeatherAnimation condition={weather.desc} size={90} />
</div>
{/* Current */}
<div onClick={() => weather?.forecast?.[0] && setSelectedDay(weather.forecast[0])} style={{ display: 'flex', alignItems: 'center', gap: 14, flexShrink: 0, position: 'relative', zIndex: 1, cursor: 'pointer' }}>
<WeatherAnimation condition={weather.desc} size={48} />
<div>
<div style={{ fontSize: 32, fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-2px' }}>{weather.temp}°</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: 3, fontWeight: 500 }}>{weather.desc}</div>
</div>
</div>
{/* Divider */}
<div style={{ width: 1, height: 50, background: 'rgba(255,255,255,0.08)', flexShrink: 0 }} />
{/* 7 day forecast */}
{weather.forecast && (
<div style={{ display: 'flex', gap: 4, flex: 1, overflow: 'hidden' }}>
{weather.forecast.map((day, idx) => {
const d = new Date(day.date)
const isToday = idx === 0
return (
<div key={day.date} onClick={() => setSelectedDay(day)} style={{
flex: 1, minWidth: 0, textAlign: 'center', padding: '4px 2px',
borderRadius: 10, cursor: 'pointer',
background: isToday ? 'rgba(99,102,241,0.1)' : 'transparent',
{weather.forecast.map((day, idx) => {
const d = new Date(day.date)
const isToday = idx === 0
return (
<div key={day.date} style={{
flex: 1, display: 'flex', alignItems: 'stretch',
borderRight: idx < weather.forecast!.length - 1 ? '1px solid var(--hairline)' : 'none',
}}>
<button
onClick={() => setSelectedDay(day)}
style={{
flex: 1, padding: '8px 4px', borderRadius: 14,
background: isToday ? 'var(--surface-2)' : 'transparent',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
transition: 'background 0.2s ease',
}}
>
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase',
color: isToday ? 'var(--accent)' : 'var(--text-tertiary)',
}}>
<div style={{ fontSize: 9, color: isToday ? '#a5b4fc' : 'var(--text-secondary)', fontWeight: 600, marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isToday ? 'Сей' : d.toLocaleDateString('ru-RU', { weekday: 'short' }).slice(0, 2)}
</div>
<div style={{ fontSize: 14, marginBottom: 2 }}>{getWeatherIcon(day.desc)}</div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)' }}>{day.maxTemp}°</div>
<div style={{ fontSize: 9, color: 'var(--text-secondary)' }}>{day.minTemp}°</div>
{isToday ? 'Сей' : d.toLocaleDateString('ru-RU', { weekday: 'short' }).replace('.', '').slice(0, 2)}
</div>
)
})}
</div>
)}
<div style={{ fontSize: 20 }}>{getWeatherIcon(day.desc)}</div>
<div style={{
fontSize: 14, fontWeight: 800, color: 'var(--text-primary)',
letterSpacing: '-0.5px', fontVariantNumeric: 'tabular-nums',
}}>
{day.maxTemp}°
</div>
<div style={{
fontSize: 11, color: 'var(--text-tertiary)', fontWeight: 500,
fontVariantNumeric: 'tabular-nums',
}}>
{day.minTemp}°
</div>
</button>
</div>
)
})}
</div>
)}
{/* Transport: tram arrivals at Ул. Антонова-Овсеенко, both directions */}
<TransportWidget />
{/* Two columns: Events + Notes */}
{/* ───── Events + Notes row ───── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, flex: 1, minHeight: 0 }}>
{/* Left: Today + Tomorrow events */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Events — today + tomorrow in one card */}
<div className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14, overflowY: 'auto' }}>
{/* Today */}
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 20, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Calendar size={14} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Сегодня</span>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{todayEvents.length}</span>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Calendar size={13} color="var(--text-secondary)" />
<span style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Сегодня</span>
<span style={{
fontSize: 10, color: 'var(--text-tertiary)',
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
}}>
{todayEvents.length}
</span>
</div>
{calLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Загрузка...</div>
<div style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Загрузка...</div>
) : todayEvents.length === 0 ? (
<div style={{ fontSize: 14, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>Свободный день</div>
<div style={{ fontSize: 14, color: 'var(--text-tertiary)', textAlign: 'center', padding: '6px 0' }}>Свободный день </div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{todayEvents.map(ev => (
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 12, background: `${ev.color}08`, border: `1px solid ${ev.color}12` }}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 30, flexShrink: 0 }} />
<div key={ev.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 12,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
}}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 28, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ev.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)} · <span style={{ color: ev.color }}>{ev.ownerName}</span>
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)} · <span style={{ color: ev.color, fontWeight: 600 }}>{ev.ownerName}</span>
</div>
</div>
</div>
@@ -515,23 +620,34 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
)}
</div>
{/* Divider */}
{(tomorrowEvents.length > 0 || todayEvents.length > 0) && (
<div style={{ height: 1, background: 'var(--hairline)' }} />
)}
{/* Tomorrow */}
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderRadius: 20, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Calendar size={14} color="var(--text-secondary)" />
<span style={{ fontSize: 11, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Завтра</span>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{tomorrowEvents.length}</span>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Calendar size={13} color="var(--text-tertiary)" />
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Завтра</span>
<span style={{
fontSize: 10, color: 'var(--text-tertiary)',
background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 8,
fontWeight: 700, fontVariantNumeric: 'tabular-nums',
}}>
{tomorrowEvents.length}
</span>
</div>
{tomorrowEvents.length === 0 ? (
<div style={{ fontSize: 14, color: 'var(--text-secondary)', textAlign: 'center', padding: '8px 0' }}>Нет событий</div>
<div style={{ fontSize: 13, color: 'var(--text-tertiary)', textAlign: 'center', padding: '4px 0' }}></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{tomorrowEvents.map(ev => (
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 12, background: `${ev.color}06` }}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 24, flexShrink: 0, opacity: 0.6 }} />
<div key={ev.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px' }}>
<div style={{ width: 3, borderRadius: 2, background: ev.color, alignSelf: 'stretch', minHeight: 22, flexShrink: 0, opacity: 0.7 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ev.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginTop: 2 }}>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 1 }}>
{ev.allDay ? 'Весь день' : formatEventTime(ev.start)}
</div>
</div>
@@ -542,53 +658,62 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
</div>
</div>
{/* Right: Pinned notes / shopping lists */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Notes */}
<div className="card" style={{ padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 12, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<StickyNote size={13} color="var(--text-secondary)" />
<span style={{ fontSize: 10, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700 }}>Заметки</span>
</div>
{pinnedNotes.length === 0 ? (
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.04)', borderRadius: 20, padding: '18px 20px', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>
<StickyNote size={24} style={{ margin: '0 auto 8px', opacity: 0.3 }} />
<div style={{ fontSize: 13 }}>Заметки появятся здесь</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', color: 'var(--text-tertiary)' }}>
<div>
<StickyNote size={22} style={{ margin: '0 auto 6px', opacity: 0.4 }} />
<div style={{ fontSize: 12 }}>Заметки появятся здесь</div>
</div>
</div>
) : (
pinnedNotes.map(note => {
const doneCount = note.items?.filter((i: any) => i.done).length || 0
const totalCount = note.items?.length || 0
return (
<div key={note.id} style={{
background: `${note.color}08`, border: `1px solid ${note.color}15`,
borderRadius: 20, padding: '18px 20px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
{note.type === 'shopping' ? <ShoppingCart size={14} color={note.color} /> : <FileText size={14} color={note.color} />}
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{note.title}</span>
{note.type === 'shopping' && totalCount > 0 && (
<span style={{ fontSize: 11, color: note.color, marginLeft: 'auto' }}>{doneCount}/{totalCount}</span>
)}
</div>
{note.type === 'shopping' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(note.items || []).filter((i: any) => !i.done).slice(0, 5).map((item: any) => (
<div key={item.id} style={{ fontSize: 13, color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 6, height: 6, borderRadius: 2, border: `1.5px solid ${note.color}50`, flexShrink: 0 }} />
{item.text}
</div>
))}
{(note.items || []).filter((i: any) => !i.done).length > 5 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
+{(note.items || []).filter((i: any) => !i.done).length - 5} ещё
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{pinnedNotes.map(note => {
const doneCount = note.items?.filter((i: any) => i.done).length || 0
const totalCount = note.items?.length || 0
return (
<div key={note.id} style={{
padding: '10px 12px', borderRadius: 12,
background: 'var(--surface-2)',
border: '1px solid var(--border-subtle)',
borderLeft: `3px solid ${note.color}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
{note.type === 'shopping' ? <ShoppingCart size={12} color={note.color} /> : <FileText size={12} color={note.color} />}
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{note.title}</span>
{note.type === 'shopping' && totalCount > 0 && (
<span style={{ fontSize: 11, color: note.color, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>{doneCount}/{totalCount}</span>
)}
</div>
) : (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical' as any }}>
{note.text || 'Пустая заметка'}
</div>
)}
</div>
)
})
{note.type === 'shopping' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{(note.items || []).filter((i: any) => !i.done).slice(0, 4).map((item: any) => (
<div key={item.id} style={{ fontSize: 12, color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 5, height: 5, borderRadius: 2, background: note.color, opacity: 0.6, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.text}</span>
</div>
))}
{(note.items || []).filter((i: any) => !i.done).length > 4 && (
<div style={{ fontSize: 10, color: 'var(--text-tertiary)', marginLeft: 11 }}>
+{(note.items || []).filter((i: any) => !i.done).length - 4} ещё
</div>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical' as any }}>
{note.text || 'Пустая заметка'}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</div>