/**
 * WeatherPanel — full-width dashboard card showing local weather, framed around
 * the thing that actually matters to this app: damp.
 *
 * Information architecture is borrowed from the TRMNL weather widget (one big
 * temperature, a condition glyph, a compact strip of secondary stats, then a
 * short forecast row). The rendering is not: TRMNL is a 1-bit e-ink dot-matrix
 * display, and that treatment would read as "broken CSS" inside Mould Detect's
 * warm-green surface. So: same skeleton, Mould Detect's skin.
 *
 * Deliberate departures from a generic weather widget:
 *   - Humidity is promoted to a first-class value with its own tile and a scale
 *     marked at 60% RH, because sustained damp air is what lets mould establish.
 *   - A plain-English "Mould watch" line reads the humidity band, and escalates
 *     only when the forecast shows the damp is sustained. It never makes a
 *     health claim and it never shouts.
 *   - Stale data is visibly labelled ("Updated 9h ago"), never passed off as now.
 *
 * Data:  GET /api/weather?lat=&lon=   (same-origin)
 *        Every field is optional. All shape knowledge lives in wxPanelMap()
 *        below — that is the ONE function to edit when the backend settles.
 *
 * Location: window.GeoLocationService. A denied permission is an ordinary
 *        outcome, not an error — we fall back to WX_FALLBACK_COORDS and label the
 *        reading approximate. /api/weather requires coordinates (a bare call is a
 *        422), so there is no "let the server decide" option.
 *
 * Route:  navigates to /weather (override with the `route` prop).
 *
 * Written in ES5 for Babel 6 standalone — no arrow functions, no template
 * literals, no const/let, no spread. A syntax error here renders a blank panel
 * with nothing in the console, so keep it boring.
 */

var _useState_WX  = React.useState;
var _useEffect_WX = React.useEffect;

/* ────────────────────────────────────────────────────────────────────────────
   FIELD MAPPING — edit here and nowhere else.

   Each wxPick() list is "the names we will accept for this value", most
   preferred first. Add the backend's real key to the front of the relevant
   list and the whole panel follows. Anything missing simply doesn't render.
   ──────────────────────────────────────────────────────────────────────────── */

// Last successful reading, kept across mounts. Navigating away from the dashboard and
// back used to re-run the whole load — skeleton, geolocation, request — which is both a
// visible flicker and a pointless round trip for data that changes hourly at best.
var _wxCache = { at: 0, data: null, place: '' };
var WX_CACHE_TTL_MS = 10 * 60 * 1000;

// Where to look when the user declines the location prompt. /api/weather requires
// coordinates (a bare call is a 422), so there has to be a default; the UI marks it
// approximate rather than presenting it as the user's own location. Single source of
// truth lives in AppConstants (loads earlier in index.html's script order, so this is
// safe at module level).
var WX_FALLBACK_COORDS = AppConstants.FALLBACK_COORDS;

/** First present, non-empty value among `keys` on `obj`. */
var wxPick = function (obj, keys) {
    if (!obj || typeof obj !== 'object') return undefined;
    for (var i = 0; i < keys.length; i++) {
        var v = obj[keys[i]];
        if (v !== undefined && v !== null && v !== '') return v;
    }
    return undefined;
};

/** Coerce to a finite number, else null. Strings like "72%" are tolerated. */
var wxNum = function (v) {
    if (typeof v === 'number') return isFinite(v) ? v : null;
    if (typeof v === 'string') {
        var n = parseFloat(v.replace('%', ''));
        return isFinite(n) ? n : null;
    }
    return null;
};

/** Coerce to a trimmed string, else ''. */
var wxStr = function (v) {
    if (typeof v === 'string') return v.trim();
    if (typeof v === 'number') return String(v);
    return '';
};

/**
 * Normalise a condition into one of our glyph keys:
 * 'sun' | 'partly' | 'cloud' | 'rain' | 'storm' | 'snow' | 'fog' | 'unknown'
 * Accepts free text, WMO codes (0-99), OpenWeather codes (2xx-8xx) and
 * OpenWeather icon slugs ('01d', '10n', ...).
 */
var wxConditionKey = function (text, code) {
    // Numeric codes are decoded by the shared table in WeatherData, so the panel and the
    // page can never disagree about what code 61 means. Free-text matching below is
    // panel-specific and stays here.
    if (typeof WeatherData !== 'undefined' && (code || code === 0)) {
        var shared = WeatherData.condition(code);
        if (shared && shared !== 'unknown') return shared;
    }
    var t = wxStr(text).toLowerCase();

    // Icon slugs first — cheapest unambiguous signal.
    var slug = wxStr(code).toLowerCase();
    if (/^0[1-9][dn]?$/.test(slug)) {
        var s = slug.substring(0, 2);
        if (s === '01') return 'sun';
        if (s === '02' || s === '03') return 'partly';
        if (s === '04') return 'cloud';
        if (s === '09' || s === '10') return 'rain';
        return 'unknown';
    }
    if (slug === '11d' || slug === '11n') return 'storm';
    if (slug === '13d' || slug === '13n') return 'snow';
    if (slug === '50d' || slug === '50n') return 'fog';

    var c = wxNum(code);
    if (c !== null) {
        if (c <= 99) {
            // WMO weather codes (Open-Meteo, BOM-derived feeds)
            if (c === 0) return 'sun';
            if (c === 1 || c === 2) return 'partly';
            if (c === 3) return 'cloud';
            if (c === 45 || c === 48) return 'fog';
            if (c >= 51 && c <= 67) return 'rain';
            if (c >= 71 && c <= 77) return 'snow';
            if (c >= 80 && c <= 82) return 'rain';
            if (c === 85 || c === 86) return 'snow';
            if (c >= 95) return 'storm';
        } else {
            // OpenWeather condition ids
            if (c >= 200 && c < 300) return 'storm';
            if (c >= 300 && c < 600) return 'rain';
            if (c >= 600 && c < 700) return 'snow';
            if (c >= 700 && c < 800) return 'fog';
            if (c === 800) return 'sun';
            if (c === 801 || c === 802) return 'partly';
            if (c > 802 && c < 900) return 'cloud';
        }
    }

    if (!t) return 'unknown';
    if (/thunder|storm|lightning/.test(t)) return 'storm';
    if (/snow|sleet|hail|blizzard/.test(t)) return 'snow';
    if (/rain|shower|drizzle|wet/.test(t)) return 'rain';
    if (/fog|mist|haze|smoke/.test(t)) return 'fog';
    if (/partly|part cloud|mostly sunny|few cloud|scattered/.test(t)) return 'partly';
    if (/cloud|overcast|dull/.test(t)) return 'cloud';
    if (/clear|sun|fine/.test(t)) return 'sun';
    return 'unknown';
};

