style: calm down frontend visual system

This commit is contained in:
zv
2026-06-04 19:54:26 +02:00
parent 2aaa93e03f
commit 9395659f07
31 changed files with 255 additions and 188 deletions

View File

@@ -4,14 +4,42 @@
:root { :root {
color-scheme: light; color-scheme: light;
--background: 210 32% 95%;
--foreground: 214 35% 16%;
--surface: 210 35% 99%;
--surface-muted: 210 26% 92%;
--surface-raised: 0 0% 100%;
--border: 214 20% 82%;
--muted: 215 14% 43%;
--accent: 207 48% 34%;
--accent-foreground: 0 0% 100%;
--warning: 38 58% 42%;
--chart-temperature: 207 48% 36%;
--chart-feels-like: 216 24% 48%;
--chart-rainfall: 202 38% 45%;
--chart-probability: 214 28% 38%;
} }
.dark { .dark {
color-scheme: dark; color-scheme: dark;
--background: 214 37% 9%;
--foreground: 210 31% 94%;
--surface: 214 33% 13%;
--surface-muted: 215 27% 17%;
--surface-raised: 214 29% 16%;
--border: 214 19% 27%;
--muted: 214 15% 70%;
--accent: 204 44% 66%;
--accent-foreground: 214 37% 9%;
--warning: 39 64% 63%;
--chart-temperature: 204 44% 66%;
--chart-feels-like: 216 22% 73%;
--chart-rainfall: 202 42% 68%;
--chart-probability: 214 26% 76%;
} }
* { * {
border-color: rgba(148, 163, 184, 0.2); border-color: hsl(var(--border) / 0.72);
} }
html { html {
@@ -21,14 +49,14 @@ html {
body { body {
min-height: 100vh; min-height: 100vh;
background: #eef5fb; background: hsl(var(--background));
color: #102238; color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.dark body { .dark body {
background: #07111f; background: hsl(var(--background));
color: #edf7ff; color: hsl(var(--foreground));
} }
button, button,
@@ -40,11 +68,19 @@ select {
@layer utilities { @layer utilities {
.glass { .glass {
@apply border border-white/35 bg-white/45 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30; @apply border border-border/70 bg-surface/80 shadow-card backdrop-blur-xl dark:bg-surface/75;
} }
.glass-subtle { .glass-subtle {
@apply border border-white/25 bg-white/25 backdrop-blur-xl dark:border-white/10 dark:bg-slate-950/20; @apply border border-border/60 bg-surface-muted/65 backdrop-blur-xl dark:bg-surface-muted/55;
}
.surface-control {
@apply border border-border/70 bg-surface/80 shadow-soft backdrop-blur-xl dark:bg-surface-muted/70;
}
.section-kicker {
@apply text-xs font-semibold uppercase tracking-[0.16em] text-accent;
} }
.text-balance { .text-balance {
@@ -58,7 +94,7 @@ select {
@supports (-moz-appearance: none) { @supports (-moz-appearance: none) {
.weather-scrollbar { .weather-scrollbar {
scrollbar-color: rgba(8, 145, 178, 0.72) rgba(14, 116, 144, 0.1); scrollbar-color: hsl(var(--accent) / 0.62) hsl(var(--border) / 0.32);
scrollbar-width: thin; scrollbar-width: thin;
} }
} }
@@ -70,28 +106,27 @@ select {
.weather-scrollbar::-webkit-scrollbar-track { .weather-scrollbar::-webkit-scrollbar-track {
border-radius: 9999px; border-radius: 9999px;
background: rgba(14, 116, 144, 0.1); background: hsl(var(--border) / 0.28);
} }
.weather-scrollbar::-webkit-scrollbar-thumb { .weather-scrollbar::-webkit-scrollbar-thumb {
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid hsl(var(--surface) / 0.78);
border-radius: 9999px; border-radius: 9999px;
background: linear-gradient(90deg, rgba(8, 145, 178, 0.78), rgba(14, 116, 144, 0.88)); background: hsl(var(--accent) / 0.68);
background-clip: padding-box; background-clip: padding-box;
box-shadow: 0 1px 5px rgba(8, 47, 73, 0.25);
} }
.weather-scrollbar::-webkit-scrollbar-thumb:hover { .weather-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(90deg, rgba(6, 182, 212, 0.9), rgba(3, 105, 161, 0.95)); background: hsl(var(--accent) / 0.82);
background-clip: padding-box; background-clip: padding-box;
} }
.dark .weather-scrollbar::-webkit-scrollbar-track { .dark .weather-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1); background: hsl(var(--border) / 0.35);
} }
.dark .weather-scrollbar::-webkit-scrollbar-thumb { .dark .weather-scrollbar::-webkit-scrollbar-thumb {
border-color: rgba(255, 255, 255, 0.18); border-color: hsl(var(--surface) / 0.72);
background: linear-gradient(90deg, rgba(34, 211, 238, 0.72), rgba(56, 189, 248, 0.82)); background: hsl(var(--accent) / 0.7);
background-clip: padding-box; background-clip: padding-box;
} }

View File

@@ -36,8 +36,8 @@ export const viewport: Viewport = {
initialScale: 1, initialScale: 1,
viewportFit: "cover", viewportFit: "cover",
themeColor: [ themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#e8f4fb" }, { media: "(prefers-color-scheme: light)", color: "#eef3f7" },
{ media: "(prefers-color-scheme: dark)", color: "#07111f" }, { media: "(prefers-color-scheme: dark)", color: "#0e1722" },
], ],
}; };

View File

@@ -7,11 +7,11 @@ import { useI18n } from "@/lib/i18n";
export default function OfflinePage() { export default function OfflinePage() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<section className="glass mx-auto mt-12 max-w-lg rounded-[2rem] p-8 text-center"> <section className="glass mx-auto mt-12 max-w-lg rounded-panel p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-sky-500/10 text-sky-700 dark:text-sky-300"><WifiOff className="size-6" /></div> <div className="mx-auto flex size-14 items-center justify-center rounded-control bg-accent/10 text-accent"><WifiOff className="size-6" /></div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1> <h1 className="mt-5 text-2xl font-semibold tracking-tight">{t("offline.title")}</h1>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("offline.description")}</p> <p className="mt-2 text-sm leading-6 text-muted">{t("offline.description")}</p>
<Link href="/" className="mt-6 inline-flex rounded-full bg-slate-950 px-4 py-2.5 text-sm font-medium text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:bg-white dark:text-slate-950">{t("offline.back")}</Link> <Link href="/" className="mt-6 inline-flex rounded-control bg-foreground px-4 py-2.5 text-sm font-medium text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">{t("offline.back")}</Link>
</section> </section>
); );
} }

View File

@@ -7,6 +7,16 @@ import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecas
import type { HourlyForecast } from "@/types/forecast"; import type { HourlyForecast } from "@/types/forecast";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const CHART_COLORS = {
grid: "hsl(var(--border) / 0.65)",
tooltipBorder: "hsl(var(--border) / 0.75)",
tooltipBackground: "hsl(var(--surface-raised) / 0.96)",
tooltipText: "hsl(var(--foreground))",
temperature: "hsl(var(--chart-temperature))",
feelsLike: "hsl(var(--chart-feels-like))",
rainfall: "hsl(var(--chart-rainfall))",
probability: "hsl(var(--chart-probability))",
};
function formatHour(value: string) { function formatHour(value: string) {
return value.slice(11, 16); return value.slice(11, 16);
@@ -30,16 +40,16 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<div className="mt-4 h-56 min-w-0"> <div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}> <ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} /> <CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} /> <XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" /> <YAxis axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="°" />
<Tooltip <Tooltip
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }} contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]} formatter={(value) => [formatForecastTemperature(typeof value === "number" ? value : null, language)]}
/> />
<Legend wrapperStyle={{ fontSize: "0.72rem" }} /> <Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke="#0284c7" strokeWidth={3} dot={false} connectNulls /> <Line type="monotone" dataKey="temperature" name={t("forecast.temperature")} stroke={CHART_COLORS.temperature} strokeWidth={3} dot={false} connectNulls />
<Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke="#818cf8" strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls /> <Line type="monotone" dataKey="feelsLike" name={t("forecast.apparentTemperature")} stroke={CHART_COLORS.feelsLike} strokeWidth={2} strokeDasharray="5 4" dot={false} connectNulls />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -51,12 +61,12 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
<div className="mt-4 h-56 min-w-0"> <div className="mt-4 h-56 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}> <ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} /> <CartesianGrid stroke={CHART_COLORS.grid} strokeDasharray="4 4" vertical={false} />
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} /> <XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
<YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" /> <YAxis yAxisId="rainfall" axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit=" mm" />
<YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" /> <YAxis yAxisId="probability" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 11 }} unit="%" />
<Tooltip <Tooltip
contentStyle={{ borderRadius: 16, border: "1px solid rgba(148,163,184,0.2)", background: "rgba(15,23,42,0.9)", color: "#f8fafc" }} contentStyle={{ borderRadius: 14, border: `1px solid ${CHART_COLORS.tooltipBorder}`, background: CHART_COLORS.tooltipBackground, color: CHART_COLORS.tooltipText }}
formatter={(value, name) => [ formatter={(value, name) => [
name === t("forecast.precipitation") name === t("forecast.precipitation")
? formatForecastRainfall(typeof value === "number" ? value : null, language) ? formatForecastRainfall(typeof value === "number" ? value : null, language)
@@ -65,8 +75,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
]} ]}
/> />
<Legend wrapperStyle={{ fontSize: "0.72rem" }} /> <Legend wrapperStyle={{ fontSize: "0.72rem" }} />
<Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill="#38bdf8" radius={[5, 5, 0, 0]} /> <Bar yAxisId="rainfall" dataKey="precipitation" name={t("forecast.precipitation")} fill={CHART_COLORS.rainfall} radius={[5, 5, 0, 0]} />
<Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke="#6366f1" strokeWidth={2} dot={false} connectNulls /> <Line yAxisId="probability" type="monotone" dataKey="precipitationProbability" name={t("forecast.precipitationProbability")} stroke={CHART_COLORS.probability} strokeWidth={2} dot={false} connectNulls />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@@ -6,26 +6,31 @@ import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 }; const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
const SNAPSHOT_COLORS = [
"hsl(var(--chart-temperature))",
"hsl(var(--chart-feels-like))",
"hsl(var(--chart-rainfall))",
];
export function SnapshotChart({ station }: { station: SynopStation }) { export function SnapshotChart({ station }: { station: SynopStation }) {
const { t } = useI18n(); const { t } = useI18n();
const rows = [ const rows = [
{ name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: "#38bdf8" }, { name: t("weather.humidity"), value: station.humidity, unit: "%", max: 100, color: SNAPSHOT_COLORS[0] },
{ name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: "#818cf8" }, { name: t("weather.wind"), value: station.windSpeed, unit: "m/s", max: 20, color: SNAPSHOT_COLORS[1] },
{ name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: "#22d3ee" }, { name: t("weather.rainfall"), value: station.rainfall, unit: "mm", max: 30, color: SNAPSHOT_COLORS[2] },
].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) })); ].filter((row) => row.value !== null).map((row) => ({ ...row, normalized: Math.min(100, ((row.value ?? 0) / row.max) * 100) }));
return ( return (
<Card className="p-5"> <Card className="p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("snapshot.label")}</p> <p className="section-kicker">{t("snapshot.label")}</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p> <p className="mt-1 text-sm leading-6 text-muted">{t("snapshot.description")}</p>
<div className="mt-5 h-52 min-w-0"> <div className="mt-5 h-52 min-w-0">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}> <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}> <BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
<XAxis type="number" hide domain={[0, 100]} /> <XAxis type="number" hide domain={[0, 100]} />
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} /> <YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
<Tooltip cursor={{ fill: "rgba(148,163,184,0.08)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} /> <Tooltip cursor={{ fill: "hsl(var(--border) / 0.22)" }} formatter={(_, __, item) => [`${item.payload.value} ${item.payload.unit}`, item.payload.name]} />
<Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}> <Bar dataKey="normalized" radius={[0, 8, 8, 0]} barSize={14}>
{rows.map((row) => <Cell fill={row.color} key={row.name} />)} {rows.map((row) => <Cell fill={row.color} key={row.name} />)}
</Bar> </Bar>

View File

@@ -32,9 +32,9 @@ function getMaximumWind(hours: HourlyForecast[]) {
function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) { function DayMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
return ( return (
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5"> <div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
<Icon className="size-4 text-sky-700 dark:text-sky-300" /> <Icon className="size-4 text-accent" />
<p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</p> <p className="mt-3 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{label}</p>
<p className="mt-1 text-base font-semibold">{value}</p> <p className="mt-1 text-base font-semibold">{value}</p>
</div> </div>
); );
@@ -84,7 +84,7 @@ export function DayForecastModal({
<AnimatePresence> <AnimatePresence>
{day ? ( {day ? (
<motion.div <motion.div
className="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/55 p-0 backdrop-blur-md sm:p-4 lg:p-8" className="fixed inset-0 z-[90] flex items-center justify-center bg-slate-950/55 p-0 backdrop-blur-sm sm:p-4 lg:p-8"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -94,7 +94,7 @@ export function DayForecastModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="day-forecast-title" aria-labelledby="day-forecast-title"
className="weather-scrollbar h-full w-full overflow-y-auto bg-gradient-to-b from-sky-100/95 via-slate-100/95 to-white/95 shadow-2xl dark:from-slate-900/95 dark:via-slate-950/95 dark:to-slate-950/95 sm:max-w-6xl sm:rounded-[2rem] sm:border sm:border-white/30 dark:sm:border-white/10" className="weather-scrollbar h-full w-full overflow-y-auto bg-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70"
initial={{ opacity: 0, y: 28, scale: 0.985 }} initial={{ opacity: 0, y: 28, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.99 }} exit={{ opacity: 0, y: 20, scale: 0.99 }}
@@ -104,32 +104,32 @@ export function DayForecastModal({
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8"> <div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"> <p className="section-kicker flex items-center gap-2">
<CloudSun className="size-4" /> <CloudSun className="size-4" />
{t("forecast.dayDetails")} {t("forecast.dayDetails")}
</p> </p>
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2> <h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
<p className="mt-1 capitalize text-slate-600 dark:text-slate-300">{formattedDate}</p> <p className="mt-1 capitalize text-muted">{formattedDate}</p>
</div> </div>
<button <button
ref={closeButtonRef} ref={closeButtonRef}
type="button" type="button"
aria-label={t("forecast.closeDetails")} aria-label={t("forecast.closeDetails")}
className="rounded-full border border-white/35 bg-white/35 p-3 text-slate-700 transition hover:bg-white/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:border-white/10 dark:bg-white/10 dark:text-slate-100 dark:hover:bg-white/20" className="surface-control rounded-control p-3 text-foreground transition hover:bg-surface-raised/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={onClose} onClick={onClose}
> >
<X className="size-5" /> <X className="size-5" />
</button> </button>
</div> </div>
<Card className="overflow-hidden bg-gradient-to-br from-sky-500/20 via-white/25 to-indigo-400/15 p-5 dark:from-sky-700/20 dark:via-white/5 dark:to-indigo-500/15 sm:p-6"> <Card className="overflow-hidden bg-surface-raised/70 p-5 sm:p-6">
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center"> <div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
<div> <div>
<p className="text-sm text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)}</p> <p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
<div className="mt-2 flex items-end gap-4"> <div className="mt-2 flex items-end gap-4">
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-sky-700 dark:text-sky-300" /> <ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p> <p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
<p className="mb-2 text-2xl text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</p> <p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]"> <div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
@@ -151,15 +151,15 @@ export function DayForecastModal({
<li <li
key={hour.time} key={hour.time}
className={cn( className={cn(
"w-[5.2rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5", "w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center",
isPast && "opacity-45", isPast && "opacity-45",
)} )}
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)} title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
> >
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p> <p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" /> <ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p> <p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300"> <p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent">
<Droplets className="size-3" /> <Droplets className="size-3" />
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
</p> </p>

View File

@@ -49,9 +49,9 @@ function getTotal(values: Array<number | null>) {
function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) { function HourlySummaryMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return ( return (
<div className="rounded-2xl border border-white/30 bg-white/20 p-3 dark:border-white/10 dark:bg-white/5"> <div className="rounded-card border border-border/60 bg-surface-muted/55 p-3">
<Icon className="size-4 text-sky-700 dark:text-sky-300" /> <Icon className="size-4 text-accent" />
<p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">{label}</p> <p className="mt-2 text-[0.62rem] font-semibold uppercase tracking-[0.12em] text-muted">{label}</p>
<p className="mt-1 text-sm font-semibold">{value}</p> <p className="mt-1 text-sm font-semibold">{value}</p>
</div> </div>
); );
@@ -69,8 +69,8 @@ function HourlyForecastSummary({ hours }: { hours: HourlyForecast[] }) {
: `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`; : `${formatForecastTemperature(minimumTemperature, language)} / ${formatForecastTemperature(maximumTemperature, language)}`;
return ( return (
<div className="mt-auto hidden border-t border-white/30 pt-4 dark:border-white/10 lg:block"> <div className="mt-auto hidden border-t border-border/70 pt-4 lg:block">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{t("forecast.nextHoursOverview")}</p> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-muted">{t("forecast.nextHoursOverview")}</p>
<div className="mt-3 grid grid-cols-4 gap-2"> <div className="mt-3 grid grid-cols-4 gap-2">
<HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} /> <HourlySummaryMetric icon={ThermometerSun} label={t("forecast.temperatureRange")} value={temperatureRange} />
<HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} /> <HourlySummaryMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
@@ -89,23 +89,23 @@ function DailyForecastRow({ day, index, onSelect }: { day: DailyForecast; index:
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }} transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
className="border-t border-white/30 first:border-t-0 dark:border-white/10" className="border-t border-border/65 first:border-t-0"
> >
<motion.button <motion.button
type="button" type="button"
whileTap={{ scale: 0.99 }} whileTap={{ scale: 0.99 }}
aria-label={t("forecast.openDayDetails", { day: label })} aria-label={t("forecast.openDayDetails", { day: label })}
className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-xl px-1 py-3 text-left transition hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-600 dark:hover:bg-white/5 sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]" className="grid w-full grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-2 rounded-card px-1 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent sm:grid-cols-[5rem_minmax(0,1fr)_5rem_auto_1rem]"
onClick={() => onSelect(day)} onClick={() => onSelect(day)}
> >
<p className="text-sm font-semibold capitalize">{label}</p> <p className="text-sm font-semibold capitalize">{label}</p>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" /> <ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-accent" />
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span> <span className="truncate text-xs text-muted">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
</div> </div>
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span> <span className="hidden items-center gap-1 text-xs text-accent sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p> <p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-muted">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
<ChevronRight className="hidden size-4 text-slate-400 sm:block" /> <ChevronRight className="hidden size-4 text-muted sm:block" />
</motion.button> </motion.button>
</motion.li> </motion.li>
); );
@@ -123,9 +123,9 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><CloudSun className="size-4" />{t("forecast.label")}</p> <p className="section-kicker flex items-center gap-2"><CloudSun className="size-4" />{t("forecast.label")}</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("forecast.description", { location: locationName })}</p> <p className="mt-1 max-w-3xl text-sm leading-6 text-muted">{t("forecast.description", { location: locationName })}</p>
</div> </div>
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? ( {!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
@@ -144,7 +144,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<div className="space-y-3"> <div className="space-y-3">
<div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch"> <div className="grid items-start gap-3 lg:grid-cols-[1.35fr_1fr] lg:items-stretch">
<Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full"> <Card className="flex flex-col overflow-hidden p-4 sm:p-5 lg:h-full">
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.hourly")}</h3> <h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-accent" />{t("forecast.hourly")}</h3>
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5"> <div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5 lg:mt-5">
<ul className="flex min-w-max gap-2"> <ul className="flex min-w-max gap-2">
{upcomingHours.map((hour, index) => ( {upcomingHours.map((hour, index) => (
@@ -153,24 +153,24 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
initial={{ opacity: 0, y: 8 }} initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }} transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
className="w-[4.6rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5 lg:w-[5.5rem] lg:py-4" className="w-[4.6rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center lg:w-[5.5rem] lg:py-4"
title={getForecastCondition(hour.weatherCode, language)} title={getForecastCondition(hour.weatherCode, language)}
> >
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p> <p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" /> <ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p> <p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p> <p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-accent"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
<div className="mt-3 hidden space-y-1.5 border-t border-white/35 pt-3 text-[0.66rem] text-slate-600 dark:border-white/10 dark:text-slate-300 lg:block"> <div className="mt-3 hidden space-y-1.5 border-t border-border/65 pt-3 text-[0.66rem] text-muted lg:block">
<p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}> <p className="flex items-center justify-center gap-1" title={t("forecast.apparentTemperature")}>
<ThermometerSun className="size-3 text-sky-600 dark:text-sky-300" /> <ThermometerSun className="size-3 text-accent" />
{formatForecastTemperature(hour.feelsLike, language)} {formatForecastTemperature(hour.feelsLike, language)}
</p> </p>
<p className="flex items-center justify-center gap-1" title={t("weather.wind")}> <p className="flex items-center justify-center gap-1" title={t("weather.wind")}>
<Wind className="size-3 text-sky-600 dark:text-sky-300" /> <Wind className="size-3 text-accent" />
{formatForecastWind(hour.windSpeed, language)} {formatForecastWind(hour.windSpeed, language)}
</p> </p>
<p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}> <p className="flex items-center justify-center gap-1" title={t("forecast.precipitation")}>
<CloudRain className="size-3 text-sky-600 dark:text-sky-300" /> <CloudRain className="size-3 text-accent" />
{formatForecastRainfall(hour.precipitation, language)} {formatForecastRainfall(hour.precipitation, language)}
</p> </p>
</div> </div>
@@ -181,7 +181,7 @@ export function ForecastPanel({ latitude, longitude, locationName }: { latitude?
<HourlyForecastSummary hours={upcomingHours} /> <HourlyForecastSummary hours={upcomingHours} />
</Card> </Card>
<Card className="p-4 sm:p-5"> <Card className="p-4 sm:p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3> <h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-accent" />{t("forecast.daily")}</h3>
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul> <ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} onSelect={setSelectedDay} />)}</ul>
</Card> </Card>
</div> </div>

View File

@@ -9,17 +9,17 @@ export function ForecastSources({ sources }: { sources: ForecastSource[] }) {
const hasImgw = sources.includes("imgw-alaro"); const hasImgw = sources.includes("imgw-alaro");
return ( return (
<p className="text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400"> <p className="text-[0.68rem] leading-5 text-muted">
{t("forecast.source")}{" "} {t("forecast.source")}{" "}
{hasImgw && ( {hasImgw && (
<> <>
<a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300"> <a href="https://meteo.imgw.pl/pogoda/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
IMGW ALARO <ExternalLink className="size-3" /> IMGW ALARO <ExternalLink className="size-3" />
</a> </a>
{", "} {", "}
</> </>
)} )}
<a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300"> <a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">
Open-Meteo <ExternalLink className="size-3" /> Open-Meteo <ExternalLink className="size-3" />
</a> </a>
. {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")} . {t(hasImgw ? "forecast.sourceCombinedDescription" : "forecast.sourceFallbackDescription")}

View File

@@ -27,16 +27,16 @@ export function HydroPage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-700 dark:text-cyan-300">{t("hydro.section")}</p> <p className="section-kicker">{t("hydro.section")}</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</h1> <h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("hydro.title")}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("hydro.description")}</p> <p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("hydro.description")}</p>
</div> </div>
<label className="glass relative block rounded-[1.5rem] p-3"> <label className="glass relative block rounded-panel p-3">
<span className="sr-only">{t("hydro.searchLabel")}</span> <span className="sr-only">{t("hydro.searchLabel")}</span>
<Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-slate-500" /> <Search className="pointer-events-none absolute left-6 top-1/2 size-4 -translate-y-1/2 text-muted" />
<input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-2xl border border-white/40 bg-white/45 py-3 pl-10 pr-4 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/5" /> <input value={query} onChange={(event) => { setQuery(event.target.value); setVisibleCount(PAGE_SIZE); }} placeholder={t("hydro.searchPlaceholder")} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3 pl-10 pr-4 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" />
</label> </label>
<p className="text-xs text-slate-500 dark:text-slate-400">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p> <p className="text-xs text-muted">{t("hydro.results", { total: filteredStations.length, visible: Math.min(visibleCount, filteredStations.length) })}</p>
{!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : ( {!filteredStations.length ? <EmptyState icon={Waves} title={t("stations.emptyTitle")} description={t("hydro.emptyDescription")} /> : (
<> <>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{filteredStations.slice(0, visibleCount).map((station, index) => <HydroStationCard key={station.id} station={station} index={index} />)}</div>

View File

@@ -11,11 +11,11 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
const { language, t } = useI18n(); const { language, t } = useI18n();
return ( return (
<motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}> <motion.article initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.02, 0.3), duration: 0.3 }}>
<Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-white/60 dark:hover:bg-slate-900/45"> <Card className="h-full p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<div> <div>
<div> <div>
<h2 className="font-semibold tracking-tight">{station.name}</h2> <h2 className="font-semibold tracking-tight">{station.name}</h2>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p> <p className="mt-1 flex items-center gap-1 text-xs text-muted"><MapPin className="size-3" />{station.river ?? t("hydro.riverUnavailable")}{station.province ? ` · ${station.province}` : ""}</p>
</div> </div>
</div> </div>
<div className="mt-5 grid grid-cols-3 gap-2"> <div className="mt-5 grid grid-cols-3 gap-2">
@@ -23,7 +23,7 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
<HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} /> <HydroMetric icon={Thermometer} label={t("hydro.water")} value={formatTemperature(station.waterTemperature, language)} />
<HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} /> <HydroMetric icon={Activity} label={t("hydro.flow")} value={formatFlow(station.flow, language)} />
</div> </div>
<p className="mt-4 text-[0.7rem] text-slate-500 dark:text-slate-400">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p> <p className="mt-4 text-[0.7rem] text-muted">{t("hydro.levelMeasurement", { date: formatDateTime(station.waterLevelMeasuredAt, language) })}</p>
</Card> </Card>
</motion.article> </motion.article>
); );
@@ -31,8 +31,8 @@ export function HydroStationCard({ station, index = 0 }: { station: HydroStation
function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) { function HydroMetric({ icon: Icon, label, value }: { icon: typeof Droplets; label: string; value: string }) {
return ( return (
<div className="rounded-2xl bg-white/35 p-2.5 dark:bg-white/5"> <div className="rounded-card bg-surface-muted/60 p-2.5">
<p className="flex items-center gap-1 text-[0.65rem] text-slate-500 dark:text-slate-400"><Icon className="size-3" />{label}</p> <p className="flex items-center gap-1 text-[0.65rem] text-muted"><Icon className="size-3" />{label}</p>
<p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p> <p className="mt-1.5 truncate text-xs font-semibold" title={value}>{value}</p>
</div> </div>
); );

View File

@@ -16,17 +16,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.28),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(129,140,248,0.18),transparent_31%)] dark:bg-[radial-gradient(circle_at_top_left,rgba(14,116,144,0.22),transparent_34%),radial-gradient(circle_at_88%_18%,rgba(49,46,129,0.22),transparent_31%)]"> <div className="min-h-screen overflow-x-hidden bg-background">
<header className="sticky top-0 z-40 border-b border-white/25 bg-white/30 backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/30"> <header className="sticky top-0 z-40 border-b border-border/70 bg-surface/80 backdrop-blur-xl dark:bg-background/80">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-white"> <Link href="/" className="text-2xl font-semibold tracking-[-0.09em] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
wtr<span className="text-sky-600 dark:text-sky-300">.</span> wtr<span className="text-accent">.</span>
</Link> </Link>
<nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex"> <nav aria-label={t("nav.main")} className="hidden items-center gap-1 md:flex">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return ( return (
<Link key={item.href} href={item.href} className={cn("rounded-full px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-white/60 text-slate-950 shadow-sm dark:bg-white/15 dark:text-white" : "text-slate-600 hover:bg-white/35 dark:text-slate-300 dark:hover:bg-white/10")}> <Link key={item.href} href={item.href} className={cn("rounded-control px-4 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background shadow-soft" : "text-muted hover:bg-surface-muted/70")}>
{t(item.labelKey)} {t(item.labelKey)}
</Link> </Link>
); );
@@ -40,12 +40,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
<main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main> <main className="mx-auto max-w-7xl px-4 pb-28 pt-5 sm:px-6 sm:pt-8 lg:px-8">{children}</main>
<nav aria-label={t("nav.mobile")} className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-[1.4rem] border border-white/40 bg-white/65 p-1.5 shadow-glass backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65 md:hidden"> <nav aria-label={t("nav.mobile")} className="fixed inset-x-3 bottom-3 z-50 flex justify-around rounded-panel border border-border/70 bg-surface/90 p-1.5 shadow-card backdrop-blur-xl dark:bg-surface/90 md:hidden">
{NAV_ITEMS.map((item, index) => { {NAV_ITEMS.map((item, index) => {
const Icon = icons[index]; const Icon = icons[index];
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href); const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return ( return (
<Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-2xl px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500", active ? "bg-slate-950 text-white dark:bg-white dark:text-slate-950" : "text-slate-600 dark:text-slate-300")}> <Link key={item.href} href={item.href} className={cn("flex min-w-[5rem] flex-col items-center gap-1 rounded-card px-3 py-2 text-[0.68rem] font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active ? "bg-foreground text-background" : "text-muted")}>
<Icon className="size-4" /> <Icon className="size-4" />
{t(item.labelKey)} {t(item.labelKey)}
</Link> </Link>

View File

@@ -5,9 +5,9 @@ import { Card } from "@/components/ui/card";
export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) { export function EmptyState({ title, description, icon: Icon = CircleCheckBig }: { title: string; description: string; icon?: LucideIcon }) {
return ( return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center"> <Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-full bg-emerald-500/10 p-3 text-emerald-600 dark:text-emerald-300"><Icon className="size-6" /></div> <div className="mb-4 rounded-control bg-accent/10 p-3 text-accent"><Icon className="size-6" /></div>
<h2 className="text-lg font-semibold">{title}</h2> <h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description}</p> <p className="mt-2 max-w-md text-sm leading-6 text-muted">{description}</p>
</Card> </Card>
); );
} }

View File

@@ -9,9 +9,9 @@ export function ErrorState({ title, description, onRetry }: { title?: string; de
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center"> <Card className="flex min-h-52 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 rounded-full bg-amber-500/15 p-3 text-amber-700 dark:text-amber-300"><TriangleAlert className="size-6" /></div> <div className="mb-4 rounded-control bg-warning/10 p-3 text-warning"><TriangleAlert className="size-6" /></div>
<h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2> <h2 className="text-lg font-semibold">{title ?? t("error.title")}</h2>
<p className="mb-5 mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-300">{description ?? t("error.description")}</p> <p className="mb-5 mt-2 max-w-md text-sm leading-6 text-muted">{description ?? t("error.description")}</p>
<Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button> <Button variant="glass" onClick={onRetry}><RefreshCw className="size-4" />{t("common.retry")}</Button>
</Card> </Card>
); );

View File

@@ -5,7 +5,7 @@ import { useI18n } from "@/lib/i18n";
export function LoadingSkeleton({ className = "" }: { className?: string }) { export function LoadingSkeleton({ className = "" }: { className?: string }) {
const { t } = useI18n(); const { t } = useI18n();
return <div className={cn("animate-pulse rounded-[1.75rem] bg-white/40 dark:bg-white/10", className)} aria-label={t("common.loading")} />; return <div className={cn("animate-pulse rounded-panel bg-surface-muted/70", className)} aria-label={t("common.loading")} />;
} }
export function PageLoadingSkeleton() { export function PageLoadingSkeleton() {

View File

@@ -3,14 +3,14 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-full text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center gap-2 rounded-control text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-slate-950 px-4 py-2.5 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950 dark:hover:bg-slate-200", default: "bg-foreground px-4 py-2.5 text-background hover:opacity-90",
glass: "border border-white/30 bg-white/30 px-4 py-2.5 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20", glass: "surface-control px-4 py-2.5 text-foreground hover:bg-surface-raised/90",
ghost: "px-3 py-2 text-slate-700 hover:bg-white/50 dark:text-slate-200 dark:hover:bg-white/10", ghost: "px-3 py-2 text-muted hover:bg-surface-muted/70 hover:text-foreground",
icon: "size-10 border border-white/30 bg-white/30 text-slate-800 backdrop-blur-xl hover:bg-white/50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20", icon: "surface-control size-10 text-foreground hover:bg-surface-raised/90",
}, },
}, },
defaultVariants: { variant: "default" }, defaultVariants: { variant: "default" },

View File

@@ -2,5 +2,5 @@ import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) { export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("glass rounded-[1.75rem]", className)} {...props} />; return <div className={cn("glass rounded-panel", className)} {...props} />;
} }

View File

@@ -8,12 +8,12 @@ export function LanguageToggle() {
return ( return (
<label className="relative flex items-center"> <label className="relative flex items-center">
<span className="sr-only">{t("language.label")}</span> <span className="sr-only">{t("language.label")}</span>
<Languages className="pointer-events-none absolute left-3 size-4 text-slate-700 dark:text-slate-200" /> <Languages className="pointer-events-none absolute left-3 size-4 text-muted" />
<select <select
aria-label={t("language.label")} aria-label={t("language.label")}
value={language} value={language}
onChange={(event) => setLanguage(event.target.value as Language)} onChange={(event) => setLanguage(event.target.value as Language)}
className="h-10 appearance-none rounded-full border border-white/30 bg-white/30 py-2 pl-9 pr-3 text-xs font-semibold uppercase text-slate-800 backdrop-blur-xl transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/20" className="surface-control h-10 appearance-none rounded-control py-2 pl-9 pr-3 text-xs font-semibold uppercase text-foreground transition hover:bg-surface-raised/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
> >
<option value="pl">{t("language.polish")}</option> <option value="pl">{t("language.polish")}</option>
<option value="en">{t("language.english")}</option> <option value="en">{t("language.english")}</option>

View File

@@ -73,28 +73,28 @@ export function DashboardWarnings() {
return ( return (
<motion.section <motion.section
aria-label={t("warnings.dashboard.title")} aria-label={t("warnings.dashboard.title")}
className="rounded-[1.75rem] border border-amber-200/60 bg-amber-50/55 p-4 shadow-[0_18px_50px_-34px_rgba(146,64,14,0.45)] backdrop-blur-xl dark:border-amber-300/15 dark:bg-amber-950/15 sm:p-5" className="rounded-panel border border-warning/25 bg-warning/10 p-4 shadow-soft backdrop-blur-xl sm:p-5"
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }} transition={{ duration: 0.35, ease: "easeOut" }}
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="rounded-full border border-amber-300/60 bg-amber-100/70 p-2 text-amber-700 dark:border-amber-300/20 dark:bg-amber-300/10 dark:text-amber-200"> <div className="rounded-control border border-warning/30 bg-warning/10 p-2 text-warning">
<AlertTriangle className="size-4" aria-hidden="true" /> <AlertTriangle className="size-4" aria-hidden="true" />
</div> </div>
<div> <div>
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-amber-800/70 dark:text-amber-100/65"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-warning">
IMGW · {formatProvinceName(province, language)} IMGW · {formatProvinceName(province, language)}
</p> </p>
<h2 className="mt-1 text-base font-semibold text-slate-900 dark:text-white"> <h2 className="mt-1 text-base font-semibold text-foreground">
{t("warnings.dashboard.title")} {t("warnings.dashboard.title")}
</h2> </h2>
</div> </div>
</div> </div>
<Link <Link
href="/warnings" href="/warnings"
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-1 text-xs font-semibold text-amber-800 transition hover:bg-amber-100/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 dark:text-amber-100 dark:hover:bg-amber-300/10" className="inline-flex w-fit items-center gap-1.5 rounded-control px-2 py-1 text-xs font-semibold text-warning transition hover:bg-warning/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-warning"
> >
{t("warnings.dashboard.viewAll")} {t("warnings.dashboard.viewAll")}
<ArrowRight className="size-3.5" aria-hidden="true" /> <ArrowRight className="size-3.5" aria-hidden="true" />
@@ -111,17 +111,17 @@ export function DashboardWarnings() {
return ( return (
<article <article
key={warning.id} key={warning.id}
className="rounded-2xl border border-amber-200/60 bg-white/45 px-3.5 py-3 dark:border-amber-200/10 dark:bg-slate-950/15" className="rounded-card border border-warning/20 bg-surface/60 px-3.5 py-3"
> >
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-amber-800/70 dark:text-amber-100/65"> <p className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-warning">
{t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")} {t(active ? "warnings.dashboard.active" : "warnings.dashboard.upcoming")}
{warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`} {warning.level !== null && ` · ${t("warnings.level", { level: warning.level })}`}
</p> </p>
<p className="mt-1 text-sm font-semibold text-slate-900 dark:text-white"> <p className="mt-1 text-sm font-semibold text-foreground">
{warning.title || t("warnings.genericMeteo")} {warning.title || t("warnings.genericMeteo")}
</p> </p>
{validityLabel && ( {validityLabel && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-slate-600 dark:text-slate-300"> <p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted">
<CalendarClock className="size-3.5" aria-hidden="true" /> <CalendarClock className="size-3.5" aria-hidden="true" />
{validityLabel} {validityLabel}
</p> </p>
@@ -132,7 +132,7 @@ export function DashboardWarnings() {
</div> </div>
{hiddenWarningsCount > 0 && ( {hiddenWarningsCount > 0 && (
<p className="mt-3 text-xs font-medium text-amber-800/75 dark:text-amber-100/70"> <p className="mt-3 text-xs font-medium text-warning">
{t("warnings.dashboard.more", { count: hiddenWarningsCount })} {t("warnings.dashboard.more", { count: hiddenWarningsCount })}
</p> </p>
)} )}

View File

@@ -20,17 +20,17 @@ export function WarningCard({ warning, index = 0 }: { warning: WeatherWarning; i
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}> <motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.04, 0.4), duration: 0.35 }}>
<Card className="h-full overflow-hidden p-5"> <Card className="h-full overflow-hidden p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="rounded-2xl bg-amber-500/15 p-2.5 text-amber-700 dark:text-amber-300"><Icon className="size-5" /></div> <div className="rounded-card bg-warning/10 p-2.5 text-warning"><Icon className="size-5" /></div>
<span className={cn("rounded-full border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-orange-300/40 bg-orange-400/15 text-orange-800 dark:text-orange-200" : "border-amber-300/40 bg-amber-400/15 text-amber-800 dark:text-amber-200")}>{levelLabel}</span> <span className={cn("rounded-control border px-2.5 py-1 text-xs font-semibold", level === -1 ? "border-warning/30 bg-warning/10 text-warning" : "border-warning/25 bg-warning/10 text-warning")}>{levelLabel}</span>
</div> </div>
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500 dark:text-slate-400">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p> <p className="mt-5 text-xs font-semibold uppercase tracking-[0.15em] text-muted">{warning.kind === "hydro" ? t("warnings.hydro") : t("warnings.meteo")}</p>
<h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2> <h2 className="mt-2 text-lg font-semibold tracking-tight">{warning.title || (warning.kind === "hydro" ? t("warnings.genericHydro") : t("warnings.genericMeteo"))}</h2>
{warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-slate-600 dark:text-slate-300">{warning.description}</p>} {warning.description && <p className="mt-3 line-clamp-5 text-sm leading-6 text-muted">{warning.description}</p>}
<div className="mt-5 space-y-2 text-xs text-slate-500 dark:text-slate-400"> <div className="mt-5 space-y-2 text-xs text-muted">
<p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p> <p className="flex items-start gap-2"><CalendarClock className="mt-0.5 size-3.5 shrink-0" />{formatDateTime(warning.validFrom, language)} {warning.validTo ? formatDateTime(warning.validTo, language) : t("warnings.untilCancelled")}</p>
<p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p> <p className="flex items-start gap-2"><MapPinned className="mt-0.5 size-3.5 shrink-0" />{areasLabel || t("warnings.areaUnknown")}</p>
</div> </div>
{warning.probability !== null && <p className="mt-4 text-xs font-medium text-slate-600 dark:text-slate-300">{t("warnings.probability", { value: warning.probability })}</p>} {warning.probability !== null && <p className="mt-4 text-xs font-medium text-muted">{t("warnings.probability", { value: warning.probability })}</p>}
</Card> </Card>
</motion.article> </motion.article>
); );

View File

@@ -8,9 +8,9 @@ export function WarningsPageContent() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("warnings.section")}</p> <p className="section-kicker">{t("warnings.section")}</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1> <h1 className="mt-2 text-3xl font-semibold tracking-tight">{t("warnings.title")}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.description")}</p> <p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{t("warnings.description")}</p>
</div> </div>
<WarningsPanel /> <WarningsPanel />
</div> </div>

View File

@@ -40,9 +40,9 @@ export function WarningsPanel() {
<div className="space-y-9"> <div className="space-y-9">
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><MapPinned className="size-4" />{t("warnings.myProvince")}</p> <p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("warnings.myProvince")}</p>
<h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{provinceLabel}</h2> <h2 className="mt-2 text-2xl font-semibold capitalize tracking-tight">{provinceLabel}</h2>
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.myProvinceDescription", { province: provinceLabel })}</p>
</div> </div>
{localWarnings.length {localWarnings.length
? <WarningGrid warnings={localWarnings} /> ? <WarningGrid warnings={localWarnings} />
@@ -52,8 +52,8 @@ export function WarningsPanel() {
{otherWarnings.length > 0 && ( {otherWarnings.length > 0 && (
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400"><Map className="size-4" />{t("warnings.otherRegions")}</p> <p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted"><Map className="size-4" />{t("warnings.otherRegions")}</p>
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("warnings.otherRegionsDescription")}</p> <p className="mt-1 max-w-2xl text-sm leading-6 text-muted">{t("warnings.otherRegionsDescription")}</p>
</div> </div>
<WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} /> <WarningGrid warnings={otherWarnings} indexOffset={localWarnings.length} />
</section> </section>

View File

@@ -100,13 +100,13 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
return ( return (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{showPrompt && ( {showPrompt && (
<div className="glass-subtle rounded-2xl p-3.5"> <div className="glass-subtle rounded-card p-3.5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="rounded-full bg-sky-500/10 p-2 text-sky-700 dark:text-sky-300"><MapPinned className="size-4" /></div> <div className="rounded-control bg-accent/10 p-2 text-accent"><MapPinned className="size-4" /></div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p> <p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("location.gpsPromptDescription")}</p> <p className="mt-1 text-xs leading-5 text-muted">{t("location.gpsPromptDescription")}</p>
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>} {!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-warning"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}> <Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />} {isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
@@ -124,11 +124,11 @@ export function CurrentLocationControl({ stations }: { stations: LocatedSynopSta
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />} {isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")} {isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
</Button> </Button>
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-slate-600 dark:text-slate-300">{message}</p>} {message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-muted">{message}</p>}
</div> </div>
)} )}
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400"> <p className="text-[0.68rem] text-muted">
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">OpenStreetMap <ExternalLink className="size-3" /></a> {t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-muted/60 underline-offset-2 transition hover:text-accent">OpenStreetMap <ExternalLink className="size-3" /></a>
</p> </p>
</div> </div>
); );

View File

@@ -18,7 +18,7 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div> <div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><MapPinned className="size-4" />{t("featured.label")}</p> <p className="section-kicker flex items-center gap-2"><MapPinned className="size-4" />{t("featured.label")}</p>
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2> <h2 className="mt-2 text-xl font-semibold tracking-tight">{t("featured.title")}</h2>
</div> </div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
@@ -29,13 +29,13 @@ export function FeaturedStationsSection({ stations }: { stations: SynopStation[]
type="button" type="button"
key={station.id} key={station.id}
onClick={() => selectStation(station.id)} onClick={() => selectStation(station.id)}
className={cn("glass-subtle flex items-center justify-between gap-2 rounded-2xl p-3 text-left transition hover:-translate-y-0.5 hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10", active && "border-sky-400/60 bg-white/60 dark:bg-white/15")} className={cn("glass-subtle flex items-center justify-between gap-2 rounded-card p-3 text-left transition hover:-translate-y-0.5 hover:bg-surface-raised/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent", active && "border-accent/60 bg-surface-raised/90")}
> >
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-xs font-medium text-slate-600 dark:text-slate-300">{station.name}</span> <span className="block truncate text-xs font-medium text-muted">{station.name}</span>
<span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span> <span className="mt-1 block text-xl font-semibold tracking-tight">{formatTemperature(station.temperature, language)}</span>
</span> </span>
<WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" /> <WeatherIcon mood={getWeatherMoodFromData(station)} className="size-6 shrink-0 text-accent" />
</button> </button>
); );
})} })}

View File

@@ -26,14 +26,14 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
return ( return (
<section className="relative z-30"> <section className="relative z-30">
<div className="glass rounded-[1.75rem] p-3 sm:p-4"> <div className="glass rounded-panel p-3 sm:p-4">
<div className="flex items-center gap-2 px-1 pb-3"> <div className="flex items-center gap-2 px-1 pb-3">
<MapPin className="size-4 text-sky-700 dark:text-sky-300" /> <MapPin className="size-4 text-accent" />
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p> <p className="section-kicker">{t("location.label")}</p>
</div> </div>
<label className="relative block"> <label className="relative block">
<span className="sr-only">{t("location.searchLabel")}</span> <span className="sr-only">{t("location.searchLabel")}</span>
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-sky-600" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />} {isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-accent" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />}
<input <input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@@ -41,25 +41,25 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)} onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
placeholder={t("location.placeholder")} placeholder={t("location.placeholder")}
autoComplete="off" autoComplete="off"
className="w-full rounded-2xl border border-white/40 bg-white/55 py-3.5 pl-10 pr-10 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10" className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
/> />
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>} {query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><X className="size-4" /></button>}
</label> </label>
<CurrentLocationControl stations={locatedStations} /> <CurrentLocationControl stations={locatedStations} />
{selectedLocation && ( {selectedLocation && (
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300"> <p className="mt-3 px-1 text-xs text-muted">
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })} {t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
</p> </p>
)} )}
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400"> <p className="mt-3 px-1 text-[0.68rem] text-muted">
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo / GeoNames</a> {t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent">Open-Meteo / GeoNames</a>
</p> </p>
</div> </div>
{showSuggestions && ( {showSuggestions && (
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass"> <div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
{isPreparingStations ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.preparing")}</p> : {isPreparingStations ? <p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p> :
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.error")}</p> : isError ? <p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p> :
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.empty")}</p> : !isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p> :
suggestions.map(({ location, nearest }) => nearest && ( suggestions.map(({ location, nearest }) => nearest && (
<button <button
type="button" type="button"
@@ -69,13 +69,13 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
setQuery(""); setQuery("");
setIsFocused(false); setIsFocused(false);
}} }}
className="flex w-full items-start justify-between gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10" className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
> >
<span> <span>
<span className="block text-sm font-semibold">{location.name}</span> <span className="block text-sm font-semibold">{location.name}</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{[location.district, location.province].filter(Boolean).join(" · ")}</span> <span className="mt-0.5 block text-xs text-muted">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
</span> </span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">{t("location.nearest")}<br /><strong className="font-semibold text-slate-700 dark:text-slate-200">{nearest.stationName} · {nearest.distanceKm} km</strong></span> <span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
</button> </button>
))} ))}
</div> </div>

View File

@@ -8,12 +8,12 @@ export function MetricCard({ icon: Icon, label, value, detail, index = 0 }: { ic
return ( return (
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}> <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.04, duration: 0.35 }}>
<Card className="h-full p-4 sm:p-5"> <Card className="h-full p-4 sm:p-5">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400"> <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
<Icon className="size-4 text-sky-600 dark:text-sky-300" /> <Icon className="size-4 text-accent" />
{label} {label}
</div> </div>
<p className="mt-4 text-xl font-semibold tracking-tight">{value}</p> <p className="mt-4 text-xl font-semibold tracking-tight">{value}</p>
{detail && <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{detail}</p>} {detail && <p className="mt-1 text-xs text-muted">{detail}</p>}
</Card> </Card>
</motion.div> </motion.div>
); );

View File

@@ -22,20 +22,20 @@ export function StationCard({ station, index = 0 }: { station: SynopStation; ind
const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`; const compactWind = station.windSpeed === null ? "—" : `${station.windSpeed.toFixed(1)} m/s`;
return ( return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}> <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: Math.min(index * 0.025, 0.3), duration: 0.3 }}>
<Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-white/60 dark:hover:bg-slate-900/45"> <Card className="group relative h-full overflow-hidden p-4 transition duration-300 hover:-translate-y-1 hover:bg-surface-raised/90">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500"> <Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="min-w-0 flex-1 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<p className="truncate text-sm font-semibold">{station.name}</p> <p className="truncate text-sm font-semibold">{station.name}</p>
<p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p> <p className="mt-3 text-4xl font-light tracking-[-0.08em]">{formatTemperature(station.temperature, language)}</p>
</Link> </Link>
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
<WeatherIcon mood={mood} className="size-9 text-sky-600 dark:text-sky-300" /> <WeatherIcon mood={mood} className="size-9 text-accent" />
<Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}> <Button variant="ghost" className="size-8 p-0" aria-label={favorite ? t("favorites.removeStation", { name: station.name }) : t("favorites.addStation", { name: station.name })} onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} /> <Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
</Button> </Button>
</div> </div>
</div> </div>
<Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-400"> <Link href={`/station/${station.id}`} onClick={() => selectStation(station.id)} className="mt-4 grid grid-cols-3 gap-2 rounded-lg text-[0.68rem] text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span> <span className="flex items-center gap-1"><Droplets className="size-3" />{formatHumidity(station.humidity, language)}</span>
<span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span> <span className="flex items-center gap-1"><Wind className="size-3" />{compactWind}</span>
<span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span> <span className="flex items-center gap-1"><Gauge className="size-3" />{station.pressure === null ? "—" : formatPressure(station.pressure, language).split(" ")[0]}</span>

View File

@@ -27,7 +27,7 @@ export function StationDetailPage({ id }: { id: string }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<Link href="/" className="inline-flex items-center gap-2 rounded-full px-1 py-1 text-sm font-medium text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:text-slate-300"><ArrowLeft className="size-4" />{t("station.all")}</Link> <Link href="/" className="inline-flex items-center gap-2 rounded-control px-1 py-1 text-sm font-medium text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"><ArrowLeft className="size-4" />{t("station.all")}</Link>
<Button variant="glass" onClick={() => toggleFavorite(station.id)}> <Button variant="glass" onClick={() => toggleFavorite(station.id)}>
<Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} /> <Heart className={cn("size-4", favorite && "fill-rose-500 text-rose-500")} />
{favorite ? t("favorites.remove") : t("favorites.add")} {favorite ? t("favorites.remove") : t("favorites.add")}
@@ -35,20 +35,20 @@ export function StationDetailPage({ id }: { id: string }) {
</div> </div>
<WeatherHero station={station} /> <WeatherHero station={station} />
<section> <section>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("station.label", { name: station.name })}</p> <p className="section-kicker">{t("station.label", { name: station.name })}</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{t("station.parameters")}</h1> <h1 className="mt-2 text-2xl font-semibold tracking-tight">{t("station.parameters")}</h1>
<p className="mb-4 mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.parametersDescription")}</p> <p className="mb-4 mt-1 text-sm leading-6 text-muted">{t("station.parametersDescription")}</p>
<WeatherDetailsGrid station={station} /> <WeatherDetailsGrid station={station} />
</section> </section>
<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]"> <div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
<SnapshotChart station={station} /> <SnapshotChart station={station} />
<Card className="p-5"> <Card className="p-5">
<div className="flex items-center gap-2 text-sky-700 dark:text-sky-300"><ShieldCheck className="size-5" /><p className="text-xs font-semibold uppercase tracking-[0.18em]">{t("station.quality")}</p></div> <div className="section-kicker flex items-center gap-2"><ShieldCheck className="size-5" /><p>{t("station.quality")}</p></div>
<h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2> <h2 className="mt-4 text-xl font-semibold tracking-tight">{t("station.lastMeasurementImgw")}</h2>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("station.qualityDescription")}</p> <p className="mt-2 text-sm leading-6 text-muted">{t("station.qualityDescription")}</p>
<dl className="mt-6 space-y-3 text-sm"> <dl className="mt-6 space-y-3 text-sm">
<div><dt className="text-slate-500 dark:text-slate-400">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div> <div><dt className="text-muted">{t("station.lastMeasurement")}</dt><dd className="mt-0.5 font-medium">{formatDateTime(station.measuredAt, language)}</dd></div>
<div><dt className="text-slate-500 dark:text-slate-400">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div> <div><dt className="text-muted">{t("station.source")}</dt><dd className="mt-0.5 font-medium">{t("station.publicApi")}</dd></div>
</dl> </dl>
</Card> </Card>
</div> </div>

View File

@@ -33,12 +33,12 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
<motion.div <motion.div
animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }} animate={reduceMotion ? undefined : { x: ["-4%", "5%", "-4%"], y: [0, 8, 0], scale: [1, 1.05, 1] }}
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
className="absolute -left-24 -top-20 h-52 w-[78%] rounded-[50%] bg-slate-100/30 blur-3xl" className="absolute -left-24 -top-20 h-52 w-[78%] rounded-full bg-slate-100/25 blur-2xl"
/> />
<motion.div <motion.div
animate={reduceMotion ? undefined : { x: ["8%", "-5%", "8%"], y: [0, -6, 0], scale: [1.04, 1, 1.04] }} animate={reduceMotion ? undefined : { x: ["8%", "-5%", "8%"], y: [0, -6, 0], scale: [1.04, 1, 1.04] }}
transition={{ duration: 22, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 22, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-24 top-0 h-60 w-[82%] rounded-[50%] bg-slate-300/24 blur-3xl" className="absolute -right-24 top-0 h-60 w-[82%] rounded-full bg-slate-300/20 blur-2xl"
/> />
<div className="absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-slate-100/25 via-slate-200/10 to-transparent" /> <div className="absolute inset-x-0 bottom-0 h-44 bg-gradient-to-t from-slate-100/25 via-slate-200/10 to-transparent" />
<div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-slate-950/15 to-transparent" /> <div className="absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-slate-950/15 to-transparent" />
@@ -48,7 +48,7 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
<motion.div <motion.div
animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }} animate={reduceMotion ? undefined : { scale: [1, 1.08, 1], opacity: [0.4, 0.58, 0.4] }}
transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }} transition={{ duration: 7, repeat: Infinity, ease: "easeInOut" }}
className="absolute -right-16 -top-20 size-64 rounded-full bg-amber-200/45 blur-3xl" className="absolute -right-12 -top-16 size-52 rounded-full bg-amber-200/25 blur-2xl"
/> />
)} )}
{mood === "night" && stars.map((star, index) => ( {mood === "night" && stars.map((star, index) => (
@@ -88,7 +88,7 @@ export function WeatherEffects({ station, mood, precipitation10m, thunderstorm =
/> />
)} )}
{mood === "cold" && ( {mood === "cold" && (
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-cyan-100/20 to-transparent" /> <div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-blue-100/20 to-transparent" />
)} )}
</div> </div>
); );

View File

@@ -50,11 +50,9 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
initial={{ opacity: 0, y: 18 }} initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: "easeOut" }} transition={{ duration: 0.55, ease: "easeOut" }}
className={`relative isolate overflow-hidden rounded-[2rem] bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-[0_24px_75px_rgba(15,23,42,0.24)] sm:px-8 sm:py-8 lg:px-10`} className={`relative isolate overflow-hidden rounded-panel bg-gradient-to-br ${moodGradient(mood)} px-5 py-6 text-white shadow-card sm:px-8 sm:py-8 lg:px-10`}
> >
<WeatherEffects station={displayedStation} mood={mood} precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} /> <WeatherEffects station={displayedStation} mood={mood} precipitation10m={currentWeather?.precipitation10m} thunderstorm={currentWeather?.condition === "thunderstorm"} />
<div className="absolute -right-20 -top-20 size-72 rounded-full bg-white/15 blur-3xl" />
<div className="absolute -bottom-24 -left-16 size-72 rounded-full bg-cyan-200/15 blur-3xl" />
<div className="relative z-10"> <div className="relative z-10">
<div> <div>
<span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{displayedLocationName}</span> <span className="flex items-center gap-1.5 text-sm font-medium text-white/85"><MapPin className="size-4" />{displayedLocationName}</span>
@@ -74,7 +72,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
</div> </div>
<div className="mt-8 flex items-end justify-between gap-3 sm:mt-10"> <div className="mt-8 flex items-end justify-between gap-3 sm:mt-10">
<div> <div>
<div className="text-[5.8rem] font-medium leading-[0.85] tracking-[-0.11em] drop-shadow-[0_10px_24px_rgba(15,23,42,0.16)] sm:text-[8rem]"> <div className="text-[5.8rem] font-semibold leading-[0.85] tracking-[-0.1em] sm:text-[8rem]">
{formatTemperature(displayedStation.temperature, language)} {formatTemperature(displayedStation.temperature, language)}
</div> </div>
<p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p> <p className="mt-5 text-xl font-medium tracking-tight sm:text-2xl">{getWeatherDescription(displayedStation, language, currentWeather?.condition)}</p>
@@ -84,7 +82,7 @@ export function WeatherHero({ station, currentWeather, currentWeatherLoading = f
</div> </div>
<div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4"> <div className="mt-8 grid grid-cols-2 gap-2.5 sm:mt-10 lg:grid-cols-4">
{metrics.map(({ icon: Icon, label, value }) => ( {metrics.map(({ icon: Icon, label, value }) => (
<div key={label} className="rounded-2xl border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl"> <div key={label} className="rounded-card border border-white/20 bg-white/10 p-3.5 backdrop-blur-xl">
<div className="flex items-center gap-2 text-xs text-white/70"><Icon className="size-3.5" />{label}</div> <div className="flex items-center gap-2 text-xs text-white/70"><Icon className="size-3.5" />{label}</div>
<p className="mt-2 text-base font-semibold">{value}</p> <p className="mt-2 text-base font-semibold">{value}</p>
</div> </div>

View File

@@ -186,11 +186,11 @@ export function getWeatherDescription(station: SynopStation, language: Language
export function moodGradient(mood: WeatherMood) { export function moodGradient(mood: WeatherMood) {
return { return {
warm: "from-sky-400 via-blue-500 to-indigo-700", warm: "from-blue-500 via-blue-700 to-slate-900",
cloudy: "from-slate-600 via-slate-700 to-slate-900", cloudy: "from-slate-600 via-slate-700 to-slate-900",
wind: "from-cyan-600 via-slate-600 to-blue-950", wind: "from-slate-500 via-blue-700 to-slate-900",
cold: "from-cyan-300 via-blue-500 to-indigo-900", cold: "from-blue-300 via-slate-500 to-slate-800",
night: "from-slate-800 via-indigo-950 to-slate-950", night: "from-slate-800 via-slate-900 to-slate-950",
mild: "from-sky-500 via-cyan-700 to-blue-900", mild: "from-blue-500 via-slate-700 to-slate-900",
}[mood]; }[mood];
} }

View File

@@ -10,11 +10,30 @@ export default {
], ],
theme: { theme: {
extend: { extend: {
colors: {
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
surface: "hsl(var(--surface) / <alpha-value>)",
"surface-muted": "hsl(var(--surface-muted) / <alpha-value>)",
"surface-raised": "hsl(var(--surface-raised) / <alpha-value>)",
border: "hsl(var(--border) / <alpha-value>)",
muted: "hsl(var(--muted) / <alpha-value>)",
accent: "hsl(var(--accent) / <alpha-value>)",
"accent-foreground": "hsl(var(--accent-foreground) / <alpha-value>)",
warning: "hsl(var(--warning) / <alpha-value>)",
},
fontFamily: { fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"], sans: ["var(--font-inter)", "system-ui", "sans-serif"],
}, },
borderRadius: {
panel: "1.5rem",
card: "1.25rem",
control: "9999px",
},
boxShadow: { boxShadow: {
glass: "0 18px 55px rgba(15, 23, 42, 0.13)", card: "0 16px 42px hsl(215 32% 18% / 0.08)",
glass: "0 16px 42px hsl(215 32% 18% / 0.08)",
soft: "0 10px 28px hsl(215 32% 18% / 0.06)",
}, },
}, },
}, },