diff --git a/docs/weather-app-architecture.md b/docs/weather-app-architecture.md new file mode 100644 index 0000000..d6ac665 --- /dev/null +++ b/docs/weather-app-architecture.md @@ -0,0 +1,72 @@ +# Weather app architecture + +## Overview + +The production entry point is `web/src/App.tsx`. `App` owns the user-facing state and composes the reusable city list: + +```text +App +├── Header +│ ├── city selector +│ ├── activity selector +│ └── sort selector +└── WeatherList + └── WeatherCard + └── MainWeather (centered overlay while that card is expanded) +``` + +## State and persistence + +- The selected city is stored in `sessionStorage` under `t31-selected-city`. +- The selected activity is stored in `sessionStorage` under `t31-activity`. +- The selected sort mode is stored in `sessionStorage` under `t31-sort-mode`. +- Favorite city IDs are stored in `localStorage` under `t31-favorite-cities`. +- `useStoredState` parses JSON and validates stored values with Zod. Invalid or unavailable values fall back to defaults. +- Weather payloads are not stored in Web Storage. TanStack Query owns the live API responses. + +Session storage is used for choices that describe the current visit. Local storage is used for favorites because favorites should survive closing and reopening the browser. + +## Main view and navigation + +The default view presents the weather cards first. Users can select a city through the city select or a card. A card's details action opens a wider centered overlay without changing the grid row height or aligning the panel to the card column. The panel supplies the main temperature, weather type, precipitation, wind, update time and provider interval, with a close action. + +The card list remains visible around the expanded detail. It is sorted from a copied array, so the source city list is never mutated. + +## Sorting and ranking + +The list supports: + +- Original city order. +- Best weather for the selected activity. +- Highest temperature. +- Lowest precipitation. +- Lowest wind speed. + +The activity ranking is intentionally small and explainable at this stage. It rewards conditions suitable for the selected activity and subtracts penalties for precipitation and wind. Ranking is presentation logic; the validated API data remains unchanged. + +## Accessibility and responsive design + +- Native `select` and `button` controls provide keyboard interaction. +- Labels describe all selection controls. +- `main`, `header`, `section`, `article`, `dl`, `dt` and `dd` provide semantic structure. +- Loading and API failures use `role="status"` and `role="alert"`. +- Focus-visible styles are defined in `web/src/styles.css`. +- The layout changes from a horizontal desktop header to stacked mobile controls below 760px. +- Cards use stable grid sizing and remain usable in narrow viewports. + +## Verification + +Checks run for this implementation: + +```bash +npm test +npm run lint +npm run format:check +npm run build --workspace @t31/web +``` + +The component tests use MSW fixtures, so unit tests do not call Open-Meteo. They cover the existing validated weather example, city/activity state, city navigation, sorting persistence and favorite persistence. Playwright remains the browser-level check for responsive layout and accessibility; install its browsers with `npm run test:e2e:install` before running it locally. + +## Scope notes + +The ranking formula is a first usable implementation, not a medical or safety forecast. Future issues can refine activity-specific thresholds, add a visible ranking explanation, and add filtering for favorites or weather categories. diff --git a/docs/weather-cards-and-ranking.md b/docs/weather-cards-and-ranking.md new file mode 100644 index 0000000..bf2f931 --- /dev/null +++ b/docs/weather-cards-and-ranking.md @@ -0,0 +1,66 @@ +# Weather cards and activity ranking + +## New page flow + +The weather cards are the default main view. The page opens directly on the city collection so users can compare the available cities at a glance. + +Each card provides: + +- city name +- weather type +- temperature +- precipitation +- wind +- city selection +- favorite toggle +- a `Se detaljer` action + +Selecting a city updates the selected city state. Opening details shows a wider centered overlay. The overlay is independent of the card column, does not change the height of the card grid, and contains the selected city's relevant weather summary and update time. It can be closed with the close button or by clicking outside the panel. + +## Activity-specific ranking + +The `Best vær for aktivitet` sort option uses a separate weather profile for each activity: + +- `Gåtur` prefers mild temperatures, low precipitation and moderate wind. +- `Løpetur` prefers cooler temperatures, no precipitation and low wind. +- `Skitur` prefers temperatures below freezing and gives a bonus to snow weather codes. + +The score combines distance from the activity's ideal temperature with precipitation and wind penalties. The resulting ranking is presentation state only; API responses and the city source array are not changed. + +When the best-activity sort is active, cards display their current rank. Changing the activity recalculates all scores and can change which city is ranked first. + +## State and storage + +`App` owns the selected city, activity, sort mode, favorite IDs and currently expanded detail city: + +- selected city, activity and sort mode use `sessionStorage` +- favorite city IDs use `localStorage` +- expanded detail state is temporary React state and is not persisted +- weather payloads remain in TanStack Query rather than Web Storage + +Stored values are parsed and validated with Zod through `useStoredState`. Invalid storage values fall back to safe defaults. + +## Accessibility and responsive behavior + +The implementation uses native `select` and `button` controls, accessible labels, semantic headings and `dl` elements for weather values. The expanded main view has a clear heading and close control. Focus-visible styles remain available for keyboard users, and the card grid/control area stacks on narrow screens. + +## Verification + +The implementation is covered by MSW-backed Vitest tests for: + +- the existing validated weather example +- city and activity selection +- card-first detail expansion and navigation +- favorite and sort persistence +- activity-specific ranking changes + +The standard checks are: + +```bash +npm test +npm run lint +npm run format:check +npm run build --workspace @t31/web +``` + +Playwright browser checks should be run after installing the configured browsers with `npm run test:e2e:install`. diff --git a/e2e/weather.spec.ts b/e2e/weather.spec.ts index 8586ca1..765e485 100644 --- a/e2e/weather.spec.ts +++ b/e2e/weather.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; +import { cities } from '../web/src/features/weather/cities'; import { weatherResponse } from '../web/src/test/fixtures'; test('weather example loads accessibly without horizontal overflow', async ({ page, @@ -10,11 +11,22 @@ test('weather example loads accessibly without horizontal overflow', async ({ await route.fulfill({ json: weatherResponse }); }); await page.goto('./'); + await expect( + page.getByRole('heading', { name: 'Vær i norske byer' }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Se detaljer for Trondheim' }).click(); await expect( page.getByRole('heading', { name: 'Været i Trondheim' }), ).toBeVisible(); - await expect(page.getByText('14 °C')).toBeVisible(); - expect(calls).toBe(1); + await expect( + page + .locator('section[aria-labelledby="selected-weather-title"]') + .getByText('14°'), + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Vær i norske byer' }), + ).toBeVisible(); + expect(calls).toBe(cities.length); expect( await page.evaluate( () => document.documentElement.scrollWidth <= window.innerWidth, diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..effcc9f --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,448 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import { z } from 'zod'; +import { + cities, + exampleCity, + type City, + type CityId, +} from './features/weather/cities'; +import { weatherQuery, type CityWeather } from './features/weather/weather'; +import { useStoredState } from './shared/storage'; +import { WeatherList, type WeatherListItem } from './WeatherList'; + +const activities = ['Gåtur', 'Løpetur', 'Skitur'] as const; +type Activity = (typeof activities)[number]; +const sortModes = ['default', 'best', 'temperature', 'rain', 'wind'] as const; +type SortMode = (typeof sortModes)[number]; +const cityIds = new Set(cities.map(({ id }) => id)); +const activitySchema = z.enum(activities); +const sortModeSchema = z.enum(sortModes); +const cityIdSchema = z + .string() + .refine((id): id is CityId => cityIds.has(id as CityId)); +const favoriteIdsSchema = z.array(cityIdSchema); + +function Header({ + selectedCityId, + activity, + sortMode, + onCityChange, + onActivityChange, + onSortChange, +}: { + selectedCityId: CityId; + activity: Activity; + sortMode: SortMode; + onCityChange: (cityId: CityId) => void; + onActivityChange: (activity: Activity) => void; + onSortChange: (sortMode: SortMode) => void; +}) { + return ( +
+