/** 'YYYY-MM-DD' / epoch seconds / epoch ms / ISO → short weekday, or ''. */
var WX_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var wxDayLabel = function (value, index) {
    var d = null;

    if (typeof value === 'number' && isFinite(value)) {
        // Seconds vs milliseconds: anything under ~1e11 is seconds.
        d = new Date(value < 100000000000 ? value * 1000 : value);
    } else if (typeof value === 'string' && value) {
        var ymd = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
        if (ymd) {
            // Built locally on purpose — new Date('2026-07-28') is UTC midnight
            // and can land on the previous weekday in some zones.
            d = new Date(parseInt(ymd[1], 10), parseInt(ymd[2], 10) - 1, parseInt(ymd[3], 10));
        } else if (/^[A-Za-z]{3}/.test(value)) {
            return value.substring(0, 3); // already a weekday name
        } else {
            var parsed = new Date(value);
            if (!isNaN(parsed.getTime())) d = parsed;
        }
    }

    if (!d || isNaN(d.getTime())) return index === 0 ? 'Today' : '';

    var today = new Date();
    if (d.getDate() === today.getDate() && d.getMonth() === today.getMonth() &&
        d.getFullYear() === today.getFullYear()) return 'Today';

    return WX_DAYS[d.getDay()];
};

/**
 * wxPanelMap — the entire contract with /api/weather lives in this function.
 *
 * Returns a normalised object; `ok` is false when there was nothing usable at
 * all (in which case the panel shows its friendly empty state rather than a
 * card full of dashes).
 */
// NAMESPACED. Both weather components originally called this `mapPayload`, and in an app
// with no module system every top-level `var` is a property of `window` — so whichever
// script loaded second silently replaced the other's mapper. The panel then ran the page's
// function, received a shape it did not understand, and rendered an empty card with
// nothing in the console. Keep weather globals `wx`-prefixed.
var wxPanelMap = function (raw) {
    var out = {
        ok: false,
        place: '',
        temp: null,
        feels: null,
        humidity: null,
        conditionText: '',
        conditionKey: 'unknown',
        precipChance: null,
        unit: '°C',
        ageHours: null,
        stale: false,
        days: []
    };
    if (!raw || typeof raw !== 'object') return out;

    // Some services wrap everything in { data: ... } or { result: ... }.
    var root = wxPick(raw, ['data', 'result', 'weather_data']);
    if (root && typeof root === 'object' && !(root instanceof Array)) raw = root;

    // ── current conditions ───────────────────────────────────────────────────
    var cur = wxPick(raw, ['current', 'currently', 'now', 'observation', 'current_weather', 'observations']);
    if (!cur || typeof cur !== 'object') cur = raw;

    out.temp     = wxNum(wxPick(cur, ['temp', 'temperature', 'temp_c', 'temperature_c', 'air_temperature', 'temperature_2m']));
    out.feels    = wxNum(wxPick(cur, ['feels_like', 'feelsLike', 'feels_like_c', 'apparent_temperature', 'apparent_temp', 'apparent_t', 'realfeel']));
    out.humidity = wxNum(wxPick(cur, ['humidity', 'relative_humidity', 'relative_humidity_2m', 'humidity_pct', 'rel_hum', 'rh']));
    out.precipChance = wxNum(wxPick(cur, ['precipitation_probability', 'rain_chance', 'chance_of_rain', 'pop', 'precip_probability']));

    var condRaw = wxPick(cur, ['condition', 'conditions', 'summary', 'description', 'weather_text', 'text', 'short_text', 'state']);
    if (condRaw && typeof condRaw === 'object') {
        // e.g. { text: 'Light rain', code: 61 }
        out.conditionText = wxStr(wxPick(condRaw, ['text', 'description', 'summary', 'label', 'main']));
        out.conditionKey  = wxConditionKey(out.conditionText, wxPick(condRaw, ['code', 'icon', 'id']));
    } else {
        out.conditionText = wxStr(condRaw);
        // OpenWeather-style arrays: weather: [{ description, id, icon }]
        var arr = wxPick(cur, ['weather']);
        if (!out.conditionText && arr instanceof Array && arr.length && arr[0]) {
            out.conditionText = wxStr(wxPick(arr[0], ['description', 'main']));
            out.conditionKey  = wxConditionKey(out.conditionText, wxPick(arr[0], ['id', 'icon']));
        } else {
            out.conditionKey = wxConditionKey(
                out.conditionText,
                wxPick(cur, ['condition_code', 'weather_code', 'weathercode', 'code', 'icon'])
            );
        }
    }

    // ── units ────────────────────────────────────────────────────────────────
    var unit = wxStr(wxPick(raw, ['units', 'unit', 'temperature_unit'])).toLowerCase();
    if (unit.indexOf('f') === 0 || unit.indexOf('fahren') > -1 || unit === 'imperial') out.unit = '°F';

    // ── place label (server may know better than reverse geocoding) ──────────
    var loc = wxPick(raw, ['location', 'place', 'station']);
    if (loc && typeof loc === 'object') {
        out.place = wxStr(wxPick(loc, ['label', 'name', 'display_name', 'suburb', 'city', 'locality']));
    } else {
        out.place = wxStr(wxPick(raw, ['location_name', 'place_name', 'city', 'suburb', 'name']));
    }

    // ── freshness ────────────────────────────────────────────────────────────
    out.ageHours = wxNum(wxPick(raw.freshness, ['age_hours', 'ageHours']));
    if (out.ageHours === null) out.ageHours = wxNum(wxPick(raw, ['age_hours', 'ageHours', 'data_age_hours', 'age_h']));
    if (out.ageHours === null) {
        var ts = wxPick(raw, ['updated_at', 'observed_at', 'observation_time', 'as_of', 'timestamp', 'time', 'retrieved_at']);
        var when = null;
        if (typeof ts === 'number' && isFinite(ts)) when = ts < 100000000000 ? ts * 1000 : ts;
        else if (typeof ts === 'string' && ts) {
            var parsedTs = new Date(ts).getTime();
            if (!isNaN(parsedTs)) when = parsedTs;
        }
        if (when !== null) out.ageHours = (Date.now() - when) / 3600000;
    }
    if (out.ageHours !== null && out.ageHours < 0) out.ageHours = 0;

    var staleFlag = wxPick(raw.freshness, ['stale', 'is_stale']);
    if (staleFlag === undefined) staleFlag = wxPick(raw, ['stale', 'is_stale']);
    out.stale = staleFlag === true || staleFlag === 'true' ||
                (out.ageHours !== null && out.ageHours >= 3);

    // ── daily forecast ───────────────────────────────────────────────────────
    var daily = wxPick(raw, ['daily', 'forecast', 'days', 'daily_forecast', 'forecasts']);
    if (daily && !(daily instanceof Array) && typeof daily === 'object') {
        daily = wxPick(daily, ['daily', 'days', 'data', 'entries', 'forecastday']);
    }
    if (daily instanceof Array) {
        for (var i = 0; i < daily.length && out.days.length < 5; i++) {
            var d = daily[i];
            if (!d || typeof d !== 'object') continue;

            // wrappers like { date, day: { maxtemp_c, ... } }
            var inner = wxPick(d, ['day', 'values']);
            var src = (inner && typeof inner === 'object') ? inner : d;

            var high = wxNum(wxPick(src, ['temp_max_c', 'high', 'temp_max', 'max_temp', 'temperature_max', 'temp_high', 'maxtemp_c', 'max', 'temperature_2m_max']));
            var low  = wxNum(wxPick(src, ['temp_min_c', 'low', 'temp_min', 'min_temp', 'temperature_min', 'temp_low', 'mintemp_c', 'min', 'temperature_2m_min']));

            // { temp: { max, min } }
            var tempObj = wxPick(src, ['temp', 'temperature']);
            if ((high === null || low === null) && tempObj && typeof tempObj === 'object') {
                if (high === null) high = wxNum(wxPick(tempObj, ['max', 'high']));
                if (low  === null) low  = wxNum(wxPick(tempObj, ['min', 'low']));
            }

            var dCondRaw = wxPick(src, ['condition', 'summary', 'description', 'text', 'short_text', 'conditions']);
            var dText = '';
            var dKey  = 'unknown';
            if (dCondRaw && typeof dCondRaw === 'object') {
                dText = wxStr(wxPick(dCondRaw, ['text', 'description', 'summary', 'main']));
                dKey  = wxConditionKey(dText, wxPick(dCondRaw, ['code', 'icon', 'id']));
            } else {
                dText = wxStr(dCondRaw);
                var dArr = wxPick(src, ['weather']);
                if (!dText && dArr instanceof Array && dArr.length && dArr[0]) {
                    dText = wxStr(wxPick(dArr[0], ['description', 'main']));
                    dKey  = wxConditionKey(dText, wxPick(dArr[0], ['id', 'icon']));
                } else {
                    dKey = wxConditionKey(dText, wxPick(src, ['condition_code', 'weather_code', 'weathercode', 'code', 'icon']));
                }
            }

            out.days.push({
                label: wxDayLabel(wxPick(d, ['date', 'day', 'dt', 'time', 'valid_date', 'datetime']), i),
                high: high,
                low: low,
                key: dKey,
                text: dText,
                humidity: wxNum(wxPick(src, ['humidity_mean_pct', 'humidity_max_pct', 'humidity', 'relative_humidity', 'avghumidity', 'humidity_avg', 'rh'])),
                precip: wxNum(wxPick(src, ['precipitation_probability', 'rain_chance', 'chance_of_rain', 'pop', 'daily_chance_of_rain', 'precip_probability']))
            });
        }
    }

    // Humidity trend for the mini chart. The observed archive is a SEPARATE top-level
    // array the panel's own mapper never read — it only ever saw the forecast — so the
    // series comes from the shared layer, which already joins the two feeds into one
    // continuous run of days. Falls back to the forecast alone if that is unavailable.
    out.series = [];
    if (typeof WeatherData !== 'undefined' && WeatherData.normalise) {
        var shared = WeatherData.normalise(raw);
        if (shared && shared.series) {
            for (var si = 0; si < shared.series.length; si++) {
                var pt = shared.series[si];
                var val = pt.humidityMean !== null ? pt.humidityMean : pt.humidityMax;
                if (val !== null && val !== undefined) {
                    out.series.push({ value: val, future: !!pt.future, date: pt.date });
                }
            }
        }
    }

    out.ok = (out.temp !== null || out.humidity !== null || out.days.length > 0);
    return out;
};

/* ────────────────────────────────────────────────────────────────────────────
   Mould read
   ──────────────────────────────────────────────────────────────────────────── */

/**
 * Plain-English humidity read. Deliberately conservative: this is outdoor air,
 * and outdoor air is a hint about indoor damp, not a verdict on it. No health
 * claims, no "danger" language.
 */
var wxMouldRead = function (data) {
    var rh = data.humidity;
    if (rh === null) return null;

    // Sustained damp — the thing that actually grows mould — from the forecast.
    var dampDays = 0;
    for (var i = 0; i < data.days.length; i++) {
        var d = data.days[i];
        if ((d.humidity !== null && d.humidity >= 70) || (d.precip !== null && d.precip >= 60)) dampDays++;
    }
    var sustained = dampDays >= 2;

    var band, headline, body, icon;
    if (rh >= 80) {
        band = 'high';
        headline = 'Damp air';
        body = 'Air this wet keeps surfaces from drying out, which is the condition mould needs. Ventilate when you can and wipe down condensation.';
        icon = 'humidity_high';
    } else if (rh >= 70) {
        band = 'raised';
        headline = 'Humid';
        body = 'Humidity is high enough that damp corners will be slow to dry. Air rooms out and keep furniture off cold walls.';
        icon = 'humidity_percentage';
    } else if (rh >= 60) {
        band = 'watch';
        headline = 'Mildly humid';
        body = 'Nothing unusual, but bathrooms and wardrobes are worth a look after a run of days like this.';
        icon = 'humidity_percentage';
    } else {
        band = 'low';
        headline = 'Drying conditions';
        body = 'Dry air outside — a good day to open up and air the house through.';
        icon = 'air';
    }

    if (sustained && (band === 'high' || band === 'raised')) {
        body += ' The forecast keeps it damp for ' + dampDays + ' of the next ' + data.days.length + ' days.';
    }

    return { band: band, headline: headline, body: body, icon: icon, dampDays: dampDays };
};

var WX_BAND_STYLES = {
    high:   'bg-warning-light border-warning/40',
    raised: 'bg-warning-light/70 border-warning/30',
    watch:  'bg-accent/30 border-accent',
    low:    'bg-accent/25 border-accent/70'
};
var WX_BAND_TEXT = {
    high:   'text-warning',
    raised: 'text-warning',
    watch:  'text-forest',
    low:    'text-forest'
};
/** Humidity bar fill colour — terracotta once we're into mould-friendly air. */
/**
 * WxHumiditySpark — the weather page's humidity trend, reduced to what survives at
 * roughly 110x38 pixels.
 *
 * Kept: the shape of the trend, the 70% damp line, and an emphasised endpoint for "now".
 * Dropped: axes, gridlines, day labels and the value scale — at this size they become
 * texture rather than information, and the page is one tap away for anyone who wants them.
 *
 * The domain is FIXED at 20-100% rather than fitted to the data. An auto-fitted sparkline
 * rescales every time the weather changes, so a flat week and a volatile one look
 * identical and the damp line wanders — which would make the one reference mark that
 * matters meaningless.
 */
var WX_SPARK_LO = 20;
var WX_SPARK_HI = 100;
var WX_DAMP_RH = 70;