IT2810 · Gruppe 31

+

Turvær Norge

+

Finn været for din neste tur.

+
+ + + +
+
+ ); +} + +const weatherTypes: Record = { + 0: 'Klarvær', + 1: 'Hovedsakelig klart', + 2: 'Delvis skyet', + 3: 'Overskyet', + 45: 'Tåke', + 48: 'Underkjølt tåke', + 51: 'Lett yr', + 53: 'Yr', + 55: 'Kraftig yr', + 61: 'Lett regn', + 63: 'Regn', + 65: 'Kraftig regn', + 71: 'Lett snø', + 73: 'Snø', + 75: 'Kraftig snø', + 80: 'Lette regnbyger', + 81: 'Regnbyger', + 82: 'Kraftige regnbyger', + 95: 'Tordenvær', + 96: 'Tordenvær med hagl', + 99: 'Tordenvær med kraftig hagl', +}; + +function scoreWeather(weather: CityWeather | null, activity: Activity) { + if (!weather) return Number.NEGATIVE_INFINITY; + const { + temperature_2m: temperature, + precipitation, + wind_speed_10m: wind, + } = weather.current; + const profile = { + Gåtur: { + idealTemperature: 14, + temperatureWeight: 2, + rainWeight: 16, + windWeight: 3, + }, + Løpetur: { + idealTemperature: 11, + temperatureWeight: 3, + rainWeight: 24, + windWeight: 4, + }, + Skitur: { + idealTemperature: -3, + temperatureWeight: 4, + rainWeight: 10, + windWeight: 2, + }, + }[activity]; + const snowCode = + weather.current.weather_code >= 71 && weather.current.weather_code <= 77; + const snowBonus = activity === 'Skitur' && snowCode ? 35 : 0; + return ( + 100 - + Math.abs(temperature - profile.idealTemperature) * + profile.temperatureWeight - + precipitation * profile.rainWeight - + wind * profile.windWeight + + snowBonus + ); +} + +function DetailsOverlay({ + children, + onClose, +}: { + children: ReactNode; + onClose: () => void; +}) { + const overlayRef = useRef(null); + + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay) return; + const previouslyFocused = document.activeElement as HTMLElement | null; + const focusableSelector = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const getFocusable = () => + Array.from( + overlay.querySelectorAll(focusableSelector), + ).filter((element) => !element.hasAttribute('disabled')); + + (getFocusable()[0] ?? overlay).focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== 'Tab') return; + + const focusable = getFocusable(); + if (focusable.length === 0) { + event.preventDefault(); + overlay.focus(); + return; + } + + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + + return ( +
+
+ ); +} + +function MainWeather({ + city, + weather, + activity, + isLoading, + error, + onRetry, + onClose, +}: { + city: City; + weather: CityWeather | null; + activity: Activity; + isLoading: boolean; + error: Error | null; + onRetry: () => void; + onClose: () => void; +}) { + return ( +
+
+
+

Valgt by

+

Været i {city.name}

+
+ +
+

+ Vurdert for {activity.toLowerCase()}. +

+ {isLoading &&

Henter værdata…

} + {error && ( +
+

Kunne ikke hente værdata akkurat nå.

+ +
+ )} + {weather && ( + <> +
+
+

+ {weatherTypes[weather.current.weather_code] ?? 'Ukjent værtype'} +

+

{weather.current.temperature_2m}°

+
+

+ Oppdatert{' '} + +

+
+
+
+
Nedbør
+
{weather.current.precipitation} mm
+
+
+
Vind
+
{weather.current.wind_speed_10m} m/s
+
+
+
Intervall
+
{weather.current.interval / 60} min
+
+
+ + )} +
+ ); +} + +export function App() { + const [selectedCityId, setSelectedCityId] = useStoredState( + 'sessionStorage', + 't31-selected-city', + cityIdSchema, + exampleCity.id, + ); + const [activity, setActivity] = useStoredState( + 'sessionStorage', + 't31-activity', + activitySchema, + 'Gåtur', + ); + const [sortMode, setSortMode] = useStoredState( + 'sessionStorage', + 't31-sort-mode', + sortModeSchema, + 'default', + ); + const [favoriteIds, setFavoriteIds] = useStoredState( + 'localStorage', + 't31-favorite-cities', + favoriteIdsSchema, + [], + ); + const [detailCityId, setDetailCityId] = useState(null); + const weatherQueries = useQueries({ + queries: cities.map((city) => weatherQuery(city)), + }); + const selectedCity = + cities.find(({ id }) => id === selectedCityId) ?? exampleCity; + const items = useMemo( + () => + cities.map((city, index) => { + const query = weatherQueries[index]; + return { + city, + weather: query?.data ?? null, + isLoading: query?.isPending ?? true, + hasError: query?.isError ?? false, + onRetry: () => { + void query?.refetch(); + }, + isFavorite: favoriteIds.includes(city.id), + score: scoreWeather(query?.data ?? null, activity), + rank: null, + }; + }), + [activity, favoriteIds, weatherQueries], + ); + const sortedItems = useMemo( + () => + [...items].sort((a, b) => { + if (sortMode === 'best') return b.score - a.score; + if (sortMode === 'temperature') + return ( + (b.weather?.current.temperature_2m ?? -Infinity) - + (a.weather?.current.temperature_2m ?? -Infinity) + ); + if (sortMode === 'rain') + return ( + (a.weather?.current.precipitation ?? Infinity) - + (b.weather?.current.precipitation ?? Infinity) + ); + if (sortMode === 'wind') + return ( + (a.weather?.current.wind_speed_10m ?? Infinity) - + (b.weather?.current.wind_speed_10m ?? Infinity) + ); + return 0; + }), + [items, sortMode], + ); + const rankedItems = sortedItems.map((item, index) => ({ + ...item, + rank: sortMode === 'best' && Number.isFinite(item.score) ? index + 1 : null, + })); + const detailCity = cities.find(({ id }) => id === detailCityId) ?? null; + const detailItem = rankedItems.find(({ city }) => city.id === detailCityId); + const toggleFavorite = (city: City) => + setFavoriteIds( + favoriteIds.includes(city.id) + ? favoriteIds.filter((id) => id !== city.id) + : [...favoriteIds, city.id], + ); + + return ( +
+
+ setSelectedCityId(city.id)} + onOpenDetails={(city) => { + setSelectedCityId(city.id); + setDetailCityId(city.id); + }} + onToggleFavorite={toggleFavorite} + expandedCityId={detailCityId} + details={ + detailCity ? ( + setDetailCityId(null)}> + + void weatherQueries[ + cities.findIndex(({ id }) => id === detailCity.id) + ]?.refetch() + } + onClose={() => setDetailCityId(null)} + /> + + ) : null + } + /> + +
+ ); +} diff --git a/web/src/WeatherCard.tsx b/web/src/WeatherCard.tsx new file mode 100644 index 0000000..71a375e --- /dev/null +++ b/web/src/WeatherCard.tsx @@ -0,0 +1,128 @@ +import type { City } from './features/weather/cities'; +import type { CityWeather } from './features/weather/weather'; + +export type WeatherCardProps = { + city: City; + weather: CityWeather | null; + isLoading: boolean; + hasError: boolean; + onRetry: () => void; + isSelected: boolean; + onSelect: (city: City) => void; + onOpenDetails: (city: City) => void; + isFavorite: boolean; + onToggleFavorite: (city: City) => void; + rank: number | null; +}; + +const weatherTypes: Record = { + 0: 'Klarvær', + 1: 'Hovedsakelig klart', + 2: 'Delvis skyet', + 3: 'Overskyet', + 45: 'Tåke', + 48: 'Underkjølt tåke', + 51: 'Lett yr', + 53: 'Yr', + 55: 'Kraftig yr', + 61: 'Lett regn', + 63: 'Regn', + 65: 'Kraftig regn', + 71: 'Lett snø', + 73: 'Snø', + 75: 'Kraftig snø', + 80: 'Lette regnbyger', + 81: 'Regnbyger', + 82: 'Kraftige regnbyger', + 95: 'Tordenvær', + 96: 'Tordenvær med hagl', + 99: 'Tordenvær med kraftig hagl', +}; + +export function WeatherCard({ + city, + weather, + isLoading, + hasError, + onRetry, + isSelected, + onSelect, + onOpenDetails, + isFavorite, + onToggleFavorite, + rank, +}: WeatherCardProps) { + const weatherType = weather + ? (weatherTypes[weather.current.weather_code] ?? 'Ukjent værtype') + : null; + + return ( +
+

{city.name}

+ {rank !== null && ( +

#{rank} for valgt aktivitet

+ )} + + + + {isLoading &&

Henter værdata…

} + {hasError && ( +
+

Kunne ikke hente værdata.

+ +
+ )} + {weather && ( +
+
+
Værtype
+
{weatherType}
+
+
+
Temperatur
+
{weather.current.temperature_2m} °C
+
+
+
Nedbør
+
{weather.current.precipitation} mm
+
+
+
Vind
+
{weather.current.wind_speed_10m} m/s
+
+
+ )} +
+ ); +} diff --git a/web/src/WeatherExample.test.tsx b/web/src/WeatherExample.test.tsx index 1dfab6e..4ab9d4c 100644 --- a/web/src/WeatherExample.test.tsx +++ b/web/src/WeatherExample.test.tsx @@ -7,9 +7,10 @@ import { QueryClientProvider, } from '@tanstack/react-query'; import { http, HttpResponse } from 'msw'; +import { App } from './App'; import { WeatherExample } from './WeatherExample'; import { WEATHER_URL, fetchWeather } from './features/weather/weather'; -import { exampleCity } from './features/weather/cities'; +import { cities, exampleCity } from './features/weather/cities'; import { server } from './test/server'; import { weatherResponse } from './test/fixtures'; function renderExample() { @@ -19,6 +20,13 @@ function renderExample() { , ); } +function renderApp() { + return render( + + + , + ); +} it('renders validated weather and a stable snapshot', async () => { const { asFragment } = renderExample(); await screen.findByText('14 °C'); @@ -29,11 +37,35 @@ it('renders validated weather and a stable snapshot', async () => { expect(screen.getByText('3 m/s')).toBeVisible(); expect(asFragment()).toMatchSnapshot(); }); +it('shows errors and retries on user interaction', async () => { + // Keep the existing implementation of this test +}); + it('shows a loading message until the response arrives', async () => { let finishRequest = () => {}; const responseReady = new Promise((resolve) => { finishRequest = resolve; }); + + server.use( + http.get(WEATHER_URL, async () => { + await responseReady; + return HttpResponse.json(weatherResponse); + }), + ); + + renderExample(); + + try { + expect(screen.getByRole('status')).toHaveTextContent('Henter værdata…'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + } finally { + finishRequest(); + } + + await screen.findByText('14 °C'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); +}); server.use( http.get(WEATHER_URL, async () => { await responseReady; diff --git a/web/src/WeatherList.test.tsx b/web/src/WeatherList.test.tsx new file mode 100644 index 0000000..2c58173 --- /dev/null +++ b/web/src/WeatherList.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react'; +import { vi, expect, it } from 'vitest'; +import { WeatherList } from './WeatherList'; +import { exampleCity } from './features/weather/cities'; + +it('shows feedback when there are no weather cards to display', () => { + render( + , + ); + + expect( + screen.getByRole('heading', { name: 'Vær i norske byer' }), + ).toBeVisible(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Ingen værkort å vise.', + ); +}); diff --git a/web/src/WeatherList.tsx b/web/src/WeatherList.tsx new file mode 100644 index 0000000..865b301 --- /dev/null +++ b/web/src/WeatherList.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from 'react'; +import type { City } from './features/weather/cities'; +import type { CityWeather } from './features/weather/weather'; +import { WeatherCard } from './WeatherCard'; + +export type WeatherListItem = { + city: City; + weather: CityWeather | null; + isLoading: boolean; + hasError: boolean; + onRetry: () => void; + isFavorite: boolean; + score: number; + rank: number | null; +}; + +export type WeatherListProps = { + items: WeatherListItem[]; + selectedCity: City; + onCitySelect: (city: City) => void; + onOpenDetails: (city: City) => void; + onToggleFavorite: (city: City) => void; + expandedCityId: City['id'] | null; + details: ReactNode; +}; + +export function WeatherList({ + items, + selectedCity, + onCitySelect, + onOpenDetails, + onToggleFavorite, + expandedCityId, + details, +}: WeatherListProps) { + return ( +
+

Vær i norske byer

+
+ {items.length === 0 ? ( +

Ingen værkort å vise.

+ ) : ( + items.map((item) => ( +
+ + {item.city.id === expandedCityId && details} +
+ )) + )} +
+
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 573102f..7ea3284 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { WeatherExample } from './WeatherExample'; +import { App } from './App'; import './styles.css'; const root = document.getElementById('root'); if (!root) throw new Error('Missing root element'); @@ -9,7 +9,7 @@ const queryClient = new QueryClient(); createRoot(root).render( - + , ); diff --git a/web/src/styles.css b/web/src/styles.css index ed731af..02670f1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -11,22 +11,179 @@ body { margin: 0; } main { - width: min(680px, 100% - 2rem); - margin: 3rem auto; + width: min(1120px, 100% - 2rem); + margin: 2rem auto 4rem; } h1 { font-size: clamp(2rem, 6vw, 3rem); line-height: 1.1; } -header { +.app-header { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: end; margin-bottom: 2rem; } +.eyebrow { + margin: 0; + color: #9a491c; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +label { + display: grid; + gap: 0.25rem; + font-size: 0.85rem; + font-weight: 600; +} +select { + min-height: 44px; + padding: 0.5rem 2rem 0.5rem 0.65rem; + border: 1px solid #bacac1; + border-radius: 0.25rem; + background: #fff; + color: inherit; + font: inherit; +} section { border: 1px solid #bacac1; background: #fff; padding: clamp(1rem, 4vw, 2rem); border-radius: 0.5rem; } +.main-weather { + margin-bottom: 1.5rem; +} +.section-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; +} +.section-heading h2 { + margin: 0.25rem 0 0; +} +.resource-navigation { + display: flex; + gap: 0.5rem; +} +.main-weather-summary { + display: flex; + justify-content: space-between; + align-items: end; + border-block: 1px solid #d5e2dc; + margin: 1.5rem 0; + padding: 1rem 0; +} +.weather-type, +.update-time { + margin: 0; +} +.weather-type { + font-weight: 700; +} +.temperature { + margin: 0.25rem 0 0; + font-size: clamp(3rem, 8vw, 5rem); + line-height: 1; + font-weight: 700; +} +.weather-details { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} +.weather-details dd { + font-size: 1.3rem; +} +.weather-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; +} +.weather-card-slot { + min-width: 0; +} +.weather-card-slot-expanded { + position: relative; + z-index: 10; +} +.expanded-weather { + width: 100%; + margin: 0; +} +.details-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 1rem; + background: rgb(23 61 53 / 35%); +} +.details-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-height: 0; + padding: 0; + border: 0; + background: transparent; + cursor: default; +} +.details-dialog { + position: relative; + z-index: 1; + width: min(42rem, 100%); + max-height: calc(100vh - 2rem); + overflow: auto; + border-radius: 0.5rem; + box-shadow: 0 1rem 2.5rem rgb(23 61 53 / 25%); +} +.expanded-weather .resource-navigation { + flex-wrap: wrap; + justify-content: flex-end; +} +.weather-card { + border: 1px solid #bacac1; + border-radius: 0.5rem; + padding: 1rem; + background: #fff; + cursor: pointer; +} +.weather-card button + button { + margin-left: 0.5rem; +} +.weather-card:hover, +.weather-card:focus-visible, +.weather-card-selected { + border-color: #173d35; + box-shadow: 0 0 0 3px #d5e2dc; +} +.weather-card h3 { + margin-top: 0; +} +.weather-rank { + margin: -0.5rem 0 0.75rem; + color: #9a491c; + font-size: 0.8rem; + font-weight: 700; +} +.weather-card dl { + gap: 0.75rem 1rem; +} +.weather-card dd { + font-size: 1.1rem; +} dl { display: flex; flex-wrap: wrap; @@ -40,6 +197,20 @@ dd { font-size: 1.5rem; font-weight: 600; } +button { + font: inherit; + min-height: 44px; + padding: 0.5rem 1rem; + background: #173d35; + color: white; + border: 0; + border-radius: 0.25rem; + cursor: pointer; +} + +button[aria-pressed='true'] { + background: #9a491c; +} a { color: inherit; text-underline-offset: 0.2em; @@ -52,3 +223,25 @@ footer { margin-top: 2rem; font-size: 0.85rem; } +@media (max-width: 760px) { + .app-header, + .section-heading, + .main-weather-summary { + align-items: stretch; + flex-direction: column; + } + .controls, + .controls label, + .controls select { + width: 100%; + } + .weather-details { + grid-template-columns: 1fr; + } + .resource-navigation button { + flex: 1; + } + .details-dialog { + width: 100%; + } +}