var WxHumiditySpark = function (props) {
    var pts = props.points || [];
    if (pts.length < 2) return null;

    var w = 112, h = 34, padT = 4, padB = 2;
    // padR leaves room for the endpoint marker; without it the circle is bisected by the
    // right edge and reads as a rendering fault rather than as "now".
    var padL = 1, padR = 4;
    var plotW = w - padL - padR;
    var inner = h - padT - padB;
    var xAt = function (i) { return padL + (i / (pts.length - 1)) * plotW; };
    var yAt = function (v) {
        var c = Math.max(WX_SPARK_LO, Math.min(WX_SPARK_HI, v));
        return padT + inner - ((c - WX_SPARK_LO) / (WX_SPARK_HI - WX_SPARK_LO)) * inner;
    };

    var line = '', area = '';
    for (var i = 0; i < pts.length; i++) {
        var x = xAt(i).toFixed(1), y = yAt(pts[i].value).toFixed(1);
        line += (i === 0 ? 'M' : 'L') + x + ' ' + y + ' ';
    }
    area = line + 'L' + (padL + plotW) + ' ' + h + ' L' + padL + ' ' + h + ' Z';

    var lastX = xAt(pts.length - 1), lastY = yAt(pts[pts.length - 1].value);
    var dampY = yAt(WX_DAMP_RH);
    var peak = pts[0].value, low = pts[0].value;
    for (var k = 1; k < pts.length; k++) {
        if (pts[k].value > peak) peak = pts[k].value;
        if (pts[k].value < low) low = pts[k].value;
    }

    return (
        <svg
            viewBox={'0 0 ' + w + ' ' + h}
            width="100%"
            height={h}
            preserveAspectRatio="none"
            role="img"
            aria-label={'Humidity trend across ' + pts.length + ' days, ranging from ' +
                Math.round(low) + ' to ' + Math.round(peak) + ' percent, currently ' +
                Math.round(pts[pts.length - 1].value) + ' percent. The damp threshold is 70 percent.'}
            style={{ display: 'block' }}
        >
            <path d={area} fill="rgba(15,189,128,0.16)" />
            <line x1={padL} y1={dampY} x2={padL + plotW} y2={dampY}
                  stroke="#D4836B" strokeWidth="1" strokeDasharray="3 2.5" opacity="0.75" />
            <path d={line} fill="none" stroke="#0fbd80" strokeWidth="1.8"
                  strokeLinecap="round" strokeLinejoin="round" />
            <circle cx={lastX} cy={lastY} r="2.6" fill="#0fbd80" stroke="#F7F6F3" strokeWidth="1.4" />
        </svg>
    );
};

var wxHumidityFill = function (rh) {
    if (rh >= 80) return '#D4836B';
    if (rh >= 70) return '#D4836B';
    if (rh >= 60) return '#86a697';
    return '#0fbd80';
};

/** null-safe "9h ago" */
var wxFormatAge = function (hours) {
    if (hours === null || hours === undefined) return '';
    if (hours < 0.03) return 'just now';
    if (hours < 1) return Math.max(1, Math.round(hours * 60)) + 'm ago';
    if (hours < 24) return Math.round(hours) + 'h ago';
    return Math.round(hours / 24) + 'd ago';
};

var wxTemp = function (v) {
    return v === null || v === undefined ? '—' : String(Math.round(v));
};

/* ────────────────────────────────────────────────────────────────────────────
   Icons — authored inline so they can be animated and tinted on-palette.
   Colours are restricted to the Mould Detect theme: terracotta sun, sage
   cloud, primary rain. Mid-tone on purpose so they stay legible against both
   the cream card and the forest-green hover state.
   ──────────────────────────────────────────────────────────────────────────── */

var WX_STYLES = [
    '@keyframes wxpSpin  { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }',
    '@keyframes wxpDrift { 0%,100% { transform: translateX(-1.5px); } 50% { transform: translateX(1.5px); } }',
    '@keyframes wxpFall  { 0% { transform: translateY(-5px); opacity: 0; } 30% { opacity: 1; } 100% { transform: translateY(9px); opacity: 0; } }',
    '@keyframes wxpFlash { 0%,86%,100% { opacity: 1; } 90% { opacity: .25; } 94% { opacity: 1; } }',
    '@keyframes wxpFog   { 0%,100% { transform: translateX(-2.5px); } 50% { transform: translateX(2.5px); } }',
    '.wxp-spin    { animation: wxpSpin 32s linear infinite; transform-box: fill-box; transform-origin: 50% 50%; }',
    '.wxp-drift   { animation: wxpDrift 7s ease-in-out infinite; transform-box: fill-box; transform-origin: 50% 50%; }',
    '.wxp-drop    { animation: wxpFall 1.6s linear infinite; transform-box: fill-box; }',
    '.wxp-drop-2  { animation-delay: .55s; }',
    '.wxp-drop-3  { animation-delay: 1.1s; }',
    '.wxp-flash   { animation: wxpFlash 4.5s ease-in-out infinite; transform-box: fill-box; }',
    '.wxp-fogline { animation: wxpFog 6s ease-in-out infinite; transform-box: fill-box; }',
    '.wxp-fogline-2 { animation-delay: -2s; }',
    '.wxp-fogline-3 { animation-delay: -4s; }',
    '@media (prefers-reduced-motion: reduce) {',
    '  .wxp-spin, .wxp-drift, .wxp-drop, .wxp-flash, .wxp-fogline { animation: none !important; }',
    '}'
].join('\n');

var WX_SAGE      = '#86a697';
var WX_SAGE_DEEP = '#6f8d7f';
var WX_SUN       = '#D4836B';
var WX_RAIN      = '#0fbd80';

/** Cloud body, reused by every cloudy variant. */
var wxCloudShape = function (fill, drift) {
    return (
        <g className={drift ? 'wxp-drift' : ''}>
            <circle cx="25" cy="35" r="10" fill={fill} />
            <circle cx="39" cy="31" r="13" fill={fill} />
            <rect x="17" y="35" width="32" height="13" rx="6.5" fill={fill} />
            <circle cx="39" cy="27" r="8" fill="#ffffff" opacity="0.18" />
        </g>
    );
};

/** Eight-point sun. `spin` drives the ray rotation. */
var wxSunShape = function (cx, cy, r, spin) {
    var rays = [];
    for (var i = 0; i < 8; i++) {
        var a = (Math.PI / 4) * i;
        rays.push(
            <line
                key={'ray' + i}
                x1={Number((cx + Math.cos(a) * (r + 4)).toFixed(2))}
                y1={Number((cy + Math.sin(a) * (r + 4)).toFixed(2))}
                x2={Number((cx + Math.cos(a) * (r + 9)).toFixed(2))}
                y2={Number((cy + Math.sin(a) * (r + 9)).toFixed(2))}
                stroke={WX_SUN}
                strokeWidth="3.5"
                strokeLinecap="round"
            />
        );
    }
    return (
        <g>
            <g className={spin ? 'wxp-spin' : ''}>{rays}</g>
            <circle cx={cx} cy={cy} r={r} fill={WX_SUN} />
            <circle cx={cx} cy={cy} r={r} fill="none" stroke="#FDECE8" strokeOpacity="0.55" strokeWidth="2" />
        </g>
    );
};

var WX_LABELS = {
    sun: 'Clear',
    partly: 'Partly cloudy',
    cloud: 'Cloudy',
    rain: 'Rain',
    storm: 'Thunderstorms',
    snow: 'Snow',
    fog: 'Fog',
    unknown: 'Weather'
};

/**
 * WeatherIcon — props: { kind, label, className }
 * role="img" + aria-label so the glyph is not silent to screen readers.
 */
var WeatherIcon = function (props) {
    var kind = props.kind || 'unknown';
    var label = props.label || WX_LABELS[kind] || WX_LABELS.unknown;
    var body;

    if (kind === 'sun') {
        body = wxSunShape(32, 32, 12, true);
    } else if (kind === 'partly') {
        body = (
            <g>
                {wxSunShape(24, 22, 8, true)}
                {wxCloudShape(WX_SAGE, true)}
            </g>
        );
    } else if (kind === 'rain') {
        body = (
            <g>
                {wxCloudShape(WX_SAGE, true)}
                <line className="wxp-drop" x1="24" y1="52" x2="22" y2="58" stroke={WX_RAIN} strokeWidth="3" strokeLinecap="round" />
                <line className="wxp-drop wxp-drop-2" x1="33" y1="52" x2="31" y2="58" stroke={WX_RAIN} strokeWidth="3" strokeLinecap="round" />
                <line className="wxp-drop wxp-drop-3" x1="42" y1="52" x2="40" y2="58" stroke={WX_RAIN} strokeWidth="3" strokeLinecap="round" />
            </g>
        );
    } else if (kind === 'storm') {
        body = (
            <g>
                {wxCloudShape(WX_SAGE_DEEP, true)}
                <path className="wxp-flash" d="M35 46 L25 57 H31 L28 64 L39 52 H33 L36 46 Z" fill={WX_SUN} />
                <line className="wxp-drop wxp-drop-2" x1="22" y1="52" x2="20" y2="57" stroke={WX_RAIN} strokeWidth="3" strokeLinecap="round" />
            </g>
        );
    } else if (kind === 'snow') {
        body = (
            <g>
                {wxCloudShape(WX_SAGE, true)}
                <circle className="wxp-drop" cx="24" cy="54" r="2.6" fill={WX_RAIN} />
                <circle className="wxp-drop wxp-drop-2" cx="33" cy="54" r="2.6" fill={WX_RAIN} />
                <circle className="wxp-drop wxp-drop-3" cx="42" cy="54" r="2.6" fill={WX_RAIN} />
            </g>
        );
    } else if (kind === 'fog') {
        body = (
            <g>
                {wxCloudShape(WX_SAGE, false)}
                <line className="wxp-fogline" x1="14" y1="53" x2="46" y2="53" stroke={WX_SAGE} strokeWidth="3.5" strokeLinecap="round" />
                <line className="wxp-fogline wxp-fogline-2" x1="19" y1="59" x2="51" y2="59" stroke={WX_SAGE} strokeWidth="3.5" strokeLinecap="round" strokeOpacity="0.75" />
                <line className="wxp-fogline wxp-fogline-3" x1="12" y1="47" x2="34" y2="47" stroke={WX_SAGE} strokeWidth="3.5" strokeLinecap="round" strokeOpacity="0.5" />
            </g>
        );
    } else if (kind === 'cloud') {
        body = wxCloudShape(WX_SAGE, true);
    } else {
        body = (
            <g>
                {wxCloudShape(WX_SAGE, true)}
                <circle cx="32" cy="56" r="2.5" fill={WX_SAGE} opacity="0.5" />
            </g>
        );
    }

    return (
        <svg
            className={props.className || 'w-14 h-14'}
            viewBox="0 0 64 64"
            width={props.size || 56}
            height={props.size || 56}
            role="img"
            aria-label={label}
            focusable="false"
        >
            {body}
        </svg>
    );
};

/* ────────────────────────────────────────────────────────────────────────────
   Panel
   ──────────────────────────────────────────────────────────────────────────── */

var WeatherPanel = function (props) {
    var opts = props || {};
    var route = opts.route || '/weather';
    var navigate = ReactRouterDOM.useNavigate();

    // 'loading' | 'ready' | 'empty'
    var statusState = _useState_WX('loading');
    var status = statusState[0];
    var setStatus = statusState[1];

    var dataState = _useState_WX(null);
    var data = dataState[0];
    var setData = dataState[1];

    var placeState = _useState_WX('');
    var place = placeState[0];
    var setPlace = placeState[1];

    // A refused permission is normal. It only changes the copy, never the tone.
    var deniedReportedRef = React.useRef(false);
    var deniedState = _useState_WX(false);
    var denied = deniedState[0];
    var setDenied = deniedState[1];

    var attemptState = _useState_WX(0);
    var attempt = attemptState[0];
    var setAttempt = attemptState[1];

    _useEffect_WX(function () {
        var cancelled = false;
        var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;

        // TIMERS ARE TRACKED, NOT SCATTERED. Every setTimeout here is registered so the
        // cleanup below can clear all of them in one place — an abandoned timer that fires
        // after unmount holds a closure over this component's state setters, which is both
        // a leak and a "set state on an unmounted component" warning waiting to happen.
        var timers = [];
        var later = function (fn, ms) { var id = setTimeout(fn, ms); timers.push(id); return id; };
        var clearTimers = function () {
            for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]);
            timers.length = 0;
        };

        // Serve a recent reading immediately. A manual retry (attempt > 0) always goes to
        // the network — that is what the button is for.
        var fresh = _wxCache.data && (Date.now() - _wxCache.at) < WX_CACHE_TTL_MS;
        if (fresh && attempt === 0) {
            setData(_wxCache.data);
            if (_wxCache.place) setPlace(_wxCache.place);
            setStatus('ready');
            return function () { cancelled = true; };
        }

        setStatus('loading');

        // Cross-session stale-while-revalidate. The in-memory _wxCache above only survives
        // within one page session; WeatherCache is IndexedDB-backed and survives a reload,
        // an app restart, or navigating away and back after the session cache's own TTL has
        // lapsed. A valid cached record (see WeatherCache.MAX_AGE_MS — up to 24h, honest
        // because the footer always shows "Updated Xh ago") is run through THIS component's
        // own wxPanelMap so the two weather surfaces never share a mapped shape — only the
        // raw payload is cached. Paints immediately, then falls through to the network fetch
        // below regardless, which will seamlessly replace it when fresh data lands. A manual
        // retry (attempt > 0) skips the cached paint — the button means "go to the network".
        // Tracks whether the cached paint above landed, so a background refresh failure
        // below can leave that reading on screen instead of regressing to the empty state.
        var cachePainted = false;
        // Guards the (rare) reverse race: an IndexedDB read usually resolves in
        // single-digit milliseconds, but a cold database open can take ~100ms — long
        // enough for a fast network response to land first. A late cache read must
        // never paint stale data over a fresh reading that is already on screen.
        var networkLanded = false;
        if (attempt === 0 && typeof WeatherCache !== 'undefined' && WeatherCache.load) {
            WeatherCache.load().then(function (rec) {
                if (cancelled || !rec || networkLanded) return;
                var cachedMapped;
                try { cachedMapped = wxPanelMap(rec.raw); } catch (e) { cachedMapped = { ok: false }; }
                if (cachedMapped.ok) {
                    cachePainted = true;
                    _wxCache = { at: Date.now(), data: cachedMapped, place: cachedMapped.place || rec.place || '' };
                    setData(cachedMapped);
                    if (cachedMapped.place || rec.place) setPlace(cachedMapped.place || rec.place);
                    setStatus('ready');
                }
            })['catch'](function () { /* a broken cache read must never block the network path below */ });
        }

        // Coordinates used for the network request, captured here so the success handler
        // below can pass them to WeatherCache.save() without re-deriving them.
        var lastCoords = null;

        // THE FIRST-LOAD BUG THIS FIXES. The abort timer used to start HERE, before
        // geolocation had even been asked for — and the browser's permission prompt does
        // not run down the Geolocation API's own `timeout`, which only begins once
        // permission is granted. So on a first visit the prompt sat open, twelve seconds
        // elapsed, the controller aborted, and the fetch that had not yet been made failed
        // instantly. The panel showed "Conditions aren't available right now" while the
        // service was perfectly healthy — and it only ever happened on the first load,
        // because afterwards the permission is remembered and resolves immediately.
        //
        // Location now gets its own short budget and falls back rather than blocking, and
        // the request timeout starts when the REQUEST does.
        var LOCATION_BUDGET_MS = 6000;
        // 12s was shorter than a real cold path. Our own Lambda answers a warm weather
        // request in about 3ms, but /api/weather composes FIVE reads from the sibling
        // platform API — and when that is cold the whole chain has been measured at
        // 15.4s. A budget below the worst real case turns a slow first visit into a
        // failed one, which is what the card was reporting.
        var REQUEST_TIMEOUT_MS = 22000;

        // Never rejects — no location is a fallback, not a failure.
        var resolveCoords = function () {
            return new Promise(function (resolve) {
                if (!window.GeoLocationService || typeof GeoLocationService.getCurrentPosition !== 'function') {
                    resolve(null);
                    return;
                }
                var settled = false;
                var finish = function (value) {
                    if (settled) return;
                    settled = true;
                    resolve(value);
                };
                // Do not wait on a prompt the user may never answer. Weather for an
                // approximate location beats no weather at all, and the UI labels it.
                later(function () { finish(null); }, LOCATION_BUDGET_MS);
                GeoLocationService.getCurrentPosition()
                    .then(function (c) {
                        finish(c && typeof c.lat === 'number' && typeof c.lon === 'number' ? c : null);
                    })
                    .catch(function () {
                        if (!cancelled) setDenied(true);
                        // Not an error: we fall back to a default city and label the
                        // reading approximate. Tracked because it explains a chunk of
                        // "approximate" readings that would otherwise look like a fault
                        // in the location service.
                        // Once per mount, not once per attempt. The effect re-runs on
                        // the automatic retry, so an unguarded call reported two denials
                        // for one refusal and would have doubled the rate.
                        if (typeof AnalyticsService !== 'undefined' && !deniedReportedRef.current) {
                            deniedReportedRef.current = true;
                            AnalyticsService.weatherLocationDenied({ surface: 'panel' });
                        }
                        finish(null);
                    });
            });
        };

        resolveCoords()
            .then(function (coords) {
                if (cancelled) return null;

                // Reverse geocode alongside, never blocking the weather itself.
                if (coords && window.GeoLocationService && typeof GeoLocationService.reverseGeocode === 'function') {
                    GeoLocationService.reverseGeocode(coords.lat, coords.lon)
                        .then(function (geo) {
                            if (!cancelled && geo && geo.displayName) setPlace(geo.displayName);
                        })
                        .catch(function () { /* a nameless location is fine */ });
                }

                // Coordinates are REQUIRED by /api/weather — calling it bare returns a 422
                // validation error, not a sensible default, so a user who declines the
                // location prompt would see the error state rather than any weather. Fall
                // back to the same city the weather page uses, and the UI labels it
                // approximate rather than passing it off as where they are.
                var at = coords || WX_FALLBACK_COORDS;
                lastCoords = at;
                var url = '/api/weather?lat=' + encodeURIComponent(at.lat.toFixed(4)) +
                          '&lon=' + encodeURIComponent(at.lon.toFixed(4));

                later(function () { if (controller) controller.abort(); }, REQUEST_TIMEOUT_MS);
                return fetch(url, {
                    headers: { 'Accept': 'application/json' },
                    signal: controller ? controller.signal : undefined
                });
            })
            .then(function (res) {
                if (cancelled || !res) return null;
                if (!res.ok) throw new Error('Weather service returned ' + res.status);
                return res.json();
            })
            .then(function (raw) {
                if (cancelled || raw === null) return;
                var mapped;
                try {
                    mapped = wxPanelMap(raw);
                } catch (e) {
                    // A surprising shape must never blank the dashboard.
                    mapped = { ok: false };
                }
                if (mapped.ok) {
                    _wxCache = { at: Date.now(), data: mapped, place: mapped.place || '' };
                    networkLanded = true;
                    setData(mapped);
                    if (mapped.place) setPlace(mapped.place);
                    setStatus('ready');
                    // Persist the verbatim raw payload, not the mapped view — WeatherPage
                    // runs its own independent mapper over the same cached raw response.
                    if (typeof WeatherCache !== 'undefined' && WeatherCache.save && lastCoords) {
                        WeatherCache.save(lastCoords.lat, lastCoords.lon, mapped.place || null, raw);
                    }
                    if (typeof AnalyticsService !== 'undefined') {
                        var risk = null;
                        try { risk = assessDamp(mapped); } catch (e2) { risk = null; }
                        AnalyticsService.weatherLoaded({
                            surface:      'panel',
                            approximate:  !!denied,
                            stale:        !!mapped.stale,
                            humidity_pct: (mapped.humidity !== null && mapped.humidity !== undefined) ? mapped.humidity : -1,
                            damp_risk:    (risk && risk.level) ? risk.level : 'unknown',
                        });
                    }
                } else {
                    // Same reasoning as the catch below: an unusable refresh should not
                    // erase a cached reading that is already on screen.
                    if (!cachePainted) setStatus('empty');
                    if (typeof AnalyticsService !== 'undefined') {
                        AnalyticsService.weatherUnavailable({ surface: 'panel', reason: 'no_data' });
                    }
                }
            })
            .catch(function (err) {
                if (cancelled) return;
                // EXACTLY ONE automatic retry, gated on the attempt counter rather than a
                // local flag: the retry re-runs this effect, so anything declared inside it
                // resets and "retry once" quietly becomes an unbounded loop against a paid
                // endpoint. attempt survives across runs by definition. A manual "Try again"
                // pushes it past 0, so the user's own retries never chain either.
                var aborted = err && err.name === 'AbortError';
                if (!aborted && attempt === 0) {
                    later(function () { if (!cancelled) setAttempt(function (a) { return a + 1; }); }, 1500);
                    return;
                }
                // A cached reading is already on screen — a background refresh failure is
                // not news to the user, and regressing a visible reading to the empty state
                // would be a worse outcome than just leaving it slightly stale.
                if (!cachePainted) setStatus('empty');
                if (typeof AnalyticsService !== 'undefined') {
                    AnalyticsService.weatherUnavailable({
                        surface: 'panel',
                        reason: aborted ? 'timeout' : 'request_failed',
                    });
                }
            });

        return function () {
            cancelled = true;
            clearTimers();
            if (controller) controller.abort();
        };
    }, [attempt]);

    var retry = function (e) {
        if (e) { e.stopPropagation(); e.preventDefault(); }
        setDenied(false);
        setAttempt(attempt + 1);
    };

    var openDetail = function () {
        if (typeof AnalyticsService !== 'undefined') {
            var risk = null;
            try { risk = data ? assessDamp(data) : null; } catch (e) { risk = null; }
            AnalyticsService.weatherDetailOpened({ damp_risk: (risk && risk.level) ? risk.level : 'unknown' });
        }
        navigate(route);
    };
    var onKeyDown = function (e) {
        if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
            e.preventDefault();
            openDetail();
        }
    };

    // Reserved height for the card, applied to EVERY state — skeleton, error,
    // empty and loaded alike.
    //
    // It used to sit only on the skeleton, at a height measured when that was
    // written. Replacing the humidity readout with the trend sparkline then grew
    // the loaded card to 519px while the reservation stayed at 504px, so the
    // swap still dropped everything below it by 15px. Measured cost: 0.185 CLS,
    // the single largest layout shift on the dashboard.
    //
    // Putting it on the shell makes the number a FLOOR for both states rather
    // than a guess about one of them, so a short state can no longer be shorter
    // than a tall one. If the card's content grows past this, re-measure the
    // loaded height and raise it — the value is only ever a lower bound.
    var CARD_MIN_H = 'min-h-[519px]';

    var SHELL = 'col-span-2 bio-bg bio-bg-50 p-6 rounded-xl shadow-soft border border-stone-100/50 btn-nature ' +
                'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ' +
                'focus-visible:ring-offset-background-light';

    var styleTag = <style dangerouslySetInnerHTML={{ __html: WX_STYLES }} />;

    // ── Loading skeleton ─────────────────────────────────────────────────────
    if (status === 'loading') {
        // Reserves the LOADED height so the swap does not move everything below
        // the card. Error and empty stay unpadded on purpose — they are
        // legitimately short, and stretching an error message to a full card
        // reads as broken.
        return (
            <div className={SHELL + ' ' + CARD_MIN_H} aria-busy="true" aria-live="polite">
                {styleTag}
                <span className="sr-only">Loading local weather</span>
                <div className="animate-pulse motion-reduce:animate-none">
                    <div className="h-3 w-28 rounded-full bg-sage/30 mb-4"></div>
                    <div className="flex items-center gap-3">
                        <div className="w-16 h-16 rounded-2xl bg-sage/25 shrink-0"></div>
                        <div className="flex-1 min-w-0">
                            <div className="h-9 w-24 rounded-lg bg-sage/30 mb-2"></div>
                            <div className="h-3 w-20 rounded-full bg-sage/20"></div>
                        </div>
                        <div className="w-[92px] h-16 rounded-2xl bg-sage/20 shrink-0"></div>
                    </div>
                    <div className="h-12 w-full rounded-2xl bg-sage/15 mt-4"></div>
                    <div className="h-10 w-full rounded-2xl bg-sage/10 mt-3"></div>
                </div>
            </div>
        );
    }

    // ── Empty / unavailable ──────────────────────────────────────────────────
    // Not framed as an error: most of the time this is a declined permission or
    // a service that hasn't answered yet, and neither deserves a red box.
    if (status === 'empty' || !data) {
        return (
            <div className={SHELL} aria-live="polite">
                {styleTag}
                <div className="flex items-center gap-4">
                    <div className="w-16 h-16 rounded-2xl bg-background-light flex items-center justify-center shrink-0">
                        <span className="material-symbols-outlined text-sage text-3xl">cloud_off</span>
                    </div>
                    <div className="min-w-0 flex-1">
                        <h4 className="font-extrabold text-sm text-forest">Local Weather</h4>
                        <p className="text-[11px] text-forest/70 leading-snug mt-0.5">
                            {denied
                                ? 'Weather needs your location to know which forecast to show. You can turn it on any time.'
                                : 'Conditions aren’t available right now. Humidity data will appear here once the service responds.'}
                        </p>
                    </div>
                </div>
                <button
                    type="button"
                    onClick={retry}
                    className="mt-4 w-full bg-primary text-white font-extrabold py-2.5 rounded-xl shadow-clay flex items-center justify-center gap-2 btn-nature hover:brightness-110 transition-all text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-forest"
                >
                    <span className="material-symbols-outlined text-base">{denied ? 'my_location' : 'refresh'}</span>
                    {denied ? 'Use my location' : 'Try again'}
                </button>
            </div>
        );
    }

    // ── Ready ────────────────────────────────────────────────────────────────
    var read = wxMouldRead(data);
    var conditionText = data.conditionText || WX_LABELS[data.conditionKey] || '';
    // A card flagged stale but carrying no timestamp still says so, vaguely and
    // honestly, rather than showing nothing and reading as current.
    var ageText = wxFormatAge(data.ageHours);
    if (!ageText && data.stale) ageText = 'earlier';

    // Secondary stats — only what actually arrived.
    var stats = [];
    if (data.feels !== null) {
        stats.push({ key: 'feels', label: 'Feels like', value: wxTemp(data.feels) + data.unit, icon: 'thermostat' });
    }
    if (data.precipChance !== null) {
        stats.push({ key: 'rain', label: 'Rain chance', value: Math.round(data.precipChance) + '%', icon: 'rainy' });
    }
    if (read && data.days.length > 0) {
        stats.push({
            key: 'damp',
            label: 'Damp days',
            value: read.dampDays + ' of ' + data.days.length,
            icon: 'water_drop'
        });
    }
    if (!stats.length && conditionText) {
        stats.push({ key: 'cond', label: 'Conditions', value: conditionText, icon: 'partly_cloudy_day' });
    }
    var statCols = { 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3' }[stats.length] || 'grid-cols-3';
    var dayCols = { 1: 'grid-cols-1', 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4', 5: 'grid-cols-5' }[data.days.length] || 'grid-cols-5';

    var rh = data.humidity;
    var humidityPct = rh === null ? 0 : Math.max(0, Math.min(100, rh));
    // Trailing window only: the card has room for a shape, not a history lesson, and the
    // recent run is what the damp read is actually based on.
    var series = (data.series || []).slice(-12);

    // One sentence a screen reader can act on, instead of a pile of numbers.
    var summaryLabel = 'Local weather' + (place ? ' for ' + place : '') + ': ' +
        (data.temp !== null ? Math.round(data.temp) + ' degrees, ' : '') +
        (conditionText ? conditionText + ', ' : '') +
        (rh !== null ? rh + ' percent humidity. ' : '') +
        (read ? read.headline + '. ' : '') +
        (data.stale && ageText ? 'Updated ' + ageText + '. ' : '') +
        'Open the weather detail.';

    return (
        <div
            role="button"
            tabIndex={0}
            onClick={openDetail}
            onKeyDown={onKeyDown}
            aria-label={summaryLabel}
            className={SHELL + ' ' + CARD_MIN_H + ' cursor-pointer group hover:bg-forest transition-all duration-300'}
        >
            {styleTag}

            {/* Header */}
            <div className="flex items-start justify-between gap-3 mb-4">
                <div className="min-w-0">
                    <div className="flex items-center gap-1.5">
                        <span className="material-symbols-outlined text-primary text-base" aria-hidden="true">humidity_percentage</span>
                        <h4 className="font-extrabold text-sm text-forest group-hover:text-white">Local Weather</h4>
                    </div>
                    <p className="text-[10px] font-bold text-muted uppercase tracking-wider group-hover:text-accent/60 truncate">
                        {place || 'Conditions near you'}
                    </p>
                </div>
                <span className="material-symbols-outlined text-muted group-hover:text-primary text-base shrink-0 transition-colors" aria-hidden="true">
                    arrow_forward
                </span>
            </div>

            {/* Hero: glyph · temperature · humidity */}
            <div className="flex items-center gap-3">
                <div className="w-16 h-16 rounded-2xl bg-background-light group-hover:bg-white/10 flex items-center justify-center shrink-0 transition-colors">
                    <WeatherIcon kind={data.conditionKey} label={conditionText || WX_LABELS[data.conditionKey]} size={48} className="w-12 h-12" />
                </div>

                <div className="min-w-0 flex-1">
                    <div className="flex items-baseline gap-0.5">
                        <span className="text-4xl sm:text-5xl font-black tracking-tighter text-forest group-hover:text-white leading-none">
                            {wxTemp(data.temp)}
                        </span>
                        <span className="text-lg font-black text-muted group-hover:text-accent/70 leading-none">{data.unit}</span>
                    </div>
                    {conditionText ? (
                        <p className="text-[11px] font-bold text-forest/70 group-hover:text-accent/80 mt-1.5 truncate">{conditionText}</p>
                    ) : null}
                </div>

                {/* Humidity gets its own tile — it is the mould-relevant number. The bar
                    it used to carry showed only the CURRENT reading against a 60% tick,
                    which cannot answer the question that actually matters: is the damp
                    building or clearing? The sparkline does, in the same space. */}
                {rh !== null ? (
                    <div className="shrink-0 w-[132px] rounded-md bg-background-light group-hover:bg-white/10 border border-stone-200/60 group-hover:border-white/10 px-3 py-2 transition-colors">
                        <div className="flex items-baseline justify-between gap-1">
                            <p className="text-[9px] font-black uppercase tracking-widest text-muted group-hover:text-accent/60">Humidity</p>
                            <p className="text-[8px] font-black uppercase tracking-wider text-terracotta/80 group-hover:text-accent/60">70% damp</p>
                        </div>
                        <p className="text-2xl font-black text-forest group-hover:text-white leading-tight">
                            {Math.round(rh)}<span className="text-sm align-top">%</span>
                        </p>
                        {series.length >= 2 ? (
                            <div className="mt-1 overflow-hidden rounded-md">
                                <WxHumiditySpark points={series} />
                            </div>
                        ) : (
                            /* No trend yet — fall back to the single-reading bar rather than
                               leaving a hole where a chart should be. */
                            <div className="relative h-2 w-full rounded-full bg-sage/25 overflow-hidden mt-1.5">
                                <div
                                    className="absolute inset-y-0 left-0 rounded-full transition-all duration-500"
                                    style={{ width: humidityPct + '%', backgroundColor: wxHumidityFill(rh) }}
                                ></div>
                                <span className="absolute inset-y-0 w-px bg-forest/40" style={{ left: '60%' }} aria-hidden="true"></span>
                            </div>
                        )}
                    </div>
                ) : null}
            </div>

            {/* Mould watch */}
            {read ? (
                <div className={'mt-4 flex items-start gap-3 rounded-2xl border px-4 py-3 group-hover:bg-white/10 group-hover:border-white/10 transition-colors ' + (WX_BAND_STYLES[read.band] || WX_BAND_STYLES.watch)}>
                    <span
                        className={'material-symbols-outlined text-lg shrink-0 group-hover:text-primary ' + (WX_BAND_TEXT[read.band] || 'text-forest')}
                        aria-hidden="true"
                    >
                        {read.icon}
                    </span>
                    <div className="min-w-0">
                        <p className={'text-[10px] font-black uppercase tracking-widest group-hover:text-primary ' + (WX_BAND_TEXT[read.band] || 'text-forest')}>
                            Mould watch &mdash; {read.headline}
                        </p>
                        <p className="text-[11px] font-medium text-forest/80 group-hover:text-white/85 leading-snug mt-0.5">
                            {read.body}
                        </p>
                    </div>
                </div>
            ) : null}

            {/* Secondary stats */}
            {stats.length ? (
                <div className={'mt-3 grid gap-2 ' + statCols}>
                    {stats.map(function (s) {
                        // The icon rides with the value, not the label: at 320px the
                        // label row has ~45px of text width, and an inline icon
                        // truncated "Feels like" to "FEELS LI…".
                        return (
                            <div key={s.key} className="rounded-xl bg-background-light/70 group-hover:bg-white/5 border border-stone-200/50 group-hover:border-white/10 px-2 py-2 min-w-0 text-center transition-colors">
                                <p className="text-[9px] font-black uppercase tracking-wide text-muted group-hover:text-accent/60 truncate">{s.label}</p>
                                <p className="text-sm font-extrabold text-forest group-hover:text-white truncate mt-0.5 flex items-center justify-center gap-1">
                                    <span className="material-symbols-outlined text-primary text-[13px] leading-none shrink-0" aria-hidden="true">{s.icon}</span>
                                    {s.value}
                                </p>
                            </div>
                        );
                    })}
                </div>
            ) : null}

            {/* Forecast */}
            {data.days.length ? (
                <div className={'mt-3 grid gap-1 ' + dayCols}>
                    {data.days.map(function (d, i) {
                        var damp = (d.humidity !== null && d.humidity >= 70) || (d.precip !== null && d.precip >= 60);
                        return (
                            <div key={'d' + i} className="flex flex-col items-center gap-0.5 rounded-xl py-2 px-1 min-w-0">
                                <p className="text-[9px] font-black uppercase tracking-wider text-muted group-hover:text-accent/60 truncate w-full text-center">
                                    {d.label || '—'}
                                </p>
                                <WeatherIcon kind={d.key} label={d.text || WX_LABELS[d.key]} size={28} className="w-7 h-7" />
                                <p className="text-[11px] font-extrabold text-forest group-hover:text-white leading-none">
                                    {wxTemp(d.high)}<span className="text-muted group-hover:text-accent/60 font-bold">/{wxTemp(d.low)}</span>
                                </p>
                                {/* A quiet damp marker rather than a second number to read */}
                                <span
                                    className={'w-1.5 h-1.5 rounded-full ' + (damp ? 'bg-warning' : 'bg-transparent')}
                                    aria-hidden="true"
                                ></span>
                            </div>
                        );
                    })}
                </div>
            ) : null}

            {/* Footer: freshness + the honest caveat */}
            <div className="mt-3 pt-3 border-t border-stone-200/60 group-hover:border-white/10 flex items-start justify-between gap-3">
                {/* Wraps rather than truncates — the caveat is the honest part */}
                <p className="text-[10px] text-muted group-hover:text-accent/60 leading-snug min-w-0">
                    Outdoor air is a guide &mdash; indoor damp is what matters.
                </p>
                {ageText ? (
                    <p
                        className={'text-[10px] font-bold shrink-0 flex items-center gap-1 ' +
                            (data.stale ? 'text-warning group-hover:text-warning' : 'text-muted group-hover:text-accent/60')}
                    >
                        <span className="material-symbols-outlined text-[12px] leading-none" aria-hidden="true">
                            {data.stale ? 'history' : 'schedule'}
                        </span>
                        Updated {ageText}
                    </p>
                ) : null}
            </div>
        </div>
    );
};

window.WeatherPanel = WeatherPanel;
